Featured image of post 第7回-やり直しもできる! - Redo機能 - Mooで作る簡易テキストエディタ

第7回-やり直しもできる! - Redo機能 - Mooで作る簡易テキストエディタ

Historyクラスにredoメソッドを実装し、Undoした操作をやり直せるようにします。undo_stackとredo_stack間のコマンド移動を理解し、Undo/Redoの連続操作をマスターします。

@nqounetです。

シリーズ「Mooで作る簡易テキストエディタ」の第7回です。

前回の振り返り

前回は、履歴管理を専門に行うHistoryクラスを作成しました。

Historyクラスには2つのスタックがあります。

  • undo_stack — 実行したコマンドを積み上げる
  • redo_stack — Undoしたコマンドを積み上げる(Redo用)

そして、execute_commandundoメソッドを実装しました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package History {
    use Moo;

    has undo_stack => (is => 'ro', default => sub { [] });
    has redo_stack => (is => 'ro', default => sub { [] });

    sub execute_command ($self, $command) {
        $command->execute;
        push $self->undo_stack->@*, $command;
        $self->redo_stack->@* = ();  # 新規操作でredo_stackをクリア
    }

    sub undo ($self) {
        return unless $self->undo_stack->@*;

        my $command = pop $self->undo_stack->@*;
        $command->undo;
        push $self->redo_stack->@*, $command;  # Redo用に保存
    }

    # sub redo ($self) { ... }  # ← 未実装
};

undoメソッドでは、コマンドをundo_stackから取り出し、redo_stackに移動しています。これは、後でRedoできるようにするためです。

今回は、いよいよredoメソッドを実装します。

Redo機能とは

Redo機能は、Undoした操作をやり直す機能です。

たとえば、以下のシナリオを考えてみましょう。

  1. 「Hello」を入力
  2. Undo(「Hello」を取り消し)
  3. 「やっぱり『Hello』が必要だった!」

ステップ3で、Undoした操作をやり直したくなることがあります。これがRedo機能です。

多くのエディタでは、Ctrl+ZでUndo、Ctrl+YまたはCtrl+Shift+ZでRedoができます。

スタック間のコマンド移動を理解する

Undo/Redoの動作を図で理解しましょう。

	flowchart LR
    subgraph initial["初期状態"]
        direction TB
        subgraph us1["undo_stack"]
            U1["cmd1"]
            U2["cmd2"]
            U3["cmd3 ←top"]
        end
        subgraph rs1["redo_stack"]
            R0["(空)"]
        end
    end
    
    subgraph after_undo["Undo後"]
        direction TB
        subgraph us2["undo_stack"]
            UA1["cmd1"]
            UA2["cmd2 ←top"]
        end
        subgraph rs2["redo_stack"]
            RA1["cmd3 ←top"]
        end
    end
    
    subgraph after_redo["Redo後"]
        direction TB
        subgraph us3["undo_stack"]
            UB1["cmd1"]
            UB2["cmd2"]
            UB3["cmd3 ←top"]
        end
        subgraph rs3["redo_stack"]
            RB0["(空)"]
        end
    end
    
    initial -->|"undo()"| after_undo
    after_undo -->|"redo()"| after_redo
    
    style us1 fill:#e1f5fe
    style us2 fill:#e1f5fe
    style us3 fill:#e1f5fe
    style rs1 fill:#fff3e0
    style rs2 fill:#fff3e0
    style rs3 fill:#fff3e0

コマンドの移動をまとめると、以下のようになります。

操作undo_stackredo_stack
execute_commandpushクリア
undopop →→ push
redopush ←← pop

undoredoは、対称的な操作です。

  • undoundo_stackからpop → コマンドのundoを呼ぶ → redo_stackにpush
  • redoredo_stackからpop → コマンドのexecuteを呼ぶ → undo_stackにpush

redoメソッドを実装する

では、redoメソッドを実装しましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# Perl v5.36 以降
# 外部依存: Moo

