Add block renaming in GUI and support block name-based addressing in generated scripts

- Реализовано переименование блоков по двойному клику и в контекстном меню дерева.
- Добавлена функция транслитерации и санитарной очистки имён блоков для C++ идентификаторов.
- Реализован массив строк имён блоков в flash-памяти (PROGMEM) и метод поиска getBlockIndexByName.
- Добавлены перегрузки функций управления светодиодами, принимающие оригинальные строковые названия блоков.
- Добавлены интеграционные тесты для проверки санитации имён, генерации макросов и функций поиска.
This commit is contained in:
Данил Ярош 2026-07-31 11:07:12 +03:00
parent 228e4e4f0b
commit 8f814bc1fc
3 changed files with 210 additions and 4 deletions

View File

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

View File

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

View File

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