aboutsummaryrefslogtreecommitdiff
path: root/pocketbook
diff options
context:
space:
mode:
Diffstat (limited to 'pocketbook')
-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
18 files changed, 0 insertions, 1054 deletions
diff --git a/pocketbook/.gitignore b/pocketbook/.gitignore
deleted file mode 100644
index b9c0bb9..0000000
--- a/pocketbook/.gitignore
+++ /dev/null
@@ -1,6 +0,0 @@
-__pycache__/
-*.egg-info/
-dist/
-build/
-.pytest_cache/
-*.pyc
diff --git a/pocketbook/Makefile b/pocketbook/Makefile
deleted file mode 100644
index f09dbbb..0000000
--- a/pocketbook/Makefile
+++ /dev/null
@@ -1,16 +0,0 @@
-.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
deleted file mode 100644
index afc5c71..0000000
--- a/pocketbook/pyproject.toml
+++ /dev/null
@@ -1,21 +0,0 @@
-[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
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);
-}
diff --git a/pocketbook/tests/__init__.py b/pocketbook/tests/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/pocketbook/tests/__init__.py
+++ /dev/null
diff --git a/pocketbook/tests/conftest.py b/pocketbook/tests/conftest.py
deleted file mode 100644
index db04f88..0000000
--- a/pocketbook/tests/conftest.py
+++ /dev/null
@@ -1,17 +0,0 @@
-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
deleted file mode 100644
index cb5ab89..0000000
--- a/pocketbook/tests/test_app_toggle.py
+++ /dev/null
@@ -1,96 +0,0 @@
-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
deleted file mode 100644
index 539451a..0000000
--- a/pocketbook/tests/test_note.py
+++ /dev/null
@@ -1,69 +0,0 @@
-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
deleted file mode 100644
index 92f8648..0000000
--- a/pocketbook/tests/test_panel.py
+++ /dev/null
@@ -1,40 +0,0 @@
-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
deleted file mode 100644
index fab5bd6..0000000
--- a/pocketbook/tests/test_store.py
+++ /dev/null
@@ -1,103 +0,0 @@
-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