LED-mapper/settings.py
Danil Iarosh d8c95a15e2 feat: Improve editor overlays and stability
- Добавлена подложка с видимостью, прозрачностью, масштабом, перемещением и редактированием через отдельное меню.

- Добавлен инструмент создания свободных светодиодов и редактирование X/Y для вручную созданных точек в таблице.

- Упрощена таблица выбранных LED: единый ID, координаты X/Y и UID в конце.

- Добавлены настраиваемые горячие клавиши для всех действий и переключатель отображения id светодиодов.

- Улучшена отрисовка линий: стрелки направления, изгибы при полном и частичном визуальном наложении сегментов.

- Оптимизировано переключение активных линий, выбор точек и поиск наложений соединений для снижения подвисаний.

- Исправлена логика ручных LED: row/col рассчитываются по сетке 34 мм, а ручные точки не сдвигают якорь сетки.

- Обновлена сборка EXE: имя использует тег, короткий хэш или дату/время для dirty-репозитория.

- Расширен smoke-набор до 113 проверок для новых функций и регрессий.
2026-05-25 13:39:59 +03:00

130 lines
5.5 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"),
("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
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.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_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