From 07cc66f4e508d9b7b4a983ded066abcf4b6ff849 Mon Sep 17 00:00:00 2001 From: Geoff Riley Date: Sat, 26 Sep 2026 15:43:21 +0100 Subject: [PATCH 01/11] Align Python support and package metadata --- pyproject.toml | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 82e42cf..1de6d3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,32 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + [project] name = "textbeside" version = "0.1.0" -requires-python = ">=3.14" -dependencies = [] +description = "A local-first split-view transcription workbench" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "PySide6>=6.7,<7", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "ruff>=0.11", +] + +[project.scripts] +textbeside = "textbeside.app:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py311" +line-length = 88 From 85adb4e82f0f409133e469390c17bfbff3de3cfc Mon Sep 17 00:00:00 2001 From: Geoff Riley Date: Sat, 26 Sep 2026 15:44:23 +0100 Subject: [PATCH 02/11] Add package scaffold and split window --- src/textbeside/__init__.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/textbeside/__init__.py diff --git a/src/textbeside/__init__.py b/src/textbeside/__init__.py new file mode 100644 index 0000000..31e0aff --- /dev/null +++ b/src/textbeside/__init__.py @@ -0,0 +1,3 @@ +"""TextBeside application package.""" + +__version__ = "0.1.0" From c6e129a03107ebf1da3773c246cb3804c4f390a5 Mon Sep 17 00:00:00 2001 From: Geoff Riley Date: Sat, 26 Sep 2026 15:44:25 +0100 Subject: [PATCH 03/11] Add package scaffold and split window --- src/textbeside/app.py | 184 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 src/textbeside/app.py diff --git a/src/textbeside/app.py b/src/textbeside/app.py new file mode 100644 index 0000000..3261ed8 --- /dev/null +++ b/src/textbeside/app.py @@ -0,0 +1,184 @@ +"""Minimal split-view TextBeside prototype.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtGui import QAction, QCloseEvent, QKeySequence, QPixmap +from PySide6.QtWidgets import ( + QApplication, + QFileDialog, + QLabel, + QMainWindow, + QMessageBox, + QPlainTextEdit, + QScrollArea, + QSplitter, + QVBoxLayout, + QWidget, +) + +TEXT_EXTENSIONS = {".md", ".txt"} +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff"} + + +def read_text_file(path: Path) -> str: + """Return a supported transcription file as UTF-8 text.""" + if path.suffix.lower() not in TEXT_EXTENSIONS: + raise ValueError("Transcription files must use .md or .txt.") + return path.read_text(encoding="utf-8") + + +def load_image(path: Path) -> QPixmap: + """Load a supported image into a pixmap.""" + if path.suffix.lower() not in IMAGE_EXTENSIONS: + raise ValueError("Unsupported image type.") + + pixmap = QPixmap(str(path)) + if pixmap.isNull(): + raise ValueError(f"Could not load image: {path.name}") + return pixmap + + +class MainWindow(QMainWindow): + """The first TextBeside split-view window.""" + + def __init__(self) -> None: + super().__init__() + self.setWindowTitle("TextBeside") + self.resize(1100, 700) + + self.current_text_path: Path | None = None + self.current_image_path: Path | None = None + + self.text_path_label = QLabel("Text: no file selected") + self.text_path_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.editor = QPlainTextEdit() + self.editor.setPlaceholderText("Open a Markdown or text file to begin transcription.") + + self.image_path_label = QLabel("Image: no file selected") + self.image_path_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.image_label = QLabel("Open an image to display it here.") + self.image_label.setAlignment(Qt.AlignCenter) + + image_scroll = QScrollArea() + image_scroll.setWidget(self.image_label) + image_scroll.setWidgetResizable(True) + + splitter = QSplitter(Qt.Horizontal) + splitter.addWidget(self._pane(self.text_path_label, self.editor)) + splitter.addWidget(self._pane(self.image_path_label, image_scroll)) + splitter.setStretchFactor(0, 1) + splitter.setStretchFactor(1, 1) + splitter.setSizes([550, 550]) + self.splitter = splitter + self.setCentralWidget(splitter) + + self._create_actions() + self.statusBar().showMessage("Ready") + + @staticmethod + def _pane(header: QLabel, content: QWidget) -> QWidget: + pane = QWidget() + layout = QVBoxLayout(pane) + layout.setContentsMargins(6, 6, 6, 6) + layout.addWidget(header) + layout.addWidget(content, 1) + return pane + + def _create_actions(self) -> None: + open_pair_action = QAction("&Open pair…", self) + open_pair_action.setShortcut(QKeySequence.Open) + open_pair_action.triggered.connect(self.open_pair_dialog) + + exit_action = QAction("E&xit", self) + exit_action.setShortcut(QKeySequence.Quit) + exit_action.triggered.connect(self.close) + + file_menu = self.menuBar().addMenu("&File") + file_menu.addAction(open_pair_action) + file_menu.addSeparator() + file_menu.addAction(exit_action) + + def open_pair_dialog(self) -> None: + """Choose an image and transcription file without directory scanning.""" + if not self._confirm_discard_if_modified(): + return + + image_name, _ = QFileDialog.getOpenFileName( + self, + "Open source image", + "", + "Images (*.jpg *.jpeg *.png *.webp *.tif *.tiff);;All files (*)", + ) + if not image_name: + return + + text_name, _ = QFileDialog.getOpenFileName( + self, + "Open transcription", + str(Path(image_name).parent), + "Transcriptions (*.md *.txt);;All files (*)", + ) + if not text_name: + return + + try: + self.open_pair(Path(image_name), Path(text_name)) + except (OSError, UnicodeError, ValueError) as exc: + QMessageBox.critical(self, "Could not open pair", str(exc)) + + def open_pair(self, image_path: Path, text_path: Path) -> None: + """Load both files, then replace the visible pair only if both succeed.""" + text = read_text_file(text_path) + pixmap = load_image(image_path) + + self.editor.setPlainText(text) + self.editor.document().setModified(False) + self.image_label.setPixmap(pixmap) + self.image_label.resize(pixmap.size()) + + self.current_text_path = text_path + self.current_image_path = image_path + self.text_path_label.setText(f"Text: {text_path.name}") + self.text_path_label.setToolTip(str(text_path)) + self.image_path_label.setText(f"Image: {image_path.name}") + self.image_path_label.setToolTip(str(image_path)) + self.statusBar().showMessage( + f"Opened {image_path.name} ↔ {text_path.name}", 5000 + ) + + def _confirm_discard_if_modified(self) -> bool: + if not self.editor.document().isModified(): + return True + + answer = QMessageBox.question( + self, + "Discard unsaved changes?", + "The current transcription has unsaved edits. " + "Saving is not implemented in this prototype yet. " + "Discard these edits?", + QMessageBox.Discard | QMessageBox.Cancel, + QMessageBox.Cancel, + ) + return answer == QMessageBox.Discard + + def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt API name + if self._confirm_discard_if_modified(): + event.accept() + else: + event.ignore() + + +def main() -> int: + """Launch TextBeside.""" + app = QApplication(sys.argv) + window = MainWindow() + window.show() + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) From 9d671b1a87d03f04277d7e11e87ceaa137d45269 Mon Sep 17 00:00:00 2001 From: Geoff Riley Date: Sat, 26 Sep 2026 15:44:27 +0100 Subject: [PATCH 04/11] Add package scaffold and split window --- tests/test_app.py | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/test_app.py diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..4c54d57 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,50 @@ +"""Smoke tests for the first TextBeside window.""" + +from __future__ import annotations + +import os +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication + +from textbeside.app import MainWindow + + +@pytest.fixture(scope="session") +def qapp() -> QApplication: + app = QApplication.instance() or QApplication([]) + return app + + +def test_main_window_has_editable_horizontal_split(qapp: QApplication) -> None: + window = MainWindow() + + assert window.splitter.orientation() == Qt.Horizontal + assert window.splitter.count() == 2 + assert not window.editor.isReadOnly() + + window.close() + + +def test_sample_pair_opens_without_conversion(qapp: QApplication) -> None: + root = Path(__file__).resolve().parents[1] + sample_dir = root / "examples" / "field-notes" + image_path = sample_dir / "field-notes-01.jpg" + text_path = sample_dir / "field-notes-01.md" + + window = MainWindow() + window.open_pair(image_path, text_path) + + assert window.current_image_path == image_path + assert window.current_text_path == text_path + assert image_path.name in window.image_path_label.text() + assert text_path.name in window.text_path_label.text() + assert window.editor.toPlainText() == text_path.read_text(encoding="utf-8") + assert not window.image_label.pixmap().isNull() + assert not window.editor.document().isModified() + + window.close() From 927e957af62558b8dc07378a456fb4b3a2da008c Mon Sep 17 00:00:00 2001 From: Geoff Riley Date: Sat, 26 Sep 2026 15:44:49 +0100 Subject: [PATCH 05/11] Document prototype development commands --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index c34d1a5..ebfb459 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,40 @@ The initial implementation will use: Qt provides native split panes, text editing, file watching, keyboard shortcuts, settings, and graphics-view support without requiring an embedded browser runtime. + +### Development setup + +TextBeside currently supports **Python 3.11 or later**. From a fresh checkout, create a virtual environment and install the package with its development tools: + +~~~bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -e '.[dev]' +~~~ + +Launch the prototype with: + +~~~bash +textbeside +~~~ + +or, equivalently: + +~~~bash +python -m textbeside.app +~~~ + +Run the current automated checks with: + +~~~bash +pytest +ruff check . +ruff format --check . +~~~ + +The first prototype opens an image and a `.md` or `.txt` transcription manually using **File → Open pair…**. Directory discovery, image zoom/pan, and saving are separate checkpoint-1 slices. + ## Version 0.1 scope ### Essential workflow From b74fd4a4d39b40ef5469493dc79145c1ea7a8ce8 Mon Sep 17 00:00:00 2001 From: Geoff Riley Date: Sat, 26 Sep 2026 15:45:12 +0100 Subject: [PATCH 06/11] Keep Qt smoke test lint-clean --- tests/test_app.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index 4c54d57..1f53778 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2,11 +2,8 @@ from __future__ import annotations -import os from pathlib import Path -os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - import pytest from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication From ca2c468a6fb41f48d7cc2dc02812964ed5e4f432 Mon Sep 17 00:00:00 2001 From: Geoff Riley Date: Sat, 26 Sep 2026 15:45:14 +0100 Subject: [PATCH 07/11] Keep Qt smoke test lint-clean --- tests/conftest.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3081b64 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +"""Pytest configuration for Qt smoke tests.""" + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") From 6b25f57b9d5901a14525c990e8ecfafacdebca21 Mon Sep 17 00:00:00 2001 From: Geoff Riley Date: Sat, 26 Sep 2026 16:39:18 +0100 Subject: [PATCH 08/11] Avoid timed status message crash --- src/textbeside/app.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/textbeside/app.py b/src/textbeside/app.py index 3261ed8..8f0951f 100644 --- a/src/textbeside/app.py +++ b/src/textbeside/app.py @@ -146,9 +146,6 @@ def open_pair(self, image_path: Path, text_path: Path) -> None: self.text_path_label.setToolTip(str(text_path)) self.image_path_label.setText(f"Image: {image_path.name}") self.image_path_label.setToolTip(str(image_path)) - self.statusBar().showMessage( - f"Opened {image_path.name} ↔ {text_path.name}", 5000 - ) def _confirm_discard_if_modified(self) -> bool: if not self.editor.document().isModified(): From cdc4ac61bccc68b0f12f3b0eb4af334605eaa694 Mon Sep 17 00:00:00 2001 From: Geoff Riley Date: Sat, 26 Sep 2026 16:57:01 +0100 Subject: [PATCH 09/11] Fix Ruff lint and formatting --- src/textbeside/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textbeside/app.py b/src/textbeside/app.py index 8f0951f..d63fe45 100644 --- a/src/textbeside/app.py +++ b/src/textbeside/app.py @@ -56,7 +56,7 @@ def __init__(self) -> None: self.text_path_label = QLabel("Text: no file selected") self.text_path_label.setTextInteractionFlags(Qt.TextSelectableByMouse) self.editor = QPlainTextEdit() - self.editor.setPlaceholderText("Open a Markdown or text file to begin transcription.") + self.editor.setPlaceholderText(\n "Open a Markdown or text file to begin transcription."\n ) self.image_path_label = QLabel("Image: no file selected") self.image_path_label.setTextInteractionFlags(Qt.TextSelectableByMouse) @@ -162,7 +162,7 @@ def _confirm_discard_if_modified(self) -> bool: ) return answer == QMessageBox.Discard - def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt API name + def closeEvent(self, event: QCloseEvent) -> None: if self._confirm_discard_if_modified(): event.accept() else: From c227854357c083c71460bdd20f1127568b4c0713 Mon Sep 17 00:00:00 2001 From: Geoff Riley Date: Sat, 26 Sep 2026 16:59:16 +0100 Subject: [PATCH 10/11] Correct placeholder formatting syntax --- src/textbeside/app.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textbeside/app.py b/src/textbeside/app.py index d63fe45..0264cb6 100644 --- a/src/textbeside/app.py +++ b/src/textbeside/app.py @@ -56,7 +56,9 @@ def __init__(self) -> None: self.text_path_label = QLabel("Text: no file selected") self.text_path_label.setTextInteractionFlags(Qt.TextSelectableByMouse) self.editor = QPlainTextEdit() - self.editor.setPlaceholderText(\n "Open a Markdown or text file to begin transcription."\n ) + self.editor.setPlaceholderText( + "Open a Markdown or text file to begin transcription." + ) self.image_path_label = QLabel("Image: no file selected") self.image_path_label.setTextInteractionFlags(Qt.TextSelectableByMouse) From bf7b26b27036ac0d7c51a295a1e9899d9a1fb151 Mon Sep 17 00:00:00 2001 From: Geoff Riley Date: Sun, 27 Sep 2026 01:24:04 +0100 Subject: [PATCH 11/11] Clarify Linux and Windows development setup --- README.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ebfb459..aa4d499 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,9 @@ Qt provides native split panes, text editing, file watching, keyboard shortcuts, ### Development setup -TextBeside currently supports **Python 3.11 or later**. From a fresh checkout, create a virtual environment and install the package with its development tools: +TextBeside currently supports **Python 3.11 or later**. + +On Linux, from a fresh checkout: ~~~bash python3 -m venv .venv @@ -65,6 +67,24 @@ python -m pip install --upgrade pip python -m pip install -e '.[dev]' ~~~ +If `python -m pip install --upgrade pip` reports that pip is unavailable in the new environment, bootstrap it first with: + +~~~bash +python -m ensurepip --upgrade +python -m pip install --upgrade pip +~~~ + +On Windows, ensure that Python 3.11 or later is installed and create the virtual environment with that interpreter explicitly if the default `python` command is older: + +~~~powershell +py -3.11 -m venv .venv +.venv\Scripts\activate +python -m pip install --upgrade pip +python -m pip install -e '.[dev]' +~~~ + +A newer installed interpreter can be selected instead, for example `py -3.14 -m venv .venv`. + Launch the prototype with: ~~~bash