Compare commits

..

5 Commits

Author SHA1 Message Date
9312ea544b Added settings dialog with loglevel 2026-07-26 15:01:49 +02:00
3bd6cfb81d Logging 2026-07-26 14:46:18 +02:00
810f993830 Panel and Toolbar enable/disable items in View menu 2026-07-26 14:39:27 +02:00
f37cc41ed0 Added document tree and log widgets 2026-07-26 14:34:22 +02:00
4c3b8b4b6d Document handling
New/Open/Save/Save as and Undo/Redo
2026-07-26 13:41:23 +02:00
25 changed files with 1296 additions and 9 deletions

View File

@@ -1,10 +1,8 @@
from __future__ import annotations
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GUI_PACKAGE = ROOT / "src" / "bedit_gui"
@@ -15,9 +13,15 @@ QRC_FILE = GUI_PACKAGE / "resources" / "resources.qrc"
GENERATED_RESOURCES = (
GUI_PACKAGE
/ "resources"
/ "generated"
/ "resources_rc.py"
)
RESOURCE_IMPORT = "import bedit_gui.resources.resources_rc"
GENERATED_RESOURCE_IMPORT = (
"from bedit_gui.resources.generated import resources_rc"
)
def execute(*command: str) -> None:
print("+", " ".join(command))
@@ -39,6 +43,16 @@ def generate_ui() -> None:
"-o",
str(destination),
)
generated = destination.read_text(encoding="utf-8")
if RESOURCE_IMPORT in generated:
destination.write_text(
generated.replace(
RESOURCE_IMPORT,
GENERATED_RESOURCE_IMPORT,
1,
),
encoding="utf-8",
)
def generate_resources() -> None:

View File

@@ -1,18 +1,36 @@
from __future__ import annotations
import sys
from PySide6.QtWidgets import QApplication
from bedit_gui.resources import resources_rc
from bedit_gui.controllers.document_controller import DocumentController
from bedit_gui.controllers.log_controller import LogController
from bedit_gui.controllers.settings_controller import SettingsController
from bedit_gui.controllers.undo_controller import UndoController
from bedit_gui.controllers.view_menu_controller import ViewMenuController
from bedit_gui.controllers.window_state_controller import WindowStateController
from bedit_gui.documents import Document
from bedit_gui.services.application_settings import ApplicationSettings
from bedit_gui.views.main_window import MainWindow
def main() -> int:
app = QApplication(sys.argv)
app.setOrganizationName("BEdit")
app.setApplicationName("BEdit")
settings = ApplicationSettings()
document = Document(app)
window = MainWindow()
window.show()
LogController(window, settings.log_level)
DocumentController(document, window)
SettingsController(window, settings)
UndoController(document, window)
ViewMenuController(window)
window_state_controller = WindowStateController(app, window)
window_state_controller.restore()
window.showMaximized()
return app.exec()

View File

