【C#】アンドゥ(リドゥ)したい

Arrow Road Signs - Free photo on Pixabay - Pixabay C#
Arrow Road Signs - Free photo on Pixabay - Pixabay

マップエディタの開発後記です。
しばらくはこの関連の記事が続きます。

Windowsのアプリケーションを使っている時に操作を間違えたとか元の状態が見たいといった場合、Ctrl+Zを押すと操作する前の状態に戻ったりするあの機能です。

アンドゥの意味

アンドゥ?アンドウ?安藤さん?
違います(笑)🤣

アンドゥ(Undo)とは「~する(Do)」の接頭辞〔Un~〕を付けたもので、直訳では「しない」という意味になります。
従ってアプリにおけるアンドゥとは操作をしない、要するに操作をなかったことにするという意味合いで一般的に認知されています。

しかしこれはWindowsのOSの機能ではないため、無論全てのアプリがそうなるわけではなくアプリケーション開発者の独自の機能です。

Ctrl+Zを押すと元に戻る(Ctrl+Yは元に戻る処理を取り消す)認識が一般に知れ渡っているくらい大半のメジャーなアプリにはこの機能があるため、Windowsはそんな機能があるんだ!と思っている方も少なからずいるんじゃないかなと思いますね。

実際Windowsの標準アプリでは出来るのでしょうか?

Windowsのアンドゥ機能は実際どんな感じなのか?

電卓

Windowsの昔からあるアプリです。

さっそく押してみた。

・・・あれ?
何も起きないf(^-^;
できないのか・・・マジで?😅

ペイント

引き続いてWindowsに必ず入っているらくがきアプリです。

さっそく押してみる。

上のサンプルは「へへののもへし、、」の順番で描いたので、Ctrl+Zを押すと「、」の描画が取り消されています。
Windows標準アプリのペイントはアンドゥができるようです😄

ウェブブラウザ

では最後に、
Microsoft Edgeです。

これはCtrl+Zでは元に戻れません。

ウェブブラウザにとって「元に戻す」処理は「遷移前のぺージに戻す」事なので、厳密には元に戻す処理自体がありません。
何か手を加える操作をするわけではないので。

元のぺージに戻るキーはAlt+←です。
ちなみに上記はMicrosoft Edgeの場合で、世の中にはMacintoshのSafariGoogle Chromeなどもあるので、それらはキーが違うかもしれません。

いずれにしてもメジャーなソフトウェアに関しては大体この機能があるという事です。
この機能があるのとないのとでは、アプリケーションの使い勝手やユーザーの満足度、ストレス軽減に大きく影響する事は間違いないでしょう。

そこで私も自前のアプリケーションにこの機能を追加しようと思います。(というか元々その予定があったんですが)

ここからが本題です。

スタックを使う

まず最初にどうやって実装するか考えます。


アンドゥ処理を行うには、行った操作を取り消すため、何の操作をしたか覚えておく必要があります。

その記憶はWindows OS側でも保持しているわけではないので操作を行ったらその操作を何かに保存する必要があります。

その覚えておく操作が一つだけなら、単純に何かのデータとして持っておけばそれでいいのですが、実際には過去をずっと先まで遡りたい場合もあります。

ちなみにですが、Windowsの「メモ帳」は過去の履歴を単体でしか持っていないみたいで、アンドゥ処理を繰り返しても一つの前に状態に戻ってその後は戻す処理をやり直す(元に戻す処理を取り消す、いわゆるリドゥ)処理を繰り返すだけでした。

「リドゥ」の処理もアンドゥと混同されて取り扱われますね。
これも実装するにはどうすればいいでしょうか?

問題となるのはやはり行った操作を直近から遡って実行していかないとだめな事です。

例えば、
「あいうえお」と文字を入力した場合では、

Ctrl+Z →「お」を消す
Ctrl+Z →「え」を消す
Ctrl+Z →「う」を消す・・・

の動きになってほしいはずです。

これを実現するには、最後に行った操作を最初に取得する仕組みが必要になります。

それを実現するには「スタック」と呼ばれる思想を利用する必要があります。

一般的に配列に代入したデータは0番目、1番目・・・と入り、通常このデータへ順番にアクセスすると代入した順番でアクセスする事になります。
これを最後の要素からアクセスするための仕組みがスタックになります。
スタックについての詳しい説明は以下からご覧ください!(外部サイト)

Error 403 (Forbidden)|「分かりそう」で「分からない」でも「分かった」気になれるIT用語辞典
403エラーページです。用語の意味を「ざっくりと」理解するためのIT用語辞典です。

「元に戻す」処理用のスタックと、元に戻した操作を「やり直す」処理用のスタックを用意して、とりあえず実装していく方針にしましょう。

この仕組みは大抵のプログラミング環境にも備わっている事が多いです。
例えば Rust ではvec型などです。
これらを使えば比較的簡単に後入れ先出し(LIFO、Last In, First Out)の仕組みを利用する事ができます。

今回はC#.NETのアプリケーションを作っている手前、C#のスタッククラスを使います。
それでは実際にコードを書いていきましょう!٩(′ω′)و

スタッククラスの解説

まずはスタックの使い方を確認しておきましょう。

プッシュとポップ

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        Stack<string> stack = new Stack<string>();

        while (true)
        {
            Console.WriteLine("文字列を入力してEnterキーを押してください。空行で終了します。");
            string input = Console.ReadLine();

            if (string.IsNullOrWhiteSpace(input))
            {
                // 空行が入力された場合、スタックから要素をポップ
                if (stack.Count > 0)
                {
                    string poppedItem = stack.Pop();
                    Console.WriteLine($"ポップされた文字列: {poppedItem}");
                }
                else
                {
                    Console.WriteLine("スタックは空です。");
                }
            }
            else
            {
                // 入力がある場合、スタックに文字列をプッシュ
                stack.Push(input);
                Console.WriteLine($"プッシュされた文字列: {input}");
            }
        }
    }
}

