---
tags: 視窗程式設計
---
# 實作:記事本(復原上一步)
接下來我們實作基本的復原上一步功能,讓記事本程式可以記錄文字編輯過程,並且可以回到前十次的文字修改紀錄。
我們會使用堆疊的資料結構,讓同學初步理解堆疊的概念。並且使用清單控制項,讓記錄能夠顯示在這個清單之中,讓同學清楚的看到如何用堆疊來實現記錄文字編輯過程。
# 第一部分:介面設定
這次我們為了要讓同學可以清楚看到文字編輯過程,使用一個新的控制項「**清單方塊(ListBox)**」,這個控制項可以顯示一連串的資料,我們要讓文字輸入的歷程可以顯示在清單方塊之中。
放一個清單方塊的方式很簡單,從工具箱裡面就可以找到,直接拖入或者拉出一個你要的大小就可以了。

你可以將清單方塊像這樣置放到視窗畫面之上。

另外我們需要再增加一個「復原上一步」的按鍵,並且你可以參考以下名稱作為清單方塊與按鍵的名稱。
:::info
1. 清單方塊:listUndo
2. 復原上一步按鍵:btnUndo
:::
:::success
其實清單方塊的設計,只是要讓同學看清楚文字編輯過程,但其實這個清單是不必要的設計,你之後熟悉了這樣的程式設計後,可以想想如何移除清單方塊。
:::
# 第二部分:程式碼撰寫
完成基本介面設計後,我們就要寫相對的程式碼來實現我們想要的效果,但是如何實現文字編輯過程?這裡我們要跟同學介紹一種在電腦儲存資料的方式(也就是「資料結構」),叫做「堆疊」。
## 堆疊(Stack)
甚麼是堆疊(Stack)呢?它是一種電腦儲存資料的抽象概念,特色是後進先出(LIFO, Last In First Out)的原則。也就是說,最後放入堆疊的元素會最先被取出。堆疊的基本操作有兩種:
1. 堆疊新資料(Push):將一個新的資料添加到堆疊的頂部。
2. 移除最上層資料(Pop):移除位於堆疊最上面的資料。
你可以參考下圖了解堆疊的概念。我們在一個堆疊裡,一個一個將「1」、「2」、「3」、「4」等數字堆疊起來(Push),最後再移除「4」這個數字(Pop)。

:::success
你可以發現,無論是新增資料或者移除資料,在堆疊這樣的資料結構,都是在「堆疊最上面的資料」進行
:::
所以你可以想想看,如何實現文字編輯過程,你只要將每次輸入文字的內容,都一個一個堆到一個堆疊裡面(Push),就可以記錄文字被編輯的歷程。例如假如我們要輸入「John」這個文字,歷程就會是這樣:

如果,要回復上一次輸入的內容,就要把最上層的歷程移除(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」事件,在旁邊的欄位連點兩下,建立事件綁定。

接著輸入以下程式碼:
```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);
}
}
```
輸入程式後,你可以試著輸入文字,右側的清單方塊就會記錄你輸入文字的歷程。

### 程式說明
在第 5 行開始,每次在 RTB 中輸入文字,就會將輸入的文字一一放到堆疊之中。不過由於我們有限制只能記錄 10 筆,因此程式的第 10 到 24 行實現如何限制在 10 筆的紀錄。
如何完成呢?以下是程式邏輯的設計:
1. 第 8 行,如果記錄到第 11 筆的文字編輯紀錄。

2. 第 14 到 18 行:當文字編輯堆疊紀錄(textHistory)超過 10 筆(也就是堆疊已經堆到 11 筆紀錄),就將第 2 到第 11 筆的紀錄由上而下,移除後再堆疊到另一個暫存的堆疊(tempStack)。

3. 第 19 行:由於文字編輯堆疊紀錄(textHistory)有 11 筆的紀錄,我們僅取第 2 到第 11 筆,所以文字編輯堆疊紀錄還有 1 筆資料,所以我們要再將它清空。

4. 第 20 到 23 行:文字編輯堆疊紀錄清空之後,再將暫存堆疊(tempStack)中的資料,逐一放回到文字編輯堆疊紀錄。這樣就可以將原本最早的紀錄給移除,只記錄最新的 10 筆紀錄。

:::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 文字框中的內容更新為上一筆文字紀錄。
可是你一定會發現,如果每次按下「回復上一步」的按鍵,再按一次,就只能回復一次,無法回到更早的紀錄。就像以下的現象。

這是因為,當我們更新了 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>是甚麼東西?