@nqounetです。
シリーズ「Mooで作る簡易テキストエディタ」の第8回です。
前回の振り返り
前回は、Historyクラスにredoメソッドを実装しました。
これにより、Undoした操作をRedoでやり直せるようになりました。undo_stackとredo_stackの間でコマンドが移動する仕組みを理解しました。
1
2
3
4
5
6
7
| sub redo ($self) {
return unless $self->redo_stack->@*;
my $command = pop $self->redo_stack->@*;
$command->execute;
push $self->undo_stack->@*, $command;
}
|
今回は、複数の操作を1つにまとめるマクロ機能を実装します。
問題:複数操作を1つのUndoで戻したい
「検索と置換」機能を考えてみましょう。テキスト内の「foo」を「bar」に置換する場合、複数箇所を一括で置換することがあります。
1
2
3
4
| # 3箇所の「foo」を「bar」に置換
$history->execute_command($replace1); # 1箇所目
$history->execute_command($replace2); # 2箇所目
$history->execute_command($replace3); # 3箇所目
|
この場合、Undoすると1回の置換しか戻りません。
1
2
3
| $history->undo; # $replace3だけが戻る
$history->undo; # $replace2だけが戻る
$history->undo; # $replace1だけが戻る
|
ユーザーとしては、「置換を全部元に戻す」という操作を1回のUndoでやりたいのではないでしょうか。
解決策:MacroCommandを作成する
この問題を解決するのがMacroCommandです。複数のコマンドを1つのコマンドとしてまとめます。
classDiagram
class Command_Role {
<<interface>>
+execute()
+undo()
}
class InsertCommand {
+execute()
+undo()
}
class DeleteCommand {
+execute()
+undo()
}
class MacroCommand {
-commands[]
+execute()
+undo()
+add_command()
}
Command_Role <|.. InsertCommand
Command_Role <|.. DeleteCommand
Command_Role <|.. MacroCommand
MacroCommand o-- Command_Role : contains
MacroCommandはCommand::Roleを適用しているため、通常のコマンドと同じように扱えます。内部に複数のコマンドを持ち、executeで全部実行、undoで全部取り消します。
これはCompositeパターンと呼ばれるデザインパターンの一種です。
MacroCommandクラスを実装する
では、MacroCommandクラスを実装しましょう。
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
| # Perl v5.36 以降
# 外部依存: Moo
package MacroCommand {
use Moo;
use v5.36;
with 'Command::Role';
has commands => (
is => 'ro',
default => sub { [] },
);
sub add_command ($self, $command) {
push $self->commands->@*, $command;
}
sub execute ($self) {
for my $cmd ($self->commands->@*) {
$cmd->execute;
}
}
sub undo ($self) {
for my $cmd (reverse $self->commands->@*) {
$cmd->undo;
}
}
};
|
MacroCommandクラスのポイントを見ていきましょう。
commandsで子コマンドを保持
1
2
3
4
| has commands => (
is => 'ro',
default => sub { [] },
);
|
commands属性は、子コマンドを配列リファレンスで保持します。
add_commandでコマンドを追加
1
2
3
| sub add_command ($self, $command) {
push $self->commands->@*, $command;
}
|
add_commandメソッドで、子コマンドを追加します。
executeで全コマンドを順番に実行
1
2
3
4
5
| sub execute ($self) {
for my $cmd ($self->commands->@*) {
$cmd->execute;
}
}
|
executeメソッドは、登録されたすべてのコマンドを順番に実行します。
undoで全コマンドを逆順に取り消す
1
2
3
4
5
| sub undo ($self) {
for my $cmd (reverse $self->commands->@*) {
$cmd->undo;
}
}
|
undoメソッドは、登録されたすべてのコマンドを逆順に取り消します。
なぜ逆順なのでしょうか?
操作を元に戻すときは、最後に実行した操作から順に戻す必要があります。
flowchart LR
subgraph execute["execute順"]
E1["cmd1"] --> E2["cmd2"] --> E3["cmd3"]
end
subgraph undo["undo順(逆順)"]
U3["cmd3"] --> U2["cmd2"] --> U1["cmd1"]
end
execute --> undo
たとえば、「位置0に’A’を挿入」→「位置1に’B’を挿入」という操作を考えます。
- execute順: ‘A’挿入 → ‘B’挿入 → 結果: “AB”
- undo順: ‘B’削除 → ‘A’削除 → 結果: ""
もしundo順を逆にしなかったら、以下のようになってしまいます。
- 誤ったundo順: ‘A’削除(位置0を削除)→ ‘B’削除(位置1を削除)
- 最初に’A’を削除すると、テキストは"B"になり、位置1には何もない!
このように、undoは逆順で行う必要があります。
複数操作の一括Undoをデモする
では、MacroCommandを使って複数操作の一括Undoをデモしましょう。
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
| # 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 MacroCommand {
use Moo;
with 'Command::Role';
has commands => (
is => 'ro',
default => sub { [] },
);
sub add_command ($self, $command) {
push $self->commands->@*, $command;
}
sub execute ($self) {
for my $cmd ($self->commands->@*) {
$cmd->execute;
}
}
sub undo ($self) {
for my $cmd (reverse $self->commands->@*) {
$cmd->undo;
}
}
};
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;
# まず通常の操作
$history->execute_command(InsertCommand->new(
editor => $editor,
position => 0,
string => 'foo foo foo',
));
say "初期状態: '" . $editor->text . "'";
say "";
say "--- マクロで一括置換 ---";
# MacroCommandで複数の置換操作をまとめる
# 簡易的に「foo」→「bar」の置換をシミュレート
# 実際には「削除して挿入」の組み合わせ
my $macro = MacroCommand->new;
# 1つ目の「foo」(位置0-2)を「bar」に置換
$macro->add_command(InsertCommand->new(
editor => $editor,
position => 0,
string => 'bar',
));
# 2つ目の「foo」(位置7-9、barを挿入後は位置7)を「bar」に置換
$macro->add_command(InsertCommand->new(
editor => $editor,
position => 7,
string => 'bar',
));
# 3つ目の「foo」(位置14-16、2回挿入後は位置14)を「bar」に置換
$macro->add_command(InsertCommand->new(
editor => $editor,
position => 14,
string => 'bar',
));
# マクロを実行(3つの操作が一括で実行される)
$history->execute_command($macro);
say "マクロ実行後: '" . $editor->text . "'";
say "";
say "--- 1回のUndoで全部戻る ---";
# 1回のUndoで3つの操作が全部戻る
$history->undo;
say "Undo後: '" . $editor->text . "'";
say "";
say "--- 1回のRedoで全部やり直す ---";
# 1回のRedoで3つの操作が全部やり直される
$history->redo;
say "Redo後: '" . $editor->text . "'";
|
実行結果は以下のようになります。
1
2
3
4
5
6
7
8
9
10
| 初期状態: 'foo foo foo'
--- マクロで一括置換 ---
マクロ実行後: 'barfoo barfoo barfoo'
--- 1回のUndoで全部戻る ---
Undo後: 'foo foo foo'
--- 1回のRedoで全部やり直す ---
Redo後: 'barfoo barfoo barfoo'
|
3つの挿入操作が、1回のUndo/Redoでまとめて処理されていることが確認できました。
Compositeパターンの力
MacroCommandが強力なのは、MacroCommandの中にMacroCommandを入れられることです。
1
2
3
4
5
6
7
| my $inner_macro = MacroCommand->new;
$inner_macro->add_command($cmd1);
$inner_macro->add_command($cmd2);
my $outer_macro = MacroCommand->new;
$outer_macro->add_command($inner_macro); # マクロの中にマクロ
$outer_macro->add_command($cmd3);
|
これは再帰的な構造であり、Compositeパターンの特徴です。
flowchart TB
subgraph outer["outer_macro"]
subgraph inner["inner_macro"]
C1["cmd1"]
C2["cmd2"]
end
C3["cmd3"]
end
outer_macro->executeを呼ぶと、以下の順で実行されます。
inner_macro->execute → cmd1->execute → cmd2->executecmd3->execute
HistoryクラスはMacroCommandを通常のコマンドと同じように扱えるため、複雑なマクロも問題なく履歴管理できます。
今回作成した完成コード
以下が今回作成した完成コードです。MacroCommandクラスを追加し、複数操作の一括実行・Undoを可能にしました。
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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
| #!/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 MacroCommand {
use Moo;
with 'Command::Role';
has commands => (
is => 'ro',
default => sub { [] },
);
sub add_command ($self, $command) {
push $self->commands->@*, $command;
}
sub execute ($self) {
for my $cmd ($self->commands->@*) {
$cmd->execute;
}
}
sub undo ($self) {
for my $cmd (reverse $self->commands->@*) {
$cmd->undo;
}
}
};
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;
say "=== 通常操作のデモ ===";
# 操作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 "=== マクロ操作のデモ ===";
# マクロを作成
my $macro = MacroCommand->new;
# マクロに複数の操作を追加
$macro->add_command(InsertCommand->new(
editor => $editor,
position => 11,
string => '!',
));
$macro->add_command(InsertCommand->new(
editor => $editor,
position => 0,
string => '>>> ',
));
$macro->add_command(InsertCommand->new(
editor => $editor,
position => 19,
string => ' <<<',
));
# マクロを実行(3つの操作が一括で実行される)
$history->execute_command($macro);
say "マクロ実行後: '" . $editor->text . "'";
say "";
say "=== Undo/Redo デモ ===";
# 1回のUndoで3つの操作が全部戻る
$history->undo;
say "Undo後: '" . $editor->text . "'";
# 通常操作もUndo
$history->undo;
say "Undo後: '" . $editor->text . "'";
# Redoで戻す
$history->redo;
say "Redo後: '" . $editor->text . "'";
# マクロもRedoできる
$history->redo;
say "Redo後: '" . $editor->text . "'";
|
実行結果は以下のようになります。
1
2
3
4
5
6
7
8
9
10
11
12
| === 通常操作のデモ ===
操作1後: 'Hello'
操作2後: 'Hello World'
=== マクロ操作のデモ ===
マクロ実行後: '>>> Hello World! <<<'
=== Undo/Redo デモ ===
Undo後: 'Hello World'
Undo後: 'Hello'
Redo後: 'Hello World'
Redo後: '>>> Hello World! <<<'
|
まとめ
MacroCommandは複数のコマンドを1つにまとめるexecuteで全コマンドを順番に実行、undoで逆順に取り消すMacroCommandもCommand::Roleを適用しているため、通常のコマンドと同じように扱える- これはCompositeパターンの一種である
- マクロの中にマクロを入れることも可能(再帰的な構造)
次回予告
これで、エディタに必要な主要機能が揃いました。
- Editor — テキストを保持
- InsertCommand / DeleteCommand — 操作をオブジェクト化
- Command::Role — 共通インターフェースを保証
- History — Undo/Redo機能
- MacroCommand — 複数操作をまとめる
次回は、これらの機能を統合し、対話的に操作できる簡易エディタを完成させます。
お楽しみに。