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

1"""クイックアクセス: よく使うメニュー項目をツールバーにピン留めする。 

2 

3ツールバーにはメニューの QAction そのものを addAction する(写しを作ると状態がずれる)。 

4ピン留めは QSettings の "quick_access_pinned_actions" に、メニューのパスを " > " でつないだ文字列で保存する 

5(QAction は起動のたびに作り直されるので、表示文字のパスを識別子にする)。見つからないものは黙って飛ばす。 

6ツールバーはタブごとに作り、共有するのは保存した識別子だけ。 

7""" 

8import logging 

9 

10from PySide6.QtCore import Qt 

11from PySide6.QtGui import QAction 

12from PySide6.QtWidgets import QToolBar, QMenu 

13 

14from graphica.core.i18n import tr 

15from graphica.gui import app_settings 

16from graphica.gui.dialogs import QuickAccessManagerDialog 

17 

18logger = logging.getLogger(__name__) 

19 

20 

21 

22# _migrate_pinned_quick_access_ids() が末尾の項目名を取り出すのにも使う 

23SEPARATOR = " > " 

24 

25 

26def quick_access_action_identifier(path): 

27 return SEPARATOR.join(path) 

28 

29 

30class QuickAccessMixin: 

31 def _create_quick_access_toolbar(self): 

32 """空のツールバーと、表示メニューの切り替えを作る。 

33 

34 ピン留めを戻すのは _restore_quick_access_actions()(プラグインのメニューを含めて全部できた後でないと見つからない)。 

35 """ 

36 self._quick_access_actions = {} # 識別子 -> QAction(回収されないよう持つ) 

37 

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 

45 

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() 

51 

52 toggle_action = toolbar.toggleViewAction() 

53 toggle_action.setText(tr("クイックアクセスツールバー")) 

54 self._view_menu.addAction(toggle_action) 

55 

56 

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 

63 

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 ) 

71 

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 

83 

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 

89 

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)) 

98 

99 

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)) 

103 

104 def _set_pinned_quick_access_ids(self, ids): 

105 app_settings.QUICK_ACCESS_PINNED_ACTIONS.write(self.settings, ids) 

106 

107 def _migrate_pinned_quick_access_ids(self, ids, available): 

108 """メニューの組み替えでパスが変わったピン留めを、新しい識別子に読み替える。((識別子のリスト, 変えたか)) 

109 

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) 

117 

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 

134 

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) 

152 

153 

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 

159 

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) 

166 

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) 

175 

176 def is_quick_access_pinned(self, ident): 

177 return ident in self._quick_access_actions 

178 

179 

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) 

190 

191 dialog = QuickAccessManagerDialog( 

192 self._collect_menu_actions, self.is_quick_access_pinned, toggle, self 

193 ) 

194 dialog.exec()