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/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_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") 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:"