diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-26 14:05:40 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-26 14:05:40 -0500 |
| commit | 70e89e946cbdff307284d11a46558161f713607c (patch) | |
| tree | c305ce9249fac0aef1318f5caabae0df31be7e95 | |
| parent | 92f4a9394ae1b662d037a3016e94058a3881bdb8 (diff) | |
| download | archsetup-70e89e946cbdff307284d11a46558161f713607c.tar.gz archsetup-70e89e946cbdff307284d11a46558161f713607c.zip | |
refactor: fold pocketbook in-tree and drop its install steps
Pocketbook is nowhere near ready, so I pulled it back from publication: deleted the github mirror and the cjennings.net repo, removed the server mirror hook, and copied the package into pocketbook/ here until it's ready to spin back out.
Dropped the steps that provisioned it on a fresh install: the gtk4-layer-shell dep and the pip install in archsetup, and the clone in post-install.sh. That clone pointed at the now-deleted github repo, so it would have failed a fresh run regardless. Re-wiring the install is tracked in the pocketbook backlog.
| -rwxr-xr-x | archsetup | 2 | ||||
| -rw-r--r-- | pocketbook/.gitignore | 6 | ||||
| -rw-r--r-- | pocketbook/Makefile | 16 | ||||
| -rw-r--r-- | pocketbook/pyproject.toml | 21 | ||||
| -rw-r--r-- | pocketbook/src/pocketbook/__init__.py | 0 | ||||
| -rw-r--r-- | pocketbook/src/pocketbook/__main__.py | 26 | ||||
| -rw-r--r-- | pocketbook/src/pocketbook/app.py | 243 | ||||
| -rw-r--r-- | pocketbook/src/pocketbook/layer_shell.py | 24 | ||||
| -rw-r--r-- | pocketbook/src/pocketbook/note.py | 28 | ||||
| -rw-r--r-- | pocketbook/src/pocketbook/note_widget.py | 93 | ||||
| -rw-r--r-- | pocketbook/src/pocketbook/panel.py | 97 | ||||
| -rw-r--r-- | pocketbook/src/pocketbook/store.py | 73 | ||||
| -rw-r--r-- | pocketbook/src/pocketbook/style.css | 102 | ||||
| -rw-r--r-- | pocketbook/tests/__init__.py | 0 | ||||
| -rw-r--r-- | pocketbook/tests/conftest.py | 17 | ||||
| -rw-r--r-- | pocketbook/tests/test_app_toggle.py | 96 | ||||
| -rw-r--r-- | pocketbook/tests/test_note.py | 69 | ||||
| -rw-r--r-- | pocketbook/tests/test_panel.py | 40 | ||||
| -rw-r--r-- | pocketbook/tests/test_store.py | 103 | ||||
| -rwxr-xr-x | scripts/post-install.sh | 1 |
20 files changed, 1054 insertions, 3 deletions
@@ -1619,8 +1619,6 @@ hyprland() { action="Hyprland Utilities" && display "subtitle" "$action" aur_install pyprland # scratchpads, magnify, expose (fixes special workspace issues) - pacman_install gtk4-layer-shell # GTK4 layer shell protocol (pocketbook dependency) - pip_install git+https://git.cjennings.net/pocketbook.git # notes panel (layer-shell, toggled via waybar) pacman_install waybar # status bar pacman_install fuzzel # app launcher (native Wayland, pinentry support) pacman_install swww # wallpaper daemon diff --git a/pocketbook/.gitignore b/pocketbook/.gitignore new file mode 100644 index 0000000..b9c0bb9 --- /dev/null +++ b/pocketbook/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.egg-info/ +dist/ +build/ +.pytest_cache/ +*.pyc diff --git a/pocketbook/Makefile b/pocketbook/Makefile new file mode 100644 index 0000000..f09dbbb --- /dev/null +++ b/pocketbook/Makefile @@ -0,0 +1,16 @@ +.PHONY: test lint install uninstall clean + +test: + python -m pytest tests/ -v + +lint: + python -m ruff check src/ tests/ + +install: + pip install --user -e . + +uninstall: + pip uninstall -y quicknotes + +clean: + rm -rf build/ dist/ src/*.egg-info diff --git a/pocketbook/pyproject.toml b/pocketbook/pyproject.toml new file mode 100644 index 0000000..afc5c71 --- /dev/null +++ b/pocketbook/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "pocketbook" +version = "0.1.0" +description = "GTK4 layer-shell notes panel for Hyprland" +requires-python = ">=3.12" +dependencies = [ + "PyGObject", +] + +[project.scripts] +pocketbook = "pocketbook.__main__:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/pocketbook/src/pocketbook/__init__.py b/pocketbook/src/pocketbook/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pocketbook/src/pocketbook/__init__.py diff --git a/pocketbook/src/pocketbook/__main__.py b/pocketbook/src/pocketbook/__main__.py new file mode 100644 index 0000000..daac841 --- /dev/null +++ b/pocketbook/src/pocketbook/__main__.py @@ -0,0 +1,26 @@ +import argparse +import ctypes +import os + +# gtk4-layer-shell must be loaded before libwayland-client. +# See: https://github.com/wmww/gtk4-layer-shell/blob/main/linking.md +_LIB = "/usr/lib/libgtk4-layer-shell.so" +if os.path.exists(_LIB): + ctypes.cdll.LoadLibrary(_LIB) + + +def main(): + parser = argparse.ArgumentParser(description="Pocketbook panel") + parser.add_argument( + "--hidden", + action="store_true", + help="Start with panel hidden (daemon mode)", + ) + args = parser.parse_args() + + from pocketbook.app import run_app + raise SystemExit(run_app(start_hidden=args.hidden)) + + +if __name__ == "__main__": + main() diff --git a/pocketbook/src/pocketbook/app.py b/pocketbook/src/pocketbook/app.py new file mode 100644 index 0000000..be13fec --- /dev/null +++ b/pocketbook/src/pocketbook/app.py @@ -0,0 +1,243 @@ +import sys +from pathlib import Path + +APP_ID = "net.cjennings.pocketbook" +DATA_DIR = Path.home() / ".local" / "share" / "pocketbook" +CSS_PATH = Path(__file__).parent / "style.css" + + +class ToggleStateMachine: + """Testable toggle logic, separated from GTK.""" + + def __init__(self, start_hidden: bool = False): + self.visible = not start_hidden + + def toggle(self): + self.visible = not self.visible + + +def navigate(current_index: int | None, total: int, direction: int) -> int | None: + """Return the target index for note navigation. + + Args: + current_index: Index of focused note, or None if no note focused. + total: Number of notes. + direction: +1 for next, -1 for previous. + + Returns: + Target index, or None if no notes exist. + """ + if total == 0: + return None + if current_index is None: + return 0 if direction == 1 else total - 1 + target = current_index + direction + return max(0, min(target, total - 1)) + + +class EscapeStateMachine: + """Testable escape logic: editing → browse → hidden.""" + + def escape(self, is_editing: bool, toggle: ToggleStateMachine): + """Returns action: 'exit_edit', 'hide', or None.""" + if is_editing: + return "exit_edit" + else: + toggle.toggle() + return "hide" + + +def _build_gtk_app_class(): + """Lazy-load GTK classes to keep ToggleStateMachine importable without GTK.""" + import gi + gi.require_version("Gtk", "4.0") + from gi.repository import Gtk, Gdk + + from pocketbook.store import NoteStore + from pocketbook.panel import PanelController, PanelWidget + from pocketbook.layer_shell import configure_layer_shell + + class PocketbookApp(Gtk.Application): + def __init__(self, start_hidden: bool = False): + super().__init__(application_id=APP_ID) + self._start_hidden = start_hidden + self._window = None + self._panel = None + self._toggle = ToggleStateMachine(start_hidden=start_hidden) + self._esc_sm = EscapeStateMachine() + self._first_activate = True + + def do_activate(self): + if self._first_activate: + self._first_activate = False + self._build_ui() + if not self._start_hidden: + self._window.set_visible(True) + self._focus_first_note() + else: + self._toggle.toggle() + self._window.set_visible(self._toggle.visible) + if self._toggle.visible: + self._focus_first_note() + + def _focus_first_note(self): + rows = self._get_note_rows() + if rows: + rows[0].grab_focus() + + def _build_ui(self): + self._window = Gtk.Window(application=self) + self._window.set_default_size(420, -1) + + configure_layer_shell(self._window) + self._load_css() + + store = NoteStore(DATA_DIR) + controller = PanelController(store) + self._panel = PanelWidget(controller) + self._window.set_child(self._panel) + + self._setup_shortcuts() + + def _load_css(self): + if CSS_PATH.exists(): + provider = Gtk.CssProvider() + provider.load_from_path(str(CSS_PATH)) + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), + provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + def _setup_shortcuts(self): + from pocketbook.note_widget import NoteRow + + sc = Gtk.ShortcutController() + sc.set_scope(Gtk.ShortcutScope.GLOBAL) + + for key, handler in [ + ("Escape", self._on_escape), + ("<Control>n", self._on_ctrl_n), + ("<Control>j", self._on_next_note), + ("<Control>k", self._on_prev_note), + ("Delete", self._on_delete_note), + ("Return", self._on_enter), + ]: + sc.add_shortcut(Gtk.Shortcut( + trigger=Gtk.ShortcutTrigger.parse_string(key), + action=Gtk.CallbackAction.new(handler), + )) + + self._window.add_controller(sc) + self._note_row_class = NoteRow + + # Arrow keys need EventControllerKey to beat GTK's focus navigation + key_ctrl = Gtk.EventControllerKey() + key_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + key_ctrl.connect("key-pressed", self._on_key_pressed) + self._window.add_controller(key_ctrl) + + def _get_focused_note_row(self): + """Find the NoteRow that currently has focus (or contains the focused widget).""" + focus = self._window.get_focus() + widget = focus + while widget: + if isinstance(widget, self._note_row_class): + return widget + widget = widget.get_parent() + return None + + def _get_note_rows(self): + """Get all NoteRow children from the panel.""" + rows = [] + child = self._panel._list_box.get_first_child() + while child: + if isinstance(child, self._note_row_class): + rows.append(child) + child = child.get_next_sibling() + return rows + + def _on_escape(self, _widget, _args): + focus = self._window.get_focus() + is_editing = bool(focus and isinstance(focus, (Gtk.Entry, Gtk.TextView, Gtk.Text))) + action = self._esc_sm.escape(is_editing, self._toggle) + if action == "exit_edit": + row = self._get_focused_note_row() + if row: + row.exit_edit_mode() + else: + self._window.set_focus(None) + elif action == "hide": + self._window.set_visible(False) + return True + + def _on_ctrl_n(self, _widget, _args): + self._panel.add_note() + return True + + def _on_next_note(self, _widget, _args): + self._navigate_notes(1) + return True + + def _on_prev_note(self, _widget, _args): + self._navigate_notes(-1) + return True + + def _navigate_notes(self, direction): + rows = self._get_note_rows() + current = self._get_focused_note_row() + current_idx = rows.index(current) if current in rows else None + target_idx = navigate(current_idx, len(rows), direction) + if target_idx is not None: + rows[target_idx].grab_focus() + + def _on_key_pressed(self, _controller, keyval, _keycode, _state): + from gi.repository import Gdk as _Gdk + focus = self._window.get_focus() + is_editing = isinstance(focus, (Gtk.Entry, Gtk.TextView, Gtk.Text)) + if keyval == _Gdk.KEY_Down and not is_editing: + self._on_next_note(None, None) + return True # stop propagation + if keyval == _Gdk.KEY_Up and not is_editing: + self._on_prev_note(None, None) + return True + if keyval == _Gdk.KEY_s and is_editing and (_state & _Gdk.ModifierType.CONTROL_MASK): + row = self._get_focused_note_row() + if row: + row.exit_edit_mode() + return True + if keyval == _Gdk.KEY_Return and not is_editing: + row = self._get_focused_note_row() + if row: + row.focus_title() + return True + return False # let GTK handle it + + def _on_delete_note(self, _widget, _args): + focus = self._window.get_focus() + # Only delete in browse mode (NoteRow focused, not a text field) + if isinstance(focus, (Gtk.Entry, Gtk.TextView, Gtk.Text)): + return False # pass through to text widget + row = self._get_focused_note_row() + if row: + self._panel._on_note_deleted(row._filename) + self._focus_first_note() + return True + + def _on_enter(self, _widget, _args): + # Only enter edit mode if a NoteRow itself is focused (browse mode), + # not when already editing a text field + focus = self._window.get_focus() + if isinstance(focus, self._note_row_class): + focus.focus_title() + return True + # Let Enter pass through normally in text fields + return False + + return PocketbookApp + + +def run_app(start_hidden: bool = False) -> int: + PocketbookApp = _build_gtk_app_class() + app = PocketbookApp(start_hidden=start_hidden) + return app.run(sys.argv[:1]) diff --git a/pocketbook/src/pocketbook/layer_shell.py b/pocketbook/src/pocketbook/layer_shell.py new file mode 100644 index 0000000..bb36373 --- /dev/null +++ b/pocketbook/src/pocketbook/layer_shell.py @@ -0,0 +1,24 @@ +import gi +gi.require_version("Gtk", "4.0") +gi.require_version("Gtk4LayerShell", "1.0") +from gi.repository import Gtk, Gtk4LayerShell + + +def configure_layer_shell(window: Gtk.Window): + """Configure a GTK4 window as a layer-shell surface.""" + Gtk4LayerShell.init_for_window(window) + Gtk4LayerShell.set_layer(window, Gtk4LayerShell.Layer.OVERLAY) + Gtk4LayerShell.set_namespace(window, "pocketbook") + + # Anchor top-right + Gtk4LayerShell.set_anchor(window, Gtk4LayerShell.Edge.TOP, True) + Gtk4LayerShell.set_anchor(window, Gtk4LayerShell.Edge.RIGHT, True) + + # Margins: below waybar, inset from edge + Gtk4LayerShell.set_margin(window, Gtk4LayerShell.Edge.TOP, 50) + Gtk4LayerShell.set_margin(window, Gtk4LayerShell.Edge.RIGHT, 10) + + # On-demand keyboard: receives keys when focused, doesn't steal focus on appear + Gtk4LayerShell.set_keyboard_mode( + window, Gtk4LayerShell.KeyboardMode.ON_DEMAND + ) diff --git a/pocketbook/src/pocketbook/note.py b/pocketbook/src/pocketbook/note.py new file mode 100644 index 0000000..e812eea --- /dev/null +++ b/pocketbook/src/pocketbook/note.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from datetime import datetime +import secrets + + +@dataclass +class Note: + title: str + body: str + + def to_file_content(self) -> str: + return f"{self.title}\n\n{self.body}" + + @classmethod + def from_file_content(cls, content: str) -> "Note": + parts = content.split("\n\n", 1) + title = parts[0] + body = parts[1] if len(parts) > 1 else "" + return cls(title=title, body=body) + + def generate_filename(self, order: int) -> str: + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + short_id = secrets.token_hex(3)[:5] + return f"{order:04d}-{ts}-{short_id}.txt" + + @staticmethod + def parse_order(filename: str) -> int: + return int(filename.split("-", 1)[0]) diff --git a/pocketbook/src/pocketbook/note_widget.py b/pocketbook/src/pocketbook/note_widget.py new file mode 100644 index 0000000..3467ad0 --- /dev/null +++ b/pocketbook/src/pocketbook/note_widget.py @@ -0,0 +1,93 @@ +from typing import Callable + +import gi +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk + +from pocketbook.note import Note + + +class NoteRow(Gtk.Box): + """A single note row with browse/edit modes.""" + + def __init__( + self, + filename: str, + note: Note, + on_changed: Callable[[str, str, str], None], + on_deleted: Callable[[str], None], + ): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=4) + self.add_css_class("note-row") + self._filename = filename + self._on_changed = on_changed + self._on_deleted = on_deleted + + # Title row with delete button + title_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + + self._title_entry = Gtk.Entry() + self._title_entry.set_text(note.title) + self._title_entry.set_hexpand(True) + self._title_entry.add_css_class("note-title") + self._title_entry.set_placeholder_text("Title") + self._title_entry.set_can_focus(False) + self._title_entry.set_focusable(False) + self._title_entry.connect("changed", self._on_text_changed) + self._title_entry.connect("notify::has-focus", self._on_title_focus_changed) + title_row.append(self._title_entry) + + delete_btn = Gtk.Button(label="") + delete_btn.add_css_class("delete-button") + delete_btn.set_focusable(False) + delete_btn.connect("clicked", self._on_delete_clicked) + title_row.append(delete_btn) + + self.append(title_row) + + # Body text view + self._body_buffer = Gtk.TextBuffer() + self._body_buffer.set_text(note.body) + self._body_buffer.connect("changed", self._on_text_changed) + + self._body_view = Gtk.TextView(buffer=self._body_buffer) + self._body_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + self._body_view.add_css_class("note-body") + self._body_view.set_vexpand(False) + self._body_view.set_can_focus(False) + self._body_view.set_focusable(False) + self.append(self._body_view) + + # NoteRow itself is focusable for browse mode + self.set_focusable(True) + + def focus_title(self): + """Enter edit mode — focus and select title text.""" + self._title_entry.set_can_focus(True) + self._title_entry.set_focusable(True) + self._body_view.set_can_focus(True) + self._body_view.set_focusable(True) + self._title_entry.grab_focus() + self._title_entry.select_region(0, -1) + + def exit_edit_mode(self): + """Return to browse mode — defocus text fields.""" + self._title_entry.set_can_focus(False) + self._title_entry.set_focusable(False) + self._body_view.set_can_focus(False) + self._body_view.set_focusable(False) + self.grab_focus() + + def _on_title_focus_changed(self, entry, _pspec): + if not entry.has_focus(): + entry.select_region(0, 0) + + def _on_text_changed(self, _widget): + title = self._title_entry.get_text() + start = self._body_buffer.get_start_iter() + end = self._body_buffer.get_end_iter() + body = self._body_buffer.get_text(start, end, False) + self._on_changed(self._filename, title, body) + + def _on_delete_clicked(self, _button): + self._on_deleted(self._filename) diff --git a/pocketbook/src/pocketbook/panel.py b/pocketbook/src/pocketbook/panel.py new file mode 100644 index 0000000..e0a708c --- /dev/null +++ b/pocketbook/src/pocketbook/panel.py @@ -0,0 +1,97 @@ +from pocketbook.store import NoteStore +from pocketbook.note import Note + +import gi +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk + + +class PanelController: + """Testable controller logic for the notes panel.""" + + def __init__(self, store: NoteStore): + self.store = store + + def add_note(self) -> str: + return self.store.create("New Note", "") + + def delete_note(self, filename: str): + self.store.delete(filename) + + def update_note(self, filename: str, title: str, body: str): + self.store.update(filename, title, body) + + def get_notes(self) -> list[tuple[str, Note]]: + return self.store.list_notes() + + +class PanelWidget(Gtk.Box): + """Main panel widget — scrollable list of notes with add button.""" + + def __init__(self, controller: PanelController): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.controller = controller + self.add_css_class("panel") + + # Header with title and add button + header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + header.add_css_class("panel-header") + + title_label = Gtk.Label(label="Pocketbook") + title_label.add_css_class("panel-title") + title_label.set_hexpand(True) + title_label.set_halign(Gtk.Align.START) + header.append(title_label) + + add_btn = Gtk.Button(label="+") + add_btn.add_css_class("add-button") + add_btn.set_focusable(False) + add_btn.connect("clicked", self._on_add_clicked) + header.append(add_btn) + + self.append(header) + + # Scrollable note list + self._scroll = Gtk.ScrolledWindow() + self._scroll.set_vexpand(True) + self._scroll.set_propagate_natural_height(True) + self._scroll.set_max_content_height(550) + self._scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + + self._list_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + self._list_box.add_css_class("note-list") + self._scroll.set_child(self._list_box) + self.append(self._scroll) + + self._refresh() + + def _refresh(self): + # Clear existing children + child = self._list_box.get_first_child() + while child: + next_child = child.get_next_sibling() + self._list_box.remove(child) + child = next_child + + from pocketbook.note_widget import NoteRow + for filename, note in self.controller.get_notes(): + row = NoteRow(filename, note, self._on_note_changed, self._on_note_deleted) + self._list_box.append(row) + + def add_note(self): + self.controller.add_note() + self._refresh() + # Focus the title entry of the newly added note (last child) + last = self._list_box.get_last_child() + if last: + last.focus_title() + + def _on_add_clicked(self, _button): + self.add_note() + + def _on_note_changed(self, filename: str, title: str, body: str): + self.controller.update_note(filename, title, body) + + def _on_note_deleted(self, filename: str): + self.controller.delete_note(filename) + self._refresh() diff --git a/pocketbook/src/pocketbook/store.py b/pocketbook/src/pocketbook/store.py new file mode 100644 index 0000000..83d3b74 --- /dev/null +++ b/pocketbook/src/pocketbook/store.py @@ -0,0 +1,73 @@ +from pathlib import Path +import re +from pocketbook.note import Note + +FILENAME_RE = re.compile(r"^\d{4}-.+\.txt$") + + +class NoteStore: + def __init__(self, directory: Path): + self.directory = directory + + def _ensure_dir(self): + self.directory.mkdir(parents=True, exist_ok=True) + + def _valid_note_files(self) -> list[tuple[str, Path]]: + """Return sorted list of (filename, path) for valid note files.""" + if not self.directory.exists(): + return [] + files = [] + for p in self.directory.iterdir(): + if p.is_file() and FILENAME_RE.match(p.name): + try: + Note.parse_order(p.name) + files.append((p.name, p)) + except (ValueError, IndexError): + continue + files.sort(key=lambda x: Note.parse_order(x[0])) + return files + + def _next_order(self) -> int: + files = self._valid_note_files() + if not files: + return 1 + return Note.parse_order(files[-1][0]) + 1 + + def create(self, title: str, body: str) -> str: + self._ensure_dir() + note = Note(title=title, body=body) + order = self._next_order() + filename = note.generate_filename(order) + (self.directory / filename).write_text(note.to_file_content()) + return filename + + def list_notes(self) -> list[tuple[str, Note]]: + """Return list of (filename, Note) sorted by order.""" + result = [] + for fname, path in self._valid_note_files(): + content = path.read_text() + note = Note.from_file_content(content) + result.append((fname, note)) + return result + + def update(self, filename: str, title: str, body: str): + path = self.directory / filename + if not path.exists(): + raise FileNotFoundError(f"Note not found: {filename}") + note = Note(title=title, body=body) + path.write_text(note.to_file_content()) + + def delete(self, filename: str): + path = self.directory / filename + if not path.exists(): + raise FileNotFoundError(f"Note not found: {filename}") + path.unlink() + + def reorder(self): + """Renumber all note files sequentially starting from 1.""" + files = self._valid_note_files() + for i, (fname, path) in enumerate(files, start=1): + current_order = Note.parse_order(fname) + if current_order != i: + new_name = f"{i:04d}{fname[4:]}" + path.rename(self.directory / new_name) diff --git a/pocketbook/src/pocketbook/style.css b/pocketbook/src/pocketbook/style.css new file mode 100644 index 0000000..92df265 --- /dev/null +++ b/pocketbook/src/pocketbook/style.css @@ -0,0 +1,102 @@ +/* Pocketbook — Dupre theme */ +/* Colors from dupre-palette.org */ + +window { + background-color: rgba(21, 19, 17, 0.95); + border: 2px solid #d7af5f; + border-radius: 16px; + font-family: "BerkeleyMono Nerd Font", "Berkeley Mono", monospace; + font-size: 14px; + color: #969385; +} + +.panel { + padding: 12px; +} + +.panel-header { + padding: 4px 4px 8px 4px; + margin-bottom: 4px; + border-bottom: 1px solid #474544; +} + +.panel-title { + font-size: 16px; + font-weight: bold; + color: #d7af5f; +} + +.add-button { + background-color: transparent; + color: #d7af5f; + border: 1px solid #d7af5f; + border-radius: 8px; + padding: 2px 10px; + font-size: 18px; + font-weight: bold; + min-width: 32px; + min-height: 32px; +} + +.add-button:hover { + background-color: #d7af5f; + color: #151311; +} + +.note-list { + padding: 4px 0; +} + +.note-row { + background-color: rgba(71, 69, 68, 0.3); + border-radius: 8px; + padding: 8px; + margin: 4px 0; + border: 1px solid transparent; +} + +.note-row:focus { + border: 1px solid #d7af5f; +} + +.note-title { + background-color: transparent; + border: none; + color: #b2c3cc; + font-weight: bold; + font-size: 14px; + padding: 4px; +} + +.note-title:focus { + outline: none; + border-bottom: 1px solid #d7af5f; +} + +.note-body { + background-color: transparent; + color: #d0cbc0; + font-size: 13px; + padding: 4px; + border-radius: 4px; +} + +.note-body:focus { + background-color: rgba(71, 69, 68, 0.4); +} + +.delete-button { + background-color: transparent; + color: #969385; + border: none; + border-radius: 8px; + padding: 2px 8px; + font-size: 16px; + min-width: 28px; + min-height: 28px; +} + +.delete-button:hover { + color: #d47c59; + background-color: rgba(212, 124, 89, 0.2); +} diff --git a/pocketbook/tests/__init__.py b/pocketbook/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pocketbook/tests/__init__.py diff --git a/pocketbook/tests/conftest.py b/pocketbook/tests/conftest.py new file mode 100644 index 0000000..db04f88 --- /dev/null +++ b/pocketbook/tests/conftest.py @@ -0,0 +1,17 @@ +import pytest + + +@pytest.fixture +def notes_dir(tmp_path): + """Temporary directory for note storage.""" + d = tmp_path / "quicknotes" + d.mkdir() + return d + + +@pytest.fixture +def sample_note_file(notes_dir): + """A sample note file on disk.""" + path = notes_dir / "0001-20260101-120000-abc12.txt" + path.write_text("Shopping List\n\nMilk\nEggs\nBread\n") + return path diff --git a/pocketbook/tests/test_app_toggle.py b/pocketbook/tests/test_app_toggle.py new file mode 100644 index 0000000..cb5ab89 --- /dev/null +++ b/pocketbook/tests/test_app_toggle.py @@ -0,0 +1,96 @@ +from pocketbook.app import ToggleStateMachine, EscapeStateMachine, navigate + + +class TestToggleStateMachine: + def test_initial_state_visible(self): + sm = ToggleStateMachine(start_hidden=False) + assert sm.visible is True + + def test_initial_state_hidden(self): + sm = ToggleStateMachine(start_hidden=True) + assert sm.visible is False + + def test_toggle_alternates(self): + sm = ToggleStateMachine(start_hidden=False) + assert sm.visible is True + sm.toggle() + assert sm.visible is False + sm.toggle() + assert sm.visible is True + + def test_toggle_from_hidden(self): + sm = ToggleStateMachine(start_hidden=True) + assert sm.visible is False + sm.toggle() + assert sm.visible is True + sm.toggle() + assert sm.visible is False + + +class TestEscapeStateMachine: + def test_escape_while_editing_returns_exit_edit(self): + esc = EscapeStateMachine() + toggle = ToggleStateMachine(start_hidden=False) + action = esc.escape(is_editing=True, toggle=toggle) + assert action == "exit_edit" + # Toggle state should not change + assert toggle.visible is True + + def test_escape_while_browsing_returns_hide(self): + esc = EscapeStateMachine() + toggle = ToggleStateMachine(start_hidden=False) + action = esc.escape(is_editing=False, toggle=toggle) + assert action == "hide" + assert toggle.visible is False + + def test_escape_edit_then_browse_hides(self): + """Simulates: editing → Escape (exit edit) → Escape (hide).""" + esc = EscapeStateMachine() + toggle = ToggleStateMachine(start_hidden=False) + + # First escape: exit edit mode + action1 = esc.escape(is_editing=True, toggle=toggle) + assert action1 == "exit_edit" + assert toggle.visible is True + + # Second escape: now in browse mode, hide panel + action2 = esc.escape(is_editing=False, toggle=toggle) + assert action2 == "hide" + assert toggle.visible is False + + def test_escape_hide_does_not_change_when_already_hidden(self): + esc = EscapeStateMachine() + toggle = ToggleStateMachine(start_hidden=True) + assert toggle.visible is False + action = esc.escape(is_editing=False, toggle=toggle) + assert action == "hide" + # Toggled again — now visible (edge case if called when hidden) + assert toggle.visible is True + + +class TestNavigate: + def test_no_notes(self): + assert navigate(None, 0, 1) is None + assert navigate(None, 0, -1) is None + + def test_no_focus_next_goes_to_first(self): + assert navigate(None, 3, 1) == 0 + + def test_no_focus_prev_goes_to_last(self): + assert navigate(None, 3, -1) == 2 + + def test_next_from_middle(self): + assert navigate(1, 3, 1) == 2 + + def test_prev_from_middle(self): + assert navigate(1, 3, -1) == 0 + + def test_next_clamps_at_end(self): + assert navigate(2, 3, 1) == 2 + + def test_prev_clamps_at_start(self): + assert navigate(0, 3, -1) == 0 + + def test_single_note(self): + assert navigate(0, 1, 1) == 0 + assert navigate(0, 1, -1) == 0 diff --git a/pocketbook/tests/test_note.py b/pocketbook/tests/test_note.py new file mode 100644 index 0000000..539451a --- /dev/null +++ b/pocketbook/tests/test_note.py @@ -0,0 +1,69 @@ +from pocketbook.note import Note + + +class TestNoteSerialisation: + def test_round_trip(self): + note = Note(title="Shopping", body="Milk\nEggs\nBread") + content = note.to_file_content() + restored = Note.from_file_content(content) + assert restored.title == note.title + assert restored.body == note.body + + def test_empty_body(self): + note = Note(title="Empty", body="") + content = note.to_file_content() + restored = Note.from_file_content(content) + assert restored.title == "Empty" + assert restored.body == "" + + def test_empty_title(self): + note = Note(title="", body="some body") + content = note.to_file_content() + restored = Note.from_file_content(content) + assert restored.title == "" + assert restored.body == "some body" + + def test_unicode(self): + note = Note(title="日本語タイトル", body="Ünïcödé bödý 🎉") + content = note.to_file_content() + restored = Note.from_file_content(content) + assert restored.title == "日本語タイトル" + assert restored.body == "Ünïcödé bödý 🎉" + + def test_multiline_body(self): + body = "Line 1\nLine 2\n\nLine 4\n" + note = Note(title="Multi", body=body) + content = note.to_file_content() + restored = Note.from_file_content(content) + assert restored.body == body + + def test_file_content_format(self): + """Title on line 1, blank line, then body.""" + note = Note(title="Title", body="Body text") + content = note.to_file_content() + assert content == "Title\n\nBody text" + + def test_from_file_content_no_blank_line(self): + """Gracefully handle files without a blank separator.""" + restored = Note.from_file_content("JustTitle") + assert restored.title == "JustTitle" + assert restored.body == "" + + +class TestNoteFilename: + def test_generate_filename(self): + note = Note(title="Test", body="") + filename = note.generate_filename(order=1) + assert filename.startswith("0001-") + assert filename.endswith(".txt") + # Format: 0001-YYYYMMDD-HHMMSS-shortid.txt + parts = filename.split("-") + assert len(parts) == 4 + assert len(parts[0]) == 4 # order + assert len(parts[1]) == 8 # date + # parts[2] = HHMMSS + shortid.txt combined via split on - + # Actually: 0001-20260225-143012-abc12.txt has 4 parts + + def test_parse_order_from_filename(self): + assert Note.parse_order("0005-20260101-120000-abc12.txt") == 5 + assert Note.parse_order("0001-20260101-120000-xyz99.txt") == 1 diff --git a/pocketbook/tests/test_panel.py b/pocketbook/tests/test_panel.py new file mode 100644 index 0000000..92f8648 --- /dev/null +++ b/pocketbook/tests/test_panel.py @@ -0,0 +1,40 @@ +from unittest.mock import MagicMock +from pocketbook.note import Note + + +class TestPanelController: + """Test panel controller logic with a mocked store.""" + + def _make_controller(self): + from pocketbook.panel import PanelController + store = MagicMock() + controller = PanelController(store) + return controller, store + + def test_add_note_calls_store_create(self): + controller, store = self._make_controller() + store.create.return_value = "0001-20260101-120000-abc12.txt" + controller.add_note() + store.create.assert_called_once_with("New Note", "") + + def test_delete_note_calls_store_delete(self): + controller, store = self._make_controller() + controller.delete_note("0001-20260101-120000-abc12.txt") + store.delete.assert_called_once_with("0001-20260101-120000-abc12.txt") + + def test_update_note_calls_store_update(self): + controller, store = self._make_controller() + controller.update_note("0001-20260101-120000-abc12.txt", "New Title", "New Body") + store.update.assert_called_once_with( + "0001-20260101-120000-abc12.txt", "New Title", "New Body" + ) + + def test_get_notes_calls_store_list(self): + controller, store = self._make_controller() + store.list_notes.return_value = [ + ("0001-20260101-120000-abc12.txt", Note(title="A", body="a")), + ] + notes = controller.get_notes() + store.list_notes.assert_called_once() + assert len(notes) == 1 + assert notes[0][1].title == "A" diff --git a/pocketbook/tests/test_store.py b/pocketbook/tests/test_store.py new file mode 100644 index 0000000..fab5bd6 --- /dev/null +++ b/pocketbook/tests/test_store.py @@ -0,0 +1,103 @@ +import pytest +from pocketbook.store import NoteStore +from pocketbook.note import Note + + +class TestNoteStoreCreate: + def test_create_note(self, notes_dir): + store = NoteStore(notes_dir) + filename = store.create("My Title", "My Body") + assert (notes_dir / filename).exists() + content = (notes_dir / filename).read_text() + assert content == "My Title\n\nMy Body" + + def test_create_assigns_incrementing_order(self, notes_dir): + store = NoteStore(notes_dir) + f1 = store.create("First", "") + f2 = store.create("Second", "") + assert Note.parse_order(f1) == 1 + assert Note.parse_order(f2) == 2 + + def test_create_auto_creates_directory(self, tmp_path): + d = tmp_path / "nonexistent" / "pocketbook" + store = NoteStore(d) + filename = store.create("Test", "body") + assert d.exists() + assert (d / filename).exists() + + +class TestNoteStoreList: + def test_list_empty(self, notes_dir): + # Remove the sample file if any fixture created one + for f in notes_dir.iterdir(): + f.unlink() + store = NoteStore(notes_dir) + assert store.list_notes() == [] + + def test_list_returns_sorted_by_order(self, notes_dir): + (notes_dir / "0002-20260101-120000-abc12.txt").write_text("B\n\nbody b") + (notes_dir / "0001-20260101-120000-def34.txt").write_text("A\n\nbody a") + (notes_dir / "0003-20260101-120000-ghi56.txt").write_text("C\n\nbody c") + store = NoteStore(notes_dir) + notes = store.list_notes() + assert len(notes) == 3 + assert notes[0][1].title == "A" + assert notes[1][1].title == "B" + assert notes[2][1].title == "C" + + def test_list_skips_non_txt_files(self, notes_dir): + (notes_dir / "0001-20260101-120000-abc12.txt").write_text("Note\n\nbody") + (notes_dir / "README.md").write_text("not a note") + store = NoteStore(notes_dir) + assert len(store.list_notes()) == 1 + + def test_list_skips_corrupted_filenames(self, notes_dir): + (notes_dir / "0001-20260101-120000-abc12.txt").write_text("Good\n\nbody") + (notes_dir / "bad-name.txt").write_text("Bad\n\nbody") + store = NoteStore(notes_dir) + notes = store.list_notes() + assert len(notes) == 1 + assert notes[0][1].title == "Good" + + +class TestNoteStoreUpdate: + def test_update_note(self, notes_dir): + fname = "0001-20260101-120000-abc12.txt" + (notes_dir / fname).write_text("Old Title\n\nOld body") + store = NoteStore(notes_dir) + store.update(fname, "New Title", "New body") + content = (notes_dir / fname).read_text() + assert content == "New Title\n\nNew body" + + def test_update_nonexistent_raises(self, notes_dir): + store = NoteStore(notes_dir) + with pytest.raises(FileNotFoundError): + store.update("0099-20260101-120000-nope0.txt", "T", "B") + + +class TestNoteStoreDelete: + def test_delete_note(self, notes_dir): + fname = "0001-20260101-120000-abc12.txt" + (notes_dir / fname).write_text("Delete me\n\nbody") + store = NoteStore(notes_dir) + store.delete(fname) + assert not (notes_dir / fname).exists() + + def test_delete_nonexistent_raises(self, notes_dir): + store = NoteStore(notes_dir) + with pytest.raises(FileNotFoundError): + store.delete("0099-20260101-120000-nope0.txt") + + +class TestNoteStoreReorder: + def test_reorder_renumbers_files(self, notes_dir): + # Create files with gaps in ordering + (notes_dir / "0001-20260101-120000-aaa11.txt").write_text("A\n\na") + (notes_dir / "0005-20260101-120000-bbb22.txt").write_text("B\n\nb") + (notes_dir / "0010-20260101-120000-ccc33.txt").write_text("C\n\nc") + store = NoteStore(notes_dir) + store.reorder() + notes = store.list_notes() + assert Note.parse_order(notes[0][0]) == 1 + assert Note.parse_order(notes[1][0]) == 2 + assert Note.parse_order(notes[2][0]) == 3 diff --git a/scripts/post-install.sh b/scripts/post-install.sh index f184d9d..9045398 100755 --- a/scripts/post-install.sh +++ b/scripts/post-install.sh @@ -58,7 +58,6 @@ echo "cloning git repos" clone_if_missing git@cjennings.net:slock.git "$HOME/code/slock" clone_if_missing git@cjennings.net:pinentry-dmenu.git "$HOME/code/pinentry-dmenu" - clone_if_missing git@github.com:cjennings/pocketbook.git "$HOME/code/pocketbook" clone_if_missing cjennings@cjennings.net:git/bsdsetup.git "$HOME/code/bsdsetup" clone_if_missing git@cjennings.net:git/archsetup.git "$HOME/code/archsetup" clone_if_missing git@cjennings.net:dotemacs.git "$HOME/code/dotemacs" |
