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