package History {
    use Moo;
    use v5.36;

    has undo_stack => (
        is      => 'ro',
        default => sub { [] },
    );

    has redo_stack => (
        is      => 'ro',
        default => sub { [] },
    );

    sub execute_command ($self, $command) {
        $command->execute;
        push $self->undo_stack->@*, $command;
        $self->redo_stack->@* = ();
    }

    sub undo ($self) {
        return unless $self->undo_stack->@*;

        my $command = pop $self->undo_stack->@*;
        $command->undo;
        push $self->redo_stack->@*, $command;
    }

    sub redo ($self) {
        return unless $self->redo_stack->@*;

        my $command = pop $self->redo_stack->@*;
        $command->execute;
        push $self->undo_stack->@*, $command;
    }
};

redoメソッドは、以下の処理を行います。

  1. redo_stackが空なら、何もせずにreturnする
  2. redo_stackから最後のコマンドをpopする
  3. コマンドのexecuteメソッドを呼び出す(やり直す)
  4. 実行したコマンドをundo_stackpushする(再度Undo可能にする)

undoメソッドと比較してみましょう。

メソッドpopするスタック呼び出すメソッドpushするスタック
undoundo_stack$command->undoredo_stack
redoredo_stack$command->executeundo_stack

対称的な構造になっていることがわかります。

Undo/Redoの連続操作をデモする

では、redoメソッドを使って、Undo/Redoの連続操作をデモしましょう。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# Perl v5.36 以降
# 外部依存: Moo

use v5.36;

package Editor {
    use Moo;

    has text => (
        is      => 'rw',
        default => '',
    );
};

package Command::Role {
    use Moo::Role;

    requires 'execute';
    requires 'undo';
};

package InsertCommand {
    use Moo;
    with 'Command::Role';

    has editor   => (is => 'ro', required => 1);
    has position => (is => 'ro', required => 1);
    has string   => (is => 'ro', required => 1);

    sub execute ($self) {
        my $editor   = $self->editor;
        my $position = $self->position;
        my $string   = $self->string;

        my $current  = $editor->text;
        my $new_text = substr($current, 0, $position) 
                     . $string 
                     . substr($current, $position);
        $editor->text($new_text);
    }

    sub undo ($self) {
        my $editor   = $self->editor;
        my $position = $self->position;
        my $length   = length($self->string);

        my $current  = $editor->text;
        my $new_text = substr($current, 0, $position) 
                     . substr($current, $position + $length);
        $editor->text($new_text);
    }
};

package History {
    use Moo;

    has undo_stack => (
        is      => 'ro',
        default => sub { [] },
    );

    has redo_stack => (
        is      => 'ro',
        default => sub { [] },
    );

    sub execute_command ($self, $command) {
        $command->execute;
        push $self->undo_stack->@*, $command;
        $self->redo_stack->@* = ();
    }

    sub undo ($self) {
        return unless $self->undo_stack->@*;

        my $command = pop $self->undo_stack->@*;
        $command->undo;
        push $self->redo_stack->@*, $command;
    }

    sub redo ($self) {
        return unless $self->redo_stack->@*;

        my $command = pop $self->redo_stack->@*;
        $command->execute;
        push $self->undo_stack->@*, $command;
    }
};

# メイン処理
my $editor  = Editor->new;
my $history = History->new;

# 操作1: 'Hello'を挿入
$history->execute_command(InsertCommand->new(
    editor   => $editor,
    position => 0,
    string   => 'Hello',
));
say "操作1後: '" . $editor->text . "'";

# 操作2: ' World'を挿入
$history->execute_command(InsertCommand->new(
    editor   => $editor,
    position => 5,
    string   => ' World',
));
say "操作2後: '" . $editor->text . "'";

say "";
say "--- Undo/Redo デモ ---";

# Undo: ' World'を取り消し
$history->undo;
say "Undo後:  '" . $editor->text . "'";

# Redo: ' World'をやり直し
$history->redo;
say "Redo後:  '" . $editor->text . "'";

