5252logger = 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+
55191class 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