基于Python打造支持Undo/Redo的文本编辑器命令系统
一、引言:你的 Ctrl+Z 背后藏着什么秘密?
每天打开代码编辑器,你最常按的快捷键是什么?
对大多数开发者来说,Ctrl+Z(撤销)和 Ctrl+Y(重做)可能是仅次于保存的高频操作。写错了一段代码?撤销。误删了一个函数?撤销。一路 Redo 找回刚才的思路?重做。
这一切看似魔法的背后,正是软件工程中一个优雅而强大的设计模式:命令模式(Command Pattern)。
初学者可能会想:撤销不就是「保存历史状态,出错时回滚」吗?用一个列表记录每次操作前的文本不就行了?
这种方案在文本量小时可行,但在复杂系统中代价极高——每次操作都复制整个状态,内存消耗随历史深度线性增长,且无法区分「哪些操作可以合并」「哪些操作是原子的」。
命令模式的解法更加精妙:将每一个操作封装成独立的对象,对象内同时包含「执行」和「撤销」的逻辑。 撤销不是回滚状态,而是执行「反向命令」——插入的反操作是删除,移动的反操作是移回原位。
本文将从零构建一个功能完整的文本编辑器命令系统,覆盖基础命令实现、命令组合、宏命令、历史管理、以及生产级的持久化方案。
二、命令模式基础:四个核心角色
2.1 角色定义
- Command(抽象命令):定义执行
execute()和撤销undo()的接口。 - ConcreteCommand(具体命令):封装一个具体操作及其撤销逻辑,持有接收者的引用。
- Receiver(接收者):真正执行业务逻辑的对象(此处为文本编辑器核心)。
- Invoker(调用者):持有命令对象,管理命令历史,负责 Undo/Redo 调度。
2.2 最小骨架
from abc import ABC, abstractmethod
class Command(ABC):
"""抽象命令:所有命令的契约"""
@abstractmethod
def execute(self) -> None:
"""执行命令"""
pass
@abstractmethod
def undo(self) -> None:
"""撤销命令(执行反操作)"""
pass
@property
def description(self) -> str:
"""命令描述,用于历史记录展示"""
return self.__class__.__name__
三、构建文本编辑器核心(接收者)
在动手写命令之前,先构建一个功能完备的文本编辑器核心——它是所有命令操作的「接收者」:
from dataclasses import dataclass, field
from typing import Optional
import re
@dataclass
class TextBuffer:
"""
文本缓冲区:编辑器的核心数据模型
支持光标定位、选区操作、文本增删
"""
content: str = ''
cursor: int = 0 # 光标位置(字符索引)
selection: Optional[tuple[int, int]] = None # 选区 (start, end)
filename: str = 'untitled.txt'
modified: bool = False
def __post_init__(self):
self._validate_cursor()
def _validate_cursor(self):
self.cursor = max(0, min(self.cursor, len(self.content)))
# ===== 基础操作 =====
def insert(self, pos: int, text: str) -> None:
"""在指定位置插入文本"""
pos = max(0, min(pos, len(self.content)))
self.content = self.content[:pos] + text + self.content[pos:]
self.cursor = pos + len(text)
self.modified = True
def delete(self, start: int, end: int) -> str:
"""删除 [start, end) 范围的文本,返回被删除内容"""
start = max(0, min(start, len(self.content)))
end = max(start, min(end, len(self.content)))
deleted = self.content[start:end]
self.content = self.content[:start] + self.content[end:]
self.cursor = start
self.modified = True
return deleted
def replace(self, start: int, end: int, new_text: str) -> str:
"""替换 [start, end) 范围的文本,返回被替换内容"""
old_text = self.delete(start, end)
self.insert(start, new_text)
return old_text
def move_cursor(self, pos: int) -> int:
"""移动光标,返回旧位置"""
old_pos = self.cursor
self.cursor = max(0, min(pos, len(self.content)))
return old_pos
def get_line_at(self, pos: int) -> tuple[int, int, str]:
"""获取指定位置所在行的 (行首, 行尾, 行内容)"""
line_start = self.content.rfind('\n', 0, pos) + 1
line_end = self.content.find('\n', pos)
if line_end == -1:
line_end = len(self.content)
return line_start, line_end, self.content[line_start:line_end]
def display(self, show_cursor: bool = True) -> str:
"""渲染文本(带光标标记)"""
if show_cursor:
content = (self.content[:self.cursor] + '|' +
self.content[self.cursor:])
else:
content = self.content
lines = content.split('\n')
result = [f"{'─' * 40}",
f"文件: {self.filename} {'*' if self.modified else ''}",
f"{'─' * 40}"]
for i, line in enumerate(lines, 1):
result.append(f"{i:3d} │ {line}")
result.append(f"{'─' * 40}")
result.append(f"光标: {self.cursor} | 字符数: {len(self.content)}")
return '\n'.join(result)
四、实现完整命令集
4.1 基础文本命令
class InsertCommand(Command):
"""插入文本命令"""
def __init__(self, buffer: TextBuffer, pos: int, text: str):
self.buffer = buffer
self.pos = pos
self.text = text
self._old_cursor: int = 0
def execute(self) -> None:
self._old_cursor = self.buffer.cursor
self.buffer.insert(self.pos, self.text)
def undo(self) -> None:
self.buffer.delete(self.pos, self.pos + len(self.text))
self.buffer.cursor = self._old_cursor
@property
def description(self) -> str:
preview = self.text[:20].replace('\n', '↵')
return f"插入文本: 「{preview}」at pos={self.pos}"
class DeleteCommand(Command):
"""删除文本命令"""
def __init__(self, buffer: TextBuffer, start: int, end: int):
self.buffer = buffer
self.start = start
self.end = end
self._deleted_text: str = ''
self._old_cursor: int = 0
def execute(self) -> None:
self._old_cursor = self.buffer.cursor
self._deleted_text = self.buffer.delete(self.start, self.end)
def undo(self) -> None:
self.buffer.insert(self.start, self._deleted_text)
self.buffer.cursor = self._old_cursor
@property
def description(self) -> str:
preview = self._deleted_text[:20].replace('\n', '↵')
return f"删除文本: 「{preview}」[{self.start}:{self.end}]"
class ReplaceCommand(Command):
"""替换文本命令(原子操作)"""
def __init__(self, buffer: TextBuffer, start: int, end: int, new_text: str):
self.buffer = buffer
self.start = start
self.end = end
self.new_text = new_text
self._old_text: str = ''
self._old_cursor: int = 0
def execute(self) -> None:
self._old_cursor = self.buffer.cursor
self._old_text = self.buffer.replace(self.start, self.end, self.new_text)
def undo(self) -> None:
self.buffer.replace(self.start,
self.start + len(self.new_text),
self._old_text)
self.buffer.cursor = self._old_cursor
@property
def description(self) -> str:
return (f"替换: 「{self._old_text[:15]}」→「{self.new_text[:15]}」"
f"[{self.start}:{self.end}]")
class MoveCursorCommand(Command):
"""移动光标命令"""
def __init__(self, buffer: TextBuffer, new_pos: int):
self.buffer = buffer
self.new_pos = new_pos
self._old_pos: int = 0
def execute(self) -> None:
self._old_pos = self.buffer.move_cursor(self.new_pos)
def undo(self) -> None:
self.buffer.move_cursor(self._old_pos)
@property
def description(self) -> str:
return f"移动光标: {self._old_pos} → {self.new_pos}"
4.2 高级命令:查找替换
class FindReplaceCommand(Command):
"""查找并全局替换命令"""
def __init__(self, buffer: TextBuffer, pattern: str,
replacement: str, use_regex: bool = False):
self.buffer = buffer
self.pattern = pattern
self.replacement = replacement
self.use_regex = use_regex
self._original_content: str = ''
self._original_cursor: int = 0
self._replace_count: int = 0
def execute(self) -> None:
self._original_content = self.buffer.content
self._original_cursor = self.buffer.cursor
if self.use_regex:
new_content, count = re.subn(
self.pattern, self.replacement, self.buffer.content
)
else:
count = self.buffer.content.count(self.pattern)
new_content = self.buffer.content.replace(
self.pattern, self.replacement
)
self._replace_count = count
self.buffer.content = new_content
self.buffer.modified = True
print(f" [查找替换] 共替换 {count} 处")
def undo(self) -> None:
"""一键恢复全部替换——这就是命令模式的威力"""
self.buffer.content = self._original_content
self.buffer.cursor = self._original_cursor
self.buffer.modified = True
@property
def description(self) -> str:
mode = "正则" if self.use_regex else "普通"
return (f"全局替换({mode}): 「{self.pattern}」→「{self.replacement}」"
f"({self._replace_count}处)")
class IndentCommand(Command):
"""缩进/反缩进命令(对选区内所有行生效)"""
def __init__(self, buffer: TextBuffer, start: int, end: int,
indent: bool = True, spaces: int = 4):
self.buffer = buffer
self.start = start
self.end = end
self.indent = indent
self.spaces = spaces
self._original_content: str = ''
self._original_cursor: int = 0
def execute(self) -> None:
self._original_content = self.buffer.content
self._original_cursor = self.buffer.cursor
lines = self.buffer.content.split('\n')
# 计算影响的行范围
char_count = 0
start_line = end_line = 0
for i, line in enumerate(lines):
if char_count <= self.start:
start_line = i
if char_count <= self.end:
end_line = i
char_count += len(line) + 1
prefix = ' ' * self.spaces
for i in range(start_line, end_line + 1):
if self.indent:
lines[i] = prefix + lines[i]
else:
lines[i] = lines[i].lstrip(' ')[:len(lines[i]) - len(lines[i].lstrip(' '))]
if lines[i].startswith(prefix):
lines[i] = lines[i][self.spaces:]
else:
lines[i] = lines[i].lstrip(' ')
self.buffer.content = '\n'.join(lines)
self.buffer.modified = True
def undo(self) -> None:
self.buffer.content = self._original_content
self.buffer.cursor = self._original_cursor
self.buffer.modified = True
@property
def description(self) -> str:
action = "缩进" if self.indent else "反缩进"
return f"{action} {self.spaces}空格 [{self.start}:{self.end}]"
4.3 宏命令:组合多个操作为一个原子单元
class MacroCommand(Command):
"""
宏命令:将多个命令组合成一个可整体撤销的原子操作
典型用途:「粘贴」= 先删除选区 + 再插入剪贴板内容
"""
def __init__(self, commands: list[Command], macro_name: str = '宏命令'):
self.commands = commands
self.macro_name = macro_name
self._executed: list[Command] = []
def execute(self) -> None:
self._executed = []
for cmd in self.commands:
try:
cmd.execute()
self._executed.append(cmd)
except Exception as e:
# 执行失败:回滚已执行的命令
print(f" ⚠ 宏命令子步骤失败: {e},正在回滚...")
for executed_cmd in reversed(self._executed):
executed_cmd.undo()
self._executed = []
raise
def undo(self) -> None:
"""逆序撤销所有子命令"""
for cmd in reversed(self._executed):
cmd.undo()
@property
def description(self) -> str:
sub_descs = [f" · {cmd.description}" for cmd in self.commands]
return f"{self.macro_name}({len(self.commands)}步):\n" + '\n'.join(sub_descs)
五、命令调度中心:历史管理器(Invoker)
from collections import deque
import json
from datetime import datetime
class CommandHistory:
"""
命令历史管理器(Invoker)
- 执行命令并记录到 undo 栈
- 支持多步 Undo/Redo
- 支持历史限制(防止内存溢出)
- 支持历史快照导出
"""
def __init__(self, max_history: int = 100):
self._undo_stack: deque[Command] = deque(maxlen=max_history)
self._redo_stack: deque[Command] = deque(maxlen=max_history)
self._max_history = max_history
self._execute_log: list[dict] = []
def execute(self, command: Command) -> None:
"""执行命令并推入撤销栈"""
command.execute()
self._undo_stack.append(command)
# 执行新命令后,清空 Redo 栈(与主流编辑器行为一致)
self._redo_stack.clear()
# 记录执行日志
self._execute_log.append({
'action': 'execute',
'command': command.description,
'timestamp': datetime.now().isoformat()
})
print(f" ✓ 执行: {command.description}")
def undo(self) -> bool:
"""撤销最近一条命令"""
if not self._undo_stack:
print(" ⚠ 没有可撤销的操作")
return False
command = self._undo_stack.pop()
command.undo()
self._redo_stack.append(command)
self._execute_log.append({
'action': 'undo',
'command': command.description,
'timestamp': datetime.now().isoformat()
})
print(f" ↩ 撤销: {command.description}")
return True
def redo(self) -> bool:
"""重做最近一条被撤销的命令"""
if not self._redo_stack:
print(" ⚠ 没有可重做的操作")
return False
command = self._redo_stack.pop()
command.execute()
self._undo_stack.append(command)
self._execute_log.append({
'action': 'redo',
'command': command.description,
'timestamp': datetime.now().isoformat()
})
print(f" ↪ 重做: {command.description}")
return True
def undo_many(self, steps: int) -> int:
"""批量撤销多步"""
count = 0
for _ in range(steps):
if self.undo():
count += 1
else:
break
return count
def redo_many(self, steps: int) -> int:
"""批量重做多步"""
count = 0
for _ in range(steps):
if self.redo():
count += 1
else:
break
return count
@property
def can_undo(self) -> bool:
return len(self._undo_stack) > 0
@property
def can_redo(self) -> bool:
return len(self._redo_stack) > 0
@property
def undo_count(self) -> int:
return len(self._undo_stack)
@property
def redo_count(self) -> int:
return len(self._redo_stack)
def get_history_summary(self) -> str:
"""获取操作历史摘要"""
lines = [f"{'─' * 50}",
f" 操作历史 (Undo栈: {self.undo_count} | Redo栈: {self.redo_count})",
f"{'─' * 50}"]
lines.append(" [可撤销操作](从新到旧):")
for cmd in reversed(list(self._undo_stack)):
lines.append(f" ↩ {cmd.description}")
if self._redo_stack:
lines.append(" [可重做操作](从新到旧):")
for cmd in reversed(list(self._redo_stack)):
lines.append(f" ↪ {cmd.description}")
lines.append(f"{'─' * 50}")
return '\n'.join(lines)
def export_log(self, filepath: str) -> None:
"""导出操作日志到 JSON 文件"""
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(self._execute_log, f, ensure_ascii=False, indent=2)
print(f" 操作日志已导出至: {filepath}")
六、完整集成:文本编辑器门面类
class TextEditor:
"""
文本编辑器门面类(Facade)
将 TextBuffer(接收者)+ CommandHistory(调用者)整合为统一 API
"""
def __init__(self, filename: str = 'untitled.txt'):
self.buffer = TextBuffer(filename=filename)
self.history = CommandHistory(max_history=200)
self._clipboard: str = ''
# ===== 编辑操作(每个操作都封装为命令)=====
def type_text(self, text: str) -> None:
"""在当前光标位置输入文本"""
cmd = InsertCommand(self.buffer, self.buffer.cursor, text)
self.history.execute(cmd)
def insert_at(self, pos: int, text: str) -> None:
"""在指定位置插入文本"""
cmd = InsertCommand(self.buffer, pos, text)
self.history.execute(cmd)
def delete_range(self, start: int, end: int) -> None:
"""删除指定范围的文本"""
cmd = DeleteCommand(self.buffer, start, end)
self.history.execute(cmd)
def replace_range(self, start: int, end: int, new_text: str) -> None:
"""替换指定范围的文本"""
cmd = ReplaceCommand(self.buffer, start, end, new_text)
self.history.execute(cmd)
def find_replace(self, pattern: str, replacement: str,
use_regex: bool = False) -> None:
"""查找并全局替换"""
cmd = FindReplaceCommand(self.buffer, pattern, replacement, use_regex)
self.history.execute(cmd)
def indent_selection(self, start: int, end: int, indent: bool = True) -> None:
"""缩进/反缩进选区"""
cmd = IndentCommand(self.buffer, start, end, indent)
self.history.execute(cmd)
def cut(self, start: int, end: int) -> str:
"""剪切:删除文本并存入剪贴板"""
# 剪切 = 复制 + 删除(两步封装为宏命令,整体可撤销)
self._clipboard = self.buffer.content[start:end]
cmd = DeleteCommand(self.buffer, start, end)
self.history.execute(cmd)
print(f" 📋 已剪切 {len(self._clipboard)} 个字符到剪贴板")
return self._clipboard
def paste(self, pos: int = None) -> None:
"""粘贴:如有选区则先删除再插入(原子宏命令)"""
if not self._clipboard:
print(" ⚠ 剪贴板为空")
return
target_pos = pos if pos is not None else self.buffer.cursor
cmd = InsertCommand(self.buffer, target_pos, self._clipboard)
self.history.execute(cmd)
print(f" 📋 粘贴 {len(self._clipboard)} 个字符")
def move_cursor(self, pos: int) -> None:
"""移动光标(不加入 Undo 历史,与主流编辑器一致)"""
self.buffer.move_cursor(pos)
# ===== Undo/Redo =====
def undo(self, steps: int = 1) -> None:
self.history.undo_many(steps)
def redo(self, steps: int = 1) -> None:
self.history.redo_many(steps)
# ===== 显示 =====
def show(self) -> None:
print(self.buffer.display())
def show_history(self) -> None:
print(self.history.get_history_summary())
七、完整演示:从零到成品的编辑过程
def demo_text_editor():
print("=" * 60)
print(" Python 文本编辑器 · 命令模式演示")
print("=" * 60)
editor = TextEditor('hello_world.py')
# ===== 第一阶段:输入初始代码 =====
print("\n【阶段一:输入代码】")
editor.type_text('def hello():\n')
editor.type_text(' print("Hello, wrold!")\n') # 故意拼错 wrold
editor.type_text('\nhello()')
editor.show()
# ===== 第二阶段:发现错误,修复拼写 =====
print("\n【阶段二:修复拼写错误】")
editor.find_replace('wrold', 'world')
editor.show()
# ===== 第三阶段:撤销修复(看看原来的错误)=====
print("\n【阶段三:撤销查找替换】")
editor.undo()
editor.show()
# ===== 第四阶段:重做修复 =====
print("\n【阶段四:重做修复】")
editor.redo()
editor.show()
# ===== 第五阶段:插入注释(宏命令演示)=====
print("\n【阶段五:在开头插入文件注释(宏命令)】")
header = '# -*- coding: utf-8 -*-\n# Author: Claude\n# Date: 2025-02-25\n\n'
macro = MacroCommand([
InsertCommand(editor.buffer, 0, header),
], macro_name='插入文件头注释')
editor.history.execute(macro)
editor.show()
# ===== 第六阶段:缩进操作 =====
print("\n【阶段六:对函数体进行缩进调整】")
content = editor.buffer.content
body_start = content.index(' print')
body_end = body_start + len(' print("Hello, world!")')
editor.indent_selection(body_start, body_end, indent=True)
# ===== 第七阶段:剪切粘贴演示 =====
print("\n【阶段七:剪切并移动代码块】")
content = editor.buffer.content
call_start = content.rfind('\nhello()')
call_end = len(content)
editor.cut(call_start, call_end)
editor.type_text('\n\nif __name__ == "__main__":')
editor.type_text('\n hello()')
editor.show()
# ===== 第八阶段:多步撤销 =====
print("\n【阶段八:连续撤销 3 步】")
editor.undo(3)
editor.show()
# ===== 查看历史 =====
print("\n【操作历史摘要】")
editor.show_history()
# ===== 统计信息 =====
print(f"\n【统计信息】")
print(f" 当前文本: {len(editor.buffer.content)} 字符")
print(f" 可撤销: {editor.history.undo_count} 步")
print(f" 可重做: {editor.history.redo_count} 步")
demo_text_editor()
八、进阶:命令合并优化(减少 Undo 粒度)
真实编辑器中,连续输入字符不会每个字符都生成一条 Undo 记录,而是将短时间内的连续输入合并为一条:
import time
class MergeableInsertCommand(InsertCommand):
"""可合并的插入命令(连续输入自动合并)"""
MERGE_INTERVAL = 0.5 # 500ms 内的连续输入合并为一条
def __init__(self, buffer: TextBuffer, pos: int, text: str):
super().__init__(buffer, pos, text)
self.timestamp = time.monotonic()
def try_merge(self, other: 'MergeableInsertCommand') -> bool:
"""
尝试与后续命令合并
条件:时间间隔短 + 位置连续 + 不含换行
"""
if not isinstance(other, MergeableInsertCommand):
return False
time_ok = (other.timestamp - self.timestamp) < self.MERGE_INTERVAL
pos_ok = (self.pos + len(self.text) == other.pos)
content_ok = '\n' not in other.text
if time_ok and pos_ok and content_ok:
self.text += other.text # 合并文本
self.timestamp = other.timestamp
return True
return False
class SmartCommandHistory(CommandHistory):
"""智能历史管理器:支持命令合并"""
def execute(self, command: Command) -> None:
# 尝试与栈顶命令合并
if (self._undo_stack and
isinstance(command, MergeableInsertCommand) and
isinstance(self._undo_stack[-1], MergeableInsertCommand)):
top = self._undo_stack[-1]
if top.try_merge(command):
# 合并成功:直接执行而不入栈
command.execute()
print(f" ✓ 合并输入: 「{top.text}」")
return
super().execute(command)
九、最佳实践与常见陷阱
最佳实践一:命令必须是完全自包含的。 每个命令对象应该包含执行和撤销所需的全部信息,不依赖外部可变状态。
最佳实践二:Undo 必须精确还原,而非近似还原。 特别是光标位置、选区状态,都应在执行前保存,在 Undo 时精确恢复。
最佳实践三:宏命令的异常回滚。 如示例所示,宏命令中任何子步骤失败都应触发已执行步骤的逆序回滚,保证原子性。
常见陷阱一:Redo 栈的清理时机。 执行新命令时必须清空 Redo 栈(主流编辑器行为),否则 Redo 操作会产生歧义。
常见陷阱二:无限历史的内存风险。 使用 collections.deque(maxlen=N) 限制历史深度,或对大操作(如全文替换)进行压缩存储。
设计哲学: 命令模式的核心不是「记录状态」而是「封装意图」。每条命令代表用户的一次有意义操作,Undo 是用户意图的逆转,而不是时间机器。
十、总结
命令模式通过将操作封装为对象,优雅地解决了撤销/重做这一经典难题。本文构建的完整系统覆盖了以下核心能力:
每种命令(插入、删除、替换、查找替换、缩进)都携带完整的撤销信息;MacroCommand 将多步操作组合为原子单元,整体可撤销;CommandHistory 用双栈模型管理 Undo/Redo,支持批量操作和历史限制;MergeableInsertCommand 实现连续输入的智能合并,贴近真实编辑器体验。
命令模式的应用远不止于文本编辑器——数据库事务、游戏操作回放、GUI 按钮绑定、任务队列调度,处处都是它的身影。一旦你建立起「将操作封装为对象」的思维模式,你会发现很多原本复杂的需求都变得清晰而优雅。
你在项目中实现过撤销功能吗?是用命令模式,还是有其他方案?欢迎在评论区分享你的设计思路,让我们一起探索更优雅的 Python 编程之道。
以上就是基于Python打造支持Undo/Redo的文本编辑器命令系统的详细内容,更多关于Python文本编辑器的资料请关注脚本之家其它相关文章!
相关文章
Python使用Webargs实现简化Web应用程序的参数处理
在开发Web应用程序时,参数处理是一个常见的任务,Python的Webargs模块为我们提供了一种简单而强大的方式来处理这些参数,下面我们就来学习一下具体操作吧2024-02-02
如何在python开发工具PyCharm中搭建QtPy环境(教程详解)
这篇文章主要介绍了在python开发工具PyCharm中搭建QtPy环境,本文通过图文并茂的形式给大家介绍的非常详细,具有一定的参考借鉴价值,需要的朋友可以参考下2020-02-02
Python(TensorFlow框架)实现手写数字识别系统的方法
这篇文章主要介绍了Python(TensorFlow框架)实现手写数字识别系统的方法。小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧2018-05-05
Python使用pyinstaller打包含有gettext locales语言环境的项目(推荐)
最近在用 pyhton 做一个图片处理的小工具,顺便接触了gettext,用来实现本地化化中英文转换,本文通过一个项目给大家详细介绍下,感兴趣的朋友跟随小编一起看看吧2022-01-01


最新评论