# もう一度Undo
$history->undo;
say "Undo後:  '" . $editor->text . "'";

# さらにUndo: 'Hello'も取り消し
$history->undo;
say "Undo後:  '" . $editor->text . "'";

# Redo: 'Hello'をやり直し
$history->redo;
say "Redo後:  '" . $editor->text . "'";

# Redo: ' World'もやり直し
$history->redo;
say "Redo後:  '" . $editor->text . "'";

実行結果は以下のようになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
操作1後: 'Hello'
操作2後: 'Hello World'

--- Undo/Redo デモ ---
Undo後:  'Hello'
Redo後:  'Hello World'
Undo後:  'Hello'
Undo後:  ''
Redo後:  'Hello'
Redo後:  'Hello World'

Undo/Redoを何度でも繰り返せることが確認できました。

新規操作でredo_stackがクリアされることを確認する

前回説明したように、新しい操作を実行するとredo_stackがクリアされます。これは多くのエディタの動作と同じです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 操作1: 'A'を挿入
$history->execute_command(InsertCommand->new(
    editor   => $editor,
    position => 0,
    string   => 'A',
));
say "操作1後: '" . $editor->text . "'";

# 操作2: 'B'を挿入
$history->execute_command(InsertCommand->new(
    editor   => $editor,
    position => 1,
    string   => 'B',
));
say "操作2後: '" . $editor->text . "'";

# Undo: 'B'を取り消し
$history->undo;
say "Undo後:  '" . $editor->text . "'";
say "redo_stackにBがある: " . ($history->redo_stack->@* ? "はい" : "いいえ");

# 新規操作: 'C'を挿入(redo_stackがクリアされる)
$history->execute_command(InsertCommand->new(
    editor   => $editor,
    position => 1,
    string   => 'C',
));
say "操作3後: '" . $editor->text . "'";
say "redo_stackにBがある: " . ($history->redo_stack->@* ? "はい" : "いいえ");

# Redoしようとしても何も起きない
$history->redo;
say "Redo後:  '" . $editor->text . "'";

実行結果は以下のようになります。

1
2
3
4
5
6
7
操作1後: 'A'
操作2後: 'AB'
Undo後:  'A'
redo_stackにBがある: はい
操作3後: 'AC'
redo_stackにBがある: いいえ
Redo後:  'AC'

Undoした後に新しい操作を実行すると、Redo履歴がクリアされることが確認できました。

今回作成した完成コード

以下が今回作成した完成コードです。Historyクラスにredoメソッドを追加し、Undo/Redoの連続操作を可能にしました。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
#!/usr/bin/env perl
# Perl v5.36 以降
# 外部依存: Moo

use v5.36;

package Editor {
    use Moo;

    has text => (
        is      => 'rw',
        default => '',
    );
};

package Command::Role {
    use Moo::Role;

    requires 'execute';
    requires 'undo';
};

package InsertCommand {
    use Moo;
    with 'Command::Role';

    has editor => (
        is       => 'ro',
        required => 1,
    );

    has position => (
        is       => 'ro',
        required => 1,
    );

    has string => (
        is       => 'ro',
        required => 1,
    );

    sub execute ($self) {
        my $editor   = $self->editor;
        my $position = $self->position;
        my $string   = $self->string;

        my $current  = $editor->text;
        my $new_text = substr($current, 0, $position) 
                     . $string 
                     . substr($current, $position);
        $editor->text($new_text);
    }

    sub undo ($self) {
        my $editor   = $self->editor;
        my $position = $self->position;
        my $length   = length($self->string);

        my $current  = $editor->text;
        my $new_text = substr($current, 0, $position) 
                     . substr($current, $position + $length);
        $editor->text($new_text);
    }
};

