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", "Удалить все", "Ctrl+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"), ("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