|
38 | 38 | from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel |
39 | 39 | from tagstudio.qt.flowlayout import FlowLayout |
40 | 40 | from tagstudio.qt.helpers.color_overlay import theme_fg_overlay |
| 41 | +from tagstudio.qt.mnemonics import assign_mnemonics |
41 | 42 | from tagstudio.qt.pagination import Pagination |
42 | 43 | from tagstudio.qt.platform_strings import trash_term |
43 | 44 | from tagstudio.qt.resource_manager import ResourceManager |
|
52 | 53 | logger = structlog.get_logger(__name__) |
53 | 54 |
|
54 | 55 |
|
55 | | -def remove_accelerator_marker(label: str) -> str: |
56 | | - """Remove existing accelerator markers (&) from a label.""" |
57 | | - result = "" |
58 | | - skip = False |
59 | | - for i, ch in enumerate(label): |
60 | | - if skip: |
61 | | - skip = False |
62 | | - continue |
63 | | - if ch == "&": |
64 | | - # escaped ampersand "&&" |
65 | | - if i + 1 < len(label) and label[i + 1] == "&": |
66 | | - result += "&" |
67 | | - skip = True |
68 | | - # otherwise skip this '&' |
69 | | - continue |
70 | | - result += ch |
71 | | - return result |
72 | | - |
73 | | - |
74 | | -# Additional weight for first character in string |
75 | | -FIRST_CHARACTER_EXTRA_WEIGHT = 50 |
76 | | -# Additional weight for the beginning of a word |
77 | | -WORD_BEGINNING_EXTRA_WEIGHT = 50 |
78 | | -# Additional weight for a 'wanted' accelerator ie string with '&' |
79 | | -WANTED_ACCEL_EXTRA_WEIGHT = 150 |
80 | | - |
81 | | - |
82 | | -def calculate_weights(text: str): |
83 | | - weights: dict[int, str] = {} |
84 | | - |
85 | | - pos = 0 |
86 | | - start_character = True |
87 | | - wanted_character = False |
88 | | - |
89 | | - while pos < len(text): |
90 | | - c = text[pos] |
91 | | - |
92 | | - # skip non typeable characters |
93 | | - if not c.isalnum() and c != "&": |
94 | | - start_character = True |
95 | | - pos += 1 |
96 | | - continue |
97 | | - |
98 | | - weight = 1 |
99 | | - |
100 | | - # add special weight to first character |
101 | | - if pos == 0: |
102 | | - weight += FIRST_CHARACTER_EXTRA_WEIGHT |
103 | | - elif start_character: # add weight to word beginnings |
104 | | - weight += WORD_BEGINNING_EXTRA_WEIGHT |
105 | | - start_character = False |
106 | | - |
107 | | - # add weight to characters that have an & beforehand |
108 | | - if wanted_character: |
109 | | - weight += WANTED_ACCEL_EXTRA_WEIGHT |
110 | | - wanted_character = False |
111 | | - |
112 | | - # add decreasing weight to left characters |
113 | | - if pos < 50: |
114 | | - weight += 50 - pos |
115 | | - |
116 | | - # try to preserve the wanted accelerators |
117 | | - if c == "&" and (pos != len(text) - 1 and text[pos + 1] != "&" and text[pos + 1].isalnum()): |
118 | | - wanted_character = True |
119 | | - pos += 1 |
120 | | - continue |
121 | | - |
122 | | - while weight in weights: |
123 | | - weight += 1 |
124 | | - |
125 | | - if c != "&": |
126 | | - weights[weight] = c |
127 | | - |
128 | | - pos += 1 |
129 | | - |
130 | | - # update our maximum weight |
131 | | - max_weight = 0 if len(weights) == 0 else max(weights.keys()) |
132 | | - return max_weight, weights |
133 | | - |
134 | | - |
135 | | -def insert_mnemonic(label: str, char: str) -> str: |
136 | | - pos = label.lower().find(char) |
137 | | - if pos >= 0: |
138 | | - return label[:pos] + "&" + label[pos:] |
139 | | - return label |
140 | | - |
141 | | - |
142 | | -def assign_mnemonics(menu: QMenu): |
143 | | - # Collect actions |
144 | | - actions = [a for a in menu.actions() if not a.isSeparator()] |
145 | | - |
146 | | - # Sequence map: mnemonic key -> QAction |
147 | | - sequence_to_action: dict[str, QAction] = {} |
148 | | - |
149 | | - final_text: dict[QAction, str] = {} |
150 | | - |
151 | | - actions.reverse() |
152 | | - |
153 | | - while len(actions) > 0: |
154 | | - action = actions.pop() |
155 | | - label = action.text() |
156 | | - _, weights = calculate_weights(label) |
157 | | - |
158 | | - chosen_char = None |
159 | | - |
160 | | - # Try candidates, starting from highest weight |
161 | | - for weight in sorted(weights.keys(), reverse=True): |
162 | | - c = weights[weight].lower() |
163 | | - other = sequence_to_action.get(c) |
164 | | - |
165 | | - if other is None: |
166 | | - chosen_char = c |
167 | | - sequence_to_action[c] = action |
168 | | - break |
169 | | - else: |
170 | | - # Compare weights with existing action |
171 | | - other_max, _ = calculate_weights(remove_accelerator_marker(other.text())) |
172 | | - if weight > other_max: |
173 | | - # Take over from weaker action |
174 | | - actions.append(other) |
175 | | - sequence_to_action[c] = action |
176 | | - chosen_char = c |
177 | | - |
178 | | - # Apply mnemonic if found |
179 | | - if chosen_char: |
180 | | - plain = remove_accelerator_marker(label) |
181 | | - new_label = insert_mnemonic(plain, chosen_char) |
182 | | - final_text[action] = new_label |
183 | | - else: |
184 | | - # No mnemonic assigned → clean text |
185 | | - final_text[action] = remove_accelerator_marker(label) |
186 | | - |
187 | | - for a, t in final_text.items(): |
188 | | - a.setText(t) |
189 | | - |
190 | | - |
191 | 56 | class MainMenuBar(QMenuBar): |
192 | 57 | file_menu: QMenu |
193 | 58 | open_library_action: QAction |
|
0 commit comments