--- tags: 視窗程式設計 --- # 實作:記事本(復原上一步) 接下來我們實作基本的復原上一步功能,讓記事本程式可以記錄文字編輯過程,並且可以回到前十次的文字修改紀錄。 我們會使用堆疊的資料結構,讓同學初步理解堆疊的概念。並且使用清單控制項,讓記錄能夠顯示在這個清單之中,讓同學清楚的看到如何用堆疊來實現記錄文字編輯過程。 # 第一部分:介面設定 這次我們為了要讓同學可以清楚看到文字編輯過程,使用一個新的控制項「**清單方塊(ListBox)**」,這個控制項可以顯示一連串的資料,我們要讓文字輸入的歷程可以顯示在清單方塊之中。 放一個清單方塊的方式很簡單,從工具箱裡面就可以找到,直接拖入或者拉出一個你要的大小就可以了。 ![](https://imgur.com/PhVgmsQ.png) 你可以將清單方塊像這樣置放到視窗畫面之上。 ![](https://imgur.com/6DuThiO.png) 另外我們需要再增加一個「復原上一步」的按鍵,並且你可以參考以下名稱作為清單方塊與按鍵的名稱。 :::info 1. 清單方塊:listUndo 2. 復原上一步按鍵:btnUndo ::: :::success 其實清單方塊的設計,只是要讓同學看清楚文字編輯過程,但其實這個清單是不必要的設計,你之後熟悉了這樣的程式設計後,可以想想如何移除清單方塊。 ::: # 第二部分:程式碼撰寫 完成基本介面設計後,我們就要寫相對的程式碼來實現我們想要的效果,但是如何實現文字編輯過程?這裡我們要跟同學介紹一種在電腦儲存資料的方式(也就是「資料結構」),叫做「堆疊」。 ## 堆疊(Stack) 甚麼是堆疊(Stack)呢?它是一種電腦儲存資料的抽象概念,特色是後進先出(LIFO, Last In First Out)的原則。也就是說,最後放入堆疊的元素會最先被取出。堆疊的基本操作有兩種: 1. 堆疊新資料(Push):將一個新的資料添加到堆疊的頂部。 2. 移除最上層資料(Pop):移除位於堆疊最上面的資料。 你可以參考下圖了解堆疊的概念。我們在一個堆疊裡,一個一個將「1」、「2」、「3」、「4」等數字堆疊起來(Push),最後再移除「4」這個數字(Pop)。 ![](https://imgur.com/k6LDiii.png) :::success 你可以發現,無論是新增資料或者移除資料,在堆疊這樣的資料結構,都是在「堆疊最上面的資料」進行 ::: 所以你可以想想看,如何實現文字編輯過程,你只要將每次輸入文字的內容,都一個一個堆到一個堆疊裡面(Push),就可以記錄文字被編輯的歷程。例如假如我們要輸入「John」這個文字,歷程就會是這樣: ![](https://imgur.com/fpF39Hd.png) 如果,要回復上一次輸入的內容,就要把最上層的歷程移除(Pop),再把移除最上層資料後的最頂端資料取出。 ## C# 中的堆疊 要實現堆疊這樣的概念,在 C# 中其實不會很困難,因為 C# 就有提供類似的資料型態來實作。以上述的在一個堆疊裡,一個一個將「1」、「2」、「3」、「4」等數字堆疊起來(Push)的歷程,就可以用以下的程式來實現。 請特別注意的是,如果用迴圈來一個一個將堆疊內的資料讀取,也是從最上面的資料開始。 ```csharp= // 建立堆疊變數,假如你的堆疊裡面要放字串,要將以下的<int>換成<string> Stack<int> stack = new Stack<int>(); // 堆疊元素 stack.Push(1); stack.Push(2); stack.Push(3); stack.Push(4); // 檢視頂部元素 stack.Peek(); // 輸出 4 // 移除4 stack.Pop(); // 用迴圈一一將堆疊內的內容顯示 Console.WriteLine("--Show stack--"); foreach (int item in stack) { Console.WriteLine(item); // 依次輸出 3、2、1 } ``` 假如我們要輸入「John」這個文字,然後回復上一次輸入的內容,歷程就會是這樣: ```csharp= Stack<string> textHistory = new Stack<string>(); // 堆疊元素 textHistory.Push("J"); textHistory.Push("Jo"); textHistory.Push("Joh"); textHistory.Push("John"); // 檢視頂部元素 textHistory.Peek(); // 輸出 John // 移除 John textHistory.Pop(); // 用迴圈一一將堆疊內的內容顯示 Console.WriteLine("--Show textHistory--"); foreach (string item in textHistory) { Console.WriteLine(item); // 依次輸出 3、2、1 } ``` ## 實作將目前的文字加入堆疊 首先我們也要先設定事件綁定,我們選擇的事件是在 RichTextBox 中的「TextChanged」,意思就是「當 RichTextBox 內的文字改變時」,同樣的再到視窗程式中豐富文字輸入框(rtbText)的「屬性視窗」設定事件綁定,請找到「TextChanged」事件,在旁邊的欄位連點兩下,建立事件綁定。 ![](https://imgur.com/uSHJvtU.png) 接著輸入以下程式碼: ```csharp= // 全域變數 private Stack<string> textHistory = new Stack<string>(); private const int MaxHistoryCount = 10; // 最多紀錄10個紀錄 private void rtbText_TextChanged(object sender, EventArgs e) { // 將當前的文本內容加入堆疊 textHistory.Push(rtbText.Text); // 確保堆疊中只保留最多10個紀錄 if (textHistory.Count > MaxHistoryCount) { // 用一個臨時堆疊,將除了最下面一筆的文字記錄之外,將文字紀錄堆疊由上而下,逐一移除再堆疊到臨時堆疊之中 Stack<string> tempStack = new Stack<string>(); for (int i = 0; i < MaxHistoryCount; i++) { tempStack.Push(textHistory.Pop()); } textHistory.Clear(); // 清空堆疊 // 文字編輯堆疊紀錄清空之後,再將暫存堆疊(tempStack)中的資料,逐一放回到文字編輯堆疊紀錄 foreach (string item in tempStack) { textHistory.Push(item); } } UpdateListBox(); // 更新 ListBox } // 更新 ListBox void UpdateListBox() { listUndo.Items.Clear(); // 清空 ListBox 中的元素 // 將堆疊中的內容逐一添加到 ListBox 中 foreach (string item in textHistory) { listUndo.Items.Add(item); } } ``` 輸入程式後,你可以試著輸入文字,右側的清單方塊就會記錄你輸入文字的歷程。 ![](https://imgur.com/Csx72Ef.png) ### 程式說明 在第 5 行開始,每次在 RTB 中輸入文字,就會將輸入的文字一一放到堆疊之中。不過由於我們有限制只能記錄 10 筆,因此程式的第 10 到 24 行實現如何限制在 10 筆的紀錄。 如何完成呢?以下是程式邏輯的設計: 1. 第 8 行,如果記錄到第 11 筆的文字編輯紀錄。 ![](https://imgur.com/Bb4hDoI.png =400x) 2. 第 14 到 18 行:當文字編輯堆疊紀錄(textHistory)超過 10 筆(也就是堆疊已經堆到 11 筆紀錄),就將第 2 到第 11 筆的紀錄由上而下,移除後再堆疊到另一個暫存的堆疊(tempStack)。 ![](https://imgur.com/JwnWWQY.png =400x) 3. 第 19 行:由於文字編輯堆疊紀錄(textHistory)有 11 筆的紀錄,我們僅取第 2 到第 11 筆,所以文字編輯堆疊紀錄還有 1 筆資料,所以我們要再將它清空。 ![](https://imgur.com/TFdRe2q.png =150x) 4. 第 20 到 23 行:文字編輯堆疊紀錄清空之後,再將暫存堆疊(tempStack)中的資料,逐一放回到文字編輯堆疊紀錄。這樣就可以將原本最早的紀錄給移除,只記錄最新的 10 筆紀錄。 ![](https://imgur.com/9gACdXu.png =400x) :::warning 你可能會問?為什麼這麼麻煩?這是因為堆疊的特性就是「只能存取最上面一筆資料」,使用這樣的方式可以避免存取其他的資料,造成意外的狀況。 ::: 從第 30 行到 39 行,則是一個方法,用來更新清單方塊,概念倒是很簡單,先將清單方塊中的內容清空,再用迴圈把文字編輯紀錄堆疊的資料,逐一放到清單方塊中。 ## 實作回復上一步功能 我們完成了文字編輯紀錄的機制,接下來,我們則是來完成讓使用者可以按下「回復上一步」的按鍵,讓使用者可以回到上一次輸入的內容。 這時你可以將「回復上一步」的按鍵 Click 事件綁定,添加以下程式碼 ```csharp= private void btnUndo_Click(object sender, EventArgs e) { if (textHistory.Count > 1) { textHistory.Pop(); // 移除當前的文本內容 rtbText.Text = textHistory.Peek(); // 將堆疊頂部的文本內容設置為當前的文本內容 } UpdateListBox(); // 更新 ListBox } ``` ### 程式說明 程式概念很簡單,首先每次都先檢查文字紀錄堆疊是不是至少有一筆紀錄,是的話,就將文字紀錄堆疊最上面一筆移除,再將 RTB 文字框中的內容更新為上一筆文字紀錄。 可是你一定會發現,如果每次按下「回復上一步」的按鍵,再按一次,就只能回復一次,無法回到更早的紀錄。就像以下的現象。 ![](https://imgur.com/hthi5UQ.png) 這是因為,當我們更新了 RTB 文字框中的內容,實際上也會觸發 RTB 文字框的「TextChanged」事件,等於再將回復上一步的文字編輯紀錄,又再記錄到堆疊裡面,也就會有重複紀錄的問題。 所以我們將原來的文字紀錄編輯堆疊修正一下: ```csharp= private void rtbText_TextChanged(object sender, EventArgs e) { // 只有當isUndo這個變數是false的時候,才能堆疊文字編輯紀錄 if (isUndo == false) { // 將當前的文本內容加入堆疊 textHistory.Push(rtbText.Text); // 確保堆疊中只保留最多10個紀錄 if (textHistory.Count > MaxHistoryCount) { // 用一個臨時堆疊,將除了最下面一筆的文字記錄之外,將文字紀錄堆疊由上而下,逐一移除再堆疊到臨時堆疊之中 Stack<string> tempStack = new Stack<string>(); for (int i = 0; i < MaxHistoryCount; i++) { tempStack.Push(textHistory.Pop()); } textHistory.Clear(); // 清空堆疊 // 文字編輯堆疊紀錄清空之後,再將暫存堆疊(tempStack)中的資料,逐一放回到文字編輯堆疊紀錄 foreach (string item in tempStack) { textHistory.Push(item); } } UpdateListBox(); // 更新 ListBox } } ``` 你可以注意到,我們就只是在既有的程式邏輯上,多加一道判斷,設定只有當isUndo這個變數是false的時候,才能堆疊文字編輯紀錄。 接下來再修正「回復上一步」的按鍵 click 事件綁定的程式。 ```csharp= private void btnUndo_Click(object sender, EventArgs e) { isUndo = true; if (textHistory.Count > 1) { textHistory.Pop(); // 移除當前的文本內容 rtbText.Text = textHistory.Peek(); // 將堆疊頂部的文本內容設置為當前的文本內容 } UpdateListBox(); // 更新 ListBox isUndo = false; } ``` 你可以看到,isUndo 一開始被設定為 true,直到回復上一步後才變回 false,這樣就可以避免觸發 RTB 文字框的「TextChanged」事件後,又重新將回復後的紀錄堆疊回去。 當然不要忘記,因為 isUndo 變數需要在兩個程式區塊運作,所以 isUndo 變數需要設定為全域變數,並且我們要預設它為 false。 ```csharp= private bool isUndo = false; ``` 這時,你再測試程式,應該就可以看到正常的結果了。 # 完整程式碼 :::spoiler ```csharp= using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Windows.Forms; using System.IO; namespace NotePad { public partial class Form1 : Form { public Form1() { InitializeComponent(); } private bool isUndo = false; private Stack<string> textHistory = new Stack<string>(); private const int MaxHistoryCount = 10; // 最多紀錄10個紀錄 private void btnOpen_Click(object sender, EventArgs e) { // 設置對話方塊標題 openFileDialog1.Title = "選擇檔案"; // 設置對話方塊篩選器,限制使用者只能選擇特定類型的檔案 openFileDialog1.Filter = "文字檔案 (*.txt)|*.txt|所有檔案 (*.*)|*.*"; // 如果希望預設開啟的檔案類型是文字檔案,可以這樣設置 openFileDialog1.FilterIndex = 1; // 如果希望對話方塊在開啟時顯示的初始目錄,可以設置 InitialDirectory openFileDialog1.InitialDirectory = "C:\\"; // 允許使用者選擇多個檔案 openFileDialog1.Multiselect = true; // 顯示對話方塊,並等待使用者選擇檔案 DialogResult result = openFileDialog1.ShowDialog(); // 檢查使用者是否選擇了檔案 if (result == DialogResult.OK) { try { // 使用者在OpenFileDialog選擇的檔案 string selectedFileName = openFileDialog1.FileName; //// 使用 FileStream 打開檔案 //// 建立一個檔案資料流,並且設定檔案名稱與檔案開啟模式為「開啟檔案」 //FileStream fileStream = new FileStream(selectedFileName, FileMode.Open, FileAccess.Read); //// 讀取資料流 //StreamReader streamReader = new StreamReader(fileStream); //// 將檔案內容顯示到 RichTextBox 中 //rtbText.Text = streamReader.ReadToEnd(); //// 關閉資料流與讀取資料流 //fileStream.Close(); //streamReader.Close(); // 使用 using 與 FileStream 打開檔案 using (FileStream fileStream = new FileStream(selectedFileName, FileMode.Open, FileAccess.Read)) { // 使用 StreamReader 讀取檔案內容 using (StreamReader streamReader = new StreamReader(fileStream, Encoding.UTF8)) { // 將檔案內容顯示到 RichTextBox 中 rtbText.Text = streamReader.ReadToEnd(); } } //// 更為簡單的做法,將檔案內容顯示到 RichTextBox 中 //string fileContent = File.ReadAllText(selectedFileName); //rtbText.Text = fileContent; } catch (Exception ex) { // 如果發生錯誤,用MessageBox顯示錯誤訊息 MessageBox.Show("讀取檔案時發生錯誤: " + ex.Message, "錯誤訊息", MessageBoxButtons.OK, MessageBoxIcon.Error); } } else { MessageBox.Show("使用者取消了選擇檔案操作。", "訊息", MessageBoxButtons.OKCancel, MessageBoxIcon.Exclamation); } } private void btnSave_Click(object sender, EventArgs e) { // 設置對話方塊標題 saveFileDialog1.Title = "儲存檔案"; // 設置對話方塊篩選器,限制使用者只能選擇特定類型的檔案 saveFileDialog1.Filter = "文字檔案 (*.txt)|*.txt|所有檔案 (*.*)|*.*"; // 如果希望預設儲存的檔案類型是文字檔案,可以這樣設置 saveFileDialog1.FilterIndex = 1; // 如果希望對話方塊在開啟時顯示的初始目錄,可以設置 InitialDirectory saveFileDialog1.InitialDirectory = "C:\\"; // 顯示對話方塊,並等待使用者指定儲存的檔案 DialogResult result = saveFileDialog1.ShowDialog(); //建立 FileStream 物件 FileStream fileStream = null; // 檢查使用者是否選擇了檔案 if (result == DialogResult.OK) { try { // 使用者指定的儲存檔案的路徑 string saveFileName = saveFileDialog1.FileName; // 使用 FileStream 建立檔案,如果檔案已存在則覆寫 fileStream = new FileStream(saveFileName, FileMode.Create, FileAccess.Write); // 將 RichTextBox 中的文字寫入檔案中 byte[] data = Encoding.UTF8.GetBytes(rtbText.Text); fileStream.Write(data, 0, data.Length); //// 使用 using 與 FileStream 建立檔案,如果檔案已存在則覆寫 //using (fileStream = new FileStream(saveFileName, FileMode.Create, FileAccess.Write)) //{ // // 將 RichTextBox 中的文字寫入檔案中 // byte[] data = Encoding.UTF8.GetBytes(rtbText.Text); // fileStream.Write(data, 0, data.Length); //} //// 將 RichTextBox 中的文字儲存到檔案中 //File.WriteAllText(saveFileName, rtbText.Text); MessageBox.Show("檔案儲存成功。", "訊息", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { // 如果發生錯誤,用 MessageBox 顯示錯誤訊息 MessageBox.Show("儲存檔案時發生錯誤: " + ex.Message, "錯誤訊息", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { // 關閉資源,如果使用 using 或者直接以 File.WriteAllText 儲存文字檔,可以不需要。 fileStream.Close(); } } else { MessageBox.Show("使用者取消了儲存檔案操作。", "訊息", MessageBoxButtons.OKCancel, MessageBoxIcon.Exclamation); } } private void btnUndo_Click(object sender, EventArgs e) { isUndo = true; if (textHistory.Count > 1) { textHistory.Pop(); // 移除當前的文本內容 rtbText.Text = textHistory.Peek(); // 將堆疊頂部的文本內容設置為當前的文本內容 } UpdateListBox(); // 更新 ListBox isUndo = false; } private void rtbText_TextChanged(object sender, EventArgs e) { // 只有當isUndo這個變數是false的時候,才能堆疊文字編輯紀錄 if (isUndo == false) { // 將當前的文本內容加入堆疊 textHistory.Push(rtbText.Text); // 確保堆疊中只保留最多10個紀錄 if (textHistory.Count > MaxHistoryCount) { // 用一個臨時堆疊,將除了最下面一筆的文字記錄之外,將文字紀錄堆疊由上而下,逐一移除再堆疊到臨時堆疊之中 Stack<string> tempStack = new Stack<string>(); for (int i = 0; i < MaxHistoryCount; i++) { tempStack.Push(textHistory.Pop()); } textHistory.Clear(); // 清空堆疊 // 文字編輯堆疊紀錄清空之後,再將暫存堆疊(tempStack)中的資料,逐一放回到文字編輯堆疊紀錄 foreach (string item in tempStack) { textHistory.Push(item); } } UpdateListBox(); // 更新 ListBox } } // 更新 ListBox void UpdateListBox() { listUndo.Items.Clear(); // 清空 ListBox 中的元素 // 將堆疊中的內容逐一添加到 ListBox 中 foreach (string item in textHistory) { listUndo.Items.Add(item); } } } } ``` ::: ## 另一種寫法,使用 List<T> 其實使用堆疊的寫法比較繁瑣,雖然可以避免讀取到不是最上一筆資料,但是程式卻不是這麼直觀。 同學這時請你試試看,利用 ChatGPT 來改寫,並且試著理解 List<T>是甚麼東西?