Coverage for graphica/gui/mixins/quick_access_mixin.py: 98.2%
122 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""クイックアクセス: よく使うメニュー項目をツールバーにピン留めする。
3ツールバーにはメニューの QAction そのものを addAction する(写しを作ると状態がずれる)。
4ピン留めは QSettings の "quick_access_pinned_actions" に、メニューのパスを " > " でつないだ文字列で保存する
5(QAction は起動のたびに作り直されるので、表示文字のパスを識別子にする)。見つからないものは黙って飛ばす。
6ツールバーはタブごとに作り、共有するのは保存した識別子だけ。
7"""
8import logging
10from PySide6.QtCore import Qt
11from PySide6.QtGui import QAction
12from PySide6.QtWidgets import QToolBar, QMenu
14from graphica.core.i18n import tr
15from graphica.gui import app_settings
16from graphica.gui.dialogs import QuickAccessManagerDialog
18logger = logging.getLogger(__name__)
22# _migrate_pinned_quick_access_ids() が末尾の項目名を取り出すのにも使う
23SEPARATOR = " > "
26def quick_access_action_identifier(path):
27 return SEPARATOR.join(path)
30class QuickAccessMixin:
31 def _create_quick_access_toolbar(self):
32 """空のツールバーと、表示メニューの切り替えを作る。
34 ピン留めを戻すのは _restore_quick_access_actions()(プラグインのメニューを含めて全部できた後でないと見つからない)。
35 """
36 self._quick_access_actions = {} # 識別子 -> QAction(回収されないよう持つ)
38 toolbar = QToolBar(tr("クイックアクセス"), self)
39 # saveState() が見分けられるように
40 toolbar.setObjectName("quick_access_toolbar")
41 # 上に固定するので、移動用のつまみは出さない(QSS では隠せない)
42 toolbar.setMovable(False)
43 self.addToolBar(Qt.ToolBarArea.TopToolBarArea, toolbar)
44 self.quick_access_toolbar = toolbar
46 # ピン留めが0件でも管理ダイアログに行けるよう、先頭に置く
47 self.quick_access_manage_action = QAction(tr("クイックアクセスの管理..."), self)
48 self.quick_access_manage_action.triggered.connect(self._on_manage_quick_access)
49 toolbar.addAction(self.quick_access_manage_action)
50 toolbar.addSeparator()
52 toggle_action = toolbar.toggleViewAction()
53 toggle_action.setText(tr("クイックアクセスツールバー"))
54 self._view_menu.addAction(toggle_action)
57 def _quick_access_pinnable_menus(self):
58 menus = [self._file_menu, self._edit_menu, self._view_menu, self._help_menu]
59 plugin_menu = getattr(self, '_plugin_menu', None)
60 if plugin_menu is not None:
61 menus.append(plugin_menu)
62 return menus
64 def _install_quick_access_context_menus(self):
65 """各メニューの項目の右クリックでピン留めと解除をできるようにする(全部のメニューができた後に1回)。"""
66 for menu in self._quick_access_pinnable_menus():
67 menu.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
68 menu.customContextMenuRequested.connect(
69 lambda pos, m=menu: self._on_quick_access_menu_context_menu(m, pos)
70 )
72 def _resolve_quick_access_menu_target(self, menu, pos):
73 """pos の項目の (識別子, パス, QAction)。区切り線やサブメニューなら None。UI を開かないのでテストできる。"""
74 action = menu.actionAt(pos)
75 if action is None or action.isSeparator() or action.menu() is not None:
76 return None
77 text = action.text().replace('&', '').strip()
78 if not text:
79 return None
80 path = [menu.title().replace('&', ''), text]
81 ident = quick_access_action_identifier(path)
82 return ident, path, action
84 def _on_quick_access_menu_context_menu(self, menu, pos):
85 target = self._resolve_quick_access_menu_target(menu, pos)
86 if target is None:
87 return
88 ident, path, action = target
90 popup = QMenu(self)
91 if self.is_quick_access_pinned(ident):
92 remove_action = popup.addAction(tr("クイックアクセスから削除"))
93 remove_action.triggered.connect(lambda: self.unpin_quick_access_action(ident))
94 else:
95 add_action = popup.addAction(tr("クイックアクセスに追加"))
96 add_action.triggered.connect(lambda: self.pin_quick_access_action(ident, action))
97 popup.exec(menu.mapToGlobal(pos))
100 def _get_pinned_quick_access_ids(self):
101 """ピン留めした順の識別子。"""
102 return app_settings.as_list_keeping_empty_string(app_settings.QUICK_ACCESS_PINNED_ACTIONS.read(self.settings))
104 def _set_pinned_quick_access_ids(self, ids):
105 app_settings.QUICK_ACCESS_PINNED_ACTIONS.write(self.settings, ids)
107 def _migrate_pinned_quick_access_ids(self, ids, available):
108 """メニューの組み替えでパスが変わったピン留めを、新しい識別子に読み替える。((識別子のリスト, 変えたか))
110 一致しない識別子は黙って飛ばされ、ピンが知らないうちに消えるので読み替える。末尾の項目名が一致する候補が
111 今のメニューにちょうど1つあるときだけ読み替え、複数あれば触らない。項目名自体が変わったものは読み替えない。
112 """
113 by_leaf = {}
114 for ident in available:
115 leaf = ident.rsplit(SEPARATOR, 1)[-1]
116 by_leaf.setdefault(leaf, []).append(ident)
118 migrated, changed = [], False
119 for ident in ids:
120 if ident in available:
121 migrated.append(ident)
122 continue
123 candidates = by_leaf.get(ident.rsplit(SEPARATOR, 1)[-1], [])
124 if len(candidates) == 1:
125 logger.info(
126 "クイックアクセスのピン留めを移動先へ読み替えました: %r -> %r",
127 ident, candidates[0],
128 )
129 migrated.append(candidates[0])
130 changed = True
131 else:
132 migrated.append(ident)
133 return migrated, changed
135 def _restore_quick_access_actions(self):
136 """保存した識別子のうち、今のメニューにあるものをツールバーに戻す(起動時に1回)。"""
137 ids = self._get_pinned_quick_access_ids()
138 if not ids:
139 return
140 available = {
141 quick_access_action_identifier(path): action
142 for path, action in self._collect_menu_actions()
143 }
144 ids, changed = self._migrate_pinned_quick_access_ids(ids, available)
145 if changed:
146 # 書き戻して、次からは読み替えずに済むように
147 self._set_pinned_quick_access_ids(ids)
148 for ident in ids:
149 action = available.get(ident)
150 if action is not None:
151 self._add_action_to_quick_access_toolbar(ident, action)
154 def _add_action_to_quick_access_toolbar(self, ident, action):
155 if ident in self._quick_access_actions:
156 return
157 self.quick_access_toolbar.addAction(action)
158 self._quick_access_actions[ident] = action
160 def pin_quick_access_action(self, ident, action):
161 self._add_action_to_quick_access_toolbar(ident, action)
162 ids = self._get_pinned_quick_access_ids()
163 if ident not in ids:
164 ids.append(ident)
165 self._set_pinned_quick_access_ids(ids)
167 def unpin_quick_access_action(self, ident):
168 action = self._quick_access_actions.pop(ident, None)
169 if action is not None: 169 ↛ 171line 169 didn't jump to line 171 because the condition on line 169 was always true
170 self.quick_access_toolbar.removeAction(action)
171 ids = self._get_pinned_quick_access_ids()
172 if ident in ids: 172 ↛ exitline 172 didn't return from function 'unpin_quick_access_action' because the condition on line 172 was always true
173 ids.remove(ident)
174 self._set_pinned_quick_access_ids(ids)
176 def is_quick_access_pinned(self, ident):
177 return ident in self._quick_access_actions
180 def _on_manage_quick_access(self):
181 def toggle(ident, path, checked):
182 if checked:
183 # 一覧を作ったときの QAction は使わず、実行する直前にパスで取り直す
184 for p, action in self._collect_menu_actions(): 184 ↛ exitline 184 didn't return from function 'toggle' because the loop on line 184 didn't complete
185 if p == path:
186 self.pin_quick_access_action(ident, action)
187 break
188 else:
189 self.unpin_quick_access_action(ident)
191 dialog = QuickAccessManagerDialog(
192 self._collect_menu_actions, self.is_quick_access_pinned, toggle, self
193 )
194 dialog.exec()