Release settings and shortcut customization
@ -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=[],
|
||||
|
||||
1
assets/input-arrow-down-dark.svg
Normal 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 |
1
assets/input-arrow-down-light.svg
Normal 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 |
1
assets/input-arrow-up-dark.svg
Normal 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 |
1
assets/input-arrow-up-light.svg
Normal 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 |
1
assets/scrollbar-arrow-down-dark.svg
Normal 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 |
1
assets/scrollbar-arrow-down-light.svg
Normal 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 |
1
assets/scrollbar-arrow-left-dark.svg
Normal 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 |
1
assets/scrollbar-arrow-left-light.svg
Normal 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 |
1
assets/scrollbar-arrow-right-dark.svg
Normal 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 |
1
assets/scrollbar-arrow-right-light.svg
Normal 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 |
1
assets/scrollbar-arrow-up-dark.svg
Normal 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 |
1
assets/scrollbar-arrow-up-light.svg
Normal 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
@ -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):
|
||||
|
||||
1
icons.py
@ -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
@ -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,
|
||||
)
|
||||
9
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)
|
||||
|
||||
306
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 "Открыть другой файл и потерять несохраненные изменения текущей карты?",
|
||||
|
||||
@ -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:
|
||||
|
||||
313
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
|
||||
|
||||
108
settings.py
Normal 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
@ -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
@ -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"))
|
||||