LED-mapper/settings.py
Danil Iarosh 0b24cbd83c feat: add LED copy and paste in edit mode
- добавить команды копирования и вставки выбранных светодиодов в режиме редактирования
- назначить на новые команды настраиваемые сочетания Ctrl+C и Ctrl+V
- создавать свободные копии с уникальными идентификаторами и каскадным смещением по шагу сетки
- сохранять взаимное расположение и пользовательские поля копируемых точек
- объединить вставку группы в одну операцию Undo с восстановлением выделения через Redo
- не перехватывать стандартное копирование и вставку при редактировании текста и координат
- добавить пункты меню, иконки, документацию и проверки, расширив QA до 164 сценариев
2026-09-09 08:30:30 +03:00

178 lines
7.3 KiB
Python

from __future__ import annotations
import json
import math
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]] = [
("new", "Создать", "Ctrl+N"),
("open", "Открыть", "Ctrl+O"),
("save", "Сохранить", "Ctrl+S"),
("save_as", "Сохранить как", "Ctrl+Shift+S"),
("generate_script", "Сгенерировать скрипт", "Ctrl+G"),
("settings", "Параметры", "Ctrl+,"),
("undo", "Отменить", "Ctrl+Z"),
("redo", "Повторить", "Ctrl+Y"),
("copy_leds", "Копировать светодиоды", "Ctrl+C"),
("paste_leds", "Вставить светодиоды", "Ctrl+V"),
("delete", "Удалить фиксацию", "Del"),
("delete_points", "Удалить точки", "Ctrl+Del"),
("clear_all", "Удалить все", "Shift+Del"),
("clear_selection", "Очистить выбор", "Esc"),
("fit_view", "Показать всё", "Ctrl+0"),
("grid", "Сетка", "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 = 3
SHORTCUT_DEFAULTS_MIGRATION_VERSION = 2
DEFAULT_GRID_STEP_MM = 34.0
MIN_GRID_STEP_MM = 1.0
MAX_GRID_STEP_MM = 1000.0
DEFAULT_LED_POINT_DIAMETER_PX = 9
MIN_LED_POINT_DIAMETER_PX = 1
MAX_LED_POINT_DIAMETER_PX = 64
@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
grid_step_mm: float = DEFAULT_GRID_STEP_MM
led_point_diameter_px: int = DEFAULT_LED_POINT_DIAMETER_PX
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.grid_step_mm = clamp_float(
settings.grid_step_mm,
MIN_GRID_STEP_MM,
MAX_GRID_STEP_MM,
DEFAULT_GRID_STEP_MM,
)
settings.led_point_diameter_px = clamp_int(
settings.led_point_diameter_px,
MIN_LED_POINT_DIAMETER_PX,
MAX_LED_POINT_DIAMETER_PX,
DEFAULT_LED_POINT_DIAMETER_PX,
)
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 < SHORTCUT_DEFAULTS_MIGRATION_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 clamp_float(value, minimum: float, maximum: float, fallback: float) -> float: # noqa: ANN001
try:
number = float(value)
except (TypeError, ValueError):
return fallback
if not math.isfinite(number):
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