From 629c873db8ec76b0704705f43d9542c1fe995fe3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 21:13:38 +0800 Subject: [PATCH] Change language without restarting Choosing a language updated the setting and then asked for a restart: not one word on screen changed. It now relabels everything at once. The menus and the toolbar are built at startup, so they are rebuilt. Panels hold state -- diagnostics, test results -- so each relabels itself instead. Tab and dock titles are moved by looking the old text back up in the old dictionary, which leaves file and branch names alone. That lookup has to be told which keys to consider. In English "Editor" is a tab name, a dock title and an untranslated menu identifier all at once, and an unguided lookup lands on the untranslated one -- which reads as the language change simply not working. Rebuilding the toolbar waits for the outgoing one'"'"'s git scan first: that scan finishes by filling a combo box which is about to be deleted, and Qt aborts the process when it lands. --- je_editor/pyside_ui/browser/browser_view.py | 17 +- .../language_menu/build_language_server.py | 15 +- .../outline_panel/outline_panel_widget.py | 14 ++ .../problems_panel/problems_panel_widget.py | 24 +++ je_editor/pyside_ui/main_ui/retranslate.py | 111 ++++++++++ .../main_ui/test_panel/test_panel_widget.py | 25 +++ .../main_ui/todo_panel/todo_panel_widget.py | 22 ++ je_editor/start_editor.py | 7 + je_editor/utils/browser/__init__.py | 0 je_editor/utils/browser/chromium_flags.py | 64 ++++++ .../utils/multi_language/retranslate_text.py | 97 +++++++++ test/test_chromium_flags.py | 48 +++++ test/test_retranslate.py | 204 ++++++++++++++++++ test/test_toolbar_actions.py | 6 +- 14 files changed, 643 insertions(+), 11 deletions(-) create mode 100644 je_editor/pyside_ui/main_ui/retranslate.py create mode 100644 je_editor/utils/browser/__init__.py create mode 100644 je_editor/utils/browser/chromium_flags.py create mode 100644 je_editor/utils/multi_language/retranslate_text.py create mode 100644 test/test_chromium_flags.py create mode 100644 test/test_retranslate.py diff --git a/je_editor/pyside_ui/browser/browser_view.py b/je_editor/pyside_ui/browser/browser_view.py index b8f92d0c..5153c8e9 100644 --- a/je_editor/pyside_ui/browser/browser_view.py +++ b/je_editor/pyside_ui/browser/browser_view.py @@ -9,11 +9,20 @@ from PySide6.QtCore import QObject from PySide6.QtGui import QCloseEvent -from PySide6.QtWebEngineCore import QWebEngineDownloadRequest -from PySide6.QtWebEngineWidgets import QWebEngineView -from je_editor.pyside_ui.browser.browser_download_window import BrowserDownloadWindow -from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.browser.chromium_flags import quiet_chromium_logging + +# 必須在 QtWebEngine 初始化之前設定,因此排在匯入它之前 +# This has to be set before QtWebEngine initialises, so it comes before the import +quiet_chromium_logging() + +from PySide6.QtWebEngineCore import QWebEngineDownloadRequest # noqa: E402 +from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: E402 + +from je_editor.pyside_ui.browser.browser_download_window import ( # noqa: E402 + BrowserDownloadWindow +) +from je_editor.utils.logging.loggin_instance import jeditor_logger # noqa: E402 class BrowserView(QWebEngineView): diff --git a/je_editor/pyside_ui/main_ui/menu/language_menu/build_language_server.py b/je_editor/pyside_ui/main_ui/menu/language_menu/build_language_server.py index 874a6a38..dbd444c4 100644 --- a/je_editor/pyside_ui/main_ui/menu/language_menu/build_language_server.py +++ b/je_editor/pyside_ui/main_ui/menu/language_menu/build_language_server.py @@ -9,7 +9,6 @@ # 匯入 Qt 動作與訊息框 # Import QAction and QMessageBox from PySide6 from PySide6.QtGui import QAction -from PySide6.QtWidgets import QMessageBox # 匯入使用者設定字典,用來保存語言設定 # Import user settings dictionary for saving language preferences @@ -78,6 +77,11 @@ def set_language(language: str, ui_we_want_to_set: EditorMain) -> None: f"language: {language} " f"ui_we_want_to_set: {ui_we_want_to_set}") + # 先留下舊字典:重新標示畫面時要靠它反查哪些字是翻譯來的 + # Keep the old dictionary: relabelling needs it to tell which words on screen + # came from a translation and which are file names + previous_words = dict(language_wrapper.language_word_dict) + # 重設語言 (更新多語言字典) # Reset language (update multi-language dictionary) language_wrapper.reset_language(language) @@ -86,8 +90,7 @@ def set_language(language: str, ui_we_want_to_set: EditorMain) -> None: # Update user settings dictionary to persist language preference user_setting_dict.update({"language": language}) - # 顯示提示訊息,提醒使用者需要重新啟動程式 - # Show a message box to remind user to restart the application - message_box = QMessageBox(ui_we_want_to_set) - message_box.setText(language_wrapper.language_word_dict.get("language_menu_bar_please_restart_messagebox")) - message_box.show() + # 立即重新標示整個介面,不需要重新啟動 + # Relabel the whole interface at once, with no restart + from je_editor.pyside_ui.main_ui.retranslate import retranslate_ui + retranslate_ui(ui_we_want_to_set, previous_words) diff --git a/je_editor/pyside_ui/main_ui/outline_panel/outline_panel_widget.py b/je_editor/pyside_ui/main_ui/outline_panel/outline_panel_widget.py index e7efea4e..54a36598 100644 --- a/je_editor/pyside_ui/main_ui/outline_panel/outline_panel_widget.py +++ b/je_editor/pyside_ui/main_ui/outline_panel/outline_panel_widget.py @@ -66,6 +66,20 @@ def __init__(self, main_window=None) -> None: self.refresh() + def retranslate(self) -> None: + """ + 換語言後重新標示自己 + Relabel after the language changes. + """ + word = language_wrapper.language_word_dict + self.refresh_button.setText(word.get("outline_panel_refresh")) + self.tree.setHeaderLabels([ + word.get("outline_panel_col_symbol"), + word.get("outline_panel_col_kind"), + word.get("outline_panel_col_line"), + ]) + self.refresh() + def current_code_edit(self): """ 取得目前分頁的程式碼編輯器 diff --git a/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py b/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py index e2b2acbe..32dcfe71 100644 --- a/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py +++ b/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py @@ -116,6 +116,30 @@ def diagnostics(self) -> list[Diagnostic]: """取得面板目前顯示的診斷 / The diagnostics currently listed.""" return list(self._diagnostics) + def retranslate(self) -> None: + """ + 換語言後重新標示自己 + Relabel after the language changes. + + 面板記著目前的診斷,因此不能整個拆掉重建;改字之後重畫清單,狀態列的文字 + 也會跟著換成新語言。 + The panel is holding the current diagnostics and so cannot be rebuilt from + scratch; relabelling and then redrawing the list also moves the status + text to the new language. + """ + word = language_wrapper.language_word_dict + self.refresh_button.setText(word.get("problems_panel_refresh")) + self.project_check.setText(word.get("problems_panel_whole_project")) + self.fix_button.setText(word.get("problems_panel_fix")) + self.severity_filter.setItemText(0, word.get("problems_panel_all_severities")) + self.result_tree.setHeaderLabels([ + word.get("problems_panel_col_code"), + word.get("problems_panel_col_message"), + word.get("problems_panel_col_line"), + word.get("problems_panel_col_file"), + ]) + self._render_items() + def refresh(self) -> None: """ 重新讀取診斷並重畫清單 diff --git a/je_editor/pyside_ui/main_ui/retranslate.py b/je_editor/pyside_ui/main_ui/retranslate.py new file mode 100644 index 00000000..b8086382 --- /dev/null +++ b/je_editor/pyside_ui/main_ui/retranslate.py @@ -0,0 +1,111 @@ +""" +換語言後把整個介面重新標示一次 +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. +""" +from __future__ import annotations + +from typing import Dict + +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 + + +def retranslate_ui(main_window, previous_words: Dict[str, str]) -> None: + """ + 依目前語言重新標示整個介面 + Relabel the whole interface in the language now in use. + + :param main_window: 主編輯器視窗 / the main editor window + :param previous_words: 換語言之前的字典 / the dictionary from before the change + """ + jeditor_logger.info("retranslate.py retranslate_ui") + 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_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 + 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 _rebuild_toolbar(main_window) -> None: + """ + 重建工具列 + Build the toolbar again. + + 先等舊工具列的背景工作結束: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. + """ + from je_editor.pyside_ui.main_ui.toolbar.toolbar_builder import ( + build_toolbar, stop_background_threads + ) + 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) + + +def _retranslate_tabs(main_window, previous: Dict[str, str], current: Dict[str, str]) -> None: + """ + 重新標示分頁標題,檔名保持原樣 + Relabel the tab titles, leaving file names alone. + """ + tab_widget = getattr(main_window, "tab_widget", None) + if tab_widget is None: + return + for index in range(tab_widget.count()): + tab_widget.setTabText( + index, translated_again(tab_widget.tabText(index), previous, current, TITLE_KEYS)) + _retranslate_widget(tab_widget.widget(index)) + + +def _retranslate_docks(main_window, previous: Dict[str, str], current: Dict[str, str]) -> None: + """重新標示浮動視窗的標題與內容 / Relabel the docks and what they hold.""" + from PySide6.QtWidgets import QDockWidget + for dock in main_window.findChildren(QDockWidget): + dock.setWindowTitle( + translated_again(dock.windowTitle(), previous, current, TITLE_KEYS)) + _retranslate_widget(dock.widget()) + + +def _retranslate_widget(widget) -> None: + """ + 請一個元件重新標示自己 + Ask a widget to relabel itself. + + 有 ``retranslate`` 就呼叫它。沒有的元件維持原本的字,直到重新開啟為止——這比 + 硬把它拆掉重建安全,因為那會連同它記著的狀態一起丟掉。 + A widget with a ``retranslate`` is asked to. One without keeps its wording + until it is reopened, which is safer than tearing it down and rebuilding it: + that would throw away whatever state it is holding. + """ + retranslate = getattr(widget, "retranslate", None) + if callable(retranslate): + retranslate() 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 9c112bba..9e0a39d9 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 @@ -187,6 +187,31 @@ def results(self) -> list[PytestResult]: """取得目前顯示的結果 / The results currently listed.""" return list(self._results) + def retranslate(self) -> None: + """ + 換語言後重新標示自己 + Relabel after the language changes. + + 面板記著上一次的測試結果,因此只換文字再重畫清單,不重建整個面板。 + The panel is holding the last run's results, so it relabels and redraws + rather than being rebuilt. + """ + word = language_wrapper.language_word_dict + self.run_button.setText(word.get("test_panel_run")) + self.run_selected_button.setText(word.get("test_panel_run_selected")) + self.rerun_failures_button.setText(word.get("test_panel_rerun_failures")) + self.filter_edit.setPlaceholderText(word.get("test_panel_filter_placeholder")) + self.coverage_check.setText(word.get("test_panel_coverage")) + self.traceback_view.setPlaceholderText(word.get("test_panel_traceback_placeholder")) + self.result_tree.setHeaderLabels([ + word.get("test_panel_col_outcome"), + word.get("test_panel_col_test"), + word.get("test_panel_col_file"), + ]) + if not self._results: + self.status_label.setText(word.get("test_panel_ready")) + self._render_items() + def start_run(self, node_ids: list[str] | None = None) -> bool: """ 啟動一次測試執行 diff --git a/je_editor/pyside_ui/main_ui/todo_panel/todo_panel_widget.py b/je_editor/pyside_ui/main_ui/todo_panel/todo_panel_widget.py index 4964d7b5..d3f675c1 100644 --- a/je_editor/pyside_ui/main_ui/todo_panel/todo_panel_widget.py +++ b/je_editor/pyside_ui/main_ui/todo_panel/todo_panel_widget.py @@ -155,6 +155,28 @@ def _on_scanned(self, items: list) -> None: self.refresh_button.setEnabled(True) self._render_items() + def retranslate(self) -> None: + """ + 換語言後重新標示自己 + Relabel after the language changes. + + 標籤篩選的第一項是「所有標籤」,其餘是掃到的標籤本身(TODO、FIXME),那些 + 不是翻譯來的,不能動。 + The first entry in the tag filter is "all tags" and the rest are the tags + as found in the code (TODO, FIXME); those did not come from a translation + and are left alone. + """ + word = language_wrapper.language_word_dict + self.refresh_button.setText(word.get("todo_panel_refresh")) + self.tag_filter.setItemText(0, word.get("todo_panel_all_tags")) + self.result_tree.setHeaderLabels([ + word.get("todo_panel_col_tag"), + word.get("todo_panel_col_message"), + word.get("todo_panel_col_file"), + word.get("todo_panel_col_line"), + ]) + self._render_items() + def _render_items(self) -> None: """依篩選條件重建清單 / Rebuild the tree for the current filter.""" visible = self.visible_items() diff --git a/je_editor/start_editor.py b/je_editor/start_editor.py index c93ec76a..c5911206 100644 --- a/je_editor/start_editor.py +++ b/je_editor/start_editor.py @@ -7,6 +7,7 @@ from je_editor.pyside_ui.main_ui.main_editor import EditorMain from je_editor.plugins.plugin_loader import load_external_plugins +from je_editor.utils.browser.chromium_flags import quiet_chromium_logging def start_editor(debug_mode: bool = False) -> None: @@ -18,6 +19,12 @@ def start_editor(debug_mode: bool = False) -> None: :param debug_mode: 是否啟用除錯模式 / whether to enable debug mode """ + # 壓下內嵌 Chromium 的啟動雜訊;標準錯誤會被導進輸出面板,那些訊息會直接 + # 出現在使用者眼前 + # Quiet the embedded Chromium's startup noise: standard error is piped into + # the output pane, so its messages land in front of the user + quiet_chromium_logging() + # 嘗試取得現有的 QCoreApplication 實例 # Try to get an existing QCoreApplication instance new_editor = QCoreApplication.instance() diff --git a/je_editor/utils/browser/__init__.py b/je_editor/utils/browser/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/browser/chromium_flags.py b/je_editor/utils/browser/chromium_flags.py new file mode 100644 index 00000000..9020fb7b --- /dev/null +++ b/je_editor/utils/browser/chromium_flags.py @@ -0,0 +1,64 @@ +""" +壓下內嵌瀏覽器的啟動雜訊 +Quiet the embedded browser's startup noise. + +QtWebEngine 裡包著一整個 Chromium,它會把自己的訊息寫到標準錯誤,例如探測硬體 +視訊編碼器失敗的 ``mf_video_encoder_util.cc: Set output type failed``。那些訊息 +對編輯器的使用者沒有意義——Chromium 自己會退回可用的路徑——但這個編輯器把標準 +錯誤導進輸出面板,於是它們就出現在使用者眼前。 +QtWebEngine embeds a whole Chromium, which writes its own messages to standard +error -- such as ``mf_video_encoder_util.cc: Set output type failed`` when it +probes a hardware video encoder that will not take the settings. None of that +means anything to someone using a text editor, since Chromium falls back on its +own, but this editor pipes standard error into its output pane, so it lands in +front of the user. + +純邏輯:只組出環境變數的值,不啟動任何東西。 +Pure logic: it only assembles an environment variable, starting nothing. +""" +from __future__ import annotations + +import os +from typing import Dict, Optional + +# Chromium 讀取旗標的環境變數 / The variable Chromium reads its flags from +FLAGS_VARIABLE = "QTWEBENGINE_CHROMIUM_FLAGS" +# 只保留致命錯誤:0=INFO、1=WARNING、2=ERROR、3=FATAL +# Keep fatal messages only: 0=INFO, 1=WARNING, 2=ERROR, 3=FATAL +QUIET_FLAG = "--log-level=3" + + +def flags_with_quiet_logging(existing: Optional[str]) -> str: + """ + 把「安靜」旗標加進既有的旗標字串 + Add the quiet flag to whatever flags are already set. + + 使用者自己設過 log level 就不動它:他顯然是想看那些訊息。 + A log level the user set is left alone: they evidently want to see them. + + :param existing: 目前的旗標字串 / the flags as they stand + :return: 加上之後的旗標字串 / the flags afterwards + """ + current = (existing or "").strip() + if "--log-level" in current: + return current + return f"{current} {QUIET_FLAG}".strip() + + +def quiet_chromium_logging(environment: Optional[Dict[str, str]] = None) -> str: + """ + 設定環境變數,讓內嵌的 Chromium 少說話 + Set the environment variable that keeps the embedded Chromium quiet. + + 必須在 QtWebEngine 初始化之前呼叫,也就是第一個瀏覽器分頁被建立之前。 + This has to run before QtWebEngine initialises, which is before the first + browser tab is created. + + :param environment: 要設定的環境,省略時用行程本身的 + / the environment to set, or the process's own when omitted + :return: 設定後的旗標字串 / the flags as they now stand + """ + target = os.environ if environment is None else environment + flags = flags_with_quiet_logging(target.get(FLAGS_VARIABLE)) + target[FLAGS_VARIABLE] = flags + return flags diff --git a/je_editor/utils/multi_language/retranslate_text.py b/je_editor/utils/multi_language/retranslate_text.py new file mode 100644 index 00000000..a8f0cea7 --- /dev/null +++ b/je_editor/utils/multi_language/retranslate_text.py @@ -0,0 +1,97 @@ +""" +把已經顯示出來的文字換成另一個語言的說法 +Move text that is already on screen to another language's wording. + +換語言時,畫面上有些字是翻譯來的(分頁標題「編輯器」、面板標題),有些不是(檔 +名、分支名稱)。全部重設會把檔名蓋掉,全部不動則等於沒換語言,因此要能分辨。 +When the language changes some of what is on screen came from a translation -- +a tab reading "Editor", a panel's title -- and some did not: file names, branch +names. Replacing everything would overwrite the file names and replacing nothing +would not be a language change at all, so the two have to be told apart. + +做法是反查:這段文字剛好等於舊語言的某個字串嗎?是的話就換成新語言的同一個鍵。 +The way to tell is to look it up backwards: is this text exactly one of the old +language's strings? If so it becomes the new language's version of that key. + +純邏輯,不含 Qt。 +Pure logic, with no Qt. +""" +from __future__ import annotations + +from typing import Dict, Iterable, Optional + +# 分頁與浮動視窗的標題可能來自這些鍵;只有這些會被換掉,其餘(檔名、分支名稱)不動 +# The keys a tab or dock title may have come from. Only these are replaced, and +# everything else -- file names, branch names -- is left alone. +TITLE_KEYS = ( + "tab_name_editor", + "tab_name_web_browser", + "tab_name_frontengine", + "tab_menu_editor_tab_name", + "tab_menu_web_tab_name", + "tab_menu_frontengine_tab_name", + "tab_menu_console_widget_tab_name", + "tab_menu_todo_panel_tab_name", + "tab_menu_test_panel_tab_name", + "tab_menu_problems_panel_tab_name", + "tab_menu_outline_panel_tab_name", + "tab_menu_diff_against_head_name", + "tab_menu_diff_against_staged_name", + "search_replace_tab_title", + "plugin_browser_tab_name", + "dock_editor_title", + "dock_browser_title", + "dock_frontengine_title", + "chat_ui_dock_label", + "editor_code_result", + "editor_format_check", + "editor_terminal", + "editor_debugger_input_title_label", +) + + +def key_for_text(text: str, words: Dict[str, str], + candidates: Optional[Iterable[str]] = None) -> Optional[str]: + """ + 反查一段文字是哪個鍵翻譯出來的 + Look up which key a piece of text was translated from. + + 要限定候選的鍵:英文裡「Editor」同時是分頁名稱、浮動視窗標題與一個沒有翻譯的 + 選單代號,盲目反查很容易挑到那個沒翻譯的,結果就是換了語言卻沒有變。 + The candidate keys have to be narrowed: in English "Editor" is a tab name, a + dock title, and an untranslated menu identifier all at once, and an unguided + lookup readily lands on the untranslated one — which looks like the language + change simply not working. + + :param text: 畫面上的文字 / the text on screen + :param words: 當時使用的字典 / the dictionary in use at the time + :param candidates: 只考慮這些鍵;省略時全部都算 / only these keys, or all when omitted + :return: 對應的鍵,找不到時為 ``None`` / the key, or ``None`` + """ + if not text: + return None + keys = list(candidates) if candidates is not None else list(words) + matches = sorted(key for key in keys if words.get(key) == text) + return matches[0] if matches else None + + +def translated_again(text: str, previous: Dict[str, str], current: Dict[str, str], + candidates: Optional[Iterable[str]] = None) -> str: + """ + 把一段文字換成新語言的說法 + Move a piece of text to the new language's wording. + + 認不出來的文字原樣保留——那多半是檔名或分支名稱,不該被翻譯。 + Text that cannot be placed is left exactly as it is: it is most likely a file + or branch name, and translating that would be wrong. + + :param text: 目前的文字 / the text as it stands + :param previous: 換語言之前的字典 / the dictionary from before the change + :param current: 換語言之後的字典 / the dictionary now in use + :param candidates: 只考慮這些鍵 / only these keys are considered + :return: 換過的文字 / the text afterwards + """ + key = key_for_text(text, previous, candidates) + if key is None: + return text + return current.get(key, text) diff --git a/test/test_chromium_flags.py b/test/test_chromium_flags.py new file mode 100644 index 00000000..67089b84 --- /dev/null +++ b/test/test_chromium_flags.py @@ -0,0 +1,48 @@ +"""Tests that the embedded browser's startup noise is quieted.""" +from __future__ import annotations + +from je_editor.utils.browser.chromium_flags import ( + FLAGS_VARIABLE, QUIET_FLAG, flags_with_quiet_logging, quiet_chromium_logging +) + + +class TestQuietingChromium: + """ + QtWebEngine embeds a whole Chromium that writes its own messages to standard + error, and this editor pipes standard error into its output pane -- so a + hardware video encoder it probed and rejected ends up in front of the user. + """ + + def test_the_flag_is_added_when_there_is_nothing(self): + assert flags_with_quiet_logging(None) == QUIET_FLAG + + def test_the_flag_is_added_when_there_is_nothing_but_space(self): + assert flags_with_quiet_logging(" ") == QUIET_FLAG + + def test_existing_flags_are_kept(self): + result = flags_with_quiet_logging("--disable-gpu") + assert "--disable-gpu" in result and QUIET_FLAG in result + + def test_a_log_level_the_user_chose_is_left_alone(self): + # Someone who set a level evidently wants to see those messages. + assert flags_with_quiet_logging("--log-level=0") == "--log-level=0" + + def test_a_log_level_among_other_flags_is_left_alone(self): + flags = "--disable-gpu --log-level=1" + assert flags_with_quiet_logging(flags) == flags + + def test_the_environment_is_set(self): + environment: dict = {} + quiet_chromium_logging(environment) + assert environment[FLAGS_VARIABLE] == QUIET_FLAG + + def test_setting_it_twice_does_not_repeat_the_flag(self): + environment: dict = {} + quiet_chromium_logging(environment) + quiet_chromium_logging(environment) + assert environment[FLAGS_VARIABLE].count("--log-level") == 1 + + def test_what_was_already_there_survives(self): + environment = {FLAGS_VARIABLE: "--single-process"} + quiet_chromium_logging(environment) + assert "--single-process" in environment[FLAGS_VARIABLE] diff --git a/test/test_retranslate.py b/test/test_retranslate.py new file mode 100644 index 00000000..0f0fd3b1 --- /dev/null +++ b/test/test_retranslate.py @@ -0,0 +1,204 @@ +"""Tests that changing the language relabels the interface without a restart.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QMainWindow, QMenuBar, QTabWidget, QWidget + +from je_editor.utils.multi_language.english import english_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 +) +from je_editor.utils.multi_language.simplified_chinese import simplified_chinese_word_dict + + +class TestLookingUpTranslatedText: + """ + Some words on screen came from a translation and some are file names. + Replacing everything would overwrite the file names. + """ + + def test_a_translated_word_is_found(self): + assert key_for_text("Editor", {"tab_name_editor": "Editor"}) == "tab_name_editor" + + def test_a_file_name_is_not_found(self): + assert key_for_text("main.py", {"tab_name_editor": "Editor"}) is None + + def test_nothing_is_found_for_nothing(self): + assert key_for_text("", {"tab_name_editor": "Editor"}) is None + + def test_a_shared_translation_answers_predictably(self): + words = {"b_key": "Same", "a_key": "Same"} + assert key_for_text("Same", words) == "a_key" + + def test_only_the_offered_keys_are_considered(self): + # "Editor" is a tab name, a dock title and an untranslated menu + # identifier all at once; without narrowing, the untranslated one wins + # and the language change looks like it did nothing. + assert key_for_text( + "Editor", english_word_dict, ["tab_name_editor"]) == "tab_name_editor" + + def test_a_key_outside_the_offered_set_is_not_matched(self): + assert key_for_text("Editor", english_word_dict, ["toolbar_run"]) is None + + def test_the_title_keys_all_exist(self): + missing = [key for key in TITLE_KEYS if key not in english_word_dict] + assert missing == [] + + def test_a_translated_word_moves_language(self): + assert translated_again( + "Editor", {"tab_name_editor": "Editor"}, + {"tab_name_editor": "编辑器"}) == "编辑器" + + def test_a_file_name_is_left_alone(self): + assert translated_again( + "main.py", {"tab_name_editor": "Editor"}, + {"tab_name_editor": "编辑器"}) == "main.py" + + def test_a_key_the_new_language_lacks_stays_put(self): + assert translated_again("Editor", {"tab_name_editor": "Editor"}, {}) == "Editor" + + def test_the_real_dictionaries_move_a_tab_title(self): + assert translated_again( + english_word_dict["tab_name_editor"], + english_word_dict, simplified_chinese_word_dict, TITLE_KEYS + ) == simplified_chinese_word_dict["tab_name_editor"] + + +@pytest.fixture() +def window(qapp, qtbot): + """A window with the pieces retranslating touches.""" + from PySide6.QtGui import QFontDatabase + 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" + yield main_window + # Building a toolbar starts a git scan that fills its combo box. The real + # window waits for those when it closes; a bare one has to do it here, or Qt + # aborts the process when the widget goes away underneath a running scan. + from je_editor.pyside_ui.main_ui.toolbar.toolbar_builder import stop_background_threads + stop_background_threads() + + +@pytest.fixture() +def english(qapp): + """Start each test in English, and leave the wrapper as it was found.""" + before = language_wrapper.language + language_wrapper.reset_language("English") + yield + language_wrapper.reset_language(before) + + +class TestRetranslatingTheWindow: + def test_a_translated_tab_title_changes(self, window, english): + from je_editor.pyside_ui.main_ui.retranslate import retranslate_ui + window.tab_widget.addTab(QWidget(), english_word_dict["tab_name_editor"]) + previous = dict(language_wrapper.language_word_dict) + language_wrapper.reset_language("Simplified_Chinese") + retranslate_ui(window, previous) + assert window.tab_widget.tabText(0) == \ + simplified_chinese_word_dict["tab_name_editor"] + + def test_a_file_name_tab_is_left_alone(self, window, english): + from je_editor.pyside_ui.main_ui.retranslate import retranslate_ui + window.tab_widget.addTab(QWidget(), "main.py") + previous = dict(language_wrapper.language_word_dict) + language_wrapper.reset_language("Japanese") + retranslate_ui(window, previous) + assert window.tab_widget.tabText(0) == "main.py" + + def test_the_window_title_changes(self, window, english): + 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) + assert window.windowTitle() == \ + simplified_chinese_word_dict["application_name"] + + def test_the_menu_bar_is_rebuilt_in_the_new_language(self, window, english): + 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()] + assert simplified_chinese_word_dict["file_menu_label"] in titles + + def test_the_toolbar_is_rebuilt(self, window, english): + 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 + + def test_a_panel_is_asked_to_relabel_itself(self, window, english): + from je_editor.pyside_ui.main_ui.retranslate import retranslate_ui + panel = MagicMock(spec=["retranslate"]) + holder = QWidget() + holder.retranslate = panel.retranslate + window.tab_widget.addTab(holder, "Panel") + previous = dict(language_wrapper.language_word_dict) + language_wrapper.reset_language("Japanese") + retranslate_ui(window, previous) + panel.retranslate.assert_called_once() + + def test_a_widget_without_one_is_tolerated(self, window, english): + from je_editor.pyside_ui.main_ui.retranslate import retranslate_ui + window.tab_widget.addTab(QWidget(), "Plain") + previous = dict(language_wrapper.language_word_dict) + language_wrapper.reset_language("Japanese") + retranslate_ui(window, previous) + assert window.tab_widget.count() == 1 + + +class TestChangingLanguageNeedsNoRestart: + def test_choosing_a_language_relabels_at_once(self, window, english): + from je_editor.pyside_ui.main_ui.menu.language_menu.build_language_server import ( + set_language + ) + window.tab_widget.addTab(QWidget(), english_word_dict["tab_name_editor"]) + with patch.dict( + "je_editor.pyside_ui.main_ui.save_settings.user_setting_file.user_setting_dict", + {}, clear=False + ): + set_language("Simplified_Chinese", window) + assert window.tab_widget.tabText(0) == \ + simplified_chinese_word_dict["tab_name_editor"] + + def test_the_choice_is_remembered(self, window, english): + from je_editor.pyside_ui.main_ui.menu.language_menu.build_language_server import ( + set_language + ) + from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import ( + user_setting_dict + ) + saved = user_setting_dict.get("language") + try: + set_language("Japanese", window) + assert user_setting_dict["language"] == "Japanese" + finally: + user_setting_dict["language"] = saved + + +class TestPanelsRelabelThemselves: + @pytest.mark.parametrize("panel_import,builder", [ + ("je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget:" + "ProblemsPanelWidget", "problems"), + ("je_editor.pyside_ui.main_ui.outline_panel.outline_panel_widget:" + "OutlinePanelWidget", "outline"), + ]) + def test_the_panel_moves_language(self, qapp, qtbot, english, panel_import, builder): + module_name, class_name = panel_import.split(":") + module = __import__(module_name, fromlist=[class_name]) + panel = getattr(module, class_name)(main_window=None) + qtbot.addWidget(panel) + english_label = panel.refresh_button.text() + language_wrapper.reset_language("Simplified_Chinese") + panel.retranslate() + assert panel.refresh_button.text() != english_label + panel.close() diff --git a/test/test_toolbar_actions.py b/test/test_toolbar_actions.py index 12c9654f..613b9083 100644 --- a/test/test_toolbar_actions.py +++ b/test/test_toolbar_actions.py @@ -4,7 +4,9 @@ import pytest from PySide6.QtWidgets import QMainWindow -from je_editor.pyside_ui.main_ui.toolbar.toolbar_builder import build_toolbar +from je_editor.pyside_ui.main_ui.toolbar.toolbar_builder import ( + build_toolbar, stop_background_threads +) from je_editor.utils.shortcuts.shortcut_registry import ( WINDOW_SHORTCUTS, normalise_sequence ) @@ -17,6 +19,8 @@ def toolbar_window(qapp, qtbot): qtbot.addWidget(window) build_toolbar(window) yield window + # The git scan the toolbar starts fills a combo box that is about to go away. + stop_background_threads() @pytest.mark.usefixtures("qapp")