@@ -0,0 +1,124 @@
from __future__ import annotations
from pathlib import Path
from typing import Protocol
from PySide6.QtCore import QEvent, QObject
from bedit_gui.documents import Document
from bedit_gui.services.application_logging import get_logger
from bedit_gui.views.dialogs.document_dialogs import (
DocumentDialogs,
SaveChangesChoice,
)
from bedit_gui.views.main_window import MainWindow
logger = get_logger(__name__)
class DocumentDialogProvider(Protocol):
def choose_open_path(self, current_path: Path | None) -> Path | None: ...
def choose_save_path(self, current_path: Path | None) -> Path | None: ...
def ask_save_changes(self) -> SaveChangesChoice: ...
def show_file_error(self, title: str, error: Exception) -> None: ...
class DocumentController(QObject):
"""Coordinates the single-document workflow with the main window."""
def __init__(
self,
document: Document,
window: MainWindow,
dialogs: DocumentDialogProvider | None = None,
) -> None:
super().__init__(window)
self.document = document
self.window = window
self.dialogs = dialogs or DocumentDialogs(window)
window.ui.actionNew_File.triggered.connect(self.new_document)
window.ui.actionOpen_File.triggered.connect(self.open_document)
window.ui.actionSave_File.triggered.connect(self.save_document)
window.ui.actionSave_File_As.triggered.connect(self.save_document_as)
window.ui.actionClose.triggered.connect(window.close)
document.path_changed.connect(self.update_window_title)
document.modified_changed.connect(self.update_window_title)
window.installEventFilter(self)
self.update_window_title()
def new_document(self) -> None:
if self.maybe_save_changes():
self.document.new()
logger.info("Created new document")
def open_document(self) -> None:
if not self.maybe_save_changes():
return
path = self.dialogs.choose_open_path(self.document.path)
if path is None:
return
try:
self.document.open(path)
except (OSError, TypeError, ValueError) as exc:
logger.exception("Could not open document %s", path)
self.dialogs.show_file_error("Could not open document", exc)
else:
logger.info("Opened document: %s", path)
def save_document(self) -> bool:
if self.document.path is None:
return self.save_document_as()
try:
self.document.save()
except (OSError, TypeError, ValueError) as exc:
logger.exception("Could not save document %s", self.document.path)
self.dialogs.show_file_error("Could not save document", exc)
return False
logger.info("Saved document: %s", self.document.path)
return True
def save_document_as(self) -> bool:
path = self.dialogs.choose_save_path(self.document.path)
if path is None:
return False
try:
self.document.save_as(path)
except (OSError, TypeError, ValueError) as exc:
logger.exception("Could not save document as %s", path)
self.dialogs.show_file_error("Could not save document", exc)
return False
logger.info("Saved document as: %s", path)
return True
def maybe_save_changes(self) -> bool:
if not self.document.modified:
return True
choice = self.dialogs.ask_save_changes()
if choice is SaveChangesChoice.SAVE:
return self.save_document()
return choice is SaveChangesChoice.DISCARD
def update_window_title(self, *_args: object) -> None:
name = self.document.path.name if self.document.path else "Untitled"
marker = "*" if self.document.modified else ""
self.window.setWindowTitle(f"{name}{marker} — BEdit")
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
window = getattr(self, "window", None)
if (
watched is window
and event.type() == QEvent.Type.Close
and not self.maybe_save_changes()
):
event.ignore()
return True
return super().eventFilter(watched, event)

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
import logging
from PySide6.QtCore import QObject, Signal
from bedit_gui.services.application_logging import configure_logging
from bedit_gui.views.main_window import MainWindow
from bedit_gui.views.models import LogListModel
class _LogEmitter(QObject):
message = Signal(str)
class _QtLogHandler(logging.Handler):
def __init__(self, emitter: _LogEmitter) -> None:
super().__init__()
self._emitter = emitter
def emit(self, record: logging.LogRecord) -> None:
try:
self._emitter.message.emit(self.format(record))
except (RuntimeError, TypeError, ValueError):
self.handleError(record)
class LogController(QObject):
"""Routes standard application log records into the log list view."""
def __init__(
self,
window: MainWindow,
level: int | str = logging.INFO,
) -> None:
super().__init__(window)
self.model = LogListModel()
self.emitter = _LogEmitter(self)
self.handler = _QtLogHandler(self.emitter)
self.handler.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
)
self.emitter.message.connect(self.model.append)
self.model.rowsInserted.connect(window.ui.listView.scrollToBottom)
window.ui.listView.setModel(self.model)
configure_logging(self.handler, level)

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Protocol
from PySide6.QtCore import QObject
from PySide6.QtWidgets import QDialog
from bedit_gui.services.application_logging import get_logger, set_log_level
from bedit_gui.services.application_settings import ApplicationSettings
from bedit_gui.views.dialogs.settings_dialog import SettingsDialog
from bedit_gui.views.main_window import MainWindow
logger = get_logger(__name__)
class SettingsDialogLike(Protocol):
@property
def log_level(self) -> int: ...
def exec(self) -> int: ...
SettingsDialogFactory = Callable[[int, MainWindow], SettingsDialogLike]
class SettingsController(QObject):
"""Opens the settings dialog and applies accepted preferences."""
def __init__(
self,
window: MainWindow,
settings: ApplicationSettings,
dialog_factory: SettingsDialogFactory = SettingsDialog,
) -> None:
super().__init__(window)
self.window = window
self.settings = settings
self.dialog_factory = dialog_factory
window.ui.actionSettings.triggered.connect(self.open_settings)
def open_settings(self) -> None:
dialog = self.dialog_factory(self.settings.log_level, self.window)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
self.settings.log_level = dialog.log_level
set_log_level(dialog.log_level)
logger.info("Application settings updated")

