From dfc0fa5637c23404436e0dd7d70134d399847665 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 28 Jul 2026 01:46:48 +0800 Subject: [PATCH 1/2] Stop the command palette from invalidating the menus it walks Collecting the palette's entries asked every action for its submenu. On PySide6 that call hands an existing menu over to Python, and once the walk is done the main window's own references -- file_menu, dock_menu and the rest -- all point at reclaimed objects. Opening the palette once was enough to leave seventeen of them unusable, so the next piece of code to touch one raised, and a C++ path following the same pointer could take the process down. The submenus are now looked up among the widgets that already exist, which moves no ownership. The sweep covers menus attached with addMenu(menu), which are not reparented and so are not children of the bar. --- .../command_palette/menu_command_collector.py | 13 +++--- .../pyside_ui/main_ui/menu/submenu_map.py | 42 +++++++++++++++++++ test/test_command_palette.py | 16 +++++++ 3 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 je_editor/pyside_ui/main_ui/menu/submenu_map.py diff --git a/je_editor/pyside_ui/main_ui/command_palette/menu_command_collector.py b/je_editor/pyside_ui/main_ui/command_palette/menu_command_collector.py index bbfd75a6..48324b41 100644 --- a/je_editor/pyside_ui/main_ui/command_palette/menu_command_collector.py +++ b/je_editor/pyside_ui/main_ui/command_palette/menu_command_collector.py @@ -6,6 +6,7 @@ from PySide6.QtWidgets import QMenu, QMenuBar +from je_editor.pyside_ui.main_ui.menu.submenu_map import submenus_of from je_editor.utils.command_palette.fuzzy_matcher import CommandEntry from je_editor.utils.logging.loggin_instance import jeditor_logger @@ -46,19 +47,21 @@ def collect_menu_commands(menu_bar: QMenuBar | None) -> list[CommandEntry]: return [] commands: list[CommandEntry] = [] seen_menus: set[int] = set() + submenus = submenus_of(menu_bar) for action in menu_bar.actions(): - submenu = action.menu() + submenu = submenus.get(action) if submenu is None: _append_action(commands, action, "") continue - _collect_from_menu(submenu, clean_action_text(action.text()), commands, seen_menus, 1) + _collect_from_menu( + submenu, clean_action_text(action.text()), commands, seen_menus, 1, submenus) jeditor_logger.info(f"menu_command_collector.py collect_menu_commands count: {len(commands)}") return commands def _collect_from_menu( menu: QMenu, prefix: str, commands: list[CommandEntry], - seen_menus: set[int], depth: int) -> None: + seen_menus: set[int], depth: int, submenus: dict) -> None: """遞迴蒐集單一選單下的動作 / Recursively collect actions under one menu.""" if depth > MAX_MENU_DEPTH or len(commands) >= MAX_COMMANDS: return @@ -75,11 +78,11 @@ def _collect_from_menu( title = clean_action_text(action.text()) if not title: continue - submenu = action.menu() + submenu = submenus.get(action) if submenu is not None: _collect_from_menu( submenu, f"{prefix}{PATH_SEPARATOR}{title}" if prefix else title, - commands, seen_menus, depth + 1) + commands, seen_menus, depth + 1, submenus) continue _append_action(commands, action, prefix) if len(commands) >= MAX_COMMANDS: diff --git a/je_editor/pyside_ui/main_ui/menu/submenu_map.py b/je_editor/pyside_ui/main_ui/menu/submenu_map.py new file mode 100644 index 00000000..910bbe05 --- /dev/null +++ b/je_editor/pyside_ui/main_ui/menu/submenu_map.py @@ -0,0 +1,42 @@ +""" +找出每個動作底下掛的是哪個子選單 +Work out which submenu each action opens. + +看似該用 ``QAction.menu()``,但在 PySide6 上那個呼叫會把既有的選單交給 Python 管, +走完一輪選單列之後,主視窗留著的 ``file_menu`` 之類的參考就全部指向已回收的物件, +之後任何一次存取都會壞掉——嵌入本視窗的程式也一樣。這裡改用「從既有的元件反查」, +一個物件的歸屬都不會動到。 +``QAction.menu()`` looks like the way to do this, but on PySide6 that call hands +an existing menu over to Python, and after one walk of the menu bar the main +window's own references -- ``file_menu`` and the rest -- all point at reclaimed +objects, so the next access to any of them breaks. The same goes for an +application embedding this window. What follows looks the menus up among the +widgets that already exist instead, and moves no ownership at all. +""" +from __future__ import annotations + +from PySide6.QtWidgets import QApplication, QMenu, QMenuBar + + +def submenus_of(menu_bar: QMenuBar) -> dict: + """ + 建立「動作 → 子選單」對照表 + Build the action-to-submenu table. + + 選單列的子元件涵蓋絕大多數的選單;另外收一輪應用程式裡的其他選單,是為了那些 + 以 ``addMenu(existing_menu)`` 掛上、沒有被收為子元件的選單。表以動作為鍵,而一 + 個動作只會屬於一個選單,因此多收不會誤配。 + The menu bar's children cover almost every menu; the sweep over the rest of + the application's menus is for those attached with ``addMenu(existing_menu)``, + which does not reparent them. The table is keyed by action and an action + belongs to exactly one menu, so the wider sweep cannot mismatch. + + :param menu_bar: 要走訪的選單列 / the menu bar being walked + :return: 動作對應子選單 / the action-to-submenu table + """ + menus = list(menu_bar.findChildren(QMenu)) + application = QApplication.instance() + if application is not None: + menus.extend( + widget for widget in application.allWidgets() if isinstance(widget, QMenu)) + return {menu.menuAction(): menu for menu in menus} diff --git a/test/test_command_palette.py b/test/test_command_palette.py index 43105b97..a3640c2e 100644 --- a/test/test_command_palette.py +++ b/test/test_command_palette.py @@ -156,6 +156,22 @@ def test_collects_nested_actions_with_path(self): assert "File > Open File" in paths assert "File > Recent > project.py" in paths + def test_the_menus_it_walked_are_still_usable_afterwards(self): + # Asking a QAction for its menu hands that menu to Python to reclaim, so + # collecting used to leave the caller's own menu references pointing at + # dead objects -- the window opens its command palette once and then + # cannot touch its File menu again. + menu_bar = QMenuBar() + file_menu = menu_bar.addMenu("File") + recent = file_menu.addMenu("Recent") + recent.addAction(QAction("project.py", menu_bar)) + + collect_menu_commands(menu_bar) + + assert file_menu.title() == "File" + assert recent.title() == "Recent" + assert [action.text() for action in recent.actions()] == ["project.py"] + def test_submenu_itself_is_not_a_command(self): menu_bar = QMenuBar() file_menu = menu_bar.addMenu("File") From c2a8ee4a9af58e90acb19fd142cc48b9a9522352 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 28 Jul 2026 01:47:02 +0800 Subject: [PATCH 2/2] Relabel the interface in place when the language changes Changing the language rebuilt the menu bar, and setMenuBar deletes the outgoing bar together with every menu on it. JEditor owns all of its own menus, so it never noticed; an application embedding this window adds its menus to that same bar. Those disappeared, the references it kept became pointers to deleted objects, and following one crashed the process -- Qt6Widgets, 0xC0000005. Restarting looked like the trigger only because the settings were never written after the crash. Nothing is torn down now. The menus, the toolbar, the tabs and the docks are walked and their wording moved from the old dictionary's key to the new one's, which leaves every widget, including a host's, exactly where it was. Placing text by key needs care where one English word belongs to several keys that differ elsewhere: a menu bar entry is matched against the ..._menu_label keys first, then a submenu item against its parent's family, so the Tab menu's "Editor" cannot be given the Dock menu's wording. Menus that list names rather than wording -- the languages, the font families -- are left alone, since translating "Symbol" would name a font nobody has. A test walks the whole menu bar after a language change and compares it against one built from scratch in that language: all 1188 entries have to read alike, in each of the three translations. --- je_editor/pyside_ui/main_ui/retranslate.py | 218 ++++++++++++++--- .../main_ui/toolbar/toolbar_builder.py | 21 +- .../utils/multi_language/retranslate_text.py | 57 +++++ test/test_retranslate.py | 225 +++++++++++++++++- 4 files changed, 481 insertions(+), 40 deletions(-) diff --git a/je_editor/pyside_ui/main_ui/retranslate.py b/je_editor/pyside_ui/main_ui/retranslate.py index b8086382..c320c0e4 100644 --- a/je_editor/pyside_ui/main_ui/retranslate.py +++ b/je_editor/pyside_ui/main_ui/retranslate.py @@ -2,21 +2,37 @@ 換語言後把整個介面重新標示一次 Relabel the whole interface after the language changes. -原本換語言只會跳出「請重新啟動」——設定改了,但畫面上一個字也沒變。選單與工具列 -是啟動時建好的,重建它們最省事也最完整;面板各自有狀態,因此由它們自己重新標示。 +原本換語言只會跳出「請重新啟動」——設定改了,但畫面上一個字也沒變。 Changing the language used to do nothing but ask for a restart: the setting -moved and not one word on screen did. The menus and the toolbar are built at -startup and rebuilding them is both the simplest and the most complete way to -relabel them; the panels hold state, so each relabels itself. +moved and not one word on screen did. + +這裡只改字,不動任何 widget。曾經是整條選單列重建(``setMenuBar`` 會把舊的連同 +底下所有選單一起刪掉),對 JEditor 自己沒差,但把自己的選單掛在同一條列上的宿主 +程式會整組消失,而它留著的參考就成了指向已刪物件的指標——碰到就是當掉。 +Nothing here is torn down; only the wording moves. The menu bar used to be +rebuilt, and ``setMenuBar`` deletes the outgoing bar together with every menu on +it. That costs JEditor nothing, since it owns them all, but an application +embedding this window adds its menus to that same bar: they vanish, and the +references it still holds become pointers to deleted objects, which crash as +soon as anything follows one. """ from __future__ import annotations -from typing import Dict +from typing import Dict, Optional + +from PySide6.QtWidgets import QComboBox, QLabel, QMenuBar, QToolBar, QWidgetAction +from je_editor.pyside_ui.main_ui.menu.submenu_map import submenus_of from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import reload_bound_shortcuts 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.multi_language.retranslate_text import TITLE_KEYS, translated_again +from je_editor.utils.multi_language.retranslate_text import ( + TITLE_KEYS, family_of, key_for_text, keys_in_family, menu_candidates, + translated_again +) + +# 工具列的鍵都在這個家族底下 / The toolbar's keys all live in this family +TOOLBAR_FAMILY = "toolbar" def retranslate_ui(main_window, previous_words: Dict[str, str]) -> None: @@ -31,45 +47,185 @@ def retranslate_ui(main_window, previous_words: Dict[str, str]) -> None: words = language_wrapper.language_word_dict main_window.setWindowTitle(words.get("application_name", "")) main_window.setToolTip(words.get("application_name", "")) - _rebuild_menu_bar(main_window) - _rebuild_toolbar(main_window) + _retranslate_menu_bar(main_window, previous_words, words) + _retranslate_toolbar(main_window, previous_words, words) _retranslate_tabs(main_window, previous_words, words) _retranslate_docks(main_window, previous_words, words) refresh = getattr(main_window, "refresh_status_bar", None) if callable(refresh): refresh() - # 舊選單與工具列的動作已經消失,把它們從快捷鍵表中清掉 - # The old menu and toolbar actions are gone; drop them from the shortcut table + # 使用者改過的快捷鍵重新套用一次 / Re-apply the keys the user reassigned reload_bound_shortcuts() -def _rebuild_menu_bar(main_window) -> None: - """重建選單列 / Build the menu bar again.""" - from je_editor.pyside_ui.main_ui.menu.set_menu_bar import set_menu_bar - # setMenuBar 會接手並刪掉舊的那一個 / setMenuBar takes over and deletes the old one - set_menu_bar(main_window) +def _retranslate_menu_bar(main_window, previous: Dict[str, str], + current: Dict[str, str]) -> None: + """ + 重新標示選單列上的每一個選單與項目 + Relabel every menu and item on the menu bar. + + 宿主程式加的選單一併換掉——它的字也在同一份字典裡——而檔名、直譯器路徑這類認 + 不出來的文字保持原樣。 + Menus an embedding application added move too, since their wording is in the + same dictionary, while text that cannot be placed -- a file name, an + interpreter's path -- is left exactly as it is. + """ + menu_bar = getattr(main_window, "menu", None) + if not isinstance(menu_bar, QMenuBar): + menu_bar = main_window.menuBar() + if menu_bar is None: + return + listings = _menus_that_list_names(main_window) + submenu_of = submenus_of(menu_bar) + + def walk(action, family: str, top_level: bool = False) -> None: + key = _relabel_in_stages(action, previous, current, family, top_level) + submenu = submenu_of.get(action) + if submenu is None or submenu in listings: + return + inner = family_of(key) or family + for child in submenu.actions(): + walk(child, inner) + + for entry in menu_bar.actions(): + walk(entry, family="", top_level=True) + + +def _menus_that_list_names(main_window) -> set: + """ + 列的是名字而不是說法的選單,裡面的項目不要動 + The menus listing names rather than wording, whose items are left alone. + + 語言選單的每一項都是某個語言自己的寫法(English、繁體中文、日本語)——看不懂 + 目前介面語言的人,就是靠這個找到自己的那一個。字型選單列的是系統裝了哪些字 + 型,其中 ``Symbol`` 與 ``Terminal`` 剛好也是字典裡別處的英文字,照翻就會把字 + 型名稱換成不存在的字型。 + Every entry in the Language menu is a language's own name for itself, which + is how someone who cannot read the current interface language finds theirs. + The font menus list what is installed, and two of those families -- ``Symbol`` + and ``Terminal`` -- happen to read like words used elsewhere in the + dictionary, so translating them would name fonts that do not exist. + + 最近開啟的檔案不必列在這裡:那些項目是完整路徑,本來就對不上字典裡任何一個 + 字,而檔案清單空的時候顯示的那句話則應該跟著語言走。 + Recent files need no entry here: those items are full paths and so match + nothing in the dictionary, while the line shown when the list is empty is + wording and should move with the language. + + :param main_window: 主編輯器視窗 / the main editor window + :return: 這些選單 / those menus + """ + menus = {getattr(main_window, "language_menu", None)} + for owner_name in ("file_menu", "text_menu"): + owner = getattr(main_window, owner_name, None) + menus.add(getattr(owner, "font_menu", None)) + return {menu for menu in menus if menu is not None} + + +def _relabel_in_stages(action, previous: Dict[str, str], current: Dict[str, str], + family: str, top_level: bool = False) -> Optional[str]: + """ + 依序用越來越寬的候選鍵去認一個項目的文字 + Place one item's text against widening sets of candidate keys. + + 選單列上那一排的鍵都叫 ``..._menu_label``,先只看這一批:宿主程式併進來的字典 + 可能也有一個字寫著 ``Run``,而那個鍵不見得每個語言都翻了。 + A menu bar entry's key is always named ``..._menu_label``, so those come + first: a dictionary merged in by an embedding application may well have its + own key reading ``Run``, and that one need not be translated everywhere. + + 接著找同一個家族的鍵:子選單的項目與它所屬的選單同一個家族,這樣「分頁選單裡 + 的 Editor」不會被「浮動視窗選單裡的 Editor」蓋掉。再放寬到所有選單的鍵,最後 + 才是整本字典——有些項目的鍵既不帶 ``_menu`` 也不以 ``_label`` 結尾。 + Then comes the family, since a submenu's items share the family of the menu + holding them, which keeps the Tab menu's "Editor" from being given the Dock + menu's wording. Then every menu key, and last the whole dictionary: some + items' keys carry neither ``_menu`` nor ``_label``. + + :return: 對應的鍵,認不出來時為 ``None`` / the key, or ``None`` + """ + for candidates in ( + _menu_bar_keys(previous) if top_level else (), + keys_in_family(previous, family) if family else (), + menu_candidates(previous), + tuple(previous), + ): + if not candidates: + continue + key = _relabel(action, previous, current, candidates) + if key is not None: + return key + return None + + +def _menu_bar_keys(words: Dict[str, str]) -> tuple: + """選單列上那一排的鍵 / The keys naming the menu bar's own entries.""" + return tuple(key for key in words if key.endswith("_menu_label")) + +def _relabel(action, previous: Dict[str, str], current: Dict[str, str], + candidates) -> Optional[str]: + """ + 把一個項目的文字與提示換成新語言的說法 + Move one item's text, and its tip, to the new language's wording. -def _rebuild_toolbar(main_window) -> None: + :return: 這段文字對應的鍵,認不出來時為 ``None`` / the key behind the text, + or ``None`` when it cannot be placed """ - 重建工具列 - Build the toolbar again. + key = key_for_text(action.text(), previous, candidates) + if key is None: + return None + tip_follows_text = action.toolTip() == action.text() + action.setText(current.get(key, action.text())) + if tip_follows_text: + action.setToolTip(action.text()) + else: + action.setToolTip( + translated_again(action.toolTip(), previous, current, candidates)) + return key + - 先等舊工具列的背景工作結束:git 分支掃描完成後要去填那個下拉選單,而下拉選單 - 正要被刪掉。不等的話 Qt 會直接讓程序中止。 - The outgoing toolbar's background work is waited for first: the git branch - scan finishes by filling that combo box, and the combo box is about to be - deleted. Without the wait, Qt aborts the process. +def _retranslate_toolbar(main_window, previous: Dict[str, str], + current: Dict[str, str]) -> None: + """ + 重新標示工具列 + Relabel the toolbar. + + 下拉選單裡的分支名稱不是翻譯來的,因此只換它的提示。 + The branch names in the combo box did not come from a translation, so only + its tip moves. """ from je_editor.pyside_ui.main_ui.toolbar.toolbar_builder import ( - build_toolbar, stop_background_threads + GIT_BRANCH_LABEL_NAME, git_branch_label_text ) - stop_background_threads() - old = getattr(main_window, "main_toolbar", None) - if old is not None: - main_window.removeToolBar(old) - old.deleteLater() - build_toolbar(main_window) + toolbar = getattr(main_window, "main_toolbar", None) + if not isinstance(toolbar, QToolBar): + return + toolbar_keys = tuple( + key for key in previous if key.startswith(f"{TOOLBAR_FAMILY}_")) + toolbar.setWindowTitle( + translated_again(toolbar.windowTitle(), previous, current, toolbar_keys)) + for action in toolbar.actions(): + if isinstance(action, QWidgetAction): + _retranslate_toolbar_widget( + action.defaultWidget(), previous, current, toolbar_keys, + GIT_BRANCH_LABEL_NAME, git_branch_label_text) + continue + _relabel(action, previous, current, toolbar_keys) + + +def _retranslate_toolbar_widget(widget, previous: Dict[str, str], + current: Dict[str, str], candidates, + label_name: str, label_text) -> None: + """重新標示工具列裡的元件 / Relabel a widget sitting in the toolbar.""" + if widget is None: + return + if isinstance(widget, QLabel) and widget.objectName() == label_name: + widget.setText(label_text()) + return + if isinstance(widget, QComboBox): + widget.setToolTip( + translated_again(widget.toolTip(), previous, current, candidates)) def _retranslate_tabs(main_window, previous: Dict[str, str], current: Dict[str, str]) -> None: 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 0d1dc512..9735337e 100644 --- a/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py +++ b/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py @@ -17,11 +17,26 @@ from je_editor.pyside_ui.main_ui.main_editor import EditorMain +# 分支標籤的名字,換語言時用它找回那個標籤 +# The branch label's name, which a language change uses to find it again +GIT_BRANCH_LABEL_NAME = "toolbar_git_branch_label" + + def _icon(widget: QWidget, std: QStyle.StandardPixmap) -> QIcon: """從 QStyle 取得內建圖示 / Get built-in icon from QStyle""" return widget.style().standardIcon(std) +def git_branch_label_text() -> str: + """ + 分支標籤上的文字,含左右間距 + The branch label's text, spacing and all. + + :return: 標籤文字 / the label's text + """ + return f" {language_wrapper.language_word_dict.get('toolbar_git_branch')} " + + def build_toolbar(main_window: EditorMain) -> None: """ 建立主工具列,類似 JetBrains 的快捷按鈕列 @@ -83,7 +98,11 @@ def build_toolbar(main_window: EditorMain) -> None: toolbar.addSeparator() # ── Git branch ──────────────────────────────────────────── - git_label = QLabel(f" {lang('toolbar_git_branch')} ") + git_label = QLabel(git_branch_label_text()) + # 換語言時要找回這個標籤:它的文字帶著間距,不是字典裡的原字 + # Naming it lets a language change find it again: its text carries spacing + # and so is not the dictionary's own string + git_label.setObjectName(GIT_BRANCH_LABEL_NAME) toolbar.addWidget(git_label) branch_combo = QComboBox() diff --git a/je_editor/utils/multi_language/retranslate_text.py b/je_editor/utils/multi_language/retranslate_text.py index a8f0cea7..9fa584f0 100644 --- a/je_editor/utils/multi_language/retranslate_text.py +++ b/je_editor/utils/multi_language/retranslate_text.py @@ -50,6 +50,63 @@ ) +def menu_candidates(words: Dict[str, str]) -> tuple: + """ + 看起來像選單用的鍵 + The keys that read as a menu's own wording. + + 選單的鍵多半帶著 ``_menu`` 或以 ``_label`` 結尾,欄位名稱、按鈕文字則不是。 + 同一個英文字常被好幾個鍵用到——``Run`` 同時是選單標題、主控台按鈕與工具列提 + 示,三者在中文並不同字——先看這一批,就不會被別處的鍵搶走。 + A menu's keys mostly carry ``_menu`` or end in ``_label``, while column + headings and button captions do not. One English word is often used by + several keys -- ``Run`` is a menu title, a console button and a toolbar tip + at once, and the three differ in Chinese -- and looking here first keeps an + unrelated key from claiming it. + + :param words: 目前的字典 / the dictionary in use + :return: 候選的鍵 / the candidate keys + """ + return tuple( + key for key in words if "_menu" in key or key.endswith("_label")) + + +def keys_in_family(words: Dict[str, str], family: str) -> tuple: + """ + 某個家族底下的鍵 + The keys belonging to one family. + + :param words: 目前的字典 / the dictionary in use + :param family: 家族名稱,例如 ``tab``;空字串代表不限 / the family, e.g. + ``tab``; an empty string means every key + :return: 該家族的鍵 / that family's keys + """ + if not family: + return tuple(words) + return tuple(key for key in words if key.startswith(f"{family}_")) + + +def family_of(key: Optional[str]) -> str: + """ + 取一個鍵的家族,也就是第一段 + A key's family, which is its first segment. + + 子選單的項目與它所屬的選單同一個家族(``tab_menu_label`` 底下都是 ``tab_``), + 因此知道上層是誰,就能把「分頁選單裡的 Editor」和「浮動視窗選單裡的 Editor」 + 分開——這兩個英文一樣,中文一個是「編輯器」一個不翻。 + A submenu's items share the family of the menu holding them: everything under + ``tab_menu_label`` starts with ``tab_``. Knowing the parent therefore tells + the Tab menu's "Editor" from the Dock menu's, which read alike in English and + differ in Chinese, one being translated and the other not. + + :param key: 鍵,可以是 ``None`` / the key, which may be ``None`` + :return: 家族名稱,取不到時為空字串 / the family, or an empty string + """ + if not key: + return "" + return key.split("_", 1)[0] + + def key_for_text(text: str, words: Dict[str, str], candidates: Optional[Iterable[str]] = None) -> Optional[str]: """ diff --git a/test/test_retranslate.py b/test/test_retranslate.py index 0f0fd3b1..6b01ba9c 100644 --- a/test/test_retranslate.py +++ b/test/test_retranslate.py @@ -4,14 +4,17 @@ from unittest.mock import MagicMock, patch import pytest -from PySide6.QtWidgets import QMainWindow, QMenuBar, QTabWidget, QWidget +from PySide6.QtWidgets import QMainWindow, QMenu, QMenuBar, QTabWidget, QWidget from je_editor.utils.multi_language.english import english_word_dict +from je_editor.utils.multi_language.japanese import japanese_word_dict from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper from je_editor.utils.multi_language.retranslate_text import ( - TITLE_KEYS, key_for_text, translated_again + TITLE_KEYS, family_of, key_for_text, keys_in_family, menu_candidates, + translated_again ) from je_editor.utils.multi_language.simplified_chinese import simplified_chinese_word_dict +from je_editor.utils.multi_language.traditional_chinese import traditional_chinese_word_dict class TestLookingUpTranslatedText: @@ -67,6 +70,47 @@ def test_the_real_dictionaries_move_a_tab_title(self): ) == simplified_chinese_word_dict["tab_name_editor"] +class TestNarrowingTheLookupToMenus: + """ + One English word is often several keys at once, and they need not agree in + another language: 'Run' is a menu title, a console button and a toolbar tip. + """ + + def test_a_menu_key_is_a_candidate(self): + assert "run_menu_label" in menu_candidates(english_word_dict) + + def test_a_column_heading_is_not(self): + assert "search_replace_col_text" not in menu_candidates(english_word_dict) + + def test_the_menu_title_wins_over_a_button_of_the_same_name(self): + key = key_for_text("Run", english_word_dict, menu_candidates(english_word_dict)) + assert english_word_dict[key] == "Run" + assert traditional_chinese_word_dict[key] == \ + traditional_chinese_word_dict["run_menu_label"] + + def test_a_family_keeps_its_own_keys(self): + keys = keys_in_family(english_word_dict, "tab") + assert "tab_menu_editor_tab_name" in keys + assert "dock_editor_menu" not in keys + + def test_no_family_means_every_key(self): + assert len(keys_in_family(english_word_dict, "")) == len(english_word_dict) + + def test_the_family_is_the_first_segment(self): + assert family_of("tab_menu_editor_tab_name") == "tab" + + def test_nothing_has_no_family(self): + assert family_of(None) == "" + + def test_the_tab_menu_editor_is_told_from_the_dock_menu_one(self): + # Both read "Editor" in English, and only one of them is translated. + in_tab = key_for_text( + "Editor", english_word_dict, keys_in_family(english_word_dict, "tab")) + in_dock = key_for_text( + "Editor", english_word_dict, keys_in_family(english_word_dict, "dock")) + assert japanese_word_dict[in_tab] != japanese_word_dict.get(in_dock, "Editor") + + @pytest.fixture() def window(qapp, qtbot): """A window with the pieces retranslating touches.""" @@ -95,6 +139,55 @@ def english(qapp): language_wrapper.reset_language(before) +def build_menus_and_toolbar(main_window) -> None: + """Give a bare window the menu bar and toolbar a real one has.""" + from je_editor.pyside_ui.main_ui.menu.set_menu_bar import set_menu_bar + from je_editor.pyside_ui.main_ui.toolbar.toolbar_builder import build_toolbar + set_menu_bar(main_window) + build_toolbar(main_window) + + +def menu_tree(menu_bar) -> list: + """Every label on a menu bar, in order and with its depth.""" + def walk(actions, depth): + rows = [] + for action in actions: + rows.append((" " * depth) + action.text()) + submenu = submenus.get(action) + if submenu is not None: + rows.extend(walk(submenu.actions(), depth + 1)) + return rows + # Asking a QAction for its menu hands that menu to Python to delete, which + # is the very fault these tests exist for; the children are safe to ask. + submenus = {menu.menuAction(): menu for menu in menu_bar.findChildren(QMenu)} + return walk(menu_bar.actions(), 0) + + +@pytest.fixture() +def full_window(window, english): + """A window carrying the real menu bar and toolbar, built in English.""" + build_menus_and_toolbar(window) + return window + + +@pytest.fixture() +def window_factory(qtbot): + """Make further bare windows, for comparing against a fresh build.""" + from PySide6.QtGui import QFontDatabase + + def make() -> QMainWindow: + main_window = QMainWindow() + qtbot.addWidget(main_window) + main_window.menu = QMenuBar() + main_window.font_database = QFontDatabase() + main_window.tab_widget = QTabWidget() + main_window.setCentralWidget(main_window.tab_widget) + main_window.encoding = "utf-8" + return main_window + + return make + + class TestRetranslatingTheWindow: def test_a_translated_tab_title_changes(self, window, english): from je_editor.pyside_ui.main_ui.retranslate import retranslate_ui @@ -121,20 +214,21 @@ def test_the_window_title_changes(self, window, english): assert window.windowTitle() == \ simplified_chinese_word_dict["application_name"] - def test_the_menu_bar_is_rebuilt_in_the_new_language(self, window, english): + def test_the_menu_bar_moves_to_the_new_language(self, full_window): from je_editor.pyside_ui.main_ui.retranslate import retranslate_ui previous = dict(language_wrapper.language_word_dict) language_wrapper.reset_language("Simplified_Chinese") - retranslate_ui(window, previous) - titles = [action.text() for action in window.menu.actions()] + retranslate_ui(full_window, previous) + titles = [action.text() for action in full_window.menu.actions()] assert simplified_chinese_word_dict["file_menu_label"] in titles - def test_the_toolbar_is_rebuilt(self, window, english): + def test_the_toolbar_moves_to_the_new_language(self, full_window): from je_editor.pyside_ui.main_ui.retranslate import retranslate_ui previous = dict(language_wrapper.language_word_dict) language_wrapper.reset_language("Japanese") - retranslate_ui(window, previous) - assert window.main_toolbar is not None + retranslate_ui(full_window, previous) + tips = [action.toolTip() for action in full_window.main_toolbar.actions()] + assert japanese_word_dict["toolbar_run"] in tips def test_a_panel_is_asked_to_relabel_itself(self, window, english): from je_editor.pyside_ui.main_ui.retranslate import retranslate_ui @@ -185,6 +279,121 @@ def test_the_choice_is_remembered(self, window, english): user_setting_dict["language"] = saved +class TestNothingIsDestroyedByALanguageChange: + """ + Relabelling used to mean rebuilding the menu bar, and ``setMenuBar`` deletes + the outgoing bar with every menu on it. An application embedding this window + adds its menus to that same bar: they disappeared, and the references it kept + became pointers to deleted objects, which crash as soon as one is followed. + """ + + @staticmethod + def switch_to(window, language: str) -> None: + from je_editor.pyside_ui.main_ui.retranslate import retranslate_ui + previous = dict(language_wrapper.language_word_dict) + language_wrapper.reset_language(language) + retranslate_ui(window, previous) + + def test_the_menu_bar_is_the_same_object_afterwards(self, full_window): + before = full_window.menuBar() + self.switch_to(full_window, "Japanese") + assert full_window.menuBar() is before + + def test_every_menu_is_still_alive(self, full_window): + menus = full_window.menuBar().findChildren(QMenu) + assert menus, "the window should have menus to begin with" + self.switch_to(full_window, "Japanese") + for menu in menus: + menu.title() # raises RuntimeError once the C++ object is gone + + def test_the_windows_own_menu_references_stay_usable(self, full_window): + self.switch_to(full_window, "Simplified_Chinese") + assert full_window.file_menu.actions() + assert full_window.dock_menu.actions() + + def test_a_menu_added_by_an_embedding_application_survives(self, full_window): + # What PyBreeze does: its own menus go on the same bar as ours. + extra = full_window.menuBar().addMenu("Automation") + extra.addAction("Run task") + self.switch_to(full_window, "Japanese") + assert extra.title() == "Automation" + assert extra.menuAction() in full_window.menuBar().actions() + + def test_the_toolbar_is_the_same_object_afterwards(self, full_window): + before = full_window.main_toolbar + self.switch_to(full_window, "Japanese") + assert full_window.main_toolbar is before + + +class TestRelabellingMatchesABuildInThatLanguage: + """ + The wording has to end up exactly where a fresh build would put it, or + relabelling in place would be trading a crash for a half-translated window. + """ + + @pytest.mark.parametrize( + "language", ["Traditional_Chinese", "Simplified_Chinese", "Japanese"]) + def test_the_menus_read_the_same(self, full_window, window_factory, language): + from je_editor.pyside_ui.main_ui.retranslate import retranslate_ui + previous = dict(language_wrapper.language_word_dict) + language_wrapper.reset_language(language) + retranslate_ui(full_window, previous) + + built = window_factory() + build_menus_and_toolbar(built) + assert menu_tree(full_window.menuBar()) == menu_tree(built.menuBar()) + + def test_the_toolbar_tips_read_the_same(self, full_window, window_factory): + from je_editor.pyside_ui.main_ui.retranslate import retranslate_ui + previous = dict(language_wrapper.language_word_dict) + language_wrapper.reset_language("Japanese") + retranslate_ui(full_window, previous) + + built = window_factory() + build_menus_and_toolbar(built) + assert [action.toolTip() for action in full_window.main_toolbar.actions()] == \ + [action.toolTip() for action in built.main_toolbar.actions()] + + +class TestNamesAreNotTranslated: + """ + Some menus list names rather than wording: font families, the languages + themselves. Translating those would name a font nobody has installed, or + hide a language from the person looking for it. + """ + + @staticmethod + def labels(menu) -> list: + return [action.text() for action in menu.actions()] + + def test_font_families_keep_their_names(self, full_window): + from je_editor.pyside_ui.main_ui.retranslate import retranslate_ui + before = self.labels(full_window.file_menu.font_menu) + previous = dict(language_wrapper.language_word_dict) + language_wrapper.reset_language("Japanese") + retranslate_ui(full_window, previous) + assert self.labels(full_window.file_menu.font_menu) == before + + def test_each_language_stays_written_the_way_it_writes_itself(self, full_window): + from je_editor.pyside_ui.main_ui.retranslate import retranslate_ui + before = self.labels(full_window.language_menu) + assert "English" in before + previous = dict(language_wrapper.language_word_dict) + language_wrapper.reset_language("Japanese") + retranslate_ui(full_window, previous) + assert self.labels(full_window.language_menu) == before + + def test_the_line_shown_when_there_are_no_recent_files_does_move(self, full_window): + from je_editor.pyside_ui.main_ui.retranslate import retranslate_ui + menu = full_window.file_menu.recent_files_menu + if self.labels(menu) != [english_word_dict["file_menu_no_recent_files"]]: + pytest.skip("this run has recent files, which are paths and never move") + previous = dict(language_wrapper.language_word_dict) + language_wrapper.reset_language("Japanese") + retranslate_ui(full_window, previous) + assert self.labels(menu) == [japanese_word_dict["file_menu_no_recent_files"]] + + class TestPanelsRelabelThemselves: @pytest.mark.parametrize("panel_import,builder", [ ("je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget:"