diff --git a/README.md b/README.md index c34d1a5..aa4d499 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,60 @@ 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**. + +On Linux, from a fresh checkout: + +~~~bash +python3 -m venv .venv +source .venv/bin/activate +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 +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 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 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" diff --git a/src/textbeside/app.py b/src/textbeside/app.py new file mode 100644 index 0000000..0264cb6 --- /dev/null +++ b/src/textbeside/app.py @@ -0,0 +1,183 @@ +"""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)) + + 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: + 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()) 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") diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..1f53778 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,47 @@ +"""Smoke tests for the first TextBeside window.""" + +from __future__ import annotations + +from pathlib import Path + +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()