From aff3c29602911c9b3f0528949b2defcd72b810b3 Mon Sep 17 00:00:00 2001 From: "Rafli I." Date: Thu, 8 Oct 2026 12:31:20 +0700 Subject: [PATCH 1/2] feat: undo and redo many steps in Windows 10 Notepad Notepad's edit control keeps one undo step, and a second control+Z redoes it. Code Compass now records the document at undo stops (a pause in typing, a caret move to another line, a save, and before and after each of its own commands), keeping only what changed, and control+Z, control+Y and control+shift+Z walk through those steps. Typing edits such as auto-close and indentation join the typing around them. A new document in the window (another title, or a changed text that Notepad reports as unmodified) starts a new history, so undo never brings back another file's text. Undoing back to the saved text clears the "*". On by default; a setting turns it off. Version 0.4.1. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- CHANGELOG.md | 6 + README.md | 1 + addon/doc/en/readme.md | 4 +- addon/doc/id/readme.md | 4 +- addon/globalPlugins/codeCompass/__init__.py | 160 ++++++++++++- .../globalPlugins/codeCompass/edit_control.py | 8 + addon/globalPlugins/codeCompass/undo.py | 117 ++++++++++ addon/locale/id/LC_MESSAGES/nvda.po | 9 + buildVars.py | 2 +- tests/test_review_040.py | 1 + tests/test_undo.py | 211 ++++++++++++++++++ tools/update_po.py | 5 + 13 files changed, 516 insertions(+), 14 deletions(-) create mode 100644 addon/globalPlugins/codeCompass/undo.py create mode 100644 tests/test_undo.py diff --git a/AGENTS.md b/AGENTS.md index fee8ce2..8598cf6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ Run the tests before committing, and add a test when you fix a bug. ## Project-specific notes -- `analyzer.py`, `snippets.py`, `editing.py`, `problems.py`, `filepath.py`, `edit_control.py`, `bookmarks.py` and `programs.py` must stay free of NVDA imports so they can be tested directly. NVDA-specific code goes in `__init__.py`. +- `analyzer.py`, `snippets.py`, `editing.py`, `problems.py`, `filepath.py`, `edit_control.py`, `bookmarks.py`, `programs.py` and `undo.py` must stay free of NVDA imports so they can be tested directly. NVDA-specific code goes in `__init__.py`. - The tests run on Linux in CI, so Windows-only calls (ctypes.windll, winreg) must happen only when a function is called, and tests must replace them (see `tests/test_programs.py`). - `addon/sounds/*.wav` are Visual Studio Code's accessibility signal sounds (MIT, see `addon/sounds/LICENSE-vscode.txt`), converted from MP3 and trimmed. NVDA plays them with `nvwave.playWaveFile`; keep them 16-bit PCM WAV. - Commands that change text only run in a writable, multi-line Win32 edit control (classic Notepad), through `edit_control.replace`, which uses EM_SETSEL and EM_REPLACESEL (one undo step, offsets in UTF-16 units). Elsewhere their keys pass through unchanged. `tests/test_plugin_v040.py` replaces `edit_control.replace` with a fake that edits the test editor's text. diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ca562..8a2c885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Version 0.4.1 + +### 🎉 New + +- **Undo and redo many steps in Windows 10's Notepad**, like in VS Code. Notepad itself keeps only one step, and its second control+Z brings the change back. Now **control+Z** goes back step by step and **control+Y** (or control+shift+Z) forward again. A step is a run of typing (it ends when you pause or move to another line) or one Code Compass command, such as a line move or a rename. The changed line is read, and going back to the saved text clears the "*" in the title. It can be turned off in the settings. + ## Version 0.4.0 The first public release. Coding in Notepad now works much more like a code editor such as VS Code. diff --git a/README.md b/README.md index 0a180f7..888cc11 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Editing in Windows 10 Notepad: - **Automatic indentation** on Enter and closing brackets, **Tab and shift+Tab** on several lines, **control+/** to comment. - **control+space** completion from the file's names, **control+shift+space** for a function's parameters. - **Delete, move and duplicate lines**, and **F2** to rename a name across the file. +- **Undo and redo many steps** with control+Z and control+Y; Notepad alone keeps only one. - **Snippets**: save code you reuse and insert it later, indented to fit. Comments and strings are ignored when counting brackets; Python and YAML blocks also follow indentation. Messages are in English or Indonesian, chosen separately from NVDA's language. NVDA+shift+K then H lists the commands that work in any editor. diff --git a/addon/doc/en/readme.md b/addon/doc/en/readme.md index d83c685..6d98c59 100644 --- a/addon/doc/en/readme.md +++ b/addon/doc/en/readme.md @@ -31,6 +31,7 @@ A line with a problem plays a low buzz after its level tone. See Problems below. ## Editing in Windows 10 Notepad - **Automatic indentation**: Enter keeps the line's indentation, adds a level after an opening bracket (or `:` in Python), and goes back a level after `return`, `pass`, `break`, `continue` or `raise` in Python. Enter between a pair such as `{}` puts the closing bracket on its own line and the caret on the line between. A closing bracket typed first on a line moves back to line up with its opening bracket. +- **Undo and redo many steps**: control+Z goes back one step at a time, as far as you like, and control+Y (or control+shift+Z) goes forward again. A step is a run of typing, which ends when you pause for a second or move to another line, or one Code Compass command such as a line move, a comment or a rename. The line that changed is read. Going back to the text that is saved clears the "*" in the title. The steps last while the file is open; Notepad itself keeps only one. - **Closing brackets and quotes** (off by default, see Settings): typing `(` adds `)` after the caret; typing the closing one over it does not double it. - **Tab and shift+Tab** with several lines selected indent or outdent them. Shift+Tab with nothing selected outdents the caret's line. A plain Tab still types a tab. - **control+/** comments or uncomments the selected lines, or the caret's line, with the language's comment (`#`, `//`, `--`). @@ -164,6 +165,7 @@ NVDA menu, Preferences, Settings, Code Compass: - Examine code while typing: off, play a sound for new problems (the default), or play a sound and say them. - Say the function or class the caret moves into. - Automatic indentation on Enter and closing brackets (Windows 10 Notepad). +- Undo and redo many steps with control+Z and control+Y (Windows 10 Notepad). On by default. - Add closing brackets and quotes automatically (Windows 10 Notepad). Off by default. Typing the closing character yourself types over the one that was added, and Backspace right after an opening bracket removes both. - Tone pitch for level 1, and how many semitones higher each level is. - Applications with level tones and quick keys. The NVDA+shift+K commands work in any text editor. @@ -174,6 +176,6 @@ NVDA menu, Preferences, Settings, Code Compass: - Windows 11's Notepad is a different program: level tones, navigation and reading commands work there, but commands that change text do not. - VS Code only exposes part of a file to screen readers, so levels there can be wrong. VS Code is not enabled by default. - The outline and function moves find declarations with patterns, not a full parser. Unusual code styles may be missed. -- Notepad keeps only one step of undo: control+Z undoes the last Code Compass change, not the one before. +- Undo steps are kept while NVDA runs: after restarting NVDA, or with the setting turned off, control+Z is Notepad's own single step again. - Python syntax errors come from Python's own parser, which reports only the first one in a file. - Code inside strings is understood for Python f-strings, JavaScript template literals and Swift's \\( ). In other languages' interpolated strings, such as C#'s `$"{name}"` or Kotlin's and Dart's `"${name}"`, the name is text: rename, uses and completion skip it there. diff --git a/addon/doc/id/readme.md b/addon/doc/id/readme.md index 2ce8e49..1b0c1b9 100644 --- a/addon/doc/id/readme.md +++ b/addon/doc/id/readme.md @@ -31,6 +31,7 @@ Baris yang ada masalahnya bunyi dengung rendah sesudah nada levelnya. Lihat bagi ## Ngedit di Notepad Windows 10 - **Indentasi otomatis**: Enter ngikutin indentasi baris sekarang, nambah satu level sesudah kurung buka (atau `:` di Python), dan mundur satu level sesudah `return`, `pass`, `break`, `continue`, atau `raise` di Python. Enter di antara pasangan kayak `{}` naruh kurung tutupnya di baris sendiri, dan kursornya di baris tengah. Kurung tutup yang diketik paling awal di sebuah baris otomatis mundur sejajar sama kurung bukanya. +- **Undo dan redo berkali-kali**: control+Z mundur satu langkah, sejauh yang kamu mau, dan control+Y (atau control+shift+Z) maju lagi. Satu langkah itu satu rombongan ketikan (selesai kalau kamu berhenti sedetik atau pindah baris), atau satu perintah Code Compass, misalnya pindah baris, komentar, atau rename. Baris yang berubah dibacain. Kalau balik ke teks yang udah disimpan, tanda "*" di judul ilang. Langkah-langkahnya ada selama file-nya kebuka; Notepad sendiri cuma nyimpen satu. - **Kurung tutup dan kutip otomatis** (mati secara default, lihat Pengaturan): ngetik `(` langsung nambah `)` sesudah kursor, dan ngetik kurung tutupnya nggak bikin dobel. - **Tab dan shift+Tab** pas beberapa baris dipilih: nambah atau ngurangin indentasi baris-baris itu. Shift+Tab tanpa pilihan ngurangin indentasi baris kursor. Tab biasa tetap ngetik tab. - **control+/**: ngomentarin atau batalin komentar di baris yang dipilih, atau di baris kursor, pakai tanda komentar bahasanya (`#`, `//`, `--`). @@ -164,6 +165,7 @@ Menu NVDA, Preferences, Settings, Code Compass: - Periksa kode saat mengetik: mati, bunyiin suara buat masalah baru (default), atau bunyiin suara sekalian dibacain. - Sebutin fungsi atau class yang dimasuki kursor. - Indentasi otomatis saat Enter dan kurung tutup (Notepad Windows 10). +- Undo dan redo berkali-kali dengan control+Z dan control+Y (Notepad Windows 10). Nyala secara default. - Tambahin kurung tutup dan kutip otomatis (Notepad Windows 10). Mati secara default. Kalau kamu ngetik sendiri karakter penutupnya, yang tadi ditambahin bakal ditimpa, dan Backspace persis setelah kurung buka ngapus dua-duanya. - Tinggi nada level 1, dan berapa semitone naiknya tiap level. - Aplikasi yang dapat nada level dan tombol cepat. Perintah lewat NVDA+shift+K jalan di semua editor teks. @@ -174,6 +176,6 @@ Menu NVDA, Preferences, Settings, Code Compass: - Notepad Windows 11 itu program yang beda: nada level, navigasi, dan perintah yang cuma baca jalan di sana, tapi perintah yang ngubah teks nggak. - VS Code cuma ngasih sebagian isi file ke screen reader, jadi levelnya bisa salah di sana. VS Code nggak aktif secara default. - Outline dan lompat per fungsi nyari deklarasi pakai pola, bukan parser lengkap. Gaya penulisan kode yang nggak biasa bisa kelewat. -- Notepad cuma bisa undo satu langkah: control+Z ngebatalin perubahan Code Compass yang terakhir, bukan yang sebelumnya. +- Langkah undo disimpan selama NVDA jalan: habis NVDA di-restart, atau kalau pengaturannya dimatiin, control+Z balik jadi undo bawaan Notepad yang cuma satu langkah. - Error sintaks Python dicek pakai parser Python sendiri, yang cuma ngelaporin error pertama di satu file. - Kode di dalam string dikenali buat f-string Python, template literal JavaScript, dan \\( ) di Swift. Di string interpolasi bahasa lain, misalnya `$"{name}"` di C# atau `"${name}"` di Kotlin dan Dart, namanya dianggap teks: rename, daftar pemakaian, dan autocomplete ngelewatin nama di situ. diff --git a/addon/globalPlugins/codeCompass/__init__.py b/addon/globalPlugins/codeCompass/__init__.py index cfa5ba5..903022c 100644 --- a/addon/globalPlugins/codeCompass/__init__.py +++ b/addon/globalPlugins/codeCompass/__init__.py @@ -40,7 +40,7 @@ from scriptHandler import isScriptWaiting, script from textInfos.offsets import Offsets, OffsetsTextInfo -from . import analyzer, bookmarks, edit_control, editing, filepath, problems, programs, snippets +from . import analyzer, bookmarks, edit_control, editing, filepath, problems, programs, snippets, undo addonHandler.initTranslation() #: Translator for NVDA's language, installed by initTranslation. @@ -60,6 +60,7 @@ "bookmarkSound": "boolean(default=True)", "soundStyle": "option('vscode', 'beeps', default='vscode')", "liveCheck": "option('off', 'sound', 'speech', default='sound')", + "multiUndo": "boolean(default=True)", "announceDeclarations": "boolean(default=True)", "autoClose": "boolean(default=False)", "basePitch": "integer(default=330, min=100, max=2000)", @@ -698,7 +699,7 @@ def _after_typed_character(obj, ch): if editing.stale_apostrophe(a, caret, ch): # "'a " (a lifetime) cannot be a character literal: the apostrophe # auto-close added after it goes. - if not _apply_edit(obj, a, caret, caret + 1, "", caret): + if not _apply_edit(obj, a, caret, caret + 1, "", caret, typing=True): return pending[:] = [p for p in pending if p[1] != lineEnd - caret] a = _cache.get(obj) @@ -706,13 +707,13 @@ def _after_typed_character(obj, ch): mine = (li, lineEnd - caret, ch) if a.text[caret:caret + 1] == ch and (mine in pending or editing.autoclose_skip(a, caret, ch)): # Typing over the closing character auto-close added. - if not _apply_edit(obj, a, caret, caret + 1, "", caret): + if not _apply_edit(obj, a, caret, caret + 1, "", caret, typing=True): return if mine in pending: pending.remove(mine) a = _cache.get(obj) elif editing.should_autoclose(a, caret, ch): - if _apply_edit(obj, a, caret, caret, editing.AUTO_PAIRS[ch], caret): + if _apply_edit(obj, a, caret, caret, editing.AUTO_PAIRS[ch], caret, typing=True): pending.append((li, lineEnd + 1 - caret, editing.AUTO_PAIRS[ch])) return if ch not in analyzer.CLOSERS: @@ -722,7 +723,7 @@ def _after_typed_character(obj, ch): if change: start, end, indent = change newCaret = start + len(indent) + (caret - end) - if _apply_edit(obj, a, start, end, indent, newCaret): + if _apply_edit(obj, a, start, end, indent, newCaret, typing=True): a = _cache.get(obj) off = newCaret - 1 if c["announceClosers"]: @@ -735,6 +736,94 @@ def _after_typed_character(obj, ch): _autoClosed = {} +# --- Undo and redo ---------------------------------------------------------------- + +#: Window handle -> {"title", "history"}: the shown document's undo steps. +_undoHistories = {} +#: Window handle -> number of the latest scheduled typing stop. +_undoStops = {} +#: A pause in typing this long ends an undo step. +UNDO_PAUSE_MS = 1000 + + +def _undo_enabled(obj): + return _conf()["multiUndo"] and _writable_notepad(obj) + + +def _undo_history(obj, text): + """The undo history of the document obj shows, started on text when new. + Another document in the window (a new title that is not this one saved + under a new name) starts a new history.""" + hwnd = getattr(obj, "windowHandle", None) + title = _window_title(obj).lstrip("*") + entry = _undoHistories.get(hwnd) + if entry is not None and entry["title"] != title: + snapshot = _saveSnapshots.get(hwnd) + if snapshot is not None and snapshot[0] == entry["title"] and snapshot[1] == entry["history"].base: + # Saved under a new name: the same document. + entry["title"] = title + else: + entry = None + if entry is None: + entry = _undoHistories[hwnd] = {"title": title, "history": undo.History(text)} + return entry["history"] + + +def _undo_record(obj, text=None): + """An undo stop: note the document's text as one step.""" + try: + if not _undo_enabled(obj): + return + if text is None: + text = _cache.get(obj).text + history = _undo_history(obj, text) + if text != history.base and edit_control.is_modified(obj.windowHandle) is False: + # Changed, yet Notepad calls it unchanged: another file was opened + # (or saved through the menu). Undoing into it would bring back + # the other text, so the history starts again here. + history.reset(text) + return + history.record(text) + except Exception: + log.debugWarning("Code Compass: recording an undo step failed", exc_info=True) + + +def _schedule_undo_stop(obj): + if not _conf()["multiUndo"]: + return + hwnd = getattr(obj, "windowHandle", None) + token = _undoStops[hwnd] = _undoStops.get(hwnd, 0) + 1 + core.callLater(UNDO_PAUSE_MS, _undo_pause, obj, token) + + +def _undo_pause(obj, token): + if _undoStops.get(getattr(obj, "windowHandle", None)) == token: + _undo_record(obj) + + +def _undo_redo(obj, redo): + """Undo or redo one step in obj. False when there was nothing to do.""" + a = _cache.get(obj) + history = _undo_history(obj, a.text) + change = history.redo(a.text) if redo else history.undo(a.text) + if change is None: + return False + start, end, replacement = change + if not _apply_edit(obj, a, start, end, replacement, start + len(replacement), typing=True): + # Notepad refused the change: the history no longer matches it. + history.reset(_cache.get(obj).text) + return True + state = _disk.get(getattr(obj, "windowHandle", None)) + if state and state.get("text") is not None and filepath.same_text(history.base, state["text"]): + # Back to the text on disk: no "*" in the title. + try: + edit_control.mark_unmodified(obj.windowHandle) + except Exception: + pass + _speak_caret_line(obj) + return True + + def _delete_auto_pair(obj): """Backspace between an opener and the closer auto-close added for it ("(|)"): delete both. True when done.""" @@ -753,7 +842,7 @@ def _delete_auto_pair(obj): pending = _autoClosed.get(hwnd, []) if entry not in pending: return False - if not _apply_edit(obj, a, caret - 1, caret + 1, "", caret - 1): + if not _apply_edit(obj, a, caret - 1, caret + 1, "", caret - 1, typing=True): return False pending.remove(entry) speech.speakSpelling(ch) @@ -871,6 +960,7 @@ def _before_save(obj): hwnd = getattr(obj, "windowHandle", None) shown = _window_title(obj) _saveSnapshots[hwnd] = (shown.lstrip("*"), a.text, shown) + _undo_record(obj, a.text) # Bookmarks match against this very text. _bookmarks_for(obj, a) _history_for(obj) @@ -1229,10 +1319,14 @@ def _live_check(obj, token): # --- Editing Notepad ---------------------------------------------------------------- -def _apply_edit(obj, a, start, end, newText, selStart=None, selEnd=None, timeout=None): +def _apply_edit(obj, a, start, end, newText, selStart=None, selEnd=None, timeout=None, typing=False): """Replace a.text[start:end] with newText in a writable Win32 edit control, then select selStart..selEnd (offsets in the new text). One - step for control+Z. True on success.""" + step for control+Z. True on success. A command's edit is an undo step + of its own; with typing (auto-close, indentation while typing), it + joins the typing around it.""" + if not typing: + _undo_record(obj, a.text) try: extra = {"timeout": timeout} if timeout else {} ok = edit_control.replace(obj.windowHandle, a.text, start, end, newText, selStart, selEnd, **extra) @@ -1241,6 +1335,8 @@ def _apply_edit(obj, a, start, end, newText, selStart=None, selEnd=None, timeout ok = False if ok: _forget_selection_change(obj) + if not typing: + _undo_record(obj, a.text[:start] + newText + a.text[end:]) return ok @@ -1299,6 +1395,9 @@ def _codeCompassLineReports(self, info=None): def _caretScriptPostMovedHelper(self, speakUnit, gesture, info=None): _autoClosed.pop(getattr(self, "windowHandle", None), None) + if speakUnit == textInfos.UNIT_LINE: + # Moving to another line ends a run of typing, as in VS Code. + _undo_record(self) after = [] if speakUnit == textInfos.UNIT_LINE and not isScriptWaiting(): after = self._codeCompassLineReports(info) @@ -1310,6 +1409,7 @@ def _caretScriptPostMovedHelper(self, speakUnit, gesture, info=None): def event_typedCharacter(self, ch): super(CodeEditor, self).event_typedCharacter(ch) _schedule_live_check(self) + _schedule_undo_stop(self) c = _conf() if ch in analyzer.CLOSERS or (c["autoClose"] and ch in editing.AUTO_PAIRS): # Let the editor insert the character first. @@ -1332,7 +1432,7 @@ def script_ccEnter(self, gesture): # does on Enter when "Speak typed words" is on. speech.speakTypedCharacters("\r") selStart = start + caretIn if caretIn is not None else None - if not _apply_edit(self, a, start, end, text, selStart): + if not _apply_edit(self, a, start, end, text, selStart, typing=True): return self.script_caret_newLine(gesture) _speak_caret_line(self, gesture) @@ -1479,6 +1579,37 @@ def script_ccGoBack(self, gesture): def script_ccGoForward(self, gesture): _delegate("goForward", gesture) + def script_ccUndo(self, gesture): + if not _undo_enabled(self): + gesture.send() + return + try: + if _undo_redo(self, False): + return + if not _undo_history(self, _cache.get(self).text).steps: + # Nothing recorded yet: Notepad's own single step. + gesture.send() + return + except Exception: + log.debugWarning("Code Compass: undo failed", exc_info=True) + gesture.send() + return + # Translators: control+Z with no earlier step. + ui.message(_("Nothing to undo")) + + def script_ccRedo(self, gesture): + if not _undo_enabled(self): + gesture.send() + return + try: + if _undo_redo(self, True): + return + except Exception: + log.debugWarning("Code Compass: redo failed", exc_info=True) + return + # Translators: control+Y with nothing undone to bring back. + ui.message(_("Nothing to redo")) + def script_ccBackspace(self, gesture): if _conf()["autoClose"] and _writable_notepad(self): try: @@ -1509,6 +1640,9 @@ def script_ccSaveAs(self, gesture): "kb:alt+end": "ccBlockEnd", "kb:control+s": "ccSave", "kb:backspace": "ccBackspace", + "kb:control+z": "ccUndo", + "kb:control+y": "ccRedo", + "kb:control+shift+z": "ccRedo", "kb:control+shift+s": "ccSaveAs", "kb:enter": "ccEnter", "kb:numpadEnter": "ccEnter", @@ -3069,7 +3203,7 @@ def script_complete(self, gesture): start = state["start"] # After the last suggestion comes back what was typed. word = state["prefix"] if index == len(words) else words[index] - if not _apply_edit(obj, a, start, caret, word, start + len(word)): + if not _apply_edit(obj, a, start, caret, word, start + len(word), typing=True): return state.update(index=index, text=a.text[:start] + word + a.text[caret:], caret=start + len(word)) self._completion = state @@ -3941,6 +4075,11 @@ def makeSettings(self, settingsSizer): wx.CheckBox(self, label=_("Add closing brackets and "es automatically (Windows 10 Notepad)")), ) self.autoClose.SetValue(c["autoClose"]) + self.multiUndo = helper.addItem( + # Translators: checkbox for undo and redo of many steps in Notepad. + wx.CheckBox(self, label=_("Undo and redo many steps with control+Z and control+Y (Windows &10 Notepad)")), + ) + self.multiUndo.SetValue(c["multiUndo"]) self.basePitch = helper.addLabeledControl( # Translators: pitch of the level 1 tone. _("Tone pitc&h for level 1 (Hz):"), @@ -3983,6 +4122,7 @@ def onSave(self): c["announceDeclarations"] = self.announceDeclarations.GetValue() c["autoIndent"] = self.autoIndent.GetValue() c["autoClose"] = self.autoClose.GetValue() + c["multiUndo"] = self.multiUndo.GetValue() c["basePitch"] = self.basePitch.GetValue() c["semitonesPerLevel"] = self.semitones.GetValue() c["apps"] = self.apps.GetValue().strip() diff --git a/addon/globalPlugins/codeCompass/edit_control.py b/addon/globalPlugins/codeCompass/edit_control.py index 34dddd0..53dae97 100644 --- a/addon/globalPlugins/codeCompass/edit_control.py +++ b/addon/globalPlugins/codeCompass/edit_control.py @@ -46,7 +46,15 @@ def is_single_line(windowClassName, hwnd): return False +EM_GETMODIFY = 0x00B8 EM_SETMODIFY = 0x00B9 + + +def is_modified(hwnd): + """The edit control's own "changed since saved" flag: True, False, or + None when it cannot be read.""" + result = _send(hwnd, EM_GETMODIFY, 0, 0) + return None if result is None else bool(result) WM_COMMAND = 0x0111 EN_CHANGE = 0x0300 diff --git a/addon/globalPlugins/codeCompass/undo.py b/addon/globalPlugins/codeCompass/undo.py new file mode 100644 index 0000000..11967f9 --- /dev/null +++ b/addon/globalPlugins/codeCompass/undo.py @@ -0,0 +1,117 @@ +# -*- coding: UTF-8 -*- +# Code Compass - undo and redo many steps. Windows 10's Notepad keeps only +# one step (its second control+Z redoes the first). Code Compass records the +# document's text at undo stops (a pause in typing, a caret move to another +# line, its own commands) and keeps only what changed between two stops. +# No NVDA imports. + +#: Steps kept per document; the oldest go first. +MAX_STEPS = 300 +#: Characters kept in all steps of a document together. +MAX_CHARS = 20 * 1000 * 1000 + + +class Step(object): + """One change: text[start:start + len(old)] became new.""" + __slots__ = ("start", "old", "new") + + def __init__(self, start, old, new): + self.start = start + self.old = old + self.new = new + + +def diff(before, after): + """The Step that turns before into after (the part between their common + start and end), or None when they are equal.""" + if before == after: + return None + n = min(len(before), len(after)) + prefix = 0 + while prefix < n and before[prefix] == after[prefix]: + prefix += 1 + suffix = 0 + while ( + suffix < n - prefix + and before[len(before) - 1 - suffix] == after[len(after) - 1 - suffix] + ): + suffix += 1 + # Keep a "\r\n" whole: a step never starts or ends between its halves. + while prefix and before[prefix - 1] == "\r" and before[prefix:prefix + 1] == "\n": + prefix -= 1 + return Step(prefix, before[prefix:len(before) - suffix], after[prefix:len(after) - suffix]) + + +class History(object): + """The undo steps of one document. base is the text as of the last + stop; steps[:index] can be undone, steps[index:] redone.""" + + def __init__(self, text): + self.base = text + self.steps = [] + self.index = 0 + + def reset(self, text): + self.base = text + self.steps = [] + self.index = 0 + + def record(self, text): + """An undo stop: text is the document now. True when it changed.""" + step = diff(self.base, text) + if step is None: + return False + del self.steps[self.index:] + self.steps.append(step) + self.index = len(self.steps) + self.base = text + self._trim() + return True + + def _trim(self): + size = sum(len(s.old) + len(s.new) for s in self.steps) + while self.steps and (len(self.steps) > MAX_STEPS or size > MAX_CHARS): + first = self.steps.pop(0) + size -= len(first.old) + len(first.new) + self.index -= 1 + + def can_undo(self): + return self.index > 0 + + def can_redo(self): + return self.index < len(self.steps) + + def undo(self, text): + """Undo one step from text (the document now; typing since the last + stop becomes a step of its own first). Returns (start, end, + replacement): replace text[start:end] with replacement. None when + there is nothing to undo.""" + self.record(text) + if not self.index: + return None + step = self.steps[self.index - 1] + end = step.start + len(step.new) + if self.base[step.start:end] != step.new: + # The text no longer matches the history: start over. + self.reset(self.base) + return None + self.index -= 1 + self.base = self.base[:step.start] + step.old + self.base[end:] + return step.start, end, step.old + + def redo(self, text): + """Redo one step, like undo. None when there is nothing to redo, or + when the text changed since the last undo (which ends redoing).""" + if text != self.base: + self.record(text) + return None + if self.index >= len(self.steps): + return None + step = self.steps[self.index] + end = step.start + len(step.old) + if self.base[step.start:end] != step.old: + self.reset(self.base) + return None + self.index += 1 + self.base = self.base[:step.start] + step.new + self.base[end:] + return step.start, end, step.new diff --git a/addon/locale/id/LC_MESSAGES/nvda.po b/addon/locale/id/LC_MESSAGES/nvda.po index 9b659a5..999240b 100644 --- a/addon/locale/id/LC_MESSAGES/nvda.po +++ b/addon/locale/id/LC_MESSAGES/nvda.po @@ -70,6 +70,12 @@ msgstr "{count} masalah: {list}" msgid "Saved, no problems" msgstr "Tersimpan, tidak ada masalah" +msgid "Nothing to undo" +msgstr "Tidak ada yang bisa dibatalkan" + +msgid "Nothing to redo" +msgstr "Tidak ada yang bisa diulang" + msgid "&Declarations:" msgstr "&Deklarasi:" @@ -610,6 +616,9 @@ msgstr "&Indentasi otomatis saat Enter dan kurung tutup (Notepad Windows 10)" msgid "Add closing brackets and "es automatically (Windows 10 Notepad)" msgstr "Tambahkan kurung tutup dan k&utip otomatis (Notepad Windows 10)" +msgid "Undo and redo many steps with control+Z and control+Y (Windows &10 Notepad)" +msgstr "Undo dan red&o berkali-kali dengan control+Z dan control+Y (Notepad Windows 10)" + msgid "level {level}" msgstr "level {level}" diff --git a/buildVars.py b/buildVars.py index 4e76d25..d967296 100644 --- a/buildVars.py +++ b/buildVars.py @@ -18,7 +18,7 @@ "(automatic indentation, comments, completion, line moves, rename)." ), # version - "addon_version": "0.4.0", + "addon_version": "0.4.1", # Author(s) "addon_author": "Rafli I. ", # URL for the add-on documentation support diff --git a/tests/test_review_040.py b/tests/test_review_040.py index 5931e1f..b83ff3b 100644 --- a/tests/test_review_040.py +++ b/tests/test_review_040.py @@ -280,6 +280,7 @@ def test_multi_line_message(self): "Say the &function or class the caret moves into", "Automatic &indentation on Enter and closing brackets (Windows 10 Notepad)", "Add closing brackets and "es automatically (Windows 10 Notepad)", + "Undo and redo many steps with control+Z and control+Y (Windows &10 Notepad)", "Tone pitc&h for level 1 (Hz):", "Semi&tones higher per level:", "Applications with level tones and quick keys (co&mma separated):", "Lan&guage of Code Compass messages:", diff --git a/tests/test_undo.py b/tests/test_undo.py new file mode 100644 index 0000000..77cd673 --- /dev/null +++ b/tests/test_undo.py @@ -0,0 +1,211 @@ +# -*- coding: UTF-8 -*- +# Undo and redo of many steps in Windows 10's Notepad. + +import os +import sys +import types +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "addon", "globalPlugins", "codeCompass")) +import test_plugin_v040 as v040 # noqa: E402 + +codeCompass = v040.codeCompass +MODS = v040.MODS +CONF = v040.CONF +Gesture = v040.Gesture + +import undo # noqa: E402 + + +def apply(text, change): + start, end, replacement = change + return text[:start] + replacement + text[end:] + + +class HistoryTests(unittest.TestCase): + def test_undo_and_redo_steps(self): + h = undo.History("a\r\n") + for text in ("ab\r\n", "abc\r\n", "x\r\nabc\r\n"): + h.record(text) + text = "x\r\nabc\r\n" + seen = [] + while True: + change = h.undo(text) + if change is None: + break + text = apply(text, change) + seen.append(text) + self.assertEqual(seen, ["abc\r\n", "ab\r\n", "a\r\n"]) + for want in ("ab\r\n", "abc\r\n", "x\r\nabc\r\n"): + text = apply(text, h.redo(text)) + self.assertEqual(text, want) + self.assertIsNone(h.redo(text)) + + def test_typing_since_the_last_stop_is_a_step(self): + h = undo.History("a") + h.record("ab") + # "c" was typed with no stop yet: undo takes it back first. + self.assertEqual(apply("abc", h.undo("abc")), "ab") + self.assertEqual(apply("ab", h.undo("ab")), "a") + + def test_new_typing_ends_redo(self): + h = undo.History("a") + h.record("ab") + text = apply("ab", h.undo("ab")) + self.assertIsNone(h.redo(text + "z")) + self.assertFalse(h.can_redo()) + self.assertEqual(apply("az", h.undo("az")), "a") + + def test_limits(self): + h = undo.History("") + for i in range(undo.MAX_STEPS + 50): + h.record("x" * (i + 1)) + self.assertEqual(len(h.steps), undo.MAX_STEPS) + self.assertEqual(h.index, undo.MAX_STEPS) + + def test_crlf_stays_whole(self): + step = undo.diff("a\r\nb", "a\r\n\r\nb") + self.assertEqual("a\r\nb"[:step.start] + step.new + "a\r\nb"[step.start + len(step.old):], "a\r\n\r\nb") + self.assertNotEqual("a\r\n\r\nb"[step.start - 1:step.start + 1], "\r\n"[0:1] + "\n") + + +PY = "def a():\r\n pass\r\n" + + +class NotepadTests(v040.V040Tests): + def setUp(self): + super(NotepadTests, self).setUp() + codeCompass._undoHistories.clear() + codeCompass._undoStops.clear() + codeCompass._disk.clear() + patcher = mock.patch.object(codeCompass.edit_control, "is_modified", return_value=True) + self.modified = patcher.start() + self.addCleanup(patcher.stop) + mark = mock.patch.object(codeCompass.edit_control, "mark_unmodified") + self.mark = mark.start() + self.addCleanup(mark.stop) + self.use(PY, "main.py - Notepad") + codeCompass._undo_record(self.ed) + + def type(self, chars, pause=True): + for ch in chars: + self.ed.text = self.ed.text[:self.ed.caret] + ch + self.ed.text[self.ed.caret:] + self.ed.caret += 1 + self.ed.selection = (self.ed.caret, self.ed.caret) + codeCompass._after_typed_character(self.ed, ch) + if pause: + codeCompass._undo_record(self.ed) + + def undo(self): + gesture = Gesture("kb:control+z") + self.ed.script_ccUndo(gesture) + return gesture + + def redo(self): + gesture = Gesture("kb:control+y") + self.ed.script_ccRedo(gesture) + return gesture + + def test_many_steps_back_and_forward(self): + self.ed.caret = len(PY) + self.type("x = 1") + self.type("\r\ny = 2") + self.type("\r\nz = 3", pause=False) + self.undo() + self.assertEqual(self.ed.text, PY + "x = 1\r\ny = 2") + self.undo() + self.assertEqual(self.ed.text, PY + "x = 1") + self.undo() + self.assertEqual(self.ed.text, PY) + self.redo() + self.redo() + self.assertEqual(self.ed.text, PY + "x = 1\r\ny = 2") + self.assertEqual(self.ed.caret, len(self.ed.text)) + + def test_command_is_one_step(self): + self.at(" pass") + self.plugin.script_deleteLine(None) + self.assertEqual(self.ed.text, "def a():\r\n") + self.undo() + self.assertEqual(self.ed.text, PY) + + def test_auto_close_joins_the_typing(self): + CONF["codeCompass"]["autoClose"] = True + self.ed.caret = len(PY) + self.type("print(") + self.assertEqual(self.ed.text, PY + "print()") + self.undo() + self.assertEqual(self.ed.text, PY) + + def test_nothing_recorded_leaves_it_to_notepad(self): + gesture = self.undo() + gesture.send.assert_called_once() + + def test_nothing_more_to_undo_or_redo(self): + self.ed.caret = len(PY) + self.type("x") + self.undo() + gesture = self.undo() + gesture.send.assert_not_called() + self.assertEqual(self.message(), "Nothing to undo") + self.redo() + self.redo() + self.assertEqual(self.message(), "Nothing to redo") + + def test_back_to_the_saved_text_clears_the_star(self): + codeCompass._disk[7] = {"title": "main.py - Notepad", "path": "x", "stamp": None, "text": PY} + self.ed.caret = len(PY) + self.type("x") + self.undo() + self.mark.assert_called_once_with(7) + + def test_another_document_starts_a_new_history(self): + self.ed.caret = len(PY) + self.type("x") + self.use("other\r\n", "other.py - Notepad") + gesture = self.undo() + gesture.send.assert_called_once() + self.assertEqual(self.ed.text, "other\r\n") + + def test_file_open_of_a_same_named_file(self): + self.ed.caret = len(PY) + self.type("x") + # File > Open of another main.py: Notepad calls it unchanged. + self.modified.return_value = False + self.use("print('B')\r\n", "main.py - Notepad") + self.ed.caret = 0 + self.ed._caretScriptPostMovedHelper(v040.base.UNIT_LINE, None, None) + self.modified.return_value = True + gesture = self.undo() + self.assertEqual(self.ed.text, "print('B')\r\n") + gesture.send.assert_called_once() + + def test_turned_off(self): + CONF["codeCompass"]["multiUndo"] = False + self.ed.caret = len(PY) + self.type("x") + gesture = self.undo() + gesture.send.assert_called_once() + self.assertEqual(self.ed.text, PY + "x") + + def test_not_in_other_editors(self): + with mock.patch.object(codeCompass, "_writable_notepad", return_value=False): + gesture = self.undo() + gesture.send.assert_called_once() + + def test_keys(self): + gestures = codeCompass.CodeEditor._CodeEditor__gestures + self.assertEqual(gestures["kb:control+z"], "ccUndo") + self.assertEqual(gestures["kb:control+y"], "ccRedo") + self.assertEqual(gestures["kb:control+shift+z"], "ccRedo") + + +for _name in list(vars(v040.V040Tests)): + if _name.startswith("test_") and _name not in vars(NotepadTests): + setattr(NotepadTests, _name, None) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/update_po.py b/tools/update_po.py index c5b80ac..009fbc4 100644 --- a/tools/update_po.py +++ b/tools/update_po.py @@ -269,6 +269,11 @@ "1 second": "1 detik", "line {line}, warning: {message}": "baris {line}, peringatan: {message}", "Put the caret inside the parentheses of a call": "Taruh kursor di dalam kurung pemanggilan fungsi", + # Undo. + "Nothing to undo": "Tidak ada yang bisa dibatalkan", + "Nothing to redo": "Tidak ada yang bisa diulang", + "Undo and redo many steps with control+Z and control+Y (Windows &10 Notepad)": + "Undo dan red&o berkali-kali dengan control+Z dan control+Y (Notepad Windows 10)", # Reload. "only spacing changed": "cuma spasi yang berubah", "Reloads the file from disk, after asking when it has unsaved changes": From 0b44189159813eaae75a57ca2efecd7f6b595420 Mon Sep 17 00:00:00 2001 From: "Rafli I." Date: Thu, 8 Oct 2026 12:50:10 +0700 Subject: [PATCH 2/2] fix: keep undo histories to their own document Review fixes for the many-step undo: - A document replaced through File > Open or File > New (Notepad calls it unchanged) starts a new history when the editor gets focus and before undo or redo, so control+Z never brings back the previous file's text. A save under a new name keeps the history only for that very text, and the save's note is dropped once the save is handled. - Histories belong to Notepad's own top-level window and title, not to the window in front (Find, Save As, another program) or to the edit control, which Word Wrap replaces. - The history starts when the editor gets focus, so the first typing in a document can be undone. - The common start and end of two texts are found block by block, so a step in a large file costs little. Co-Authored-By: Claude Opus 5.5 --- addon/globalPlugins/codeCompass/__init__.py | 53 +++++++++---- .../globalPlugins/codeCompass/edit_control.py | 21 +++++ addon/globalPlugins/codeCompass/undo.py | 36 ++++++--- tests/test_undo.py | 76 +++++++++++++++++++ 4 files changed, 163 insertions(+), 23 deletions(-) diff --git a/addon/globalPlugins/codeCompass/__init__.py b/addon/globalPlugins/codeCompass/__init__.py index 903022c..98536b6 100644 --- a/addon/globalPlugins/codeCompass/__init__.py +++ b/addon/globalPlugins/codeCompass/__init__.py @@ -750,25 +750,49 @@ def _undo_enabled(obj): return _conf()["multiUndo"] and _writable_notepad(obj) +def _undo_key(obj): + """(window, title) of the document obj shows: Notepad's own top-level + window and title, not whatever window is in front (a Find dialog, or + another program after alt+tab), and the same when Word Wrap replaces + the edit control.""" + hwnd = getattr(obj, "windowHandle", None) + root, title = edit_control.top_window(hwnd) + if root is None or title is None: + root, title = hwnd, _window_title(obj) + return root, title.lstrip("*") + + def _undo_history(obj, text): """The undo history of the document obj shows, started on text when new. Another document in the window (a new title that is not this one saved under a new name) starts a new history.""" - hwnd = getattr(obj, "windowHandle", None) - title = _window_title(obj).lstrip("*") - entry = _undoHistories.get(hwnd) + root, title = _undo_key(obj) + entry = _undoHistories.get(root) if entry is not None and entry["title"] != title: - snapshot = _saveSnapshots.get(hwnd) - if snapshot is not None and snapshot[0] == entry["title"] and snapshot[1] == entry["history"].base: - # Saved under a new name: the same document. + snapshot = _saveSnapshots.get(getattr(obj, "windowHandle", None)) + if ( + snapshot is not None and snapshot[0] == entry["title"] + and snapshot[1] == entry["history"].base and snapshot[1] == text + ): + # Saved under a new name: the same document, the same text. entry["title"] = title else: entry = None if entry is None: - entry = _undoHistories[hwnd] = {"title": title, "history": undo.History(text)} + entry = _undoHistories[root] = {"title": title, "history": undo.History(text)} return entry["history"] +def _undo_replaced(obj, history, text): + """True (and the history starts again on text) when the document was + replaced: changed, yet Notepad calls it unchanged, as after File > Open + or File > New. Undoing into it would bring back the other text.""" + if text != history.base and edit_control.is_modified(obj.windowHandle) is False: + history.reset(text) + return True + return False + + def _undo_record(obj, text=None): """An undo stop: note the document's text as one step.""" try: @@ -777,13 +801,8 @@ def _undo_record(obj, text=None): if text is None: text = _cache.get(obj).text history = _undo_history(obj, text) - if text != history.base and edit_control.is_modified(obj.windowHandle) is False: - # Changed, yet Notepad calls it unchanged: another file was opened - # (or saved through the menu). Undoing into it would bring back - # the other text, so the history starts again here. - history.reset(text) - return - history.record(text) + if not _undo_replaced(obj, history, text): + history.record(text) except Exception: log.debugWarning("Code Compass: recording an undo step failed", exc_info=True) @@ -805,6 +824,8 @@ def _undo_redo(obj, redo): """Undo or redo one step in obj. False when there was nothing to do.""" a = _cache.get(obj) history = _undo_history(obj, a.text) + if _undo_replaced(obj, history, a.text): + return False change = history.redo(a.text) if redo else history.undo(a.text) if change is None: return False @@ -997,6 +1018,7 @@ def _after_save(obj, viaDialog=False): _check_after_save(obj) else: _play("save") + _saveSnapshots.pop(hwnd, None) def _check_after_save(obj): @@ -1562,6 +1584,9 @@ def script_ccGoToLine(self, gesture): def event_gainFocus(self): super(CodeEditor, self).event_gainFocus() hwnd = getattr(self, "windowHandle", None) + # Undo starts here, before any typing (and notices a document that + # File > Open or New replaced). + _undo_record(self) # Back in the editor: was the file changed by another program? core.callLater(150, _check_disk, self) before = _goToPending.pop(hwnd, None) diff --git a/addon/globalPlugins/codeCompass/edit_control.py b/addon/globalPlugins/codeCompass/edit_control.py index 53dae97..0308387 100644 --- a/addon/globalPlugins/codeCompass/edit_control.py +++ b/addon/globalPlugins/codeCompass/edit_control.py @@ -50,6 +50,27 @@ def is_single_line(windowClassName, hwnd): EM_SETMODIFY = 0x00B9 +GA_ROOT = 2 + + +def top_window(hwnd): + """(handle, title) of the top-level window holding hwnd (Notepad's own + window, whatever window is in front), or (None, None).""" + try: + user32 = ctypes.windll.user32 + user32.GetAncestor.restype = ctypes.c_void_p + user32.GetAncestor.argtypes = [ctypes.c_void_p, ctypes.c_uint] + user32.GetWindowTextW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_int] + root = user32.GetAncestor(hwnd, GA_ROOT) if hwnd else None + if not root: + return None, None + buf = ctypes.create_unicode_buffer(1024) + user32.GetWindowTextW(root, buf, 1024) + return root, buf.value + except (AttributeError, OSError): + return None, None + + def is_modified(hwnd): """The edit control's own "changed since saved" flag: True, False, or None when it cannot be read.""" diff --git a/addon/globalPlugins/codeCompass/undo.py b/addon/globalPlugins/codeCompass/undo.py index 11967f9..ee293eb 100644 --- a/addon/globalPlugins/codeCompass/undo.py +++ b/addon/globalPlugins/codeCompass/undo.py @@ -27,21 +27,39 @@ def diff(before, after): if before == after: return None n = min(len(before), len(after)) - prefix = 0 - while prefix < n and before[prefix] == after[prefix]: - prefix += 1 - suffix = 0 - while ( - suffix < n - prefix - and before[len(before) - 1 - suffix] == after[len(after) - 1 - suffix] - ): - suffix += 1 + prefix = _common_prefix(before, after, n) + suffix = _common_suffix(before, after, n - prefix) # Keep a "\r\n" whole: a step never starts or ends between its halves. while prefix and before[prefix - 1] == "\r" and before[prefix:prefix + 1] == "\n": prefix -= 1 return Step(prefix, before[prefix:len(before) - suffix], after[prefix:len(after) - suffix]) +_BLOCK = 4096 + + +def _common_prefix(a, b, n): + """Length of the common start of a and b (at most n), comparing whole + blocks first so a large file costs little.""" + i = 0 + while i + _BLOCK <= n and a[i:i + _BLOCK] == b[i:i + _BLOCK]: + i += _BLOCK + while i < n and a[i] == b[i]: + i += 1 + return i + + +def _common_suffix(a, b, n): + """Length of the common end of a and b (at most n).""" + la, lb = len(a), len(b) + i = 0 + while i + _BLOCK <= n and a[la - i - _BLOCK:la - i] == b[lb - i - _BLOCK:lb - i]: + i += _BLOCK + while i < n and a[la - 1 - i] == b[lb - 1 - i]: + i += 1 + return i + + class History(object): """The undo steps of one document. base is the text as of the last stop; steps[:index] can be undone, steps[index:] redone.""" diff --git a/tests/test_undo.py b/tests/test_undo.py index 77cd673..a713363 100644 --- a/tests/test_undo.py +++ b/tests/test_undo.py @@ -195,6 +195,82 @@ def test_not_in_other_editors(self): gesture = self.undo() gesture.send.assert_called_once() + # Review round. + + def focus(self): + with mock.patch.object(v040.base.FakeEditableText, "event_gainFocus", create=True): + self.ed.event_gainFocus() + + def test_file_open_after_save_never_brings_back_the_old_text(self): + self.ed.caret = len(PY) + self.type("x") + codeCompass._before_save(self.ed) + # File > Open other.py: Notepad calls the new document unchanged. + self.modified.return_value = False + self.use("other\r\n", "other.py - Notepad") + self.ed.caret = 0 + self.focus() + self.modified.return_value = True + gesture = self.undo() + gesture.send.assert_called_once() + self.assertEqual(self.ed.text, "other\r\n") + # Typing at the top, then undo: only that typing goes. + self.type("# ") + self.undo() + self.assertEqual(self.ed.text, "other\r\n") + + def test_file_new_in_an_untitled_document(self): + self.use("", "Untitled - Notepad") + self.focus() + self.ed.caret = 0 + self.type("secret draft") + self.modified.return_value = False + self.use("", "Untitled - Notepad") + self.ed.caret = 0 + self.focus() + self.modified.return_value = True + gesture = self.undo() + gesture.send.assert_called_once() + self.assertEqual(self.ed.text, "") + + def test_first_typing_after_opening_can_be_undone(self): + self.use("x = 1\r\n", "fresh.py - Notepad") + self.ed.caret = 0 + self.focus() + self.type("# a\r\n", pause=False) + self.type("# b\r\n") + self.undo() + self.undo() + self.assertEqual(self.ed.text, "x = 1\r\n") + + def test_a_pause_with_another_window_in_front_keeps_the_steps(self): + with mock.patch.object(codeCompass.edit_control, "top_window", return_value=(100, "main.py - Notepad")): + codeCompass._undoHistories.clear() + codeCompass._undo_record(self.ed) + self.ed.caret = len(PY) + self.type("x = 1") + self.type("\r\ny = 2", pause=False) + # The Find dialog is in front when the typing pause ends. + MODS["api"].getForegroundObject.return_value = types.SimpleNamespace(name="Find") + codeCompass._undo_record(self.ed) + MODS["api"].getForegroundObject.return_value = types.SimpleNamespace(name="*main.py - Notepad") + self.undo() + self.undo() + self.assertEqual(self.ed.text, PY) + + def test_history_belongs_to_notepads_own_window(self): + with mock.patch.object(codeCompass.edit_control, "top_window", return_value=(100, "*main.py - Notepad")): + self.assertEqual(codeCompass._undo_key(self.ed), (100, "main.py - Notepad")) + + def test_large_file_steps_are_quick(self): + import time + big = "x = 1\r\n" * 700000 + h = undo.History(big) + start = time.perf_counter() + h.record(big[:2000000] + "y" + big[2000000:]) + self.assertLess(time.perf_counter() - start, 0.2) + self.assertEqual(h.steps[-1].start, 2000000) + def test_keys(self): gestures = codeCompass.CodeEditor._CodeEditor__gestures self.assertEqual(gestures["kb:control+z"], "ccUndo")