基于Python打造支持Undo/Redo的文本编辑器命令系统

 更新时间:2026年02月26日 08:47:25   作者:铭渊老黄  
这篇文章主要介绍了如何使用命令模式(Command Pattern)在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文本编辑器的资料请关注脚本之家其它相关文章!

相关文章

最新评论