diff --git a/projects/khora/ROADMAP.md b/projects/khora/ROADMAP.md index 3b3dbfa4..19648d31 100644 --- a/projects/khora/ROADMAP.md +++ b/projects/khora/ROADMAP.md @@ -7,8 +7,8 @@ The order reflects engineering dependencies rather than product priority. time, calendar, location, description, URL, organizer, and attendees. 2. **Keyboard navigation — done.** Previous/next period, Today, view switching, refresh, and zoom shortcuts. -3. **Session persistence** — remember the selected view, zoom, sidebar width, - collapsed accounts, and visible calendars across restarts. +3. **Session persistence — done.** Remember the selected view, zoom, sidebar + width, collapsed accounts, and visible calendars across restarts. 4. **Filesystem monitoring** — refresh when local vdir files change, without requiring the toolbar refresh button. 5. **Forward agenda** — replace the selected-day list with a lazily loaded, diff --git a/projects/khora/khora/state.py b/projects/khora/khora/state.py new file mode 100644 index 00000000..1a6fb34e --- /dev/null +++ b/projects/khora/khora/state.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import json +import os +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class UiState: + view: str = "week" + slot_height: int = 24 + sidebar_width: int = 280 + visible_calendars: tuple[str, ...] | None = None + collapsed_accounts: tuple[str, ...] = () + + +class StateStore: + def __init__(self, path: Path | None = None) -> None: + state_home = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state")) + self.path = path or state_home / "khora" / "state.json" + + def load(self) -> UiState: + try: + data = json.loads(self.path.read_text()) + return UiState( + view=data.get("view", "week"), + slot_height=int(data.get("slot_height", 24)), + sidebar_width=int(data.get("sidebar_width", 280)), + visible_calendars=self._strings_or_none(data.get("visible_calendars")), + collapsed_accounts=self._strings(data.get("collapsed_accounts", ())), + ) + except (OSError, TypeError, ValueError, json.JSONDecodeError): + return UiState() + + def save(self, state: UiState) -> bool: + temporary = self.path.with_suffix(".tmp") + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary.write_text(json.dumps(asdict(state), indent=2, sort_keys=True) + "\n") + temporary.replace(self.path) + return True + except OSError: + temporary.unlink(missing_ok=True) + return False + + @staticmethod + def _strings(value: object) -> tuple[str, ...]: + if not isinstance(value, (list, tuple)): + return () + return tuple(item for item in value if isinstance(item, str)) + + @classmethod + def _strings_or_none(cls, value: object) -> tuple[str, ...] | None: + return None if value is None else cls._strings(value) diff --git a/projects/khora/khora/window.py b/projects/khora/khora/window.py index 7fd21c41..fb44f18f 100644 --- a/projects/khora/khora/window.py +++ b/projects/khora/khora/window.py @@ -11,6 +11,7 @@ from gi.repository import Adw, Gdk, Gio, GLib, Gtk, Pango from .colors import contrasting_foreground, display_color from .khal_adapter import KhalRepository from .model import Calendar, Event, event_slot_range, period_label, shifted_date, week_dates +from .state import StateStore, UiState SIDEBAR_WIDTH = 280 @@ -26,12 +27,21 @@ class KhoraWindow(Adw.ApplicationWindow): application: Adw.Application, repository: KhalRepository | None, error: str | None = None, + state_store: StateStore | None = None, ) -> None: super().__init__(application=application, title="Khora") self.set_default_size(1280, 800) self._repository = repository + self._state_store = state_store or StateStore() + self._state = self._state_store.load() + self._save_source = 0 self._visible_calendars: set[str] = set() - self._view_mode = "week" + self._collapsed_accounts = set(self._state.collapsed_accounts) + self._view_mode = ( + self._state.view + if self._state.view in {"day", "week", "month", "agenda"} + else "week" + ) self._toolbar = Adw.ToolbarView() self.set_content(self._toolbar) @@ -43,10 +53,18 @@ class KhoraWindow(Adw.ApplicationWindow): return assert repository is not None - self._visible_calendars = {calendar.name for calendar in repository.calendars} + calendar_names = {calendar.name for calendar in repository.calendars} + self._visible_calendars = ( + calendar_names + if self._state.visible_calendars is None + else calendar_names & set(self._state.visible_calendars) + ) self._event_color_providers: dict[str, Gtk.CssProvider] = {} self._view_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - self._slot_height = DEFAULT_SLOT_HEIGHT + self._slot_height = max( + MIN_SLOT_HEIGHT, + min(MAX_SLOT_HEIGHT, self._state.slot_height), + ) self._zoom_scroll_accumulator = 0.0 self._displayed_days: tuple[dt.date, ...] = () self._displayed_events: dict[dt.date, tuple[Event, ...]] = {} @@ -58,12 +76,16 @@ class KhoraWindow(Adw.ApplicationWindow): description="A suspiciously peaceful day.", ) - split = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL, position=SIDEBAR_WIDTH) - split.set_start_child(self._build_sidebar()) - split.set_end_child(self._build_calendar_view()) - split.set_resize_start_child(False) - split.set_shrink_start_child(False) - self._toolbar.set_content(split) + self._split = Gtk.Paned( + orientation=Gtk.Orientation.HORIZONTAL, + position=max(280, min(600, self._state.sidebar_width)), + ) + self._split.set_start_child(self._build_sidebar()) + self._split.set_end_child(self._build_calendar_view()) + self._split.set_resize_start_child(False) + self._split.set_shrink_start_child(False) + self._split.connect("notify::position", lambda *_: self._schedule_state_save()) + self._toolbar.set_content(self._split) self._refresh() self._clock_source = GLib.timeout_add_seconds(30, self._on_clock_tick) self.connect("close-request", self._on_close_request) @@ -98,7 +120,7 @@ class KhoraWindow(Adw.ApplicationWindow): menu = Gio.Menu() for mode in ("day", "week", "month", "agenda"): menu.append(mode.title(), f"win.view::{mode}") - self._view_button = Gtk.MenuButton(label="Week", menu_model=menu) + self._view_button = Gtk.MenuButton(label=self._view_mode.title(), menu_model=menu) controls = Gtk.Box(spacing=6) controls.append( @@ -157,10 +179,14 @@ class KhoraWindow(Adw.ApplicationWindow): account_list.add_css_class("boxed-list") account_row = Adw.ExpanderRow( title=self._account_label(account), - expanded=True, + expanded=account not in self._collapsed_accounts, ) + account_row.connect("notify::expanded", self._on_account_expanded, account) for calendar in calendars: - toggle = Gtk.Switch(active=True, valign=Gtk.Align.CENTER) + toggle = Gtk.Switch( + active=calendar.name in self._visible_calendars, + valign=Gtk.Align.CENTER, + ) toggle.connect("notify::active", self._on_calendar_toggled, calendar.name) row = Adw.ActionRow(title=calendar.name, activatable=True) row.add_prefix(self._color_dot(calendar.color)) @@ -497,11 +523,24 @@ class KhoraWindow(Adw.ApplicationWindow): dot.set_markup(f'') return dot + def _on_account_expanded( + self, + row: Adw.ExpanderRow, + _property, + account: str, + ) -> None: + if row.get_expanded(): + self._collapsed_accounts.discard(account) + else: + self._collapsed_accounts.add(account) + self._schedule_state_save() + def _on_calendar_toggled(self, button: Gtk.Switch, _property, name: str) -> None: if button.get_active(): self._visible_calendars.add(name) else: self._visible_calendars.discard(name) + self._schedule_state_save() self._refresh() def _on_today(self, *_args) -> None: @@ -517,6 +556,7 @@ class KhoraWindow(Adw.ApplicationWindow): def _on_view_selected(self, _action: Gio.SimpleAction, parameter: GLib.Variant) -> None: self._view_mode = parameter.get_string() self._view_button.set_label(self._view_mode.title()) + self._schedule_state_save() self._refresh() def _on_time_grid_scroll( @@ -558,6 +598,7 @@ class KhoraWindow(Adw.ApplicationWindow): 1, ) self._slot_height = slot_height + self._schedule_state_save() self._clear_view() self._view_content.append(self._time_grid(self._displayed_days, self._displayed_events)) GLib.idle_add(self._restore_zoom_position, focus) @@ -582,8 +623,30 @@ class KhoraWindow(Adw.ApplicationWindow): if self._clock_source: GLib.source_remove(self._clock_source) self._clock_source = 0 + if self._save_source: + GLib.source_remove(self._save_source) + self._save_source = 0 + self._save_state() return False + def _schedule_state_save(self) -> None: + if self._save_source: + GLib.source_remove(self._save_source) + self._save_source = GLib.timeout_add(500, self._save_state) + + def _save_state(self) -> bool: + self._save_source = 0 + self._state_store.save( + UiState( + view=self._view_mode, + slot_height=self._slot_height, + sidebar_width=self._split.get_position(), + visible_calendars=tuple(sorted(self._visible_calendars)), + collapsed_accounts=tuple(sorted(self._collapsed_accounts)), + ) + ) + return GLib.SOURCE_REMOVE + @staticmethod def _install_accelerators(application: Adw.Application) -> None: shortcuts = { diff --git a/projects/khora/tests/test_state.py b/projects/khora/tests/test_state.py new file mode 100644 index 00000000..ffd05493 --- /dev/null +++ b/projects/khora/tests/test_state.py @@ -0,0 +1,23 @@ +from khora.state import StateStore, UiState + + +def test_round_trips_ui_state(tmp_path) -> None: + store = StateStore(tmp_path / "state.json") + state = UiState( + view="agenda", + slot_height=32, + sidebar_width=300, + visible_calendars=("Personal", "Work"), + collapsed_accounts=("university",), + ) + + assert store.save(state) + assert store.load() == state + + +def test_missing_or_malformed_state_uses_defaults(tmp_path) -> None: + store = StateStore(tmp_path / "state.json") + assert store.load() == UiState() + + store.path.write_text("calendar goblins") + assert store.load() == UiState()