通常のC#.NETでコンソールアプリケーションプロジェクトを作成し、上記のソースコードを書きます。
実行後、コンソール上に文字列を入力してエンターキーを押すとスタックに文字列が格納(プッシュ)されます。
これで後入れ先出しのスタック領域でデータを管理できます。
何も入力せずにエンターキーを押すと、スタック上のデータを取り出し(ポップ)します。
(コンソールを終了するにはCtrl+Cを押してください)

最近取り込んだものから取り出していき、最初に格納した文字列は最後に取り出される

とりあえずこの仕組みを利用して、最後に行った処理に対して戻す処理をスタック領域に入れてやれば、後はその情報を取り出すだけでアンドゥはできそうですね。

実処理はどこに書くの?

それでは実際にスタックを使って、アプリケーションに実装していきます。
まずはアプリケーションですが、

一定間隔で並んだ画像データのパネル(右側の枠内)をクリックすると選択した画像が右上の隅にある窓に表示されるというものです。
これはゲームのマップを作成するエディタなので、例えばポチポチとマップを作っていく上で配置場所がずれたりするとどうしても元に戻したい事が出てきます。

今回はこの画像データのパネル(以下、グラフィックチップと呼ぶ)のクリックイベントで、スタックで管理していくサンプルを解説していきます。

まずは上記のクリックイベントハンドラです。
(selectedChipTexture[PictureBox] = 右上のグラフィックチップが変わっている場所、窓の中)
(sender[Button] = 右側の大枠内に一定間隔で並べてあるグラフィックチップオブジェクト)

public partial class MainForm
{
    private void ChipLists_GraphicChipClick(object? sender, EventArgs e)
    {
        Button button = (Button)sender!;
        selectedChipTexture.Image = button.BackgroundImage;
    }
}

次にこれを呼び出している場所ですが、今回の解説ではあまり関わりがありません。
クラスが複数分割されていて、それらを全部書くのも面倒だったので割愛させてください(;’∀’)゜。
(もし興味がある方がおりましたら以下のエントリーをご覧くださいませ😅)

