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 /pocketbook/src | |
| 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.
Diffstat (limited to 'pocketbook/src')
| -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 |
9 files changed, 686 insertions, 0 deletions
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); +} |
