Source code for mafw.steering_gui.dialogs.steering_text_editor

#  Copyright 2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""Dialog for manual editing of steering files in TOML format.

:Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
:Description: Allow advanced users to edit the TOML serialization of the current steering builder.
"""

from __future__ import annotations

import re
import tempfile
from pathlib import Path
from typing import Any

from PySide6.QtCore import QTimer, Signal
from PySide6.QtGui import QColor, QFont, QSyntaxHighlighter, Qt, QTextCharFormat
from PySide6.QtWidgets import (
    QDialog,
    QHBoxLayout,
    QLabel,
    QMessageBox,
    QPlainTextEdit,
    QPushButton,
    QVBoxLayout,
    QWidget,
)

from mafw.steering.builder import SteeringBuilder
from mafw.steering_gui.controllers.steering_controller import SteeringController

#: Debounce duration for TOML validation in milliseconds.
_VALIDATION_DEBOUNCE_MS = 500


[docs] class TomlSyntaxHighlighter(QSyntaxHighlighter): """Simple TOML syntax highlighter for the manual editor.""" def __init__(self, parent: Any = None) -> None: super().__init__(parent) self._comment_format = QTextCharFormat() self._comment_format.setForeground(QColor('#6a737d')) self._comment_format.setFontItalic(True) self._table_format = QTextCharFormat() self._table_format.setForeground(QColor('#005cc5')) self._table_format.setFontWeight(QFont.Weight.Bold) self._key_format = QTextCharFormat() self._key_format.setForeground(QColor('#22863a')) self._string_format = QTextCharFormat() self._string_format.setForeground(QColor('#b31d28')) self._number_format = QTextCharFormat() self._number_format.setForeground(QColor('#005cc5')) self._bool_format = QTextCharFormat() self._bool_format.setForeground(QColor('#6f42c1')) self._comment_pattern = re.compile(r'#.*$') self._table_pattern = re.compile(r'^\s*\[.*\]\s*$') self._key_pattern = re.compile(r'(?P<key>[A-Za-z0-9_.-]+)(?=\s*=)') self._string_pattern = re.compile(r'("([^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\')') self._number_pattern = re.compile(r'\b\d+(?:\.\d+)?\b') self._bool_pattern = re.compile(r'\b(true|false)\b', re.IGNORECASE) def highlightBlock(self, text: str) -> None: # noqa: N802 table_match = self._table_pattern.match(text) if table_match: self.setFormat(0, len(text), self._table_format) for match in self._key_pattern.finditer(text): self.setFormat(match.start('key'), len(match.group('key')), self._key_format) for match in self._string_pattern.finditer(text): self.setFormat(match.start(0), len(match.group(0)), self._string_format) for match in self._number_pattern.finditer(text): self.setFormat(match.start(0), len(match.group(0)), self._number_format) for match in self._bool_pattern.finditer(text): self.setFormat(match.start(0), len(match.group(0)), self._bool_format) comment_match = self._comment_pattern.search(text) if comment_match: self.setFormat(comment_match.start(0), len(comment_match.group(0)), self._comment_format)
[docs] class SteeringTextEditor(QDialog): """Dialog that lets users edit the steering file directly as TOML.""" builder_applied = Signal() def __init__(self, controller: SteeringController, parent: QWidget | None = None) -> None: super().__init__(parent) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self._controller = controller self.setMinimumSize(600, 500) self._temp_path: Path | None = None self._skip_dirty_prompt = False self._is_valid = False self._validation_message: str | None = None filename = self._current_filename() self._base_title = f'Steering file: {filename}' self.setWindowTitle(self._base_title) layout = QVBoxLayout(self) layout.addWidget(QLabel(f'Steering file: {filename}')) layout.addWidget(QLabel('Note: formatting and comments may be lost after saving')) self._editor = QPlainTextEdit(self) self._editor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) TomlSyntaxHighlighter(self._editor.document()) layout.addWidget(self._editor, 1) self._file_status_label = QLabel() layout.addWidget(self._file_status_label) buttons_row = QHBoxLayout() buttons_row.addStretch(1) self._cancel_button = QPushButton('Cancel') self._save_button = QPushButton('Save') self._apply_button = QPushButton('Apply and close') buttons_row.addWidget(self._cancel_button) buttons_row.addWidget(self._save_button) buttons_row.addWidget(self._apply_button) layout.addLayout(buttons_row) self._validation_timer = QTimer(self) self._validation_timer.setSingleShot(True) self._validation_timer.timeout.connect(self._validate_current_text) self._editor.document().modificationChanged.connect(self._on_modified_changed) self._editor.textChanged.connect(self._schedule_validation) self._cancel_button.clicked.connect(self._request_cancel) self._save_button.clicked.connect(self._save_temp_file) self._apply_button.clicked.connect(self._apply_and_close) if not self._load_initial_text(): return self._schedule_validation() def closeEvent(self, event: Any) -> None: # noqa: N802 if not self._skip_dirty_prompt and self._editor.document().isModified(): if not self._confirm_discard(): event.ignore() return self._cleanup_temp_file() super().closeEvent(event) def _current_filename(self) -> str: current = self._controller.current_file() return current.name if current is not None else 'Untitled' def _load_initial_text(self) -> bool: try: temp_handle = tempfile.NamedTemporaryFile(delete=False, suffix='.toml') temp_handle.close() self._temp_path = Path(temp_handle.name) self._controller.export_to_path(self._temp_path) text = self._temp_path.read_text(encoding='utf-8') except Exception as exc: # noqa: BLE001 QMessageBox.critical(self, 'Failed to open editor', str(exc)) self._cleanup_temp_file() self.reject() return False self._editor.setPlainText(text) self._editor.document().setModified(False) self._update_status_label() return True def _schedule_validation(self) -> None: self._validation_timer.start(_VALIDATION_DEBOUNCE_MS) def _on_modified_changed(self, modified: bool) -> None: self._update_window_title(modified) self._update_status_label() def _update_window_title(self, modified: bool) -> None: title = self._base_title if modified: title += ' *' self.setWindowTitle(title) def _update_status_label(self) -> None: modified = self._editor.document().isModified() state = 'Modified' if modified else 'Saved' validity = 'valid' if self._is_valid else 'invalid' self._file_status_label.setText(f'{state} and {validity}') self._file_status_label.setToolTip(self._validation_message or '') self._apply_button.setEnabled(self._is_valid) def _validate_current_text(self) -> None: text = self._editor.toPlainText() validation_level = self._controller.validation_level() try: builder = SteeringBuilder.from_toml_text(text) if validation_level is None: self._set_validation_state(True, None) return issues = builder.validate(validation_level) except Exception as exc: # noqa: BLE001 self._set_validation_state(False, str(exc)) return if issues: self._set_validation_state(False, str(issues[0])) else: self._set_validation_state(True, None) def _set_validation_state(self, valid: bool, message: str | None) -> None: self._is_valid = valid self._validation_message = message self._update_status_label() def _request_cancel(self) -> None: self.close() def _confirm_discard(self) -> bool: prompt = QMessageBox(self) prompt.setWindowTitle('Discard changes?') prompt.setText('Discard your edits to the steering file?') prompt.setStandardButtons( QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel, ) prompt.setDefaultButton(QMessageBox.StandardButton.Cancel) response = prompt.exec() return response == QMessageBox.StandardButton.Discard def _save_temp_file(self) -> None: self._validate_current_text() if self._temp_path is None: return try: self._temp_path.write_text(self._editor.toPlainText(), encoding='utf-8') except Exception as exc: # noqa: BLE001 QMessageBox.critical(self, 'Failed to save', str(exc)) return if not self._is_valid: QMessageBox.warning(self, 'Validation issues', self._validation_message or 'Invalid steering file.') self._update_status_label() return self._editor.document().setModified(False) self._update_status_label() def _apply_and_close(self) -> None: self._validate_current_text() if not self._is_valid: QMessageBox.warning(self, 'Validation issues', self._validation_message or 'Invalid steering file.') return if self._temp_path is None: return try: self._temp_path.write_text(self._editor.toPlainText(), encoding='utf-8') builder = SteeringBuilder.from_toml(self._temp_path) except Exception as exc: # noqa: BLE001 QMessageBox.critical(self, 'Failed to apply', str(exc)) return self._controller.replace_builder(builder) self.builder_applied.emit() self._skip_dirty_prompt = True self.accept() def _cleanup_temp_file(self) -> None: if self._temp_path is None: return try: self._temp_path.unlink(missing_ok=True) except Exception: # noqa: BLE001 pass self._temp_path = None