実際にはこのイベントハンドラの中にアンドゥ、リドゥ用のコードを追加する事になります。
もちろんメソッドを分割する事も可能です(`・ω・´)
ごちゃごちゃするのがイヤだという事でしたらその方法でももちろんOKです!

ソースコードと解説

概要としては、


  • 必要なデータを保持できるようにメンバを洗い出す(極力絞り込む)
  • データを格納して管理できるように構造体、クラス化する
  • アンドゥ用とリドゥ用それぞれのスタックメンバを用意する

となります。

あと注意事項として、アンドゥ、リドゥの処理に付加する処理が今後できた場合に備えてメソッド化しておくとなお良いでしょう。

それでは今回のエディタを例にしてスタックに詰め込んでいくパラメータをクラスで定義して、アンドゥ/リドゥのスタック操作処理を書いていきます。

internal class MementoParameter
{
    public int? MapRow { get; set; } = null;
    public int? MapColumn { get; set; } = null;
    public bool Holder { get; set; } = false;
    public byte? OldImageBinNum { get; set; } = null;
    public byte? NewImageBinNum { get; set; } = null;
}


public partial class MainForm
{
    private readonly Stack<List<MementoParameter>> _undoMemento = new();
    private readonly Stack<List<MementoParameter>> _redoMemento = new();


    private void Recollection(List<MementoParameter> parameters)
    {
        _undoMemento.Push(parameters);
        _redoMemento.Clear();
    }

    private void Undo()
    {
        if (0 < _undoMemento.Count)
        {
            List<MementoParameter> changes = _undoMemento.Pop();
            foreach (var change in changes)
            {
                // The process of reverting to the SelectedChipTexture object.
                if (change.Holder)
                {
                    SetSelectedChipTexture(change.OldImageBinNum.ToString(), _mainContainer?.GetChipListImage(change.OldImageBinNum));
                }
            }
            _redoMemento.Push(changes);
        }
    }

    private void Redo()
    {
        if (0 < _redoMemento.Count)
        {
            List<MementoParameter> changes = _redoMemento.Pop();
            foreach (var change in changes)
            {
                // The process of redoing the SelectedChipTexture object.
                if (change.Holder)
                {
                    SetSelectedChipTexture(change.NewImageBinNum.ToString(), _mainContainer?.GetChipListImage(change.NewImageBinNum));
                }
            }
            _undoMemento.Push(changes);
        }
    }
}
public partial class MainForm : Form
{
    private readonly MapContainer _mainContainer = new();

    private void ChipLists_GraphicChipClick(object? sender, EventArgs e)
    {
        Button button = (Button)sender!;
        List<MementoParameter> parameters = new()
        {
            new MementoParameter
            {
                OldImageBinNum = "" != selectedChipTexture.Text ? byte.Parse(selectedChipTexture.Text) : null,
                NewImageBinNum = "" != button.Text ? byte.Parse(button.Text) : null,
                Holder = true
            }
        };
        Recollection(parameters);
        SetSelectedChipTexture(button.Text, button.BackgroundImage);
    }

    private void SetSelectedChipTexture(string? text, Image? backgroundimage)
    {
        selectedChipTexture.Text = text;
        selectedChipTexture.Image = backgroundimage;
    }
}
internal class MapContainer
{
    internal Image? GetChipListImage(byte? index)
    {
        if (index.HasValue)
        {
            return _chipLists?.GetBackgroundImage(index.Value);  // 以下メソッドは省略... 画像が取得できる
        }
        else
        {
            return null;
        }
    }
}

それでは解説ですが、MementoParameterクラスにこのエディタのアンドゥ/リドゥで必要なデータを定義しています。

マップの位置を特定する行列番号と選択グラフィックチップの窓(右上の窓)、旧画像、新画像を持っています。

画像情報はImageクラスではなく、配列のインデックス番号を持つようにして、この番号でグラフィックチップを取得するメソッドに問い合わせるようにしています。
(この部分については省略させて頂きました🙇‍♂️)
(実際にはImageクラスで直接画像を取得するメソッドで問題ありません)

ChipLists_GraphicChipClickイベントハンドラ内部で、

...
List<MementoParameter> parameters = new()
{
    new MementoParameter
    {
        OldImageBinNum = "" != selectedChipTexture.Text ? byte.Parse(selectedChipTexture.Text) : null,
        NewImageBinNum = "" != button.Text ? byte.Parse(button.Text) : null,
        Holder = true
    }
};
Recollection(parameters);
...

としていますが、ここが実際のアンドゥ、リドゥ処理本体になります。
Recollectionメソッドで、アンドゥ用スタックにMementoParameterをプッシュして、リドゥ用スタックの中身を消しています。

アンドゥを実行する時は、OldImageBinNumメンバの画像を再描画します。
リドゥはその逆なので、NewImageBinNumメンバの画像を再描画します。

アンドゥを実行したら、

やり直す処理で再度アンドゥの逆の処理(すなわち操作した処理)を行う必要があるので、リドゥ用スタックにMementoParameterをプッシュして積み上げていきます。

リドゥを実行した場合も同様です。

このようにして、アンドゥ/リドゥの処理は実装できます。

他にも手法は色々あるので、ググって最適な実装方法を模索してみてください(^ω^)
また、間違ってる箇所がございましたらコメント等で教えていただけると幸いです🤗

コメント

タイトルとURLをコピーしました