diff --git a/LEDMapper.spec b/LEDMapper.spec
index ad89e27..89d8fe7 100644
--- a/LEDMapper.spec
+++ b/LEDMapper.spec
@@ -1,11 +1,41 @@
# -*- mode: python ; coding: utf-8 -*-
+import subprocess
from pathlib import Path
spec_dir = Path(SPECPATH)
+def git_build_ref():
+ try:
+ tag = subprocess.check_output(
+ ['git', 'describe', '--tags', '--exact-match', 'HEAD'],
+ cwd=spec_dir,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ ).strip()
+ if tag:
+ return tag
+ except (OSError, subprocess.CalledProcessError):
+ pass
+ try:
+ commit = subprocess.check_output(
+ ['git', 'rev-parse', '--short', 'HEAD'],
+ cwd=spec_dir,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ ).strip()
+ if commit:
+ return commit
+ except (OSError, subprocess.CalledProcessError):
+ pass
+ return 'untagged'
+
+
+app_name = f'LEDMapper-{git_build_ref()}'
+
+
a = Analysis(
[str(spec_dir / 'main.py')],
pathex=[str(spec_dir), str(spec_dir.parent)],
@@ -34,7 +64,7 @@ exe = EXE(
a.binaries,
a.datas,
[],
- name='LEDMapper',
+ name=app_name,
debug=False,
bootloader_ignore_signals=False,
strip=False,
diff --git a/README.md b/README.md
index bd02dcb..eeb8ae8 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,7 @@ python -m venv .venv
| Очистить выбор | `Esc` |
| Показать всю схему | `Ctrl+0` |
| Сетка 34 мм | `G` |
+| Стрелки направления | `Ctrl+Shift+A` |
| Скрыть/показать боковую панель | `F9` |
| Новая линия | `Ctrl+L` |
| Автоблоки | `Ctrl+Shift+B` |
diff --git a/canvas.py b/canvas.py
index c5e00ae..b28d520 100644
--- a/canvas.py
+++ b/canvas.py
@@ -66,6 +66,7 @@ class LedCanvas(QGraphicsView):
self.items_by_id: dict[int, LedItem] = {}
self.grid_items: list[QGraphicsLineItem] = []
self.connection_items: list[QGraphicsPathItem] = []
+ self._direction_arrows_visible = True
self._point_radius = 4.5
self._brush_radius = 18.0
self._brush_zone_item: QGraphicsEllipseItem | None = None
@@ -100,6 +101,15 @@ class LedCanvas(QGraphicsView):
self._grid_visible = visible
self._refresh_grid()
+ def set_direction_arrows_visible(self, visible: bool) -> None:
+ if self._direction_arrows_visible == visible:
+ return
+ self._direction_arrows_visible = visible
+ self.refresh_styles(redraw_connections=True)
+
+ def direction_arrows_visible(self) -> bool:
+ return self._direction_arrows_visible
+
def set_brush_enabled(self, enabled: bool) -> None:
self._brush_enabled = enabled
self._is_brush_stroke = False
@@ -537,9 +547,11 @@ class LedCanvas(QGraphicsView):
ordered_items = [self.items_by_id[point_id] for point_id in block.point_ids if point_id in self.items_by_id]
for first, second in zip(ordered_items, ordered_items[1:]):
self._append_connection(inner_path, first.pos(), second.pos())
- self._append_arrow_marker(inner_arrows, first.pos(), second.pos())
+ if self._direction_arrows_visible:
+ self._append_arrow_marker(inner_arrows, first.pos(), second.pos())
self._add_connection_path(inner_path, line.color, inner_width, inner_alpha)
- self._add_connection_path(inner_arrows, line.color, arrow_width, arrow_alpha, z_value=2)
+ if self._direction_arrows_visible:
+ self._add_connection_path(inner_arrows, line.color, arrow_width, arrow_alpha, z_value=2)
previous_block_end = None
bridge_path = QPainterPath()
@@ -551,11 +563,13 @@ class LedCanvas(QGraphicsView):
block_end = self.items_by_id.get(block.point_ids[-1])
if block_start is not None and previous_block_end is not None:
self._append_connection(bridge_path, previous_block_end.pos(), block_start.pos())
- self._append_arrow_marker(bridge_arrows, previous_block_end.pos(), block_start.pos())
+ if self._direction_arrows_visible:
+ self._append_arrow_marker(bridge_arrows, previous_block_end.pos(), block_start.pos())
if block_end is not None:
previous_block_end = block_end
self._add_connection_path(bridge_path, line.color, bridge_width, bridge_alpha)
- self._add_connection_path(bridge_arrows, line.color, arrow_width, bridge_alpha, z_value=2)
+ if self._direction_arrows_visible:
+ self._add_connection_path(bridge_arrows, line.color, arrow_width, bridge_alpha, z_value=2)
@staticmethod
def _append_connection(path: QPainterPath, start: QPointF, end: QPointF) -> None:
diff --git a/icons.py b/icons.py
index 31d74ff..281e0a2 100644
--- a/icons.py
+++ b/icons.py
@@ -7,6 +7,7 @@ from PySide6.QtSvg import QSvgRenderer
LUCIDE_PATHS = {
"archive-restore": '',
+ "arrow-right": '',
"brush": '',
"chevron-right": '',
"eraser": '',
diff --git a/main_window.py b/main_window.py
index 1d0e8d8..7c5c155 100644
--- a/main_window.py
+++ b/main_window.py
@@ -209,6 +209,16 @@ 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)
+ arrows_action = add_action(
+ "direction_arrows",
+ "Стрелки направления",
+ self.toggle_direction_arrows,
+ "arrow-right",
+ QKeySequence("Ctrl+Shift+A"),
+ "Показать или скрыть стрелки направления на линиях",
+ )
+ arrows_action.setCheckable(True)
+ arrows_action.setChecked(True)
add_action("toggle_sidebar", "Скрыть боковую панель", self.toggle_sidebar, "chevron-right", QKeySequence("F9"))
self._build_menu_bar()
self.addAction(self.delete_action)
@@ -234,6 +244,7 @@ class MainWindow(QMainWindow):
view_menu = menu_bar.addMenu("Вид")
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"])
@@ -349,6 +360,11 @@ class MainWindow(QMainWindow):
else:
self.collapse_sidebar()
+ def toggle_direction_arrows(self) -> None:
+ action = self.actions.get("direction_arrows")
+ visible = True if action is None else action.isChecked()
+ self.canvas.set_direction_arrows_visible(visible)
+
def collapse_sidebar(self) -> None:
if self.sidebar_splitter is None or self.sidebar_stack is None or self._sidebar_collapsed:
return
diff --git a/qa_smoke.py b/qa_smoke.py
index 52d0cf5..0bbeb49 100644
--- a/qa_smoke.py
+++ b/qa_smoke.py
@@ -82,6 +82,7 @@ def main() -> None:
test_grid_toggle_adds_grid_items,
test_canvas_connects_new_block_to_previous_non_empty_block,
test_canvas_draws_direction_arrows_on_connections,
+ test_canvas_can_toggle_direction_arrows,
test_canvas_rebuild_drops_deleted_selection_items,
test_canvas_refresh_does_not_accumulate_connection_items,
test_main_window_has_classic_menu_bar,
@@ -90,6 +91,7 @@ def main() -> None:
test_undo_redo_roundtrip_for_line_creation,
test_main_window_actions_use_lucide_icons,
test_main_window_action_shortcuts_are_assigned,
+ test_main_window_direction_arrow_action_toggles_canvas,
test_lucide_icon_names_are_declared,
test_table_hides_empty_line_index_columns,
test_sidebar_collapses_and_expands,
@@ -794,6 +796,37 @@ def test_canvas_draws_direction_arrows_on_connections() -> None:
assert sum(path.elementCount() for path in arrow_paths) >= 6
+def test_canvas_can_toggle_direction_arrows() -> None:
+ if LedCanvas is None or QApplication is None:
+ return
+ app = QApplication.instance() or QApplication([])
+ with tempfile.TemporaryDirectory() as temp_dir:
+ path = Path(temp_dir) / "arrows-toggle.json"
+ points = [
+ {"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0},
+ {"id": 1, "x": 34.0, "y": 0.0, "row": 0, "col": 1},
+ {"id": 2, "x": 68.0, "y": 0.0, "row": 0, "col": 2},
+ ]
+ path.write_text(json.dumps({"circles": points}), encoding="utf-8")
+ doc = MappingDocument()
+ doc.load(path)
+ line = doc.add_line()
+ doc.add_block(line.id, [0, 1, 2])
+
+ canvas = LedCanvas()
+ canvas.set_document(doc)
+ canvas.set_active_line(line.id)
+ assert any(item.zValue() == 2 for item in canvas.connection_items)
+
+ canvas.set_direction_arrows_visible(False)
+ assert canvas.direction_arrows_visible() is False
+ assert not any(item.zValue() == 2 for item in canvas.connection_items)
+
+ canvas.set_direction_arrows_visible(True)
+ assert canvas.direction_arrows_visible() is True
+ assert any(item.zValue() == 2 for item in canvas.connection_items)
+
+
def test_canvas_rebuild_drops_deleted_selection_items() -> None:
if LedCanvas is None or QApplication is None:
return
@@ -855,7 +888,7 @@ def test_classic_menu_contains_expected_actions() -> None:
expected = {
"Файл": ["Открыть", "Сохранить", "Сохранить как", "Сгенерировать скрипт"],
"Правка": ["Отменить", "Повторить", "Удалить фиксацию", "Удалить все", "Очистить выбор"],
- "Вид": ["Показать всё", "Сетка 34 мм"],
+ "Вид": ["Показать всё", "Сетка 34 мм", "Стрелки направления"],
"Окно": ["Скрыть боковую панель"],
"Инструменты": ["Новая линия", "Сгенерировать скрипт", "Автоблоки", "Кисть"],
}
@@ -950,6 +983,7 @@ def test_main_window_actions_use_lucide_icons() -> None:
"clear_selection",
"fit_view",
"grid",
+ "direction_arrows",
"toggle_sidebar",
}
missing = [key for key in expected_icon_actions if window.actions[key].icon().isNull()]
@@ -970,6 +1004,7 @@ def test_main_window_action_shortcuts_are_assigned() -> None:
"clear_selection": "Esc",
"fit_view": "Ctrl+0",
"grid": "G",
+ "direction_arrows": "Ctrl+Shift+A",
"toggle_sidebar": "F9",
"add_line": "Ctrl+L",
"auto_blocks": "Ctrl+Shift+B",
@@ -981,9 +1016,31 @@ def test_main_window_action_shortcuts_are_assigned() -> None:
window.close()
+def test_main_window_direction_arrow_action_toggles_canvas() -> None:
+ if MainWindow is None or QApplication is None:
+ return
+ app = QApplication.instance() or QApplication([])
+ window = MainWindow()
+ app.processEvents()
+
+ action = window.actions["direction_arrows"]
+ assert action.isChecked()
+ assert window.canvas.direction_arrows_visible() is True
+
+ action.trigger()
+ assert not action.isChecked()
+ assert window.canvas.direction_arrows_visible() is False
+
+ action.trigger()
+ assert action.isChecked()
+ assert window.canvas.direction_arrows_visible() is True
+ window.close()
+
+
def test_lucide_icon_names_are_declared() -> None:
expected_names = {
"archive-restore",
+ "arrow-right",
"chevron-right",
"eraser",
"eye",