View File

@@ -0,0 +1,49 @@
from PySide6.QtCore import QObject
from bedit_gui.documents import Document
from bedit_gui.services.application_logging import get_logger
from bedit_gui.views.main_window import MainWindow
logger = get_logger(__name__)
class UndoController(QObject):
def __init__(self, document: Document, window: MainWindow) -> None:
super().__init__(window)
self.document = document
self.window = window
undo_action = window.ui.actionUndo
redo_action = window.ui.actionRedo
undo_stack = document.undo_stack
undo_action.triggered.connect(self.undo)
redo_action.triggered.connect(self.redo)
undo_stack.canUndoChanged.connect(undo_action.setEnabled)
undo_stack.canRedoChanged.connect(redo_action.setEnabled)
undo_stack.undoTextChanged.connect(self._update_undo_text)
undo_stack.redoTextChanged.connect(self._update_redo_text)
undo_action.setEnabled(undo_stack.canUndo())
redo_action.setEnabled(undo_stack.canRedo())
def undo(self) -> None:
command = self.document.undo_stack.undoText()
self.document.undo_stack.undo()
logger.info("Undo: %s", command)
def redo(self) -> None:
command = self.document.undo_stack.redoText()
self.document.undo_stack.redo()
logger.info("Redo: %s", command)
def _update_undo_text(self, command: str) -> None:
text = f"Undo {command}" if command else "Undo"
self.window.ui.actionUndo.setText(text)
def _update_redo_text(self, command: str) -> None:
text = f"Redo {command}" if command else "Redo"
self.window.ui.actionRedo.setText(text)

View File

@@ -0,0 +1,49 @@
from __future__ import annotations
import re
from PySide6.QtCore import QObject
from PySide6.QtWidgets import QDockWidget, QMenu, QToolBar
from bedit_gui.views.main_window import MainWindow
class ViewMenuController(QObject):
"""Populate View submenus with synchronized visibility actions."""
def __init__(self, window: MainWindow) -> None:
super().__init__(window)
self.panels_menu = QMenu("Panels", window.ui.menuView)
self.toolbars_menu = QMenu("Toolbars", window.ui.menuView)
window.ui.actionPanels.setMenu(self.panels_menu)
window.ui.actionToolbars.setMenu(self.toolbars_menu)
self._populate(
self.panels_menu,
window.findChildren(QDockWidget),
)
self._populate(
self.toolbars_menu,
window.findChildren(QToolBar),
)
def _populate(
self,
menu: QMenu,
widgets: list[QDockWidget] | list[QToolBar],
) -> None:
for widget in sorted(widgets, key=self._label):
action = widget.toggleViewAction()
action.setText(self._label(widget))
menu.addAction(action)
@staticmethod
def _label(widget: QDockWidget | QToolBar) -> str:
title = widget.windowTitle().strip()
if title and title.lower() != "toolbar":
return title
name = re.sub(r"(DockWidget|Widget|ToolBar)$", "", widget.objectName())
return re.sub(r"(?<!^)(?=[A-Z])", " ", name).title()

View File

@@ -0,0 +1,37 @@
from PySide6.QtCore import QObject, QSettings, QByteArray
from PySide6.QtWidgets import QApplication
from bedit_gui.views.main_window import MainWindow
class WindowStateController(QObject):
def __init__(self,app: QApplication,window: MainWindow) -> None:
super().__init__(window)
self.window = window
self.settings = QSettings()
# Capture the layout created by Designer.
self._default_geometry = QByteArray(window.saveGeometry())
self._default_state = QByteArray(window.saveState())
app.aboutToQuit.connect(self.save)
window.ui.actionReset_Layout.triggered.connect(self.reset)
def restore(self) -> None:
if self.settings.contains("main_window/geometry"):
self.window.restoreGeometry(self.settings.value("main_window/geometry"))
if self.settings.contains("main_window/state"):
self.window.restoreState(self.settings.value("main_window/state"))
def save(self) -> None:
self.settings.setValue("main_window/geometry",self.window.saveGeometry())
self.settings.setValue("main_window/state",self.window.saveState())
def reset(self) -> None:
self.settings.remove("main_window/geometry")
self.settings.remove("main_window/state")
self.window.restoreGeometry(self._default_geometry)
self.window.restoreState(self._default_state)
self.window.showMaximized()

