Release settings and shortcut customization

This commit is contained in:
Данил Ярош 2026-05-21 16:58:28 +03:00
parent bbf580bb56
commit 9f297b326f
23 changed files with 1748 additions and 81 deletions

View File

@ -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=[],

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"><path fill="#d4d4d4" d="M2 3.5h6L5 7z"/></svg>

After

Width:  |  Height:  |  Size: 130 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"><path fill="#475569" d="M2 3.5h6L5 7z"/></svg>

After

Width:  |  Height:  |  Size: 130 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"><path fill="#d4d4d4" d="M5 3 8 6.5H2z"/></svg>

After

Width:  |  Height:  |  Size: 130 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"><path fill="#475569" d="M5 3 8 6.5H2z"/></svg>

After

Width:  |  Height:  |  Size: 130 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><path fill="#a3a3a3" d="M4 7 1 2h6z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><path fill="#64748b" d="M4 7 1 2h6z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><path fill="#a3a3a3" d="M1 4 6 1v6z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><path fill="#64748b" d="M1 4 6 1v6z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><path fill="#a3a3a3" d="M7 4 2 7V1z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><path fill="#64748b" d="M7 4 2 7V1z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><path fill="#a3a3a3" d="M4 1 7 6H1z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><path fill="#64748b" d="M4 1 7 6H1z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

174
canvas.py
View File

@ -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):

View File

@ -25,6 +25,7 @@ LUCIDE_PATHS = {
"rotate-cw": '<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8"/><path d="M21 3v5h-5"/>',
"save": '<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/>',
"save-all": '<path d="M10 2v3a1 1 0 0 0 1 1h5"/><path d="M18 18v-6a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6"/><path d="M18 22H6a2 2 0 0 1-2-2V6"/><path d="M8 2h8l4 4v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2"/>',
"settings": '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.51a2 2 0 0 1 1-1.72l.15-.1a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
"trash-2": '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>',
"undo-2": '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5A5.5 5.5 0 0 1 20 14.5 5.5 5.5 0 0 1 14.5 20H11"/>',
}

98
localization.py Normal file
View File

@ -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,
)

View File

@ -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)

View File

@ -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 "Открыть другой файл и потерять несохраненные изменения текущей карты?",

View File

@ -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:

View File

@ -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

108
settings.py Normal file
View File

@ -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

217
settings_dialog.py Normal file
View File

@ -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)

583
theme.py Normal file
View File

@ -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"))