diff --git a/LEDMapper.spec b/LEDMapper.spec index 89d8fe7..0395bc9 100644 --- a/LEDMapper.spec +++ b/LEDMapper.spec @@ -40,13 +40,17 @@ a = Analysis( [str(spec_dir / 'main.py')], pathex=[str(spec_dir), str(spec_dir.parent)], binaries=[], - datas=[], + datas=[(str(spec_dir / 'assets'), 'assets')], hiddenimports=[ 'canvas', 'icons', + 'localization', 'main_window', 'models', 'panel_mapping_generator', + 'settings', + 'settings_dialog', + 'theme', 'unfolding', ], hookspath=[], diff --git a/assets/input-arrow-down-dark.svg b/assets/input-arrow-down-dark.svg new file mode 100644 index 0000000..413bcc4 --- /dev/null +++ b/assets/input-arrow-down-dark.svg @@ -0,0 +1 @@ + diff --git a/assets/input-arrow-down-light.svg b/assets/input-arrow-down-light.svg new file mode 100644 index 0000000..46d784d --- /dev/null +++ b/assets/input-arrow-down-light.svg @@ -0,0 +1 @@ + diff --git a/assets/input-arrow-up-dark.svg b/assets/input-arrow-up-dark.svg new file mode 100644 index 0000000..690c853 --- /dev/null +++ b/assets/input-arrow-up-dark.svg @@ -0,0 +1 @@ + diff --git a/assets/input-arrow-up-light.svg b/assets/input-arrow-up-light.svg new file mode 100644 index 0000000..bc439cc --- /dev/null +++ b/assets/input-arrow-up-light.svg @@ -0,0 +1 @@ + diff --git a/assets/scrollbar-arrow-down-dark.svg b/assets/scrollbar-arrow-down-dark.svg new file mode 100644 index 0000000..a96bafc --- /dev/null +++ b/assets/scrollbar-arrow-down-dark.svg @@ -0,0 +1 @@ + diff --git a/assets/scrollbar-arrow-down-light.svg b/assets/scrollbar-arrow-down-light.svg new file mode 100644 index 0000000..46a413a --- /dev/null +++ b/assets/scrollbar-arrow-down-light.svg @@ -0,0 +1 @@ + diff --git a/assets/scrollbar-arrow-left-dark.svg b/assets/scrollbar-arrow-left-dark.svg new file mode 100644 index 0000000..c53c0f9 --- /dev/null +++ b/assets/scrollbar-arrow-left-dark.svg @@ -0,0 +1 @@ + diff --git a/assets/scrollbar-arrow-left-light.svg b/assets/scrollbar-arrow-left-light.svg new file mode 100644 index 0000000..70e3221 --- /dev/null +++ b/assets/scrollbar-arrow-left-light.svg @@ -0,0 +1 @@ + diff --git a/assets/scrollbar-arrow-right-dark.svg b/assets/scrollbar-arrow-right-dark.svg new file mode 100644 index 0000000..8ded0d2 --- /dev/null +++ b/assets/scrollbar-arrow-right-dark.svg @@ -0,0 +1 @@ + diff --git a/assets/scrollbar-arrow-right-light.svg b/assets/scrollbar-arrow-right-light.svg new file mode 100644 index 0000000..5d2e99c --- /dev/null +++ b/assets/scrollbar-arrow-right-light.svg @@ -0,0 +1 @@ + diff --git a/assets/scrollbar-arrow-up-dark.svg b/assets/scrollbar-arrow-up-dark.svg new file mode 100644 index 0000000..92b028d --- /dev/null +++ b/assets/scrollbar-arrow-up-dark.svg @@ -0,0 +1 @@ + diff --git a/assets/scrollbar-arrow-up-light.svg b/assets/scrollbar-arrow-up-light.svg new file mode 100644 index 0000000..2330500 --- /dev/null +++ b/assets/scrollbar-arrow-up-light.svg @@ -0,0 +1 @@ + diff --git a/canvas.py b/canvas.py index b28d520..bd4fff5 100644 --- a/canvas.py +++ b/canvas.py @@ -49,6 +49,7 @@ class LedCanvas(QGraphicsView): selectionChanged = Signal(list) deleteSelectionRequested = Signal() autoBlockFromPointRequested = Signal(int) + brushStrokeStarted = Signal(bool) brushPointTouched = Signal(int) brushStrokeFinished = Signal() @@ -69,14 +70,21 @@ class LedCanvas(QGraphicsView): self._direction_arrows_visible = True self._point_radius = 4.5 self._brush_radius = 18.0 + self._zoom_focus_pull = 0.35 self._brush_zone_item: QGraphicsEllipseItem | None = None self._grid_visible = False self._grid_step = 34.0 self._raw_bounds: tuple[float, float, float, float] | None = None + self._content_scene_rect: QRectF | None = None self._display_scale = 1.0 self._margin = 40.0 - self._is_middle_button_panning = False + self._theme = "dark" + self._pan_mode = "middle_space" + self._instant_space_pan = False + self._space_pressed = False + self._is_panning = False self._last_pan_pos = None + self._last_mouse_pos = None self._brush_enabled = False self._is_brush_stroke = False self._brush_touched_ids: set[int] = set() @@ -84,6 +92,7 @@ class LedCanvas(QGraphicsView): self._brush_excluded_point_ids: set[int] = set() self._last_brush_viewport_pos = None self._last_selected_ids: set[int] = set() + self.scene().setBackgroundBrush(QBrush(QColor("#2b2b2b"))) self.scene().selectionChanged.connect(self._emit_selection) self.setToolTip("Выделение рамкой: перетащите мышью. Масштаб: колесо. Del: снять фиксацию выбранных LED.") @@ -110,6 +119,40 @@ class LedCanvas(QGraphicsView): def direction_arrows_visible(self) -> bool: return self._direction_arrows_visible + def set_brush_radius(self, radius: int) -> None: + self._brush_radius = float(max(4, min(80, radius))) + if self._brush_zone_item is not None and isValid(self._brush_zone_item): + self.scene().removeItem(self._brush_zone_item) + self._brush_zone_item = None + if self._brush_enabled: + self._ensure_brush_zone() + if self._last_brush_viewport_pos is not None: + self._update_brush_zone(self._last_brush_viewport_pos) + + def set_zoom_focus_pull(self, percent: int) -> None: + self._zoom_focus_pull = max(0.0, min(1.0, percent / 100.0)) + + def set_pan_mode(self, mode: str) -> None: + self._pan_mode = mode if mode in {"middle", "space", "middle_space"} else "middle_space" + if self._pan_mode == "middle" and self._space_pressed: + self._space_pressed = False + self._apply_default_cursor() + + def set_instant_space_pan(self, enabled: bool) -> None: + self._instant_space_pan = enabled + if not enabled and self._is_panning and self._space_pressed: + self._finish_pan() + + def set_theme(self, theme: str) -> None: + next_theme = "dark" if theme == "dark" else "light" + if self._theme == next_theme: + return + self._theme = next_theme + background = "#2b2b2b" if self._theme == "dark" else "#ffffff" + self.scene().setBackgroundBrush(QBrush(QColor(background))) + self._refresh_grid() + self.refresh_styles(redraw_connections=True) + def set_brush_enabled(self, enabled: bool) -> None: self._brush_enabled = enabled self._is_brush_stroke = False @@ -146,6 +189,7 @@ class LedCanvas(QGraphicsView): scene.blockSignals(False) self.setUpdatesEnabled(True) self._raw_bounds = None + self._content_scene_rect = None if self.document is None or not self.document.points: return @@ -163,14 +207,13 @@ class LedCanvas(QGraphicsView): self.items_by_id[point.uid] = item self.scene().addItem(item) - self.scene().setSceneRect( - QRectF( - 0, - 0, - max(max_x - min_x, 1.0) + self._margin * 2, - max(max_y - min_y, 1.0) + self._margin * 2, - ) + self._content_scene_rect = QRectF( + 0, + 0, + max(max_x - min_x, 1.0) + self._margin * 2, + max(max_y - min_y, 1.0) + self._margin * 2, ) + self.scene().setSceneRect(self._content_scene_rect) self._refresh_grid() self.refresh_styles() if reset_view: @@ -239,37 +282,81 @@ class LedCanvas(QGraphicsView): self._emit_selection() def fit_to_content(self) -> None: + if self._content_scene_rect is not None: + self.scene().setSceneRect(self._content_scene_rect) if self.scene() is None or self.sceneRect().isEmpty(): return self.fitInView(self.sceneRect(), Qt.KeepAspectRatio) def wheelEvent(self, event) -> None: # noqa: ANN001 factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15 + viewport_pos = event.position().toPoint() if hasattr(event, "position") else event.pos() + self._zoom_at_viewport_pos(viewport_pos, factor) + if self._brush_enabled: + self._update_brush_zone(viewport_pos) + event.accept() + + def _zoom_at_viewport_pos(self, viewport_pos, factor: float) -> None: # noqa: ANN001 + old_anchor = self.transformationAnchor() + self.setTransformationAnchor(QGraphicsView.NoAnchor) + focus_scene_pos = self.mapToScene(viewport_pos) + current_center = self.mapToScene(self.viewport().rect().center()) self.scale(factor, factor) - if self._brush_enabled and self._last_brush_viewport_pos is not None: - self._update_brush_zone(self._last_brush_viewport_pos) + self._ensure_zoom_padding() + pull_strength = self._zoom_focus_pull + target_center = QPointF( + current_center.x() + (focus_scene_pos.x() - current_center.x()) * pull_strength, + current_center.y() + (focus_scene_pos.y() - current_center.y()) * pull_strength, + ) + self.centerOn(target_center) + self.setTransformationAnchor(old_anchor) + + def _ensure_zoom_padding(self) -> None: + if self._content_scene_rect is None or self.viewport().rect().isEmpty(): + return + visible_scene_rect = self.mapToScene(self.viewport().rect()).boundingRect() + pad_x = visible_scene_rect.width() * 0.5 + pad_y = visible_scene_rect.height() * 0.5 + self.scene().setSceneRect(self._content_scene_rect.adjusted(-pad_x, -pad_y, pad_x, pad_y)) def keyPressEvent(self, event) -> None: # noqa: ANN001 if event.key() == Qt.Key_Delete: self.deleteSelectionRequested.emit() event.accept() return + if event.key() == Qt.Key_Space and not event.isAutoRepeat() and self._can_pan_with_space(): + self._space_pressed = True + if self._instant_space_pan and self._last_mouse_pos is not None: + self._start_pan(self._last_mouse_pos) + self._apply_default_cursor() + event.accept() + return super().keyPressEvent(event) + def keyReleaseEvent(self, event) -> None: # noqa: ANN001 + if event.key() == Qt.Key_Space and not event.isAutoRepeat(): + self._space_pressed = False + if self._is_panning: + self._is_panning = False + self._last_pan_pos = None + self._apply_default_cursor() + event.accept() + return + super().keyReleaseEvent(event) + def mousePressEvent(self, event) -> None: # noqa: ANN001 + self._last_mouse_pos = event.pos() if event.button() == Qt.RightButton: event.accept() return - if event.button() == Qt.MiddleButton: - self._is_middle_button_panning = True - self._last_pan_pos = event.pos() - self.setCursor(Qt.ClosedHandCursor) - self.viewport().setCursor(Qt.ClosedHandCursor) + if self._should_start_pan(event.button()): + self._start_pan(event.pos()) event.accept() return if self._brush_enabled and event.button() == Qt.LeftButton: self._is_brush_stroke = True self._brush_touched_ids.clear() + self.brushStrokeStarted.emit(bool(event.modifiers() & Qt.ShiftModifier)) self._update_brush_zone(event.pos()) self._touch_brush_point(event.pos()) event.accept() @@ -277,7 +364,17 @@ class LedCanvas(QGraphicsView): super().mousePressEvent(event) def mouseMoveEvent(self, event) -> None: # noqa: ANN001 - if self._is_middle_button_panning and self._last_pan_pos is not None: + previous_mouse_pos = self._last_mouse_pos + self._last_mouse_pos = event.pos() + if ( + self._instant_space_pan + and self._space_pressed + and self._can_pan_with_space() + and not self._is_panning + and previous_mouse_pos is not None + ): + self._start_pan(previous_mouse_pos) + if self._is_panning and self._last_pan_pos is not None: delta = event.pos() - self._last_pan_pos self._last_pan_pos = event.pos() self._move_scrollbar(self.horizontalScrollBar(), delta.x()) @@ -296,13 +393,12 @@ class LedCanvas(QGraphicsView): super().mouseMoveEvent(event) def mouseReleaseEvent(self, event) -> None: # noqa: ANN001 + self._last_mouse_pos = event.pos() if event.button() == Qt.RightButton: event.accept() return - if event.button() == Qt.MiddleButton and self._is_middle_button_panning: - self._is_middle_button_panning = False - self._last_pan_pos = None - self._apply_default_cursor() + if self._should_finish_pan(event.button()): + self._finish_pan() event.accept() return if self._brush_enabled and event.button() == Qt.LeftButton and self._is_brush_stroke: @@ -325,10 +421,44 @@ class LedCanvas(QGraphicsView): super().leaveEvent(event) def _apply_default_cursor(self) -> None: - cursor_shape = Qt.CrossCursor if self._brush_enabled else Qt.ArrowCursor + if self._is_panning: + cursor_shape = Qt.ClosedHandCursor + elif self._space_pressed and self._can_pan_with_space(): + cursor_shape = Qt.OpenHandCursor + elif self._brush_enabled: + cursor_shape = Qt.CrossCursor + else: + cursor_shape = Qt.ArrowCursor self.setCursor(cursor_shape) self.viewport().setCursor(cursor_shape) + def _can_pan_with_middle(self) -> bool: + return self._pan_mode in {"middle", "middle_space"} + + def _can_pan_with_space(self) -> bool: + return self._pan_mode in {"space", "middle_space"} + + def _should_start_pan(self, button: Qt.MouseButton) -> bool: + return (button == Qt.MiddleButton and self._can_pan_with_middle()) or ( + button == Qt.LeftButton and self._space_pressed and self._can_pan_with_space() + ) + + def _should_finish_pan(self, button: Qt.MouseButton) -> bool: + return self._is_panning and ( + (button == Qt.MiddleButton and self._can_pan_with_middle()) + or (button == Qt.LeftButton and self._space_pressed and self._can_pan_with_space()) + ) + + def _start_pan(self, pos) -> None: # noqa: ANN001 + self._is_panning = True + self._last_pan_pos = pos + self._apply_default_cursor() + + def _finish_pan(self) -> None: + self._is_panning = False + self._last_pan_pos = None + self._apply_default_cursor() + def contextMenuEvent(self, event) -> None: # noqa: ANN001 item = self.itemAt(event.pos()) point_id = item.point_id if isinstance(item, LedItem) and isValid(item) else self._single_selected_point_id() @@ -514,7 +644,7 @@ class LedCanvas(QGraphicsView): last_col = math.ceil((right_x - origin_x) / self._grid_step) first_row = math.floor((bottom_y - origin_y) / self._grid_step) last_row = math.ceil((top_y - origin_y) / self._grid_step) - pen = QPen(QColor("#334155"), 0.6) + pen = QPen(QColor("#334155" if self._theme == "dark" else "#d1d5db"), 0.6) pen.setCosmetic(True) for col in range(first_col, last_col + 1): diff --git a/icons.py b/icons.py index 281e0a2..320e7d2 100644 --- a/icons.py +++ b/icons.py @@ -25,6 +25,7 @@ LUCIDE_PATHS = { "rotate-cw": '', "save": '', "save-all": '', + "settings": '', "trash-2": '', "undo-2": '', } diff --git a/localization.py b/localization.py new file mode 100644 index 0000000..b511768 --- /dev/null +++ b/localization.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from PySide6.QtWidgets import QDialogButtonBox, QMessageBox, QWidget + + +_DIALOG_BUTTON_TEXTS = { + QDialogButtonBox.StandardButton.Ok: "ОК", + QDialogButtonBox.StandardButton.Cancel: "Отмена", + QDialogButtonBox.StandardButton.Yes: "Да", + QDialogButtonBox.StandardButton.No: "Нет", + QDialogButtonBox.StandardButton.Save: "Сохранить", + QDialogButtonBox.StandardButton.SaveAll: "Сохранить все", + QDialogButtonBox.StandardButton.Open: "Открыть", + QDialogButtonBox.StandardButton.Close: "Закрыть", + QDialogButtonBox.StandardButton.Apply: "Применить", + QDialogButtonBox.StandardButton.Reset: "Сбросить", + QDialogButtonBox.StandardButton.RestoreDefaults: "По умолчанию", + QDialogButtonBox.StandardButton.Discard: "Не сохранять", + QDialogButtonBox.StandardButton.Abort: "Прервать", + QDialogButtonBox.StandardButton.Retry: "Повторить", + QDialogButtonBox.StandardButton.Ignore: "Игнорировать", +} + +_MESSAGE_BUTTON_TEXTS = { + QMessageBox.StandardButton.Ok: "ОК", + QMessageBox.StandardButton.Cancel: "Отмена", + QMessageBox.StandardButton.Yes: "Да", + QMessageBox.StandardButton.No: "Нет", + QMessageBox.StandardButton.Save: "Сохранить", + QMessageBox.StandardButton.SaveAll: "Сохранить все", + QMessageBox.StandardButton.Open: "Открыть", + QMessageBox.StandardButton.Close: "Закрыть", + QMessageBox.StandardButton.Apply: "Применить", + QMessageBox.StandardButton.Reset: "Сбросить", + QMessageBox.StandardButton.RestoreDefaults: "По умолчанию", + QMessageBox.StandardButton.Discard: "Не сохранять", + QMessageBox.StandardButton.Abort: "Прервать", + QMessageBox.StandardButton.Retry: "Повторить", + QMessageBox.StandardButton.Ignore: "Игнорировать", +} + + +def localize_dialog_buttons(button_box: QDialogButtonBox) -> None: + for button in button_box.buttons(): + standard_button = button_box.standardButton(button) + text = _DIALOG_BUTTON_TEXTS.get(standard_button) + if text: + button.setText(text) + + +def localize_message_box(message_box: QMessageBox) -> None: + for button in message_box.buttons(): + standard_button = message_box.standardButton(button) + text = _MESSAGE_BUTTON_TEXTS.get(standard_button) + if text: + button.setText(text) + + +def _show_message( + parent: QWidget | None, + icon: QMessageBox.Icon, + title: str, + text: str, + buttons: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok, + default_button: QMessageBox.StandardButton = QMessageBox.StandardButton.NoButton, +) -> QMessageBox.StandardButton: + message_box = QMessageBox(parent) + message_box.setIcon(icon) + message_box.setWindowTitle(title) + message_box.setText(text) + message_box.setStandardButtons(buttons) + if default_button != QMessageBox.StandardButton.NoButton: + message_box.setDefaultButton(default_button) + localize_message_box(message_box) + return QMessageBox.StandardButton(message_box.exec()) + + +def show_information(parent: QWidget | None, title: str, text: str) -> QMessageBox.StandardButton: + return _show_message(parent, QMessageBox.Icon.Information, title, text) + + +def show_warning(parent: QWidget | None, title: str, text: str) -> QMessageBox.StandardButton: + return _show_message(parent, QMessageBox.Icon.Warning, title, text) + + +def show_critical(parent: QWidget | None, title: str, text: str) -> QMessageBox.StandardButton: + return _show_message(parent, QMessageBox.Icon.Critical, title, text) + + +def ask_question(parent: QWidget | None, title: str, text: str) -> QMessageBox.StandardButton: + return _show_message( + parent, + QMessageBox.Icon.Question, + title, + text, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) diff --git a/main.py b/main.py index 4b4fe2f..1105e70 100644 --- a/main.py +++ b/main.py @@ -12,14 +12,21 @@ if str(APP_DIR) not in sys.path: try: from .icons import lucide_icon from .main_window import MainWindow + from .settings import AppSettings + from .theme import apply_app_theme, effective_theme except ImportError: from icons import lucide_icon from main_window import MainWindow + from settings import AppSettings + from theme import apply_app_theme, effective_theme def main() -> int: app = QApplication(sys.argv) - app_icon = lucide_icon("git-branch", "#e5e7eb", 256) + settings = AppSettings.load() + apply_app_theme(settings.theme) + icon_color = "#111827" if effective_theme(settings.theme) == "light" else "#e5e7eb" + app_icon = lucide_icon("git-branch", icon_color, 256) app.setWindowIcon(app_icon) window = MainWindow() window.setWindowIcon(app_icon) diff --git a/main_window.py b/main_window.py index 7c5c155..d6122c6 100644 --- a/main_window.py +++ b/main_window.py @@ -2,7 +2,7 @@ from __future__ import annotations from copy import deepcopy from pathlib import Path -from PySide6.QtCore import QSignalBlocker, QSize, Qt +from PySide6.QtCore import QEvent, QSignalBlocker, QSize, Qt from PySide6.QtGui import QAction, QColor, QKeySequence from PySide6.QtWidgets import ( QAbstractItemView, @@ -26,19 +26,31 @@ from PySide6.QtWidgets import ( QTreeWidgetItem, QVBoxLayout, QWidget, + QKeySequenceEdit, + QLineEdit, + QPlainTextEdit, + QTextEdit, ) try: from .canvas import LedCanvas from .icons import lucide_icon + from .localization import ask_question, localize_message_box, show_critical, show_information, show_warning from .models import LedBlock, LedLine, MappingDocument from .panel_mapping_generator import generate_panel_mapping_from_document + from .settings import AppSettings + from .settings_dialog import SettingsDialog + from .theme import apply_app_theme, effective_theme from .unfolding import MAX_LINES except ImportError: from canvas import LedCanvas from icons import lucide_icon + from localization import ask_question, localize_message_box, show_critical, show_information, show_warning from models import LedBlock, LedLine, MappingDocument from panel_mapping_generator import generate_panel_mapping_from_document + from settings import AppSettings + from settings_dialog import SettingsDialog + from theme import apply_app_theme, effective_theme from unfolding import MAX_LINES @@ -48,6 +60,37 @@ SIDEBAR_DEFAULT_WIDTH = 260 SIDEBAR_MIN_WIDTH = 180 SIDEBAR_COLLAPSED_WIDTH = 34 MAX_WIDGET_WIDTH = 16777215 +_NATIVE_VIRTUAL_KEY_NAMES = { + 0x08: "Backspace", + 0x09: "Tab", + 0x0D: "Return", + 0x1B: "Esc", + 0x2E: "Del", + 0xBC: ",", +} +_NATIVE_VIRTUAL_KEY_NAMES.update({0x30 + index: str(index) for index in range(10)}) +_NATIVE_VIRTUAL_KEY_NAMES.update({0x41 + index: chr(0x41 + index) for index in range(26)}) +_NATIVE_VIRTUAL_KEY_NAMES.update({0x70 + index: f"F{index + 1}" for index in range(24)}) + + +def physical_key_name(event) -> str: # noqa: ANN001 + native_key = event.nativeVirtualKey() + if native_key in _NATIVE_VIRTUAL_KEY_NAMES: + return _NATIVE_VIRTUAL_KEY_NAMES[native_key] + key = event.key() + if Qt.Key_A <= key <= Qt.Key_Z: + return chr(ord("A") + key - Qt.Key_A) + if Qt.Key_0 <= key <= Qt.Key_9: + return chr(ord("0") + key - Qt.Key_0) + if Qt.Key_F1 <= key <= Qt.Key_F24: + return f"F{key - Qt.Key_F1 + 1}" + if key == Qt.Key_Delete: + return "Del" + if key == Qt.Key_Escape: + return "Esc" + if key == Qt.Key_Comma: + return "," + return QKeySequence(key).toString(QKeySequence.SequenceFormat.PortableText) class SortableTableItem(QTableWidgetItem): @@ -86,11 +129,17 @@ class MainWindow(QMainWindow): self._dirty = False self._undo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = [] self._redo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = [] + self.settings = AppSettings.load() + apply_app_theme(self.settings.theme) self.actions: dict[str, QAction] = {} + self.action_icon_names: dict[str, str] = {} + self.shortcut_action_keys: set[str] = set() self.side_buttons: dict[str, QPushButton] = {} + self.side_button_icon_names: dict[str, str] = {} self.block_append_buttons: dict[tuple[int, int], QPushButton] = {} self.brush_target: tuple[int, int] | None = None self._brush_stroke_started = False + self._brush_append_to_selected = False self._brush_pending_point_ids: list[int] = [] self._brush_pending_point_set: set[int] = set() self.sidebar_splitter: QSplitter | None = None @@ -102,9 +151,11 @@ class MainWindow(QMainWindow): self._sidebar_last_width = SIDEBAR_DEFAULT_WIDTH self.canvas = LedCanvas() + self._apply_canvas_settings() self.canvas.selectionChanged.connect(self._on_selection_changed) self.canvas.deleteSelectionRequested.connect(self.clear_selected_point_assignments) self.canvas.autoBlockFromPointRequested.connect(self.auto_block_from_point) + self.canvas.brushStrokeStarted.connect(self.start_brush_stroke) self.canvas.brushPointTouched.connect(self.add_brush_point_to_block) self.canvas.brushStrokeFinished.connect(self.finish_brush_stroke) self.tree = QTreeWidget() @@ -151,7 +202,8 @@ class MainWindow(QMainWindow): self._build_layout() application = QApplication.instance() if application is not None: - application.focusChanged.connect(lambda _old, _new: self._refresh_ui_state()) + application.focusChanged.connect(self._on_application_focus_changed) + application.installEventFilter(self) self._refresh_ui_state() def _build_actions(self) -> None: @@ -165,10 +217,12 @@ class MainWindow(QMainWindow): ) -> QAction: action = QAction(title, self) if icon_name is not None: - action.setIcon(lucide_icon(icon_name)) + self.action_icon_names[key] = icon_name + action.setIcon(lucide_icon(icon_name, self._icon_color())) if shortcut is not None: - action.setShortcut(shortcut) + action.setShortcut(self._shortcut_sequence(key, shortcut)) action.setShortcutContext(Qt.ApplicationShortcut) + self.shortcut_action_keys.add(key) if tooltip is not None: action.setToolTip(tooltip) action.setStatusTip(tooltip) @@ -179,6 +233,7 @@ class MainWindow(QMainWindow): add_action("open", "Открыть", self.open_file, "folder-open", QKeySequence.Open) add_action("save", "Сохранить", self.save_file, "save", QKeySequence.Save) add_action("save_as", "Сохранить как", self.save_file_as, "save-all", QKeySequence.SaveAs) + add_action("settings", "Параметры", self.open_settings, "settings", QKeySequence("Ctrl+,")) add_action( "generate_script", "Сгенерировать скрипт", @@ -209,6 +264,7 @@ class MainWindow(QMainWindow): add_action("fit_view", "Показать всё", self.canvas.fit_to_content, "eye", QKeySequence("Ctrl+0")) grid_action = add_action("grid", "Сетка 34 мм", self.toggle_grid, "grid-2x2", QKeySequence("G"), "Показать или скрыть сетку 34 мм") grid_action.setCheckable(True) + grid_action.setChecked(self.settings.show_grid_by_default) arrows_action = add_action( "direction_arrows", "Стрелки направления", @@ -218,7 +274,7 @@ class MainWindow(QMainWindow): "Показать или скрыть стрелки направления на линиях", ) arrows_action.setCheckable(True) - arrows_action.setChecked(True) + arrows_action.setChecked(self.settings.show_direction_arrows_by_default) add_action("toggle_sidebar", "Скрыть боковую панель", self.toggle_sidebar, "chevron-right", QKeySequence("F9")) self._build_menu_bar() self.addAction(self.delete_action) @@ -232,6 +288,8 @@ class MainWindow(QMainWindow): file_menu.addAction(self.actions["save_as"]) file_menu.addSeparator() file_menu.addAction(self.actions["generate_script"]) + file_menu.addSeparator() + file_menu.addAction(self.actions["settings"]) edit_menu = menu_bar.addMenu("Правка") edit_menu.addAction(self.actions["undo"]) @@ -245,9 +303,8 @@ class MainWindow(QMainWindow): view_menu.addAction(self.actions["fit_view"]) view_menu.addAction(self.actions["grid"]) view_menu.addAction(self.actions["direction_arrows"]) - - window_menu = menu_bar.addMenu("Окно") - window_menu.addAction(self.actions["toggle_sidebar"]) + view_menu.addSeparator() + view_menu.addAction(self.actions["toggle_sidebar"]) tools_menu = menu_bar.addMenu("Инструменты") tools_menu.addAction(self.actions["add_line"]) @@ -278,12 +335,13 @@ class MainWindow(QMainWindow): "add_line": "file-plus", "auto_blocks": "layers", } + self.side_button_icon_names.update(side_button_icons) for key, title, handler, tooltip in ( ("add_line", "Линия", self.add_line, f"Создать линию, максимум {MAX_LINES}"), ("auto_blocks", "Автоблоки", self.auto_blocks_from_selection, "Разбить выделение на связанные блоки"), ): button = QPushButton(title) - button.setIcon(lucide_icon(side_button_icons[key])) + button.setIcon(lucide_icon(side_button_icons[key], self._icon_color())) button.setMinimumWidth(0) button.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) button.setToolTip(tooltip) @@ -302,7 +360,7 @@ class MainWindow(QMainWindow): collapsed_layout.setContentsMargins(0, 0, 0, 0) collapsed_layout.addStretch(1) self.sidebar_tab = QToolButton() - self.sidebar_tab.setIcon(lucide_icon("chevron-right")) + self.sidebar_tab.setIcon(lucide_icon("chevron-right", self._icon_color())) self.sidebar_tab.setToolTip("Показать боковую панель") self.sidebar_tab.setAutoRaise(True) self.sidebar_tab.setCursor(Qt.PointingHandCursor) @@ -365,6 +423,113 @@ class MainWindow(QMainWindow): visible = True if action is None else action.isChecked() self.canvas.set_direction_arrows_visible(visible) + def open_settings(self) -> None: + dialog = SettingsDialog(self.settings, self) + if dialog.exec() != SettingsDialog.Accepted: + return + self.settings = dialog.settings() + self.settings.save() + apply_app_theme(self.settings.theme) + self._refresh_theme_icons() + self._apply_settings_to_actions() + self._apply_canvas_settings() + self.status.setText("Параметры сохранены.") + self._refresh_ui_state() + + def _apply_settings_to_actions(self) -> None: + self._apply_shortcuts_to_actions() + grid_action = self.actions.get("grid") + if grid_action is not None: + grid_action.setChecked(self.settings.show_grid_by_default) + arrows_action = self.actions.get("direction_arrows") + if arrows_action is not None: + arrows_action.setChecked(self.settings.show_direction_arrows_by_default) + + def _apply_shortcuts_to_actions(self) -> None: + for key in self.shortcut_action_keys: + action = self.actions.get(key) + if action is not None: + action.setShortcut(QKeySequence(self.settings.shortcut_for(key))) + + def _shortcut_sequence(self, key: str, fallback: QKeySequence) -> QKeySequence: + shortcut = self.settings.shortcut_for(key) + return QKeySequence(shortcut) if shortcut else QKeySequence() + + def _apply_canvas_settings(self) -> None: + self.canvas.set_theme(effective_theme(self.settings.theme)) + self.canvas.set_grid_visible(self.settings.show_grid_by_default) + self.canvas.set_direction_arrows_visible(self.settings.show_direction_arrows_by_default) + self.canvas.set_brush_radius(self.settings.brush_radius) + self.canvas.set_zoom_focus_pull(self.settings.zoom_focus_pull) + self.canvas.set_pan_mode(self.settings.pan_mode) + self.canvas.set_instant_space_pan(self.settings.instant_space_pan) + + def eventFilter(self, obj, event) -> bool: # noqa: ANN001, N802 + if event.type() == QEvent.Type.KeyPress and not event.isAutoRepeat(): + if self._trigger_layout_independent_shortcut(event): + return True + return super().eventFilter(obj, event) + + def _trigger_layout_independent_shortcut(self, event) -> bool: # noqa: ANN001 + sequence = self._physical_key_sequence(event) + if not sequence: + return False + focus = self.focusWidget() + modifiers = event.modifiers() + if isinstance(focus, QKeySequenceEdit): + return False + text_input_has_focus = isinstance(focus, (QLineEdit, QTextEdit, QPlainTextEdit, QKeySequenceEdit)) + if text_input_has_focus and not (modifiers & (Qt.ControlModifier | Qt.AltModifier)): + return False + for key in self.shortcut_action_keys: + if self.settings.shortcut_for(key) != sequence: + continue + action = self.actions.get(key) + if action is not None and action.isEnabled(): + action.trigger() + return True + return False + + def _physical_key_sequence(self, event) -> str: # noqa: ANN001 + key_name = physical_key_name(event) + if not key_name: + return "" + modifiers = event.modifiers() + parts: list[str] = [] + if modifiers & Qt.ControlModifier: + parts.append("Ctrl") + if modifiers & Qt.AltModifier: + parts.append("Alt") + if modifiers & Qt.ShiftModifier: + parts.append("Shift") + if modifiers & Qt.MetaModifier: + parts.append("Meta") + parts.append(key_name) + return "+".join(parts) + + def _icon_color(self) -> str: + return "#111827" if effective_theme(self.settings.theme) == "light" else "#e5e7eb" + + def _refresh_theme_icons(self) -> None: + icon_color = self._icon_color() + app_icon = lucide_icon("git-branch", icon_color, 256) + self.setWindowIcon(app_icon) + application = QApplication.instance() + if application is not None: + application.setWindowIcon(app_icon) + for key, icon_name in self.action_icon_names.items(): + action = self.actions.get(key) + if action is not None: + action.setIcon(lucide_icon(icon_name, icon_color)) + for key, icon_name in self.side_button_icon_names.items(): + button = self.side_buttons.get(key) + if button is not None: + button.setIcon(lucide_icon(icon_name, icon_color)) + if self.sidebar_tab is not None: + self.sidebar_tab.setIcon(lucide_icon("chevron-right", icon_color)) + for button in self.block_append_buttons.values(): + button.setIcon(lucide_icon("plus", icon_color)) + def collapse_sidebar(self) -> None: if self.sidebar_splitter is None or self.sidebar_stack is None or self._sidebar_collapsed: return @@ -419,7 +584,7 @@ class MainWindow(QMainWindow): try: loaded.load(path) except Exception as exc: - QMessageBox.critical(self, "Ошибка загрузки", str(exc)) + show_critical(self, "Ошибка загрузки", str(exc)) return self.document = loaded self._dirty = False @@ -454,13 +619,13 @@ class MainWindow(QMainWindow): if isinstance(output_dir, bool): output_dir = None if not self.document.points: - QMessageBox.information(self, "Нет точек", "Откройте карту LED перед генерацией скрипта.") + show_information(self, "Нет точек", "Откройте карту LED перед генерацией скрипта.") self._refresh_ui_state() return assigned_count = self._script_assigned_count() total_count = len(self.document.points) if assigned_count == 0: - QMessageBox.information( + show_information( self, "Нет назначенных LED", "Ни один светодиод схемы не назначен. Назначьте LED в линии и блоки перед генерацией скрипта.", @@ -468,29 +633,36 @@ class MainWindow(QMainWindow): self.status.setText("Генерация отменена: нет назначенных LED.") self._refresh_ui_state() return - if assigned_count < total_count and not self._confirm_partial_script_generation(assigned_count, total_count): + if ( + assigned_count < total_count + and self.settings.confirm_partial_generation + and not self._confirm_partial_script_generation(assigned_count, total_count) + ): self.status.setText("Генерация отменена: пользователь продолжает редактирование.") self._refresh_ui_state() return if output_dir is None: - selected_dir = QFileDialog.getExistingDirectory( - self, - "Выберите папку для генерации скрипта", - str(ROOT_DIR / "panel_mapping_build"), - ) - if not selected_dir: - return - output_dir = selected_dir + if self.settings.default_output_dir: + output_dir = self.settings.default_output_dir + else: + selected_dir = QFileDialog.getExistingDirectory( + self, + "Выберите папку для генерации скрипта", + str(ROOT_DIR / "panel_mapping_build"), + ) + if not selected_dir: + return + output_dir = selected_dir try: result = generate_panel_mapping_from_document(self.document, output_dir) except Exception as exc: - QMessageBox.critical(self, "Ошибка генерации", str(exc)) + show_critical(self, "Ошибка генерации", str(exc)) self.status.setText(f"Ошибка генерации скрипта: {exc}") self._refresh_ui_state() return generated_list = "\n".join(str(path) for path in result.generated_files.values()) - QMessageBox.information( + show_information( self, "Скрипт сгенерирован", ( @@ -522,12 +694,13 @@ class MainWindow(QMainWindow): generate_button = message.addButton("Сгенерировать скрипт", QMessageBox.ButtonRole.AcceptRole) message.addButton("Продолжить редактирование", QMessageBox.ButtonRole.RejectRole) message.setDefaultButton(generate_button) + localize_message_box(message) message.exec() return message.clickedButton() is generate_button def _save_to(self, path: Path) -> None: if len(self.document.lines) > MAX_LINES: - QMessageBox.warning( + show_warning( self, "Карта не сохранена", f"Новая модель поддерживает максимум {MAX_LINES} линии. Сейчас в карте {len(self.document.lines)}.", @@ -543,7 +716,7 @@ class MainWindow(QMainWindow): self.canvas.refresh_styles() self.refresh_tree() self._on_selection_changed(self.canvas.selected_point_ids()) - QMessageBox.critical(self, "Ошибка сохранения", str(exc)) + show_critical(self, "Ошибка сохранения", str(exc)) self._refresh_ui_state() return self._dirty = False @@ -558,7 +731,7 @@ class MainWindow(QMainWindow): def add_line(self) -> None: if len(self.document.lines) >= MAX_LINES: - QMessageBox.information( + show_information( self, "Лимит линий", f"Новая модель поддерживает максимум {MAX_LINES} линии. Удалите лишнюю линию, чтобы создать новую.", @@ -596,7 +769,7 @@ class MainWindow(QMainWindow): return point_ids = self.canvas.selected_point_ids() if not point_ids: - QMessageBox.information(self, "Нет выбора", "Выберите LED-точки для разбиения на блоки.") + show_information(self, "Нет выбора", "Выберите LED-точки для разбиения на блоки.") return preview = self.document.auto_block_preview(point_ids) if not self._confirm_auto_blocks(point_ids, preview): @@ -617,7 +790,7 @@ class MainWindow(QMainWindow): line_id = self.document.lines[0].id self.active_line_id = line_id if line_id is None and len(self.document.lines) >= MAX_LINES: - QMessageBox.information( + show_information( self, "Лимит линий", f"Новая модель поддерживает максимум {MAX_LINES} линии. Удалите лишнюю линию, чтобы создать новую.", @@ -625,7 +798,7 @@ class MainWindow(QMainWindow): return preview = self.document.clockwise_block_from_seed_preview(point_id) if not preview: - QMessageBox.information(self, "Нет блока", "Не удалось найти связанный блок от выбранной LED.") + show_information(self, "Нет блока", "Не удалось найти связанный блок от выбранной LED.") return if not self._confirm_reassignment(preview): return @@ -660,7 +833,7 @@ class MainWindow(QMainWindow): if not line.blocks: self.status.setText("В активной линии нет блоков для пересборки.") return - answer = QMessageBox.question( + answer = ask_question( self, "Пересобрать блоки", "Пересобрать блоки активной линии по физическим соседям и правилу 34 мм?", @@ -744,7 +917,7 @@ class MainWindow(QMainWindow): def clear_all_assignments(self) -> None: if not self.document.lines: return - answer = QMessageBox.question( + answer = ask_question( self, "Удалить все фиксации", "Удалить все линии, блоки и назначенные индексы LED?", @@ -806,7 +979,7 @@ class MainWindow(QMainWindow): label.setMinimumWidth(0) label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) button = QPushButton() - button.setIcon(lucide_icon("plus")) + button.setIcon(lucide_icon("plus", self._icon_color())) button.setFixedSize(22, 20) button.setToolTip("Добавить выбранные LED в конец этого блока") button.clicked.connect(lambda _checked=False, lid=line_id, bid=block_id: self.append_selection_to_block(lid, bid)) @@ -853,23 +1026,43 @@ class MainWindow(QMainWindow): def toggle_brush_mode(self) -> None: action = self.actions.get("brush") enabled = action.isChecked() if action is not None else False - self.brush_target = self._initial_brush_target() + self.brush_target = None self._brush_stroke_started = False + self._brush_append_to_selected = False self.canvas.set_brush_enabled(enabled) self._sync_brush_preview_exclusions() - if enabled and self.brush_target is not None: - line_id, block_id = self.brush_target - block = self._find_block_or_none(line_id, block_id) - block_name = "блок" if block is None else block.name - self.status.setText(f"Кисть включена: рисуйте продолжение для {block_name}.") - elif enabled: - self.status.setText("Кисть включена: начните рисовать, чтобы создать новый блок.") + if enabled: + self.status.setText("Кисть включена: обычный мазок создает новый блок, Shift добавляет в выбранный блок.") else: self.status.setText("Кисть выключена.") self._refresh_ui_state() + def start_brush_stroke(self, append_to_selected: bool) -> None: + self._brush_append_to_selected = append_to_selected + self._brush_stroke_started = False + self._brush_pending_point_ids.clear() + self._brush_pending_point_set.clear() + self.brush_target = self._initial_brush_target() if append_to_selected else None + self._sync_brush_preview_exclusions() + if append_to_selected: + if self.brush_target is None: + self.status.setText("Кисть + Shift: выберите блок, к которому надо добавить LED.") + else: + line_id, block_id = self.brush_target + block = self._find_block_or_none(line_id, block_id) + block_name = "блок" if block is None else block.name + self.status.setText(f"Кисть + Shift: добавление в {block_name}.") + else: + self.status.setText("Кисть: новый мазок создаст новый блок.") + def add_brush_point_to_block(self, point_id: int) -> None: - target = self.brush_target or self._initial_brush_target() + if self._brush_append_to_selected: + target = self.brush_target or self._initial_brush_target() + if target is None: + self._sync_brush_preview_exclusions() + return + else: + target = self.brush_target if target is None: if not self._brush_stroke_started: self._push_undo() @@ -955,6 +1148,7 @@ class MainWindow(QMainWindow): action.setChecked(False) self.canvas.set_brush_enabled(False) self._brush_stroke_started = False + self._brush_append_to_selected = False self._brush_pending_point_ids.clear() self._brush_pending_point_set.clear() self._sync_brush_preview_exclusions() @@ -1266,6 +1460,24 @@ class MainWindow(QMainWindow): and not selected_set.issubset(block_point_set) ) + def _on_application_focus_changed(self, _old: QWidget | None, _new: QWidget | None) -> None: + self._refresh_focus_sensitive_actions() + + def _refresh_focus_sensitive_actions(self) -> None: + point_ids = self.canvas.selected_point_ids() + selected_has_assignment = any( + self.document.get_point(point_id).line_id is not None + or self.document.get_point(point_id).block_id is not None + for point_id in point_ids + ) + enabled = (bool(point_ids) and selected_has_assignment) or self._tree_has_focus_with_item() + action = self.actions.get("delete") + if action is not None: + action.setEnabled(enabled) + button = self.side_buttons.get("delete") + if button is not None: + button.setEnabled(enabled) + def _refresh_window_title(self) -> None: file_name = "без файла" if self.document.path is None else self.document.path.name dirty_mark = "*" if self._dirty else "" @@ -1385,13 +1597,13 @@ class MainWindow(QMainWindow): if not foreign_points and not assigned_points: return True if assigned_points and not foreign_points: - answer = QMessageBox.question( + answer = ask_question( self, "Перезаписать блок", f"{len(assigned_points)} выбранных LED уже находятся в блоках. Перезаписать их новым блоком?", ) return answer == QMessageBox.StandardButton.Yes - answer = QMessageBox.question( + answer = ask_question( self, "Переназначить LED", f"{len(foreign_points)} выбранных LED уже находятся в других линиях. Переназначить их в активную линию?", @@ -1410,7 +1622,7 @@ class MainWindow(QMainWindow): assigned = sum(1 for point_id in point_ids if self.document.get_point(point_id).line_id is not None) if assigned == 0 and len(preview) == 1: return True - answer = QMessageBox.question( + answer = ask_question( self, "Создать автоблоки", f"Будет создано блоков: {len(preview)}, LED: {sum(len(group) for group in preview)}. " @@ -1419,7 +1631,7 @@ class MainWindow(QMainWindow): return answer == QMessageBox.StandardButton.Yes def _confirm_delete(self, kind: str, name: str, affected: int) -> bool: - answer = QMessageBox.question( + answer = ask_question( self, "Подтвердите удаление", f"Удалить {kind} '{name}' и снять фиксацию с {affected} LED?", @@ -1429,7 +1641,7 @@ class MainWindow(QMainWindow): def _confirm_discard_changes(self, message: str | None = None) -> bool: if not self._dirty: return True - answer = QMessageBox.question( + answer = ask_question( self, "Есть несохраненные изменения", message or "Открыть другой файл и потерять несохраненные изменения текущей карты?", diff --git a/models.py b/models.py index 4162a20..294fc34 100644 --- a/models.py +++ b/models.py @@ -110,7 +110,6 @@ class MappingDocument: self._rebuild_lines_from_points() self._deduplicate_block_memberships() self._validate_line_limit() - self.renumber_lines() self.reindex_all() def _load_lines(self, data: dict[str, Any]) -> None: @@ -184,7 +183,6 @@ class MappingDocument: def save(self, path: str | Path) -> None: path = Path(path) - self.renumber_lines() self.reindex_all() point_payload = [] for point in self.points: diff --git a/qa_smoke.py b/qa_smoke.py index 0bbeb49..b127d32 100644 --- a/qa_smoke.py +++ b/qa_smoke.py @@ -25,21 +25,35 @@ from app.unfolding import MAX_LINES try: from app.canvas import LedCanvas from app.icons import LUCIDE_PATHS + from app.localization import localize_dialog_buttons, localize_message_box from app.main_window import MainWindow, SIDEBAR_COLLAPSED_WIDTH, SIDEBAR_MIN_WIDTH - from PySide6.QtCore import Qt + from app.settings import DEFAULT_SHORTCUTS, AppSettings + from app.settings_dialog import SettingsDialog + from app.theme import apply_app_theme, effective_theme + from PySide6.QtCore import QPoint, Qt from PySide6.QtGui import QColor, QKeySequence - from PySide6.QtWidgets import QApplication, QGraphicsItem, QToolBar + from PySide6.QtWidgets import QApplication, QDialogButtonBox, QGraphicsItem, QMessageBox, QToolBar except ImportError: LedCanvas = None LUCIDE_PATHS = {} + localize_dialog_buttons = None + localize_message_box = None MainWindow = None + AppSettings = None + DEFAULT_SHORTCUTS = {} + SettingsDialog = None + apply_app_theme = None + effective_theme = None SIDEBAR_COLLAPSED_WIDTH = 34 SIDEBAR_MIN_WIDTH = 180 + QPoint = None Qt = None QColor = None QKeySequence = None QApplication = None + QDialogButtonBox = None QGraphicsItem = None + QMessageBox = None QToolBar = None @@ -53,6 +67,7 @@ def main() -> None: test_line_indexes_are_scoped_per_line, test_remove_selected_leds_blocks_and_lines, test_remove_block_preserves_line_numbers, + test_remove_block_save_load_preserves_line_identity, test_save_load_unfolding_roundtrip, test_reassigning_leds_removes_old_memberships, test_reassigning_leds_reindexes_affected_lines, @@ -92,20 +107,29 @@ def main() -> None: test_main_window_actions_use_lucide_icons, test_main_window_action_shortcuts_are_assigned, test_main_window_direction_arrow_action_toggles_canvas, + test_settings_apply_to_canvas_and_generation, + test_settings_dialog_has_requested_categories, + test_theme_resolution_and_canvas_apply, + test_shortcut_settings_are_editable_and_layout_independent, + test_standard_dialog_buttons_are_localized, test_lucide_icon_names_are_declared, test_table_hides_empty_line_index_columns, test_sidebar_collapses_and_expands, test_sidebar_action_toggle_updates_state_and_constraints, test_sidebar_collapsed_tab_click_and_drag_expand, test_canvas_select_points_skips_duplicate_selection_emit, + test_canvas_pan_mode_helpers_follow_settings, test_canvas_brush_cursor_survives_pan_cursor_reset, test_canvas_brush_zone_is_visible_and_collects_points, test_canvas_brush_zone_uses_viewport_radius_after_zoom, + test_canvas_zoom_anchor_is_same_with_brush_enabled, test_canvas_brush_preview_highlights_only_actionable_points, test_brush_can_create_new_block_without_preselected_block, - test_brush_continues_block_from_selected_led, + test_brush_default_stroke_creates_new_block_even_with_selected_block, + test_brush_shift_stroke_continues_selected_block, test_main_window_restores_table_state_after_selection_error, test_main_window_restores_tree_state_after_refresh_error, + test_focus_change_does_not_run_full_ui_refresh, test_package_imports_work_from_project_root, test_route_adjacency_large_grid_perf_guard, test_route_prefers_up_left_down_right, @@ -229,6 +253,35 @@ def test_remove_block_preserves_line_numbers() -> None: assert doc.get_line(second_line.id).blocks == [] +def test_remove_block_save_load_preserves_line_identity() -> None: + with temp_document_path() as path: + save_path = path.with_name("line_identity_after_block_delete.json") + doc = MappingDocument() + doc.load(path) + line_one = doc.add_line() + line_two = doc.add_line() + line_three = doc.add_line() + doc.add_block(line_one.id, [8, 9]) + removed_block = doc.add_block(line_two.id, [0, 1]) + doc.add_block(line_three.id, [4, 5]) + expected_ids = [line.id for line in doc.lines] + expected_names = [line.name for line in doc.lines] + + doc.remove_block(line_two.id, removed_block.id) + doc.save(save_path) + + assert [line.id for line in doc.lines] == expected_ids + assert [line.name for line in doc.lines] == expected_names + + loaded = MappingDocument() + loaded.load(save_path) + assert [line.id for line in loaded.lines] == expected_ids + assert [line.name for line in loaded.lines] == expected_names + assert loaded.get_line(line_one.id).blocks[0].point_ids == [8, 9] + assert loaded.get_line(line_two.id).blocks == [] + assert loaded.get_line(line_three.id).blocks[0].point_ids == [4, 5] + + def test_save_load_unfolding_roundtrip() -> None: with temp_document_path() as path: save_path = path.with_name("saved_mapping.json") @@ -873,7 +926,7 @@ def test_main_window_has_classic_menu_bar() -> None: app.processEvents() menu_titles = [action.text() for action in window.menuBar().actions()] - assert menu_titles[:5] == ["Файл", "Правка", "Вид", "Окно", "Инструменты"] + assert menu_titles[:4] == ["Файл", "Правка", "Вид", "Инструменты"] assert QToolBar is None or not window.findChildren(QToolBar) window.close() @@ -886,10 +939,9 @@ def test_classic_menu_contains_expected_actions() -> None: app.processEvents() expected = { - "Файл": ["Открыть", "Сохранить", "Сохранить как", "Сгенерировать скрипт"], + "Файл": ["Открыть", "Сохранить", "Сохранить как", "Сгенерировать скрипт", "Параметры"], "Правка": ["Отменить", "Повторить", "Удалить фиксацию", "Удалить все", "Очистить выбор"], - "Вид": ["Показать всё", "Сетка 34 мм", "Стрелки направления"], - "Окно": ["Скрыть боковую панель"], + "Вид": ["Показать всё", "Сетка 34 мм", "Стрелки направления", "Скрыть боковую панель"], "Инструменты": ["Новая линия", "Сгенерировать скрипт", "Автоблоки", "Кисть"], } for top_action in list(window.menuBar().actions()): @@ -969,6 +1021,7 @@ def test_main_window_actions_use_lucide_icons() -> None: "open", "save", "save_as", + "settings", "generate_script", "add_line", "add_block", @@ -1000,6 +1053,7 @@ def test_main_window_action_shortcuts_are_assigned() -> None: expected = { "generate_script": "Ctrl+G", + "settings": "Ctrl+,", "clear_all": "Ctrl+Shift+Del", "clear_selection": "Esc", "fit_view": "Ctrl+0", @@ -1037,6 +1091,132 @@ def test_main_window_direction_arrow_action_toggles_canvas() -> None: window.close() +def test_settings_apply_to_canvas_and_generation() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + with tempfile.TemporaryDirectory() as temp_dir: + window.settings.show_grid_by_default = True + window.settings.show_direction_arrows_by_default = False + window.settings.theme = "dark" + window.settings.brush_radius = 31 + window.settings.zoom_focus_pull = 12 + window.settings.pan_mode = "space" + window.settings.instant_space_pan = True + window.settings.default_output_dir = temp_dir + window.settings.confirm_partial_generation = False + window._apply_settings_to_actions() + window._apply_canvas_settings() + apply_app_theme(window.settings.theme) + + assert window.actions["grid"].isChecked() + assert not window.actions["direction_arrows"].isChecked() + assert window.canvas._grid_visible is True + assert window.canvas.direction_arrows_visible() is False + assert window.canvas._brush_radius == 31 + assert abs(window.canvas._zoom_focus_pull - 0.12) < 0.001 + assert window.canvas._pan_mode == "space" + assert window.canvas._instant_space_pan is True + assert window.canvas._theme == "dark" + assert window.settings.theme == "dark" + window.close() + + +def test_settings_dialog_has_requested_categories() -> None: + if SettingsDialog is None or AppSettings is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + dialog = SettingsDialog(AppSettings()) + section_titles = [dialog.section_list.item(index).text() for index in range(dialog.section_list.count())] + assert section_titles == ["Внешний вид", "Поведение", "Инструменты", "Файлы и генерация", "Комбинации клавиш"] + assert dialog.section_stack.count() == len(section_titles) + assert dialog.theme.itemText(0) == "Системная" + assert dialog.theme.itemText(1) == "Светлая" + assert dialog.theme.itemText(2) == "Темная" + assert dialog.theme.currentData() == "system" + assert dialog.pan_mode.currentData() == "middle_space" + assert dialog.instant_space_pan.isChecked() is False + dialog.close() + + +def test_theme_resolution_and_canvas_apply() -> None: + if QApplication is None or MainWindow is None or effective_theme is None: + return + app = QApplication.instance() or QApplication([]) + assert effective_theme("dark") == "dark" + assert effective_theme("light") == "light" + assert effective_theme("system") in {"dark", "light"} + window = MainWindow() + window.settings.theme = "light" + window._apply_canvas_settings() + assert window.canvas._theme == "light" + window.settings.theme = "dark" + window._apply_canvas_settings() + assert window.canvas._theme == "dark" + window.close() + + +def test_shortcut_settings_are_editable_and_layout_independent() -> None: + if QApplication is None or MainWindow is None or SettingsDialog is None or AppSettings is None: + return + app = QApplication.instance() or QApplication([]) + settings = AppSettings(shortcuts={**DEFAULT_SHORTCUTS, "grid": "Ctrl+Alt+G"}) + dialog = SettingsDialog(settings) + assert dialog.shortcut_edits["grid"].keySequence().toString(QKeySequence.SequenceFormat.PortableText) == "Ctrl+Alt+G" + dialog.shortcut_edits["grid"].setKeySequence(QKeySequence("G")) + assert dialog.settings().shortcut_for("grid") == "G" + dialog.close() + + window = MainWindow() + window.settings.shortcuts = {**DEFAULT_SHORTCUTS, "grid": "G"} + triggered = 0 + window.actions["grid"].triggered.connect(lambda _checked=False: nonlocal_increment()) + + def nonlocal_increment(): + nonlocal triggered + triggered += 1 + + class FakeKeyEvent: + def nativeVirtualKey(self): + return 0x47 + + def key(self): + return 0 + + def modifiers(self): + return Qt.NoModifier + + assert window._physical_key_sequence(FakeKeyEvent()) == "G" + assert window._trigger_layout_independent_shortcut(FakeKeyEvent()) is True + assert triggered == 1 + window.close() + + +def test_standard_dialog_buttons_are_localized() -> None: + if ( + QApplication is None + or QDialogButtonBox is None + or QMessageBox is None + or localize_dialog_buttons is None + or localize_message_box is None + ): + return + app = QApplication.instance() or QApplication([]) + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + localize_dialog_buttons(button_box) + assert button_box.button(QDialogButtonBox.Ok).text() == "ОК" + assert button_box.button(QDialogButtonBox.Cancel).text() == "Отмена" + + message_box = QMessageBox() + message_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel) + localize_message_box(message_box) + assert message_box.button(QMessageBox.Yes).text() == "Да" + assert message_box.button(QMessageBox.No).text() == "Нет" + assert message_box.button(QMessageBox.Cancel).text() == "Отмена" + message_box.close() + + def test_lucide_icon_names_are_declared() -> None: expected_names = { "archive-restore", @@ -1056,6 +1236,7 @@ def test_lucide_icon_names_are_declared() -> None: "rotate-cw", "save", "save-all", + "settings", "trash-2", "undo-2", } @@ -1222,6 +1403,39 @@ def test_canvas_select_points_skips_duplicate_selection_emit() -> None: assert emitted == [[0, 1]] +def test_canvas_pan_mode_helpers_follow_settings() -> None: + if LedCanvas is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + canvas = LedCanvas() + + canvas.set_pan_mode("middle") + assert canvas._can_pan_with_middle() + assert not canvas._can_pan_with_space() + + canvas.set_pan_mode("space") + assert not canvas._can_pan_with_middle() + assert canvas._can_pan_with_space() + + canvas.set_pan_mode("middle_space") + assert canvas._can_pan_with_middle() + assert canvas._can_pan_with_space() + + canvas.set_pan_mode("unknown") + assert canvas._pan_mode == "middle_space" + assert canvas._can_pan_with_middle() + assert canvas._can_pan_with_space() + + canvas._space_pressed = True + canvas._start_pan(QPoint(10, 10)) + assert canvas._is_panning + canvas.set_instant_space_pan(False) + assert not canvas._is_panning + canvas.set_instant_space_pan(True) + assert canvas._instant_space_pan is True + app.processEvents() + + def test_canvas_brush_cursor_survives_pan_cursor_reset() -> None: if LedCanvas is None or QApplication is None: return @@ -1300,6 +1514,34 @@ def test_canvas_brush_zone_uses_viewport_radius_after_zoom() -> None: assert set(touched) == {0, 1} +def test_canvas_zoom_anchor_is_same_with_brush_enabled() -> None: + if LedCanvas is None or QApplication is None or QPoint is None: + return + app = QApplication.instance() or QApplication([]) + doc = MappingDocument() + doc.points = [ + LedPoint(row * 20 + col, row * 20 + col, col * 34.0, row * 34.0, row, col) + for row in range(20) + for col in range(20) + ] + viewport_pos = QPoint(120, 90) + + for brush_enabled in (False, True): + canvas = LedCanvas() + canvas.resize(320, 240) + canvas.set_document(doc) + canvas.centerOn(canvas.items_by_id[210].pos()) + canvas.set_brush_enabled(brush_enabled) + scene_before = canvas.mapToScene(viewport_pos) + viewport_center = canvas.viewport().rect().center() + distance_before = (viewport_pos - viewport_center).manhattanLength() + canvas._zoom_at_viewport_pos(viewport_pos, 1.15) + focused_viewport_pos = canvas.mapFromScene(scene_before) + distance_after = (focused_viewport_pos - viewport_center).manhattanLength() + assert distance_after < distance_before + assert distance_after > 2 + + def test_canvas_brush_preview_highlights_only_actionable_points() -> None: if LedCanvas is None or QApplication is None: return @@ -1359,7 +1601,7 @@ def test_brush_can_create_new_block_without_preselected_block() -> None: window.close() -def test_brush_continues_block_from_selected_led() -> None: +def test_brush_default_stroke_creates_new_block_even_with_selected_block() -> None: if MainWindow is None or QApplication is None: return app = QApplication.instance() or QApplication([]) @@ -1376,6 +1618,38 @@ def test_brush_continues_block_from_selected_led() -> None: window.actions["brush"].setChecked(True) window.toggle_brush_mode() + window.start_brush_stroke(False) + window.add_brush_point_to_block(2) + window.finish_brush_stroke() + app.processEvents() + + line_after = window.document.get_line(line.id) + assert len(line_after.blocks) == 2 + assert line_after.blocks[0].id == block.id + assert line_after.blocks[0].point_ids == [0, 1] + assert line_after.blocks[1].point_ids == [2] + window._dirty = False + window.close() + + +def test_brush_shift_stroke_continues_selected_block() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + block = doc.add_block(line.id, [0, 1]) + window.document = doc + window.active_line_id = line.id + window.canvas.set_document(doc) + window.canvas.select_points([0]) + + window.actions["brush"].setChecked(True) + window.toggle_brush_mode() + window.start_brush_stroke(True) window.add_brush_point_to_block(2) window.finish_brush_stroke() app.processEvents() @@ -1440,10 +1714,33 @@ def test_main_window_restores_tree_state_after_refresh_error() -> None: window.close() +def test_focus_change_does_not_run_full_ui_refresh() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + calls = 0 + original = window._refresh_ui_state + + def counted_refresh(): + nonlocal calls + calls += 1 + return original() + + window._refresh_ui_state = counted_refresh + window._on_application_focus_changed(None, window.tree) + assert calls == 0 + window.close() + + def test_package_imports_work_from_project_root() -> None: importlib.import_module("app.main") importlib.import_module("app.canvas") + importlib.import_module("app.localization") importlib.import_module("app.main_window") + importlib.import_module("app.settings") + importlib.import_module("app.settings_dialog") + importlib.import_module("app.theme") def path_has_segment(path, x1: float, y1: float, x2: float, y2: float) -> bool: # noqa: ANN001 diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..da2f4b2 --- /dev/null +++ b/settings.py @@ -0,0 +1,108 @@ +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"), + ("toggle_sidebar", "Скрыть боковую панель", "F9"), + ("add_line", "Новая линия", "Ctrl+L"), + ("auto_blocks", "Автоблоки", "Ctrl+Shift+B"), + ("brush", "Кисть", "B"), +] +DEFAULT_SHORTCUTS = {key: shortcut for key, _title, shortcut in SHORTCUT_DEFINITIONS} + + +@dataclass +class AppSettings: + theme: str = "system" + show_grid_by_default: bool = False + show_direction_arrows_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() + for key, value in data.items(): + if hasattr(settings, key): + setattr(settings, key, value) + 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.confirm_partial_generation = bool(settings.confirm_partial_generation) + settings.default_output_dir = str(settings.default_output_dir or "") + settings.shortcuts = normalize_shortcuts(settings.shortcuts) + 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) -> 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: + raw = "" + sequence = QKeySequence(str(raw)) + shortcuts[key] = sequence.toString(QKeySequence.SequenceFormat.PortableText) + return shortcuts diff --git a/settings_dialog.py b/settings_dialog.py new file mode 100644 index 0000000..b3e3c3f --- /dev/null +++ b/settings_dialog.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtGui import QKeySequence +from PySide6.QtWidgets import ( + QListWidget, + QListWidgetItem, + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QFormLayout, + QHBoxLayout, + QKeySequenceEdit, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QSpinBox, + QStackedWidget, + QVBoxLayout, + QWidget, +) + +try: + from .localization import localize_dialog_buttons, show_warning + from .settings import DEFAULT_SHORTCUTS, SHORTCUT_DEFINITIONS, AppSettings +except ImportError: + from localization import localize_dialog_buttons, show_warning + from settings import DEFAULT_SHORTCUTS, SHORTCUT_DEFINITIONS, AppSettings + + +class SettingsDialog(QDialog): + def __init__(self, settings: AppSettings, parent=None) -> None: # noqa: ANN001 + super().__init__(parent) + self.setWindowTitle("Параметры") + self.setMinimumSize(520, 360) + self._settings = settings + + self.theme = QComboBox() + self.theme.addItem("Системная", "system") + self.theme.addItem("Светлая", "light") + self.theme.addItem("Темная", "dark") + self.theme.setCurrentIndex(max(0, self.theme.findData(settings.theme))) + self.show_grid = QCheckBox("Показывать сетку 34 мм при запуске") + self.show_grid.setChecked(settings.show_grid_by_default) + self.show_arrows = QCheckBox("Показывать стрелки направления при запуске") + self.show_arrows.setChecked(settings.show_direction_arrows_by_default) + self.zoom_pull = QSpinBox() + self.zoom_pull.setRange(0, 100) + self.zoom_pull.setSuffix(" %") + self.zoom_pull.setValue(settings.zoom_focus_pull) + self.pan_mode = QComboBox() + self.pan_mode.addItem("СКМ", "middle") + self.pan_mode.addItem("Пробел", "space") + self.pan_mode.addItem("СКМ + Пробел", "middle_space") + self.pan_mode.setCurrentIndex(max(0, self.pan_mode.findData(settings.pan_mode))) + self.instant_space_pan = QCheckBox("Сразу перетаскивать на пробел") + self.instant_space_pan.setChecked(settings.instant_space_pan) + + self.brush_radius = QSpinBox() + self.brush_radius.setRange(4, 80) + self.brush_radius.setSuffix(" px") + self.brush_radius.setValue(settings.brush_radius) + + self.output_dir = QLineEdit(settings.default_output_dir) + self.output_dir.setPlaceholderText("Выбирать папку при генерации") + self.confirm_partial = QCheckBox("Предупреждать, если назначены не все LED") + self.confirm_partial.setChecked(settings.confirm_partial_generation) + self.shortcut_edits: dict[str, QKeySequenceEdit] = {} + + self.section_list = QListWidget() + self.section_list.setFixedWidth(170) + self.section_stack = QStackedWidget() + self._add_section("Внешний вид", self._build_appearance_tab()) + self._add_section("Поведение", self._build_behavior_tab()) + self._add_section("Инструменты", self._build_tools_tab()) + self._add_section("Файлы и генерация", self._build_files_tab()) + self._add_section("Комбинации клавиш", self._build_shortcuts_tab()) + self.section_list.setCurrentRow(0) + self.section_list.currentRowChanged.connect(self.section_stack.setCurrentIndex) + + content = QWidget() + content_layout = QHBoxLayout(content) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.addWidget(self.section_list) + content_layout.addWidget(self.section_stack, 1) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + localize_dialog_buttons(buttons) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addWidget(content) + layout.addWidget(buttons) + + def _add_section(self, title: str, widget: QWidget) -> None: + item = QListWidgetItem(title) + self.section_list.addItem(item) + self.section_stack.addWidget(widget) + + def settings(self) -> AppSettings: + return AppSettings( + theme=str(self.theme.currentData()), + show_grid_by_default=self.show_grid.isChecked(), + show_direction_arrows_by_default=self.show_arrows.isChecked(), + zoom_focus_pull=self.zoom_pull.value(), + pan_mode=str(self.pan_mode.currentData()), + instant_space_pan=self.instant_space_pan.isChecked(), + brush_radius=self.brush_radius.value(), + default_output_dir=self.output_dir.text().strip(), + confirm_partial_generation=self.confirm_partial.isChecked(), + shortcuts=self._shortcuts(), + ) + + def _build_appearance_tab(self) -> QWidget: + page = QWidget() + layout = QFormLayout(page) + layout.addRow("Тема приложения", self.theme) + layout.addRow(self.show_grid) + layout.addRow(self.show_arrows) + layout.addRow("Притяжение зума к курсору", self.zoom_pull) + layout.addRow(QLabel("0% оставляет центр спокойнее, 100% сильнее тянет схему к курсору.")) + return page + + def _build_behavior_tab(self) -> QWidget: + page = QWidget() + layout = QFormLayout(page) + layout.addRow("Перемещение", self.pan_mode) + layout.addRow(self.instant_space_pan) + layout.addRow(QLabel("Пробел означает: удерживайте Пробел и перетаскивайте схему ЛКМ.")) + return page + + def _build_tools_tab(self) -> QWidget: + page = QWidget() + layout = QFormLayout(page) + layout.addRow("Радиус кисти", self.brush_radius) + layout.addRow(QLabel("Обычный мазок создает новый блок. Shift + ЛКМ добавляет точки к выбранному блоку.")) + return page + + def _build_files_tab(self) -> QWidget: + page = QWidget() + form = QFormLayout(page) + row = QWidget() + row_layout = QHBoxLayout(row) + row_layout.setContentsMargins(0, 0, 0, 0) + browse = QPushButton("Выбрать") + browse.clicked.connect(self._browse_output_dir) + row_layout.addWidget(self.output_dir, 1) + row_layout.addWidget(browse) + form.addRow("Папка вывода", row) + form.addRow(self.confirm_partial) + return page + + def _build_shortcuts_tab(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(0, 0, 0, 0) + scroll_content = QWidget() + form = QFormLayout() + form.setContentsMargins(0, 0, 0, 0) + for key, title, _default_shortcut in SHORTCUT_DEFINITIONS: + edit = QKeySequenceEdit(QKeySequence(self._settings.shortcut_for(key))) + edit.setClearButtonEnabled(True) + edit.setMinimumWidth(190) + self.shortcut_edits[key] = edit + form.addRow(title, edit) + scroll_content.setLayout(form) + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setFrameShape(QScrollArea.NoFrame) + scroll_area.setWidget(scroll_content) + reset = QPushButton("Сбросить по умолчанию") + reset.clicked.connect(self._reset_shortcuts) + layout.addWidget(scroll_area, 1) + layout.addWidget(reset) + layout.addWidget(QLabel("Сочетания работают по физическим клавишам и не зависят от раскладки клавиатуры.")) + return page + + def accept(self) -> None: + duplicates: dict[str, list[str]] = {} + for key, sequence in self._shortcuts().items(): + if not sequence: + continue + duplicates.setdefault(sequence, []).append(key) + conflict = next((keys for keys in duplicates.values() if len(keys) > 1), None) + if conflict: + names = {key: title for key, title, _shortcut in SHORTCUT_DEFINITIONS} + show_warning( + self, + "Конфликт сочетаний", + "Одно сочетание назначено нескольким действиям:\n" + + "\n".join(names.get(key, key) for key in conflict), + ) + return + super().accept() + + def _shortcuts(self) -> dict[str, str]: + shortcuts: dict[str, str] = {} + for key, edit in self.shortcut_edits.items(): + shortcuts[key] = edit.keySequence().toString(QKeySequence.SequenceFormat.PortableText) + return shortcuts + + def _reset_shortcuts(self) -> None: + for key, edit in self.shortcut_edits.items(): + edit.setKeySequence(QKeySequence(DEFAULT_SHORTCUTS.get(key, ""))) + + def _browse_output_dir(self) -> None: + start = self.output_dir.text().strip() + if not start or not Path(start).exists(): + start = str(Path.home()) + selected = QFileDialog.getExistingDirectory(self, "Папка вывода", start) + if selected: + self.output_dir.setText(selected) diff --git a/theme.py b/theme.py new file mode 100644 index 0000000..9e7785c --- /dev/null +++ b/theme.py @@ -0,0 +1,583 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QPalette +from PySide6.QtWidgets import QApplication + + +ASSET_DIR = Path(__file__).resolve().parent / "assets" + + +def asset_url(name: str) -> str: + return ASSET_DIR.joinpath(name).as_posix() + + +def apply_app_theme(theme: str) -> None: + app = QApplication.instance() + if app is None: + return + effective = effective_theme(theme) + app.setStyle("Fusion") + if effective == "dark": + app.setPalette(dark_palette()) + app.setStyleSheet(dark_stylesheet()) + else: + app.setPalette(light_palette()) + app.setStyleSheet(light_stylesheet()) + + +def effective_theme(theme: str) -> str: + if theme == "dark": + return "dark" + if theme == "light": + return "light" + return system_theme() + + +def system_theme() -> str: + app = QApplication.instance() + if app is not None: + try: + color_scheme = app.styleHints().colorScheme() + if color_scheme == Qt.ColorScheme.Dark: + return "dark" + if color_scheme == Qt.ColorScheme.Light: + return "light" + except (AttributeError, TypeError): + pass + if sys.platform.startswith("win"): + try: + import winreg + + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", + ) as key: + apps_use_light_theme, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") + return "light" if int(apps_use_light_theme) else "dark" + except OSError: + pass + if app is not None: + return "dark" if app.style().standardPalette().color(QPalette.Window).lightness() < 128 else "light" + return "light" + + +def light_palette() -> QPalette: + palette = QPalette() + palette.setColor(QPalette.Window, QColor("#f8fafc")) + palette.setColor(QPalette.WindowText, QColor("#111827")) + palette.setColor(QPalette.Base, QColor("#ffffff")) + palette.setColor(QPalette.AlternateBase, QColor("#f1f5f9")) + palette.setColor(QPalette.ToolTipBase, QColor("#ffffff")) + palette.setColor(QPalette.ToolTipText, QColor("#111827")) + palette.setColor(QPalette.Text, QColor("#111827")) + palette.setColor(QPalette.Button, QColor("#e5e7eb")) + palette.setColor(QPalette.ButtonText, QColor("#111827")) + palette.setColor(QPalette.Highlight, QColor("#c084fc")) + palette.setColor(QPalette.HighlightedText, QColor("#111827")) + return palette + + +def dark_palette() -> QPalette: + palette = QPalette() + palette.setColor(QPalette.Window, QColor("#1f1f1f")) + palette.setColor(QPalette.WindowText, QColor("#f8fafc")) + palette.setColor(QPalette.Base, QColor("#2b2b2b")) + palette.setColor(QPalette.AlternateBase, QColor("#353535")) + palette.setColor(QPalette.ToolTipBase, QColor("#111827")) + palette.setColor(QPalette.ToolTipText, QColor("#f8fafc")) + palette.setColor(QPalette.Text, QColor("#f8fafc")) + palette.setColor(QPalette.Button, QColor("#2f2f2f")) + palette.setColor(QPalette.ButtonText, QColor("#f8fafc")) + palette.setColor(QPalette.Highlight, QColor("#c084fc")) + palette.setColor(QPalette.HighlightedText, QColor("#111827")) + return palette + + +def light_stylesheet() -> str: + return """ + QWidget { + background-color: #f8fafc; + color: #111827; + selection-background-color: #93c5fd; + selection-color: #111827; + } + QGraphicsView { + background-color: #ffffff; + border: none; + } + QMenuBar { + background-color: #f1f5f9; + color: #111827; + } + QMenuBar::item { + background: transparent; + padding: 5px 10px; + } + QMenuBar::item:selected { + background-color: #e2e8f0; + } + QMenu { + background-color: #ffffff; + color: #111827; + border: 1px solid #cbd5e1; + } + QMenu::item { + padding: 5px 24px 5px 24px; + } + QMenu::item:selected { + background-color: #dbeafe; + } + QPushButton, QToolButton { + background-color: #e5e7eb; + color: #111827; + border: 1px solid #cbd5e1; + border-radius: 4px; + padding: 4px 8px; + } + QPushButton:hover, QToolButton:hover { + background-color: #dbeafe; + } + QPushButton:disabled, QToolButton:disabled { + background-color: #f1f5f9; + color: #94a3b8; + border-color: #e2e8f0; + } + QTreeWidget, QTableWidget, QListWidget, QLineEdit, QComboBox, QSpinBox { + background-color: #ffffff; + color: #111827; + alternate-background-color: #f1f5f9; + border: 1px solid #cbd5e1; + border-radius: 4px; + min-height: 24px; + selection-background-color: #bfdbfe; + selection-color: #111827; + } + QLineEdit, QComboBox, QSpinBox { + padding: 2px 8px; + } + QComboBox { + padding-right: 28px; + } + QComboBox:hover, QSpinBox:hover, QLineEdit:hover { + border-color: #94a3b8; + } + QComboBox:focus, QSpinBox:focus, QLineEdit:focus { + border-color: #60a5fa; + } + QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 24px; + border-left: 1px solid #cbd5e1; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + background-color: #f1f5f9; + } + QComboBox::drop-down:hover { + background-color: #e2e8f0; + } + QComboBox::down-arrow { + image: url("__INPUT_DOWN_ARROW__"); + width: 10px; + height: 10px; + } + QSpinBox { + padding-right: 28px; + } + QSpinBox::up-button, QSpinBox::down-button { + subcontrol-origin: border; + width: 24px; + border-left: 1px solid #cbd5e1; + background-color: #f1f5f9; + } + QSpinBox::up-button { + subcontrol-position: top right; + border-top-right-radius: 4px; + } + QSpinBox::down-button { + subcontrol-position: bottom right; + border-bottom-right-radius: 4px; + } + QSpinBox::up-button:hover, QSpinBox::down-button:hover { + background-color: #e2e8f0; + } + QSpinBox::up-arrow { + image: url("__INPUT_UP_ARROW__"); + width: 10px; + height: 10px; + } + QSpinBox::down-arrow { + image: url("__INPUT_DOWN_ARROW__"); + width: 10px; + height: 10px; + } + QComboBox QAbstractItemView { + background-color: #ffffff; + color: #111827; + border: 1px solid #cbd5e1; + selection-background-color: #bfdbfe; + selection-color: #111827; + } + QHeaderView::section { + background-color: #e5e7eb; + color: #111827; + border: 1px solid #cbd5e1; + padding: 4px; + } + QSplitter::handle { + background-color: #e2e8f0; + } + QStatusBar { + background-color: #f1f5f9; + color: #111827; + } + QToolTip { + background-color: #ffffff; + color: #111827; + border: 1px solid #cbd5e1; + } + QScrollBar:vertical { + background: #f1f5f9; + width: 12px; + margin: 12px 0 12px 0; + border: none; + } + QScrollBar::handle:vertical { + background: #94a3b8; + min-height: 28px; + margin: 0 4px; + border-radius: 2px; + } + QScrollBar::handle:vertical:hover { + background: #64748b; + margin: 0 2px; + border-radius: 4px; + } + QScrollBar::add-page:vertical, + QScrollBar::sub-page:vertical { + background: transparent; + border: none; + height: 0; + } + QScrollBar::sub-line:vertical, + QScrollBar::add-line:vertical { + background: #f1f5f9; + border: none; + height: 12px; + subcontrol-origin: margin; + } + QScrollBar::sub-line:vertical { + subcontrol-position: top; + } + QScrollBar::add-line:vertical { + subcontrol-position: bottom; + } + QScrollBar::up-arrow:vertical { + image: url("__UP_ARROW__"); + width: 8px; + height: 8px; + } + QScrollBar::down-arrow:vertical { + image: url("__DOWN_ARROW__"); + width: 8px; + height: 8px; + } + QScrollBar:horizontal { + background: #f1f5f9; + height: 12px; + margin: 0 12px 0 12px; + border: none; + } + QScrollBar::handle:horizontal { + background: #94a3b8; + min-width: 28px; + margin: 4px 0; + border-radius: 2px; + } + QScrollBar::handle:horizontal:hover { + background: #64748b; + margin: 2px 0; + border-radius: 4px; + } + QScrollBar::add-page:horizontal, + QScrollBar::sub-page:horizontal { + background: transparent; + border: none; + width: 0; + } + QScrollBar::sub-line:horizontal, + QScrollBar::add-line:horizontal { + background: #f1f5f9; + border: none; + width: 12px; + subcontrol-origin: margin; + } + QScrollBar::sub-line:horizontal { + subcontrol-position: left; + } + QScrollBar::add-line:horizontal { + subcontrol-position: right; + } + QScrollBar::left-arrow:horizontal { + image: url("__LEFT_ARROW__"); + width: 8px; + height: 8px; + } + QScrollBar::right-arrow:horizontal { + image: url("__RIGHT_ARROW__"); + width: 8px; + height: 8px; + } + """.replace("__INPUT_UP_ARROW__", asset_url("input-arrow-up-light.svg")) \ + .replace("__INPUT_DOWN_ARROW__", asset_url("input-arrow-down-light.svg")) \ + .replace("__UP_ARROW__", asset_url("scrollbar-arrow-up-light.svg")) \ + .replace("__DOWN_ARROW__", asset_url("scrollbar-arrow-down-light.svg")) \ + .replace("__LEFT_ARROW__", asset_url("scrollbar-arrow-left-light.svg")) \ + .replace("__RIGHT_ARROW__", asset_url("scrollbar-arrow-right-light.svg")) + + +def dark_stylesheet() -> str: + return """ + QWidget { + background-color: #1f1f1f; + color: #f8fafc; + selection-background-color: #3b82f6; + selection-color: #f8fafc; + } + QGraphicsView { + background-color: #2b2b2b; + border: none; + } + QMenuBar { + background-color: #242424; + color: #f8fafc; + } + QMenuBar::item { + background: transparent; + padding: 5px 10px; + } + QMenuBar::item:selected { + background-color: #343434; + } + QMenu { + background-color: #242424; + color: #f8fafc; + border: 1px solid #444444; + } + QMenu::item { + padding: 5px 24px 5px 24px; + } + QMenu::item:selected { + background-color: #3a3a3a; + } + QPushButton, QToolButton { + background-color: #2f2f2f; + color: #f8fafc; + border: 1px solid #4b5563; + border-radius: 4px; + padding: 4px 8px; + } + QPushButton:hover, QToolButton:hover { + background-color: #3f3f46; + } + QPushButton:disabled, QToolButton:disabled { + background-color: #262626; + color: #737373; + border-color: #3a3a3a; + } + QTreeWidget, QTableWidget, QListWidget, QLineEdit, QComboBox, QSpinBox { + background-color: #2b2b2b; + color: #f8fafc; + alternate-background-color: #353535; + border: 1px solid #444444; + border-radius: 4px; + min-height: 24px; + selection-background-color: #3b82f6; + selection-color: #f8fafc; + } + QLineEdit, QComboBox, QSpinBox { + padding: 2px 8px; + } + QComboBox { + padding-right: 28px; + } + QComboBox:hover, QSpinBox:hover, QLineEdit:hover { + border-color: #737373; + } + QComboBox:focus, QSpinBox:focus, QLineEdit:focus { + border-color: #60a5fa; + } + QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 24px; + border-left: 1px solid #444444; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + background-color: #262626; + } + QComboBox::drop-down:hover { + background-color: #3f3f46; + } + QComboBox::down-arrow { + image: url("__INPUT_DOWN_ARROW__"); + width: 10px; + height: 10px; + } + QSpinBox { + padding-right: 28px; + } + QSpinBox::up-button, QSpinBox::down-button { + subcontrol-origin: border; + width: 24px; + border-left: 1px solid #444444; + background-color: #262626; + } + QSpinBox::up-button { + subcontrol-position: top right; + border-top-right-radius: 4px; + } + QSpinBox::down-button { + subcontrol-position: bottom right; + border-bottom-right-radius: 4px; + } + QSpinBox::up-button:hover, QSpinBox::down-button:hover { + background-color: #3f3f46; + } + QSpinBox::up-arrow { + image: url("__INPUT_UP_ARROW__"); + width: 10px; + height: 10px; + } + QSpinBox::down-arrow { + image: url("__INPUT_DOWN_ARROW__"); + width: 10px; + height: 10px; + } + QComboBox QAbstractItemView { + background-color: #2b2b2b; + color: #f8fafc; + border: 1px solid #444444; + selection-background-color: #3b82f6; + selection-color: #f8fafc; + } + QHeaderView::section { + background-color: #3a3a3a; + color: #f8fafc; + border: 1px solid #444444; + padding: 4px; + } + QSplitter::handle { + background-color: #111827; + } + QStatusBar { + background-color: #1f1f1f; + color: #f8fafc; + } + QToolTip { + background-color: #111827; + color: #f8fafc; + border: 1px solid #4b5563; + } + QScrollBar:vertical { + background: #1f1f1f; + width: 12px; + margin: 12px 0 12px 0; + border: none; + } + QScrollBar::handle:vertical { + background: #737373; + min-height: 28px; + margin: 0 4px; + border-radius: 2px; + } + QScrollBar::handle:vertical:hover { + background: #a3a3a3; + margin: 0 2px; + border-radius: 4px; + } + QScrollBar::add-page:vertical, + QScrollBar::sub-page:vertical { + background: transparent; + border: none; + height: 0; + } + QScrollBar::sub-line:vertical, + QScrollBar::add-line:vertical { + background: #1f1f1f; + border: none; + height: 12px; + subcontrol-origin: margin; + } + QScrollBar::sub-line:vertical { + subcontrol-position: top; + } + QScrollBar::add-line:vertical { + subcontrol-position: bottom; + } + QScrollBar::up-arrow:vertical { + image: url("__UP_ARROW__"); + width: 8px; + height: 8px; + } + QScrollBar::down-arrow:vertical { + image: url("__DOWN_ARROW__"); + width: 8px; + height: 8px; + } + QScrollBar:horizontal { + background: #1f1f1f; + height: 12px; + margin: 0 12px 0 12px; + border: none; + } + QScrollBar::handle:horizontal { + background: #737373; + min-width: 28px; + margin: 4px 0; + border-radius: 2px; + } + QScrollBar::handle:horizontal:hover { + background: #a3a3a3; + margin: 2px 0; + border-radius: 4px; + } + QScrollBar::add-page:horizontal, + QScrollBar::sub-page:horizontal { + background: transparent; + border: none; + width: 0; + } + QScrollBar::sub-line:horizontal, + QScrollBar::add-line:horizontal { + background: #1f1f1f; + border: none; + width: 12px; + subcontrol-origin: margin; + } + QScrollBar::sub-line:horizontal { + subcontrol-position: left; + } + QScrollBar::add-line:horizontal { + subcontrol-position: right; + } + QScrollBar::left-arrow:horizontal { + image: url("__LEFT_ARROW__"); + width: 8px; + height: 8px; + } + QScrollBar::right-arrow:horizontal { + image: url("__RIGHT_ARROW__"); + width: 8px; + height: 8px; + } + """.replace("__INPUT_UP_ARROW__", asset_url("input-arrow-up-dark.svg")) \ + .replace("__INPUT_DOWN_ARROW__", asset_url("input-arrow-down-dark.svg")) \ + .replace("__UP_ARROW__", asset_url("scrollbar-arrow-up-dark.svg")) \ + .replace("__DOWN_ARROW__", asset_url("scrollbar-arrow-down-dark.svg")) \ + .replace("__LEFT_ARROW__", asset_url("scrollbar-arrow-left-dark.svg")) \ + .replace("__RIGHT_ARROW__", asset_url("scrollbar-arrow-right-dark.svg"))