Skip to content

Commit 54f9c93

Browse files
refactor: auto mnemonics (#1083)
1 parent 1132026 commit 54f9c93

File tree

3 files changed

+146
-139
lines changed

3 files changed

+146
-139
lines changed

src/tagstudio/qt/main_window.py

Lines changed: 1 addition & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel
3939
from tagstudio.qt.flowlayout import FlowLayout
4040
from tagstudio.qt.helpers.color_overlay import theme_fg_overlay
41+
from tagstudio.qt.mnemonics import assign_mnemonics
4142
from tagstudio.qt.pagination import Pagination
4243
from tagstudio.qt.platform_strings import trash_term
4344
from tagstudio.qt.resource_manager import ResourceManager
@@ -52,142 +53,6 @@
5253
logger = structlog.get_logger(__name__)
5354

5455

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-
19156
class MainMenuBar(QMenuBar):
19257
file_menu: QMenu
19358
open_library_action: QAction

src/tagstudio/qt/mnemonics.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# Licensed under the GPL-3.0 License.
2+
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
3+
4+
5+
from PySide6.QtGui import QAction
6+
from PySide6.QtWidgets import QMenu
7+
8+
9+
def remove_mnemonic_marker(label: str) -> str:
10+
"""Remove existing accelerator markers (&) from a label."""
11+
result = ""
12+
skip = False
13+
for i, ch in enumerate(label):
14+
if skip:
15+
skip = False
16+
continue
17+
if ch == "&":
18+
# escaped ampersand "&&"
19+
if i + 1 < len(label) and label[i + 1] == "&":
20+
result += "&"
21+
skip = True
22+
# otherwise skip this '&'
23+
continue
24+
result += ch
25+
return result
26+
27+
28+
# Additional weight for first character in string
29+
FIRST_CHARACTER_EXTRA_WEIGHT = 50
30+
# Additional weight for the beginning of a word
31+
WORD_BEGINNING_EXTRA_WEIGHT = 50
32+
# Additional weight for a 'wanted' accelerator ie string with '&'
33+
WANTED_ACCEL_EXTRA_WEIGHT = 150
34+
35+
36+
def calculate_weights(text: str):
37+
weights: dict[int, str] = {}
38+
39+
pos = 0
40+
start_character = True
41+
wanted_character = False
42+
43+
while pos < len(text):
44+
c = text[pos]
45+
46+
# skip non typeable characters
47+
if not c.isalnum() and c != "&":
48+
start_character = True
49+
pos += 1
50+
continue
51+
52+
weight = 1
53+
54+
# add special weight to first character
55+
if pos == 0:
56+
weight += FIRST_CHARACTER_EXTRA_WEIGHT
57+
elif start_character: # add weight to word beginnings
58+
weight += WORD_BEGINNING_EXTRA_WEIGHT
59+
start_character = False
60+
61+
# add weight to characters that have an & beforehand
62+
if wanted_character:
63+
weight += WANTED_ACCEL_EXTRA_WEIGHT
64+
wanted_character = False
65+
66+
# add decreasing weight to left characters
67+
if pos < 50:
68+
weight += 50 - pos
69+
70+
# try to preserve the wanted accelerators
71+
if c == "&" and (pos != len(text) - 1 and text[pos + 1] != "&" and text[pos + 1].isalnum()):
72+
wanted_character = True
73+
pos += 1
74+
continue
75+
76+
while weight in weights:
77+
weight += 1
78+
79+
if c != "&":
80+
weights[weight] = c
81+
82+
pos += 1
83+
84+
# update our maximum weight
85+
max_weight = 0 if len(weights) == 0 else max(weights.keys())
86+
return max_weight, weights
87+
88+
89+
def insert_mnemonic(label: str, char: str) -> str:
90+
pos = label.lower().find(char)
91+
if pos >= 0:
92+
return label[:pos] + "&" + label[pos:]
93+
return label
94+
95+
96+
def assign_mnemonics(menu: QMenu):
97+
# Collect actions
98+
actions = [a for a in menu.actions() if not a.isSeparator()]
99+
100+
# Sequence map: mnemonic key -> QAction
101+
sequence_to_action: dict[str, QAction] = {}
102+
103+
final_text: dict[QAction, str] = {}
104+
105+
actions.reverse()
106+
107+
while len(actions) > 0:
108+
action = actions.pop()
109+
label = action.text()
110+
_, weights = calculate_weights(label)
111+
112+
chosen_char = None
113+
114+
# Try candidates, starting from highest weight
115+
for weight in sorted(weights.keys(), reverse=True):
116+
c = weights[weight].lower()
117+
other = sequence_to_action.get(c)
118+
119+
if other is None:
120+
chosen_char = c
121+
sequence_to_action[c] = action
122+
break
123+
else:
124+
# Compare weights with existing action
125+
other_max, _ = calculate_weights(remove_mnemonic_marker(other.text()))
126+
if weight > other_max:
127+
# Take over from weaker action
128+
actions.append(other)
129+
sequence_to_action[c] = action
130+
chosen_char = c
131+
132+
# Apply mnemonic if found
133+
if chosen_char:
134+
plain = remove_mnemonic_marker(label)
135+
new_label = insert_mnemonic(plain, chosen_char)
136+
final_text[action] = new_label
137+
else:
138+
# No mnemonic assigned → clean text
139+
final_text[action] = remove_mnemonic_marker(label)
140+
141+
for a, t in final_text.items():
142+
a.setText(t)

src/tagstudio/qt/translations.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import structlog
77
import ujson
88

9+
from tagstudio.qt.mnemonics import remove_mnemonic_marker
10+
911
logger = structlog.get_logger(__name__)
1012

1113
DEFAULT_TRANSLATION = "en"
@@ -61,9 +63,7 @@ def change_language(self, lang: str):
6163
self._strings = self.__get_translation_dict(lang)
6264
if system() == "Darwin":
6365
for k, v in self._strings.items():
64-
self._strings[k] = (
65-
v.replace("&&", "<ESC_AMP>").replace("&", "", 1).replace("<ESC_AMP>", "&&")
66-
)
66+
self._strings[k] = remove_mnemonic_marker(v)
6767

6868
def __format(self, text: str, **kwargs) -> str:
6969
try:

0 commit comments

Comments
 (0)