# 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