View File

@@ -0,0 +1,3 @@
from .document import Document
__all__ = ["Document"]

View File

@@ -0,0 +1,80 @@
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import QObject, Signal
from PySide6.QtGui import QUndoStack
from bedit_core.models import ID
from bedit_core.models import Document as CoreDocument
from bedit_gui.services import document_files
class Document(QObject):
"""The editable document currently owned by the GUI application."""
model_changed = Signal(object)
path_changed = Signal(object)
modified_changed = Signal(bool)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self.undo_stack = QUndoStack(self)
self.undo_stack.cleanChanged.connect(self._on_clean_changed)
self._model = self._new_model()
self._path: Path | None = None
self.undo_stack.setClean()
@property
def model(self) -> CoreDocument:
return self._model
@property
def path(self) -> Path | None:
return self._path
@property
def modified(self) -> bool:
return not self.undo_stack.isClean()
def new(self) -> None:
self._replace(self._new_model(), None)
def open(self, path: str | Path) -> None:
file_path = Path(path)
model = document_files.load(file_path)
self._replace(model, file_path)
def save(self) -> None:
if self._path is None:
raise ValueError("the document does not have a file path")
document_files.save(self._model, self._path)
self.undo_stack.setClean()
def save_as(self, path: str | Path) -> None:
file_path = Path(path)
document_files.save(self._model, file_path)
if file_path != self._path:
self._path = file_path
self.path_changed.emit(file_path)
self.undo_stack.setClean()
def _replace(self, model: CoreDocument, path: Path | None) -> None:
self.undo_stack.clear()
self._model = model
self._path = path
self.model_changed.emit(model)
self.path_changed.emit(path)
self.undo_stack.setClean()
def _on_clean_changed(self, clean: bool) -> None:
self.modified_changed.emit(not clean)
@staticmethod
def _new_model() -> CoreDocument:
return CoreDocument(
format_version=1,
id=ID(),
name="Untitled",
root={},
)

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import logging
LOGGER_NAMESPACE = "bedit"
_application_logger = logging.getLogger(LOGGER_NAMESPACE)
_application_logger.propagate = False
_application_logger.addHandler(logging.NullHandler())
def get_logger(name: str) -> logging.Logger:
"""Return a logger routed to the BEdit application log."""
return logging.getLogger(f"{LOGGER_NAMESPACE}.{name}")
def configure_logging(
handler: logging.Handler,
level: int | str = logging.INFO,
) -> None:
"""Replace the application output handler and set its log level."""
for existing in list(_application_logger.handlers):
_application_logger.removeHandler(existing)
_application_logger.addHandler(handler)
set_log_level(level)
def set_log_level(level: int | str) -> None:
"""Set the minimum severity shown by all BEdit loggers."""
if isinstance(level, str):
level = level.upper()
_application_logger.setLevel(level)

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
import logging
from PySide6.QtCore import QSettings
class ApplicationSettings:
"""Typed access to persistent BEdit application settings."""
LOG_LEVEL_KEY = "logging/level"
DEFAULT_LOG_LEVEL = logging.INFO
def __init__(self, settings: QSettings | None = None) -> None:
self._settings = settings if settings is not None else QSettings()
@property
def log_level(self) -> int:
return self._settings.value(
self.LOG_LEVEL_KEY,
self.DEFAULT_LOG_LEVEL,
type=int,
)
@log_level.setter
def log_level(self, level: int) -> None:
self._settings.setValue(self.LOG_LEVEL_KEY, level)

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from pathlib import Path
from bedit_core.models import Document
from bedit_core.serialization import load as load_document
from bedit_core.serialization import save as save_document
def load(path: str | Path) -> Document:
"""Load a supported document file into the core model."""
return load_document(path)
def save(document: Document, path: str | Path) -> None:
"""Save a core model using the format selected by its file extension."""
save_document(document, path)

View File

