aboutsummaryrefslogtreecommitdiff
path: root/pocketbook/src
diff options
context:
space:
mode:
Diffstat (limited to 'pocketbook/src')
-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
9 files changed, 0 insertions, 686 deletions
diff --git a/pocketbook/src/pocketbook/__init__.py b/pocketbook/src/pocketbook/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/pocketbook/src/pocketbook/__init__.py
+++ /dev/null
diff --git a/pocketbook/src/pocketbook/__main__.py b/pocketbook/src/pocketbook/__main__.py
deleted file mode 100644
index daac841..0000000
--- a/pocketbook/src/pocketbook/__main__.py
+++ /dev/null
@@ -1,26 +0,0 @@
-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
deleted file mode 100644
index be13fec..0000000
--- a/pocketbook/src/pocketbook/app.py
+++ /dev/null
@@ -1,243 +0,0 @@
-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
deleted file mode 100644
index bb36373..0000000
--- a/pocketbook/src/pocketbook/layer_shell.py
+++ /dev/null
@@ -1,24 +0,0 @@
-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
deleted file mode 100644
index e812eea..0000000
--- a/pocketbook/src/pocketbook/note.py
+++ /dev/null
@@ -1,28 +0,0 @@
-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
deleted file mode 100644
index 3467ad0..0000000
--- a/pocketbook/src/pocketbook/note_widget.py
+++ /dev/null
@@ -1,93 +0,0 @@
-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
deleted file mode 100644
index e0a708c..0000000
--- a/pocketbook/src/pocketbook/panel.py
+++ /dev/null
@@ -1,97 +0,0 @@
-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
deleted file mode 100644
index 83d3b74..0000000
--- a/pocketbook/src/pocketbook/store.py
+++ /dev/null
@@ -1,73 +0,0 @@
-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
deleted file mode 100644
index 92df265..0000000
--- a/pocketbook/src/pocketbook/style.css
+++ /dev/null
@@ -1,102 +0,0 @@
-/* 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);
-}