aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-26 14:05:40 -0500
committerCraig Jennings <c@cjennings.net>2026-05-26 14:05:40 -0500
commit70e89e946cbdff307284d11a46558161f713607c (patch)
treec305ce9249fac0aef1318f5caabae0df31be7e95
parent92f4a9394ae1b662d037a3016e94058a3881bdb8 (diff)
downloadarchsetup-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-xarchsetup2
-rw-r--r--pocketbook/.gitignore6
-rw-r--r--pocketbook/Makefile16
-rw-r--r--pocketbook/pyproject.toml21
-rw-r--r--pocketbook/src/pocketbook/__init__.py0
-rw-r--r--pocketbook/src/pocketbook/__main__.py26
-rw-r--r--pocketbook/src/pocketbook/app.py243
-rw-r--r--pocketbook/src/pocketbook/layer_shell.py24
-rw-r--r--pocketbook/src/pocketbook/note.py28
-rw-r--r--pocketbook/src/pocketbook/note_widget.py93
-rw-r--r--pocketbook/src/pocketbook/panel.py97
-rw-r--r--pocketbook/src/pocketbook/store.py73
-rw-r--r--pocketbook/src/pocketbook/style.css102
-rw-r--r--pocketbook/tests/__init__.py0
-rw-r--r--pocketbook/tests/conftest.py17
-rw-r--r--pocketbook/tests/test_app_toggle.py96
-rw-r--r--pocketbook/tests/test_note.py69
-rw-r--r--pocketbook/tests/test_panel.py40
-rw-r--r--pocketbook/tests/test_store.py103
-rwxr-xr-xscripts/post-install.sh1
20 files changed, 1054 insertions, 3 deletions
diff --git a/archsetup b/archsetup
index 156af27..5bbf177 100755
--- a/archsetup
+++ b/archsetup
@@ -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"