package DeleteCommand {
    use Moo;
    with 'Command::Role';

    has editor => (
        is       => 'ro',
        required => 1,
    );

    has position => (
        is       => 'ro',
        required => 1,
    );

    has length => (
        is       => 'ro',
        required => 1,
    );

    has _deleted_string => (
        is      => 'rw',
        default => '',
    );

    sub execute ($self) {
        my $editor   = $self->editor;
        my $position = $self->position;
        my $length   = $self->length;

        my $current = $editor->text;
        my $deleted = substr($current, $position, $length);
        $self->_deleted_string($deleted);

        my $new_text = substr($current, 0, $position) 
                     . substr($current, $position + $length);
        $editor->text($new_text);
    }

    sub undo ($self) {
        my $editor   = $self->editor;
        my $position = $self->position;
        my $deleted  = $self->_deleted_string;

        my $current  = $editor->text;
        my $new_text = substr($current, 0, $position) 
                     . $deleted 
                     . substr($current, $position);
        $editor->text($new_text);
    }
};

package History {
    use Moo;

    has undo_stack => (
        is      => 'ro',
        default => sub { [] },
    );

    has redo_stack => (
        is      => 'ro',
        default => sub { [] },
    );

    sub execute_command ($self, $command) {
        $command->execute;
        push $self->undo_stack->@*, $command;
        $self->redo_stack->@* = ();
    }

    sub undo ($self) {
        return unless $self->undo_stack->@*;

        my $command = pop $self->undo_stack->@*;
        $command->undo;
        push $self->redo_stack->@*, $command;
    }

    sub redo ($self) {
        return unless $self->redo_stack->@*;

        my $command = pop $self->redo_stack->@*;
        $command->execute;
        push $self->undo_stack->@*, $command;
    }
};

# メイン処理
my $editor  = Editor->new;
my $history = History->new;

# 操作1: 'Hello'を挿入
$history->execute_command(InsertCommand->new(
    editor   => $editor,
    position => 0,
    string   => 'Hello',
));
say "操作1後: '" . $editor->text . "'";

# 操作2: ' World'を挿入
$history->execute_command(InsertCommand->new(
    editor   => $editor,
    position => 5,
    string   => ' World',
));
say "操作2後: '" . $editor->text . "'";

# 操作3: '!'を挿入
$history->execute_command(InsertCommand->new(
    editor   => $editor,
    position => 11,
    string   => '!',
));
say "操作3後: '" . $editor->text . "'";

say "";
say "--- Undo/Redo デモ ---";

# Undo×2
$history->undo;
say "Undo後:  '" . $editor->text . "'";

$history->undo;
say "Undo後:  '" . $editor->text . "'";

# Redo×2
$history->redo;
say "Redo後:  '" . $editor->text . "'";

$history->redo;
say "Redo後:  '" . $editor->text . "'";

実行結果は以下のようになります。

1
2
3
4
5
6
7
8
9
操作1後: 'Hello'
操作2後: 'Hello World'
操作3後: 'Hello World!'

--- Undo/Redo デモ ---
Undo後:  'Hello World'
Undo後:  'Hello'
Redo後:  'Hello World'
Redo後:  'Hello World!'

まとめ

  • redoメソッドはundoメソッドの対称的な操作である
  • redoredo_stackからpop → コマンドのexecuteを呼ぶ → undo_stackにpush
  • Undo/Redoは何度でも繰り返せる
  • 新規操作を実行するとredo_stackはクリアされる

次回予告

Undo/Redo機能が完成しました。これで操作の取り消しとやり直しが自由にできるようになりました。

しかし、「複数の操作をまとめて実行・Undoしたい」場面があります。たとえば、「検索と置換」機能では、複数の置換操作を1つのUndoで元に戻したいことがあります。

1
2
3
4
5
6
7
# 今の実装
$history->execute_command($replace1);
$history->execute_command($replace2);
$history->execute_command($replace3);

# Undoすると1回の置換しか戻らない
$history->undo;  # $replace3だけが戻る

次回は、複数の操作を1つにまとめるマクロ機能(MacroCommand)を実装します。

お楽しみに。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。