mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-02-03 00:29:14 +00:00
refactor: auto mnemonics (#1083)
This commit is contained in:
@@ -38,6 +38,7 @@ from tagstudio.core.library.alchemy.enums import SortingModeEnum
|
||||
from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel
|
||||
from tagstudio.qt.flowlayout import FlowLayout
|
||||
from tagstudio.qt.helpers.color_overlay import theme_fg_overlay
|
||||
from tagstudio.qt.mnemonics import assign_mnemonics
|
||||
from tagstudio.qt.pagination import Pagination
|
||||
from tagstudio.qt.platform_strings import trash_term
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
@@ -52,142 +53,6 @@ if typing.TYPE_CHECKING:
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def remove_accelerator_marker(label: str) -> str:
|
||||
"""Remove existing accelerator markers (&) from a label."""
|
||||
result = ""
|
||||
skip = False
|
||||
for i, ch in enumerate(label):
|
||||
if skip:
|
||||
skip = False
|
||||
continue
|
||||
if ch == "&":
|
||||
# escaped ampersand "&&"
|
||||
if i + 1 < len(label) and label[i + 1] == "&":
|
||||
result += "&"
|
||||
skip = True
|
||||
# otherwise skip this '&'
|
||||
continue
|
||||
result += ch
|
||||
return result
|
||||
|
||||
|
||||
# Additional weight for first character in string
|
||||
FIRST_CHARACTER_EXTRA_WEIGHT = 50
|
||||
# Additional weight for the beginning of a word
|
||||
WORD_BEGINNING_EXTRA_WEIGHT = 50
|
||||
# Additional weight for a 'wanted' accelerator ie string with '&'
|
||||
WANTED_ACCEL_EXTRA_WEIGHT = 150
|
||||
|
||||
|
||||
def calculate_weights(text: str):
|
||||
weights: dict[int, str] = {}
|
||||
|
||||
pos = 0
|
||||
start_character = True
|
||||
wanted_character = False
|
||||
|
||||
while pos < len(text):
|
||||
c = text[pos]
|
||||
|
||||
# skip non typeable characters
|
||||
if not c.isalnum() and c != "&":
|
||||
start_character = True
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
weight = 1
|
||||
|
||||
# add special weight to first character
|
||||
if pos == 0:
|
||||
weight += FIRST_CHARACTER_EXTRA_WEIGHT
|
||||
elif start_character: # add weight to word beginnings
|
||||
weight += WORD_BEGINNING_EXTRA_WEIGHT
|
||||
start_character = False
|
||||
|
||||
# add weight to characters that have an & beforehand
|
||||
if wanted_character:
|
||||
weight += WANTED_ACCEL_EXTRA_WEIGHT
|
||||
wanted_character = False
|
||||
|
||||
# add decreasing weight to left characters
|
||||
if pos < 50:
|
||||
weight += 50 - pos
|
||||
|
||||
# try to preserve the wanted accelerators
|
||||
if c == "&" and (pos != len(text) - 1 and text[pos + 1] != "&" and text[pos + 1].isalnum()):
|
||||
wanted_character = True
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
while weight in weights:
|
||||
weight += 1
|
||||
|
||||
if c != "&":
|
||||
weights[weight] = c
|
||||
|
||||
pos += 1
|
||||
|
||||
# update our maximum weight
|
||||
max_weight = 0 if len(weights) == 0 else max(weights.keys())
|
||||
return max_weight, weights
|
||||
|
||||
|
||||
def insert_mnemonic(label: str, char: str) -> str:
|
||||
pos = label.lower().find(char)
|
||||
if pos >= 0:
|
||||
return label[:pos] + "&" + label[pos:]
|
||||
return label
|
||||
|
||||
|
||||
def assign_mnemonics(menu: QMenu):
|
||||
# Collect actions
|
||||
actions = [a for a in menu.actions() if not a.isSeparator()]
|
||||
|
||||
# Sequence map: mnemonic key -> QAction
|
||||
sequence_to_action: dict[str, QAction] = {}
|
||||
|
||||
final_text: dict[QAction, str] = {}
|
||||
|
||||
actions.reverse()
|
||||
|
||||
while len(actions) > 0:
|
||||
action = actions.pop()
|
||||
label = action.text()
|
||||
_, weights = calculate_weights(label)
|
||||
|
||||
chosen_char = None
|
||||
|
||||
# Try candidates, starting from highest weight
|
||||
for weight in sorted(weights.keys(), reverse=True):
|
||||
c = weights[weight].lower()
|
||||
other = sequence_to_action.get(c)
|
||||
|
||||
if other is None:
|
||||
chosen_char = c
|
||||
sequence_to_action[c] = action
|
||||
break
|
||||
else:
|
||||
# Compare weights with existing action
|
||||
other_max, _ = calculate_weights(remove_accelerator_marker(other.text()))
|
||||
if weight > other_max:
|
||||
# Take over from weaker action
|
||||
actions.append(other)
|
||||
sequence_to_action[c] = action
|
||||
chosen_char = c
|
||||
|
||||
# Apply mnemonic if found
|
||||
if chosen_char:
|
||||
plain = remove_accelerator_marker(label)
|
||||
new_label = insert_mnemonic(plain, chosen_char)
|
||||
final_text[action] = new_label
|
||||
else:
|
||||
# No mnemonic assigned → clean text
|
||||
final_text[action] = remove_accelerator_marker(label)
|
||||
|
||||
for a, t in final_text.items():
|
||||
a.setText(t)
|
||||
|
||||
|
||||
class MainMenuBar(QMenuBar):
|
||||
file_menu: QMenu
|
||||
open_library_action: QAction
|
||||
|
||||
142
src/tagstudio/qt/mnemonics.py
Normal file
142
src/tagstudio/qt/mnemonics.py
Normal file
@@ -0,0 +1,142 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from PySide6.QtGui import QAction
|
||||
from PySide6.QtWidgets import QMenu
|
||||
|
||||
|
||||
def remove_mnemonic_marker(label: str) -> str:
|
||||
"""Remove existing accelerator markers (&) from a label."""
|
||||
result = ""
|
||||
skip = False
|
||||
for i, ch in enumerate(label):
|
||||
if skip:
|
||||
skip = False
|
||||
continue
|
||||
if ch == "&":
|
||||
# escaped ampersand "&&"
|
||||
if i + 1 < len(label) and label[i + 1] == "&":
|
||||
result += "&"
|
||||
skip = True
|
||||
# otherwise skip this '&'
|
||||
continue
|
||||
result += ch
|
||||
return result
|
||||
|
||||
|
||||
# Additional weight for first character in string
|
||||
FIRST_CHARACTER_EXTRA_WEIGHT = 50
|
||||
# Additional weight for the beginning of a word
|
||||
WORD_BEGINNING_EXTRA_WEIGHT = 50
|
||||
# Additional weight for a 'wanted' accelerator ie string with '&'
|
||||
WANTED_ACCEL_EXTRA_WEIGHT = 150
|
||||
|
||||
|
||||
def calculate_weights(text: str):
|
||||
weights: dict[int, str] = {}
|
||||
|
||||
pos = 0
|
||||
start_character = True
|
||||
wanted_character = False
|
||||
|
||||
while pos < len(text):
|
||||
c = text[pos]
|
||||
|
||||
# skip non typeable characters
|
||||
if not c.isalnum() and c != "&":
|
||||
start_character = True
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
weight = 1
|
||||
|
||||
# add special weight to first character
|
||||
if pos == 0:
|
||||
weight += FIRST_CHARACTER_EXTRA_WEIGHT
|
||||
elif start_character: # add weight to word beginnings
|
||||
weight += WORD_BEGINNING_EXTRA_WEIGHT
|
||||
start_character = False
|
||||
|
||||
# add weight to characters that have an & beforehand
|
||||
if wanted_character:
|
||||
weight += WANTED_ACCEL_EXTRA_WEIGHT
|
||||
wanted_character = False
|
||||
|
||||
# add decreasing weight to left characters
|
||||
if pos < 50:
|
||||
weight += 50 - pos
|
||||
|
||||
# try to preserve the wanted accelerators
|
||||
if c == "&" and (pos != len(text) - 1 and text[pos + 1] != "&" and text[pos + 1].isalnum()):
|
||||
wanted_character = True
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
while weight in weights:
|
||||
weight += 1
|
||||
|
||||
if c != "&":
|
||||
weights[weight] = c
|
||||
|
||||
pos += 1
|
||||
|
||||
# update our maximum weight
|
||||
max_weight = 0 if len(weights) == 0 else max(weights.keys())
|
||||
return max_weight, weights
|
||||
|
||||
|
||||
def insert_mnemonic(label: str, char: str) -> str:
|
||||
pos = label.lower().find(char)
|
||||
if pos >= 0:
|
||||
return label[:pos] + "&" + label[pos:]
|
||||
return label
|
||||
|
||||
|
||||
def assign_mnemonics(menu: QMenu):
|
||||
# Collect actions
|
||||
actions = [a for a in menu.actions() if not a.isSeparator()]
|
||||
|
||||
# Sequence map: mnemonic key -> QAction
|
||||
sequence_to_action: dict[str, QAction] = {}
|
||||
|
||||
final_text: dict[QAction, str] = {}
|
||||
|
||||
actions.reverse()
|
||||
|
||||
while len(actions) > 0:
|
||||
action = actions.pop()
|
||||
label = action.text()
|
||||
_, weights = calculate_weights(label)
|
||||
|
||||
chosen_char = None
|
||||
|
||||
# Try candidates, starting from highest weight
|
||||
for weight in sorted(weights.keys(), reverse=True):
|
||||
c = weights[weight].lower()
|
||||
other = sequence_to_action.get(c)
|
||||
|
||||
if other is None:
|
||||
chosen_char = c
|
||||
sequence_to_action[c] = action
|
||||
break
|
||||
else:
|
||||
# Compare weights with existing action
|
||||
other_max, _ = calculate_weights(remove_mnemonic_marker(other.text()))
|
||||
if weight > other_max:
|
||||
# Take over from weaker action
|
||||
actions.append(other)
|
||||
sequence_to_action[c] = action
|
||||
chosen_char = c
|
||||
|
||||
# Apply mnemonic if found
|
||||
if chosen_char:
|
||||
plain = remove_mnemonic_marker(label)
|
||||
new_label = insert_mnemonic(plain, chosen_char)
|
||||
final_text[action] = new_label
|
||||
else:
|
||||
# No mnemonic assigned → clean text
|
||||
final_text[action] = remove_mnemonic_marker(label)
|
||||
|
||||
for a, t in final_text.items():
|
||||
a.setText(t)
|
||||
@@ -6,6 +6,8 @@ from typing import Any
|
||||
import structlog
|
||||
import ujson
|
||||
|
||||
from tagstudio.qt.mnemonics import remove_mnemonic_marker
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
DEFAULT_TRANSLATION = "en"
|
||||
@@ -61,9 +63,7 @@ class Translator:
|
||||
self._strings = self.__get_translation_dict(lang)
|
||||
if system() == "Darwin":
|
||||
for k, v in self._strings.items():
|
||||
self._strings[k] = (
|
||||
v.replace("&&", "<ESC_AMP>").replace("&", "", 1).replace("<ESC_AMP>", "&&")
|
||||
)
|
||||
self._strings[k] = remove_mnemonic_marker(v)
|
||||
|
||||
def __format(self, text: str, **kwargs) -> str:
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user