From 22c996cc32e01c778a2518aa233548ab8e4cac1e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 14:19:19 +0800 Subject: [PATCH 01/16] Scope editor shortcuts to the focused editor A split view shows two editors of the same document at once. Their actions shared the default window-level context, which gave every editor sequence two owners in that window -- and Qt fires neither of two owners, so the whole set went dead as soon as the view was split. Tabs were never affected, since Qt skips invisible widgets when matching. This was tried once before and reverted: the suite was crashing at the time and the change looked responsible. The crash was the lint thread outliving its editor, fixed separately; with that gone, six full runs on 3.10 are clean. --- .../code/plaintext_code_edit/code_edit_plaintext.py | 11 +++++++++++ test/test_shortcut_registry.py | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py index 492a192e..30bdfc1f 100644 --- a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +++ b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py @@ -561,6 +561,16 @@ def _add_shortcut_action(self, sequence: str, command: str, handler) -> QAction: sequence is recorded here first: a clash is logged, and a test fails on it outright. + 內容脈絡設為 ``WidgetWithChildrenShortcut``,動作只在這個編輯器有焦點時作用。 + Qt 比對快捷鍵時會略過看不見的 widget,所以分頁之間本來就不會互相干擾;但分割 + 檢視是同一個視窗裡兩個**同時可見**的編輯器,用視窗層級的脈絡就會讓每一組編輯器 + 快捷鍵各有兩個擁有者,於是兩個都不執行。 + The context is ``WidgetWithChildrenShortcut`` so an action only fires while + this editor has focus. Qt already skips invisible widgets when matching, so + tabs never collide; but a split view puts two editors on screen **at once**, + and with the window-level context every editor shortcut would have two + owners and none of them would fire. + :param sequence: 按鍵組合 / the key sequence :param command: 指令名稱,用於衝突訊息 / the command name, used in clash messages :param handler: 觸發時呼叫的函式 / what to call when it fires @@ -569,6 +579,7 @@ def _add_shortcut_action(self, sequence: str, command: str, handler) -> QAction: action = QAction(self) action.setObjectName(command) action.setShortcut(sequence) + action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) action.triggered.connect(handler) owner = self.shortcut_registry.register(sequence, command) if owner is not None: diff --git a/test/test_shortcut_registry.py b/test/test_shortcut_registry.py index b2ebdcf1..133c1fcb 100644 --- a/test/test_shortcut_registry.py +++ b/test/test_shortcut_registry.py @@ -143,6 +143,18 @@ def test_no_action_takes_a_menu_sequence(self, editor): } assert taken & reserved == set() + def test_shortcuts_are_scoped_to_the_focused_editor(self, editor): + # A split view shows two editors of the same document at once. With the + # window-level default every sequence would have two owners, and Qt fires + # neither of them. + from PySide6.QtCore import Qt + contexts = { + action.shortcutContext() + for action in editor.actions() + if action.shortcut().toString() + } + assert contexts == {Qt.ShortcutContext.WidgetWithChildrenShortcut} + def test_duplicate_line_still_owns_control_d(self, editor): # Ctrl+D is handled in keyPressEvent; no action may shadow it. assert editor.shortcut_registry.owner_of("Ctrl+D") is None From 42b2faec6ced26824f85d07a4849e69647bb670e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 14:24:54 +0800 Subject: [PATCH 02/16] Make the status bar describe the tab in front of you It showed the encoding from the global setting rather than the one the tab was opened or saved with, and worked out the line ending by re-reading up to 8KB of the file from disk on every tab change. Both meant a choice made from the Encoding or Line Endings menu never reached the display, and the second put file I/O on the UI thread. Both now read the tab's own state, the menus ask for a refresh after changing it, and a language indicator sits alongside them -- twelve languages are highlighted and until now nothing said which one was in effect. --- je_editor/pyside_ui/main_ui/main_editor.py | 67 +++--- .../menu/file_menu/encoding_actions.py | 17 ++ je_editor/utils/status/__init__.py | 0 je_editor/utils/status/status_text.py | 71 +++++++ test/test_status_text.py | 193 ++++++++++++++++++ 5 files changed, 315 insertions(+), 33 deletions(-) create mode 100644 je_editor/utils/status/__init__.py create mode 100644 je_editor/utils/status/status_text.py create mode 100644 test/test_status_text.py diff --git a/je_editor/pyside_ui/main_ui/main_editor.py b/je_editor/pyside_ui/main_ui/main_editor.py index 1cc8023b..63581348 100644 --- a/je_editor/pyside_ui/main_ui/main_editor.py +++ b/je_editor/pyside_ui/main_ui/main_editor.py @@ -38,6 +38,9 @@ from je_editor.pyside_ui.main_ui.system_tray.extend_system_tray import ExtendSystemTray from je_editor.utils.file.open.open_file import read_file from je_editor.utils.session.editor_state import editor_state, restore_editor_state +from je_editor.utils.status.status_text import ( + PLAIN_TEXT, cursor_position, encoding_name, language_name, line_ending_display +) from je_editor.utils.session.open_files_session import ( SESSION_SETTING_KEY, SESSION_STATE_KEY, @@ -169,11 +172,13 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, from je_editor.pyside_ui.main_ui.toolbar.toolbar_builder import build_toolbar build_toolbar(self) - # 設定狀態列 (行/列位置、編碼、行尾) - # Setup status bar (line/column, encoding, line ending) + # 設定狀態列 (語言、行尾、編碼、行/列位置) + # Setup status bar (language, line ending, encoding, line/column) + self._language_label = QLabel(PLAIN_TEXT) self._line_ending_label = QLabel("LF") self._encoding_label = QLabel("UTF-8") self._cursor_pos_label = QLabel("Ln 1, Col 1") + self.statusBar().addPermanentWidget(self._language_label) self.statusBar().addPermanentWidget(self._line_ending_label) self.statusBar().addPermanentWidget(self._encoding_label) self.statusBar().addPermanentWidget(self._cursor_pos_label) @@ -476,9 +481,7 @@ def _on_tab_changed(self, index: int) -> None: if isinstance(widget, EditorWidget): widget.code_edit.cursorPositionChanged.connect(self._update_cursor_pos) self._prev_editor_widget = widget - self._update_cursor_pos() - self._update_encoding_label() - self._update_line_ending_label(widget) + self.refresh_status_bar() def _update_cursor_pos(self) -> None: """ @@ -488,37 +491,35 @@ def _update_cursor_pos(self) -> None: widget = self.tab_widget.currentWidget() if isinstance(widget, EditorWidget): cursor = widget.code_edit.textCursor() - line = cursor.blockNumber() + 1 - col = cursor.positionInBlock() + 1 - self._cursor_pos_label.setText(f"Ln {line}, Col {col}") + self._cursor_pos_label.setText(cursor_position( + cursor.blockNumber() + 1, cursor.positionInBlock() + 1)) - def _update_encoding_label(self) -> None: - """ - 更新狀態列的編碼顯示 - Update status bar encoding display + def refresh_status_bar(self) -> None: """ - encoding = user_setting_dict.get("encoding", "utf-8").upper() - self._encoding_label.setText(encoding) - - def _update_line_ending_label(self, widget: EditorWidget) -> None: + 依目前分頁重畫狀態列 + Redraw the status bar from the current tab. + + 顯示的是這個分頁記憶中的狀態,而不是全域設定或磁碟上的內容:使用者從選單 + 改過編碼或行尾之後,狀態列要跟著改,而不是繼續顯示檔案原本的樣子。改完 + 設定的地方要呼叫這個方法。 + What it shows is the tab's own state, not the global setting or what is on + disk: after the user changes the encoding or line ending from the menu the + status bar has to follow, rather than keep describing the file as it was + opened. Whatever changes those settings calls this. """ - 偵測並更新行尾格式 (CRLF/LF/CR) - Detect and update line ending format - """ - if widget.current_file is not None: - try: - with open(widget.current_file, "rb") as f: - raw = f.read(8192) - if b"\r\n" in raw: - self._line_ending_label.setText("CRLF") - elif b"\r" in raw: - self._line_ending_label.setText("CR") - else: - self._line_ending_label.setText("LF") - except (OSError, TypeError): - self._line_ending_label.setText("LF") - else: - self._line_ending_label.setText("LF") + widget = self.tab_widget.currentWidget() + if not isinstance(widget, EditorWidget): + self._language_label.setText(PLAIN_TEXT) + self._line_ending_label.setText(line_ending_display(None)) + self._encoding_label.setText(encoding_name(None)) + self._cursor_pos_label.setText(cursor_position(1, 1)) + return + self._language_label.setText(language_name(widget.current_file)) + self._line_ending_label.setText( + line_ending_display(getattr(widget, "line_ending", None))) + self._encoding_label.setText( + encoding_name(getattr(widget, "file_encoding", None))) + self._update_cursor_pos() def _periodic_save_settings(self) -> None: """ diff --git a/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py b/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py index 4b77a9ba..6a6c5daf 100644 --- a/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py +++ b/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py @@ -40,6 +40,20 @@ def _update_auto_save(widget) -> None: thread.line_ending = widget.line_ending +def _refresh_status_bar(ui_we_want_to_set) -> None: + """ + 讓狀態列跟上剛剛的改動 + Let the status bar follow the change just made. + + 狀態列顯示的是分頁記憶中的編碼與行尾,改完不通知它就會停在舊值上。 + The status bar shows the tab's remembered encoding and line ending, and would + otherwise sit on the old values. + """ + refresh = getattr(ui_we_want_to_set, "refresh_status_bar", None) + if callable(refresh): + refresh() + + def apply_encoding(ui_we_want_to_set, encoding: str) -> bool: """ 以指定編碼重新解讀目前檔案,並用它存檔 @@ -60,6 +74,7 @@ def apply_encoding(ui_we_want_to_set, encoding: str) -> bool: return False widget.file_encoding = encoding _update_auto_save(widget) + _refresh_status_bar(ui_we_want_to_set) if widget.current_file is None or getattr(widget, "_is_modified", False): return True try: @@ -76,6 +91,7 @@ def apply_encoding(ui_we_want_to_set, encoding: str) -> bool: widget.file_encoding = used_encoding widget.line_ending = line_ending _update_auto_save(widget) + _refresh_status_bar(ui_we_want_to_set) return True @@ -157,4 +173,5 @@ def apply_line_ending_choice(ui_we_want_to_set, line_ending: str = LINE_ENDING_L return False widget.line_ending = line_ending _update_auto_save(widget) + _refresh_status_bar(ui_we_want_to_set) return True diff --git a/je_editor/utils/status/__init__.py b/je_editor/utils/status/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/status/status_text.py b/je_editor/utils/status/status_text.py new file mode 100644 index 00000000..e8afb1d5 --- /dev/null +++ b/je_editor/utils/status/status_text.py @@ -0,0 +1,71 @@ +""" +組出狀態列要顯示的文字 +Build the text the status bar shows. + +純邏輯,不匯入 Qt,因此可以單獨測試;狀態列本身只負責把這些字串放上去。 +Pure logic with no Qt import so it can be tested on its own; the status bar +itself only has to put these strings on screen. +""" +from __future__ import annotations + +from pathlib import Path + +from je_editor.utils.encodings.text_codec import DEFAULT_ENCODING, line_ending_name +from je_editor.utils.syntax.language_rules import rules_for + +# Python 有專屬的高亮器,因此不在通用規則表中 +# Python has a highlighter of its own and so is absent from the generic rules +_PYTHON_SUFFIXES = (".py", ".pyi", ".pyw") +# 沒有對應語言時顯示的名稱 / Shown when no language is recognised +PLAIN_TEXT = "Plain Text" + + +def language_name(file_path: str | Path | None) -> str: + """ + 取得檔案對應的語言名稱 + The language name for a file. + + :param file_path: 檔案路徑,未存檔時為 ``None`` / the file, or ``None`` when unsaved + :return: 語言名稱,認不出來時為 ``Plain Text`` / the name, or ``Plain Text`` + """ + if file_path is None: + return PLAIN_TEXT + suffix = Path(str(file_path)).suffix.lower() + if suffix in _PYTHON_SUFFIXES: + return "Python" + rules = rules_for(suffix) + return rules.name if rules is not None else PLAIN_TEXT + + +def encoding_name(encoding: str | None) -> str: + """ + 取得編碼的顯示名稱 + The display name of an encoding. + + :param encoding: 編碼名稱 / the encoding + :return: 大寫的名稱 / the name in upper case + """ + return (encoding or DEFAULT_ENCODING).upper() + + +def line_ending_display(ending: str | None) -> str: + """ + 取得行尾的顯示名稱 + The display name of a line ending. + + :param ending: 行尾字串 / the line-ending string + :return: ``CRLF``、``LF`` 或 ``CR`` / one of ``CRLF``, ``LF`` or ``CR`` + """ + return line_ending_name(ending or "\n") + + +def cursor_position(line: int, column: int) -> str: + """ + 取得游標位置的顯示文字 + The display text for a caret position. + + :param line: 以 1 起算的行號 / the 1-based line number + :param column: 以 1 起算的欄號 / the 1-based column number + :return: 顯示文字 / the text to show + """ + return f"Ln {max(1, line)}, Col {max(1, column)}" diff --git a/test/test_status_text.py b/test/test_status_text.py new file mode 100644 index 00000000..8e0b8722 --- /dev/null +++ b/test/test_status_text.py @@ -0,0 +1,193 @@ +"""Tests for what the status bar says about the current tab.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from je_editor.utils.status.status_text import ( + PLAIN_TEXT, cursor_position, encoding_name, language_name, line_ending_display +) + + +class TestLanguageName: + @pytest.mark.parametrize("path,expected", [ + ("app.py", "Python"), + ("stub.pyi", "Python"), + ("script.pyw", "Python"), + ("main.rs", "Rust"), + ("index.ts", "TypeScript"), + ("data.json", "JSON"), + ("query.sql", "SQL"), + ]) + def test_a_known_suffix_names_its_language(self, path, expected): + assert language_name(path) == expected + + def test_the_suffix_is_matched_case_insensitively(self): + assert language_name("APP.PY") == "Python" + + def test_an_unknown_suffix_is_plain_text(self): + assert language_name("notes.qqq") == PLAIN_TEXT + + def test_a_file_without_a_suffix_is_plain_text(self): + assert language_name("Makefile") == PLAIN_TEXT + + def test_an_unsaved_buffer_is_plain_text(self): + assert language_name(None) == PLAIN_TEXT + + def test_a_full_path_still_resolves(self): + assert language_name("/home/user/project/main.go") == "Go" + + +class TestEncodingName: + def test_it_is_upper_cased(self): + assert encoding_name("utf-8") == "UTF-8" + + def test_nothing_falls_back_to_the_default(self): + assert encoding_name(None) == "UTF-8" + + def test_another_encoding_is_shown_as_given(self): + assert encoding_name("big5") == "BIG5" + + +class TestLineEndingDisplay: + @pytest.mark.parametrize("ending,expected", [ + ("\r\n", "CRLF"), ("\n", "LF"), ("\r", "CR"), + ]) + def test_each_ending_has_a_name(self, ending, expected): + assert line_ending_display(ending) == expected + + def test_nothing_falls_back_to_lf(self): + assert line_ending_display(None) == "LF" + + +class TestCursorPosition: + def test_it_reads_as_line_and_column(self): + assert cursor_position(3, 12) == "Ln 3, Col 12" + + def test_positions_below_one_are_clamped(self): + assert cursor_position(0, 0) == "Ln 1, Col 1" + + +@pytest.fixture() +def editor_tab(qapp, qtbot): + """An EditorWidget with a mocked main window, as the other tab tests use.""" + from PySide6.QtWidgets import QTabWidget + mock_main = MagicMock() + mock_main.working_dir = None + mock_main.tab_widget = QTabWidget() + mock_main.python_compiler = None + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + widget = EditorWidget(mock_main) + mock_main.tab_widget.addTab(widget, "Test") + qtbot.addWidget(mock_main.tab_widget) + yield mock_main, widget + widget.close() + + +def _status_window(tab_widget): + """A bare main window with just the status-bar labels the refresh touches.""" + from PySide6.QtWidgets import QLabel + from je_editor.pyside_ui.main_ui.main_editor import EditorMain + window = EditorMain.__new__(EditorMain) + window.tab_widget = tab_widget + window._language_label = QLabel() + window._line_ending_label = QLabel() + window._encoding_label = QLabel() + window._cursor_pos_label = QLabel() + return window + + +class TestTheStatusBarReadsTheTab: + """ + It has to describe the tab in front of the user. The encoding label used to + read the global setting and the line-ending label re-read the file from disk + on every tab change, so neither followed a change made from the menu. + """ + + def test_the_language_comes_from_the_file(self, editor_tab): + _window, widget = editor_tab + widget.current_file = "app.py" + window = _status_window(_window.tab_widget) + window.refresh_status_bar() + assert window._language_label.text() == "Python" + + def test_an_unsaved_buffer_is_plain_text(self, editor_tab): + _window, widget = editor_tab + widget.current_file = None + window = _status_window(_window.tab_widget) + window.refresh_status_bar() + assert window._language_label.text() == PLAIN_TEXT + + def test_the_line_ending_comes_from_the_tab(self, editor_tab): + _window, widget = editor_tab + widget.line_ending = "\r\n" + window = _status_window(_window.tab_widget) + window.refresh_status_bar() + assert window._line_ending_label.text() == "CRLF" + + def test_the_encoding_comes_from_the_tab(self, editor_tab): + _window, widget = editor_tab + widget.file_encoding = "big5" + window = _status_window(_window.tab_widget) + window.refresh_status_bar() + assert window._encoding_label.text() == "BIG5" + + def test_the_caret_position_is_shown(self, editor_tab): + _window, widget = editor_tab + widget.code_edit.setPlainText("one\ntwo\n") + cursor = widget.code_edit.textCursor() + cursor.setPosition(5) + widget.code_edit.setTextCursor(cursor) + window = _status_window(_window.tab_widget) + window.refresh_status_bar() + assert window._cursor_pos_label.text() == "Ln 2, Col 2" + + def test_a_tab_that_is_not_an_editor_shows_defaults(self, qapp, qtbot): + from PySide6.QtWidgets import QTabWidget, QWidget + tabs = QTabWidget() + qtbot.addWidget(tabs) + tabs.addTab(QWidget(), "Browser") + window = _status_window(tabs) + window.refresh_status_bar() + assert window._language_label.text() == PLAIN_TEXT + assert window._encoding_label.text() == "UTF-8" + assert window._cursor_pos_label.text() == "Ln 1, Col 1" + + +class TestRefreshingFromTheMenu: + """ + Changing the encoding or line ending has to move the status bar with it. + The labels used to read the global setting and the bytes on disk, so a menu + change left them describing the file as it was opened. + """ + + def test_the_line_ending_choice_asks_for_a_refresh(self, editor_tab): + from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import ( + apply_line_ending_choice + ) + window, widget = editor_tab + assert apply_line_ending_choice(window, "\r\n") is True + assert widget.line_ending == "\r\n" + window.refresh_status_bar.assert_called() + + def test_the_encoding_choice_asks_for_a_refresh(self, editor_tab): + from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import ( + apply_encoding + ) + window, widget = editor_tab + assert apply_encoding(window, "big5") is True + assert widget.file_encoding == "big5" + window.refresh_status_bar.assert_called() + + def test_a_window_without_a_status_bar_is_tolerated(self, editor_tab): + from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import ( + apply_line_ending_choice + ) + window, _widget = editor_tab + window.refresh_status_bar = None + assert apply_line_ending_choice(window, "\n") is True From 3ccdfe783b94dfd82c3d5c872b95656e4b8cb084 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 14:28:06 +0800 Subject: [PATCH 03/16] Let breakpoints move during a debug run Breakpoints only reached pdb when the debugger started, so a line marked or unmarked part-way through a session had no effect until the next run. Toggling one now sends the matching break or clear command, which pdb applies at its next prompt -- exactly when it should take effect. --- .../code_edit_plaintext.py | 33 +++++++++++- je_editor/utils/debugger/pdb_commands.py | 32 +++++++++++- test/test_breakpoints.py | 52 +++++++++++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py index 30bdfc1f..dfabf2b5 100644 --- a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +++ b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py @@ -35,7 +35,9 @@ guide_columns, trailing_whitespace_start ) from je_editor.git_client.file_staging import stage_content, staged_text -from je_editor.utils.debugger.pdb_commands import breakpoint_commands, step_command +from je_editor.utils.debugger.pdb_commands import ( + breakpoint_commands, step_command, toggle_command +) from je_editor.utils.file_diff.line_status import apply_hunk from je_editor.utils.file_diff.unified import unified_diff_text from je_editor.utils.lint.ruff_diagnostics import diagnostics_from_entries @@ -2238,12 +2240,39 @@ def toggle_breakpoint(self) -> bool: 切換游標所在行的中斷點 Add or remove a breakpoint on the caret's line. + 除錯器正在跑的話,這次改動也會送過去,中斷點因此在除錯途中也能加減,而不是 + 只能在啟動前決定好。 + With the debugger running the change is sent across as well, so breakpoints + can be added and removed part-way through a session rather than only being + settled before it starts. + :return: 切換後是否有中斷點 / whether the line now has one """ - result = self.breakpoint_manager.toggle(self.textCursor().blockNumber()) + line = self.textCursor().blockNumber() + result = self.breakpoint_manager.toggle(line) self.line_number.update() + self.send_breakpoint_change(line, result) return result + def send_breakpoint_change(self, line: int, is_set: bool) -> bool: + """ + 把一行的中斷點改動送給正在執行的除錯器 + Send one line's breakpoint change to the running debugger. + + pdb 只在提示符時讀指令,所以程式還在跑的話這個指令會等到下次停下來才生效 + ——那正是它該生效的時機。 + pdb only reads commands at its prompt, so while the program is running the + command waits for the next stop, which is exactly when it should apply. + + :param line: 以 0 起算的行號 / the 0-based line number + :param is_set: 該行現在是否有中斷點 / whether the line now has one + :return: 有送出時為 ``True`` / ``True`` when the command was sent + """ + if self.current_file is None: + return False + return self._write_debugger_line( + toggle_command(str(self.current_file), line + 1, is_set)) + def _debugger_process(self): """取得正在執行的除錯器程序 / The debugger process that is running, if any.""" manager = getattr(self.main_window, "exec_python_debugger", None) diff --git a/je_editor/utils/debugger/pdb_commands.py b/je_editor/utils/debugger/pdb_commands.py index bf172579..09cb203a 100644 --- a/je_editor/utils/debugger/pdb_commands.py +++ b/je_editor/utils/debugger/pdb_commands.py @@ -45,7 +45,37 @@ def breakpoint_command(file_path: str | Path, line: int) -> str: :param line: 1 起算的行號 / the 1-based line number :return: pdb 指令 / the pdb command """ - return f"break {Path(file_path).as_posix()}:{max(1, line)}" + return f"break {_location(file_path, line)}" + + +def clear_command(file_path: str | Path, line: int) -> str: + """ + 組出清除一個中斷點的指令 + Build the command that clears one breakpoint. + + :param file_path: 檔案路徑 / the file the breakpoint is in + :param line: 1 起算的行號 / the 1-based line number + :return: pdb 指令 / the pdb command + """ + return f"clear {_location(file_path, line)}" + + +def toggle_command(file_path: str | Path, line: int, is_set: bool) -> str: + """ + 組出切換一個中斷點的指令 + Build the command for toggling one breakpoint. + + :param file_path: 檔案路徑 / the file the breakpoint is in + :param line: 1 起算的行號 / the 1-based line number + :param is_set: 切換後該行是否有中斷點 / whether the line now has one + :return: pdb 指令 / the pdb command + """ + return breakpoint_command(file_path, line) if is_set else clear_command(file_path, line) + + +def _location(file_path: str | Path, line: int) -> str: + """組出 pdb 認得的「檔案:行號」/ The ``file:line`` pdb understands.""" + return f"{Path(file_path).as_posix()}:{max(1, line)}" def breakpoint_commands(file_path: str | Path, lines: list[int]) -> list[str]: diff --git a/test/test_breakpoints.py b/test/test_breakpoints.py index 20fe3049..e883e293 100644 --- a/test/test_breakpoints.py +++ b/test/test_breakpoints.py @@ -13,7 +13,9 @@ STEP_OVER, breakpoint_command, breakpoint_commands, + clear_command, step_command, + toggle_command, ) @@ -34,6 +36,18 @@ def test_several_breakpoints_are_sorted_and_unique(self): def test_invalid_lines_are_dropped(self): assert breakpoint_commands("app.py", [0, -2]) == [] + def test_clearing_names_the_file_and_line(self): + assert clear_command("/project/app.py", 12) == "clear /project/app.py:12" + + def test_clearing_uses_forward_slashes_too(self): + assert clear_command("D:\\project\\app.py", 3) == "clear D:/project/app.py:3" + + def test_toggling_on_sets_a_breakpoint(self): + assert toggle_command("app.py", 4, True) == "break app.py:4" + + def test_toggling_off_clears_it(self): + assert toggle_command("app.py", 4, False) == "clear app.py:4" + @pytest.mark.parametrize("action,expected", [ ("into", STEP_INTO), ("over", STEP_OVER), ("out", STEP_OUT), ("continue", CONTINUE), @@ -144,6 +158,8 @@ def test_breakpoints_are_sent_for_the_current_file(self, editor): editor.setPlainText("one\ntwo\nthree\n") _place_cursor(editor, 1) editor.toggle_breakpoint() + # Toggling sends its own change; this is about the full set going over. + stdin.write.reset_mock() assert editor.send_breakpoints_to_debugger() == 1 stdin.write.assert_called_once_with(b"break app.py:2\n") @@ -163,6 +179,7 @@ def test_starting_the_debugger_sends_them(self, editor): editor.setPlainText("one\ntwo\nthree\n") _place_cursor(editor, 2) editor.toggle_breakpoint() + stdin.write.reset_mock() widget = MagicMock() widget.code_edit = editor assert send_breakpoints(widget) == 1 @@ -176,6 +193,41 @@ def test_a_tab_without_an_editor_sends_nothing(self, editor): widget.code_edit = None assert send_breakpoints(widget) == 0 + def test_toggling_during_a_run_sets_the_breakpoint(self, editor): + stdin = MagicMock() + editor.main_window.exec_python_debugger = MagicMock() + editor.main_window.exec_python_debugger.process.stdin = stdin + editor.current_file = "app.py" + editor.setPlainText("one\ntwo\nthree\n") + _place_cursor(editor, 1) + assert editor.toggle_breakpoint() is True + stdin.write.assert_called_once_with(b"break app.py:2\n") + + def test_toggling_it_off_during_a_run_clears_it(self, editor): + stdin = MagicMock() + editor.main_window.exec_python_debugger = MagicMock() + editor.main_window.exec_python_debugger.process.stdin = stdin + editor.current_file = "app.py" + editor.setPlainText("one\ntwo\nthree\n") + _place_cursor(editor, 1) + editor.toggle_breakpoint() + stdin.write.reset_mock() + assert editor.toggle_breakpoint() is False + stdin.write.assert_called_once_with(b"clear app.py:2\n") + + def test_an_unsaved_buffer_sends_nothing(self, editor): + editor.main_window.exec_python_debugger = MagicMock() + editor.current_file = None + assert editor.send_breakpoint_change(0, True) is False + + def test_toggling_without_a_debugger_still_works(self, editor): + editor.main_window.exec_python_debugger = None + editor.current_file = "app.py" + editor.setPlainText("one\ntwo\n") + _place_cursor(editor, 0) + assert editor.toggle_breakpoint() is True + assert editor.breakpoint_manager.lines() == [0] + def test_a_broken_pipe_is_reported_rather_than_raised(self, editor): stdin = MagicMock() stdin.write.side_effect = OSError("broken pipe") From 0ad34e8bb826cf7dcf8f0c9d9c9b63499f2d1875 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 14:34:52 +0800 Subject: [PATCH 04/16] Type a snippet placeholder once A number repeated in a snippet body was only a tab stop the first time; the rest expanded to plain text, so "${1:name} = ${1:name}" meant typing the same name twice and nothing kept the two in step. Repeats are now recorded as mirrors of their stop and rewritten as you type in it. They are tracked as positions and a length rather than cursors, because a cursor selection collapses when its whole content is replaced -- which is exactly what the first keystroke over a placeholder does. --- .../code/snippets/snippet_manager.py | 112 +++++++++++++++++- je_editor/utils/snippets/snippet_expand.py | 66 ++++++++++- test/test_snippets.py | 111 +++++++++++++++++ 3 files changed, 277 insertions(+), 12 deletions(-) diff --git a/je_editor/pyside_ui/code/snippets/snippet_manager.py b/je_editor/pyside_ui/code/snippets/snippet_manager.py index b636c2b2..3c5e4ef8 100644 --- a/je_editor/pyside_ui/code/snippets/snippet_manager.py +++ b/je_editor/pyside_ui/code/snippets/snippet_manager.py @@ -12,10 +12,13 @@ import json from pathlib import Path +from PySide6.QtCore import QObject from PySide6.QtGui import QTextCursor from je_editor.utils.logging.loggin_instance import jeditor_logger -from je_editor.utils.snippets.snippet_expand import SnippetStop, expand_snippet, merge_snippets +from je_editor.utils.snippets.snippet_expand import ( + SnippetStop, expand_snippet, merge_snippets, positions_after_mirroring, shift_mirrors +) # 使用者片段檔名(放在設定資料夾內)/ The user snippet file, kept beside the settings SNIPPET_FILE_NAME = "snippets.json" @@ -75,20 +78,39 @@ def load_snippets(path: Path | None = None, suffix: str = "") -> dict[str, str]: return merge_snippets(stored, suffix) -class SnippetManager: +class SnippetManager(QObject): """ 管理一個編輯器的片段展開狀態 Track one editor's snippet expansion. + + 是編輯器的 QObject 子物件:要監看文件的變更訊號,接收者必須是 QObject,編輯器 + 被銷毀時 Qt 才會自動斷開。 + A QObject child of the editor: watching the document's change signal needs a + receiver Qt can disconnect when the editor goes away. """ def __init__(self, code_edit) -> None: """ :param code_edit: 要展開片段的編輯器 / the editor snippets expand into """ + super().__init__(code_edit) self._code_edit = code_edit self._snippets = load_snippets(suffix=self._suffix()) # 尚未走訪的定位點(文件中的絕對位置)/ Stops not yet visited, as document positions self._pending: list[SnippetStop] = [] + # 目前定位點與它的複本;記成位置與長度而不是 QTextCursor,因為整段內容被 + # 取代時游標的選取範圍會塌掉,而使用者輸入第一個字就正是在取代整段預設值 + # The current stop and its mirrors, kept as positions and a length rather + # than cursors: a cursor's selection collapses when its whole content is + # replaced, which is exactly what the first keystroke over a default does + self._stop_start = 0 + self._stop_length = 0 + self._mirror_starts: list[int] = [] + # 每個複本目前的長度;改寫後才會變 / Each mirror's current length + self._mirror_length = 0 + # 自己在改複本時不要再處理一次變更 / Ignore the changes this makes itself + self._mirroring = False + code_edit.document().contentsChange.connect(self._on_contents_change) def snippets(self) -> dict[str, str]: """取得目前可用的片段 / The snippets currently available.""" @@ -148,8 +170,13 @@ def expand_at_cursor(self) -> bool: start = cursor.selectionStart() cursor.insertText(text) cursor.endEditBlock() + # 展開得到的是片段內的相對位置,這裡換算成文件中的絕對位置 + # Expansion gives offsets inside the snippet; these are document positions self._pending = [ - SnippetStop(position=start + stop.position, length=stop.length) + SnippetStop( + position=start + stop.position, + length=stop.length, + mirrors=tuple(start + mirror for mirror in stop.mirrors)) for stop in stops ] self.next_stop() @@ -166,15 +193,88 @@ def next_stop(self) -> bool: return False stop = self._pending.pop(0) cursor = self._code_edit.textCursor() - cursor.setPosition(min(stop.position, self._code_edit.document().characterCount() - 1)) + cursor.setPosition(self._clamp(stop.position)) if stop.length: cursor.setPosition( - min(stop.position + stop.length, - self._code_edit.document().characterCount() - 1), + self._clamp(stop.position + stop.length), QTextCursor.MoveMode.KeepAnchor) self._code_edit.setTextCursor(cursor) + self._track_mirrors(stop) return True + def _clamp(self, position: int) -> int: + """把位置限制在文件範圍內 / Keep a position inside the document.""" + return max(0, min(position, self._code_edit.document().characterCount() - 1)) + + def _track_mirrors(self, stop: SnippetStop) -> None: + """ + 記住這個定位點與它的複本,準備讓它們一起改 + Remember this stop and its mirrors so they can be kept in step. + + :param stop: 剛跳到的定位點 / the stop just moved to + """ + self._stop_start = stop.position + self._stop_length = stop.length + self._mirror_length = stop.length + self._mirror_starts = sorted(stop.mirrors) + + def _on_contents_change(self, position: int, removed: int, added: int) -> None: + """ + 文件變更時把目前定位點的內容複製到每個複本 + Copy the current stop's text into each mirror whenever the document changes. + + 同一個編號在片段裡出現好幾次時,使用者只編輯第一個,其餘要跟著變;否則 + ``${1:name}`` 用兩次就得手動改兩遍。 + Where a number appears several times in a snippet the user only edits the + first, and the rest have to follow; otherwise using ``${1:name}`` twice + means typing the same thing twice. + """ + if self._mirroring or not self._mirror_starts: + return + if not self._stop_start <= position <= self._stop_start + self._stop_length: + # 在別處輸入代表使用者已經離開這個定位點,複本不該再跟著動 + # Typing elsewhere means the user has moved on, and the mirrors with them + return + delta = added - removed + self._stop_length = max(0, self._stop_length + delta) + self._mirror_starts = shift_mirrors(self._mirror_starts, position, delta) + self._write_mirrors() + + def _write_mirrors(self) -> None: + """ + 把定位點目前的內容寫進每個複本 + Write the stop's current text into every mirror. + + 由後往前改寫,前面的位置才不會因為後面的改寫而失效;改完再算出各自的新位置。 + The rewrite runs back to front so an earlier position is not invalidated by + a later one, and the new positions are worked out afterwards. + """ + text = self._text_between(self._stop_start, self._stop_start + self._stop_length) + old_length = self._mirror_length + cursor = QTextCursor(self._code_edit.document()) + self._mirroring = True + try: + cursor.beginEditBlock() + for start in sorted(self._mirror_starts, reverse=True): + cursor.setPosition(self._clamp(start)) + cursor.setPosition(self._clamp(start + old_length), + QTextCursor.MoveMode.KeepAnchor) + cursor.insertText(text) + cursor.endEditBlock() + finally: + self._mirroring = False + self._stop_start, self._mirror_starts = positions_after_mirroring( + self._stop_start, self._mirror_starts, len(text) - old_length) + self._mirror_length = len(text) + + def _text_between(self, start: int, end: int) -> str: + """取得文件中一段範圍的文字 / The document text between two positions.""" + cursor = QTextCursor(self._code_edit.document()) + cursor.setPosition(self._clamp(start)) + cursor.setPosition(self._clamp(end), QTextCursor.MoveMode.KeepAnchor) + return cursor.selectedText() + def clear_stops(self) -> None: """放棄剩下的定位點 / Give up on the remaining stops.""" self._pending = [] + self._mirror_starts = [] diff --git a/je_editor/utils/snippets/snippet_expand.py b/je_editor/utils/snippets/snippet_expand.py index f71e8491..76aafa3b 100644 --- a/je_editor/utils/snippets/snippet_expand.py +++ b/je_editor/utils/snippets/snippet_expand.py @@ -16,6 +16,7 @@ import re from dataclasses import dataclass +from typing import Sequence # 比對 $1 或 ${1:預設值} / Matches $1 or ${1:default} _STOP_PATTERN = re.compile(r"\$(\d+)|\$\{(\d+):([^}]*)\}") @@ -32,10 +33,13 @@ class SnippetStop: :param position: 相對於片段起點的字元位置 / offset from the snippet's start :param length: 預設值的長度(沒有預設值時為 0)/ the default value's length + :param mirrors: 同編號其他出現處的位置,長度與 ``length`` 相同 + / where the same number repeats, each the same length as ``length`` """ position: int length: int + mirrors: tuple[int, ...] = () def expand_snippet(body: str) -> tuple[str, list[SnippetStop]]: @@ -43,15 +47,19 @@ def expand_snippet(body: str) -> tuple[str, list[SnippetStop]]: 展開片段內容,回傳文字與定位點 Expand a snippet body into text and its tab stops. - 定位點依編號排序,``$0`` 排在最後;同一個編號出現多次時只取第一次。 - Stops come back in numeric order with ``$0`` last, and a number used more - than once keeps only its first appearance. + 定位點依編號排序,``$0`` 排在最後。同一個編號出現多次時,第一次是使用者要編輯 + 的那一個,其餘記在 ``mirrors`` 裡跟著它一起改。 + Stops come back in numeric order with ``$0`` last. Where a number repeats, the + first appearance is the one the user edits and the rest are recorded in + ``mirrors`` so they follow along. :param body: 片段內容 / the snippet body :return: ``(展開後的文字, 定位點清單)`` / ``(expanded text, stops)`` """ pieces: list[str] = [] stops: dict[int, SnippetStop] = {} + defaults: dict[int, str] = {} + mirrors: dict[int, list[int]] = {} length = 0 last_end = 0 for match in _STOP_PATTERN.finditer(body): @@ -59,8 +67,15 @@ def expand_snippet(body: str) -> tuple[str, list[SnippetStop]]: pieces.append(literal) length += len(literal) number = int(match.group(1) or match.group(2)) - default = match.group(3) or "" - if number not in stops: + if number in stops: + # 重複出現:寫上第一次的預設值,並記下位置好跟著一起改 + # A repeat: write the first appearance's default and note where it is + # so it can follow along + default = defaults[number] + mirrors.setdefault(number, []).append(length) + else: + default = match.group(3) or "" + defaults[number] = default stops[number] = SnippetStop(position=length, length=len(default)) pieces.append(default) length += len(default) @@ -68,7 +83,46 @@ def expand_snippet(body: str) -> tuple[str, list[SnippetStop]]: pieces.append(body[last_end:]) text = "".join(pieces) ordered = sorted(stops.items(), key=lambda item: (item[0] == _FINAL_STOP, item[0])) - return text, [stop for _number, stop in ordered] + return text, [ + SnippetStop(stop.position, stop.length, tuple(mirrors.get(number, ()))) + for number, stop in ordered + ] + + +def shift_mirrors(mirrors: Sequence[int], position: int, delta: int) -> list[int]: + """ + 使用者在某處增刪文字後,複本移到哪裡 + Where the mirrors land after the user inserts or removes text somewhere. + + :param mirrors: 複本目前的位置 / where the mirrors are now + :param position: 變更發生的位置 / where the change happened + :param delta: 字元數的增減 / how many characters were gained or lost + :return: 移動後的位置 / the positions afterwards + """ + return [start + delta if start >= position else start for start in mirrors] + + +def positions_after_mirroring(start: int, mirrors: Sequence[int], + delta: int) -> tuple[int, list[int]]: + """ + 每個複本都改寫成新長度之後,定位點與複本各自落在哪裡 + Where the stop and its mirrors land once every mirror is rewritten. + + 改寫一個複本會把它後面的所有東西往後推,因此排在越後面的複本累積的位移越多, + 而定位點只受排在它前面的複本影響。 + Rewriting a mirror pushes everything after it along, so a later mirror + accumulates more of the shift, and the stop itself only moves for the mirrors + that sit ahead of it. + + :param start: 定位點的起點 / where the stop starts + :param mirrors: 複本的起點 / where the mirrors start + :param delta: 每個複本的長度變化 / how much each mirror's length changes + :return: ``(定位點起點, 複本起點)`` / ``(stop start, mirror starts)`` + """ + ordered = sorted(mirrors) + moved_start = start + delta * sum(1 for mirror in ordered if mirror < start) + moved = [mirror + delta * index for index, mirror in enumerate(ordered)] + return moved_start, moved def default_snippets() -> dict[str, str]: diff --git a/test/test_snippets.py b/test/test_snippets.py index a641a93d..e68b8cdd 100644 --- a/test/test_snippets.py +++ b/test/test_snippets.py @@ -14,9 +14,69 @@ default_snippets, expand_snippet, merge_snippets, + positions_after_mirroring, + shift_mirrors, ) +class TestExpandingRepeatedStops: + def test_a_repeat_is_not_a_separate_stop(self): + _text, stops = expand_snippet("${1:name} = ${1:name}") + assert len(stops) == 1 + + def test_the_repeat_is_recorded_as_a_mirror(self): + # "name = name" -- the second copy starts at index 7. + _text, stops = expand_snippet("${1:name} = ${1:name}") + assert stops[0].mirrors == (7,) + + def test_the_repeat_carries_the_first_default(self): + text, _stops = expand_snippet("${1:name} = $1") + assert text == "name = name" + + def test_several_repeats_are_all_recorded(self): + _text, stops = expand_snippet("$1 $1 $1") + assert len(stops[0].mirrors) == 2 + + def test_a_stop_used_once_has_no_mirrors(self): + _text, stops = expand_snippet("${1:one} ${2:two}") + assert all(stop.mirrors == () for stop in stops) + + +class TestShiftingMirrors: + def test_a_mirror_after_the_change_moves(self): + assert shift_mirrors([10], 4, 3) == [13] + + def test_a_mirror_before_the_change_stays(self): + assert shift_mirrors([2], 4, 3) == [2] + + def test_a_deletion_pulls_it_back(self): + assert shift_mirrors([10], 4, -2) == [8] + + def test_a_mirror_exactly_at_the_change_moves(self): + assert shift_mirrors([4], 4, 3) == [7] + + +class TestPositionsAfterMirroring: + def test_nothing_moves_when_the_length_is_unchanged(self): + assert positions_after_mirroring(0, [10], 0) == (0, [10]) + + def test_a_later_mirror_absorbs_every_earlier_rewrite(self): + _start, mirrors = positions_after_mirroring(0, [10, 20], 2) + assert mirrors == [10, 22] + + def test_the_stop_moves_for_mirrors_ahead_of_it(self): + start, _mirrors = positions_after_mirroring(30, [10, 20], 2) + assert start == 34 + + def test_the_stop_ignores_mirrors_behind_it(self): + start, _mirrors = positions_after_mirroring(0, [10, 20], 2) + assert start == 0 + + def test_mirrors_are_handled_in_position_order(self): + _start, mirrors = positions_after_mirroring(0, [20, 10], 5) + assert mirrors == [10, 25] + + class TestExpandSnippet: def test_plain_text_has_no_stops(self): text, stops = expand_snippet("print('hello')") @@ -151,6 +211,57 @@ def _press_tab(editor) -> None: QTest.keyClick(editor, Qt.Key.Key_Tab) +class TestMirroredStops: + """ + A number used more than once in a snippet should only be typed once. The + repeats used to be plain text, so `${1:name}` twice meant typing it twice. + """ + + @staticmethod + def _expand(editor, body: str) -> None: + editor.snippet_manager._snippets["mirror"] = body + editor.setPlainText("mirror") + editor.moveCursor(editor.textCursor().MoveOperation.End) + _press_tab(editor) + + def test_a_repeat_starts_with_the_same_default(self, editor): + self._expand(editor, "${1:name} = ${1:name}") + assert editor.toPlainText() == "name = name" + + def test_typing_updates_the_repeat(self, editor): + self._expand(editor, "${1:name} = ${1:name}") + editor.textCursor().insertText("total") + assert editor.toPlainText() == "total = total" + + def test_every_repeat_follows(self, editor): + self._expand(editor, "${1:x}, ${1:x}, ${1:x}") + editor.textCursor().insertText("ab") + assert editor.toPlainText() == "ab, ab, ab" + + def test_a_repeat_after_other_text_follows(self, editor): + self._expand(editor, "def ${1:name}():\n return ${1:name}") + editor.textCursor().insertText("run") + assert editor.toPlainText() == "def run():\n return run" + + def test_typing_elsewhere_leaves_the_repeat_alone(self, editor): + self._expand(editor, "${1:name} = ${1:name}") + cursor = editor.textCursor() + cursor.movePosition(cursor.MoveOperation.End) + cursor.insertText(" # note") + assert editor.toPlainText() == "name = name # note" + + def test_a_stop_without_repeats_still_works(self, editor): + self._expand(editor, "${1:one} ${2:two}") + editor.textCursor().insertText("first") + assert editor.toPlainText() == "first two" + + def test_moving_on_stops_the_mirroring(self, editor): + self._expand(editor, "${1:name} = ${1:name}$0") + _press_tab(editor) + editor.textCursor().insertText("!") + assert editor.toPlainText() == "name = name!" + + class TestSnippetExpansionInEditor: def test_tab_expands_a_trigger_word(self, editor): editor.setPlainText("for") From 143fad91c7d65d583c64ff1263a9db15ade1072f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 14:39:12 +0800 Subject: [PATCH 05/16] Mark what the search is finding in the minimap The middle column of marks showed occurrences of the word under the caret even while a search was open, so the one thing the user was actually looking for was the one thing it did not show. It now marks the search hits whenever something is being searched for, and falls back to the word under the caret otherwise. Search matching is on the typed string rather than whole identifiers, so "val" marks the line holding "value" -- which is what a search is for. --- .../pyside_ui/code/minimap/minimap_widget.py | 30 ++++++++--- .../code_edit_plaintext.py | 53 ++++++++++++++++++- .../dialog/search_ui/search_replace_widget.py | 7 ++- .../utils/occurrence/word_occurrences.py | 26 +++++++++ test/test_minimap.py | 38 +++++++++++++ test/test_word_occurrences.py | 30 +++++++++++ 6 files changed, 175 insertions(+), 9 deletions(-) diff --git a/je_editor/pyside_ui/code/minimap/minimap_widget.py b/je_editor/pyside_ui/code/minimap/minimap_widget.py index 24ce54aa..38f689ba 100644 --- a/je_editor/pyside_ui/code/minimap/minimap_widget.py +++ b/je_editor/pyside_ui/code/minimap/minimap_widget.py @@ -82,24 +82,40 @@ def marker_lines(self) -> dict[str, list[int]]: 取得要在縮圖上標記的行 The lines the minimap should mark. - 資料全部來自編輯器已經算好的東西——git 變更、診斷、游標所在字詞的其他 - 出現處——所以標記不需要自己再掃一次檔案。 - Everything comes from what the editor has already worked out: git - changes, diagnostics, and the other occurrences of the word under the - caret. The markers never rescan the file themselves. + 診斷與 git 變更來自編輯器已經算好的結果。中間那一排標的是「使用者正在找的 + 東西」:搜尋框有輸入時就是它的命中行,沒有時才退回游標所在字詞的其他出現處。 + Diagnostics and git changes come from what the editor has already worked + out. The middle column marks what the user is looking for: the search + box's hits while something is being searched for, and otherwise the other + occurrences of the word under the caret. :return: 標記種類對應行號(0 起算)/ marker kind -> 0-based line numbers """ editor = self._code_edit diagnostics = sorted({item.line - 1 for item in editor.lint_manager.diagnostics()}) changes = sorted(editor.diff_marker_manager.statuses()) + return { + "diagnostic": diagnostics, + "change": changes, + "occurrence": self._lines_being_looked_for(), + } + + def _lines_being_looked_for(self) -> list[int]: + """ + 取得使用者正在找的行 + The lines holding whatever the user is looking for. + + :return: 行號(0 起算)/ the 0-based line numbers + """ + editor = self._code_edit + if editor.search_term(): + return editor.search_hit_lines() document = editor.document() - occurrences = sorted({ + return sorted({ document.findBlock(position).blockNumber() for position in editor.word_occurrences_under_cursor( editor.toPlainText(), editor.textCursor().position()) }) - return {"diagnostic": diagnostics, "change": changes, "occurrence": occurrences} def _paint_markers(self, painter: QPainter, step: int) -> None: """在縮圖兩側畫出診斷、變更與出現處的標記 / Mark diagnostics, changes and hits.""" diff --git a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py index dfabf2b5..dbb7ded4 100644 --- a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +++ b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py @@ -52,7 +52,7 @@ from je_editor.utils.navigation.location_history import LocationHistory from je_editor.utils.number_ops.number_ops import adjust_number_at, to_base from je_editor.utils.occurrence.word_occurrences import ( - find_occurrences, replace_whole_word, word_at + find_occurrences, lines_containing, replace_whole_word, word_at ) from je_editor.utils.text_cleanup.text_cleanup import trim_trailing_whitespace from je_editor.pyside_ui.code.syntax.generic_syntax import highlighter_for @@ -234,6 +234,9 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: # 搜尋框 (延遲建立) self.search_box = None + # 目前搜尋框在找的字串;縮圖用它標出命中的行 + # What the search box is looking for; the minimap marks the lines it hits + self._search_term = "" # 行號區域 (LineNumber 是另一個自訂類別) self.line_number: LineNumber = LineNumber(self) @@ -538,6 +541,7 @@ def find_next_text(self) -> None: jeditor_logger.info("CodeEditor find_next_text") if self.search_box.isVisible(): text = self.search_box.search_input.text() + self.set_search_term(text) self.find(text) def find_back_text(self) -> None: @@ -548,8 +552,55 @@ def find_back_text(self) -> None: jeditor_logger.info("CodeEditor find_back_text") if self.search_box.isVisible(): text = self.search_box.search_input.text() + self.set_search_term(text) self.find(text, QTextDocument.FindFlag.FindBackward) + def set_search_term(self, term: str) -> bool: + """ + 記下目前正在搜尋的字串 + Remember what is currently being searched for. + + 縮圖用它來標出命中的行;搜尋框關掉或字串清空時就恢復標記游標所在的字詞。 + The minimap marks the lines it hits; clearing it, or closing the search, + goes back to marking the word under the caret. + + :param term: 搜尋字串 / the string being searched for + :return: 字串有變時為 ``True`` / ``True`` when it changed + """ + term = term or "" + if term == self._search_term: + return False + self._search_term = term + minimap = getattr(self.main_window, "minimap", None) + if minimap is not None: + minimap.update() + return True + + def search_term(self) -> str: + """ + 取得目前的搜尋字串 + The string currently being searched for. + + 搜尋框關掉之後就不算還在搜尋了,因此標記會跟著消失。 + A closed search box means nothing is being searched for any more, so the + marks go with it. + + :return: 搜尋字串,沒在搜尋時為空字串 / the term, or an empty string + """ + box = self.search_box + if box is not None and not box.isVisible(): + return "" + return self._search_term + + def search_hit_lines(self) -> list[int]: + """ + 取得含有目前搜尋字串的行 + The lines holding the current search term. + + :return: 行號(0 起算)/ the 0-based line numbers + """ + return lines_containing(self.toPlainText(), self._search_term) + # ── 快捷鍵註冊 / Shortcut registration ──────────────────────── def _add_shortcut_action(self, sequence: str, command: str, handler) -> QAction: diff --git a/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py b/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py index 2329bde1..0d954eaf 100644 --- a/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py +++ b/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py @@ -240,6 +240,8 @@ def _search_in_current_file(self, pattern: str) -> None: """在目前檔案中搜尋 / Search in current file""" self.result_tree.clear() editor = self.editor_widget.code_edit + # 讓縮圖標出命中的行 / So the minimap marks the lines it hits + editor.set_search_term(pattern) text = editor.toPlainText() case = self.chk_case.isChecked() use_regex = self.chk_regex.isChecked() @@ -586,8 +588,11 @@ def _get_project_root(self) -> str: return os.getcwd() def closeEvent(self, event: QCloseEvent) -> None: - """關閉對話框時停止搜尋執行緒 / Stop search worker on close""" + """關閉對話框時停止搜尋執行緒並清掉縮圖標記 / Stop the worker and clear the marks""" if self._worker and self._worker.isRunning(): self._worker.stop() self._worker.wait() + editor = getattr(self.editor_widget, "code_edit", None) + if editor is not None: + editor.set_search_term("") super().closeEvent(event) diff --git a/je_editor/utils/occurrence/word_occurrences.py b/je_editor/utils/occurrence/word_occurrences.py index 089c5255..510d3500 100644 --- a/je_editor/utils/occurrence/word_occurrences.py +++ b/je_editor/utils/occurrence/word_occurrences.py @@ -85,6 +85,32 @@ def replace_whole_word(text: str, word: str, replacement: str) -> str: return pattern.sub(lambda _match: replacement, text) +def lines_containing(text: str, term: str, case_sensitive: bool = False) -> list[int]: + """ + 找出含有指定字串的行 + Find the lines that contain a piece of text. + + 這是「搜尋框正在找的東西」用的:比對的是使用者輸入的字串本身,不加字界也不 + 要求是識別字,因此 ``val`` 會在 ``value`` 裡命中——搜尋本來就該如此。 + This backs what the search box is looking for: it matches the string the user + typed, with no word boundaries and no identifier requirement, so ``val`` does + hit inside ``value`` — which is what searching is meant to do. + + :param text: 要搜尋的文字 / the text to search + :param term: 要尋找的字串 / the string to find + :param case_sensitive: 是否區分大小寫 / whether case matters + :return: 含有該字串的行號(0 起算,已排序去重) + / the 0-based line numbers holding it, sorted and unique + """ + if not term: + return [] + needle = term if case_sensitive else term.lower() + return [ + number for number, line in enumerate(text.splitlines()) + if needle in (line if case_sensitive else line.lower()) + ] + + def find_occurrences(text: str, word: str) -> list[int]: """ 找出字詞在文字中所有以完整字界出現的位置 diff --git a/test/test_minimap.py b/test/test_minimap.py index 1a947720..51d8ef26 100644 --- a/test/test_minimap.py +++ b/test/test_minimap.py @@ -1,6 +1,7 @@ """Tests for the minimap's geometry and its wiring into the editor.""" from __future__ import annotations + from unittest.mock import MagicMock, patch import pytest @@ -164,6 +165,43 @@ def test_markers_report_occurrences_of_the_word_under_the_caret(self, editor_wid editor_widget.code_edit.setTextCursor(cursor) assert editor_widget.minimap.marker_lines()["occurrence"] == [0, 1, 2] + def test_a_search_term_replaces_the_word_occurrences(self, editor_widget): + # What the user is looking for is the search, not the word they last + # happened to leave the caret on. + editor_widget.toggle_minimap() + editor = editor_widget.code_edit + editor.setPlainText("total = 1\nx = total\nnothing here\n") + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + editor.set_search_term("here") + assert editor_widget.minimap.marker_lines()["occurrence"] == [2] + + def test_a_search_hit_inside_a_longer_word_counts(self, editor_widget): + editor_widget.toggle_minimap() + editor = editor_widget.code_edit + editor.setPlainText("value\nother\n") + editor.set_search_term("val") + assert editor_widget.minimap.marker_lines()["occurrence"] == [0] + + def test_the_search_ignores_case(self, editor_widget): + editor_widget.toggle_minimap() + editor = editor_widget.code_edit + editor.setPlainText("Total\nnothing\n") + editor.set_search_term("total") + assert editor_widget.minimap.marker_lines()["occurrence"] == [0] + + def test_clearing_the_search_goes_back_to_occurrences(self, editor_widget): + editor_widget.toggle_minimap() + editor = editor_widget.code_edit + editor.setPlainText("total = 1\nx = total\n") + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + editor.set_search_term("total") + editor.set_search_term("") + assert editor_widget.minimap.marker_lines()["occurrence"] == [0, 1] + def test_no_markers_when_there_is_nothing_to_mark(self, editor_widget): editor_widget.toggle_minimap() editor_widget.code_edit.setPlainText("alpha\nbeta\n") diff --git a/test/test_word_occurrences.py b/test/test_word_occurrences.py index 08225192..afc663a6 100644 --- a/test/test_word_occurrences.py +++ b/test/test_word_occurrences.py @@ -1,13 +1,43 @@ """Tests for word-under-cursor detection and occurrence finding.""" from __future__ import annotations + from je_editor.utils.occurrence.word_occurrences import ( find_occurrences, is_highlightable_word, + lines_containing, word_at, ) +class TestLinesContaining: + """Backs the search marks, so it matches the typed string, not whole words.""" + + def test_it_reports_the_line(self): + assert lines_containing("alpha\nbeta\ngamma\n", "beta") == [1] + + def test_every_matching_line_is_reported(self): + assert lines_containing("a\nb\na\n", "a") == [0, 2] + + def test_a_partial_word_matches(self): + assert lines_containing("value = 1\n", "val") == [0] + + def test_case_is_ignored_by_default(self): + assert lines_containing("Total\n", "total") == [0] + + def test_case_can_be_required(self): + assert lines_containing("Total\n", "total", case_sensitive=True) == [] + + def test_a_line_matching_twice_is_reported_once(self): + assert lines_containing("aa\n", "a") == [0] + + def test_an_empty_term_matches_nothing(self): + assert lines_containing("anything\n", "") == [] + + def test_a_term_that_is_absent_matches_nothing(self): + assert lines_containing("alpha\n", "zzz") == [] + + class TestWordAt: def test_inside_word(self): assert word_at("foo bar", 1) == ("foo", 0, 3) From e79cd14989b1e8707727f42948e0033ec72eee52 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 14:42:42 +0800 Subject: [PATCH 06/16] Fold brace languages on their braces Folding was indentation-only, which suits Python but misreads the C-family languages now that they are highlighted: a brace placed on its own line left the header no more indented than its body, so the block did not fold at all. Those languages now fold on brace pairs, with the closing brace left visible. Braces inside strings and comments are skipped, since one in a string would throw every pair after it out of step. --- .../pyside_ui/code/folding/folding_manager.py | 17 +- je_editor/utils/code_folding/brace_regions.py | 161 ++++++++++++++++++ test/test_brace_regions.py | 104 +++++++++++ 3 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 je_editor/utils/code_folding/brace_regions.py create mode 100644 test/test_brace_regions.py diff --git a/je_editor/pyside_ui/code/folding/folding_manager.py b/je_editor/pyside_ui/code/folding/folding_manager.py index 181fb31f..a59916bd 100644 --- a/je_editor/pyside_ui/code/folding/folding_manager.py +++ b/je_editor/pyside_ui/code/folding/folding_manager.py @@ -9,11 +9,11 @@ """ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING -from je_editor.utils.code_folding.fold_regions import ( - FoldRegion, compute_fold_regions, region_at_line -) +from je_editor.utils.code_folding.brace_regions import fold_regions_for +from je_editor.utils.code_folding.fold_regions import FoldRegion, region_at_line from je_editor.utils.logging.loggin_instance import jeditor_logger if TYPE_CHECKING: @@ -44,10 +44,19 @@ def compute_regions(self) -> list[FoldRegion]: 依目前文字計算所有可折疊區塊 Compute every foldable region for the current text. + 折疊方式依語言而定:以大括號劃分區塊的語言看括號配對,其餘看縮排。 + How the regions are found depends on the language: brace-delimited ones + follow their pairs, and everything else follows indentation. + :return: 可折疊區塊清單 / The list of foldable regions """ text = self._editor.toPlainText() - return compute_fold_regions(text.split("\n")) + return fold_regions_for(text.split("\n"), self._suffix()) + + def _suffix(self) -> str: + """目前檔案的副檔名 / The current file's suffix.""" + current = getattr(self._editor, "current_file", None) + return Path(str(current)).suffix if current else "" def foldable_header_lines(self) -> set[int]: """ diff --git a/je_editor/utils/code_folding/brace_regions.py b/je_editor/utils/code_folding/brace_regions.py new file mode 100644 index 00000000..f48e72d7 --- /dev/null +++ b/je_editor/utils/code_folding/brace_regions.py @@ -0,0 +1,161 @@ +""" +以大括號計算可折疊區塊(純邏輯,不含 Qt) +Compute foldable regions from brace pairs (pure logic, no Qt imports). + +縮排式折疊對 Python 剛好,但 C 家族的語言把區塊寫在 ``{`` 與 ``}`` 之間,縮排只是 +慣例——只用縮排判斷的話,把開頭的大括號放在下一行、或整段擠在一行,折疊就對不上。 +Indentation folding suits Python, but the C-family languages delimit blocks with +``{`` and ``}`` and treat indentation as a convention; judging by indentation +alone then misreads a brace placed on its own line, or a block written on one. + +字串與註解裡的括號不算,否則 ``"{"`` 之類的內容會讓整份檔案的配對錯開。 +Braces inside strings and comments do not count, or something like ``"{"`` would +throw every pair in the file out of step. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from je_editor.utils.code_folding.fold_regions import MAX_SCAN_LINES, FoldRegion, line_indent +from je_editor.utils.syntax.language_rules import rules_for + +# 用大括號表達區塊的副檔名 / The suffixes whose blocks are delimited by braces +BRACE_SUFFIXES = frozenset({ + ".js", ".ts", ".rs", ".go", ".c", ".h", ".cpp", ".hpp", ".java", ".json", +}) + + +@dataclass +class _ScanState: + """ + 掃描過程中的狀態 + What the scan is in the middle of. + + :param string_quote: 目前所在字串的引號,不在字串中則為空字串 + / the quote of the string being scanned, or empty when outside one + :param in_block_comment: 是否在區塊註解中 / whether a block comment is open + """ + + string_quote: str = "" + in_block_comment: bool = False + + +def uses_braces(suffix: str) -> bool: + """ + 判斷某個副檔名是否以大括號表達區塊 + Whether a file suffix delimits its blocks with braces. + + :param suffix: 副檔名(含點)/ the file suffix, dot included + :return: 是的話為 ``True`` / ``True`` when it does + """ + return suffix.lower() in BRACE_SUFFIXES + + +def compute_brace_fold_regions(lines: list[str], suffix: str) -> list[FoldRegion]: + """ + 從大括號配對計算可折疊區塊 + Compute the foldable regions from brace pairs. + + 每一對括號構成一個區塊:標頭是 ``{`` 所在的行,結尾是配對的 ``}`` 所在的行。 + 兩者同一行的區塊不算,折疊它沒有任何意義。 + Each pair makes a region: the header is the line holding ``{`` and the end is + the line holding its match. A pair that opens and closes on one line is not a + region, since folding it would hide nothing. + + :param lines: 檔案的文字行 / the file's text lines + :param suffix: 副檔名,用來判斷字串與註解的寫法 / the suffix, for its comment syntax + :return: 依標頭行排序的區塊 / the regions, ordered by header line + """ + count = min(len(lines), MAX_SCAN_LINES) + rules = rules_for(suffix) + line_comment = rules.line_comment if rules is not None else "//" + block_comment = rules.block_comment if rules is not None else ("/*", "*/") + quotes = rules.string_delimiters if rules is not None else ('"', "'") + state = _ScanState() + open_lines: list[int] = [] + regions: list[FoldRegion] = [] + for number in range(count): + for symbol in _braces_in(lines[number], state, line_comment, block_comment, quotes): + if symbol == "{": + open_lines.append(number) + elif open_lines: + start = open_lines.pop() + # 至少要藏得住一行才算得上區塊;結尾的 ``}`` 留著可見 + # A region has to hide at least one line; the closing ``}`` stays visible + if number > start + 1: + regions.append(FoldRegion( + start=start, end=number - 1, indent=line_indent(lines[start]) or 0)) + return sorted(regions, key=lambda region: region.start) + + +def _braces_in(line: str, state: _ScanState, line_comment: str, + block_comment: tuple[str, str] | None, quotes: tuple[str, ...]) -> list[str]: + """ + 取出一行中真正算數的大括號 + The braces on one line that actually count. + + :param line: 一行文字 / the line of text + :param state: 跨行延續的狀態,會被就地更新 / the state carried across lines, updated in place + :param line_comment: 單行註解的開頭 / what starts a line comment + :param block_comment: 區塊註解的起訖 / the block comment delimiters + :param quotes: 字串的引號 / the quote characters + :return: 依序出現的 ``{`` 與 ``}`` / the braces in the order they appear + """ + found: list[str] = [] + index = 0 + while index < len(line): + char = line[index] + if state.in_block_comment: + index = _skip_to_block_end(line, index, block_comment, state) + continue + if state.string_quote: + index = _skip_in_string(line, index, state) + continue + if block_comment and line.startswith(block_comment[0], index): + state.in_block_comment = True + index += len(block_comment[0]) + continue + if line_comment and line.startswith(line_comment, index): + break + if char in quotes: + state.string_quote = char + index += 1 + continue + if char in "{}": + found.append(char) + index += 1 + return found + + +def _skip_to_block_end(line: str, index: int, block_comment: tuple[str, str] | None, + state: _ScanState) -> int: + """跳過區塊註解的內容 / Step past what is inside a block comment.""" + if block_comment and line.startswith(block_comment[1], index): + state.in_block_comment = False + return index + len(block_comment[1]) + return index + 1 + + +def _skip_in_string(line: str, index: int, state: _ScanState) -> int: + """跳過字串的內容,並處理跳脫字元 / Step past a string's content, honouring escapes.""" + char = line[index] + if char == "\\": + return index + 2 + if char == state.string_quote: + state.string_quote = "" + return index + 1 + + +def fold_regions_for(lines: list[str], suffix: str) -> list[FoldRegion]: + """ + 依語言選擇折疊方式 + Compute the foldable regions the way this language expresses blocks. + + :param lines: 檔案的文字行 / the file's text lines + :param suffix: 副檔名(含點)/ the file suffix, dot included + :return: 可折疊區塊 / the foldable regions + """ + from je_editor.utils.code_folding.fold_regions import compute_fold_regions + if uses_braces(suffix): + return compute_brace_fold_regions(lines, suffix) + return compute_fold_regions(lines) diff --git a/test/test_brace_regions.py b/test/test_brace_regions.py new file mode 100644 index 00000000..95637443 --- /dev/null +++ b/test/test_brace_regions.py @@ -0,0 +1,104 @@ +"""Tests for folding the languages that delimit blocks with braces.""" +from __future__ import annotations + +import pytest + +from je_editor.utils.code_folding.brace_regions import ( + compute_brace_fold_regions, fold_regions_for, uses_braces +) + + +def _regions(source: str, suffix: str = ".ts"): + return compute_brace_fold_regions(source.split("\n"), suffix) + + +class TestWhichLanguagesUseBraces: + @pytest.mark.parametrize("suffix", [".js", ".ts", ".rs", ".go", ".c", ".java"]) + def test_the_c_family_does(self, suffix): + assert uses_braces(suffix) is True + + @pytest.mark.parametrize("suffix", [".py", ".yaml", ".toml", ".sh", ""]) + def test_the_indented_ones_do_not(self, suffix): + assert uses_braces(suffix) is False + + def test_the_suffix_is_matched_case_insensitively(self): + assert uses_braces(".TS") is True + + +class TestBracePairs: + def test_a_block_folds_from_its_opening_brace(self): + regions = _regions("function f() {\n body();\n}\n") + assert (regions[0].start, regions[0].end) == (0, 1) + + def test_the_closing_brace_stays_visible(self): + regions = _regions("function f() {\n a();\n b();\n}\n") + assert 3 not in regions[0].body_lines + + def test_a_brace_on_its_own_line_still_opens_a_region(self): + # Indentation folding gets this wrong: the header is no more indented + # than its body's first line. + regions = _regions("function f()\n{\n body();\n}\n") + assert (regions[0].start, regions[0].end) == (1, 2) + + def test_nested_blocks_each_fold(self): + regions = _regions("a {\n b {\n c();\n }\n}\n") + assert [(region.start, region.end) for region in regions] == [(0, 3), (1, 2)] + + def test_a_one_line_block_is_not_foldable(self): + assert _regions("function f() { body(); }\n") == [] + + def test_an_empty_block_is_not_foldable(self): + assert _regions("function f() {\n}\n") == [] + + def test_an_unclosed_brace_yields_nothing(self): + assert _regions("function f() {\n body();\n") == [] + + def test_a_stray_closing_brace_is_ignored(self): + assert _regions("}\n") == [] + + def test_the_header_indent_is_recorded(self): + regions = _regions(" if (x) {\n y();\n }\n") + assert regions[0].indent == 4 + + +class TestBracesThatDoNotCount: + """A brace inside a string or comment would throw every pair out of step.""" + + def test_a_brace_in_a_string_is_ignored(self): + assert _regions('const a = "{";\nconst b = 2;\n') == [] + + def test_a_brace_in_a_line_comment_is_ignored(self): + assert _regions("// {\nconst b = 2;\n") == [] + + def test_a_brace_in_a_block_comment_is_ignored(self): + assert _regions("/* { */\nconst b = 2;\n") == [] + + def test_a_block_comment_spanning_lines_is_ignored(self): + assert _regions("/*\n{\n*/\nconst b = 2;\n") == [] + + def test_an_escaped_quote_does_not_end_the_string(self): + assert _regions('const a = "\\"{";\nconst b = 2;\n') == [] + + def test_a_real_block_after_a_string_still_folds(self): + regions = _regions('const a = "{";\nfunction f() {\n body();\n}\n') + assert (regions[0].start, regions[0].end) == (1, 2) + + def test_a_hash_comment_language_uses_its_own_marker(self): + # Go uses //, so a # is just text; the braces on the line still count. + regions = compute_brace_fold_regions( + "func f() { // {\n body()\n}".split("\n"), ".go") + assert (regions[0].start, regions[0].end) == (0, 1) + + +class TestChoosingHowToFold: + def test_a_brace_language_folds_on_braces(self): + regions = fold_regions_for("function f()\n{\n body();\n}".split("\n"), ".ts") + assert (regions[0].start, regions[0].end) == (1, 2) + + def test_python_still_folds_on_indentation(self): + regions = fold_regions_for("def f():\n body()\n".split("\n"), ".py") + assert (regions[0].start, regions[0].end) == (0, 1) + + def test_a_file_without_a_suffix_folds_on_indentation(self): + regions = fold_regions_for("header\n body\n".split("\n"), "") + assert (regions[0].start, regions[0].end) == (0, 1) From 88f1a1068e47d19a581e48753a9f5c5d306bb922 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 14:46:39 +0800 Subject: [PATCH 07/16] Move every caret up, down, Home and End The extra carets only went left and right, so any vertical move meant losing them and starting over. They now follow Up, Down, Home and End as well, each keeping its own column and stopping at a shorter line''s end. Left and right also take the primary caret along. It used to stay put while the extras moved, so a single arrow press pulled them out of alignment -- the one thing multiple carets exist to preserve. --- .../code/multi_cursor/multi_cursor_manager.py | 94 ++++++++++++++++++- .../code_edit_plaintext.py | 4 + test/test_multi_cursor.py | 68 +++++++++++++- 3 files changed, 158 insertions(+), 8 deletions(-) diff --git a/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py b/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py index b193d873..d4a837a4 100644 --- a/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py +++ b/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py @@ -224,19 +224,105 @@ def insert_newline(self) -> bool: def move_all(self, offset: int) -> bool: """ - 把每個額外游標左右移動 - Move every extra caret left or right. + 把每個游標左右移動 + Move every caret left or right. + + 主游標也要一起走,否則按一次方向鍵就會讓它落在別的游標後面。 + The primary caret moves too, or one press of an arrow key would leave it + behind the others. :param offset: 移動量(負數往左)/ how far to move, negative for left :return: 有移動時為 ``True`` / ``True`` when the carets moved """ if not self._positions: return False + limit = self._document_limit() self._positions = clamp_positions( - [position + offset for position in self._positions], self._document_limit()) - self._code_edit.viewport().update() + [position + offset for position in self._positions], limit) + self._move_primary_to(self._code_edit.textCursor().position() + offset) + return True + + def move_all_vertically(self, direction: int) -> bool: + """ + 把每個游標上下移動一行,保持各自的欄位 + Move every caret one line up or down, each keeping its column. + + 比目標行長度更靠右的游標停在該行的行尾,與一般游標的行為一致。 + A caret further right than the target line stops at that line's end, the + same as an ordinary caret does. + + :param direction: ``-1`` 為上一行,``1`` 為下一行 / ``-1`` up, ``1`` down + :return: 有移動時為 ``True`` / ``True`` when the carets moved + """ + if not self._positions: + return False + # 沒有那一行的游標留在原地,而不是消失 + # A caret with no line to move to stays where it is rather than vanishing + self._positions = clamp_positions( + [self._neighbour_or_here(position, direction) for position in self._positions], + self._document_limit()) + self._move_primary_to(self._neighbour_or_here( + self._code_edit.textCursor().position(), direction)) + return True + + def _neighbour_or_here(self, position: int, direction: int) -> int: + """上下一行的同一欄,沒有那一行時維持原位 / The same column one line away, or where it is.""" + moved = self._line_neighbour(position, direction) + return position if moved is None else moved + + def move_all_to_line_edge(self, to_end: bool) -> bool: + """ + 把每個游標移到所在行的行首或行尾 + Move every caret to the start or the end of its own line. + + :param to_end: ``True`` 移到行尾,``False`` 移到行首 / ``True`` for the end + :return: 有移動時為 ``True`` / ``True`` when the carets moved + """ + if not self._positions: + return False + self._positions = clamp_positions( + [self._line_edge(position, to_end) for position in self._positions], + self._document_limit()) + self._move_primary_to( + self._line_edge(self._code_edit.textCursor().position(), to_end)) return True + def _line_neighbour(self, position: int, direction: int) -> int | None: + """ + 取得同一欄在上下一行的位置 + The position one line up or down, at the same column. + + :param position: 目前位置 / where the caret is + :param direction: ``-1`` 為上一行,``1`` 為下一行 / ``-1`` up, ``1`` down + :return: 新位置,沒有那一行時為 ``None`` / the new position, or ``None`` + """ + document = self._code_edit.document() + block = document.findBlock(position) + target = document.findBlockByNumber(block.blockNumber() + direction) + if not target.isValid(): + return None + column = position - block.position() + return target.position() + min(column, len(target.text())) + + def _line_edge(self, position: int, to_end: bool) -> int: + """ + 取得所在行的行首或行尾位置 + The position at the start or the end of a caret's line. + + :param position: 目前位置 / where the caret is + :param to_end: ``True`` 取行尾 / ``True`` for the end of the line + :return: 新位置 / the new position + """ + block = self._code_edit.document().findBlock(position) + return block.position() + (len(block.text()) if to_end else 0) + + def _move_primary_to(self, position: int) -> None: + """把主游標移到指定位置並重畫 / Move the primary caret there and repaint.""" + main = self._code_edit.textCursor() + main.setPosition(min(max(0, position), self._document_limit())) + self._code_edit.setTextCursor(main) + self._code_edit.viewport().update() + def select_column(self, anchor_position: int, current_position: int) -> int: """ 以矩形範圍在每一行放一個游標 diff --git a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py index dbb7ded4..163338b9 100644 --- a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +++ b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py @@ -128,6 +128,10 @@ def run(self) -> None: Qt.Key.Key_Enter: lambda manager: manager.insert_newline(), Qt.Key.Key_Left: lambda manager: manager.move_all(-1), Qt.Key.Key_Right: lambda manager: manager.move_all(1), + Qt.Key.Key_Up: lambda manager: manager.move_all_vertically(-1), + Qt.Key.Key_Down: lambda manager: manager.move_all_vertically(1), + Qt.Key.Key_Home: lambda manager: manager.move_all_to_line_edge(False), + Qt.Key.Key_End: lambda manager: manager.move_all_to_line_edge(True), } diff --git a/test/test_multi_cursor.py b/test/test_multi_cursor.py index b12bdd7a..8f32f646 100644 --- a/test/test_multi_cursor.py +++ b/test/test_multi_cursor.py @@ -216,11 +216,9 @@ def test_delete_reaches_every_line(self, editor): editor.setPlainText("one!\ntwo!\nthree!") _select_all(editor) editor.add_cursors_to_selected_lines() - # Each caret sits at its line end, so Delete takes the newline after it. + # Each caret sits at its line end, so step back one to sit before the "!". + # move_all takes the primary caret with it, so no separate nudge is needed. editor.multi_cursor_manager.move_all(-1) - cursor = editor.textCursor() - cursor.setPosition(cursor.position() - 1) - editor.setTextCursor(cursor) _press(editor, Qt.Key.Key_Delete) assert editor.toPlainText() == "one\ntwo\nthree" @@ -245,6 +243,68 @@ def test_arrow_keys_move_the_extra_carets(self, editor): _press(editor, Qt.Key.Key_Left) assert editor.multi_cursor_manager.positions() == [5] + def test_the_primary_caret_moves_along(self, editor): + # It used to stay behind, so one arrow press pulled the carets apart. + editor.setPlainText("hello world") + cursor = editor.textCursor() + cursor.setPosition(0) + editor.setTextCursor(cursor) + editor.multi_cursor_manager.toggle_at(5) + _press(editor, Qt.Key.Key_Right) + assert editor.textCursor().position() == 1 + + def test_down_moves_every_caret_a_line(self, editor): + editor.setPlainText("alpha\nbravo\ncharlie") + cursor = editor.textCursor() + cursor.setPosition(1) + editor.setTextCursor(cursor) + editor.multi_cursor_manager.toggle_at(3) + _press(editor, Qt.Key.Key_Down) + assert editor.multi_cursor_manager.positions() == [9] + assert editor.textCursor().position() == 7 + + def test_up_moves_every_caret_a_line(self, editor): + editor.setPlainText("alpha\nbravo") + editor.multi_cursor_manager.toggle_at(8) + _press(editor, Qt.Key.Key_Up) + assert editor.multi_cursor_manager.positions() == [2] + + def test_a_caret_past_a_shorter_line_stops_at_its_end(self, editor): + editor.setPlainText("longer line\nab") + editor.multi_cursor_manager.toggle_at(9) + _press(editor, Qt.Key.Key_Down) + assert editor.multi_cursor_manager.positions() == [14] + + def test_moving_past_the_last_line_drops_nothing(self, editor): + editor.setPlainText("only") + editor.multi_cursor_manager.toggle_at(2) + _press(editor, Qt.Key.Key_Down) + assert editor.multi_cursor_manager.positions() == [2] + + def test_end_sends_every_caret_to_its_line_end(self, editor): + editor.setPlainText("alpha\nbravo") + editor.multi_cursor_manager.toggle_at(1) + cursor = editor.textCursor() + cursor.setPosition(7) + editor.setTextCursor(cursor) + _press(editor, Qt.Key.Key_End) + assert editor.multi_cursor_manager.positions() == [5] + assert editor.textCursor().position() == 11 + + def test_home_sends_every_caret_to_its_line_start(self, editor): + editor.setPlainText("alpha\nbravo") + editor.multi_cursor_manager.toggle_at(4) + cursor = editor.textCursor() + cursor.setPosition(9) + editor.setTextCursor(cursor) + _press(editor, Qt.Key.Key_Home) + assert editor.multi_cursor_manager.positions() == [0] + assert editor.textCursor().position() == 6 + + def test_vertical_movement_needs_extra_carets(self, editor): + editor.setPlainText("alpha\nbravo") + assert editor.multi_cursor_manager.move_all_vertically(1) is False + def test_carets_never_move_outside_the_document(self, editor): editor.setPlainText("ab") editor.multi_cursor_manager.toggle_at(0) From 7f0b6983fd775d14887eb206884f696e09f9bc5d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 14:52:22 +0800 Subject: [PATCH 08/16] Let every caret hold its own selection Extra carets were positions with nothing behind them, so Shift with an arrow key did nothing and there was no way to select the same span on several lines at once. Each caret now keeps an anchor, Shift extends them all together, and the spans are washed in the caret colour. Insert, Backspace and Delete go through one operation -- replace each caret''s range with this text -- rather than three sets of offset arithmetic. A caret without a selection covers a zero-length range, so inserting and replacing are the same thing, and the positions afterwards fall out of one calculation. --- .../code/multi_cursor/multi_cursor_manager.py | 224 +++++++++++++----- .../code_edit_plaintext.py | 70 +++++- .../utils/multi_cursor/cursor_positions.py | 23 ++ test/test_multi_cursor.py | 105 +++++++- 4 files changed, 355 insertions(+), 67 deletions(-) diff --git a/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py b/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py index d4a837a4..569e6eb1 100644 --- a/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py +++ b/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py @@ -12,7 +12,8 @@ from PySide6.QtGui import QTextCursor from je_editor.utils.multi_cursor.cursor_positions import ( - add_position, clamp_positions, column_caret_columns, column_span, toggle_position + add_position, clamp_positions, column_caret_columns, column_span, + positions_after_replacing, toggle_position ) @@ -28,6 +29,8 @@ def __init__(self, code_edit) -> None: """ self._code_edit = code_edit self._positions: list[int] = [] + # 有選取範圍的游標:位置對應它的錨點 / A caret with a selection, mapped to its anchor + self._anchors: dict[int, int] = {} @property def active(self) -> bool: @@ -48,6 +51,7 @@ def clear(self) -> bool: if not self._positions: return False self._positions = [] + self._anchors = {} self._code_edit.viewport().update() return True @@ -99,54 +103,90 @@ def _edit_targets(self) -> list[int]: """ return sorted({*self._positions, self._code_edit.textCursor().position()}) - def _apply_at_targets( - self, edit, shift_per_target: int, shift_others: int | None = None) -> None: - """ - 由後往前在每個位置套用一次編輯,再把游標移到新位置 - Apply one edit at each position from the end backwards, then move the - carets to where they ended up. - - 由後往前做,先做的編輯就不會影響還沒處理的位置;做完之後,第 n 個位置前面 - 共發生了 n 次編輯,加上自己那一次,所以位移量是 ``(n + 1) * 每次的位移``。 - Working backwards keeps an edit from moving a position not yet handled. - Afterwards, the n-th position has n edits before it plus its own, so it - moves by ``(n + 1) * shift_per_target``. - - :param edit: 對單一位置執行的編輯 / the edit to run at one position - :param shift_per_target: 這個位置自己編輯後要移動多少(插入為正,Backspace - 為負,Delete 為 0,因為刪的是游標後面的字元) - how far this position itself moves after its own edit: positive to - insert, negative for Backspace, zero for Delete, which removes the - character after the caret - :param shift_others: 每次編輯讓「後面的位置」移動多少;省略時與 - *shift_per_target* 相同 - how far each edit moves the positions after it; defaults to - *shift_per_target* - """ - following = shift_per_target if shift_others is None else shift_others - targets = self._edit_targets() - main_position = self._code_edit.textCursor().position() + def selections(self) -> list[tuple[int, int]]: + """ + 取得每個額外游標的選取範圍 + Each extra caret's selection. + + :return: ``(起, 訖)`` 的清單,只含真的有選取的游標 + / the ``(start, end)`` pairs, only for carets that have a selection + """ + return sorted( + (min(anchor, position), max(anchor, position)) + for position, anchor in self._anchors.items() + if anchor != position and position in self._positions + ) + + def has_selections(self) -> bool: + """是否有任何游標帶著選取範圍 / Whether any caret holds a selection.""" + return bool(self.selections()) or self._code_edit.textCursor().hasSelection() + + def _range_for(self, position: int) -> tuple[int, int]: + """ + 取得一個游標涵蓋的範圍 + The range one caret covers. + + 有選取範圍就是那一段,否則是游標本身(長度為零)。 + A caret with a selection covers it, and one without covers just itself. + + :param position: 游標位置 / the caret's position + :return: ``(起, 訖)``,訖不含 / ``(start, end)``, end exclusive + """ + if position == self._code_edit.textCursor().position(): + cursor = self._code_edit.textCursor() + if cursor.hasSelection(): + return cursor.selectionStart(), cursor.selectionEnd() + anchor = self._anchors.get(position, position) + return min(anchor, position), max(anchor, position) + + def _replace_ranges(self, ranges: list[tuple[int, int]], text: str) -> None: + """ + 把每個範圍換成同一段文字,並把游標移到各自的新位置 + Replace every range with the same text, then move each caret after its own. + + 由後往前改寫,先做的改寫就不會挪動還沒處理的範圍。 + The rewrite runs back to front, so an earlier one cannot move a range that + has not been handled yet. + + :param ranges: 要改寫的範圍 / the ranges to rewrite + :param text: 換上去的文字 / the text to put in their place + """ + ordered = sorted(ranges) + main_range = self._range_for(self._code_edit.textCursor().position()) cursor = self._code_edit.textCursor() cursor.beginEditBlock() try: - for position in reversed(targets): - edit(cursor, position) + for start, end in reversed(ordered): + cursor.setPosition(start) + cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor) + cursor.insertText(text) finally: cursor.endEditBlock() - moved = { - position: position + index * following + shift_per_target - for index, position in enumerate(targets) - } + moved = dict(zip(ordered, positions_after_replacing(ordered, len(text)))) + limit = self._document_limit() + self._anchors = {} + self._positions = clamp_positions( + [moved[item] for item in ordered if item != main_range], limit) main = self._code_edit.textCursor() - main.setPosition(min( - max(0, moved.get(main_position, main_position)), self._document_limit())) + main.setPosition(min(max(0, moved.get(main_range, main_range[0])), limit)) self._code_edit.setTextCursor(main) - self._positions = clamp_positions( - [moved[position] for position in self._positions if position in moved], - self._document_limit()) self._code_edit.viewport().update() + def _all_ranges(self, fallback) -> list[tuple[int, int]]: + """ + 取得每個游標要編輯的範圍 + The range each caret edits. + + :param fallback: 沒有選取範圍時要用的範圍 / what to use for a caret without a selection + :return: 每個游標的範圍 / one range per caret + """ + ranges = [] + for position in self._edit_targets(): + start, end = self._range_for(position) + ranges.append((start, end) if end > start else fallback(position)) + return ranges + def insert_text(self, text: str) -> bool: """ 在每個游標插入文字 @@ -157,12 +197,11 @@ def insert_text(self, text: str) -> bool: """ if not self._positions or not text: return False - - def insert(cursor: QTextCursor, position: int) -> None: - cursor.setPosition(position) - cursor.insertText(text) - - self._apply_at_targets(insert, len(text)) + # 有選取範圍的游標是「取代」,沒有的是「插入」;兩者都是把範圍換成這段文字 + # A caret with a selection replaces it and one without inserts; both are + # the same operation on a range + self._replace_ranges( + self._all_ranges(lambda position: (position, position)), text) return True def delete_before(self) -> bool: @@ -178,14 +217,12 @@ def delete_before(self) -> bool: :return: 有刪除時為 ``True`` / ``True`` when anything was deleted """ - if not self._positions or any(position <= 0 for position in self._edit_targets()): + if not self._positions: return False - - def delete(cursor: QTextCursor, position: int) -> None: - cursor.setPosition(position) - cursor.deletePreviousChar() - - self._apply_at_targets(delete, -1) + ranges = self._all_ranges(lambda position: (position - 1, position)) + if any(start < 0 for start, _end in ranges): + return False + self._replace_ranges(ranges, "") return True def delete_after(self) -> bool: @@ -200,17 +237,12 @@ def delete_after(self) -> bool: :return: 有刪除時為 ``True`` / ``True`` when anything was deleted """ limit = self._document_limit() - if not self._positions or any(position >= limit for position in self._edit_targets()): + if not self._positions: return False - - def delete(cursor: QTextCursor, position: int) -> None: - cursor.setPosition(position) - cursor.deleteChar() - - # 刪除的是游標之後的字元,游標自己不動,只有其後的位置要往前 - # The character after the caret goes, so the caret stays where it is and - # only what follows shifts back - self._apply_at_targets(delete, 0, shift_others=-1) + ranges = self._all_ranges(lambda position: (position, position + 1)) + if any(end > limit for _start, end in ranges): + return False + self._replace_ranges(ranges, "") return True def insert_newline(self) -> bool: @@ -237,6 +269,7 @@ def move_all(self, offset: int) -> bool: if not self._positions: return False limit = self._document_limit() + self._anchors = {} self._positions = clamp_positions( [position + offset for position in self._positions], limit) self._move_primary_to(self._code_edit.textCursor().position() + offset) @@ -256,6 +289,7 @@ def move_all_vertically(self, direction: int) -> bool: """ if not self._positions: return False + self._anchors = {} # 沒有那一行的游標留在原地,而不是消失 # A caret with no line to move to stays where it is rather than vanishing self._positions = clamp_positions( @@ -265,6 +299,71 @@ def move_all_vertically(self, direction: int) -> bool: self._code_edit.textCursor().position(), direction)) return True + def extend_all(self, offset: int) -> bool: + """ + 以每個游標為基準左右擴大選取範圍 + Extend every caret's selection left or right. + + :param offset: 移動量(負數往左)/ how far to move, negative for left + :return: 有擴大時為 ``True`` / ``True`` when the selections grew + """ + return self._extend(lambda position: position + offset) + + def extend_all_vertically(self, direction: int) -> bool: + """ + 以每個游標為基準上下擴大選取範圍 + Extend every caret's selection one line up or down. + + :param direction: ``-1`` 為上一行,``1`` 為下一行 / ``-1`` up, ``1`` down + :return: 有擴大時為 ``True`` / ``True`` when the selections grew + """ + return self._extend(lambda position: self._neighbour_or_here(position, direction)) + + def extend_all_to_line_edge(self, to_end: bool) -> bool: + """ + 把每個游標的選取範圍延伸到所在行的行首或行尾 + Extend every caret's selection to the start or end of its own line. + + :param to_end: ``True`` 延伸到行尾 / ``True`` for the end of the line + :return: 有擴大時為 ``True`` / ``True`` when the selections grew + """ + return self._extend(lambda position: self._line_edge(position, to_end)) + + def _extend(self, move) -> bool: + """ + 移動每個游標但保留錨點,選取範圍因此跟著長大 + Move every caret while keeping its anchor, so the selection grows with it. + + 錨點記的是選取開始的地方;已經在選取中的游標沿用原本的錨點,其餘就以目前 + 位置作為錨點。 + The anchor is where the selection began: a caret already selecting keeps + the one it has, and any other takes its current position as the anchor. + + :param move: 算出新位置的函式 / works out the new position + :return: 有移動時為 ``True`` / ``True`` when the carets moved + """ + if not self._positions: + return False + limit = self._document_limit() + anchors: dict[int, int] = {} + moved: list[int] = [] + for position in self._positions: + anchor = self._anchors.get(position, position) + landed = min(max(0, move(position)), limit) + moved.append(landed) + anchors[landed] = anchor + self._positions = clamp_positions(moved, limit) + self._anchors = { + position: anchor for position, anchor in anchors.items() + if position in self._positions + } + main = self._code_edit.textCursor() + main.setPosition(min(max(0, move(main.position())), limit), + QTextCursor.MoveMode.KeepAnchor) + self._code_edit.setTextCursor(main) + self._code_edit.viewport().update() + return True + def _neighbour_or_here(self, position: int, direction: int) -> int: """上下一行的同一欄,沒有那一行時維持原位 / The same column one line away, or where it is.""" moved = self._line_neighbour(position, direction) @@ -280,6 +379,7 @@ def move_all_to_line_edge(self, to_end: bool) -> bool: """ if not self._positions: return False + self._anchors = {} self._positions = clamp_positions( [self._line_edge(position, to_end) for position in self._positions], self._document_limit()) diff --git a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py index 163338b9..d0a83d5d 100644 --- a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +++ b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py @@ -118,6 +118,13 @@ def run(self) -> None: # markers because it spawns a subprocess _LINT_REFRESH_DELAY_MS = 900 +# 額外游標選取範圍的底色透明度;夠淡才不會蓋掉底下的文字 +# How solid an extra caret's selection wash is; faint enough to read through +_EXTRA_SELECTION_ALPHA = 70 +# 選取範圍剛好落在行尾時仍畫出的最小寬度 +# The width still drawn when a selection ends exactly at a line's end +_EMPTY_SELECTION_WIDTH = 2 + # 多重游標啟用時,這些按鍵各自對應一個整批動作 # With extra carets active, each of these keys drives one batched action _MULTI_CURSOR_KEYS = { @@ -134,6 +141,17 @@ def run(self) -> None: Qt.Key.Key_End: lambda manager: manager.move_all_to_line_edge(True), } +# 按住 Shift 時,同樣的按鍵改為擴大每個游標的選取範圍 +# With Shift held, the same keys extend each caret's selection instead +_MULTI_CURSOR_SHIFT_KEYS = { + Qt.Key.Key_Left: lambda manager: manager.extend_all(-1), + Qt.Key.Key_Right: lambda manager: manager.extend_all(1), + Qt.Key.Key_Up: lambda manager: manager.extend_all_vertically(-1), + Qt.Key.Key_Down: lambda manager: manager.extend_all_vertically(1), + Qt.Key.Key_Home: lambda manager: manager.extend_all_to_line_edge(False), + Qt.Key.Key_End: lambda manager: manager.extend_all_to_line_edge(True), +} + def _document_position(document: QTextDocument, line: int, column: int) -> Union[int, None]: """ @@ -2597,9 +2615,11 @@ def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None: super().mouseReleaseEvent(event) def _paint_extra_cursors(self) -> None: - """畫出每個額外游標 / Draw each extra caret.""" + """畫出每個額外游標與它選取的範圍 / Draw each extra caret and what it selects.""" painter = QPainter(self.viewport()) - painter.setPen(actually_color_dict.get("extra_cursor_color")) + colour = actually_color_dict.get("extra_cursor_color") + self._paint_extra_selections(painter, colour) + painter.setPen(colour) cursor = QTextCursor(self.document()) for position in self.multi_cursor_manager.positions(): cursor.setPosition(min(position, self.document().characterCount() - 1)) @@ -2607,6 +2627,48 @@ def _paint_extra_cursors(self) -> None: painter.drawLine(rect.left(), rect.top(), rect.left(), rect.bottom()) painter.end() + def _paint_extra_selections(self, painter: QPainter, colour) -> None: + """ + 把每個額外游標選取的範圍畫成淡色底 + Wash each extra caret's selection in a faint version of the caret colour. + + 逐行畫:一段選取可能跨好幾行,每一行的矩形都要各自算。 + Line by line: a selection can span several, and each needs its own rect. + """ + tint = QColor(colour) + tint.setAlpha(_EXTRA_SELECTION_ALPHA) + cursor = QTextCursor(self.document()) + for start, end in self.multi_cursor_manager.selections(): + for line_start, line_end in self._selection_rows(start, end): + cursor.setPosition(line_start) + left = self.cursorRect(cursor) + cursor.setPosition(line_end) + right = self.cursorRect(cursor) + painter.fillRect( + left.left(), left.top(), + max(right.left() - left.left(), _EMPTY_SELECTION_WIDTH), + left.height(), tint) + + def _selection_rows(self, start: int, end: int) -> list[tuple[int, int]]: + """ + 把一段選取切成每一行各自的範圍 + Split a selection into one range per line it covers. + + :param start: 選取的起點 / where the selection starts + :param end: 選取的終點 / where it ends + :return: 每一行的 ``(起, 訖)`` / the ``(start, end)`` of each line's part + """ + document = self.document() + rows: list[tuple[int, int]] = [] + block = document.findBlock(start) + while block.isValid() and block.position() < end: + row_start = max(start, block.position()) + row_end = min(end, block.position() + len(block.text())) + if row_end >= row_start: + rows.append((row_start, row_end)) + block = block.next() + return rows + def _handle_multi_cursor_key(self, event: QKeyEvent) -> bool: """ 把輸入與退格套用到每個額外游標 @@ -2618,6 +2680,10 @@ def _handle_multi_cursor_key(self, event: QKeyEvent) -> bool: if not self.multi_cursor_manager.active: return False key = event.key() + if event.modifiers() & Qt.KeyboardModifier.ShiftModifier: + extend = _MULTI_CURSOR_SHIFT_KEYS.get(key) + if extend is not None: + return extend(self.multi_cursor_manager) handler = _MULTI_CURSOR_KEYS.get(key) if handler is not None: return handler(self.multi_cursor_manager) diff --git a/je_editor/utils/multi_cursor/cursor_positions.py b/je_editor/utils/multi_cursor/cursor_positions.py index c555a302..b4d6627d 100644 --- a/je_editor/utils/multi_cursor/cursor_positions.py +++ b/je_editor/utils/multi_cursor/cursor_positions.py @@ -126,6 +126,29 @@ def column_caret_columns( return [min(target, max(0, length)) for length in line_lengths] +def positions_after_replacing(ranges: list[tuple[int, int]], length: int) -> list[int]: + """ + 把每個範圍都換成等長的文字之後,各游標落在哪裡 + Where each caret lands once every range is replaced by text of one length. + + 範圍由前往後累積位移:排在越後面的範圍,前面被改寫的次數越多。游標停在自己 + 那段新文字的結尾,接著輸入就會接在後面。 + The shift accumulates front to back, since a later range has more rewrites + ahead of it. Each caret ends up after its own new text, so typing carries on + from there. + + :param ranges: 每個游標涵蓋的範圍 ``(起, 訖)``,訖不含 / each caret's ``(start, end)``, end exclusive + :param length: 換上去的文字長度 / the length of the text replacing each range + :return: 各游標的新位置(依原順序)/ the new caret positions, in the given order + """ + shift = 0 + moved: list[int] = [] + for start, end in sorted(ranges): + moved.append(start + shift + length) + shift += length - (end - start) + return moved + + def clamp_positions(positions: list[int], limit: int) -> list[int]: """ 把位置限制在文件範圍內 diff --git a/test/test_multi_cursor.py b/test/test_multi_cursor.py index 8f32f646..431619b2 100644 --- a/test/test_multi_cursor.py +++ b/test/test_multi_cursor.py @@ -13,6 +13,7 @@ clamp_positions, column_caret_columns, column_span, + positions_after_replacing, remove_position, shift_after_delete, shift_after_insert, @@ -68,6 +69,29 @@ def test_clamping_an_empty_document(self): assert clamp_positions([3], -1) == [] +class TestPositionsAfterReplacing: + """Where each caret lands once every range is swapped for the same text.""" + + def test_an_insertion_puts_the_caret_after_it(self): + assert positions_after_replacing([(3, 3)], 2) == [5] + + def test_a_later_caret_absorbs_the_earlier_insertions(self): + assert positions_after_replacing([(0, 0), (5, 5)], 1) == [1, 7] + + def test_replacing_accounts_for_what_was_removed(self): + # "XXX" -> "Z" at 1 and 7 leaves the second caret two places earlier. + assert positions_after_replacing([(1, 4), (7, 10)], 1) == [2, 6] + + def test_deleting_pulls_the_carets_back(self): + assert positions_after_replacing([(1, 2), (5, 6)], 0) == [1, 4] + + def test_ranges_are_handled_in_position_order(self): + assert positions_after_replacing([(5, 5), (0, 0)], 1) == [1, 7] + + def test_no_ranges_gives_no_positions(self): + assert positions_after_replacing([], 3) == [] + + class TestColumnGeometry: def test_span_downwards(self): assert list(column_span(2, 5)) == [2, 3, 4, 5] @@ -131,9 +155,9 @@ def _type(editor, text: str) -> None: QTest.keyClicks(editor, text) -def _press(editor, key) -> None: - """Send one non-printable key.""" - QTest.keyClick(editor, key) +def _press(editor, key, modifier=Qt.KeyboardModifier.NoModifier) -> None: + """Send one non-printable key, optionally with a modifier held.""" + QTest.keyClick(editor, key, modifier) class TestMultiCursorEditing: @@ -305,6 +329,81 @@ def test_vertical_movement_needs_extra_carets(self, editor): editor.setPlainText("alpha\nbravo") assert editor.multi_cursor_manager.move_all_vertically(1) is False + def test_shift_right_selects_at_every_caret(self, editor): + editor.setPlainText("abcd\nefgh") + editor.multi_cursor_manager.toggle_at(0) + cursor = editor.textCursor() + cursor.setPosition(5) + editor.setTextCursor(cursor) + _press(editor, Qt.Key.Key_Right, Qt.KeyboardModifier.ShiftModifier) + assert editor.multi_cursor_manager.selections() == [(0, 1)] + assert editor.textCursor().selectedText() == "e" + + def test_the_selection_keeps_growing(self, editor): + editor.setPlainText("abcd") + editor.multi_cursor_manager.toggle_at(0) + _press(editor, Qt.Key.Key_Right, Qt.KeyboardModifier.ShiftModifier) + _press(editor, Qt.Key.Key_Right, Qt.KeyboardModifier.ShiftModifier) + assert editor.multi_cursor_manager.selections() == [(0, 2)] + + def test_selecting_backwards_works_too(self, editor): + editor.setPlainText("abcd") + editor.multi_cursor_manager.toggle_at(3) + _press(editor, Qt.Key.Key_Left, Qt.KeyboardModifier.ShiftModifier) + assert editor.multi_cursor_manager.selections() == [(2, 3)] + + def test_shift_end_selects_to_the_line_end(self, editor): + editor.setPlainText("abcd\nefgh") + editor.multi_cursor_manager.toggle_at(1) + _press(editor, Qt.Key.Key_End, Qt.KeyboardModifier.ShiftModifier) + assert editor.multi_cursor_manager.selections() == [(1, 4)] + + def test_typing_replaces_every_selection(self, editor): + editor.setPlainText("aXc\ndXf") + editor.multi_cursor_manager.toggle_at(1) + cursor = editor.textCursor() + cursor.setPosition(5) + editor.setTextCursor(cursor) + _press(editor, Qt.Key.Key_Right, Qt.KeyboardModifier.ShiftModifier) + _press(editor, Qt.Key.Key_Y) + assert editor.toPlainText() == "ayc\ndyf" + + def test_backspace_removes_every_selection(self, editor): + editor.setPlainText("aXc\ndXf") + editor.multi_cursor_manager.toggle_at(1) + cursor = editor.textCursor() + cursor.setPosition(5) + editor.setTextCursor(cursor) + _press(editor, Qt.Key.Key_Right, Qt.KeyboardModifier.ShiftModifier) + _press(editor, Qt.Key.Key_Backspace) + assert editor.toPlainText() == "ac\ndf" + + def test_replacing_a_longer_selection_still_lines_up(self, editor): + editor.setPlainText("aXXXc\ndXXXf") + editor.multi_cursor_manager.toggle_at(1) + cursor = editor.textCursor() + cursor.setPosition(7) + editor.setTextCursor(cursor) + for _ in range(3): + _press(editor, Qt.Key.Key_Right, Qt.KeyboardModifier.ShiftModifier) + _press(editor, Qt.Key.Key_Z) + assert editor.toPlainText() == "azc\ndzf" + + def test_moving_without_shift_drops_the_selection(self, editor): + editor.setPlainText("abcd") + editor.multi_cursor_manager.toggle_at(0) + _press(editor, Qt.Key.Key_Right, Qt.KeyboardModifier.ShiftModifier) + _press(editor, Qt.Key.Key_Right) + assert editor.multi_cursor_manager.selections() == [] + + def test_painting_a_selection_does_not_raise(self, editor): + editor.setPlainText("abcd\nefgh") + editor.multi_cursor_manager.toggle_at(0) + _press(editor, Qt.Key.Key_Down, Qt.KeyboardModifier.ShiftModifier) + editor.show() + QApplication.processEvents() + editor.hide() + def test_carets_never_move_outside_the_document(self, editor): editor.setPlainText("ab") editor.multi_cursor_manager.toggle_at(0) From 7b433e2654d2e1712a5f1df58d2e9963cb0e5f7f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 14:56:51 +0800 Subject: [PATCH 09/16] Give the editor colours a light set Everything the editor paints itself -- line numbers, indent guides, change markers, the minimap, syntax -- had one set of colours, tuned against a dark background. Choosing a light qt-material style left the minimap dark, the guides invisible and the syntax washed out. There are now two sets, and picking a style moves the colours with it. A colour the user chose stays: the test is whether a value is still one of the known defaults, since the settings file writes every colour back and cannot say which ones were deliberate. --- je_editor/pyside_ui/main_ui/main_editor.py | 9 +- .../menu/style_menu/build_style_menu.py | 21 +++ .../save_settings/user_color_setting_file.py | 47 +++---- je_editor/utils/theme/__init__.py | 0 je_editor/utils/theme/theme_colors.py | 127 ++++++++++++++++++ test/test_theme_colors.py | 104 ++++++++++++++ 6 files changed, 278 insertions(+), 30 deletions(-) create mode 100644 je_editor/utils/theme/__init__.py create mode 100644 je_editor/utils/theme/theme_colors.py create mode 100644 test/test_theme_colors.py diff --git a/je_editor/pyside_ui/main_ui/main_editor.py b/je_editor/pyside_ui/main_ui/main_editor.py index 63581348..1e6b0c2b 100644 --- a/je_editor/pyside_ui/main_ui/main_editor.py +++ b/je_editor/pyside_ui/main_ui/main_editor.py @@ -25,9 +25,9 @@ from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget from je_editor.pyside_ui.main_ui.menu.set_menu_bar import set_menu_bar from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( + apply_theme_colors, write_user_color_setting, read_user_color_setting, - update_actually_color_dict, actually_color_dict ) from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import ( @@ -368,9 +368,12 @@ def startup_setting(self) -> None: self._restore_open_files_session() app = QApplication.instance() + style_name = user_setting_dict.get("ui_style", "dark_amber.xml") if app is not None: - self.apply_stylesheet(app, user_setting_dict.get("ui_style", "dark_amber.xml")) - update_actually_color_dict() + self.apply_stylesheet(app, style_name) + # 顏色跟著樣式走:淺色樣式要用淺色底調出來的那一組 + # The colours follow the style: a light one needs the light set + apply_theme_colors(style_name) def _open_file_paths(self) -> list[str]: """取得所有分頁目前開啟的檔案 / Every tab's currently open file.""" diff --git a/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py b/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py index 7759c467..848dc050 100644 --- a/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py @@ -9,6 +9,7 @@ # 匯入使用者設定字典,用來保存 UI 樣式設定 # Import user settings dictionary for saving UI style preferences +from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import apply_theme_colors from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict # 匯入日誌紀錄器 # Import logger instance @@ -121,3 +122,23 @@ def set_style(ui_we_want_to_set: EditorMain, action: QAction) -> None: # 更新使用者設定,保存目前選擇的樣式 # Update user settings dictionary to persist the chosen style user_setting_dict.update({"ui_style": action.text()}) + + # 編輯器自己畫的顏色也要跟著換,否則淺色樣式配深色底調出來的標記會看不清楚 + # The colours the editor paints itself follow along, or a light style would + # be left with markers tuned for a dark background + apply_theme_colors(action.text()) + _repaint_editors(ui_we_want_to_set) + + +def _repaint_editors(ui_we_want_to_set: EditorMain) -> None: + """讓每個編輯分頁用新顏色重畫 / Repaint every editor tab in the new colours.""" + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + tab_widget = getattr(ui_we_want_to_set, "tab_widget", None) + if tab_widget is None: + return + for index in range(tab_widget.count()): + widget = tab_widget.widget(index) + if isinstance(widget, EditorWidget): + widget.code_edit.highlight_current_line() + widget.code_edit.viewport().update() + widget.code_edit.line_number.update() diff --git a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py index 9fea4d8c..204340e7 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py +++ b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py @@ -7,6 +7,7 @@ from je_editor.pyside_ui.main_ui.save_settings.setting_utils import write_setting from je_editor.utils.json.json_file import read_json from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.theme.theme_colors import DARK_COLORS, retheme def _to_qcolor(key: str, fallback: list) -> QColor: @@ -59,34 +60,10 @@ def update_actually_color_dict() -> None: ) -# 使用者設定的顏色字典 (以 RGB list 表示) -# User-defined color dictionary (stored as RGB lists) +# 使用者設定的顏色字典 (以 RGB list 表示),預設為深色底的那一組 +# User-defined color dictionary (stored as RGB lists), starting from the dark set user_setting_color_dict: Dict[str, list] = { - "line_number_color": [255, 255, 255], - "line_number_background_color": [179, 179, 179], - "current_line_color": [148, 148, 184], - "normal_output_color": [255, 255, 255], - "error_output_color": [255, 0, 0], - "warning_output_color": [204, 204, 0], - "bookmark_marker_color": [66, 165, 245], - "fold_marker_color": [120, 120, 120], - "occurrence_highlight_color": [80, 90, 60], - "diff_added_marker_color": [76, 175, 80], - "diff_modified_marker_color": [255, 167, 38], - "diff_removed_marker_color": [229, 57, 53], - "lint_underline_color": [255, 138, 101], - "blame_annotation_color": [130, 130, 130], - "indent_guide_color": [90, 90, 110], - "minimap_background_color": [40, 40, 48], - "minimap_line_color": [130, 130, 150], - "minimap_viewport_color": [80, 80, 110], - "extra_cursor_color": [255, 215, 64], - "breakpoint_marker_color": [229, 57, 53], - "syntax_keyword_color": [86, 156, 214], - "syntax_string_color": [206, 145, 120], - "syntax_comment_color": [106, 153, 85], - "syntax_number_color": [181, 206, 168], - "trailing_whitespace_color": [120, 70, 70] + key: list(value) for key, value in DARK_COLORS.items() } # 實際使用的顏色字典 (以 QColor 表示) @@ -98,6 +75,22 @@ def update_actually_color_dict() -> None: update_actually_color_dict() +def apply_theme_colors(style_name: str) -> None: + """ + 依視窗樣式換上對應的編輯器顏色 + Move the editor's own colours to the ones that suit the window style. + + 使用者親自挑過的顏色不會被蓋掉;只有還是預設值的那些會跟著主題走。 + A colour the user picked is left alone; only those still at a default follow + the theme. + + :param style_name: qt-material 的樣式檔名 / the qt-material style file name + """ + jeditor_logger.info(f"user_color_setting_file.py apply_theme_colors {style_name}") + user_setting_color_dict.update(retheme(user_setting_color_dict, style_name)) + update_actually_color_dict() + + def write_user_color_setting() -> None: """ 將使用者顏色設定寫入 JSON 檔案 diff --git a/je_editor/utils/theme/__init__.py b/je_editor/utils/theme/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/theme/theme_colors.py b/je_editor/utils/theme/theme_colors.py new file mode 100644 index 00000000..16bb2576 --- /dev/null +++ b/je_editor/utils/theme/theme_colors.py @@ -0,0 +1,127 @@ +""" +編輯器自有顏色的深色與淺色預設值 +The editor's own colours, in a dark and a light set. + +視窗樣式來自 qt-material,但編輯器自己畫的東西——行號、縮排參考線、變更標記、 +語法高亮——用的是這裡的顏色。它們原本只有一組值,是照深色底調的,所以選了淺色 +樣式之後那些顏色會淡到看不見。 +The window style comes from qt-material, but everything the editor paints itself +— line numbers, indent guides, change markers, syntax — uses the colours here. +There used to be only one set, tuned against a dark background, so choosing a +light style washed them out. + +純資料與純邏輯,不含 Qt。 +Pure data and pure logic, with no Qt. +""" +from __future__ import annotations + +from typing import Dict, List + +Palette = Dict[str, List[int]] + +# 深色底用的顏色 / The colours for a dark background +DARK_COLORS: Palette = { + "line_number_color": [255, 255, 255], + "line_number_background_color": [179, 179, 179], + "current_line_color": [148, 148, 184], + "normal_output_color": [255, 255, 255], + "error_output_color": [255, 0, 0], + "warning_output_color": [204, 204, 0], + "bookmark_marker_color": [66, 165, 245], + "fold_marker_color": [120, 120, 120], + "occurrence_highlight_color": [80, 90, 60], + "diff_added_marker_color": [76, 175, 80], + "diff_modified_marker_color": [255, 167, 38], + "diff_removed_marker_color": [229, 57, 53], + "lint_underline_color": [255, 138, 101], + "blame_annotation_color": [130, 130, 130], + "indent_guide_color": [90, 90, 110], + "minimap_background_color": [40, 40, 48], + "minimap_line_color": [130, 130, 150], + "minimap_viewport_color": [80, 80, 110], + "extra_cursor_color": [255, 215, 64], + "breakpoint_marker_color": [229, 57, 53], + "syntax_keyword_color": [86, 156, 214], + "syntax_string_color": [206, 145, 120], + "syntax_comment_color": [106, 153, 85], + "syntax_number_color": [181, 206, 168], + "trailing_whitespace_color": [120, 70, 70], +} + +# 淺色底用的顏色 / The colours for a light background +LIGHT_COLORS: Palette = { + "line_number_color": [90, 90, 90], + "line_number_background_color": [232, 232, 232], + "current_line_color": [225, 232, 245], + "normal_output_color": [30, 30, 30], + "error_output_color": [198, 40, 40], + "warning_output_color": [150, 120, 0], + "bookmark_marker_color": [21, 101, 192], + "fold_marker_color": [130, 130, 130], + "occurrence_highlight_color": [255, 238, 160], + "diff_added_marker_color": [46, 125, 50], + "diff_modified_marker_color": [230, 126, 0], + "diff_removed_marker_color": [198, 40, 40], + "lint_underline_color": [216, 67, 21], + "blame_annotation_color": [145, 145, 145], + "indent_guide_color": [205, 205, 215], + "minimap_background_color": [245, 245, 248], + "minimap_line_color": [160, 160, 175], + "minimap_viewport_color": [200, 205, 230], + "extra_cursor_color": [216, 98, 0], + "breakpoint_marker_color": [198, 40, 40], + "syntax_keyword_color": [0, 0, 192], + "syntax_string_color": [163, 21, 21], + "syntax_comment_color": [0, 128, 0], + "syntax_number_color": [9, 134, 88], + "trailing_whitespace_color": [255, 205, 205], +} + + +def is_light_style(style_name: str | None) -> bool: + """ + 判斷 qt-material 的樣式名稱是不是淺色的 + Whether a qt-material style name is a light one. + + :param style_name: 樣式檔名,例如 ``light_blue.xml`` / a style file name + :return: 淺色時為 ``True`` / ``True`` when it is light + """ + return str(style_name or "").strip().lower().startswith("light") + + +def palette_for(style_name: str | None) -> Palette: + """ + 取得某個樣式該用的顏色組 + The colour set a style should use. + + :param style_name: 樣式檔名 / the style file name + :return: 顏色組的副本 / a copy of the colours + """ + source = LIGHT_COLORS if is_light_style(style_name) else DARK_COLORS + return {key: list(value) for key, value in source.items()} + + +def retheme(current: Palette, style_name: str | None) -> Palette: + """ + 換主題時把預設色換成新主題的,並保留使用者自己挑過的顏色 + Swap the defaults for the new theme's, keeping any colour the user picked. + + 判斷方式是「這個值還是某一組的預設值嗎」:是的話就換成新主題的預設值,不是的 + 話代表使用者動過,原樣保留。設定檔會把整份顏色寫回去,因此無法只靠「檔案裡有 + 沒有這個鍵」來判斷。 + The test is whether a value is still one of the known defaults: if so it takes + the new theme's default, and if not the user has chosen it and it stays. The + settings file writes every colour back, so "is the key in the file" cannot + tell the two apart. + + :param current: 目前生效的顏色 / the colours in effect now + :param style_name: 要套用的樣式檔名 / the style being applied + :return: 套用後的顏色 / the colours afterwards + """ + target = palette_for(style_name) + result = dict(current) + for key, value in target.items(): + existing = current.get(key) + if existing is None or existing in (DARK_COLORS.get(key), LIGHT_COLORS.get(key)): + result[key] = list(value) + return result diff --git a/test/test_theme_colors.py b/test/test_theme_colors.py new file mode 100644 index 00000000..c08d1a17 --- /dev/null +++ b/test/test_theme_colors.py @@ -0,0 +1,104 @@ +"""Tests that the editor's own colours follow the chosen window style.""" +from __future__ import annotations + +import pytest + +from je_editor.utils.theme.theme_colors import ( + DARK_COLORS, LIGHT_COLORS, is_light_style, palette_for, retheme +) + + +class TestRecognisingALightStyle: + @pytest.mark.parametrize("name", [ + "light_blue.xml", "light_amber.xml", "LIGHT_RED.XML", " light_teal.xml ", + ]) + def test_a_light_style_is_recognised(self, name): + assert is_light_style(name) is True + + @pytest.mark.parametrize("name", ["dark_amber.xml", "dark_teal.xml", "", None]) + def test_anything_else_is_dark(self, name): + assert is_light_style(name) is False + + +class TestThePalettes: + def test_both_sets_cover_the_same_colours(self): + assert set(DARK_COLORS) == set(LIGHT_COLORS) + + def test_every_colour_is_a_valid_rgb_triple(self): + for palette in (DARK_COLORS, LIGHT_COLORS): + for key, value in palette.items(): + assert len(value) == 3, key + assert all(0 <= channel <= 255 for channel in value), key + + def test_the_light_set_really_is_lighter_behind_the_text(self): + # The minimap draws a background, so it has to invert with the theme. + assert sum(LIGHT_COLORS["minimap_background_color"]) > \ + sum(DARK_COLORS["minimap_background_color"]) + + def test_a_light_style_gets_the_light_set(self): + assert palette_for("light_blue.xml") == LIGHT_COLORS + + def test_a_dark_style_gets_the_dark_set(self): + assert palette_for("dark_amber.xml") == DARK_COLORS + + def test_the_palette_is_a_copy(self): + palette = palette_for("dark_amber.xml") + palette["line_number_color"][0] = 1 + assert DARK_COLORS["line_number_color"][0] != 1 + + +class TestRethemeing: + def test_a_default_moves_to_the_new_theme(self): + result = retheme(dict(DARK_COLORS), "light_blue.xml") + assert result["minimap_background_color"] == LIGHT_COLORS["minimap_background_color"] + + def test_switching_back_restores_the_dark_default(self): + light = retheme(dict(DARK_COLORS), "light_blue.xml") + assert retheme(light, "dark_amber.xml") == DARK_COLORS + + def test_a_colour_the_user_picked_survives(self): + current = dict(DARK_COLORS) + current["line_number_color"] = [10, 20, 30] + result = retheme(current, "light_blue.xml") + assert result["line_number_color"] == [10, 20, 30] + + def test_the_other_colours_still_move(self): + current = dict(DARK_COLORS) + current["line_number_color"] = [10, 20, 30] + result = retheme(current, "light_blue.xml") + assert result["indent_guide_color"] == LIGHT_COLORS["indent_guide_color"] + + def test_a_missing_colour_is_filled_in(self): + result = retheme({}, "light_blue.xml") + assert result == LIGHT_COLORS + + def test_the_input_is_not_modified(self): + current = dict(DARK_COLORS) + retheme(current, "light_blue.xml") + assert current["indent_guide_color"] == DARK_COLORS["indent_guide_color"] + + +class TestApplyingToTheSettings: + def test_applying_a_light_style_updates_the_live_colours(self): + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( + actually_color_dict, apply_theme_colors, user_setting_color_dict + ) + try: + apply_theme_colors("light_blue.xml") + assert user_setting_color_dict["minimap_background_color"] == \ + LIGHT_COLORS["minimap_background_color"] + # The QColor dictionary the painting reads has to move with it. + colour = actually_color_dict["minimap_background_color"] + assert (colour.red(), colour.green(), colour.blue()) == \ + tuple(LIGHT_COLORS["minimap_background_color"]) + finally: + apply_theme_colors("dark_amber.xml") + + def test_applying_a_dark_style_puts_them_back(self): + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( + apply_theme_colors, user_setting_color_dict + ) + apply_theme_colors("light_blue.xml") + apply_theme_colors("dark_amber.xml") + assert user_setting_color_dict["minimap_background_color"] == \ + DARK_COLORS["minimap_background_color"] From 2d2cf60bcd47f9bfb5b06dd5ffaa078eeb90e660 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 15:00:32 +0800 Subject: [PATCH 10/16] Scan each line once for keywords The generic highlighter held one regular expression per keyword and ran every one of them over every line -- 44 passes per line for TypeScript. They are now a single alternation, which measures 72 microseconds per line down to 17.5, so a 20,000-line file highlights in 0.37s rather than 1.52s. --- .../pyside_ui/code/syntax/generic_syntax.py | 29 ++++++++++-- test/test_generic_syntax.py | 45 +++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/je_editor/pyside_ui/code/syntax/generic_syntax.py b/je_editor/pyside_ui/code/syntax/generic_syntax.py index d9af1192..62d4f415 100644 --- a/je_editor/pyside_ui/code/syntax/generic_syntax.py +++ b/je_editor/pyside_ui/code/syntax/generic_syntax.py @@ -22,6 +22,29 @@ _INSIDE_BLOCK_COMMENT = 1 +def keyword_pattern(keywords: tuple[str, ...]) -> str: + """ + 把所有關鍵字組成一個交替式樣 + Build one alternation covering every keyword. + + 每個關鍵字各一條規則的話,每一行都要重跑幾十次比對;併成一條之後只掃一次, + 大檔案上色因此快上一個數量級。 + A pattern per keyword means running dozens of matches over every line; one + alternation scans it once, which is an order of magnitude off the cost of + highlighting a large file. + + 長的關鍵字排在前面,交替式才不會先被短的吃掉;字界本身也擋得住,但排序讓這件 + 事不必依賴細節。 + Longer keywords come first so a short one cannot claim the match. The word + boundaries already prevent that, but the ordering means not relying on it. + + :param keywords: 該語言的關鍵字 / the language's keywords + :return: 一條正規表示式 / a single regular expression + """ + ordered = sorted(set(keywords), key=lambda word: (-len(word), word)) + return r"\b(?:" + "|".join(re.escape(word) for word in ordered) + r")\b" + + def _format_for(colour_key: str) -> QTextCharFormat: """建立指定顏色的格式 / Build a format in the given colour.""" text_format = QTextCharFormat() @@ -43,10 +66,10 @@ def __init__(self, document: QTextDocument, rules: LanguageRules) -> None: super().__init__(document) self._rules = rules self._patterns: list[tuple[QRegularExpression, QTextCharFormat]] = [] - keyword_format = _format_for("syntax_keyword_color") - for keyword in rules.keywords: + if rules.keywords: self._patterns.append( - (QRegularExpression(rf"\b{re.escape(keyword)}\b"), keyword_format)) + (QRegularExpression(keyword_pattern(rules.keywords)), + _format_for("syntax_keyword_color"))) number_format = _format_for("syntax_number_color") self._patterns.append((QRegularExpression(r"\b\d+(\.\d+)?\b"), number_format)) string_format = _format_for("syntax_string_color") diff --git a/test/test_generic_syntax.py b/test/test_generic_syntax.py index 3a19ccad..15fe79d4 100644 --- a/test/test_generic_syntax.py +++ b/test/test_generic_syntax.py @@ -1,6 +1,7 @@ """Tests for highlighting languages other than Python.""" from __future__ import annotations +import re from unittest.mock import MagicMock, patch import pytest @@ -10,6 +11,50 @@ from je_editor.utils.syntax.language_rules import LANGUAGE_RULES, rules_for, supported_suffixes +class TestKeywordPattern: + """ + All the keywords go into one alternation. Running a pattern per keyword cost + four times as much per line, which a large file feels on open. + """ + + @staticmethod + def _matches(pattern: str, text: str) -> list[str]: + return re.findall(pattern, text) + + def test_a_keyword_matches(self): + from je_editor.pyside_ui.code.syntax.generic_syntax import keyword_pattern + assert self._matches(keyword_pattern(("if", "else")), "if x") == ["if"] + + def test_every_keyword_is_covered(self): + from je_editor.pyside_ui.code.syntax.generic_syntax import keyword_pattern + pattern = keyword_pattern(("if", "else", "return")) + assert self._matches(pattern, "if a else return b") == ["if", "else", "return"] + + def test_a_keyword_inside_a_word_does_not_match(self): + from je_editor.pyside_ui.code.syntax.generic_syntax import keyword_pattern + assert self._matches(keyword_pattern(("in",)), "printing") == [] + + def test_a_longer_keyword_wins_over_its_prefix(self): + from je_editor.pyside_ui.code.syntax.generic_syntax import keyword_pattern + pattern = keyword_pattern(("in", "instanceof")) + assert self._matches(pattern, "a instanceof B") == ["instanceof"] + + def test_a_regex_character_in_a_keyword_is_literal(self): + from je_editor.pyside_ui.code.syntax.generic_syntax import keyword_pattern + assert self._matches(keyword_pattern(("c++",)), "c++ code") == [] + + def test_duplicates_do_not_repeat(self): + from je_editor.pyside_ui.code.syntax.generic_syntax import keyword_pattern + assert keyword_pattern(("if", "if")).count("if") == 1 + + @pytest.mark.parametrize("suffix", sorted(LANGUAGE_RULES)) + def test_every_language_builds_a_usable_pattern(self, suffix): + from je_editor.pyside_ui.code.syntax.generic_syntax import keyword_pattern + rules = rules_for(suffix) + # A pattern that does not compile would break highlighting for the language. + assert re.compile(keyword_pattern(rules.keywords)) is not None + + class TestLanguageRules: @pytest.mark.parametrize("suffix", [".ts", ".js", ".rs", ".go", ".c", ".cpp", ".java"]) def test_common_languages_have_rules(self, suffix): From 5c1f6435a728c91bd6eda154c13c10586714f4f4 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 15:06:29 +0800 Subject: [PATCH 11/16] Show why a test failed, and how much is covered The panel listed outcomes and jumped to a line, but the reason lived only in the terminal it never showed. It now runs with --tb=short, keeps each failure''s traceback, and shows the selected test''s in a pane below the list. A coverage box adds --cov and reports the total beside the summary. It needs pytest-cov in the project being tested rather than in the editor, so leaving it off costs nothing and turning it on says plainly when the plugin is missing. --- .../main_ui/test_panel/test_panel_widget.py | 85 +++++++++-- je_editor/utils/multi_language/english.py | 2 + .../multi_language/traditional_chinese.py | 2 + je_editor/utils/test_runner/pytest_output.py | 93 ++++++++++++ test/test_pytest_panel.py | 142 +++++++++++++++++- 5 files changed, 310 insertions(+), 14 deletions(-) diff --git a/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py b/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py index dc0a0a0c..9c112bba 100644 --- a/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py +++ b/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py @@ -17,14 +17,15 @@ from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtWidgets import ( - QHBoxLayout, QLabel, QLineEdit, QPushButton, QTreeWidget, QTreeWidgetItem, - QVBoxLayout, QWidget + QCheckBox, QHBoxLayout, QLabel, QLineEdit, QPlainTextEdit, QPushButton, + QSplitter, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget ) from je_editor.utils.logging.loggin_instance import jeditor_logger from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper from je_editor.utils.test_runner.pytest_output import ( - PytestResult, failure_for_result, parse_failures, parse_results, parse_summary + PytestResult, failure_for_result, parse_coverage, parse_failures, parse_results, + parse_summary, parse_tracebacks, traceback_for_result ) # 樹狀清單欄位索引 / Column indexes in the tree @@ -35,21 +36,32 @@ TEST_COLUMN_WIDTH = 360 # 單次測試執行的逾時(秒)/ Timeout for one test run RUN_TIMEOUT_SECONDS = 600 +# 清單與追蹤訊息的高度比例 / How the list and the traceback share the height +_RESULT_STRETCH = 3 +_TRACEBACK_STRETCH = 2 -def pytest_command(node_ids: list[str] | None = None) -> list[str]: +def pytest_command(node_ids: list[str] | None = None, + with_coverage: bool = False) -> list[str]: """ 組出執行測試的指令 Build the command that runs the tests. - ``-v`` 才會逐項列出結果,``--tb=line`` 讓每個失敗只印一行位置,剛好夠面板用。 - ``-v`` is what lists each test, and ``--tb=line`` prints one location per - failure, which is exactly what the panel needs. + ``-v`` 才會逐項列出結果;``--tb=short`` 每個失敗印出足夠閱讀的追蹤訊息,同時 + 仍然保留「檔案:行號」那一行,跳轉才有得用。 + ``-v`` is what lists each test, and ``--tb=short`` prints a traceback worth + reading while still carrying the ``file:line`` the jump needs. :param node_ids: 只跑這些測試,``None`` 表示全部 / run only these, or all when ``None`` + :param with_coverage: 是否一併量測覆蓋率 / whether to measure coverage too :return: 引數清單(不經過 shell)/ the argument list, never a shell string """ - command = [sys.executable, "-m", "pytest", "-v", "--tb=line", "-p", "no:cacheprovider"] + command = [sys.executable, "-m", "pytest", "-v", "--tb=short", "-p", "no:cacheprovider"] + if with_coverage: + # 需要目標專案裝有 pytest-cov;沒有的話 pytest 會直接說不認得這個參數 + # This needs pytest-cov in the target project; without it pytest simply + # reports that it does not recognise the argument + command.extend(["--cov=.", "--cov-report=term"]) # 節點名稱是 pytest 自己印出來的,原樣傳回去;仍然是獨立引數,不經過 shell # The node ids came from pytest itself and go straight back as separate # arguments, never through a shell @@ -65,21 +77,25 @@ class PytestRunThread(QThread): finished_output = Signal(str) - def __init__(self, working_dir: str, node_ids: list[str] | None = None, parent=None) -> None: + def __init__(self, working_dir: str, node_ids: list[str] | None = None, + with_coverage: bool = False, parent=None) -> None: """ :param working_dir: 執行測試的目錄 / the directory to run the tests in :param node_ids: 只跑這些測試 / run only these tests + :param with_coverage: 是否一併量測覆蓋率 / whether to measure coverage too :param parent: Qt 父物件 / the Qt parent """ super().__init__(parent) + self.setObjectName("PytestRunThread") self._working_dir = working_dir self._node_ids = node_ids + self._with_coverage = with_coverage def run(self) -> None: """執行測試並回報輸出 / Run the tests and report their output.""" try: completed = subprocess.run( # nosemgrep # noqa: S603 # nosec B603 - pytest_command(self._node_ids), + pytest_command(self._node_ids, self._with_coverage), cwd=self._working_dir, capture_output=True, text=True, @@ -112,6 +128,7 @@ def __init__(self, main_window=None, working_dir: str | None = None) -> None: self._working_dir = working_dir or resolve_working_dir(main_window) self._results: list[PytestResult] = [] self._failures: list = [] + self._tracebacks: dict[str, str] = {} self._thread: PytestRunThread | None = None self.run_button = QPushButton(word.get("test_panel_run")) @@ -123,8 +140,15 @@ def __init__(self, main_window=None, working_dir: str | None = None) -> None: self.filter_edit = QLineEdit() self.filter_edit.setPlaceholderText(word.get("test_panel_filter_placeholder")) self.filter_edit.textChanged.connect(self._render_items) + self.coverage_check = QCheckBox(word.get("test_panel_coverage")) self.status_label = QLabel(word.get("test_panel_ready")) + # 失敗的追蹤訊息:選到某個測試就顯示它的那一段 + # The failing traceback: selecting a test shows the block belonging to it + self.traceback_view = QPlainTextEdit() + self.traceback_view.setReadOnly(True) + self.traceback_view.setPlaceholderText(word.get("test_panel_traceback_placeholder")) + self.result_tree = QTreeWidget() self.result_tree.setColumnCount(3) self.result_tree.setHeaderLabels([ @@ -135,18 +159,28 @@ def __init__(self, main_window=None, working_dir: str | None = None) -> None: self.result_tree.setColumnWidth(COLUMN_TEST, TEST_COLUMN_WIDTH) self.result_tree.setRootIsDecorated(False) self.result_tree.itemDoubleClicked.connect(self._open_item) + self.result_tree.itemSelectionChanged.connect(self._show_selected_traceback) controls = QHBoxLayout() controls.addWidget(self.run_button) controls.addWidget(self.run_selected_button) controls.addWidget(self.rerun_failures_button) controls.addWidget(self.filter_edit) + controls.addWidget(self.coverage_check) controls.addWidget(self.status_label) controls.addStretch() + # 清單與追蹤訊息上下並排,中間可以拖動 + # The list and the traceback sit one above the other, with a draggable split + split = QSplitter(Qt.Orientation.Vertical) + split.addWidget(self.result_tree) + split.addWidget(self.traceback_view) + split.setStretchFactor(0, _RESULT_STRETCH) + split.setStretchFactor(1, _TRACEBACK_STRETCH) + layout = QVBoxLayout(self) layout.addLayout(controls) - layout.addWidget(self.result_tree) + layout.addWidget(split) self.setLayout(layout) def results(self) -> list[PytestResult]: @@ -168,7 +202,8 @@ def start_run(self, node_ids: list[str] | None = None) -> bool: return False self.run_button.setEnabled(False) self.status_label.setText(language_wrapper.language_word_dict.get("test_panel_running")) - self._thread = PytestRunThread(self._working_dir, node_ids) + self._thread = PytestRunThread( + self._working_dir, node_ids, self.coverage_check.isChecked()) self._thread.finished_output.connect(self.apply_output) self._thread.finished.connect(self._thread.deleteLater) self._thread.start() @@ -230,15 +265,39 @@ def apply_output(self, output: str) -> None: """ self._results = parse_results(output) self._failures = parse_failures(output) + self._tracebacks = parse_tracebacks(output) summary = parse_summary(output) + coverage = parse_coverage(output) self.run_button.setEnabled(True) self._render_items() + self.traceback_view.setPlainText("") if summary: - self.status_label.setText(summary) + self.status_label.setText( + f"{summary} — {coverage}" if coverage else summary) elif not self._results: self.status_label.setText( language_wrapper.language_word_dict.get("test_panel_no_results")) + def traceback_for(self, result: PytestResult) -> str: + """ + 取得某個測試的追蹤訊息 + The traceback reported for one test. + + :param result: 測試結果 / the test result + :return: 追蹤訊息,沒有時為空字串 / the traceback, or an empty string + """ + return traceback_for_result(result, self._tracebacks) + + def _show_selected_traceback(self) -> None: + """把選取測試的追蹤訊息顯示出來 / Show the selected test's traceback.""" + items = self.result_tree.selectedItems() + if not items: + self.traceback_view.setPlainText("") + return + result = items[0].data(COLUMN_OUTCOME, Qt.ItemDataRole.UserRole) + self.traceback_view.setPlainText( + self.traceback_for(result) if result is not None else "") + def visible_results(self) -> list[PytestResult]: """ 取得符合篩選條件的結果,失敗的排在最前面 diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 499cd610..8236acd3 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -409,6 +409,8 @@ "test_panel_run_selected": "Run Selected", "test_panel_rerun_failures": "Re-run Failures", "test_panel_filter_placeholder": "Filter tests...", + "test_panel_coverage": "With coverage", + "test_panel_traceback_placeholder": "Select a failing test to see why it failed", "test_panel_ready": "Ready", "test_panel_running": "Running tests...", "test_panel_no_results": "No tests were reported", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index b8211c7f..85a9f17f 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -399,6 +399,8 @@ "test_panel_run_selected": "只跑選取的", "test_panel_rerun_failures": "重跑失敗的", "test_panel_filter_placeholder": "篩選測試...", + "test_panel_coverage": "含覆蓋率", + "test_panel_traceback_placeholder": "選擇一個失敗的測試以查看原因", "test_panel_ready": "就緒", "test_panel_running": "測試執行中...", "test_panel_no_results": "沒有回報任何測試", diff --git a/je_editor/utils/test_runner/pytest_output.py b/je_editor/utils/test_runner/pytest_output.py index b3834ae4..de937cf5 100644 --- a/je_editor/utils/test_runner/pytest_output.py +++ b/je_editor/utils/test_runner/pytest_output.py @@ -24,6 +24,20 @@ # Matches the summary line, e.g. ``3 failed, 5 passed in 0.42s`` _SUMMARY_PATTERN = re.compile(r"^=+\s*(?P.*?(?:passed|failed|error|no tests ran).*?)\s*=+$") +# 比對區段標題:pytest 用等號或連字號把標題包起來,覆蓋率報告與結尾統計都算 +# Matches a section banner: pytest fences its titles in equals signs or dashes, +# which covers the coverage report and the closing summary as well +_SECTION_BANNER = re.compile(r"^(?P[=-]){2,}\s*(?P.*?)\s*(?P=fence){2,}$") +# 比對追蹤訊息的標題,例如 ``____ TestThing.test_case ____`` +# Matches a traceback heading such as ``____ TestThing.test_case ____`` +_FAILURE_HEADER = re.compile(r"^_{2,}\s*(?P<name>.+?)\s*_{2,}$") +# 比對覆蓋率報告的總計行,例如 ``TOTAL 1200 84 93%`` +# Matches a coverage report's total, e.g. ``TOTAL 1200 84 93%`` +_COVERAGE_TOTAL = re.compile(r"^TOTAL\s+\d+\s+\d+\s+(?P<percent>\d+)%") + +# 帶有追蹤訊息的區段 / The sections that carry tracebacks +_FAILURE_SECTIONS = frozenset({"FAILURES", "ERRORS"}) + # 視為失敗的結果 / Outcomes that count as a failure FAILING_OUTCOMES = frozenset({"FAILED", "ERROR"}) @@ -124,6 +138,85 @@ def parse_failures(output: str) -> list[FailureLocation]: return failures +def parse_tracebacks(output: str) -> dict[str, str]: + """ + 解析每個失敗測試的追蹤訊息 + Parse the traceback reported for each failing test. + + pytest 會在 ``FAILURES`` 區段裡,以一行底線包住測試名稱作為每一段的開頭。 + In its ``FAILURES`` section pytest starts each block with the test's name + fenced by underscores. + + :param output: pytest 的輸出(需要 ``--tb=short`` 或更詳細) + pytest's output, with ``--tb=short`` or longer + :return: 測試名稱對應追蹤訊息 / test name -> its traceback + """ + blocks: dict[str, list[str]] = {} + current: str | None = None + in_failures = False + for line in output.splitlines(): + stripped = line.rstrip() + banner = _SECTION_BANNER.match(stripped.strip()) + if banner is not None: + title = banner.group("title").strip().upper() + in_failures = title in _FAILURE_SECTIONS + current = None + continue + if not in_failures: + continue + header = _FAILURE_HEADER.match(stripped.strip()) + if header is not None: + current = header.group("name").strip() + blocks.setdefault(current, []) + continue + if current is not None: + blocks[current].append(stripped) + return {name: "\n".join(lines).strip() for name, lines in blocks.items() if lines} + + +def traceback_name(node_id: str) -> str: + """ + 把節點名稱換成 pytest 在追蹤訊息標題用的寫法 + Turn a node id into the name pytest heads its traceback with. + + ``test/x.py::TestThing::test_case`` 的標題是 ``TestThing.test_case``。 + ``test/x.py::TestThing::test_case`` is headed ``TestThing.test_case``. + + :param node_id: pytest 的節點名稱 / pytest's node id + :return: 追蹤訊息的標題 / the traceback's heading + """ + parts = node_id.split("::") + return ".".join(parts[1:]) if len(parts) > 1 else node_id + + +def traceback_for_result(result: PytestResult, tracebacks: dict[str, str]) -> str: + """ + 取得某個測試的追蹤訊息 + The traceback belonging to one test. + + :param result: 測試結果 / the test result + :param tracebacks: :func:`parse_tracebacks` 的結果 / what :func:`parse_tracebacks` returned + :return: 追蹤訊息,沒有時為空字串 / the traceback, or an empty string + """ + return tracebacks.get(traceback_name(result.node_id), "") + + +def parse_coverage(output: str) -> str: + """ + 取得覆蓋率報告的總計 + The total from a coverage report. + + :param output: pytest 的輸出 / pytest's output + :return: 總計百分比,例如 ``87%``;沒有報告時為空字串 + the total percentage such as ``87%``, or an empty string + """ + for line in reversed(output.splitlines()): + match = _COVERAGE_TOTAL.match(line.strip()) + if match is not None: + return f"{match.group('percent')}%" + return "" + + def parse_summary(output: str) -> str: """ 取得結尾的統計文字 diff --git a/test/test_pytest_panel.py b/test/test_pytest_panel.py index 7f4f1e57..e3fad883 100644 --- a/test/test_pytest_panel.py +++ b/test/test_pytest_panel.py @@ -9,11 +9,39 @@ from je_editor.utils.test_runner.pytest_output import ( PytestResult, failure_for_result, + parse_coverage, parse_failures, parse_results, parse_summary, + parse_tracebacks, + traceback_for_result, + traceback_name, ) +# What --tb=short actually looks like: a banner, then one block per failure +# headed by the test's name between underscores. +TRACEBACK_OUTPUT = """ +============================= test session starts ============================= +test/test_alpha.py::TestAlpha::test_two FAILED [ 50%] +test/test_beta.py::test_three FAILED [100%] + +=================================== FAILURES ================================== +_________________________ TestAlpha.test_two __________________________________ +test/test_alpha.py:42: in test_two + assert total == 2 +E AssertionError: assert 1 == 2 +______________________________ test_three _____________________________________ +test/test_beta.py:11: in test_three + raise ValueError("nope") +E ValueError: nope +---------- coverage: platform win32, python 3.11.9 ----------- +Name Stmts Miss Cover +---------------------------------------- +je_editor/thing.py 40 4 90% +TOTAL 1200 84 93% +========================= 2 failed in 0.42s =================================== +""" + SAMPLE_OUTPUT = """ ============================= test session starts ============================= collected 3 items @@ -29,6 +57,65 @@ """ +class TestParseTracebacks: + """The panel shows why a test failed, not only that it did.""" + + def test_each_failure_gets_its_own_block(self): + assert set(parse_tracebacks(TRACEBACK_OUTPUT)) == {"TestAlpha.test_two", "test_three"} + + def test_the_block_holds_the_assertion(self): + blocks = parse_tracebacks(TRACEBACK_OUTPUT) + assert "assert 1 == 2" in blocks["TestAlpha.test_two"] + + def test_a_block_stops_at_the_next_failure(self): + blocks = parse_tracebacks(TRACEBACK_OUTPUT) + assert "ValueError" not in blocks["TestAlpha.test_two"] + + def test_the_section_ends_at_the_next_banner(self): + blocks = parse_tracebacks(TRACEBACK_OUTPUT) + assert "2 failed" not in blocks["test_three"] + + def test_output_without_failures_has_no_blocks(self): + assert parse_tracebacks(SAMPLE_OUTPUT.replace("FAILURES", "warnings summary")) == {} + + def test_empty_output_has_no_blocks(self): + assert parse_tracebacks("") == {} + + +class TestTracebackNames: + def test_a_class_based_test_joins_with_a_dot(self): + assert traceback_name("test/test_a.py::TestThing::test_case") == "TestThing.test_case" + + def test_a_plain_test_keeps_its_name(self): + assert traceback_name("test/test_a.py::test_case") == "test_case" + + def test_something_without_a_node_separator_is_unchanged(self): + assert traceback_name("test_case") == "test_case" + + def test_a_result_finds_its_traceback(self): + blocks = parse_tracebacks(TRACEBACK_OUTPUT) + result = PytestResult(node_id="test/test_alpha.py::TestAlpha::test_two", + outcome="FAILED") + assert "assert 1 == 2" in traceback_for_result(result, blocks) + + def test_a_passing_test_has_no_traceback(self): + blocks = parse_tracebacks(TRACEBACK_OUTPUT) + result = PytestResult(node_id="test/test_alpha.py::TestAlpha::test_one", + outcome="PASSED") + assert traceback_for_result(result, blocks) == "" + + +class TestParseCoverage: + def test_the_total_is_read(self): + assert parse_coverage(TRACEBACK_OUTPUT) == "93%" + + def test_output_without_coverage_reports_nothing(self): + assert parse_coverage(SAMPLE_OUTPUT) == "" + + def test_a_per_file_line_is_not_mistaken_for_the_total(self): + assert parse_coverage("je_editor/thing.py 40 4 90%") == "" + + class TestParseResults: def test_every_reported_test_is_read(self): assert len(parse_results(SAMPLE_OUTPUT)) == 3 @@ -116,6 +203,59 @@ def panel(app, tmp_path): widget.deleteLater() +class TestTracebackPane: + """Seeing why a test failed without leaving the panel.""" + + def test_it_starts_empty(self, panel): + assert panel.traceback_view.toPlainText() == "" + + def test_selecting_a_failure_shows_its_traceback(self, panel): + panel.apply_output(TRACEBACK_OUTPUT) + panel.result_tree.setCurrentItem(panel.result_tree.topLevelItem(0)) + assert "assert 1 == 2" in panel.traceback_view.toPlainText() + + def test_each_failure_shows_its_own(self, panel): + panel.apply_output(TRACEBACK_OUTPUT) + panel.result_tree.setCurrentItem(panel.result_tree.topLevelItem(1)) + assert "ValueError" in panel.traceback_view.toPlainText() + + def test_a_passing_test_shows_nothing(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + rows = [panel.result_tree.topLevelItem(index) + for index in range(panel.result_tree.topLevelItemCount())] + passing = next(row for row in rows if row.text(0) == "PASSED") + panel.result_tree.setCurrentItem(passing) + assert panel.traceback_view.toPlainText() == "" + + def test_a_new_run_clears_the_previous_traceback(self, panel): + panel.apply_output(TRACEBACK_OUTPUT) + panel.result_tree.setCurrentItem(panel.result_tree.topLevelItem(0)) + panel.apply_output(SAMPLE_OUTPUT) + assert panel.traceback_view.toPlainText() == "" + + +class TestCoverageOption: + def test_it_is_off_by_default(self, panel): + assert panel.coverage_check.isChecked() is False + + def test_the_command_leaves_coverage_out_by_default(self): + from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import pytest_command + assert not any(argument.startswith("--cov") for argument in pytest_command()) + + def test_asking_for_coverage_adds_the_flags(self): + from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import pytest_command + command = pytest_command(with_coverage=True) + assert "--cov=." in command and "--cov-report=term" in command + + def test_the_total_reaches_the_status_label(self, panel): + panel.apply_output(TRACEBACK_OUTPUT) + assert "93%" in panel.status_label.text() + + def test_a_run_without_coverage_leaves_the_summary_alone(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + assert "%" not in panel.status_label.text() + + class TestTestPanel: def test_starts_empty(self, panel): assert panel.results() == [] @@ -159,7 +299,7 @@ def test_command_is_an_argument_list(self): from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import pytest_command command = pytest_command() assert command[1:3] == ["-m", "pytest"] - assert "-v" in command and "--tb=line" in command + assert "-v" in command and "--tb=short" in command def test_command_can_target_specific_tests(self): from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import pytest_command From 3e6b052669fa0966850a50a1922b4c9af00ab004 Mon Sep 17 00:00:00 2001 From: JeffreyChen <zenxcvwait@gmail.com> Date: Sun, 26 Jul 2026 15:16:28 +0800 Subject: [PATCH 12/16] Let the keyboard shortcuts be changed Every sequence was written down where it was used -- one in each menu builder, the toolbar and the editor -- so nothing could be reassigned without editing the source, and a clash only showed up as a key that did nothing. They now live in one table keyed by command, and everything reads its keys from there. A dialog under Style lists every command, refuses to save while two of them claim the same keys, and records only what differs from a default so a later change to a default still reaches the user. Ctrl+Shift+Escape is spelled Ctrl+Shift+Esc to match what Qt itself produces; otherwise the first save recorded that command as changed without anyone touching it. --- .../code_edit_plaintext.py | 97 +++++----- .../dialog/shortcut_dialog/__init__.py | 0 .../shortcut_settings_dialog.py | 178 +++++++++++++++++ .../build_check_style_menu.py | 9 +- .../main_ui/menu/file_menu/build_file_menu.py | 14 +- .../menu/python_env_menu/build_venv_menu.py | 9 +- .../menu/style_menu/build_style_menu.py | 26 +++ .../main_ui/menu/tab_menu/build_tab_menu.py | 5 +- .../main_ui/menu/text_menu/build_text_menu.py | 3 +- .../main_ui/save_settings/shortcut_setting.py | 25 +++ .../save_settings/user_setting_file.py | 2 + .../main_ui/toolbar/toolbar_builder.py | 15 +- je_editor/utils/multi_language/english.py | 9 + .../multi_language/traditional_chinese.py | 9 + .../utils/shortcuts/shortcut_registry.py | 179 +++++++++++++++--- test/test_shortcut_registry.py | 8 +- test/test_shortcut_settings.py | 175 +++++++++++++++++ test/test_toolbar_actions.py | 2 +- 18 files changed, 659 insertions(+), 106 deletions(-) create mode 100644 je_editor/pyside_ui/dialog/shortcut_dialog/__init__.py create mode 100644 je_editor/pyside_ui/dialog/shortcut_dialog/shortcut_settings_dialog.py create mode 100644 je_editor/pyside_ui/main_ui/save_settings/shortcut_setting.py create mode 100644 test/test_shortcut_settings.py diff --git a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py index d0a83d5d..298c3bef 100644 --- a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +++ b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py @@ -43,9 +43,8 @@ from je_editor.utils.lint.ruff_diagnostics import diagnostics_from_entries from je_editor.utils.macro.keystroke_macro import KeystrokeMacro from je_editor.utils.selection.surround import SURROUND_PAIRS, surround -from je_editor.utils.shortcuts.shortcut_registry import ( - WINDOW_SHORTCUTS, ShortcutRegistry -) +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import shortcut_for +from je_editor.utils.shortcuts.shortcut_registry import WINDOW_SHORTCUTS, ShortcutRegistry from je_editor.utils.line_ops.line_operations import ( join_lines, natural_sort, remove_blank_lines, reverse_lines, sort_lines, unique_lines ) @@ -290,12 +289,10 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: # 搜尋 (Ctrl+F)、搜尋與取代 (Ctrl+H)、跳到指定行 (Ctrl+G) # Search (Ctrl+F), search & replace (Ctrl+H) and go to line (Ctrl+G) - self.search_action = self._add_shortcut_action( - "Ctrl+F", "search", self.start_search_dialog) + self.search_action = self._add_shortcut_action("search", self.start_search_dialog) self.search_replace_action = self._add_shortcut_action( - "Ctrl+H", "search_and_replace", self.open_search_replace_dialog) - self.goto_line_action = self._add_shortcut_action( - "Ctrl+G", "go_to_line", self.go_to_line) + "search_and_replace", self.open_search_replace_dialog) + self.goto_line_action = self._add_shortcut_action("go_to_line", self.go_to_line) # 自動補全初始化 self.completer: Union[None, QCompleter] = None @@ -625,11 +622,15 @@ def search_hit_lines(self) -> list[int]: # ── 快捷鍵註冊 / Shortcut registration ──────────────────────── - def _add_shortcut_action(self, sequence: str, command: str, handler) -> QAction: + def _add_shortcut_action(self, command: str, handler) -> QAction: """ 建立一個綁定快捷鍵的動作,並登記到本編輯器的快捷鍵表 Create an action bound to a key sequence and record it in this editor's table. + 按鍵來自共用的快捷鍵表,因此使用者改過的設定會直接生效,這裡不再各自寫死。 + The sequence comes from the shared table, so a setting the user changed + takes effect here without any of these being written down twice. + 兩個動作共用同一組按鍵時,Qt 不會挑一個執行而是兩個都不執行,因此每組按鍵 都要先登記;重複指派會留下警告紀錄,測試也會直接失敗。 Two actions sharing a sequence make Qt run neither of them, so every @@ -646,11 +647,11 @@ def _add_shortcut_action(self, sequence: str, command: str, handler) -> QAction: and with the window-level context every editor shortcut would have two owners and none of them would fire. - :param sequence: 按鍵組合 / the key sequence - :param command: 指令名稱,用於衝突訊息 / the command name, used in clash messages + :param command: 指令名稱 / the command's name :param handler: 觸發時呼叫的函式 / what to call when it fires :return: 建立好的動作 / the action that was created """ + sequence = shortcut_for(command) action = QAction(self) action.setObjectName(command) action.setShortcut(sequence) @@ -668,23 +669,23 @@ def _add_shortcut_actions(self, bindings) -> None: 一次註冊多組快捷鍵 Register several shortcuts at once. - :param bindings: ``(按鍵, 指令名稱, 處理函式)`` 的序列 - / a sequence of ``(key sequence, command name, handler)`` + :param bindings: ``(指令名稱, 處理函式)`` 的序列 + / a sequence of ``(command name, handler)`` """ - for sequence, command, handler in bindings: - self._add_shortcut_action(sequence, command, handler) + for command, handler in bindings: + self._add_shortcut_action(command, handler) # ── 程式碼折疊與書籤 / Code folding and bookmarks ────────────── def _register_fold_bookmark_actions(self) -> None: """註冊折疊與書籤的快捷鍵 / Register folding and bookmark shortcuts.""" self._add_shortcut_actions(( - ("Ctrl+Shift+[", "toggle_fold", self.toggle_fold_at_cursor), - ("Ctrl+Alt+[", "fold_all", self.fold_all), - ("Ctrl+Alt+]", "unfold_all", self.unfold_all), - ("Ctrl+Alt+K", "toggle_bookmark", self.toggle_bookmark), - ("Ctrl+Alt+L", "next_bookmark", self.next_bookmark), - ("Ctrl+Alt+J", "previous_bookmark", self.previous_bookmark), + ("toggle_fold", self.toggle_fold_at_cursor), + ("fold_all", self.fold_all), + ("unfold_all", self.unfold_all), + ("toggle_bookmark", self.toggle_bookmark), + ("next_bookmark", self.next_bookmark), + ("previous_bookmark", self.previous_bookmark), )) def _on_text_changed_for_features(self) -> None: @@ -730,10 +731,10 @@ def _register_diff_marker_actions(self) -> None: decrement the number under the caret. """ self._add_shortcut_actions(( - ("F7", "next_change", self.next_change), - ("Shift+F7", "previous_change", self.previous_change), - ("Ctrl+Alt+Z", "revert_change", self.revert_change_at_cursor), - ("Ctrl+Alt+B", "toggle_blame", self.toggle_blame), + ("next_change", self.next_change), + ("previous_change", self.previous_change), + ("revert_change", self.revert_change_at_cursor), + ("toggle_blame", self.toggle_blame), )) def next_change(self) -> bool: @@ -1098,8 +1099,8 @@ def previous_bookmark(self) -> None: def _register_history_actions(self) -> None: """註冊上一步/下一步快捷鍵 / Register back/forward shortcuts.""" self._add_shortcut_actions(( - ("Alt+Left", "navigate_back", self.navigate_back), - ("Alt+Right", "navigate_forward", self.navigate_forward), + ("navigate_back", self.navigate_back), + ("navigate_forward", self.navigate_forward), )) def _record_cursor_jump(self) -> None: @@ -1554,8 +1555,8 @@ def _duplicate_selection(self, cursor: QTextCursor) -> None: def _register_smart_selection_actions(self) -> None: """註冊智慧選取快捷鍵 / Register smart selection shortcuts.""" self._add_shortcut_actions(( - ("Ctrl+Alt+Right", "expand_selection", self.expand_selection), - ("Ctrl+Alt+Left", "shrink_selection", self.shrink_selection), + ("expand_selection", self.expand_selection), + ("shrink_selection", self.shrink_selection), )) def expand_selection(self) -> None: @@ -1571,9 +1572,9 @@ def shrink_selection(self) -> None: def _register_number_actions(self) -> None: """註冊數字加減快捷鍵 / Register number increment/decrement shortcuts.""" self._add_shortcut_actions(( - ("Ctrl+Alt+Up", "increment_number", lambda: self.adjust_number(1)), - ("Ctrl+Alt+Down", "decrement_number", lambda: self.adjust_number(-1)), - ("F2", "rename_occurrences", self.rename_word_under_cursor), + ("increment_number", lambda: self.adjust_number(1)), + ("decrement_number", lambda: self.adjust_number(-1)), + ("rename_occurrences", self.rename_word_under_cursor), )) def rename_word_under_cursor(self) -> bool: @@ -1635,9 +1636,9 @@ def adjust_number(self, delta: int) -> bool: def _register_line_operation_actions(self) -> None: """註冊行操作快捷鍵 / Register line-operation shortcuts.""" self._add_shortcut_actions(( - ("Ctrl+Shift+D", "delete_line", self.delete_current_line), - ("Ctrl+Shift+J", "join_lines", self.join_selected_lines), - ("Ctrl+Alt+S", "sort_lines", self.sort_selected_lines), + ("delete_line", self.delete_current_line), + ("join_lines", self.join_selected_lines), + ("sort_lines", self.sort_selected_lines), )) def _selected_block_range(self, cursor: QTextCursor) -> tuple[int, int]: @@ -2301,11 +2302,11 @@ def _register_breakpoint_actions(self) -> None: breakpoint and continuing use Ctrl+F9 and Ctrl+F5 instead. """ self._add_shortcut_actions(( - ("Ctrl+F9", "toggle_breakpoint", self.toggle_breakpoint), - ("Ctrl+F5", "debug_continue", lambda: self.send_debugger_command("continue")), - ("F10", "debug_step_over", lambda: self.send_debugger_command("over")), - ("F11", "debug_step_into", lambda: self.send_debugger_command("into")), - ("Shift+F11", "debug_step_out", lambda: self.send_debugger_command("out")), + ("toggle_breakpoint", self.toggle_breakpoint), + ("debug_continue", lambda: self.send_debugger_command("continue")), + ("debug_step_over", lambda: self.send_debugger_command("over")), + ("debug_step_into", lambda: self.send_debugger_command("into")), + ("debug_step_out", lambda: self.send_debugger_command("out")), )) def toggle_breakpoint(self) -> bool: @@ -2408,9 +2409,9 @@ def _register_macro_actions(self) -> None: Playback uses Ctrl+Shift+G because Ctrl+Shift+P installs packages with pip. """ self._add_shortcut_actions(( - ("Ctrl+Shift+R", "record_macro", self.toggle_macro_recording), - ("Ctrl+Shift+G", "play_macro", self.play_macro), - ("Ctrl+Alt+E", "recent_locations", self.show_recent_locations), + ("record_macro", self.toggle_macro_recording), + ("play_macro", self.play_macro), + ("recent_locations", self.show_recent_locations), )) def recent_location_labels(self) -> list[str]: @@ -2516,11 +2517,11 @@ def _register_multi_cursor_actions(self) -> None: always duplicated the current line. """ self._add_shortcut_actions(( - ("Ctrl+Shift+L", "cursors_on_selected_lines", self.add_cursors_to_selected_lines), - ("Ctrl+Shift+Escape", "clear_extra_cursors", self.clear_extra_cursors), - ("Ctrl+Alt+Shift+Up", "cursor_above", self.add_cursor_above), - ("Ctrl+Alt+Shift+Down", "cursor_below", self.add_cursor_below), - ("Ctrl+Alt+N", "cursor_at_next_occurrence", self.add_cursor_at_next_occurrence), + ("cursors_on_selected_lines", self.add_cursors_to_selected_lines), + ("clear_extra_cursors", self.clear_extra_cursors), + ("cursor_above", self.add_cursor_above), + ("cursor_below", self.add_cursor_below), + ("cursor_at_next_occurrence", self.add_cursor_at_next_occurrence), )) def add_cursors_to_selected_lines(self) -> int: diff --git a/je_editor/pyside_ui/dialog/shortcut_dialog/__init__.py b/je_editor/pyside_ui/dialog/shortcut_dialog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/dialog/shortcut_dialog/shortcut_settings_dialog.py b/je_editor/pyside_ui/dialog/shortcut_dialog/shortcut_settings_dialog.py new file mode 100644 index 00000000..389806aa --- /dev/null +++ b/je_editor/pyside_ui/dialog/shortcut_dialog/shortcut_settings_dialog.py @@ -0,0 +1,178 @@ +""" +編輯快捷鍵的對話框 +A dialog for changing the keyboard shortcuts. + +快捷鍵原本散落在各個選單與編輯器裡各自寫死,衝突只能等到按下去沒反應才發現。 +現在全部集中在一份表裡,這個對話框就是那份表的編輯介面:改完立刻檢查有沒有兩個 +指令搶同一組按鍵,存檔後新開的分頁就照新設定走。 +The shortcuts used to be written down separately in each menu and in the editor, +so a clash only showed up as a key that did nothing. They now live in one table, +and this is its editor: a change is checked for two commands claiming the same +keys, and a save applies to newly opened tabs. +""" +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtGui import QKeySequence +from PySide6.QtWidgets import ( + QDialog, QHBoxLayout, QKeySequenceEdit, QLabel, QPushButton, QTreeWidget, + QTreeWidgetItem, QVBoxLayout, QWidget +) + +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict +from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper +from je_editor.utils.shortcuts.shortcut_registry import ( + DEFAULT_SHORTCUTS, EDITOR_SHORTCUTS, clean_overrides, effective_shortcuts, + find_conflicts +) + +# 樹狀清單的欄位 / The columns in the tree +COLUMN_COMMAND = 0 +COLUMN_SEQUENCE = 1 +# 指令欄的預設寬度 / Default width of the command column +COMMAND_COLUMN_WIDTH = 260 + + +def command_label(command: str) -> str: + """ + 取得指令的顯示名稱 + The name a command is shown under. + + 有翻譯就用翻譯,沒有的話把指令名稱本身整理成看得懂的樣子,這樣新增指令時不會 + 因為忘了加翻譯就變成一列空白。 + A translation is used when there is one, and otherwise the command's own name + is tidied into something readable, so a newly added command never shows up as + an empty row because its translation was forgotten. + + :param command: 指令名稱 / the command's name + :return: 顯示名稱 / the label to show + """ + translated = language_wrapper.language_word_dict.get(f"shortcut_{command}") + return translated or command.replace("_", " ").capitalize() + + +class ShortcutSettingsDialog(QDialog): + """ + 列出所有指令並讓使用者改按鍵 + List every command and let the user change its keys. + """ + + def __init__(self, main_window=None, parent: QWidget | None = None) -> None: + """ + :param main_window: 儲存後要通知的主視窗 / the window told about a save + :param parent: Qt 父元件 / the Qt parent + """ + super().__init__(parent) + word = language_wrapper.language_word_dict + self._main_window = main_window + self.setWindowTitle(word.get("shortcut_settings_title")) + self._editors: dict[str, QKeySequenceEdit] = {} + + self.tree = QTreeWidget() + self.tree.setColumnCount(2) + self.tree.setHeaderLabels([ + word.get("shortcut_settings_col_command"), + word.get("shortcut_settings_col_keys"), + ]) + self.tree.setColumnWidth(COLUMN_COMMAND, COMMAND_COLUMN_WIDTH) + self.tree.setRootIsDecorated(False) + + self.status_label = QLabel("") + self.save_button = QPushButton(word.get("shortcut_settings_save")) + self.save_button.clicked.connect(self.save) + self.reset_button = QPushButton(word.get("shortcut_settings_reset")) + self.reset_button.clicked.connect(self.reset_to_defaults) + + buttons = QHBoxLayout() + buttons.addWidget(self.save_button) + buttons.addWidget(self.reset_button) + buttons.addWidget(self.status_label) + buttons.addStretch() + + layout = QVBoxLayout(self) + layout.addWidget(QLabel(word.get("shortcut_settings_hint"))) + layout.addWidget(self.tree) + layout.addLayout(buttons) + self.setLayout(layout) + + self._fill(effective_shortcuts(user_setting_dict.get("shortcuts"))) + + def _fill(self, shortcuts: dict[str, str]) -> None: + """依目前的設定重建清單 / Rebuild the list from the given shortcuts.""" + self.tree.clear() + self._editors = {} + # 編輯器的指令排在後面,選單與工具列在前,與使用者尋找的順序一致 + # Menu and toolbar commands come first and the editor's after, which is + # the order someone looks for them in + for command in sorted(DEFAULT_SHORTCUTS, key=lambda name: (name in EDITOR_SHORTCUTS, name)): + row = QTreeWidgetItem([command_label(command), ""]) + row.setData(COLUMN_COMMAND, Qt.ItemDataRole.UserRole, command) + self.tree.addTopLevelItem(row) + editor = QKeySequenceEdit(QKeySequence(shortcuts.get(command, ""))) + editor.keySequenceChanged.connect(self._check_conflicts) + self.tree.setItemWidget(row, COLUMN_SEQUENCE, editor) + self._editors[command] = editor + self._check_conflicts() + + def current_shortcuts(self) -> dict[str, str]: + """ + 取得畫面上目前的按鍵設定 + The shortcuts as the dialog currently shows them. + + :return: 指令對應按鍵 / command mapped to sequence + """ + return { + command: editor.keySequence().toString() + for command, editor in self._editors.items() + } + + def conflicts(self) -> list[tuple[str, list[str]]]: + """ + 取得目前設定中重複的按鍵 + The sequences currently claimed by more than one command. + + :return: ``(按鍵, 指令清單)`` / ``(sequence, commands)`` + """ + return find_conflicts(self.current_shortcuts()) + + def _check_conflicts(self) -> None: + """把衝突狀況顯示出來 / Say whether anything currently clashes.""" + word = language_wrapper.language_word_dict + clashes = self.conflicts() + if not clashes: + self.status_label.setText("") + self.save_button.setEnabled(True) + return + sequence, commands = clashes[0] + self.status_label.setText(word.get("shortcut_settings_conflict").format( + keys=sequence, commands=", ".join(command_label(name) for name in commands))) + # 有衝突就存不了:存下去等於讓兩個功能一起失效 + # A clash cannot be saved: doing so would disable both features at once + self.save_button.setEnabled(False) + + def reset_to_defaults(self) -> None: + """把所有按鍵改回預設值 / Put every shortcut back to its default.""" + self._fill(dict(DEFAULT_SHORTCUTS)) + + def save(self) -> bool: + """ + 儲存設定 + Store the shortcuts. + + 只記下與預設不同的項目,設定檔因此不會被幾十個沒改過的值塞滿,日後改動預設 + 值時使用者也能跟著更新。 + Only what differs from a default is recorded, so the settings file does not + fill with dozens of untouched values and a later change to a default still + reaches the user. + + :return: 有存起來時為 ``True``;有衝突時為 ``False`` + ``True`` when stored, and ``False`` while something clashes + """ + if self.conflicts(): + return False + user_setting_dict["shortcuts"] = clean_overrides(self.current_shortcuts()) + notify = getattr(self._main_window, "reload_shortcuts", None) + if callable(notify): + notify() + self.accept() + return True diff --git a/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py b/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py index ea1fc95f..75d8f539 100644 --- a/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py @@ -3,13 +3,14 @@ from typing import TYPE_CHECKING # 僅在型別檢查時使用,避免循環匯入 / Used only for type checking to avoid circular imports from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget # 編輯器分頁元件 / Editor tab widget +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import shortcut_for from je_editor.utils.logging.loggin_instance import jeditor_logger # 專案內的日誌紀錄器 / Project logger from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper # 多語系支援 / Multi-language wrapper if TYPE_CHECKING: from je_editor.pyside_ui.main_ui.main_editor import EditorMain # 僅在型別檢查時匯入 / Import only for type checking -from PySide6.QtGui import QAction, QKeySequence # Qt 動作與快捷鍵 / Qt actions and shortcuts +from PySide6.QtGui import QAction # Qt 動作與快捷鍵 / Qt actions and shortcuts from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict # 使用者設定 / User settings from je_editor.utils.format_code.yapf_format import format_python_source # 格式化邏輯 / Formatting logic @@ -38,7 +39,7 @@ def set_check_menu(ui_we_want_to_set: EditorMain) -> None: ui_we_want_to_set.check_menu.yapf_check_python_action = QAction( language_wrapper.language_word_dict.get("yapf_reformat_label")) ui_we_want_to_set.check_menu.yapf_check_python_action.setShortcut( - QKeySequence("Ctrl+Shift+Y")) # 設定快捷鍵 / Set shortcut + shortcut_for("yapf_reformat")) # 設定快捷鍵 / Set shortcut ui_we_want_to_set.check_menu.yapf_check_python_action.triggered.connect( lambda: yapf_check_python_code(ui_we_want_to_set) ) @@ -47,7 +48,7 @@ def set_check_menu(ui_we_want_to_set: EditorMain) -> None: # === 2. JSON 重新格式化 / Reformat JSON === ui_we_want_to_set.check_menu.reformat_json_action = QAction( language_wrapper.language_word_dict.get("reformat_json_label")) - ui_we_want_to_set.check_menu.reformat_json_action.setShortcut("Ctrl+j") + ui_we_want_to_set.check_menu.reformat_json_action.setShortcut(shortcut_for("reformat_json")) ui_we_want_to_set.check_menu.reformat_json_action.triggered.connect( lambda: reformat_json_text(ui_we_want_to_set) ) @@ -56,7 +57,7 @@ def set_check_menu(ui_we_want_to_set: EditorMain) -> None: # === 3. Python 格式檢查 / Python format check === ui_we_want_to_set.check_menu.check_python_format = QAction( language_wrapper.language_word_dict.get("python_format_checker")) - ui_we_want_to_set.check_menu.check_python_format.setShortcut("Ctrl+Alt+p") + ui_we_want_to_set.check_menu.check_python_format.setShortcut(shortcut_for("check_python_format")) ui_we_want_to_set.check_menu.check_python_format.triggered.connect( lambda: check_python_format(ui_we_want_to_set) ) diff --git a/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py b/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py index eb045160..add0280f 100644 --- a/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py @@ -8,6 +8,7 @@ # 匯入使用者設定字典,用來保存 UI 設定 # Import user settings dictionary for saving UI preferences +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import shortcut_for from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict # 匯入 Python 編碼清單 (例如 utf-8, gbk 等) # Import list of Python encodings (e.g., utf-8, gbk, etc.) @@ -59,7 +60,7 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: # New File action ui_we_want_to_set.file_menu.new_file_action = QAction( language_wrapper.language_word_dict.get("file_menu_new_file_label")) - ui_we_want_to_set.file_menu.new_file_action.setShortcut("Ctrl+n") + ui_we_want_to_set.file_menu.new_file_action.setShortcut(shortcut_for("new_file")) ui_we_want_to_set.file_menu.new_file_action.triggered.connect( lambda: show_create_file_dialog(ui_we_want_to_set) ) @@ -69,7 +70,7 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: # Open File action ui_we_want_to_set.file_menu.open_file_action = QAction( language_wrapper.language_word_dict.get("file_menu_open_file_label")) - ui_we_want_to_set.file_menu.open_file_action.setShortcut("Ctrl+o") + ui_we_want_to_set.file_menu.open_file_action.setShortcut(shortcut_for("open_file")) ui_we_want_to_set.file_menu.open_file_action.triggered.connect( lambda: choose_file_get_open_file_path(parent_qt_instance=ui_we_want_to_set) ) @@ -79,7 +80,7 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: # Open Folder action ui_we_want_to_set.file_menu.open_folder_action = QAction( language_wrapper.language_word_dict.get("file_menu_open_folder_label")) - ui_we_want_to_set.file_menu.open_folder_action.setShortcut("Ctrl+K") + ui_we_want_to_set.file_menu.open_folder_action.setShortcut(shortcut_for("open_folder")) ui_we_want_to_set.file_menu.open_folder_action.triggered.connect( lambda: choose_dir_get_dir_path(parent_qt_instance=ui_we_want_to_set) ) @@ -89,7 +90,7 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: # Save File action ui_we_want_to_set.file_menu.save_file_action = QAction( language_wrapper.language_word_dict.get("file_menu_save_file_label")) - ui_we_want_to_set.file_menu.save_file_action.setShortcut("Ctrl+s") + ui_we_want_to_set.file_menu.save_file_action.setShortcut(shortcut_for("save_file")) ui_we_want_to_set.file_menu.save_file_action.triggered.connect( lambda: choose_file_get_save_file_path(parent_qt_instance=ui_we_want_to_set) ) @@ -110,10 +111,7 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: # 儲存所有分頁 / Save every modified tab ui_we_want_to_set.file_menu.save_all_action = QAction( language_wrapper.language_word_dict.get("file_menu_save_all_label")) - # Ctrl+Alt+S 是編輯器的「排序選取的行」,因此這裡用慣例的 Ctrl+Shift+S - # Ctrl+Alt+S sorts the selected lines in the editor, so this uses the - # conventional Ctrl+Shift+S - ui_we_want_to_set.file_menu.save_all_action.setShortcut("Ctrl+Shift+S") + ui_we_want_to_set.file_menu.save_all_action.setShortcut(shortcut_for("save_all")) ui_we_want_to_set.file_menu.save_all_action.triggered.connect( lambda: save_all_tabs(ui_we_want_to_set)) ui_we_want_to_set.file_menu.addAction(ui_we_want_to_set.file_menu.save_all_action) diff --git a/je_editor/pyside_ui/main_ui/menu/python_env_menu/build_venv_menu.py b/je_editor/pyside_ui/main_ui/menu/python_env_menu/build_venv_menu.py index deed3b4e..4ea880de 100644 --- a/je_editor/pyside_ui/main_ui/menu/python_env_menu/build_venv_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/python_env_menu/build_venv_menu.py @@ -17,6 +17,7 @@ from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict # 匯入日誌紀錄器 # Import logger instance +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import shortcut_for from je_editor.utils.logging.loggin_instance import jeditor_logger # 僅在型別檢查時匯入 EditorMain,避免循環依賴 @@ -29,7 +30,7 @@ # 匯入 Qt 動作、快捷鍵、文字格式、訊息框、輸入框、檔案對話框 # Import QAction, QKeySequence, QTextCharFormat, QMessageBox, QInputDialog, QFileDialog -from PySide6.QtGui import QAction, QKeySequence, QTextCharFormat +from PySide6.QtGui import QAction, QTextCharFormat from PySide6.QtWidgets import QMessageBox, QInputDialog, QFileDialog # 匯入 ShellManager,用於執行系統命令 (建立 venv、pip install 等) @@ -52,7 +53,7 @@ def set_venv_menu(ui_we_want_to_set: EditorMain) -> None: # Create virtual environment ui_we_want_to_set.venv_menu.change_language_menu = QAction( language_wrapper.language_word_dict.get("python_env_menu_create_venv_label")) - ui_we_want_to_set.venv_menu.change_language_menu.setShortcut(QKeySequence("Ctrl+Shift+V")) + ui_we_want_to_set.venv_menu.change_language_menu.setShortcut(shortcut_for("change_language")) ui_we_want_to_set.venv_menu.change_language_menu.triggered.connect( lambda: create_venv(ui_we_want_to_set)) ui_we_want_to_set.venv_menu.addAction(ui_we_want_to_set.venv_menu.change_language_menu) @@ -61,7 +62,7 @@ def set_venv_menu(ui_we_want_to_set: EditorMain) -> None: # pip upgrade package ui_we_want_to_set.venv_menu.pip_upgrade_action = QAction( language_wrapper.language_word_dict.get("python_env_menu_pip_upgrade_label")) - ui_we_want_to_set.venv_menu.pip_upgrade_action.setShortcut(QKeySequence("Ctrl+Shift+U")) + ui_we_want_to_set.venv_menu.pip_upgrade_action.setShortcut(shortcut_for("pip_upgrade")) ui_we_want_to_set.venv_menu.pip_upgrade_action.triggered.connect( lambda: pip_install_package_update(ui_we_want_to_set)) ui_we_want_to_set.venv_menu.addAction(ui_we_want_to_set.venv_menu.pip_upgrade_action) @@ -70,7 +71,7 @@ def set_venv_menu(ui_we_want_to_set: EditorMain) -> None: # pip install package ui_we_want_to_set.venv_menu.pip_action = QAction( language_wrapper.language_word_dict.get("python_env_menu_pip_label")) - ui_we_want_to_set.venv_menu.pip_action.setShortcut(QKeySequence("Ctrl+Shift+P")) + ui_we_want_to_set.venv_menu.pip_action.setShortcut(shortcut_for("pip_install")) ui_we_want_to_set.venv_menu.pip_action.triggered.connect( lambda: pip_install_package(ui_we_want_to_set)) ui_we_want_to_set.venv_menu.addAction(ui_we_want_to_set.venv_menu.pip_action) diff --git a/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py b/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py index 848dc050..5c5d20b9 100644 --- a/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py @@ -4,6 +4,7 @@ # 匯入 Qt 動作 # Import QAction +from PySide6.QtCore import Qt from PySide6.QtGui import QAction from PySide6.QtWidgets import QApplication @@ -62,6 +63,16 @@ def set_style_menu(ui_we_want_to_set: EditorMain) -> None: ui_we_want_to_set.menu.style_menu.addSeparator() add_overlay_toggles(ui_we_want_to_set) + # 快捷鍵設定 / The keyboard shortcut settings + ui_we_want_to_set.menu.style_menu.addSeparator() + ui_we_want_to_set.menu.style_menu.shortcut_settings_action = QAction( + language_wrapper.language_word_dict.get("shortcut_settings_menu_label"), + parent=ui_we_want_to_set.menu.style_menu) + ui_we_want_to_set.menu.style_menu.shortcut_settings_action.triggered.connect( + lambda: open_shortcut_settings(ui_we_want_to_set)) + ui_we_want_to_set.menu.style_menu.addAction( + ui_we_want_to_set.menu.style_menu.shortcut_settings_action) + def add_overlay_toggles(ui_we_want_to_set: EditorMain) -> None: """ @@ -130,6 +141,21 @@ def set_style(ui_we_want_to_set: EditorMain, action: QAction) -> None: _repaint_editors(ui_we_want_to_set) +def open_shortcut_settings(ui_we_want_to_set: EditorMain) -> None: + """ + 開啟快捷鍵設定對話框 + Open the keyboard shortcut settings. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + """ + from je_editor.pyside_ui.dialog.shortcut_dialog.shortcut_settings_dialog import ( + ShortcutSettingsDialog + ) + dialog = ShortcutSettingsDialog(ui_we_want_to_set, parent=ui_we_want_to_set) + dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + dialog.show() + + def _repaint_editors(ui_we_want_to_set: EditorMain) -> None: """讓每個編輯分頁用新顏色重畫 / Repaint every editor tab in the new colours.""" from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget diff --git a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py index d767b9b1..79348505 100644 --- a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py @@ -9,6 +9,7 @@ from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget from je_editor.pyside_ui.main_ui.menu.tab_menu.build_tab_git_menu import set_tab_git_menu from je_editor.pyside_ui.main_ui.menu.tab_menu.build_tab_tools_menu import set_tab_tools_menu +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import shortcut_for from je_editor.utils.logging.loggin_instance import jeditor_logger if TYPE_CHECKING: @@ -63,7 +64,7 @@ def set_tab_menu(ui_we_want_to_set: EditorMain) -> None: # === Split view of the same file === ui_we_want_to_set.tab_menu.toggle_split_view_action = QAction( language_wrapper.language_word_dict.get("tab_menu_split_view_label")) - ui_we_want_to_set.tab_menu.toggle_split_view_action.setShortcut("Ctrl+Alt+\\") + ui_we_want_to_set.tab_menu.toggle_split_view_action.setShortcut(shortcut_for("toggle_split_view")) ui_we_want_to_set.tab_menu.toggle_split_view_action.triggered.connect( lambda: toggle_split_view(ui_we_want_to_set) ) @@ -73,7 +74,7 @@ def set_tab_menu(ui_we_want_to_set: EditorMain) -> None: # === Minimap === ui_we_want_to_set.tab_menu.toggle_minimap_action = QAction( language_wrapper.language_word_dict.get("tab_menu_minimap_label")) - ui_we_want_to_set.tab_menu.toggle_minimap_action.setShortcut("Ctrl+Alt+M") + ui_we_want_to_set.tab_menu.toggle_minimap_action.setShortcut(shortcut_for("toggle_minimap")) ui_we_want_to_set.tab_menu.toggle_minimap_action.triggered.connect( lambda: toggle_minimap(ui_we_want_to_set) ) diff --git a/je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py b/je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py index 60d33bf2..93130ce7 100644 --- a/je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py @@ -7,6 +7,7 @@ from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import shortcut_for from je_editor.utils.logging.loggin_instance import jeditor_logger from je_editor.utils.text_stats.text_statistics import TextStatistics, text_statistics @@ -81,7 +82,7 @@ def set_text_menu(ui_we_want_to_set: EditorMain) -> None: language_wrapper.language_word_dict.get("text_menu_word_wrap"), ui_we_want_to_set) word_wrap_action.setCheckable(True) word_wrap_action.setChecked(False) - word_wrap_action.setShortcut("Alt+w") + word_wrap_action.setShortcut(shortcut_for("word_wrap")) word_wrap_action.triggered.connect( lambda checked: toggle_word_wrap(ui_we_want_to_set, checked)) ui_we_want_to_set.text_menu.addAction(word_wrap_action) diff --git a/je_editor/pyside_ui/main_ui/save_settings/shortcut_setting.py b/je_editor/pyside_ui/main_ui/save_settings/shortcut_setting.py new file mode 100644 index 00000000..92d4bcde --- /dev/null +++ b/je_editor/pyside_ui/main_ui/save_settings/shortcut_setting.py @@ -0,0 +1,25 @@ +""" +取得指令目前生效的快捷鍵 +Look up the key sequence a command currently answers to. + +選單、工具列與編輯器都經由這裡取得按鍵,因此使用者在設定裡改過的組合會一致地 +反映到每一處,不會有某個選單還記著舊的寫死值。 +The menus, the toolbar and the editor all ask here, so a sequence the user +changed reaches every one of them and no menu is left holding an old hard-coded +value. +""" +from __future__ import annotations + +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict +from je_editor.utils.shortcuts.shortcut_registry import sequence_for + + +def shortcut_for(command: str) -> str: + """ + 取得一個指令目前的按鍵 + The sequence a command currently answers to. + + :param command: 指令名稱 / the command's name + :return: 按鍵組合,沒有指派時為空字串 / the sequence, or an empty string + """ + return sequence_for(command, user_setting_dict.get("shortcuts")) diff --git a/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py b/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py index 0e6756c6..10ab1c61 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py +++ b/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py @@ -31,6 +31,8 @@ "indent_size": 4, # 縮排空格數 / Indent size (spaces) "open_files": [], # 上次關閉時開啟的分頁 / Tabs open at last shutdown "restore_session": True, # 啟動時還原分頁 / Restore tabs on startup + # 使用者改過的快捷鍵,只記與預設不同的 / Shortcuts the user changed, defaults omitted + "shortcuts": {}, } diff --git a/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py b/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py index 9d49d214..d784e224 100644 --- a/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py +++ b/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py @@ -8,6 +8,7 @@ QToolBar, QComboBox, QStyle, QLabel, QWidget, QMessageBox ) +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import shortcut_for from je_editor.utils.logging.loggin_instance import jeditor_logger from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper @@ -61,21 +62,21 @@ def build_toolbar(main_window: EditorMain) -> None: act_run = QAction(_icon(main_window, QStyle.StandardPixmap.SP_MediaPlay), lang("toolbar_run"), main_window) act_run.setToolTip(lang("toolbar_run")) - act_run.setShortcut("F5") + act_run.setShortcut(shortcut_for("run_program")) act_run.triggered.connect(lambda: _run_program(main_window)) toolbar.addAction(act_run) act_debug = QAction(_icon(main_window, QStyle.StandardPixmap.SP_MediaSeekForward), lang("toolbar_debug"), main_window) act_debug.setToolTip(lang("toolbar_debug")) - act_debug.setShortcut("F9") + act_debug.setShortcut(shortcut_for("run_debugger")) act_debug.triggered.connect(lambda: _run_debugger(main_window)) toolbar.addAction(act_debug) act_stop = QAction(_icon(main_window, QStyle.StandardPixmap.SP_MediaStop), lang("toolbar_stop"), main_window) act_stop.setToolTip(lang("toolbar_stop")) - act_stop.setShortcut("Shift+F5") + act_stop.setShortcut(shortcut_for("stop_program")) act_stop.triggered.connect(lambda: _stop_program(main_window)) toolbar.addAction(act_stop) @@ -110,7 +111,7 @@ def build_toolbar(main_window: EditorMain) -> None: act_search = QAction(_icon(main_window, QStyle.StandardPixmap.SP_FileDialogContentsView), lang("toolbar_search"), main_window) act_search.setToolTip(lang("toolbar_search")) - act_search.setShortcut("Ctrl+Shift+F") + act_search.setShortcut(shortcut_for("search_in_files")) act_search.triggered.connect(lambda: _open_search(main_window)) toolbar.addAction(act_search) @@ -120,7 +121,7 @@ def build_toolbar(main_window: EditorMain) -> None: act_palette = QAction(_icon(main_window, QStyle.StandardPixmap.SP_FileDialogDetailedView), lang("toolbar_command_palette"), main_window) act_palette.setToolTip(lang("toolbar_command_palette")) - act_palette.setShortcut("Ctrl+Shift+A") + act_palette.setShortcut(shortcut_for("command_palette")) act_palette.triggered.connect(lambda: _open_command_palette(main_window)) toolbar.addAction(act_palette) main_window.command_palette_action = act_palette @@ -128,7 +129,7 @@ def build_toolbar(main_window: EditorMain) -> None: act_quick_open = QAction(_icon(main_window, QStyle.StandardPixmap.SP_FileDialogListView), lang("toolbar_quick_open"), main_window) act_quick_open.setToolTip(lang("toolbar_quick_open")) - act_quick_open.setShortcut("Ctrl+P") + act_quick_open.setShortcut(shortcut_for("quick_open")) act_quick_open.triggered.connect(lambda: _open_quick_open(main_window)) toolbar.addAction(act_quick_open) main_window.quick_open_action = act_quick_open @@ -136,7 +137,7 @@ def build_toolbar(main_window: EditorMain) -> None: act_go_to_symbol = QAction(_icon(main_window, QStyle.StandardPixmap.SP_FileDialogInfoView), lang("toolbar_go_to_symbol"), main_window) act_go_to_symbol.setToolTip(lang("toolbar_go_to_symbol")) - act_go_to_symbol.setShortcut("Ctrl+Shift+O") + act_go_to_symbol.setShortcut(shortcut_for("go_to_symbol")) act_go_to_symbol.triggered.connect(lambda: _open_go_to_symbol(main_window)) toolbar.addAction(act_go_to_symbol) main_window.go_to_symbol_action = act_go_to_symbol diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 8236acd3..c9b98686 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -410,6 +410,15 @@ "test_panel_rerun_failures": "Re-run Failures", "test_panel_filter_placeholder": "Filter tests...", "test_panel_coverage": "With coverage", + # Shortcut settings + "shortcut_settings_title": "Keyboard Shortcuts", + "shortcut_settings_menu_label": "Keyboard Shortcuts...", + "shortcut_settings_col_command": "Command", + "shortcut_settings_col_keys": "Keys", + "shortcut_settings_save": "Save", + "shortcut_settings_reset": "Restore defaults", + "shortcut_settings_hint": "Changes apply to tabs opened after saving.", + "shortcut_settings_conflict": "{keys} is claimed by {commands}", "test_panel_traceback_placeholder": "Select a failing test to see why it failed", "test_panel_ready": "Ready", "test_panel_running": "Running tests...", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index 85a9f17f..9395d060 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -400,6 +400,15 @@ "test_panel_rerun_failures": "重跑失敗的", "test_panel_filter_placeholder": "篩選測試...", "test_panel_coverage": "含覆蓋率", + # 快捷鍵設定 / Shortcut settings + "shortcut_settings_title": "鍵盤快捷鍵", + "shortcut_settings_menu_label": "鍵盤快捷鍵…", + "shortcut_settings_col_command": "指令", + "shortcut_settings_col_keys": "按鍵", + "shortcut_settings_save": "儲存", + "shortcut_settings_reset": "還原預設值", + "shortcut_settings_hint": "儲存後對之後開啟的分頁生效。", + "shortcut_settings_conflict": "{keys} 同時被 {commands} 使用", "test_panel_traceback_placeholder": "選擇一個失敗的測試以查看原因", "test_panel_ready": "就緒", "test_panel_running": "測試執行中...", diff --git a/je_editor/utils/shortcuts/shortcut_registry.py b/je_editor/utils/shortcuts/shortcut_registry.py index 8856dec4..a7ad7481 100644 --- a/je_editor/utils/shortcuts/shortcut_registry.py +++ b/je_editor/utils/shortcuts/shortcut_registry.py @@ -78,12 +78,12 @@ class ShortcutRegistry: def __init__(self, reserved: Optional[Dict[str, str]] = None) -> None: """ - :param reserved: 已經被外部(選單、工具列)佔用的按鍵,對應到指令名稱 - / sequences already taken elsewhere (menus, toolbar), mapped to command names + :param reserved: 已經被外部(選單、工具列)佔用的指令,對應到它們的按鍵 + / commands already spoken for elsewhere (menus, toolbar), mapped to their sequences """ self._owners: Dict[str, str] = {} self._conflicts: List[Tuple[str, str, str]] = [] - for sequence, command in (reserved or {}).items(): + for command, sequence in (reserved or {}).items(): key = normalise_sequence(sequence) if key: self._owners[key] = command @@ -155,37 +155,162 @@ def commands(self) -> Dict[str, str]: return dict(self._owners) -# 選單與工具列佔用的按鍵:編輯器的動作不能重複使用這些組合,否則兩邊都不會作用。 -# 這裡列出的是「視窗層級」的指令,編輯器有焦點時它們仍然必須能用。 -# The sequences the menus and toolbar take. Editor actions must not reuse them or +# 選單與工具列的指令:編輯器的動作不能重複使用這些組合,否則兩邊都不會作用。 +# 這些是「視窗層級」的指令,編輯器有焦點時它們仍然必須能用。 +# The menu and toolbar commands. Editor actions must not reuse their sequences or # neither side fires. These are window-level commands that have to keep working # while the editor has focus. WINDOW_SHORTCUTS: Dict[str, str] = { # 工具列 / Toolbar - "F5": "run_program", - "F9": "run_debugger", - "Shift+F5": "stop_program", - "Ctrl+Shift+F": "search_in_files", - "Ctrl+Shift+A": "command_palette", - "Ctrl+P": "quick_open", - "Ctrl+Shift+O": "go_to_symbol", + "run_program": "F5", + "run_debugger": "F9", + "stop_program": "Shift+F5", + "search_in_files": "Ctrl+Shift+F", + "command_palette": "Ctrl+Shift+A", + "quick_open": "Ctrl+P", + "go_to_symbol": "Ctrl+Shift+O", # 檔案選單 / File menu - "Ctrl+N": "new_file", - "Ctrl+O": "open_file", - "Ctrl+K": "open_folder", - "Ctrl+S": "save_file", - "Ctrl+Shift+S": "save_all", + "new_file": "Ctrl+N", + "open_file": "Ctrl+O", + "open_folder": "Ctrl+K", + "save_file": "Ctrl+S", + "save_all": "Ctrl+Shift+S", # 檢查與格式化選單 / Check and format menu - "Ctrl+Shift+Y": "yapf_reformat", - "Ctrl+J": "reformat_json", - "Ctrl+Alt+P": "check_python_format", + "yapf_reformat": "Ctrl+Shift+Y", + "reformat_json": "Ctrl+J", + "check_python_format": "Ctrl+Alt+P", # 文字選單 / Text menu - "Alt+W": "word_wrap", + "word_wrap": "Alt+W", # Python 環境選單 / Python environment menu - "Ctrl+Shift+V": "change_language", - "Ctrl+Shift+U": "pip_upgrade", - "Ctrl+Shift+P": "pip_install", + "change_language": "Ctrl+Shift+V", + "pip_upgrade": "Ctrl+Shift+U", + "pip_install": "Ctrl+Shift+P", # 分頁選單 / Tab menu - "Ctrl+Alt+\\": "toggle_split_view", - "Ctrl+Alt+M": "toggle_minimap", + "toggle_split_view": "Ctrl+Alt+\\", + "toggle_minimap": "Ctrl+Alt+M", } + +# 編輯器自己的指令 / The editor's own commands +EDITOR_SHORTCUTS: Dict[str, str] = { + # 搜尋與跳轉 / Search and navigation + "search": "Ctrl+F", + "search_and_replace": "Ctrl+H", + "go_to_line": "Ctrl+G", + "navigate_back": "Alt+Left", + "navigate_forward": "Alt+Right", + "recent_locations": "Ctrl+Alt+E", + # 折疊與書籤 / Folding and bookmarks + "toggle_fold": "Ctrl+Shift+[", + "fold_all": "Ctrl+Alt+[", + "unfold_all": "Ctrl+Alt+]", + "toggle_bookmark": "Ctrl+Alt+K", + "next_bookmark": "Ctrl+Alt+L", + "previous_bookmark": "Ctrl+Alt+J", + # 行與選取 / Lines and selection + "delete_line": "Ctrl+Shift+D", + "join_lines": "Ctrl+Shift+J", + "sort_lines": "Ctrl+Alt+S", + "expand_selection": "Ctrl+Alt+Right", + "shrink_selection": "Ctrl+Alt+Left", + "increment_number": "Ctrl+Alt+Up", + "decrement_number": "Ctrl+Alt+Down", + "rename_occurrences": "F2", + # git + "next_change": "F7", + "previous_change": "Shift+F7", + "revert_change": "Ctrl+Alt+Z", + "toggle_blame": "Ctrl+Alt+B", + # 多重游標 / Multiple carets + "cursors_on_selected_lines": "Ctrl+Shift+L", + # Qt 自己把 Escape 印成 Esc;用同一種寫法,設定介面才不會把沒改過的項目 + # 當成使用者的變更記下來 + # Qt spells Escape as Esc; matching that keeps the settings dialog from + # recording an untouched command as a change + "clear_extra_cursors": "Ctrl+Shift+Esc", + "cursor_above": "Ctrl+Alt+Shift+Up", + "cursor_below": "Ctrl+Alt+Shift+Down", + "cursor_at_next_occurrence": "Ctrl+Alt+N", + # 巨集 / Macros + "record_macro": "Ctrl+Shift+R", + "play_macro": "Ctrl+Shift+G", + # 除錯 / Debugging + "toggle_breakpoint": "Ctrl+F9", + "debug_continue": "Ctrl+F5", + "debug_step_over": "F10", + "debug_step_into": "F11", + "debug_step_out": "Shift+F11", +} + +# 全部指令的預設按鍵 / Every command's default sequence +DEFAULT_SHORTCUTS: Dict[str, str] = {**WINDOW_SHORTCUTS, **EDITOR_SHORTCUTS} + + +def sequence_for(command: str, overrides: Optional[Dict[str, str]] = None) -> str: + """ + 取得一個指令目前的按鍵 + The sequence a command currently answers to. + + 使用者設定過就用設定的,否則用預設值;設定成空字串代表刻意取消這個快捷鍵。 + A configured sequence wins over the default, and an empty one means the + shortcut was deliberately removed. + + :param command: 指令名稱 / the command's name + :param overrides: 使用者設定的按鍵 / the sequences the user configured + :return: 按鍵組合,沒有指派時為空字串 / the sequence, or an empty string + """ + if overrides is not None and command in overrides: + return str(overrides[command] or "") + return DEFAULT_SHORTCUTS.get(command, "") + + +def effective_shortcuts(overrides: Optional[Dict[str, str]] = None) -> Dict[str, str]: + """ + 取得所有指令目前的按鍵 + Every command's current sequence. + + :param overrides: 使用者設定的按鍵 / the sequences the user configured + :return: 指令對應按鍵 / command mapped to sequence + """ + return { + command: sequence_for(command, overrides) + for command in DEFAULT_SHORTCUTS + } + + +def find_conflicts(shortcuts: Dict[str, str]) -> List[Tuple[str, List[str]]]: + """ + 找出被兩個以上指令共用的按鍵 + Find the sequences claimed by more than one command. + + :param shortcuts: 指令對應按鍵 / command mapped to sequence + :return: ``(按鍵, 指令清單)``,依按鍵排序 / ``(sequence, commands)``, sorted by sequence + """ + owners: Dict[str, List[str]] = {} + for command, sequence in shortcuts.items(): + key = normalise_sequence(sequence) + if key: + owners.setdefault(key, []).append(command) + return sorted( + (key, sorted(commands)) for key, commands in owners.items() if len(commands) > 1 + ) + + +def clean_overrides(overrides: Dict[str, str]) -> Dict[str, str]: + """ + 整理使用者設定:丟掉不認得的指令與跟預設相同的項目 + Tidy the configured sequences: drop unknown commands and anything still at its default. + + 設定檔可能被手動編輯,也可能來自舊版本,因此認不得的指令直接略過。 + A settings file may be hand-edited or come from an older version, so a command + that is not recognised is simply skipped. + + :param overrides: 讀進來的設定 / the sequences as loaded + :return: 只留下真正改過的項目 / only the ones that actually differ + """ + cleaned: Dict[str, str] = {} + for command, sequence in (overrides or {}).items(): + if command not in DEFAULT_SHORTCUTS or not isinstance(sequence, str): + continue + if normalise_sequence(sequence) != normalise_sequence(DEFAULT_SHORTCUTS[command]): + cleaned[command] = sequence + return cleaned diff --git a/test/test_shortcut_registry.py b/test/test_shortcut_registry.py index 133c1fcb..1a1b225b 100644 --- a/test/test_shortcut_registry.py +++ b/test/test_shortcut_registry.py @@ -69,7 +69,7 @@ def test_clashes_are_collected(self): assert registry.conflicts() == [("ctrl+d", "duplicate_line", "next_occurrence")] def test_reserved_sequences_are_already_taken(self): - registry = ShortcutRegistry({"Ctrl+Shift+P": "pip_install"}) + registry = ShortcutRegistry({"pip_install": "Ctrl+Shift+P"}) assert registry.register("Ctrl+Shift+P", "play_macro") == "pip_install" def test_register_all_reports_only_its_own_clashes(self): @@ -94,12 +94,12 @@ class TestWindowShortcutTable: """ def test_no_sequence_is_listed_twice(self): - normalised = [normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS] + normalised = [normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS.values()] assert len(normalised) == len(set(normalised)) def test_save_all_moved_off_the_editors_sort_shortcut(self): assert normalise_sequence("Ctrl+Alt+S") not in { - normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS} + normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS.values()} @pytest.fixture() @@ -135,7 +135,7 @@ def test_no_two_actions_share_a_sequence(self, editor): assert len(sequences) == len(set(sequences)) def test_no_action_takes_a_menu_sequence(self, editor): - reserved = {normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS} + reserved = {normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS.values()} taken = { normalise_sequence(action.shortcut().toString()) for action in editor.actions() diff --git a/test/test_shortcut_settings.py b/test/test_shortcut_settings.py new file mode 100644 index 00000000..2d082e42 --- /dev/null +++ b/test/test_shortcut_settings.py @@ -0,0 +1,175 @@ +"""Tests for the shortcut table, user overrides and the settings dialog.""" +from __future__ import annotations + +import pytest + +from je_editor.utils.shortcuts.shortcut_registry import ( + DEFAULT_SHORTCUTS, EDITOR_SHORTCUTS, WINDOW_SHORTCUTS, clean_overrides, + effective_shortcuts, find_conflicts, normalise_sequence, sequence_for +) + + +class TestTheDefaultTable: + def test_it_covers_both_halves(self): + assert set(DEFAULT_SHORTCUTS) == set(WINDOW_SHORTCUTS) | set(EDITOR_SHORTCUTS) + + def test_the_two_halves_do_not_overlap(self): + assert set(WINDOW_SHORTCUTS) & set(EDITOR_SHORTCUTS) == set() + + def test_nothing_is_claimed_twice(self): + # A duplicate here would make Qt fire neither of the two commands. + assert find_conflicts(DEFAULT_SHORTCUTS) == [] + + def test_every_command_has_keys(self): + assert all(sequence for sequence in DEFAULT_SHORTCUTS.values()) + + def test_every_default_survives_a_round_trip_through_qt(self, qapp): + # A default Qt spells differently would be recorded as a user change the + # first time the settings dialog is saved. + from PySide6.QtGui import QKeySequence + for command, sequence in DEFAULT_SHORTCUTS.items(): + assert normalise_sequence(QKeySequence(sequence).toString()) == \ + normalise_sequence(sequence), command + + +class TestLookingUpASequence: + def test_an_unconfigured_command_uses_its_default(self): + assert sequence_for("save_file") == DEFAULT_SHORTCUTS["save_file"] + + def test_a_configured_command_uses_the_override(self): + assert sequence_for("save_file", {"save_file": "Ctrl+Alt+W"}) == "Ctrl+Alt+W" + + def test_an_override_for_another_command_is_ignored(self): + assert sequence_for("save_file", {"open_file": "Ctrl+Alt+W"}) == \ + DEFAULT_SHORTCUTS["save_file"] + + def test_an_empty_override_removes_the_shortcut(self): + assert sequence_for("save_file", {"save_file": ""}) == "" + + def test_an_unknown_command_has_no_sequence(self): + assert sequence_for("no_such_command") == "" + + def test_the_effective_table_covers_every_command(self): + assert set(effective_shortcuts()) == set(DEFAULT_SHORTCUTS) + + def test_the_effective_table_applies_overrides(self): + table = effective_shortcuts({"save_file": "Ctrl+Alt+W"}) + assert table["save_file"] == "Ctrl+Alt+W" + + +class TestFindingConflicts: + def test_two_commands_on_one_sequence_are_reported(self): + assert find_conflicts({"a": "Ctrl+G", "b": "Ctrl+G"}) == [("ctrl+g", ["a", "b"])] + + def test_the_comparison_ignores_case_and_order(self): + assert find_conflicts({"a": "Ctrl+Shift+G", "b": "shift+ctrl+g"}) != [] + + def test_distinct_sequences_are_fine(self): + assert find_conflicts({"a": "Ctrl+G", "b": "Ctrl+H"}) == [] + + def test_unassigned_commands_do_not_clash(self): + assert find_conflicts({"a": "", "b": ""}) == [] + + +class TestCleaningOverrides: + def test_a_real_change_is_kept(self): + assert clean_overrides({"save_file": "Ctrl+Alt+W"}) == {"save_file": "Ctrl+Alt+W"} + + def test_a_value_still_at_its_default_is_dropped(self): + assert clean_overrides({"save_file": DEFAULT_SHORTCUTS["save_file"]}) == {} + + def test_a_default_written_differently_is_still_a_default(self): + assert clean_overrides({"command_palette": "shift+ctrl+a"}) == {} + + def test_an_unknown_command_is_dropped(self): + assert clean_overrides({"no_such_command": "Ctrl+G"}) == {} + + def test_a_non_string_is_dropped(self): + assert clean_overrides({"save_file": 5}) == {} + + def test_nothing_configured_stays_nothing(self): + assert clean_overrides({}) == {} + + +@pytest.fixture() +def dialog(qapp, qtbot): + """The settings dialog, with the real command table behind it.""" + from je_editor.pyside_ui.dialog.shortcut_dialog.shortcut_settings_dialog import ( + ShortcutSettingsDialog + ) + from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict + saved = dict(user_setting_dict.get("shortcuts") or {}) + widget = ShortcutSettingsDialog() + qtbot.addWidget(widget) + yield widget + user_setting_dict["shortcuts"] = saved + + +class TestTheSettingsDialog: + def test_it_lists_every_command(self, dialog): + assert dialog.tree.topLevelItemCount() == len(DEFAULT_SHORTCUTS) + + def test_it_starts_from_the_current_shortcuts(self, dialog): + assert normalise_sequence(dialog.current_shortcuts()["save_file"]) == \ + normalise_sequence(DEFAULT_SHORTCUTS["save_file"]) + + def test_it_starts_without_conflicts(self, dialog): + assert dialog.conflicts() == [] + + def test_a_clash_is_reported(self, dialog): + from PySide6.QtGui import QKeySequence + dialog._editors["save_file"].setKeySequence( + QKeySequence(DEFAULT_SHORTCUTS["open_file"])) + assert dialog.conflicts() != [] + + def test_a_clash_cannot_be_saved(self, dialog): + from PySide6.QtGui import QKeySequence + dialog._editors["save_file"].setKeySequence( + QKeySequence(DEFAULT_SHORTCUTS["open_file"])) + assert dialog.save() is False + assert dialog.save_button.isEnabled() is False + + def test_a_change_is_stored(self, dialog): + from PySide6.QtGui import QKeySequence + from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict + dialog._editors["save_all"].setKeySequence(QKeySequence("Ctrl+Alt+W")) + assert dialog.save() is True + assert user_setting_dict["shortcuts"]["save_all"] == "Ctrl+Alt+W" + + def test_untouched_commands_are_not_stored(self, dialog): + from PySide6.QtGui import QKeySequence + from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict + dialog._editors["save_all"].setKeySequence(QKeySequence("Ctrl+Alt+W")) + dialog.save() + assert set(user_setting_dict["shortcuts"]) == {"save_all"} + + def test_restoring_defaults_clears_a_change(self, dialog): + from PySide6.QtGui import QKeySequence + dialog._editors["save_all"].setKeySequence(QKeySequence("Ctrl+Alt+W")) + dialog.reset_to_defaults() + assert normalise_sequence(dialog.current_shortcuts()["save_all"]) == \ + normalise_sequence(DEFAULT_SHORTCUTS["save_all"]) + + +class TestTheEditorFollowsTheSetting: + def test_a_configured_sequence_reaches_the_action(self, qapp, qtbot): + from unittest.mock import MagicMock, patch + from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict + saved = dict(user_setting_dict.get("shortcuts") or {}) + user_setting_dict["shortcuts"] = {"go_to_line": "Ctrl+Alt+W"} + try: + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import ( + CodeEditor + ) + editor = CodeEditor(parent) + qtbot.addWidget(editor) + assert normalise_sequence(editor.goto_line_action.shortcut().toString()) == \ + normalise_sequence("Ctrl+Alt+W") + finally: + user_setting_dict["shortcuts"] = saved diff --git a/test/test_toolbar_actions.py b/test/test_toolbar_actions.py index c7b0b0fa..8e1858f9 100644 --- a/test/test_toolbar_actions.py +++ b/test/test_toolbar_actions.py @@ -63,7 +63,7 @@ def test_new_actions_are_on_the_toolbar(self, toolbar_window): def test_every_toolbar_shortcut_is_reserved(self, toolbar_window): # The editor checks its own shortcuts against this table, so a sequence # the toolbar takes without listing it there could be claimed twice. - reserved = {normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS} + reserved = {normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS.values()} for action in toolbar_window.main_toolbar.actions(): sequence = normalise_sequence(action.shortcut().toString()) if sequence: From 0fe0f739f8dd3a0cf4ff2414960c477ea1e61cb9 Mon Sep 17 00:00:00 2001 From: JeffreyChen <zenxcvwait@gmail.com> Date: Sun, 26 Jul 2026 15:35:14 +0800 Subject: [PATCH 13/16] Share one language server between the tabs that need it Each editor started a server of its own, so five open TypeScript files meant five processes each indexing the whole project -- five times the memory and startup cost, with five separate ideas of the same code. A session now owns the process, keyed by command and project root, and the per-file client keeps only its own version number and requests. Replies go back to whichever tab asked and diagnostics to the tab holding that URI. The server shuts down when the last of its tabs closes, which is also where didClose is sent; saving sends didSave, without which some servers never run their slower checks at all. --- je_editor/pyside_ui/code/lsp/lsp_client.py | 145 ++++++----- je_editor/pyside_ui/code/lsp/lsp_session.py | 242 ++++++++++++++++++ .../dialog/file_dialog/save_file_dialog.py | 4 + test/test_lsp_session.py | 201 +++++++++++++++ 4 files changed, 519 insertions(+), 73 deletions(-) create mode 100644 je_editor/pyside_ui/code/lsp/lsp_session.py create mode 100644 test/test_lsp_session.py diff --git a/je_editor/pyside_ui/code/lsp/lsp_client.py b/je_editor/pyside_ui/code/lsp/lsp_client.py index d18b9b51..a1955cae 100644 --- a/je_editor/pyside_ui/code/lsp/lsp_client.py +++ b/je_editor/pyside_ui/code/lsp/lsp_client.py @@ -1,12 +1,12 @@ """ -以 stdio 與語言伺服器溝通 -Talk to a language server over stdio. +一個檔案這一端的語言伺服器連線 +One file's end of a language server connection. -用 QProcess 而不是自己開執行緒:QProcess 本來就是非同步的,讀到資料會發訊號, -所以不需要為了等待輸出而佔住一條執行緒。 -This uses QProcess rather than a thread of its own: QProcess is already -asynchronous and signals when data arrives, so no thread has to sit waiting for -output. +伺服器程序本身由 ``lsp_session`` 保管並共用;這裡負責的是「這個檔案」的部分: +版本號、送出的請求、以及把回覆變成編輯器聽得懂的訊號。 +The process itself is held and shared by ``lsp_session``; what lives here is the +part belonging to one file — its version number, the requests it sent, and +turning replies into signals the editor understands. 伺服器沒安裝、啟動失敗或中途結束時都只是「沒有補全」,不會影響編輯。 A server that is missing, fails to start, or dies mid-session simply means no @@ -16,16 +16,14 @@ from pathlib import Path -from PySide6.QtCore import QObject, QProcess, Signal +from PySide6.QtCore import QObject, Signal -from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.pyside_ui.code.lsp.lsp_session import LspSession, session_registry from je_editor.utils.lsp.language_servers import language_id, server_command from je_editor.utils.lsp.lsp_protocol import ( - MessageReader, completion_labels, definition_location, diagnostic_entries, - encode_message, file_uri, hover_text, notification, @@ -33,9 +31,6 @@ text_edits, ) -# 等待伺服器結束的時間(毫秒)/ How long to wait for the server to exit -_SHUTDOWN_WAIT_MS = 2000 - class LspClient(QObject): """ @@ -54,9 +49,7 @@ def __init__(self, parent: QObject | None = None) -> None: :param parent: Qt 父物件 / the Qt parent """ super().__init__(parent) - self._process: QProcess | None = None - self._reader = MessageReader() - self._next_id = 1 + self._session: LspSession | None = None self._file_path: str | None = None self._version = 0 self._pending_completion_id: int | None = None @@ -67,52 +60,46 @@ def __init__(self, parent: QObject | None = None) -> None: @property def running(self) -> bool: """伺服器是否正在執行 / Whether the server is running.""" - return self._process is not None and self._process.state() != QProcess.ProcessState.NotRunning + return self._session is not None and self._session.running def start_for(self, file_path: str, servers: dict | None = None) -> bool: """ - 為某個檔案啟動對應的語言伺服器 - Start the language server that handles a file. + 接上負責這個檔案的語言伺服器 + Attach to the language server that handles a file. + + 同一個指令與專案根目錄底下的檔案共用一個伺服器程序,因此開第二個同語言的 + 檔案不會再啟動一個。 + Files under the same command and project root share one process, so + opening a second file of that language does not start another. :param file_path: 檔案路徑 / the file to serve :param servers: 伺服器對照表 / the server mapping to consult - :return: 有啟動時為 ``True`` / ``True`` when a server was started + :return: 有接上時為 ``True`` / ``True`` when a server was attached """ command = server_command(Path(file_path).suffix, servers) if command is None: return False self.stop() - process = QProcess(self) - process.setProgram(command[0]) - process.setArguments(command[1:]) - process.readyReadStandardOutput.connect(self._read_output) - process.start() - if not process.waitForStarted(_SHUTDOWN_WAIT_MS): - jeditor_logger.debug(f"lsp_client: {command[0]} did not start") - process.deleteLater() + root = str(Path(file_path).parent) + session = session_registry.session_for(command, root, file_uri(root)) + if session is None: return False - self._process = process + self._session = session self._file_path = file_path - self._send(request(self._take_id(), "initialize", { - "processId": None, - "rootUri": file_uri(str(Path(file_path).parent)), - "capabilities": {}, - })) - self._send(notification("initialized", {})) + session.register_document(file_uri(file_path), self) return True - def _take_id(self) -> int: - """取得下一個請求編號 / Take the next request id.""" - request_id = self._next_id - self._next_id += 1 - return request_id - def _send(self, payload: dict) -> bool: """把訊息寫給伺服器 / Write a message to the server.""" - if not self.running: - return False - self._process.write(encode_message(payload)) - return True + return self._session.send(payload) if self._session is not None else False + + def _send_request(self, payload: dict) -> bool: + """送出請求,回覆會回到這個客戶端 / Send a request, so its reply comes back here.""" + return self._session.send_request(self, payload) if self._session is not None else False + + def _take_id(self) -> int: + """取得下一個請求編號 / Take the next request id.""" + return self._session.take_id() if self._session is not None else 0 def did_open(self, text: str) -> bool: """ @@ -163,7 +150,7 @@ def request_completion(self, line: int, column: int) -> bool: return False request_id = self._take_id() self._pending_completion_id = request_id - return self._send(request(request_id, "textDocument/completion", { + return self._send_request(request(request_id, "textDocument/completion", { "textDocument": {"uri": file_uri(self._file_path)}, "position": {"line": line, "character": column}, })) @@ -194,7 +181,7 @@ def request_rename(self, line: int, column: int, new_name: str) -> bool: return False request_id = self._take_id() self._pending_edit_id = request_id - return self._send(request(request_id, "textDocument/rename", { + return self._send_request(request(request_id, "textDocument/rename", { "textDocument": {"uri": file_uri(self._file_path)}, "position": {"line": line, "character": column}, "newName": new_name, @@ -212,7 +199,7 @@ def request_formatting(self, tab_size: int = 4) -> bool: return False request_id = self._take_id() self._pending_edit_id = request_id - return self._send(request(request_id, "textDocument/formatting", { + return self._send_request(request(request_id, "textDocument/formatting", { "textDocument": {"uri": file_uri(self._file_path)}, "options": {"tabSize": tab_size, "insertSpaces": True}, })) @@ -235,7 +222,7 @@ def _position_request( return False request_id = self._take_id() setattr(self, pending_attribute, request_id) - return self._send(request(request_id, method, { + return self._send_request(request(request_id, method, { "textDocument": {"uri": file_uri(self._file_path)}, "position": {"line": line, "character": column}, })) @@ -276,32 +263,44 @@ def handle_message(self, message: dict) -> None: if edits: self.edits_ready.emit(edits) - def _read_output(self) -> None: - """讀取伺服器輸出並逐則處理 / Read the server's output and handle each message.""" - if self._process is None: - return - data = bytes(self._process.readAllStandardOutput()) - for message in self._reader.feed(data): - self.handle_message(message) - def stop(self) -> None: """ - 結束伺服器 - Shut the server down. + 放掉這個檔案的連線 + Let go of this file's connection. - 先以 ``shutdown``/``exit`` 請它自己結束,逾時才強制終止。 - It is asked to finish with ``shutdown``/``exit`` first, and only killed - if it does not. + 伺服器是共用的,所以這裡只通知它這個檔案關了;等到沒有任何編輯器還用著同 + 一個伺服器,連線表才會把程序關掉。 + The server is shared, so this only tells it the file is closed; the + process is shut down once no editor is using that server any more. """ - process, self._process = self._process, None - self._reader = MessageReader() + session, self._session = self._session, None self._pending_completion_id = None - if process is None: + self._pending_definition_id = None + self._pending_hover_id = None + self._pending_edit_id = None + if session is None: return - if process.state() != QProcess.ProcessState.NotRunning: - process.write(encode_message(request(self._take_id(), "shutdown"))) - process.write(encode_message(notification("exit"))) - if not process.waitForFinished(_SHUTDOWN_WAIT_MS): - process.kill() - process.waitForFinished(_SHUTDOWN_WAIT_MS) - process.deleteLater() + if self._file_path is not None: + uri = file_uri(self._file_path) + session.send(notification("textDocument/didClose", {"textDocument": {"uri": uri}})) + session.forget_document(uri) + session_registry.release(session) + + def did_save(self, text: str) -> bool: + """ + 通知伺服器檔案已存檔 + Tell the server the file was saved. + + 有些伺服器只在存檔後才重跑比較慢的檢查,收不到這個通知就永遠不會跑。 + Some servers only run their slower checks on save, and never run them at + all without this. + + :param text: 存下去的內容 / the content that was saved + :return: 有送出時為 ``True`` / ``True`` when the notification was sent + """ + if self._file_path is None: + return False + return self._send(notification("textDocument/didSave", { + "textDocument": {"uri": file_uri(self._file_path)}, + "text": text, + })) diff --git a/je_editor/pyside_ui/code/lsp/lsp_session.py b/je_editor/pyside_ui/code/lsp/lsp_session.py new file mode 100644 index 00000000..2c800744 --- /dev/null +++ b/je_editor/pyside_ui/code/lsp/lsp_session.py @@ -0,0 +1,242 @@ +""" +一個語言伺服器程序,供開著同種檔案的編輯器共用 +One language server process, shared by every editor holding that kind of file. + +原本是「一個編輯器一個伺服器」,開五個 ``.ts`` 就有五個伺服器程序,各自把整個 +專案索引一遍——記憶體與啟動時間都乘以五,而且它們對同一份專案的認知還各自獨立。 +It used to be one server per editor, so five open ``.ts`` files meant five +processes each indexing the whole project: five times the memory and the startup +cost, with five separate ideas of the same project. + +同一個指令與同一個專案根目錄只會有一個連線,用參考計數決定什麼時候關掉。回覆依 +請求編號送回發問的那個客戶端,診斷則依 URI 送給對應的檔案。 +One connection exists per command and project root, closed when the last user +lets go. A reply goes back to whichever client asked, and a diagnostic goes to +the client holding that URI. +""" +from __future__ import annotations + +from typing import Dict, List, Optional, Tuple + +from PySide6.QtCore import QObject, QProcess + +from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.lsp.lsp_protocol import ( + MessageReader, encode_message, notification, request +) + +# 等待伺服器啟動或結束的時間(毫秒)/ How long to wait for the server to start or finish +_WAIT_MS = 2000 + + +class LspSession(QObject): + """ + 一個語言伺服器連線 + One connection to a language server. + """ + + def __init__(self, command: List[str], root: str, parent: QObject | None = None) -> None: + """ + :param command: 啟動伺服器的指令 / the command that starts the server + :param root: 專案根目錄 / the project root it serves + :param parent: Qt 父物件 / the Qt parent + """ + super().__init__(parent) + self._command = list(command) + self._root = root + self._process: QProcess | None = None + self._reader = MessageReader() + self._next_id = 1 + # 每個請求編號屬於哪個客戶端 / Which client each request id belongs to + self._waiting: Dict[int, object] = {} + # 目前開著的檔案,URI 對應客戶端 / The open files, by URI + self._documents: Dict[str, object] = {} + + @property + def running(self) -> bool: + """伺服器是否正在執行 / Whether the server is running.""" + return (self._process is not None + and self._process.state() != QProcess.ProcessState.NotRunning) + + @property + def key(self) -> Tuple[Tuple[str, ...], str]: + """這個連線的識別 / What identifies this connection.""" + return tuple(self._command), self._root + + def start(self, root_uri: str) -> bool: + """ + 啟動伺服器 + Start the server. + + :param root_uri: 專案根目錄的 URI / the project root's URI + :return: 有啟動時為 ``True`` / ``True`` when it started + """ + if self.running: + return True + process = QProcess(self) + process.setProgram(self._command[0]) + process.setArguments(self._command[1:]) + process.readyReadStandardOutput.connect(self._read_output) + process.start() + if not process.waitForStarted(_WAIT_MS): + jeditor_logger.debug(f"lsp_session: {self._command[0]} did not start") + process.deleteLater() + return False + self._process = process + self.send(request(self.take_id(), "initialize", { + "processId": None, + "rootUri": root_uri, + "capabilities": {}, + })) + self.send(notification("initialized", {})) + return True + + def take_id(self) -> int: + """取得下一個請求編號 / Take the next request id.""" + request_id = self._next_id + self._next_id += 1 + return request_id + + def send(self, payload: dict) -> bool: + """把訊息寫給伺服器 / Write a message to the server.""" + if not self.running: + return False + self._process.write(encode_message(payload)) + return True + + def send_request(self, client: object, payload: dict) -> bool: + """ + 代表某個客戶端送出請求,回覆會送回給它 + Send a request on a client's behalf, so the reply comes back to it. + + :param client: 發問的客戶端 / the client asking + :param payload: 請求內容 / the request + :return: 有送出時為 ``True`` / ``True`` when it was sent + """ + if not self.send(payload): + return False + self._waiting[payload.get("id")] = client + return True + + def register_document(self, uri: str, client: object) -> None: + """記下某個檔案由哪個客戶端負責 / Note which client holds a file.""" + self._documents[uri] = client + + def forget_document(self, uri: str) -> None: + """放掉一個檔案 / Let go of a file.""" + self._documents.pop(uri, None) + + @property + def user_count(self) -> int: + """目前有幾個客戶端在用 / How many clients are using this.""" + return len(self._documents) + + def _read_output(self) -> None: + """讀取伺服器輸出並逐則轉交 / Read the server's output and pass each message on.""" + if self._process is None: + return + data = bytes(self._process.readAllStandardOutput()) + for message in self._reader.feed(data): + self.route(message) + + def route(self, message: dict) -> Optional[object]: + """ + 把一則訊息交給該收的客戶端 + Hand one message to the client it belongs to. + + 診斷帶著 URI,交給持有該檔案的客戶端;其餘依請求編號交回發問的那一個。 + A diagnostic carries a URI and goes to whoever holds that file; anything + else goes back to whichever client asked for it. + + :param message: 已解析的訊息 / the parsed message + :return: 收下這則訊息的客戶端,沒有時為 ``None`` / the client, or ``None`` + """ + if message.get("method") == "textDocument/publishDiagnostics": + uri = (message.get("params") or {}).get("uri", "") + client = self._documents.get(uri) + else: + client = self._waiting.pop(message.get("id"), None) + if client is not None: + client.handle_message(message) + return client + + def shutdown(self) -> None: + """ + 結束伺服器 + Shut the server down. + + 先以 ``shutdown``/``exit`` 請它自己結束,逾時才強制終止。 + It is asked to finish with ``shutdown``/``exit`` first, and only killed + if it does not. + """ + process, self._process = self._process, None + self._reader = MessageReader() + self._waiting = {} + self._documents = {} + if process is None: + return + if process.state() != QProcess.ProcessState.NotRunning: + process.write(encode_message(request(self.take_id(), "shutdown"))) + process.write(encode_message(notification("exit"))) + if not process.waitForFinished(_WAIT_MS): + process.kill() + process.waitForFinished(_WAIT_MS) + process.deleteLater() + + +class LspSessionRegistry: + """ + 保管目前開著的語言伺服器連線 + Keep the language server connections that are currently open. + """ + + def __init__(self) -> None: + self._sessions: Dict[Tuple[Tuple[str, ...], str], LspSession] = {} + + def session_for(self, command: List[str], root: str, root_uri: str) -> Optional[LspSession]: + """ + 取得對應的連線,沒有就開一個 + The connection for a command and root, starting one when there is none. + + :param command: 啟動伺服器的指令 / the command that starts the server + :param root: 專案根目錄 / the project root + :param root_uri: 專案根目錄的 URI / that root as a URI + :return: 連線,啟動失敗時為 ``None`` / the session, or ``None`` when it failed + """ + key = (tuple(command), root) + session = self._sessions.get(key) + if session is not None and session.running: + return session + session = LspSession(command, root) + if not session.start(root_uri): + return None + self._sessions[key] = session + return session + + def release(self, session: LspSession) -> bool: + """ + 放掉一個連線;沒有人在用就關掉伺服器 + Let go of a connection, shutting the server down once nobody holds it. + + :param session: 要放掉的連線 / the session being let go + :return: 是否關掉了伺服器 / whether the server was shut down + """ + if session.user_count > 0: + return False + self._sessions.pop(session.key, None) + session.shutdown() + return True + + def sessions(self) -> List[LspSession]: + """目前開著的連線 / The connections currently open.""" + return list(self._sessions.values()) + + def shutdown_all(self) -> None: + """關掉每一個連線 / Shut every connection down.""" + for session in list(self._sessions.values()): + session.shutdown() + self._sessions = {} + + +# 整個應用程式共用的連線表 / The connections the whole application shares +session_registry = LspSessionRegistry() diff --git a/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py b/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py index 15fe6dfe..0006e80c 100644 --- a/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py +++ b/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py @@ -120,6 +120,10 @@ def choose_file_get_save_file_path(parent_qt_instance: EditorMain) -> bool: widget.code_save_thread.file = file_path widget.code_save_thread.editor = widget.code_edit + # 告訴語言伺服器檔案存了:有些檢查只在存檔後才跑 + # Tell the language server the file was saved: some checks only run then + widget.code_edit.lsp_client.did_save(widget.code_edit.toPlainText()) + # 更新分頁標題並清除未儲存標記 / Update tab title and clear unsaved marker widget.rename_self_tab() widget.mark_saved() diff --git a/test/test_lsp_session.py b/test/test_lsp_session.py new file mode 100644 index 00000000..624498d8 --- /dev/null +++ b/test/test_lsp_session.py @@ -0,0 +1,201 @@ +"""Tests that one language server is shared rather than started per editor.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from je_editor.pyside_ui.code.lsp.lsp_session import LspSession, LspSessionRegistry + + +class _FakeSession(LspSession): + """A session that records what it would have sent, without a process.""" + + def __init__(self, command=None, root="/project"): + super().__init__(command or ["fake-server"], root) + self.sent: list = [] + self.started = False + + def start(self, root_uri: str) -> bool: + self.started = True + return True + + @property + def running(self) -> bool: + return self.started + + def send(self, payload: dict) -> bool: + self.sent.append(payload) + return True + + +class TestRoutingMessages: + """Two files share one process, so each reply has to find its own file.""" + + def test_a_reply_goes_to_the_client_that_asked(self): + session = _FakeSession() + asker, other = MagicMock(), MagicMock() + session.send_request(asker, {"id": 1, "method": "textDocument/hover"}) + session.route({"id": 1, "result": {}}) + asker.handle_message.assert_called_once() + other.handle_message.assert_not_called() + + def test_a_reply_is_only_delivered_once(self): + session = _FakeSession() + asker = MagicMock() + session.send_request(asker, {"id": 1, "method": "textDocument/hover"}) + session.route({"id": 1, "result": {}}) + session.route({"id": 1, "result": {}}) + assert asker.handle_message.call_count == 1 + + def test_a_reply_nobody_asked_for_is_dropped(self): + session = _FakeSession() + assert session.route({"id": 99, "result": {}}) is None + + def test_diagnostics_go_to_the_file_they_name(self): + session = _FakeSession() + first, second = MagicMock(), MagicMock() + session.register_document("file:///a.ts", first) + session.register_document("file:///b.ts", second) + session.route({ + "method": "textDocument/publishDiagnostics", + "params": {"uri": "file:///b.ts", "diagnostics": []}, + }) + second.handle_message.assert_called_once() + first.handle_message.assert_not_called() + + def test_diagnostics_for_an_unopened_file_are_dropped(self): + session = _FakeSession() + assert session.route({ + "method": "textDocument/publishDiagnostics", + "params": {"uri": "file:///gone.ts"}, + }) is None + + def test_request_ids_do_not_repeat(self): + session = _FakeSession() + assert session.take_id() != session.take_id() + + +class TestSharingASession: + @staticmethod + def _registry(): + registry = LspSessionRegistry() + return registry + + def test_two_files_of_one_language_share_a_session(self): + registry = self._registry() + with patch( + "je_editor.pyside_ui.code.lsp.lsp_session.LspSession", _FakeSession + ): + first = registry.session_for(["fake-server"], "/project", "file:///project") + second = registry.session_for(["fake-server"], "/project", "file:///project") + assert first is second + + def test_a_different_language_gets_its_own(self): + registry = self._registry() + with patch("je_editor.pyside_ui.code.lsp.lsp_session.LspSession", _FakeSession): + first = registry.session_for(["ts-server"], "/project", "file:///project") + second = registry.session_for(["rs-server"], "/project", "file:///project") + assert first is not second + + def test_a_different_project_gets_its_own(self): + registry = self._registry() + with patch("je_editor.pyside_ui.code.lsp.lsp_session.LspSession", _FakeSession): + first = registry.session_for(["fake-server"], "/one", "file:///one") + second = registry.session_for(["fake-server"], "/two", "file:///two") + assert first is not second + + def test_a_server_that_will_not_start_gives_nothing(self): + registry = self._registry() + + class _Dead(_FakeSession): + def start(self, root_uri: str) -> bool: + return False + + with patch("je_editor.pyside_ui.code.lsp.lsp_session.LspSession", _Dead): + assert registry.session_for(["fake"], "/p", "file:///p") is None + + def test_a_session_still_in_use_is_not_shut_down(self): + registry = self._registry() + with patch("je_editor.pyside_ui.code.lsp.lsp_session.LspSession", _FakeSession): + session = registry.session_for(["fake-server"], "/project", "file:///project") + session.register_document("file:///a.ts", MagicMock()) + assert registry.release(session) is False + + def test_the_last_user_shuts_it_down(self): + registry = self._registry() + with patch("je_editor.pyside_ui.code.lsp.lsp_session.LspSession", _FakeSession): + session = registry.session_for(["fake-server"], "/project", "file:///project") + session.register_document("file:///a.ts", MagicMock()) + session.forget_document("file:///a.ts") + assert registry.release(session) is True + assert registry.sessions() == [] + + +@pytest.fixture() +def attached_clients(qapp): + """Two clients attached to one fake server, as two open tabs would be.""" + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + registry = LspSessionRegistry() + # server_command only answers for a server installed on this machine, which a + # fake one is not. + with patch("je_editor.pyside_ui.code.lsp.lsp_client.session_registry", registry), \ + patch("je_editor.pyside_ui.code.lsp.lsp_client.server_command", + return_value=["fake-server"]), \ + patch("je_editor.pyside_ui.code.lsp.lsp_session.LspSession", _FakeSession): + first, second = LspClient(), LspClient() + first.start_for("/project/a.ts") + second.start_for("/project/b.ts") + yield registry, first, second + + +class TestTheClientUsesTheSharedSession: + def test_two_tabs_start_one_server(self, attached_clients): + registry, _first, _second = attached_clients + assert len(registry.sessions()) == 1 + + def test_both_tabs_report_it_running(self, attached_clients): + _registry, first, second = attached_clients + assert first.running and second.running + + def test_closing_one_tab_leaves_the_server_up(self, attached_clients): + registry, first, second = attached_clients + with patch("je_editor.pyside_ui.code.lsp.lsp_client.session_registry", registry): + first.stop() + assert len(registry.sessions()) == 1 + assert second.running + + def test_closing_the_last_tab_shuts_it_down(self, attached_clients): + registry, first, second = attached_clients + with patch("je_editor.pyside_ui.code.lsp.lsp_client.session_registry", registry): + first.stop() + second.stop() + assert registry.sessions() == [] + + def test_closing_tells_the_server_the_file_is_closed(self, attached_clients): + registry, first, _second = attached_clients + session = registry.sessions()[0] + with patch("je_editor.pyside_ui.code.lsp.lsp_client.session_registry", registry): + first.stop() + methods = [message.get("method") for message in session.sent] + assert "textDocument/didClose" in methods + + def test_a_reply_reaches_only_the_tab_that_asked(self, attached_clients): + registry, first, second = attached_clients + session = registry.sessions()[0] + received: list = [] + first.completions_ready.connect(received.append) + second.completions_ready.connect(lambda _labels: received.append("wrong tab")) + first.request_completion(0, 0) + session.route({"id": first._pending_completion_id, "result": [{"label": "ok"}]}) + assert received == [["ok"]] + + def test_saving_tells_the_server(self, attached_clients): + registry, first, _second = attached_clients + session = registry.sessions()[0] + assert first.did_save("text") is True + assert session.sent[-1]["method"] == "textDocument/didSave" + + def test_saving_without_a_file_sends_nothing(self, qapp): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + assert LspClient().did_save("text") is False From 2d8db4026f1a7bb7b43506106a1815f2a1b62a88 Mon Sep 17 00:00:00 2001 From: JeffreyChen <zenxcvwait@gmail.com> Date: Sun, 26 Jul 2026 15:42:23 +0800 Subject: [PATCH 14/16] Ask the language server the rest of what it knows Signature help, find references, quick fixes and document symbols were the parts of the protocol still unused. Signature help and references sit beside hover in the right-click menu, and a quick fix applies the edits the server offers. Only actions that carry edits are listed: a command-only action needs another round trip, and offering one that does nothing when pressed is worse than not offering it. The diagnostics sent with a fix request are the server''s own objects rather than the editor''s converted form, which does not count lines the same way. --- je_editor/pyside_ui/code/lsp/lsp_client.py | 140 +++++++++- .../code_edit_plaintext.py | 95 +++++++ je_editor/utils/lsp/lsp_protocol.py | 125 +++++++++ je_editor/utils/multi_language/english.py | 7 + .../multi_language/traditional_chinese.py | 7 + test/test_lsp_more_features.py | 254 ++++++++++++++++++ 6 files changed, 626 insertions(+), 2 deletions(-) create mode 100644 test/test_lsp_more_features.py diff --git a/je_editor/pyside_ui/code/lsp/lsp_client.py b/je_editor/pyside_ui/code/lsp/lsp_client.py index a1955cae..907abe1f 100644 --- a/je_editor/pyside_ui/code/lsp/lsp_client.py +++ b/je_editor/pyside_ui/code/lsp/lsp_client.py @@ -21,13 +21,17 @@ from je_editor.pyside_ui.code.lsp.lsp_session import LspSession, session_registry from je_editor.utils.lsp.language_servers import language_id, server_command from je_editor.utils.lsp.lsp_protocol import ( + code_action_titles, completion_labels, definition_location, diagnostic_entries, + document_symbols, file_uri, hover_text, notification, + reference_locations, request, + signature_text, text_edits, ) @@ -43,6 +47,10 @@ class LspClient(QObject): definition_ready = Signal(dict) # {"path": str, "line": int, "column": int} hover_ready = Signal(str) edits_ready = Signal(list) # list[dict] of text edits to apply + signature_ready = Signal(str) + references_ready = Signal(list) # list[dict] of {"path", "line", "column"} + code_actions_ready = Signal(list) # list[dict] of {"title", "edits"} + symbols_ready = Signal(list) # list[dict] of {"name", "kind", "line", "depth"} def __init__(self, parent: QObject | None = None) -> None: """ @@ -56,6 +64,25 @@ def __init__(self, parent: QObject | None = None) -> None: self._pending_definition_id: int | None = None self._pending_hover_id: int | None = None self._pending_edit_id: int | None = None + self._pending_signature_id: int | None = None + self._pending_references_id: int | None = None + self._pending_action_id: int | None = None + self._pending_symbol_id: int | None = None + # 伺服器最後一次回報的診斷,未經轉換 / The server's last diagnostics, unconverted + self._raw_diagnostics: list = [] + + def diagnostics_on_line(self, line: int) -> list: + """ + 取得某一行的診斷,維持伺服器給的原始格式 + The diagnostics on one line, exactly as the server reported them. + + :param line: 以 0 起算的行號,與 LSP 相同 / the 0-based line, as LSP counts them + :return: 該行的診斷 / the diagnostics there + """ + return [ + item for item in self._raw_diagnostics + if ((item.get("range") or {}).get("start") or {}).get("line") == line + ] @property def running(self) -> bool: @@ -204,6 +231,79 @@ def request_formatting(self, tab_size: int = 4) -> bool: "options": {"tabSize": tab_size, "insertSpaces": True}, })) + def request_signature_help(self, line: int, column: int) -> bool: + """ + 要求目前正在輸入的呼叫的簽章 + Ask for the signature of the call being typed. + + :param line: 以 0 起算的行號 / the 0-based line + :param column: 以 0 起算的欄位 / the 0-based column + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + return self._position_request( + "textDocument/signatureHelp", line, column, "_pending_signature_id") + + def request_references(self, line: int, column: int) -> bool: + """ + 要求游標所在符號的所有參照 + Ask where the symbol at a position is referred to. + + :param line: 以 0 起算的行號 / the 0-based line + :param column: 以 0 起算的欄位 / the 0-based column + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + if self._file_path is None: + return False + request_id = self._take_id() + self._pending_references_id = request_id + return self._send_request(request(request_id, "textDocument/references", { + "textDocument": {"uri": file_uri(self._file_path)}, + "position": {"line": line, "character": column}, + "context": {"includeDeclaration": True}, + })) + + def request_code_actions(self, line: int, column: int, diagnostics: list | None = None) -> bool: + """ + 要求某個位置可以套用的修正 + Ask what can be done about a position. + + 把該處的診斷一併送去,伺服器才知道要提出哪些修正——沒有診斷時通常只會回 + 重構類的動作。 + The diagnostics there go along with it, since that is what tells the + server which fixes to offer; without them the reply is usually only + refactorings. + + :param line: 以 0 起算的行號 / the 0-based line + :param column: 以 0 起算的欄位 / the 0-based column + :param diagnostics: 該處的診斷 / the diagnostics reported there + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + if self._file_path is None: + return False + request_id = self._take_id() + self._pending_action_id = request_id + position = {"line": line, "character": column} + return self._send_request(request(request_id, "textDocument/codeAction", { + "textDocument": {"uri": file_uri(self._file_path)}, + "range": {"start": position, "end": position}, + "context": {"diagnostics": diagnostics or []}, + })) + + def request_document_symbols(self) -> bool: + """ + 要求這個檔案裡的所有符號 + Ask for every symbol in this file. + + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + if self._file_path is None: + return False + request_id = self._take_id() + self._pending_symbol_id = request_id + return self._send_request(request(request_id, "textDocument/documentSymbol", { + "textDocument": {"uri": file_uri(self._file_path)}, + })) + def request_definition(self, line: int, column: int) -> bool: """ 要求某個位置的定義位置 @@ -235,8 +335,15 @@ def handle_message(self, message: dict) -> None: :param message: 已解析的訊息 / the parsed message """ if message.get("method") == "textDocument/publishDiagnostics": - entries = diagnostic_entries(message.get("params")) - self.diagnostics_ready.emit(entries) + params = message.get("params") + # 原樣留一份:要求修正時得把伺服器自己給的診斷送回去,而不是編輯器 + # 轉換過的版本(行號起算方式都不同) + # Keep them as they came: asking for a fix means handing the server + # back its own diagnostics, not the editor's converted form, which + # does not even count lines the same way + raw = (params or {}).get("diagnostics") + self._raw_diagnostics = list(raw) if isinstance(raw, list) else [] + self.diagnostics_ready.emit(diagnostic_entries(params)) return if "result" not in message: return @@ -262,6 +369,35 @@ def handle_message(self, message: dict) -> None: edits = text_edits(message.get("result"), uri) if edits: self.edits_ready.emit(edits) + return + self._handle_reply(message) + + def _handle_reply(self, message: dict) -> None: + """ + 處理其餘幾種回覆 + Handle the remaining kinds of reply. + + 每一種都是「編號對得上就解析結果並發出訊號」,因此用一張表處理,不必為每 + 一種各寫一段幾乎相同的判斷。 + Each is the same shape — a matching id means parse the result and emit — + so one table handles them rather than a near-identical branch for each. + + :param message: 已解析的訊息 / the parsed message + """ + replies = ( + ("_pending_signature_id", signature_text, self.signature_ready), + ("_pending_references_id", reference_locations, self.references_ready), + ("_pending_action_id", code_action_titles, self.code_actions_ready), + ("_pending_symbol_id", document_symbols, self.symbols_ready), + ) + for attribute, parse, signal in replies: + if message.get("id") != getattr(self, attribute): + continue + setattr(self, attribute, None) + parsed = parse(message.get("result")) + if parsed: + signal.emit(parsed) + return def stop(self) -> None: """ diff --git a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py index 298c3bef..659644aa 100644 --- a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +++ b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py @@ -389,6 +389,9 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: self.lsp_client.hover_ready.connect(self.show_hover_text) self.lsp_client.edits_ready.connect(self.apply_server_edits) self.lsp_client.definition_ready.connect(self.go_to_definition_location) + self.lsp_client.signature_ready.connect(self.show_signature_text) + self.lsp_client.references_ready.connect(self.show_references) + self.lsp_client.code_actions_ready.connect(self.show_quick_fixes) self.start_language_server() def reset_highlighter(self) -> None: @@ -2824,6 +2827,10 @@ def build_context_menu(self) -> QMenu: ("context_menu_go_to_definition", self.go_to_definition, self.lsp_client.running), ("context_menu_hover", self.request_hover, self.lsp_client.running), + ("context_menu_find_references", self.find_references, + self.lsp_client.running), + ("context_menu_quick_fix", self.request_quick_fixes, + self.lsp_client.running), ("context_menu_rename_symbol", self.rename_symbol, True), ("context_menu_format_document", self.format_with_language_server, self.lsp_client.running), @@ -2963,6 +2970,94 @@ def go_to_definition(self) -> bool: return self.lsp_client.request_definition( cursor.blockNumber(), cursor.positionInBlock()) + def request_signature_help(self) -> bool: + """ + 請語言伺服器說明正在輸入的呼叫 + Ask the language server about the call being typed. + + :return: 有送出請求時為 ``True`` / ``True`` when a request was sent + """ + cursor = self.textCursor() + return self.lsp_client.request_signature_help( + cursor.blockNumber(), cursor.positionInBlock()) + + def show_signature_text(self, text: str) -> None: + """ + 顯示簽章說明 + Show the signature the server described. + + :param text: 簽章文字 / the signature + """ + self.setToolTip(text) + + def find_references(self) -> bool: + """ + 請語言伺服器找出游標所在符號的所有參照 + Ask the language server where the symbol under the caret is referred to. + + :return: 有送出請求時為 ``True`` / ``True`` when a request was sent + """ + cursor = self.textCursor() + return self.lsp_client.request_references( + cursor.blockNumber(), cursor.positionInBlock()) + + def show_references(self, locations: list) -> bool: + """ + 列出參照位置,選一個就跳過去 + List the references and jump to the chosen one. + + :param locations: 參照位置 / the locations found + :return: 有跳轉時為 ``True`` / ``True`` when something was opened + """ + if not locations: + return False + # 位置的行號已經是 1 起算的,與跳轉用的一致 + # The locations already count lines from one, as the jump does + labels = [f"{Path(item['path']).name}:{item['line']}" for item in locations] + chosen, confirmed = QInputDialog.getItem( + self, language_wrapper.language_word_dict.get("lsp_references_title"), + language_wrapper.language_word_dict.get("lsp_references_prompt"), + labels, 0, False) + if not confirmed or not chosen: + return False + return self.go_to_definition_location(locations[labels.index(chosen)]) + + def request_quick_fixes(self) -> bool: + """ + 請語言伺服器提出游標所在位置的修正 + Ask the language server what can be fixed at the caret. + + 把該行的診斷一併送過去,伺服器才知道要針對哪個問題提修正。 + The line's diagnostics go with it, since that is what tells the server + which problem to offer a fix for. + + :return: 有送出請求時為 ``True`` / ``True`` when a request was sent + """ + cursor = self.textCursor() + line = cursor.blockNumber() + return self.lsp_client.request_code_actions( + line, cursor.positionInBlock(), self.lsp_client.diagnostics_on_line(line)) + + def show_quick_fixes(self, actions: list) -> bool: + """ + 列出可套用的修正,選一個就套用 + List the available fixes and apply the chosen one. + + :param actions: 伺服器提供的修正 / the fixes the server offered + :return: 有套用時為 ``True`` / ``True`` when a fix was applied + """ + if not actions: + return False + titles = [action["title"] for action in actions] + chosen, confirmed = QInputDialog.getItem( + self, language_wrapper.language_word_dict.get("lsp_quick_fix_title"), + language_wrapper.language_word_dict.get("lsp_quick_fix_prompt"), + titles, 0, False) + if not confirmed or not chosen: + return False + self.apply_server_edits(actions[titles.index(chosen)]["edits"]) + return True + def closeEvent(self, event: QtGui.QCloseEvent) -> None: """ 關閉前停掉所有背景工作 diff --git a/je_editor/utils/lsp/lsp_protocol.py b/je_editor/utils/lsp/lsp_protocol.py index f6c93103..feeff2f5 100644 --- a/je_editor/utils/lsp/lsp_protocol.py +++ b/je_editor/utils/lsp/lsp_protocol.py @@ -229,6 +229,131 @@ def hover_text(result: object) -> str: return "\n".join(texts) +def signature_text(result: object) -> str: + """ + 從 signature help 回應取出目前這個簽章 + Take the signature currently in play out of a signature help response. + + 只取伺服器指的那一個:一個函式可能有好幾個多載,全部列出來反而看不出正在打的 + 是哪一個。 + Only the one the server points at is taken: a function may have several + overloads, and listing them all hides the one being typed. + + :param result: 伺服器的回應內容 / the server's result + :return: 簽章文字,沒有內容時為空字串 / the signature, or an empty string + """ + if not isinstance(result, dict): + return "" + signatures = result.get("signatures") + if not isinstance(signatures, list) or not signatures: + return "" + index = result.get("activeSignature") + if not isinstance(index, int) or not 0 <= index < len(signatures): + index = 0 + active = signatures[index] + if not isinstance(active, dict): + return "" + label = active.get("label") + documentation = active.get("documentation") + if isinstance(documentation, dict): + documentation = documentation.get("value") + parts = [str(part).strip() for part in (label, documentation) if isinstance(part, str)] + return "\n".join(part for part in parts if part) + + +def reference_locations(result: object) -> list[dict]: + """ + 從 references 回應取出所有位置 + Take every location out of a references response. + + 行列與 :func:`definition_location` 一樣轉成 1 起算,跳轉才不必再換算一次。 + Lines and columns come back 1-based, as :func:`definition_location` gives + them, so a jump does not have to convert again. + + :param result: 伺服器的回應內容 / the server's result + :return: ``{"path", "line", "column"}`` 的清單 / the locations + """ + if not isinstance(result, list): + return [] + locations = [] + for item in result: + location = definition_location(item) + if location is not None: + locations.append(location) + return locations + + +def code_action_titles(result: object) -> list[dict]: + """ + 從 code action 回應取出可以套用的動作 + Take the applicable actions out of a code action response. + + 只保留帶著編輯內容的動作。純指令型的動作要再跟伺服器往返一次才知道要改什麼, + 列出來卻按不動比不列還糟。 + Only actions carrying edits are kept: a command-only action needs another + round trip before anything can be changed, and listing one that does nothing + when pressed is worse than not listing it. + + :param result: 伺服器的回應內容 / the server's result + :return: ``{"title", "edits"}`` 的清單 / the actions + """ + if not isinstance(result, list): + return [] + actions = [] + for item in result: + if not isinstance(item, dict): + continue + title = item.get("title") + edits = text_edits(item.get("edit")) + if isinstance(title, str) and title.strip() and edits: + actions.append({"title": title.strip(), "edits": edits}) + return actions + + +def document_symbols(result: object) -> list[dict]: + """ + 從 document symbol 回應取出符號,巢狀的一併攤平 + Take the symbols out of a document symbol response, nesting included. + + 回應可能是 ``DocumentSymbol``(有 ``children``)或 ``SymbolInformation`` + (帶 ``location``),兩種格式都要接受。 + A response may be ``DocumentSymbol`` with ``children`` or + ``SymbolInformation`` with a ``location``, and both are accepted. + + 行號轉成 1 起算,與其他位置一致。 + Lines come back 1-based, as every other position here does. + + :param result: 伺服器的回應內容 / the server's result + :return: ``{"name", "kind", "line", "depth"}`` 的清單 / the symbols + """ + return _flatten_symbols(result, depth=0) + + +def _flatten_symbols(result: object, depth: int) -> list[dict]: + """把符號樹攤成一層,並記下每個符號的深度 / Flatten the symbol tree, noting each depth.""" + if not isinstance(result, list): + return [] + symbols: list[dict] = [] + for item in result: + if not isinstance(item, dict): + continue + name = item.get("name") + if not isinstance(name, str) or not name.strip(): + continue + span = item.get("selectionRange") or item.get("range") + if span is None: + span = (item.get("location") or {}).get("range") + line, _column = _position(span.get("start") if isinstance(span, dict) else None) + symbols.append({ + "name": name.strip(), + "kind": item.get("kind") if isinstance(item.get("kind"), int) else 0, + "line": line, + "depth": depth, + }) + symbols.extend(_flatten_symbols(item.get("children"), depth + 1)) + return symbols + + def text_edits(result: object, file_uri_text: str = "") -> list[dict]: """ 從 rename 或 formatting 回應取出要套用的編輯 diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index c9b98686..b4a93e1e 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -419,6 +419,13 @@ "shortcut_settings_reset": "Restore defaults", "shortcut_settings_hint": "Changes apply to tabs opened after saving.", "shortcut_settings_conflict": "{keys} is claimed by {commands}", + # Language server + "lsp_references_title": "References", + "lsp_references_prompt": "Go to:", + "lsp_quick_fix_title": "Quick Fixes", + "lsp_quick_fix_prompt": "Apply:", + "context_menu_find_references": "Find References", + "context_menu_quick_fix": "Quick Fix...", "test_panel_traceback_placeholder": "Select a failing test to see why it failed", "test_panel_ready": "Ready", "test_panel_running": "Running tests...", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index 9395d060..732de576 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -409,6 +409,13 @@ "shortcut_settings_reset": "還原預設值", "shortcut_settings_hint": "儲存後對之後開啟的分頁生效。", "shortcut_settings_conflict": "{keys} 同時被 {commands} 使用", + # 語言伺服器 / Language server + "lsp_references_title": "參照", + "lsp_references_prompt": "跳到:", + "lsp_quick_fix_title": "快速修正", + "lsp_quick_fix_prompt": "套用:", + "context_menu_find_references": "尋找參照", + "context_menu_quick_fix": "快速修正…", "test_panel_traceback_placeholder": "選擇一個失敗的測試以查看原因", "test_panel_ready": "就緒", "test_panel_running": "測試執行中...", diff --git a/test/test_lsp_more_features.py b/test/test_lsp_more_features.py new file mode 100644 index 00000000..885ec742 --- /dev/null +++ b/test/test_lsp_more_features.py @@ -0,0 +1,254 @@ +"""Tests for signature help, references, quick fixes and document symbols.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from je_editor.utils.lsp.lsp_protocol import ( + code_action_titles, document_symbols, reference_locations, signature_text +) + + +class TestSignatureText: + def test_the_label_is_taken(self): + result = {"signatures": [{"label": "join(sep: str)"}]} + assert signature_text(result) == "join(sep: str)" + + def test_the_documentation_follows_the_label(self): + result = {"signatures": [{"label": "f()", "documentation": "Does a thing"}]} + assert signature_text(result) == "f()\nDoes a thing" + + def test_markup_documentation_is_unwrapped(self): + result = {"signatures": [{ + "label": "f()", "documentation": {"kind": "markdown", "value": "Docs"}}]} + assert signature_text(result) == "f()\nDocs" + + def test_the_active_overload_wins(self): + result = {"signatures": [{"label": "one"}, {"label": "two"}], "activeSignature": 1} + assert signature_text(result) == "two" + + def test_an_out_of_range_index_falls_back_to_the_first(self): + result = {"signatures": [{"label": "one"}], "activeSignature": 9} + assert signature_text(result) == "one" + + def test_no_signatures_gives_nothing(self): + assert signature_text({"signatures": []}) == "" + + def test_a_null_result_gives_nothing(self): + assert signature_text(None) == "" + + +class TestReferenceLocations: + def test_each_location_is_read(self): + result = [ + {"uri": "file:///a.ts", "range": {"start": {"line": 3, "character": 2}}}, + {"uri": "file:///b.ts", "range": {"start": {"line": 9, "character": 0}}}, + ] + assert [item["line"] for item in reference_locations(result)] == [4, 10] + + def test_the_path_comes_out_of_the_uri(self): + result = [{"uri": "file:///a.ts", "range": {"start": {"line": 0, "character": 0}}}] + assert reference_locations(result)[0]["path"].endswith("a.ts") + + def test_an_unreadable_entry_is_skipped(self): + result = [{"no": "uri"}, {"uri": "file:///a.ts", "range": {"start": {"line": 1}}}] + assert len(reference_locations(result)) == 1 + + def test_a_null_result_gives_nothing(self): + assert reference_locations(None) == [] + + +class TestCodeActionTitles: + @staticmethod + def _action(title: str) -> dict: + return { + "title": title, + "edit": {"changes": {"file:///a.ts": [ + {"range": {"start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 1}}, + "newText": "x"}]}}, + } + + def test_an_action_with_edits_is_offered(self): + assert [item["title"] for item in code_action_titles([self._action("Add import")])] == \ + ["Add import"] + + def test_the_edits_come_along(self): + assert code_action_titles([self._action("Fix")])[0]["edits"] + + def test_an_action_without_edits_is_dropped(self): + # A command-only action needs another round trip, so offering it would + # give the user a fix that does nothing when pressed. + assert code_action_titles([{"title": "Run command", "command": "do.thing"}]) == [] + + def test_an_action_without_a_title_is_dropped(self): + action = self._action("") + assert code_action_titles([action]) == [] + + def test_a_null_result_gives_nothing(self): + assert code_action_titles(None) == [] + + +class TestDocumentSymbols: + def test_a_flat_symbol_is_read(self): + result = [{"name": "main", "kind": 12, + "range": {"start": {"line": 4, "character": 0}}}] + assert document_symbols(result) == [ + {"name": "main", "kind": 12, "line": 5, "depth": 0}] + + def test_children_are_flattened_with_their_depth(self): + result = [{ + "name": "Thing", "kind": 5, + "selectionRange": {"start": {"line": 1, "character": 0}}, + "children": [{"name": "method", "kind": 6, + "selectionRange": {"start": {"line": 2, "character": 4}}}], + }] + assert [(item["name"], item["depth"]) for item in document_symbols(result)] == \ + [("Thing", 0), ("method", 1)] + + def test_the_older_format_with_a_location_is_read(self): + result = [{"name": "helper", "kind": 12, + "location": {"uri": "file:///a.ts", + "range": {"start": {"line": 7, "character": 0}}}}] + assert document_symbols(result)[0]["line"] == 8 + + def test_an_entry_without_a_name_is_skipped(self): + assert document_symbols([{"kind": 12}]) == [] + + def test_a_null_result_gives_nothing(self): + assert document_symbols(None) == [] + + +@pytest.fixture() +def client(qapp): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + from je_editor.pyside_ui.code.lsp.lsp_session import LspSession + + class _FakeSession(LspSession): + def __init__(self): + super().__init__(["fake"], "/project") + self.sent: list = [] + + @property + def running(self) -> bool: + return True + + def send(self, payload: dict) -> bool: + self.sent.append(payload) + return True + + def send_request(self, requester, payload: dict) -> bool: + return self.send(payload) + + instance = LspClient() + instance._session = _FakeSession() + instance._file_path = "/project/a.ts" + return instance + + +class TestTheClientAsksForThem: + def test_signature_help_is_requested(self, client): + assert client.request_signature_help(1, 2) is True + assert client._session.sent[-1]["method"] == "textDocument/signatureHelp" + + def test_references_ask_to_include_the_declaration(self, client): + client.request_references(1, 2) + assert client._session.sent[-1]["params"]["context"]["includeDeclaration"] is True + + def test_code_actions_carry_the_diagnostics(self, client): + client.request_code_actions(1, 2, [{"message": "unused"}]) + context = client._session.sent[-1]["params"]["context"] + assert context["diagnostics"] == [{"message": "unused"}] + + def test_document_symbols_are_requested(self, client): + assert client.request_document_symbols() is True + assert client._session.sent[-1]["method"] == "textDocument/documentSymbol" + + def test_nothing_is_requested_without_a_file(self, qapp): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + assert LspClient().request_document_symbols() is False + + +class TestTheClientReportsTheReplies: + def test_a_signature_reaches_the_editor(self, client): + client.request_signature_help(0, 0) + received: list = [] + client.signature_ready.connect(received.append) + client.handle_message({ + "id": client._pending_signature_id, + "result": {"signatures": [{"label": "f()"}]}, + }) + assert received == ["f()"] + + def test_references_reach_the_editor(self, client): + client.request_references(0, 0) + received: list = [] + client.references_ready.connect(received.append) + client.handle_message({ + "id": client._pending_references_id, + "result": [{"uri": "file:///a.ts", "range": {"start": {"line": 2}}}], + }) + assert received[0][0]["line"] == 3 + + def test_a_stale_reply_is_ignored(self, client): + client.request_references(0, 0) + received: list = [] + client.references_ready.connect(received.append) + client.handle_message({"id": 999, "result": [ + {"uri": "file:///a.ts", "range": {"start": {"line": 2}}}]}) + assert received == [] + + def test_symbols_reach_the_editor(self, client): + client.request_document_symbols() + received: list = [] + client.symbols_ready.connect(received.append) + client.handle_message({ + "id": client._pending_symbol_id, + "result": [{"name": "main", "range": {"start": {"line": 0}}}], + }) + assert received[0][0]["name"] == "main" + + +@pytest.fixture() +def editor(qapp, qtbot): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + qtbot.addWidget(code_editor) + return code_editor + + +class TestTheEditorUsesThem: + def test_a_signature_is_shown(self, editor): + editor.show_signature_text("join(sep: str)") + assert editor.toolTip() == "join(sep: str)" + + def test_no_references_means_nothing_to_show(self, editor): + assert editor.show_references([]) is False + + def test_no_fixes_means_nothing_to_show(self, editor): + assert editor.show_quick_fixes([]) is False + + def test_the_diagnostics_for_a_line_are_kept_as_the_server_sent_them(self, editor): + # A fix request has to hand the server its own objects back: the + # editor's converted form does not even count lines the same way. + first = {"range": {"start": {"line": 2, "character": 0}}, "message": "unused"} + second = {"range": {"start": {"line": 5, "character": 0}}, "message": "other"} + editor.lsp_client.handle_message({ + "method": "textDocument/publishDiagnostics", + "params": {"uri": "file:///a.ts", "diagnostics": [first, second]}, + }) + assert editor.lsp_client.diagnostics_on_line(2) == [first] + + def test_a_line_without_diagnostics_has_none(self, editor): + editor.lsp_client.handle_message({ + "method": "textDocument/publishDiagnostics", + "params": {"uri": "file:///a.ts", "diagnostics": []}, + }) + assert editor.lsp_client.diagnostics_on_line(2) == [] From b55e7e7ce1068176c09e450111c8209a78d16bf2 Mon Sep 17 00:00:00 2001 From: JeffreyChen <zenxcvwait@gmail.com> Date: Sun, 26 Jul 2026 15:49:19 +0800 Subject: [PATCH 15/16] Undo a staging mistake, and handle stashes and conflicts Hunks could be staged but never unstaged, so putting the wrong one in left no way back except the command line, and nothing committed the index -- which is the whole point of staging hunk by hunk. Unstaging returns the index to the committed content and leaves the working tree alone; a file that was never committed is dropped from the index instead. Committing commits the index, so the unstaged edits stay where they are. Both sit in the right-click menu beside staging. Stash save, list and pop, and listing and resolving conflicts, round out the git service. Resolving refuses a file that is not in conflict: git accepts "checkout --ours" there quite happily, but all it does is quietly stage a file nobody asked to stage. --- je_editor/git_client/file_staging.py | 82 +++++++++++ je_editor/git_client/git_action.py | 96 +++++++++++++ .../code_edit_plaintext.py | 44 +++++- je_editor/utils/multi_language/english.py | 4 + .../multi_language/traditional_chinese.py | 4 + test/test_git_stash_conflicts.py | 131 ++++++++++++++++++ test/test_hunk_staging.py | 67 ++++++++- 7 files changed, 426 insertions(+), 2 deletions(-) create mode 100644 test/test_git_stash_conflicts.py diff --git a/je_editor/git_client/file_staging.py b/je_editor/git_client/file_staging.py index 76aeb454..500240de 100644 --- a/je_editor/git_client/file_staging.py +++ b/je_editor/git_client/file_staging.py @@ -94,3 +94,85 @@ def stage_content(file_path: str | Path, content: str) -> bool: jeditor_logger.error(f"file_staging: could not stage {file_path}: {error!r}") return False return True + + +def unstage_file(file_path: str | Path) -> bool: + """ + 把一個檔案的暫存內容還原成 HEAD 的版本 + Put a file's staged content back to the way HEAD has it. + + 這是「取消暫存」:索引回到已提交的內容,工作區的檔案完全不動,因此使用者正在 + 編輯的東西不會受影響。暫存了不該暫存的一段之後,這是唯一能收回的方法。 + This is unstaging: the index goes back to the committed content while the file + on disk is left exactly as it is, so nothing the user is editing is touched. + After staging a hunk that should not have been staged, this is the only way + back. + + 尚未提交過的新檔案沒有 HEAD 版本可回去,改為從索引移除。 + A new file has no committed version to return to, so it is removed from the + index instead. + + :param file_path: 檔案路徑 / the file to unstage + :return: 索引有變時為 ``True`` / ``True`` when the index changed + """ + repo = open_repository(file_path) + if repo is None: + return False + with repo: + relative = _relative_to(repo, file_path) + if relative is None: + return False + try: + blob = repo.head.commit.tree / relative + except (KeyError, ValueError, TypeError, AttributeError, GitError): + return _remove_from_index(repo, relative, file_path) + try: + repo.index.add([IndexEntry.from_blob( + Blob(repo, blob.binsha, _FILE_MODE, relative))]) + repo.index.write() + except (ValueError, TypeError, AttributeError, GitError, OSError) as error: + jeditor_logger.error(f"file_staging: could not unstage {file_path}: {error!r}") + return False + return True + + +def commit_index(file_path: str | Path, message: str) -> bool: + """ + 把索引目前的內容提交出去 + Commit whatever the index currently holds. + + 提交的是索引而不是磁碟上的檔案,因此逐段暫存之後可以只提交挑好的那幾段,還沒 + 暫存的修改留在工作區。 + What is committed is the index rather than the files on disk, so after staging + hunk by hunk only the chosen parts go in and the rest stays in the working + tree. + + :param file_path: 儲存庫中的任一個檔案 / any file in the repository + :param message: 提交訊息 / the commit message + :return: 有提交時為 ``True`` / ``True`` when a commit was made + """ + if not message.strip(): + return False + repo = open_repository(file_path) + if repo is None: + return False + with repo: + try: + repo.index.commit(message.strip()) + except (ValueError, TypeError, AttributeError, GitError, OSError) as error: + jeditor_logger.error(f"file_staging: could not commit: {error!r}") + return False + return True + + +def _remove_from_index(repo, relative: str, file_path: str | Path) -> bool: + """把一個還沒提交過的檔案從索引移除 / Drop a never-committed file from the index.""" + try: + if (relative, 0) not in repo.index.entries: + return False + repo.index.remove([relative]) + repo.index.write() + except (KeyError, ValueError, TypeError, AttributeError, GitError, OSError) as error: + jeditor_logger.error(f"file_staging: could not unstage {file_path}: {error!r}") + return False + return True diff --git a/je_editor/git_client/git_action.py b/je_editor/git_client/git_action.py index 4c85788c..5d897e55 100644 --- a/je_editor/git_client/git_action.py +++ b/je_editor/git_client/git_action.py @@ -148,6 +148,102 @@ def remotes(self) -> list[str]: self._ensure_repo() return [r.name for r in self.repo.remotes] + def stash_save(self, message: str = "") -> str: + """ + 把目前的修改收進 stash + Put the current changes away in a stash. + + 要暫時放下手邊的東西去改別的地方時用這個;內容留在 stash 裡,之後可以取回。 + This is for putting work down to deal with something else; the changes + stay in the stash and can be taken back afterwards. + + :param message: 這次 stash 的說明 / a note describing the stash + :return: git 的輸出 / what git printed + """ + self._ensure_repo() + arguments = ["push"] + (["-m", message] if message.strip() else []) + try: + result = self.repo.git.stash(*arguments) + audit_log(self.repo_path, "stash_save", message, True) + return result + except GitCommandError as error: + audit_log(self.repo_path, "stash_save", message, False, str(error)) + raise + + def stash_list(self) -> list[str]: + """ + 列出目前收著的 stash + The stashes currently put away. + + :return: 每個 stash 的說明 / a description of each + """ + self._ensure_repo() + output = self.repo.git.stash("list") + return [line for line in output.splitlines() if line.strip()] + + def stash_pop(self, index: int = 0) -> str: + """ + 取回一個 stash,並把它從清單移除 + Take a stash back, removing it from the list. + + :param index: 要取回的 stash 編號 / which stash to take back + :return: git 的輸出 / what git printed + """ + self._ensure_repo() + reference = f"stash@{{{max(0, index)}}}" + try: + result = self.repo.git.stash("pop", reference) + audit_log(self.repo_path, "stash_pop", reference, True) + return result + except GitCommandError as error: + audit_log(self.repo_path, "stash_pop", reference, False, str(error)) + raise + + def conflicted_files(self) -> list[str]: + """ + 列出目前處於衝突狀態的檔案 + The files currently left in conflict. + + 合併或 pull 之後,這些檔案裡有需要人來決定的部分。 + After a merge or a pull, these hold the parts someone has to decide about. + + :return: 相對於儲存庫根目錄的路徑 / paths relative to the repository root + """ + self._ensure_repo() + # 索引裡同一個路徑有多個 stage 就代表衝突:2 是我們的版本,3 是對方的 + # A path with more than one stage in the index is in conflict: 2 is ours + # and 3 is theirs + return sorted({ + path for path, stage in self.repo.index.entries.keys() if stage != 0 + }) + + def resolve_conflict(self, file_path: str, keep: str = "ours") -> bool: + """ + 以某一邊的內容解決一個檔案的衝突 + Resolve a file's conflict by keeping one side of it. + + :param file_path: 相對於儲存庫根目錄的路徑 / the path, relative to the root + :param keep: ``ours`` 保留自己這邊,``theirs`` 保留對方 / which side to keep + :return: 有解決時為 ``True`` / ``True`` when it was resolved + """ + self._ensure_repo() + if keep not in ("ours", "theirs"): + return False + # 沒有衝突的檔案也吃得下 ``checkout --ours``,但那只會把它默默加進索引—— + # 呼叫端要的是解決衝突,不是暫存一個沒事的檔案 + # ``checkout --ours`` is accepted for a file with no conflict too, but all + # that does is quietly stage it, which is not what resolving asks for + if file_path not in self.conflicted_files(): + return False + try: + self.repo.git.checkout(f"--{keep}", "--", file_path) + self.repo.git.add(file_path) + audit_log(self.repo_path, "resolve_conflict", f"{file_path} {keep}", True) + except GitCommandError as error: + audit_log(self.repo_path, "resolve_conflict", file_path, False, str(error)) + return False + return True + def _ensure_repo(self) -> None: if self.repo is None: raise RuntimeError("Repository not opened.") diff --git a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py index 659644aa..fc1a7e62 100644 --- a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +++ b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py @@ -34,7 +34,9 @@ from je_editor.utils.indentation.indent_guides import ( guide_columns, trailing_whitespace_start ) -from je_editor.git_client.file_staging import stage_content, staged_text +from je_editor.git_client.file_staging import ( + commit_index, stage_content, staged_text, unstage_file +) from je_editor.utils.debugger.pdb_commands import ( breakpoint_commands, step_command, toggle_command ) @@ -883,6 +885,42 @@ def stage_change_at_cursor(self) -> bool: staged = apply_hunk(baseline, self.toPlainText(), hunk) return stage_content(str(self.current_file), staged) + def unstage_current_file(self) -> bool: + """ + 取消這個檔案的暫存內容 + Unstage this file. + + 索引回到已提交的版本,編輯中的內容完全不動——暫存錯一段之後只能這樣收回。 + The index returns to the committed version and what is being edited is + untouched, which is the only way back from staging the wrong hunk. + + :return: 索引有變時為 ``True`` / ``True`` when the index changed + """ + if self.current_file is None: + return False + return unstage_file(str(self.current_file)) + + def commit_current_file(self) -> bool: + """ + 把已暫存的內容提交出去 + Commit what is currently staged. + + 提交的是索引裡的內容,不是編輯器裡的:逐段暫存之後,這樣才能只提交挑好的 + 那幾段。 + What is committed is the index, not the buffer: after staging hunk by + hunk, that is what makes committing only the chosen parts possible. + + :return: 有提交時為 ``True`` / ``True`` when a commit was made + """ + if self.current_file is None: + return False + message, confirmed = QInputDialog.getText( + self, language_wrapper.language_word_dict.get("git_commit_title"), + language_wrapper.language_word_dict.get("git_commit_prompt")) + if not confirmed or not message.strip(): + return False + return commit_index(str(self.current_file), message) + def staged_diff_text(self) -> str: """ 取得索引版本與編輯中內容的差異 @@ -2836,6 +2874,10 @@ def build_context_menu(self) -> QMenu: self.lsp_client.running), ("context_menu_stage_hunk", self.stage_change_at_cursor, self.diff_marker_manager.has_baseline), + ("context_menu_unstage_file", self.unstage_current_file, + self.current_file is not None), + ("context_menu_commit_file", self.commit_current_file, + self.current_file is not None), ("context_menu_revert_hunk", self.revert_change_at_cursor, self.diff_marker_manager.has_baseline), ): diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index b4a93e1e..1d3fdbd6 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -426,6 +426,10 @@ "lsp_quick_fix_prompt": "Apply:", "context_menu_find_references": "Find References", "context_menu_quick_fix": "Quick Fix...", + "context_menu_unstage_file": "Unstage File", + "context_menu_commit_file": "Commit Staged...", + "git_commit_title": "Commit", + "git_commit_prompt": "Message:", "test_panel_traceback_placeholder": "Select a failing test to see why it failed", "test_panel_ready": "Ready", "test_panel_running": "Running tests...", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index 732de576..d07c7c4e 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -416,6 +416,10 @@ "lsp_quick_fix_prompt": "套用:", "context_menu_find_references": "尋找參照", "context_menu_quick_fix": "快速修正…", + "context_menu_unstage_file": "取消暫存", + "context_menu_commit_file": "提交已暫存的內容…", + "git_commit_title": "提交", + "git_commit_prompt": "訊息:", "test_panel_traceback_placeholder": "選擇一個失敗的測試以查看原因", "test_panel_ready": "就緒", "test_panel_running": "測試執行中...", diff --git a/test/test_git_stash_conflicts.py b/test/test_git_stash_conflicts.py new file mode 100644 index 00000000..c0dd40fd --- /dev/null +++ b/test/test_git_stash_conflicts.py @@ -0,0 +1,131 @@ +"""Tests for stashing, listing conflicts and resolving them.""" +from __future__ import annotations + +import pytest + +from je_editor.git_client.git_action import GitService + +git = pytest.importorskip("git") + + +@pytest.fixture() +def service(tmp_path): + """A throw-away repository with one committed file, opened by the service.""" + repository = git.Repo.init(tmp_path, initial_branch="main") + with repository.config_writer() as config: + config.set_value("user", "name", "Git Tester") + config.set_value("user", "email", "git@example.com") + tracked = tmp_path / "tracked.txt" + tracked.write_text("first\n", encoding="utf-8") + repository.index.add(["tracked.txt"]) + repository.index.commit("initial") + repository.close() + instance = GitService() + instance.open_repo(str(tmp_path)) + yield instance, tmp_path + instance.repo.close() + + +class TestStashing: + def test_a_change_can_be_put_away(self, service): + instance, root = service + (root / "tracked.txt").write_text("changed\n", encoding="utf-8") + instance.stash_save("wip") + assert (root / "tracked.txt").read_text(encoding="utf-8") == "first\n" + + def test_the_stash_is_listed(self, service): + instance, root = service + (root / "tracked.txt").write_text("changed\n", encoding="utf-8") + instance.stash_save("wip") + assert any("wip" in line for line in instance.stash_list()) + + def test_nothing_is_stashed_when_nothing_changed(self, service): + instance, _root = service + instance.stash_save("empty") + assert instance.stash_list() == [] + + def test_taking_it_back_restores_the_change(self, service): + instance, root = service + (root / "tracked.txt").write_text("changed\n", encoding="utf-8") + instance.stash_save("wip") + instance.stash_pop() + assert (root / "tracked.txt").read_text(encoding="utf-8") == "changed\n" + + def test_taking_it_back_removes_it_from_the_list(self, service): + instance, root = service + (root / "tracked.txt").write_text("changed\n", encoding="utf-8") + instance.stash_save("wip") + instance.stash_pop() + assert instance.stash_list() == [] + + def test_a_stash_without_a_message_still_works(self, service): + instance, root = service + (root / "tracked.txt").write_text("changed\n", encoding="utf-8") + instance.stash_save() + assert len(instance.stash_list()) == 1 + + +def _make_conflict(instance, root) -> None: + """Merge two branches that changed the same line, leaving a conflict.""" + repository = instance.repo + repository.git.checkout("-b", "other") + (root / "tracked.txt").write_text("theirs\n", encoding="utf-8") + repository.index.add(["tracked.txt"]) + repository.index.commit("theirs") + repository.git.checkout("main") + (root / "tracked.txt").write_text("ours\n", encoding="utf-8") + repository.index.add(["tracked.txt"]) + repository.index.commit("ours") + try: + repository.git.merge("other") + except git.GitCommandError: + # The merge is meant to fail; the conflict is the point. + pass + + +class TestConflicts: + def test_a_clean_repository_has_none(self, service): + instance, _root = service + assert instance.conflicted_files() == [] + + def test_a_conflicted_file_is_listed(self, service): + instance, root = service + _make_conflict(instance, root) + assert instance.conflicted_files() == ["tracked.txt"] + + def test_keeping_our_side_resolves_it(self, service): + instance, root = service + _make_conflict(instance, root) + assert instance.resolve_conflict("tracked.txt", "ours") is True + assert (root / "tracked.txt").read_text(encoding="utf-8") == "ours\n" + + def test_keeping_their_side_resolves_it(self, service): + instance, root = service + _make_conflict(instance, root) + assert instance.resolve_conflict("tracked.txt", "theirs") is True + assert (root / "tracked.txt").read_text(encoding="utf-8") == "theirs\n" + + def test_resolving_clears_it_from_the_list(self, service): + instance, root = service + _make_conflict(instance, root) + instance.resolve_conflict("tracked.txt", "ours") + assert instance.conflicted_files() == [] + + def test_an_unknown_side_is_refused(self, service): + instance, root = service + _make_conflict(instance, root) + assert instance.resolve_conflict("tracked.txt", "either") is False + + def test_resolving_a_file_that_is_not_in_conflict_reports_failure(self, service): + instance, _root = service + assert instance.resolve_conflict("tracked.txt", "ours") is False + + +class TestWithoutARepository: + def test_stashing_needs_an_open_repository(self): + with pytest.raises(RuntimeError): + GitService().stash_save("wip") + + def test_listing_conflicts_needs_an_open_repository(self): + with pytest.raises(RuntimeError): + GitService().conflicted_files() diff --git a/test/test_hunk_staging.py b/test/test_hunk_staging.py index b0e0cefb..529173d4 100644 --- a/test/test_hunk_staging.py +++ b/test/test_hunk_staging.py @@ -6,7 +6,9 @@ import pytest from PySide6.QtWidgets import QApplication -from je_editor.git_client.file_staging import stage_content, staged_text +from je_editor.git_client.file_staging import ( + commit_index, stage_content, staged_text, unstage_file +) from je_editor.utils.file_diff.line_status import apply_hunk, hunk_at_line git = pytest.importorskip("git") @@ -91,6 +93,69 @@ def test_an_untracked_file_has_no_staged_text(self, repo): assert staged_text(new_file) is None +class TestUnstaging: + """The only way back from staging a hunk that should not have been staged.""" + + def test_the_index_returns_to_the_committed_content(self, repo): + tracked = repo / "tracked.py" + stage_content(tracked, "a\nSTAGED\nc\n") + assert unstage_file(tracked) is True + assert staged_text(tracked) == "a\nb\nc\n" + + def test_the_working_tree_is_left_alone(self, repo): + tracked = repo / "tracked.py" + tracked.write_text("a\nWORKING\nc\n", encoding="utf-8") + stage_content(tracked, "a\nSTAGED\nc\n") + unstage_file(tracked) + assert tracked.read_text(encoding="utf-8") == "a\nWORKING\nc\n" + + def test_a_never_committed_file_leaves_the_index(self, repo): + new_file = repo / "fresh.py" + new_file.write_text("x\n", encoding="utf-8") + stage_content(new_file, "x\n") + assert unstage_file(new_file) is True + assert staged_text(new_file) is None + + def test_a_file_that_was_never_staged_changes_nothing(self, repo): + new_file = repo / "never.py" + new_file.write_text("x\n", encoding="utf-8") + assert unstage_file(new_file) is False + + def test_a_file_outside_a_repository_cannot_be_unstaged(self, tmp_path): + loose = tmp_path / "loose.py" + loose.write_text("x\n", encoding="utf-8") + assert unstage_file(loose) is False + + +class TestCommittingTheIndex: + """Staging hunk by hunk is only useful if the index is what gets committed.""" + + def test_only_the_staged_content_is_committed(self, repo): + tracked = repo / "tracked.py" + tracked.write_text("a\nWORKING\nc\n", encoding="utf-8") + stage_content(tracked, "a\nSTAGED\nc\n") + assert commit_index(tracked, "stage only") is True + repository = git.Repo(repo) + committed = (repository.head.commit.tree / "tracked.py").data_stream.read() + repository.close() + assert committed.decode("utf-8") == "a\nSTAGED\nc\n" + + def test_the_working_tree_keeps_the_unstaged_edit(self, repo): + tracked = repo / "tracked.py" + tracked.write_text("a\nWORKING\nc\n", encoding="utf-8") + stage_content(tracked, "a\nSTAGED\nc\n") + commit_index(tracked, "stage only") + assert tracked.read_text(encoding="utf-8") == "a\nWORKING\nc\n" + + def test_an_empty_message_is_refused(self, repo): + assert commit_index(repo / "tracked.py", " ") is False + + def test_a_file_outside_a_repository_cannot_be_committed(self, tmp_path): + loose = tmp_path / "loose.py" + loose.write_text("x\n", encoding="utf-8") + assert commit_index(loose, "message") is False + + @pytest.fixture(scope="module") def app(): return QApplication.instance() or QApplication([]) From 738822c4332c45a3884bba7b2fdbb7aac69a2521 Mon Sep 17 00:00:00 2001 From: JeffreyChen <zenxcvwait@gmail.com> Date: Sun, 26 Jul 2026 16:31:02 +0800 Subject: [PATCH 16/16] Resolve the analysis findings on the new code - The traceback parser matched banners with a regex whose lazy middle sits between two repeated fences, which backtracks exponentially on a line of nothing but fence characters -- and pytest prints plenty of those. It scans instead. - Saving a shortcut called a reload method that was never written, so the change only reached tabs opened afterwards and never the menus or toolbar at all. Actions are bound through one place now and all of them follow a save. - handle_message grew past the complexity limit; each reply is the same shape, so one table dispatches them. - Resolving a conflict, unstaging and the exception tests each had a smaller fix: an unnecessary list(), an assertion comparing a call with itself, one with its arguments the wrong way round, and two raises blocks holding two calls apiece. --- je_editor/pyside_ui/code/lsp/lsp_client.py | 92 +++++++++---------- je_editor/pyside_ui/code/lsp/lsp_session.py | 4 +- .../code_edit_plaintext.py | 4 +- .../shortcut_settings_dialog.py | 7 +- .../build_check_style_menu.py | 9 +- .../main_ui/menu/file_menu/build_file_menu.py | 12 +-- .../menu/python_env_menu/build_venv_menu.py | 8 +- .../main_ui/menu/tab_menu/build_tab_menu.py | 6 +- .../main_ui/menu/text_menu/build_text_menu.py | 4 +- .../main_ui/save_settings/shortcut_setting.py | 51 ++++++++++ .../main_ui/toolbar/toolbar_builder.py | 16 ++-- je_editor/utils/multi_language/english.py | 2 +- .../multi_language/traditional_chinese.py | 2 +- je_editor/utils/test_runner/pytest_output.py | 62 ++++++++++--- test/test_git_stash_conflicts.py | 6 +- test/test_lsp_session.py | 3 +- test/test_pytest_panel.py | 36 ++++++++ test/test_shortcut_settings.py | 12 +++ test/test_status_text.py | 2 +- test/test_theme_colors.py | 4 +- 20 files changed, 238 insertions(+), 104 deletions(-) diff --git a/je_editor/pyside_ui/code/lsp/lsp_client.py b/je_editor/pyside_ui/code/lsp/lsp_client.py index 907abe1f..fc29407b 100644 --- a/je_editor/pyside_ui/code/lsp/lsp_client.py +++ b/je_editor/pyside_ui/code/lsp/lsp_client.py @@ -335,62 +335,41 @@ def handle_message(self, message: dict) -> None: :param message: 已解析的訊息 / the parsed message """ if message.get("method") == "textDocument/publishDiagnostics": - params = message.get("params") - # 原樣留一份:要求修正時得把伺服器自己給的診斷送回去,而不是編輯器 - # 轉換過的版本(行號起算方式都不同) - # Keep them as they came: asking for a fix means handing the server - # back its own diagnostics, not the editor's converted form, which - # does not even count lines the same way - raw = (params or {}).get("diagnostics") - self._raw_diagnostics = list(raw) if isinstance(raw, list) else [] - self.diagnostics_ready.emit(diagnostic_entries(params)) + self._handle_diagnostics(message.get("params")) return - if "result" not in message: - return - if message.get("id") == self._pending_completion_id: - self._pending_completion_id = None - self.completions_ready.emit(completion_labels(message.get("result"))) - return - if message.get("id") == self._pending_definition_id: - self._pending_definition_id = None - location = definition_location(message.get("result")) - if location is not None: - self.definition_ready.emit(location) - return - if message.get("id") == self._pending_hover_id: - self._pending_hover_id = None - text = hover_text(message.get("result")) - if text: - self.hover_ready.emit(text) - return - if message.get("id") == self._pending_edit_id: - self._pending_edit_id = None - uri = file_uri(self._file_path) if self._file_path else "" - edits = text_edits(message.get("result"), uri) - if edits: - self.edits_ready.emit(edits) - return - self._handle_reply(message) + if "result" in message: + self._handle_reply(message) + + def _handle_diagnostics(self, params: object) -> None: + """ + 處理伺服器主動送來的診斷 + Handle the diagnostics the server pushes. + + 原樣留一份:要求修正時得把伺服器自己給的診斷送回去,而不是編輯器轉換過的 + 版本——連行號起算方式都不同。 + Keep them as they came: asking for a fix means handing the server back its + own diagnostics, not the editor's converted form, which does not even + count lines the same way. + + :param params: 通知的參數 / the notification's params + """ + raw = (params or {}).get("diagnostics") if isinstance(params, dict) else None + self._raw_diagnostics = list(raw) if isinstance(raw, list) else [] + self.diagnostics_ready.emit(diagnostic_entries(params)) def _handle_reply(self, message: dict) -> None: """ - 處理其餘幾種回覆 - Handle the remaining kinds of reply. + 把回覆交給發問的那一個請求 + Give a reply back to the request that asked for it. - 每一種都是「編號對得上就解析結果並發出訊號」,因此用一張表處理,不必為每 - 一種各寫一段幾乎相同的判斷。 - Each is the same shape — a matching id means parse the result and emit — - so one table handles them rather than a near-identical branch for each. + 每一種回覆都是同一個形狀——編號對得上就解析結果並發出訊號——因此用一張表 + 處理,不必為每一種各寫一段幾乎相同的判斷。 + Every reply has the same shape, a matching id means parse the result and + emit, so one table handles them rather than a near-identical branch each. :param message: 已解析的訊息 / the parsed message """ - replies = ( - ("_pending_signature_id", signature_text, self.signature_ready), - ("_pending_references_id", reference_locations, self.references_ready), - ("_pending_action_id", code_action_titles, self.code_actions_ready), - ("_pending_symbol_id", document_symbols, self.symbols_ready), - ) - for attribute, parse, signal in replies: + for attribute, parse, signal in self._reply_handlers(): if message.get("id") != getattr(self, attribute): continue setattr(self, attribute, None) @@ -399,6 +378,23 @@ def _handle_reply(self, message: dict) -> None: signal.emit(parsed) return + def _reply_handlers(self) -> tuple: + """每種回覆的解析方式與去處 / How each kind of reply is parsed, and where it goes.""" + return ( + ("_pending_completion_id", completion_labels, self.completions_ready), + ("_pending_definition_id", definition_location, self.definition_ready), + ("_pending_hover_id", hover_text, self.hover_ready), + ("_pending_edit_id", self._edits_of, self.edits_ready), + ("_pending_signature_id", signature_text, self.signature_ready), + ("_pending_references_id", reference_locations, self.references_ready), + ("_pending_action_id", code_action_titles, self.code_actions_ready), + ("_pending_symbol_id", document_symbols, self.symbols_ready), + ) + + def _edits_of(self, result: object) -> list: + """取出要套用到這個檔案的編輯 / The edits to apply to this file.""" + return text_edits(result, file_uri(self._file_path) if self._file_path else "") + def stop(self) -> None: """ 放掉這個檔案的連線 diff --git a/je_editor/pyside_ui/code/lsp/lsp_session.py b/je_editor/pyside_ui/code/lsp/lsp_session.py index 2c800744..832362b8 100644 --- a/je_editor/pyside_ui/code/lsp/lsp_session.py +++ b/je_editor/pyside_ui/code/lsp/lsp_session.py @@ -233,9 +233,9 @@ def sessions(self) -> List[LspSession]: def shutdown_all(self) -> None: """關掉每一個連線 / Shut every connection down.""" - for session in list(self._sessions.values()): + sessions, self._sessions = self._sessions, {} + for session in sessions.values(): session.shutdown() - self._sessions = {} # 整個應用程式共用的連線表 / The connections the whole application shares diff --git a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py index fc1a7e62..f2372804 100644 --- a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +++ b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py @@ -45,7 +45,7 @@ from je_editor.utils.lint.ruff_diagnostics import diagnostics_from_entries from je_editor.utils.macro.keystroke_macro import KeystrokeMacro from je_editor.utils.selection.surround import SURROUND_PAIRS, surround -from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import shortcut_for +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind, shortcut_for from je_editor.utils.shortcuts.shortcut_registry import WINDOW_SHORTCUTS, ShortcutRegistry from je_editor.utils.line_ops.line_operations import ( join_lines, natural_sort, remove_blank_lines, reverse_lines, sort_lines, unique_lines @@ -659,7 +659,7 @@ def _add_shortcut_action(self, command: str, handler) -> QAction: sequence = shortcut_for(command) action = QAction(self) action.setObjectName(command) - action.setShortcut(sequence) + bind(action, command) action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) action.triggered.connect(handler) owner = self.shortcut_registry.register(sequence, command) diff --git a/je_editor/pyside_ui/dialog/shortcut_dialog/shortcut_settings_dialog.py b/je_editor/pyside_ui/dialog/shortcut_dialog/shortcut_settings_dialog.py index 389806aa..b093a70b 100644 --- a/je_editor/pyside_ui/dialog/shortcut_dialog/shortcut_settings_dialog.py +++ b/je_editor/pyside_ui/dialog/shortcut_dialog/shortcut_settings_dialog.py @@ -19,6 +19,7 @@ QTreeWidgetItem, QVBoxLayout, QWidget ) +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import reload_bound_shortcuts from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper from je_editor.utils.shortcuts.shortcut_registry import ( @@ -171,8 +172,8 @@ def save(self) -> bool: if self.conflicts(): return False user_setting_dict["shortcuts"] = clean_overrides(self.current_shortcuts()) - notify = getattr(self._main_window, "reload_shortcuts", None) - if callable(notify): - notify() + # 立刻套用到已經建好的選單、工具列與分頁 + # Reach the menus, toolbar and tabs that are already built + reload_bound_shortcuts() self.accept() return True diff --git a/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py b/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py index 75d8f539..d1ce07a2 100644 --- a/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING # 僅在型別檢查時使用,避免循環匯入 / Used only for type checking to avoid circular imports from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget # 編輯器分頁元件 / Editor tab widget -from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import shortcut_for +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind from je_editor.utils.logging.loggin_instance import jeditor_logger # 專案內的日誌紀錄器 / Project logger from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper # 多語系支援 / Multi-language wrapper @@ -38,8 +38,7 @@ def set_check_menu(ui_we_want_to_set: EditorMain) -> None: # === 1. Yapf Python 程式碼格式化 / Yapf Python code reformat === ui_we_want_to_set.check_menu.yapf_check_python_action = QAction( language_wrapper.language_word_dict.get("yapf_reformat_label")) - ui_we_want_to_set.check_menu.yapf_check_python_action.setShortcut( - shortcut_for("yapf_reformat")) # 設定快捷鍵 / Set shortcut + bind(ui_we_want_to_set.check_menu.yapf_check_python_action, "yapf_reformat") # 設定快捷鍵 / Set shortcut ui_we_want_to_set.check_menu.yapf_check_python_action.triggered.connect( lambda: yapf_check_python_code(ui_we_want_to_set) ) @@ -48,7 +47,7 @@ def set_check_menu(ui_we_want_to_set: EditorMain) -> None: # === 2. JSON 重新格式化 / Reformat JSON === ui_we_want_to_set.check_menu.reformat_json_action = QAction( language_wrapper.language_word_dict.get("reformat_json_label")) - ui_we_want_to_set.check_menu.reformat_json_action.setShortcut(shortcut_for("reformat_json")) + bind(ui_we_want_to_set.check_menu.reformat_json_action, "reformat_json") ui_we_want_to_set.check_menu.reformat_json_action.triggered.connect( lambda: reformat_json_text(ui_we_want_to_set) ) @@ -57,7 +56,7 @@ def set_check_menu(ui_we_want_to_set: EditorMain) -> None: # === 3. Python 格式檢查 / Python format check === ui_we_want_to_set.check_menu.check_python_format = QAction( language_wrapper.language_word_dict.get("python_format_checker")) - ui_we_want_to_set.check_menu.check_python_format.setShortcut(shortcut_for("check_python_format")) + bind(ui_we_want_to_set.check_menu.check_python_format, "check_python_format") ui_we_want_to_set.check_menu.check_python_format.triggered.connect( lambda: check_python_format(ui_we_want_to_set) ) diff --git a/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py b/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py index add0280f..62ed2f7d 100644 --- a/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py @@ -8,7 +8,7 @@ # 匯入使用者設定字典,用來保存 UI 設定 # Import user settings dictionary for saving UI preferences -from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import shortcut_for +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict # 匯入 Python 編碼清單 (例如 utf-8, gbk 等) # Import list of Python encodings (e.g., utf-8, gbk, etc.) @@ -60,7 +60,7 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: # New File action ui_we_want_to_set.file_menu.new_file_action = QAction( language_wrapper.language_word_dict.get("file_menu_new_file_label")) - ui_we_want_to_set.file_menu.new_file_action.setShortcut(shortcut_for("new_file")) + bind(ui_we_want_to_set.file_menu.new_file_action, "new_file") ui_we_want_to_set.file_menu.new_file_action.triggered.connect( lambda: show_create_file_dialog(ui_we_want_to_set) ) @@ -70,7 +70,7 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: # Open File action ui_we_want_to_set.file_menu.open_file_action = QAction( language_wrapper.language_word_dict.get("file_menu_open_file_label")) - ui_we_want_to_set.file_menu.open_file_action.setShortcut(shortcut_for("open_file")) + bind(ui_we_want_to_set.file_menu.open_file_action, "open_file") ui_we_want_to_set.file_menu.open_file_action.triggered.connect( lambda: choose_file_get_open_file_path(parent_qt_instance=ui_we_want_to_set) ) @@ -80,7 +80,7 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: # Open Folder action ui_we_want_to_set.file_menu.open_folder_action = QAction( language_wrapper.language_word_dict.get("file_menu_open_folder_label")) - ui_we_want_to_set.file_menu.open_folder_action.setShortcut(shortcut_for("open_folder")) + bind(ui_we_want_to_set.file_menu.open_folder_action, "open_folder") ui_we_want_to_set.file_menu.open_folder_action.triggered.connect( lambda: choose_dir_get_dir_path(parent_qt_instance=ui_we_want_to_set) ) @@ -90,7 +90,7 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: # Save File action ui_we_want_to_set.file_menu.save_file_action = QAction( language_wrapper.language_word_dict.get("file_menu_save_file_label")) - ui_we_want_to_set.file_menu.save_file_action.setShortcut(shortcut_for("save_file")) + bind(ui_we_want_to_set.file_menu.save_file_action, "save_file") ui_we_want_to_set.file_menu.save_file_action.triggered.connect( lambda: choose_file_get_save_file_path(parent_qt_instance=ui_we_want_to_set) ) @@ -111,7 +111,7 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: # 儲存所有分頁 / Save every modified tab ui_we_want_to_set.file_menu.save_all_action = QAction( language_wrapper.language_word_dict.get("file_menu_save_all_label")) - ui_we_want_to_set.file_menu.save_all_action.setShortcut(shortcut_for("save_all")) + bind(ui_we_want_to_set.file_menu.save_all_action, "save_all") ui_we_want_to_set.file_menu.save_all_action.triggered.connect( lambda: save_all_tabs(ui_we_want_to_set)) ui_we_want_to_set.file_menu.addAction(ui_we_want_to_set.file_menu.save_all_action) diff --git a/je_editor/pyside_ui/main_ui/menu/python_env_menu/build_venv_menu.py b/je_editor/pyside_ui/main_ui/menu/python_env_menu/build_venv_menu.py index 4ea880de..759e5a81 100644 --- a/je_editor/pyside_ui/main_ui/menu/python_env_menu/build_venv_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/python_env_menu/build_venv_menu.py @@ -17,7 +17,7 @@ from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict # 匯入日誌紀錄器 # Import logger instance -from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import shortcut_for +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind from je_editor.utils.logging.loggin_instance import jeditor_logger # 僅在型別檢查時匯入 EditorMain,避免循環依賴 @@ -53,7 +53,7 @@ def set_venv_menu(ui_we_want_to_set: EditorMain) -> None: # Create virtual environment ui_we_want_to_set.venv_menu.change_language_menu = QAction( language_wrapper.language_word_dict.get("python_env_menu_create_venv_label")) - ui_we_want_to_set.venv_menu.change_language_menu.setShortcut(shortcut_for("change_language")) + bind(ui_we_want_to_set.venv_menu.change_language_menu, "change_language") ui_we_want_to_set.venv_menu.change_language_menu.triggered.connect( lambda: create_venv(ui_we_want_to_set)) ui_we_want_to_set.venv_menu.addAction(ui_we_want_to_set.venv_menu.change_language_menu) @@ -62,7 +62,7 @@ def set_venv_menu(ui_we_want_to_set: EditorMain) -> None: # pip upgrade package ui_we_want_to_set.venv_menu.pip_upgrade_action = QAction( language_wrapper.language_word_dict.get("python_env_menu_pip_upgrade_label")) - ui_we_want_to_set.venv_menu.pip_upgrade_action.setShortcut(shortcut_for("pip_upgrade")) + bind(ui_we_want_to_set.venv_menu.pip_upgrade_action, "pip_upgrade") ui_we_want_to_set.venv_menu.pip_upgrade_action.triggered.connect( lambda: pip_install_package_update(ui_we_want_to_set)) ui_we_want_to_set.venv_menu.addAction(ui_we_want_to_set.venv_menu.pip_upgrade_action) @@ -71,7 +71,7 @@ def set_venv_menu(ui_we_want_to_set: EditorMain) -> None: # pip install package ui_we_want_to_set.venv_menu.pip_action = QAction( language_wrapper.language_word_dict.get("python_env_menu_pip_label")) - ui_we_want_to_set.venv_menu.pip_action.setShortcut(shortcut_for("pip_install")) + bind(ui_we_want_to_set.venv_menu.pip_action, "pip_install") ui_we_want_to_set.venv_menu.pip_action.triggered.connect( lambda: pip_install_package(ui_we_want_to_set)) ui_we_want_to_set.venv_menu.addAction(ui_we_want_to_set.venv_menu.pip_action) diff --git a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py index 79348505..a64627eb 100644 --- a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py @@ -9,7 +9,7 @@ from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget from je_editor.pyside_ui.main_ui.menu.tab_menu.build_tab_git_menu import set_tab_git_menu from je_editor.pyside_ui.main_ui.menu.tab_menu.build_tab_tools_menu import set_tab_tools_menu -from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import shortcut_for +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind from je_editor.utils.logging.loggin_instance import jeditor_logger if TYPE_CHECKING: @@ -64,7 +64,7 @@ def set_tab_menu(ui_we_want_to_set: EditorMain) -> None: # === Split view of the same file === ui_we_want_to_set.tab_menu.toggle_split_view_action = QAction( language_wrapper.language_word_dict.get("tab_menu_split_view_label")) - ui_we_want_to_set.tab_menu.toggle_split_view_action.setShortcut(shortcut_for("toggle_split_view")) + bind(ui_we_want_to_set.tab_menu.toggle_split_view_action, "toggle_split_view") ui_we_want_to_set.tab_menu.toggle_split_view_action.triggered.connect( lambda: toggle_split_view(ui_we_want_to_set) ) @@ -74,7 +74,7 @@ def set_tab_menu(ui_we_want_to_set: EditorMain) -> None: # === Minimap === ui_we_want_to_set.tab_menu.toggle_minimap_action = QAction( language_wrapper.language_word_dict.get("tab_menu_minimap_label")) - ui_we_want_to_set.tab_menu.toggle_minimap_action.setShortcut(shortcut_for("toggle_minimap")) + bind(ui_we_want_to_set.tab_menu.toggle_minimap_action, "toggle_minimap") ui_we_want_to_set.tab_menu.toggle_minimap_action.triggered.connect( lambda: toggle_minimap(ui_we_want_to_set) ) diff --git a/je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py b/je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py index 93130ce7..fe24f8ad 100644 --- a/je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py @@ -7,7 +7,7 @@ from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict -from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import shortcut_for +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind from je_editor.utils.logging.loggin_instance import jeditor_logger from je_editor.utils.text_stats.text_statistics import TextStatistics, text_statistics @@ -82,7 +82,7 @@ def set_text_menu(ui_we_want_to_set: EditorMain) -> None: language_wrapper.language_word_dict.get("text_menu_word_wrap"), ui_we_want_to_set) word_wrap_action.setCheckable(True) word_wrap_action.setChecked(False) - word_wrap_action.setShortcut(shortcut_for("word_wrap")) + bind(word_wrap_action, "word_wrap") word_wrap_action.triggered.connect( lambda checked: toggle_word_wrap(ui_we_want_to_set, checked)) ui_we_want_to_set.text_menu.addAction(word_wrap_action) diff --git a/je_editor/pyside_ui/main_ui/save_settings/shortcut_setting.py b/je_editor/pyside_ui/main_ui/save_settings/shortcut_setting.py index 92d4bcde..b9906daf 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/shortcut_setting.py +++ b/je_editor/pyside_ui/main_ui/save_settings/shortcut_setting.py @@ -10,9 +10,15 @@ """ from __future__ import annotations +from PySide6.QtGui import QAction + from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict from je_editor.utils.shortcuts.shortcut_registry import sequence_for +# 已經綁上按鍵的動作,設定改變時要一起更新 +# The actions that have been given keys, to be updated when the setting changes +_bound: list[tuple[QAction, str]] = [] + def shortcut_for(command: str) -> str: """ @@ -23,3 +29,48 @@ def shortcut_for(command: str) -> str: :return: 按鍵組合,沒有指派時為空字串 / the sequence, or an empty string """ return sequence_for(command, user_setting_dict.get("shortcuts")) + + +def bind(action: QAction, command: str) -> QAction: + """ + 把一個動作綁到某個指令的按鍵上 + Give an action the keys belonging to a command. + + 記下這個動作,之後在設定裡改按鍵時它才會跟著換——選單與工具列都是啟動時建好 + 的,不記下來就只能等下次開啟才生效。 + The action is remembered so a change made in the settings reaches it: the + menus and the toolbar are built once at startup and would otherwise not pick + a new sequence up until the next launch. + + :param action: 要綁定的動作 / the action to bind + :param command: 指令名稱 / the command's name + :return: 同一個動作,方便串接 / the same action, for chaining + """ + action.setShortcut(shortcut_for(command)) + _bound.append((action, command)) + return action + + +def reload_bound_shortcuts() -> int: + """ + 依目前設定重新套用每個動作的按鍵 + Re-apply every bound action's keys from the current setting. + + 已經被銷毀的動作會順手從清單移除;分頁關掉之後它的動作就不在了。 + An action that has already been destroyed is dropped from the list on the + way past, which is what happens to a closed tab's actions. + + :return: 更新了幾個動作 / how many actions were updated + """ + updated = 0 + alive: list[tuple[QAction, str]] = [] + for action, command in _bound: + try: + action.setShortcut(shortcut_for(command)) + except RuntimeError: + # 這個動作已經隨著它的元件被刪掉了 / It went away with its widget + continue + alive.append((action, command)) + updated += 1 + _bound[:] = alive + return updated diff --git a/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py b/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py index d784e224..1f9e8e95 100644 --- a/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py +++ b/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py @@ -8,7 +8,7 @@ QToolBar, QComboBox, QStyle, QLabel, QWidget, QMessageBox ) -from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import shortcut_for +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind from je_editor.utils.logging.loggin_instance import jeditor_logger from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper @@ -62,21 +62,21 @@ def build_toolbar(main_window: EditorMain) -> None: act_run = QAction(_icon(main_window, QStyle.StandardPixmap.SP_MediaPlay), lang("toolbar_run"), main_window) act_run.setToolTip(lang("toolbar_run")) - act_run.setShortcut(shortcut_for("run_program")) + bind(act_run, "run_program") act_run.triggered.connect(lambda: _run_program(main_window)) toolbar.addAction(act_run) act_debug = QAction(_icon(main_window, QStyle.StandardPixmap.SP_MediaSeekForward), lang("toolbar_debug"), main_window) act_debug.setToolTip(lang("toolbar_debug")) - act_debug.setShortcut(shortcut_for("run_debugger")) + bind(act_debug, "run_debugger") act_debug.triggered.connect(lambda: _run_debugger(main_window)) toolbar.addAction(act_debug) act_stop = QAction(_icon(main_window, QStyle.StandardPixmap.SP_MediaStop), lang("toolbar_stop"), main_window) act_stop.setToolTip(lang("toolbar_stop")) - act_stop.setShortcut(shortcut_for("stop_program")) + bind(act_stop, "stop_program") act_stop.triggered.connect(lambda: _stop_program(main_window)) toolbar.addAction(act_stop) @@ -111,7 +111,7 @@ def build_toolbar(main_window: EditorMain) -> None: act_search = QAction(_icon(main_window, QStyle.StandardPixmap.SP_FileDialogContentsView), lang("toolbar_search"), main_window) act_search.setToolTip(lang("toolbar_search")) - act_search.setShortcut(shortcut_for("search_in_files")) + bind(act_search, "search_in_files") act_search.triggered.connect(lambda: _open_search(main_window)) toolbar.addAction(act_search) @@ -121,7 +121,7 @@ def build_toolbar(main_window: EditorMain) -> None: act_palette = QAction(_icon(main_window, QStyle.StandardPixmap.SP_FileDialogDetailedView), lang("toolbar_command_palette"), main_window) act_palette.setToolTip(lang("toolbar_command_palette")) - act_palette.setShortcut(shortcut_for("command_palette")) + bind(act_palette, "command_palette") act_palette.triggered.connect(lambda: _open_command_palette(main_window)) toolbar.addAction(act_palette) main_window.command_palette_action = act_palette @@ -129,7 +129,7 @@ def build_toolbar(main_window: EditorMain) -> None: act_quick_open = QAction(_icon(main_window, QStyle.StandardPixmap.SP_FileDialogListView), lang("toolbar_quick_open"), main_window) act_quick_open.setToolTip(lang("toolbar_quick_open")) - act_quick_open.setShortcut(shortcut_for("quick_open")) + bind(act_quick_open, "quick_open") act_quick_open.triggered.connect(lambda: _open_quick_open(main_window)) toolbar.addAction(act_quick_open) main_window.quick_open_action = act_quick_open @@ -137,7 +137,7 @@ def build_toolbar(main_window: EditorMain) -> None: act_go_to_symbol = QAction(_icon(main_window, QStyle.StandardPixmap.SP_FileDialogInfoView), lang("toolbar_go_to_symbol"), main_window) act_go_to_symbol.setToolTip(lang("toolbar_go_to_symbol")) - act_go_to_symbol.setShortcut(shortcut_for("go_to_symbol")) + bind(act_go_to_symbol, "go_to_symbol") act_go_to_symbol.triggered.connect(lambda: _open_go_to_symbol(main_window)) toolbar.addAction(act_go_to_symbol) main_window.go_to_symbol_action = act_go_to_symbol diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 1d3fdbd6..12947a26 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -417,7 +417,7 @@ "shortcut_settings_col_keys": "Keys", "shortcut_settings_save": "Save", "shortcut_settings_reset": "Restore defaults", - "shortcut_settings_hint": "Changes apply to tabs opened after saving.", + "shortcut_settings_hint": "Changes take effect as soon as they are saved.", "shortcut_settings_conflict": "{keys} is claimed by {commands}", # Language server "lsp_references_title": "References", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index d07c7c4e..4b41fb8a 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -407,7 +407,7 @@ "shortcut_settings_col_keys": "按鍵", "shortcut_settings_save": "儲存", "shortcut_settings_reset": "還原預設值", - "shortcut_settings_hint": "儲存後對之後開啟的分頁生效。", + "shortcut_settings_hint": "儲存後立即生效。", "shortcut_settings_conflict": "{keys} 同時被 {commands} 使用", # 語言伺服器 / Language server "lsp_references_title": "參照", diff --git a/je_editor/utils/test_runner/pytest_output.py b/je_editor/utils/test_runner/pytest_output.py index de937cf5..83a5aa37 100644 --- a/je_editor/utils/test_runner/pytest_output.py +++ b/je_editor/utils/test_runner/pytest_output.py @@ -24,13 +24,15 @@ # Matches the summary line, e.g. ``3 failed, 5 passed in 0.42s`` _SUMMARY_PATTERN = re.compile(r"^=+\s*(?P<summary>.*?(?:passed|failed|error|no tests ran).*?)\s*=+$") -# 比對區段標題:pytest 用等號或連字號把標題包起來,覆蓋率報告與結尾統計都算 -# Matches a section banner: pytest fences its titles in equals signs or dashes, -# which covers the coverage report and the closing summary as well -_SECTION_BANNER = re.compile(r"^(?P<fence>[=-]){2,}\s*(?P<title>.*?)\s*(?P=fence){2,}$") -# 比對追蹤訊息的標題,例如 ``____ TestThing.test_case ____`` -# Matches a traceback heading such as ``____ TestThing.test_case ____`` -_FAILURE_HEADER = re.compile(r"^_{2,}\s*(?P<name>.+?)\s*_{2,}$") +# 區段標題的圍籬字元:pytest 用等號或連字號把標題包起來,覆蓋率報告與結尾統計都算 +# What fences a section banner: pytest wraps its titles in equals signs or +# dashes, which covers the coverage report and the closing summary as well +_BANNER_FENCES = "=-" +# 追蹤訊息標題的圍籬字元,例如 ``____ TestThing.test_case ____`` +# What fences a traceback heading, as in ``____ TestThing.test_case ____`` +_HEADER_FENCE = "_" +# 兩側各至少要有幾個圍籬字元 / How many fence characters each side needs +_MIN_FENCE = 2 # 比對覆蓋率報告的總計行,例如 ``TOTAL 1200 84 93%`` # Matches a coverage report's total, e.g. ``TOTAL 1200 84 93%`` _COVERAGE_TOTAL = re.compile(r"^TOTAL\s+\d+\s+\d+\s+(?P<percent>\d+)%") @@ -38,6 +40,41 @@ # 帶有追蹤訊息的區段 / The sections that carry tracebacks _FAILURE_SECTIONS = frozenset({"FAILURES", "ERRORS"}) + +def fenced_title(line: str, fences: str) -> str | None: + """ + 取出被圍籬字元包住的標題 + Take the title out of a line fenced by repeated characters. + + 掃描而不是用正規表示式:``^(.){2,}(.*?)(\\1){2,}$`` 這種寫法在整行都是圍籬 + 字元時會退化成指數級的回溯,而 pytest 的輸出裡這種行到處都是。 + This scans rather than matching ``^(.){2,}(.*?)(\\1){2,}$``, which backtracks + exponentially on a line made entirely of fence characters — and pytest's + output is full of those. + + :param line: 一行文字 / the line to read + :param fences: 可以當圍籬的字元 / the characters that may fence it + :return: 標題(可能為空字串);不是圍籬行時為 ``None`` + the title, possibly empty, or ``None`` when the line is not fenced + """ + stripped = line.strip() + if not stripped or stripped[0] not in fences: + return None + fence = stripped[0] + start = 0 + while start < len(stripped) and stripped[start] == fence: + start += 1 + if start == len(stripped): + # 整行都是圍籬字元:這是一條分隔線,標題是空的 + # The whole line is fence characters: a plain separator, with no title + return "" if start >= _MIN_FENCE * 2 else None + end = len(stripped) + while end > start and stripped[end - 1] == fence: + end -= 1 + if start < _MIN_FENCE or len(stripped) - end < _MIN_FENCE: + return None + return stripped[start:end].strip() + # 視為失敗的結果 / Outcomes that count as a failure FAILING_OUTCOMES = frozenset({"FAILED", "ERROR"}) @@ -156,17 +193,16 @@ def parse_tracebacks(output: str) -> dict[str, str]: in_failures = False for line in output.splitlines(): stripped = line.rstrip() - banner = _SECTION_BANNER.match(stripped.strip()) + banner = fenced_title(stripped, _BANNER_FENCES) if banner is not None: - title = banner.group("title").strip().upper() - in_failures = title in _FAILURE_SECTIONS + in_failures = banner.upper() in _FAILURE_SECTIONS current = None continue if not in_failures: continue - header = _FAILURE_HEADER.match(stripped.strip()) - if header is not None: - current = header.group("name").strip() + header = fenced_title(stripped, _HEADER_FENCE) + if header: + current = header blocks.setdefault(current, []) continue if current is not None: diff --git a/test/test_git_stash_conflicts.py b/test/test_git_stash_conflicts.py index c0dd40fd..c7bb5094 100644 --- a/test/test_git_stash_conflicts.py +++ b/test/test_git_stash_conflicts.py @@ -123,9 +123,11 @@ def test_resolving_a_file_that_is_not_in_conflict_reports_failure(self, service) class TestWithoutARepository: def test_stashing_needs_an_open_repository(self): + unopened = GitService() with pytest.raises(RuntimeError): - GitService().stash_save("wip") + unopened.stash_save("wip") def test_listing_conflicts_needs_an_open_repository(self): + unopened = GitService() with pytest.raises(RuntimeError): - GitService().conflicted_files() + unopened.conflicted_files() diff --git a/test/test_lsp_session.py b/test/test_lsp_session.py index 624498d8..c618a0bf 100644 --- a/test/test_lsp_session.py +++ b/test/test_lsp_session.py @@ -73,7 +73,8 @@ def test_diagnostics_for_an_unopened_file_are_dropped(self): def test_request_ids_do_not_repeat(self): session = _FakeSession() - assert session.take_id() != session.take_id() + first = session.take_id() + assert session.take_id() != first class TestSharingASession: diff --git a/test/test_pytest_panel.py b/test/test_pytest_panel.py index e3fad883..4afebeec 100644 --- a/test/test_pytest_panel.py +++ b/test/test_pytest_panel.py @@ -57,6 +57,42 @@ """ +class TestFencedTitles: + """ + Banners and headings are found by scanning rather than by a regex: the + pattern for them backtracks exponentially on a line of nothing but fence + characters, and pytest's output is full of those. + """ + + def test_a_banner_title_is_read(self): + from je_editor.utils.test_runner.pytest_output import fenced_title + assert fenced_title("===== FAILURES =====", "=-") == "FAILURES" + + def test_a_dashed_banner_is_read(self): + from je_editor.utils.test_runner.pytest_output import fenced_title + assert fenced_title("--- coverage: win32 ---", "=-") == "coverage: win32" + + def test_a_heading_is_read(self): + from je_editor.utils.test_runner.pytest_output import fenced_title + assert fenced_title("____ TestThing.test_case ____", "_") == "TestThing.test_case" + + def test_a_line_of_only_fence_characters_has_an_empty_title(self): + from je_editor.utils.test_runner.pytest_output import fenced_title + assert fenced_title("=" * 200, "=-") == "" + + def test_an_ordinary_line_is_not_fenced(self): + from je_editor.utils.test_runner.pytest_output import fenced_title + assert fenced_title("assert 1 == 2", "=-") is None + + def test_one_fence_character_is_not_enough(self): + from je_editor.utils.test_runner.pytest_output import fenced_title + assert fenced_title("=title=", "=-") is None + + def test_a_blank_line_is_not_fenced(self): + from je_editor.utils.test_runner.pytest_output import fenced_title + assert fenced_title(" ", "=-") is None + + class TestParseTracebacks: """The panel shows why a test failed, not only that it did.""" diff --git a/test/test_shortcut_settings.py b/test/test_shortcut_settings.py index 2d082e42..6d8ee529 100644 --- a/test/test_shortcut_settings.py +++ b/test/test_shortcut_settings.py @@ -143,6 +143,18 @@ def test_untouched_commands_are_not_stored(self, dialog): dialog.save() assert set(user_setting_dict["shortcuts"]) == {"save_all"} + def test_saving_reaches_the_menus_already_built(self, dialog): + # The menus and the toolbar are built once at startup, so a saved change + # has to be pushed to them rather than waiting for the next launch. + from PySide6.QtGui import QAction, QKeySequence + from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind + action = QAction() + bind(action, "save_all") + dialog._editors["save_all"].setKeySequence(QKeySequence("Ctrl+Alt+W")) + dialog.save() + assert normalise_sequence(action.shortcut().toString()) == \ + normalise_sequence("Ctrl+Alt+W") + def test_restoring_defaults_clears_a_change(self, dialog): from PySide6.QtGui import QKeySequence dialog._editors["save_all"].setKeySequence(QKeySequence("Ctrl+Alt+W")) diff --git a/test/test_status_text.py b/test/test_status_text.py index 8e0b8722..579a60e2 100644 --- a/test/test_status_text.py +++ b/test/test_status_text.py @@ -93,7 +93,7 @@ def _status_window(tab_widget): """A bare main window with just the status-bar labels the refresh touches.""" from PySide6.QtWidgets import QLabel from je_editor.pyside_ui.main_ui.main_editor import EditorMain - window = EditorMain.__new__(EditorMain) + window = EditorMain.__new__(EditorMain) # pylint: disable=no-value-for-parameter window.tab_widget = tab_widget window._language_label = QLabel() window._line_ending_label = QLabel() diff --git a/test/test_theme_colors.py b/test/test_theme_colors.py index c08d1a17..63abc900 100644 --- a/test/test_theme_colors.py +++ b/test/test_theme_colors.py @@ -89,8 +89,8 @@ def test_applying_a_light_style_updates_the_live_colours(self): LIGHT_COLORS["minimap_background_color"] # The QColor dictionary the painting reads has to move with it. colour = actually_color_dict["minimap_background_color"] - assert (colour.red(), colour.green(), colour.blue()) == \ - tuple(LIGHT_COLORS["minimap_background_color"]) + actual = [colour.red(), colour.green(), colour.blue()] + assert actual == LIGHT_COLORS["minimap_background_color"] finally: apply_theme_colors("dark_amber.xml")