Skip to content
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 29 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions src/textbeside/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""TextBeside application package."""

__version__ = "0.1.0"
183 changes: 183 additions & 0 deletions src/textbeside/app.py
Original file line number Diff line number Diff line change
@@ -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}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (bug_risk): Long selected filenames are placed in fixed-width, non-wrapping QLabels without elision or a scrollable header, so the filename is clipped rather than remaining visible in the pane.

Triggers: When either selected filename is longer than the available pane width.

Suggested fix: Use a horizontally scrollable label, enable wrapping, or display an elided filename with the complete name in an accessible tooltip.

Suggested change
self.text_path_label.setText(f"Text: {text_path.name}")
self.text_path_label.setText(f"Text: {text_path.name}")
self.text_path_label.setWordWrap(True)

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())
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Pytest configuration for Qt smoke tests."""

import os

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
47 changes: 47 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
@@ -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()
Loading