From 8f814bc1fc0cd0723fb37595cb5e64cb1e83b5f1 Mon Sep 17 00:00:00 2001 From: Danil Iarosh Date: Fri, 31 Jul 2026 11:07:12 +0300 Subject: [PATCH] Add block renaming in GUI and support block name-based addressing in generated scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Реализовано переименование блоков по двойному клику и в контекстном меню дерева. - Добавлена функция транслитерации и санитарной очистки имён блоков для C++ идентификаторов. - Реализован массив строк имён блоков в flash-памяти (PROGMEM) и метод поиска getBlockIndexByName. - Добавлены перегрузки функций управления светодиодами, принимающие оригинальные строковые названия блоков. - Добавлены интеграционные тесты для проверки санитации имён, генерации макросов и функций поиска. --- main_window.py | 40 +++++++++- panel_mapping_generator.py | 145 ++++++++++++++++++++++++++++++++++++- qa_smoke.py | 29 ++++++++ 3 files changed, 210 insertions(+), 4 deletions(-) diff --git a/main_window.py b/main_window.py index ca6032b..be978ac 100644 --- a/main_window.py +++ b/main_window.py @@ -15,6 +15,7 @@ from PySide6.QtWidgets import ( QDoubleSpinBox, QFileDialog, QFormLayout, + QInputDialog, QHBoxLayout, QHeaderView, QLabel, @@ -242,6 +243,7 @@ class MainWindow(QMainWindow): self.tree.setContextMenuPolicy(Qt.CustomContextMenu) self.tree.itemSelectionChanged.connect(self._on_tree_selection) self.tree.customContextMenuRequested.connect(self._show_tree_menu) + self.tree.itemDoubleClicked.connect(self._on_tree_item_double_clicked) self.table = QTableWidget(0, 8) self.table.setHorizontalHeaderLabels( @@ -1681,12 +1683,17 @@ class MainWindow(QMainWindow): menu = QMenu(self) merge_action = menu.addAction("Объединить блоки") merge_action.setEnabled(self._can_merge_selected_tree_blocks()) - label = "Удалить выбранную фиксацию" + rename_block_action = None + data = None if item is not None: data = item.data(0, Qt.UserRole) - if data and data[0] == "line": + if data and data[0] == "block": + rename_block_action = menu.addAction("Переименовать блок") + label = "Удалить выбранную фиксацию" + if item is not None and data is not None: + if data[0] == "line": label = "Удалить линию" - elif data and data[0] == "block": + elif data[0] == "block": label = "Удалить блок" delete_action = menu.addAction(label) delete_action.setEnabled(item is not None) @@ -1697,6 +1704,9 @@ class MainWindow(QMainWindow): chosen = menu.exec(self.tree.viewport().mapToGlobal(pos)) if chosen == merge_action: self.merge_selected_blocks() + elif rename_block_action and chosen == rename_block_action: + line_id, block_id = data[1], data[2] + self.rename_block(line_id, block_id) elif chosen == delete_action: self.remove_selected_tree_item() elif chosen == clear_all_action: @@ -1704,6 +1714,30 @@ class MainWindow(QMainWindow): elif chosen == fit_action: self.canvas.fit_to_content() + def _on_tree_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None: + data = item.data(0, Qt.UserRole) + if data and data[0] == "block": + line_id, block_id = data[1], data[2] + self.rename_block(line_id, block_id) + + def rename_block(self, line_id: int, block_id: int) -> None: + block = self._find_block_or_none(line_id, block_id) + if block is None: + return + new_name, ok = QInputDialog.getText( + self, + "Переименовать блок", + "Введите новое имя блока:", + QLineEdit.EchoMode.Normal, + block.name + ) + if ok and new_name.strip() and new_name.strip() != block.name: + self._push_undo() + block.name = new_name.strip() + self._dirty = True + self.status.setText(f"Блок переименован в '{block.name}'.") + self._refresh_ui_state() + def merge_selected_blocks(self) -> None: selected_blocks = self._selected_tree_blocks() if len(selected_blocks) < 2: diff --git a/panel_mapping_generator.py b/panel_mapping_generator.py index 74910fd..fdc2b43 100644 --- a/panel_mapping_generator.py +++ b/panel_mapping_generator.py @@ -69,6 +69,51 @@ class BuildResult: unassigned_count: int +def sanitize_cpp_identifier(name: str) -> str: + # Transliteration dictionary for Russian Cyrillic + cyrillic_translit = { + 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', + 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', + 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', + 'ф': 'f', 'х': 'kh', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'shch', + 'ы': 'y', 'э': 'e', 'ю': 'yu', 'я': 'ya', + 'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo', + 'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'Y', 'К': 'K', 'Л': 'L', 'М': 'M', + 'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', + 'Ф': 'F', 'Х': 'Kh', 'Ц': 'Ts', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Shch', + 'Ы': 'Y', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya', + 'ь': '', 'Ь': '', 'ъ': '', 'Ъ': '' + } + + # Transliterate Cyrillic to Latin + transliterated = "".join(cyrillic_translit.get(c, c) for c in name) + + # Replace non-alphanumeric chars with underscore + sanitized = "" + for c in transliterated: + if c.isalnum() or c == '_': + sanitized += c + else: + sanitized += '_' + + # Remove multiple consecutive underscores + while "__" in sanitized: + sanitized = sanitized.replace("__", "_") + + # Strip leading/trailing underscores + sanitized = sanitized.strip("_") + + # Prepend underscore if starts with a digit + if sanitized and sanitized[0].isdigit(): + sanitized = "_" + sanitized + + # If empty, return a default prefix + if not sanitized: + sanitized = "BLOCK" + + return sanitized + + def generate_panel_mapping_from_document( document: MappingDocument, output_dir: str | Path, @@ -324,6 +369,13 @@ def write_vm_map_header( file.write("};\n\n") if generate_blocks: + # Generate compile-time block macros/constants + file.write("// Compile-time block indices\n") + for index, (offset, count, name) in enumerate(block_ranges): + identifier = sanitize_cpp_identifier(name) + file.write(f"#define BLOCK_{identifier} {index}\n") + file.write("\n") + file.write(f"#define TOTAL_BLOCK_LEDS {len(flat_block_leds)}\n\n") file.write("struct BlockRange {\n") file.write(" uint16_t offset;\n") @@ -344,6 +396,19 @@ def write_vm_map_header( file.write(f" {{{offset}, {count}}}{suffix} // {name}\n") file.write("};\n\n") + # String names array in PROGMEM + file.write("// Original string names of blocks in flash memory\n") + for index, (offset, count, name) in enumerate(block_ranges): + escaped_name = name.replace('"', '\\"') + file.write(f"const char BLOCK_NAME_{index}[] PROGMEM = \"{escaped_name}\";\n") + file.write("\n") + + file.write("const char* const BLOCK_NAMES[LED_BLOCK_COUNT] PROGMEM = {\n") + for index in range(len(block_ranges)): + suffix = "," if index < len(block_ranges) - 1 else "" + file.write(f" BLOCK_NAME_{index}{suffix}\n") + file.write("};\n\n") + # Inline helpers for blocks file.write("// Control block LEDs by count inside a block\n") file.write("inline void setBlockLEDsCount(uint16_t blockIdx, uint16_t count, uint32_t color) {\n") @@ -373,6 +438,35 @@ def write_vm_map_header( file.write(" if (strip < VM_NUM_STRIPS) {\n") file.write(" hwStrips[strip].setPixelColor(led_id, color);\n") file.write(" }\n") + file.write("}\n\n") + + # Add getBlockIndexByName + file.write("// Find block index by its string name (case-sensitive, original name)\n") + file.write("inline int16_t getBlockIndexByName(const char* name) {\n") + file.write(" for (uint16_t i = 0; i < LED_BLOCK_COUNT; i++) {\n") + file.write(" const char* blockNamePtr = (const char*)pgm_read_word(&BLOCK_NAMES[i]);\n") + file.write(" if (strcmp_P(name, blockNamePtr) == 0) {\n") + file.write(" return (int16_t)i;\n") + file.write(" }\n") + file.write(" }\n") + file.write(" return -1;\n") + file.write("}\n\n") + + # Add string-based control overloads + file.write("// Control block LEDs by string name and count\n") + file.write("inline void setBlockLEDsCount(const char* name, uint16_t count, uint32_t color) {\n") + file.write(" int16_t idx = getBlockIndexByName(name);\n") + file.write(" if (idx >= 0) {\n") + file.write(" setBlockLEDsCount((uint16_t)idx, count, color);\n") + file.write(" }\n") + file.write("}\n\n") + + file.write("// Control single block LED by block string name and 0-based index\n") + file.write("inline void setBlockLED(const char* name, uint16_t ledIdxInBlock, uint32_t color) {\n") + file.write(" int16_t idx = getBlockIndexByName(name);\n") + file.write(" if (idx >= 0) {\n") + file.write(" setBlockLED((uint16_t)idx, ledIdxInBlock, color);\n") + file.write(" }\n") file.write("}\n") @@ -495,6 +589,12 @@ def write_legacy_ino( file.write("}\n\n") if generate_blocks: + file.write("// Compile-time block indices\n") + for index, (offset, count, name) in enumerate(block_ranges): + identifier = sanitize_cpp_identifier(name) + file.write(f"#define BLOCK_{identifier} {index}\n") + file.write("\n") + file.write("#ifndef LED_BLOCK_COUNT\n") file.write(f"#define LED_BLOCK_COUNT {block_count}\n") file.write("#endif\n") @@ -514,8 +614,26 @@ def write_legacy_ino( suffix = "," if index < len(block_ranges) - 1 else "" file.write(f" {{{offset}, {count}}}{suffix} // {name}\n") file.write("};\n\n") + + # String names array in PROGMEM + file.write("// Original string names of blocks in flash memory\n") + for index, (offset, count, name) in enumerate(block_ranges): + escaped_name = name.replace('"', '\\"') + file.write(f"static const char BLOCK_NAME_{index}[] PROGMEM = \"{escaped_name}\";\n") + file.write("\n") + + file.write("static const char* const BLOCK_NAMES[LED_BLOCK_COUNT] PROGMEM = {\n") + for index in range(len(block_ranges)): + suffix = "," if index < len(block_ranges) - 1 else "" + file.write(f" BLOCK_NAME_{index}{suffix}\n") + file.write("};\n\n") + file.write("void setBlockLEDsCount(uint16_t blockIdx, uint16_t count, uint32_t color);\n") - file.write("void setBlockLED(uint16_t blockIdx, uint16_t ledIdxInBlock, uint32_t color);\n\n") + file.write("void setBlockLED(uint16_t blockIdx, uint16_t ledIdxInBlock, uint32_t color);\n") + file.write("int16_t getBlockIndexByName(const char* name);\n") + file.write("void setBlockLEDsCount(const char* name, uint16_t count, uint32_t color);\n") + file.write("void setBlockLED(const char* name, uint16_t ledIdxInBlock, uint32_t color);\n\n") + file.write("void setBlockLEDsCount(uint16_t blockIdx, uint16_t count, uint32_t color) {\n") file.write(" if (blockIdx >= LED_BLOCK_COUNT) return;\n") file.write(" uint16_t offset = pgm_read_word(&BLOCK_RANGES[blockIdx].offset);\n") @@ -529,6 +647,7 @@ def write_legacy_ino( file.write(" }\n") file.write(" }\n") file.write("}\n\n") + file.write("void setBlockLED(uint16_t blockIdx, uint16_t ledIdxInBlock, uint32_t color) {\n") file.write(" if (blockIdx >= LED_BLOCK_COUNT) return;\n") file.write(" uint16_t offset = pgm_read_word(&BLOCK_RANGES[blockIdx].offset);\n") @@ -541,6 +660,30 @@ def write_legacy_ino( file.write(" }\n") file.write("}\n\n") + file.write("int16_t getBlockIndexByName(const char* name) {\n") + file.write(" for (uint16_t i = 0; i < LED_BLOCK_COUNT; i++) {\n") + file.write(" const char* blockNamePtr = (const char*)pgm_read_word(&BLOCK_NAMES[i]);\n") + file.write(" if (strcmp_P(name, blockNamePtr) == 0) {\n") + file.write(" return (int16_t)i;\n") + file.write(" }\n") + file.write(" }\n") + file.write(" return -1;\n") + file.write("}\n\n") + + file.write("void setBlockLEDsCount(const char* name, uint16_t count, uint32_t color) {\n") + file.write(" int16_t idx = getBlockIndexByName(name);\n") + file.write(" if (idx >= 0) {\n") + file.write(" setBlockLEDsCount((uint16_t)idx, count, color);\n") + file.write(" }\n") + file.write("}\n\n") + + file.write("void setBlockLED(const char* name, uint16_t ledIdxInBlock, uint32_t color) {\n") + file.write(" int16_t idx = getBlockIndexByName(name);\n") + file.write(" if (idx >= 0) {\n") + file.write(" setBlockLED((uint16_t)idx, ledIdxInBlock, color);\n") + file.write(" }\n") + file.write("}\n\n") + file.write("void initVirtualMatrix() {\n") if generate_grid: file.write(" clearVirtualMatrix();\n") diff --git a/qa_smoke.py b/qa_smoke.py index 87c792d..5c82511 100644 --- a/qa_smoke.py +++ b/qa_smoke.py @@ -119,6 +119,7 @@ def main() -> None: test_uid_point_id_mode_does_not_fallback_to_source_ids, test_panel_mapping_generation_from_document, test_panel_mapping_generation_with_strategies, + test_block_renaming_and_naming_strategy, test_save_preserves_unknown_source_fields, test_ordered_json_preserves_document_and_remaps_uids, test_background_load_save_roundtrip, @@ -1064,6 +1065,34 @@ def test_panel_mapping_generation_with_strategies() -> None: assert "vmCount" in legacy_ino +def test_block_renaming_and_naming_strategy() -> None: + from panel_mapping_generator import sanitize_cpp_identifier + assert sanitize_cpp_identifier("Блок 1") == "Blok_1" + assert sanitize_cpp_identifier("Left Wing") == "Left_Wing" + assert sanitize_cpp_identifier("123-Block") == "_123_Block" + assert sanitize_cpp_identifier("") == "BLOCK" + + with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + block = doc.add_block(line.id, [0, 1]) + block.name = "Левое Крыло" + + result = generate_panel_mapping_from_document( + doc, Path(temp_dir), generate_grid=True, generate_blocks=True + ) + vm_map = result.generated_files["vm_map"].read_text(encoding="utf-8") + assert "#define BLOCK_Levoe_Krylo 0" in vm_map + assert "const char BLOCK_NAME_0[] PROGMEM = \"Левое Крыло\";" in vm_map + assert "getBlockIndexByName" in vm_map + + legacy_ino = result.generated_files["legacy_ino"].read_text(encoding="utf-8") + assert "#define BLOCK_Levoe_Krylo 0" in legacy_ino + assert "static const char BLOCK_NAME_0[] PROGMEM = \"Левое Крыло\";" in legacy_ino + assert "getBlockIndexByName" in legacy_ino + + def test_save_preserves_unknown_source_fields() -> None: with tempfile.TemporaryDirectory() as temp_dir: source = Path(temp_dir) / "custom.json"