@@ -6,20 +6,30 @@
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>600</height>
<width>940</width>
<height>729</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<property name="windowIcon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/office-chart-line.png</normaloff>:/icons/icons/office-chart-line.png</iconset>
</property>
<property name="documentMode">
<bool>false</bool>
</property>
<property name="tabShape">
<enum>QTabWidget::TabShape::Triangular</enum>
</property>
<widget class="QWidget" name="centralwidget"/>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<width>940</width>
<height>22</height>
</rect>
</property>
@@ -39,11 +49,18 @@
<property name="title">
<string>Edit</string>
</property>
<addaction name="actionUndo"/>
<addaction name="actionRedo"/>
<addaction name="separator"/>
<addaction name="actionSettings"/>
</widget>
<widget class="QMenu" name="menuView">
<property name="title">
<string>View</string>
</property>
<addaction name="actionReset_Layout"/>
<addaction name="actionPanels"/>
<addaction name="actionToolbars"/>
</widget>
<widget class="QMenu" name="menuHelp">
<property name="title">
@@ -72,6 +89,61 @@
<addaction name="actionSave_File"/>
<addaction name="actionSave_File_As"/>
</widget>
<widget class="QToolBar" name="undoToolBar">
<property name="windowTitle">
<string>toolBar</string>
</property>
<attribute name="toolBarArea">
<enum>TopToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
</attribute>
<addaction name="actionUndo"/>
<addaction name="actionRedo"/>
</widget>
<widget class="QDockWidget" name="documentTreeWidget">
<property name="minimumSize">
<size>
<width>150</width>
<height>533</height>
</size>
</property>
<property name="windowTitle">
<string>Document Tree</string>
</property>
<attribute name="dockWidgetArea">
<number>1</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTreeView" name="documentTree"/>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="logWidget">
<property name="minimumSize">
<size>
<width>150</width>
<height>107</height>
</size>
</property>
<property name="windowTitle">
<string>Log</string>
</property>
<attribute name="dockWidgetArea">
<number>8</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_5">
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QListView" name="listView"/>
</item>
</layout>
</widget>
</widget>
<action name="actionOpen_File">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
@@ -166,6 +238,62 @@
<enum>QAction::MenuRole::AboutQtRole</enum>
</property>
</action>
<action name="actionUndo">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/edit-undo.png</normaloff>:/icons/icons/edit-undo.png</iconset>
</property>
<property name="text">
<string>Undo</string>
</property>
<property name="shortcut">
<string>Ctrl+Z</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionRedo">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/edit-redo.png</normaloff>:/icons/icons/edit-redo.png</iconset>
</property>
<property name="text">
<string>Redo</string>
</property>
<property name="shortcut">
<string>Ctrl+Y</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionReset_Layout">
<property name="text">
<string>Reset Layout</string>
</property>
<property name="toolTip">
<string>Reset window layout to default</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionPanels">
<property name="text">
<string>Panels</string>
</property>
</action>
<action name="actionToolbars">
<property name="text">
<string>Toolbars</string>
</property>
</action>
<action name="actionSettings">
<property name="text">
<string>Settings</string>
</property>
</action>
</widget>
<resources>
<include location="../../resources/resources.qrc"/>

View File

