Skip to content

Commit c16445f

Browse files
feat: auto generation of mnemonics (#1082)
1 parent 781aca2 commit c16445f

File tree

1 file changed

+142
-0
lines changed

1 file changed

+142
-0
lines changed

src/tagstudio/qt/main_window.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,142 @@
5252
logger = structlog.get_logger(__name__)
5353

5454

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+
55191
class MainMenuBar(QMenuBar):
56192
file_menu: QMenu
57193
open_library_action: QAction
@@ -167,6 +303,7 @@ def setup_file_menu(self):
167303

168304
self.file_menu.addSeparator()
169305

306+
assign_mnemonics(self.file_menu)
170307
self.addMenu(self.file_menu)
171308

172309
def setup_edit_menu(self):
@@ -294,6 +431,7 @@ def setup_edit_menu(self):
294431
self.color_manager_action.setEnabled(False)
295432
self.edit_menu.addAction(self.color_manager_action)
296433

434+
assign_mnemonics(self.edit_menu)
297435
self.addMenu(self.edit_menu)
298436

299437
def setup_view_menu(self):
@@ -338,6 +476,7 @@ def setup_view_menu(self):
338476

339477
self.view_menu.addSeparator()
340478

479+
assign_mnemonics(self.view_menu)
341480
self.addMenu(self.view_menu)
342481

343482
def setup_tools_menu(self):
@@ -371,6 +510,7 @@ def setup_tools_menu(self):
371510
self.clear_thumb_cache_action.setEnabled(False)
372511
self.tools_menu.addAction(self.clear_thumb_cache_action)
373512

513+
assign_mnemonics(self.tools_menu)
374514
self.addMenu(self.tools_menu)
375515

376516
def setup_macros_menu(self):
@@ -380,6 +520,7 @@ def setup_macros_menu(self):
380520
self.folders_to_tags_action.setEnabled(False)
381521
self.macros_menu.addAction(self.folders_to_tags_action)
382522

523+
assign_mnemonics(self.macros_menu)
383524
self.addMenu(self.macros_menu)
384525

385526
def setup_help_menu(self):
@@ -388,6 +529,7 @@ def setup_help_menu(self):
388529
self.about_action = QAction(Translations["menu.help.about"], self)
389530
self.help_menu.addAction(self.about_action)
390531

532+
assign_mnemonics(self.help_menu)
391533
self.addMenu(self.help_menu)
392534

393535
def rebuild_open_recent_library_menu(

0 commit comments

Comments
 (0)