LED-mapper/settings.py
Danil Iarosh 65d7580977 Add tree context menu hotkeys and support renaming lines in GUI
- Добавлены локальные горячие клавиши для действий в дереве: F2 для переименования, Ctrl+Del для удаления, Ctrl+M для объединения блоков.
- Изменен дефолтный хоткей для удаления всех фиксаций (clear_all) на Shift+Del.
- Реализована функция rename_line для переименования линий по аналогии с блоками.
- В контекстное меню дерева добавлено отображение горячих клавиш справа от названий команд.
- Добавлены интеграционные тесты для верификации переименования линий и регистрации хоткеев.
2026-07-31 12:35:15 +03:00

143 lines
6.1 KiB
Python

from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from pathlib import Path
from PySide6.QtCore import QStandardPaths
from PySide6.QtGui import QKeySequence
SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [
("open", "Открыть", "Ctrl+O"),
("save", "Сохранить", "Ctrl+S"),
("save_as", "Сохранить как", "Ctrl+Shift+S"),
("generate_script", "Сгенерировать скрипт", "Ctrl+G"),
("settings", "Параметры", "Ctrl+,"),
("undo", "Отменить", "Ctrl+Z"),
("redo", "Повторить", "Ctrl+Y"),
("delete", "Удалить фиксацию", "Del"),
("delete_points", "Удалить точки", "Ctrl+Del"),
("clear_all", "Удалить все", "Shift+Del"),
("clear_selection", "Очистить выбор", "Esc"),
("fit_view", "Показать всё", "Ctrl+0"),
("grid", "Сетка 34 мм", "G"),
("direction_arrows", "Стрелки направления", "Ctrl+Shift+A"),
("led_ids", "Отображать id светодиодов", "I"),
("background", "Подложка", "Ctrl+B"),
("background_visible", "Показать подложку", "Ctrl+Alt+B"),
("background_edit", "Редактировать подложку", "Ctrl+Shift+I"),
("background_reset_scale", "Сбросить масштаб подложки", "Ctrl+Alt+0"),
("background_clear", "Удалить подложку", "Ctrl+Alt+Backspace"),
("toggle_sidebar", "Скрыть боковую панель", "F9"),
("add_line", "Новая линия", "Ctrl+L"),
("add_led", "Новый светодиод", "N"),
("add_block", "Блок из выбора", "Ctrl+Shift+L"),
("auto_blocks", "Автоблоки", "Ctrl+Shift+B"),
("clockwise", "По часовой", "Ctrl+Alt+R"),
("counterclockwise", "Против часовой", "Ctrl+Alt+Shift+R"),
("rebuild_route", "Пересобрать", "Ctrl+Shift+R"),
("brush", "Кисть", "B"),
]
DEFAULT_SHORTCUTS = {key: shortcut for key, _title, shortcut in SHORTCUT_DEFINITIONS}
SETTINGS_VERSION = 2
@dataclass
class AppSettings:
version: int = SETTINGS_VERSION
theme: str = "system"
show_grid_by_default: bool = False
show_direction_arrows_by_default: bool = True
show_led_ids_by_default: bool = True
zoom_focus_pull: int = 35
pan_mode: str = "middle_space"
instant_space_pan: bool = False
brush_radius: int = 18
default_output_dir: str = ""
confirm_partial_generation: bool = True
sidebar_inner_sizes: list[int] | None = None
shortcuts: dict[str, str] | None = None
@classmethod
def load(cls) -> "AppSettings":
path = settings_path()
if not path.exists():
return cls()
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return cls()
settings = cls()
saved_version = clamp_int(data.get("version", 1), 1, SETTINGS_VERSION, 1)
for key, value in data.items():
if hasattr(settings, key):
setattr(settings, key, value)
settings.version = SETTINGS_VERSION
settings.zoom_focus_pull = clamp_int(settings.zoom_focus_pull, 0, 100, cls.zoom_focus_pull)
settings.brush_radius = clamp_int(settings.brush_radius, 4, 80, cls.brush_radius)
if settings.theme not in {"system", "light", "dark"}:
settings.theme = cls.theme
if settings.pan_mode not in {"middle", "space", "middle_space"}:
settings.pan_mode = cls.pan_mode
settings.instant_space_pan = bool(settings.instant_space_pan)
settings.show_grid_by_default = bool(settings.show_grid_by_default)
settings.show_direction_arrows_by_default = bool(settings.show_direction_arrows_by_default)
settings.show_led_ids_by_default = bool(settings.show_led_ids_by_default)
settings.confirm_partial_generation = bool(settings.confirm_partial_generation)
settings.default_output_dir = str(settings.default_output_dir or "")
settings.sidebar_inner_sizes = normalize_splitter_sizes(settings.sidebar_inner_sizes, [360, 420])
settings.shortcuts = normalize_shortcuts(
settings.shortcuts,
fill_new_defaults=saved_version < SETTINGS_VERSION,
)
return settings
def save(self) -> None:
path = settings_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(asdict(self), ensure_ascii=False, indent=2), encoding="utf-8")
def shortcut_for(self, key: str) -> str:
if self.shortcuts is None:
return DEFAULT_SHORTCUTS.get(key, "")
return self.shortcuts.get(key, DEFAULT_SHORTCUTS.get(key, ""))
def settings_path() -> Path:
config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation)
if not config_dir:
config_dir = str(Path.home() / ".led_mapper")
return Path(config_dir) / "settings.json"
def clamp_int(value, minimum: int, maximum: int, fallback: int) -> int: # noqa: ANN001
try:
number = int(value)
except (TypeError, ValueError):
return fallback
return max(minimum, min(maximum, number))
def normalize_splitter_sizes(value, fallback: list[int]) -> list[int]: # noqa: ANN001
if not isinstance(value, list) or len(value) != len(fallback):
return fallback[:]
sizes = [clamp_int(size, 80, 10000, fallback[index]) for index, size in enumerate(value)]
if sum(sizes) <= 0:
return fallback[:]
return sizes
def normalize_shortcuts(value, *, fill_new_defaults: bool = False) -> dict[str, str]: # noqa: ANN001
source = value if isinstance(value, dict) else {}
shortcuts: dict[str, str] = {}
for key, default in DEFAULT_SHORTCUTS.items():
raw = source.get(key, default)
if raw is None or (fill_new_defaults and raw == "" and default):
raw = ""
if fill_new_defaults and default:
raw = default
sequence = QKeySequence(str(raw))
shortcuts[key] = sequence.toString(QKeySequence.SequenceFormat.PortableText)
return shortcuts