@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Settings</class>
<widget class="QDialog" name="Settings">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>230</height>
</rect>
</property>
<property name="windowTitle">
<string>Settings</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="General">
<attribute name="title">
<string>General</string>
</attribute>
<layout class="QFormLayout" name="formLayout">
<item row="0" column="1">
<widget class="QComboBox" name="logLevel">
<item>
<property name="text">
<string>Debug</string>
</property>
</item>
<item>
<property name="text">
<string>Info</string>
</property>
</item>
<item>
<property name="text">
<string>Warning</string>
</property>
</item>
<item>
<property name="text">
<string>Error</string>
</property>
</item>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="lableLogLevel">
<property name="text">
<string>Log level:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>Settings</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>Settings</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
from enum import Enum, auto
from pathlib import Path
from PySide6.QtWidgets import QFileDialog, QMessageBox, QWidget
class SaveChangesChoice(Enum):
SAVE = auto()
DISCARD = auto()
CANCEL = auto()
class DocumentDialogs:
"""All modal dialogs used by the document workflow."""
FILE_FILTER = "BEdit documents (*.beb *.json);;BEdit binary (*.beb);;JSON (*.json)"
def __init__(self, parent: QWidget) -> None:
self._parent = parent
def choose_open_path(self, current_path: Path | None) -> Path | None:
directory = str(current_path.parent) if current_path else ""
file_name, _ = QFileDialog.getOpenFileName(
self._parent,
"Open BEdit Document",
directory,
self.FILE_FILTER,
)
return Path(file_name) if file_name else None
def choose_save_path(self, current_path: Path | None) -> Path | None:
suggested = str(current_path) if current_path else "Untitled.beb"
file_name, selected_filter = QFileDialog.getSaveFileName(
self._parent,
"Save BEdit Document",
suggested,
self.FILE_FILTER,
)
if not file_name:
return None
path = Path(file_name)
if not path.suffix:
suffix = ".json" if "JSON" in selected_filter else ".beb"
path = path.with_suffix(suffix)
return path
def ask_save_changes(self) -> SaveChangesChoice:
answer = QMessageBox.warning(
self._parent,
"Unsaved Changes",
"The current document has unsaved changes.",
QMessageBox.StandardButton.Save
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Save,
)
if answer == QMessageBox.StandardButton.Save:
return SaveChangesChoice.SAVE
if answer == QMessageBox.StandardButton.Discard:
return SaveChangesChoice.DISCARD
return SaveChangesChoice.CANCEL
def show_file_error(self, title: str, error: Exception) -> None:
QMessageBox.critical(self._parent, title, str(error))

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import logging
from PySide6.QtWidgets import QDialog, QWidget
from bedit_gui.ui.generated.ui_settings_dialog import Ui_Settings
class SettingsDialog(QDialog):
"""Handwritten behavior for the generated settings form."""
LOG_LEVELS = (
logging.DEBUG,
logging.INFO,
logging.WARNING,
logging.ERROR,
)
def __init__(
self,
log_level: int,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.ui = Ui_Settings()
self.ui.setupUi(self)
self.setWindowTitle("Settings")
for index, level in enumerate(self.LOG_LEVELS):
self.ui.logLevel.setItemData(index, level)
selected = self.ui.logLevel.findData(log_level)
self.ui.logLevel.setCurrentIndex(
selected if selected >= 0 else self.LOG_LEVELS.index(logging.INFO)
)
@property
def log_level(self) -> int:
return int(self.ui.logLevel.currentData())

View File

@@ -1,4 +1,5 @@
from PySide6.QtWidgets import QMainWindow
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QMainWindow, QTabWidget
from bedit_gui.ui.generated.ui_main_window import Ui_MainWindow
@@ -9,3 +10,7 @@ class MainWindow(QMainWindow):
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.setTabPosition(Qt.AllDockWidgetAreas, QTabWidget.North)
self.setCorner(Qt.Corner.BottomLeftCorner, Qt.DockWidgetArea.LeftDockWidgetArea)
self.setCorner(Qt.Corner.BottomRightCorner, Qt.DockWidgetArea.RightDockWidgetArea)

View File

@@ -0,0 +1,3 @@
from .log_list_model import LogListModel
__all__ = ["LogListModel"]

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from PySide6.QtCore import QAbstractListModel, QModelIndex, Qt
class LogListModel(QAbstractListModel):
"""Bounded list model containing formatted application log messages."""
def __init__(self, max_entries: int = 2_000) -> None:
super().__init__()
self._messages: list[str] = []
self._max_entries = max_entries
def rowCount(self, parent: QModelIndex | None = None) -> int:
return (
0
if parent is not None and parent.isValid()
else len(self._messages)
)
def data(
self,
index: QModelIndex,
role: int = Qt.ItemDataRole.DisplayRole,
) -> str | None:
if (
not index.isValid()
or not 0 <= index.row() < len(self._messages)
):
return None
if role in (
Qt.ItemDataRole.DisplayRole,
Qt.ItemDataRole.ToolTipRole,
):
return self._messages[index.row()]
return None
def append(self, message: str) -> None:
overflow = len(self._messages) - self._max_entries + 1
if overflow > 0:
self.beginRemoveRows(QModelIndex(), 0, overflow - 1)
del self._messages[:overflow]
self.endRemoveRows()
row = len(self._messages)
self.beginInsertRows(QModelIndex(), row, row)
self._messages.append(message)
self.endInsertRows()

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
import logging
from pathlib import Path
import pytest
from PySide6.QtCore import Qt
from PySide6.QtGui import QUndoCommand
from PySide6.QtWidgets import QApplication
from bedit_gui.controllers.document_controller import DocumentController
from bedit_gui.controllers.log_controller import LogController
from bedit_gui.controllers.undo_controller import UndoController
from bedit_gui.documents import Document
from bedit_gui.services.application_logging import get_logger, set_log_level
from bedit_gui.views.dialogs.document_dialogs import SaveChangesChoice
from bedit_gui.views.main_window import MainWindow
class FakeDialogs:
def __init__(self, path: Path) -> None:
self.path = path
def choose_open_path(self, _current_path: Path | None) -> Path:
return self.path
def choose_save_path(self, _current_path: Path | None) -> Path:
return self.path
def ask_save_changes(self) -> SaveChangesChoice:
return SaveChangesChoice.DISCARD
def show_file_error(self, _title: str, error: Exception) -> None:
raise AssertionError("unexpected file error") from error
@pytest.fixture(scope="module")
def qt_app() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.mark.unit
def test_log_level_and_qt_log_model(qt_app: QApplication) -> None:
window = MainWindow()
controller = LogController(window, logging.WARNING)
logger = get_logger("tests")
logger.info("hidden message")
logger.warning("visible message")
qt_app.processEvents()
assert controller.model.rowCount() == 1
assert "WARNING visible message" in controller.model.data(
controller.model.index(0),
Qt.ItemDataRole.DisplayRole,
)
set_log_level(logging.INFO)
logger.info("now visible")
qt_app.processEvents()
assert controller.model.rowCount() == 2
@pytest.mark.unit
def test_document_and_undo_actions_are_logged(
qt_app: QApplication,
tmp_path: Path,
) -> None:
path = tmp_path / "document.json"
window = MainWindow()
log_controller = LogController(window)
document = Document(qt_app)
dialogs = FakeDialogs(path)
document_controller = DocumentController(document, window, dialogs)
undo_controller = UndoController(document, window)
document_controller.new_document()
document_controller.save_document_as()
document_controller.save_document()
document_controller.open_document()
document.undo_stack.push(QUndoCommand("test change"))
undo_controller.undo()
undo_controller.redo()
qt_app.processEvents()
messages = [
log_controller.model.data(log_controller.model.index(row))
for row in range(log_controller.model.rowCount())
]
assert any("Created new document" in message for message in messages)
assert any("Opened document:" in message for message in messages)
assert any("Saved document:" in message for message in messages)
assert any("Saved document as:" in message for message in messages)
assert any("Undo: test change" in message for message in messages)
assert any("Redo: test change" in message for message in messages)

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
from pathlib import Path
import pytest
from PySide6.QtGui import QUndoCommand
from PySide6.QtWidgets import QApplication
from bedit_gui.controllers.document_controller import DocumentController
from bedit_gui.documents import Document
from bedit_gui.views.dialogs.document_dialogs import SaveChangesChoice
from bedit_gui.views.main_window import MainWindow
class FakeDialogs:
def __init__(self) -> None:
self.open_path: Path | None = None
self.save_path: Path | None = None
self.save_choice = SaveChangesChoice.CANCEL
self.errors: list[tuple[str, Exception]] = []
def choose_open_path(self, _current_path: Path | None) -> Path | None:
return self.open_path
def choose_save_path(self, _current_path: Path | None) -> Path | None:
return self.save_path
def ask_save_changes(self) -> SaveChangesChoice:
return self.save_choice
def show_file_error(self, title: str, error: Exception) -> None:
self.errors.append((title, error))
@pytest.fixture(scope="module")
def qt_app() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.mark.unit
def test_new_document_respects_unsaved_changes(qt_app: QApplication) -> None:
document = Document(qt_app)
window = MainWindow()
dialogs = FakeDialogs()
controller = DocumentController(document, window, dialogs)
original_id = document.model.id
document.undo_stack.push(QUndoCommand("change"))
controller.new_document()
assert document.model.id == original_id
dialogs.save_choice = SaveChangesChoice.DISCARD
controller.new_document()
assert document.model.id != original_id
assert not document.modified
@pytest.mark.unit
def test_save_as_then_open_document(
qt_app: QApplication,
tmp_path: Path,
) -> None:
document = Document(qt_app)
window = MainWindow()
dialogs = FakeDialogs()
controller = DocumentController(document, window, dialogs)
original_id = document.model.id
path = tmp_path / "document.json"
dialogs.save_path = path
assert controller.save_document_as()
document.new()
dialogs.open_path = path
controller.open_document()
assert document.model.id == original_id
assert document.path == path
assert not dialogs.errors

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from pathlib import Path
import pytest
from PySide6.QtGui import QUndoCommand
from bedit_gui.documents import Document
@pytest.mark.unit
@pytest.mark.parametrize("suffix", [".beb", ".json"])
def test_document_save_and_open(tmp_path: Path, suffix: str) -> None:
path = tmp_path / f"document{suffix}"
document = Document()
original_id = document.model.id
document.save_as(path)
document.new()
assert document.model.id != original_id
document.open(path)
assert document.model.id == original_id
assert document.path == path
assert not document.modified
@pytest.mark.unit
def test_document_modified_state_uses_undo_stack() -> None:
document = Document()
document.undo_stack.push(QUndoCommand("change"))
assert document.modified
document.undo_stack.setClean()
assert not document.modified

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
import logging
from pathlib import Path
import pytest
from PySide6.QtCore import QSettings
from PySide6.QtWidgets import QApplication, QDialog
from bedit_gui.controllers.settings_controller import SettingsController
from bedit_gui.services.application_logging import get_logger
from bedit_gui.services.application_settings import ApplicationSettings
from bedit_gui.views.main_window import MainWindow
class FakeSettingsDialog:
def __init__(self, log_level: int) -> None:
self.initial_log_level = log_level
self.log_level = logging.ERROR
def exec(self) -> QDialog.DialogCode:
return QDialog.DialogCode.Accepted
@pytest.fixture(scope="module")
def qt_app() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.mark.unit
def test_log_level_is_persisted(tmp_path: Path) -> None:
path = tmp_path / "settings.ini"
backend = QSettings(str(path), QSettings.Format.IniFormat)
settings = ApplicationSettings(backend)
settings.log_level = logging.DEBUG
backend.sync()
reloaded = ApplicationSettings(
QSettings(str(path), QSettings.Format.IniFormat)
)
assert reloaded.log_level == logging.DEBUG
@pytest.mark.unit
def test_settings_action_applies_log_level(
qt_app: QApplication,
tmp_path: Path,
) -> None:
backend = QSettings(
str(tmp_path / "settings.ini"),
QSettings.Format.IniFormat,
)
settings = ApplicationSettings(backend)
window = MainWindow()
dialogs: list[FakeSettingsDialog] = []
def make_dialog(
log_level: int,
_window: MainWindow,
) -> FakeSettingsDialog:
dialog = FakeSettingsDialog(log_level)
dialogs.append(dialog)
return dialog
SettingsController(window, settings, make_dialog)
window.ui.actionSettings.trigger()
assert dialogs[0].initial_log_level == logging.INFO
assert settings.log_level == logging.ERROR
assert get_logger("tests").isEnabledFor(logging.ERROR)
assert not get_logger("tests").isEnabledFor(logging.WARNING)

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
import pytest
from PySide6.QtWidgets import QApplication
from bedit_gui.controllers.view_menu_controller import ViewMenuController
from bedit_gui.views.main_window import MainWindow
@pytest.fixture(scope="module")
def qt_app() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.mark.unit
def test_view_submenus_contain_visibility_actions(
qt_app: QApplication,
) -> None:
window = MainWindow()
controller = ViewMenuController(window)
assert window.ui.actionPanels.menu() is controller.panels_menu
assert window.ui.actionToolbars.menu() is controller.toolbars_menu
assert [action.text() for action in controller.panels_menu.actions()] == [
"Document Tree",
"Log",
]
assert [action.text() for action in controller.toolbars_menu.actions()] == [
"File",
"Undo",
]
assert all(
action.isCheckable()
for action in (
controller.panels_menu.actions()
+ controller.toolbars_menu.actions()
)
)