Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
42 changes: 42 additions & 0 deletions je_editor/pyside_ui/main_ui/menu/submenu_map.py
Original file line number Diff line number Diff line change
@@ -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}
218 changes: 187 additions & 31 deletions je_editor/pyside_ui/main_ui/retranslate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
21 changes: 20 additions & 1 deletion je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 的快捷按鈕列
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading