From 25bef837af93ae3a014ff777a428ac4c1fa09f64 Mon Sep 17 00:00:00 2001 From: Scott Nichols Date: Fri, 23 Jan 2026 07:58:55 -0800 Subject: [PATCH 1/8] run 2 --- AGENTS.md | 39 +- pkg/tui/cache/cache_test.go | 347 ++++++++++++++++++ pkg/tui/cache/sync.go | 1 + pkg/tui/cache/sync_test.go | 108 ++++++ .../components/collectiondetail/model_test.go | 29 ++ .../components/collectionnav/model_test.go | 28 ++ todo.md | 60 +++ 7 files changed, 592 insertions(+), 20 deletions(-) create mode 100644 pkg/tui/cache/cache_test.go create mode 100644 pkg/tui/cache/sync_test.go create mode 100644 todo.md diff --git a/AGENTS.md b/AGENTS.md index df2172e..66dcd9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,8 @@ ## Project Structure & Module Organization - `bujo.go` hosts the Cobra root; subcommands in `pkg/commands/` hand requests to runners under `pkg/runner//`. Collection metadata helpers (type enums, validation, JSON marshalling) live in `pkg/collection/`, while CLI runners for that metadata sit in `pkg/runner/collections/`. -- The interactive TUI resides in `pkg/runner/tea/`. `ui.go` orchestrates modes and service integration; view-model logic is split into `internal/indexview/` (calendar + index) and `internal/detailview/` (stacked detail pane). The bottom bar component lives in `internal/bottombar/`. Regression suites (`ui_navigation_test.go`, `ui_refresh_test.go`) pin current behaviour. -- Persistence and configuration helpers stay in `pkg/store/`; calendar rendering lives alongside the TUI in `pkg/runner/tea/internal/calendar/`. `pkg/store/watch.go` wraps `fsnotify` so runners can subscribe to disk changes without reimplementing walkers. +- The interactive TUI lives under `pkg/tui/` with the root Bubble Tea model in `pkg/tui/app/` and reusable panes in `pkg/tui/components/`. `pkg/runner/tea` is a thin shim that invokes the TUI app so CLI wiring stays stable. +- Persistence and configuration helpers stay in `pkg/store/`. `pkg/store/watch.go` wraps `fsnotify` so runners can subscribe to disk changes without reimplementing walkers. - Domain types, glyphs, and printers are in `pkg/entry/`, `pkg/glyph/`, and `pkg/printers/`; feature helpers belong next to the runner they serve. - Reporting logic lives in `pkg/app/report.go`; shared duration parsing sits in `pkg/timeutil/`. CLI wiring is in `pkg/commands/report.go`, while the TUI report overlay reuses `detailview` sections. @@ -14,7 +14,7 @@ - `go run . report --last 3d` — list recently completed entries (window defaults to `1w`). - `go run . collections type "Future" monthly` — set or create a collection with the requested type (monthly/daily/generic/tracking). - `gofmt -s -w . && go vet ./...` — enforce formatting and vet checks. -- `GOCACHE=$(pwd)/.gocache go test ./pkg/runner/tea` — run the current TUI test suite; add `-race -v` when debugging. +- `GOCACHE=$(pwd)/.gocache go test ./pkg/tui/...` — run the current TUI test suite; add `-race -v` when debugging. - `GOCACHE=$(pwd)/.gocache go test ./pkg/store` — verify the fsnotify-backed watcher and persistence helpers without touching global caches. - `GOCACHE=$(pwd)/.gocache go test ./pkg/timeutil` — validate duration parsing helpers before shipping new window keywords. @@ -25,13 +25,13 @@ - Always format with `gofmt`/`goimports`; group imports stdlib → third-party → internal. - Exported identifiers use `PascalCase`, locals `camelCase`, package names stay short and lowercase. - Cobra command descriptions are imperative. Prefer small helpers (e.g., `handleNormalKey`, `loadDetailSectionsWithFocus`) over monolithic switches, and comment intent only where logic is non-obvious. -- UI view-model code favours pure state transitions; rendering lives in dedicated components (`collectionnav`, `collectiondetail`, `bottombar`, etc.). +- UI view-model code favours pure state transitions; rendering lives in dedicated components (`collectionnav`, `collectiondetail`, `bottombar`, etc.). Cross-pane communication should flow through typed events in `pkg/tui/events` (event bridge), not direct coupling. - Keep `Update`/`View` work fast; move expensive operations into `tea.Cmd`s or background services so the event loop never stalls. ## Testing Guidelines - Co-locate tests (`*_test.go`) with the code they cover; use table-driven cases for runners, stores, and state helpers. - Rely on in-memory fakes (see `fakePersistence` in tests) when touching persistence. -- Before refactors around calendar/index behaviour, extend the component/new-app regression tests (for example `pkg/tui/components/.../_test.go` or `pkg/tui/newapp/command_layout_test.go`) and run `go test ./pkg/tui/...`. +- Before refactors around calendar/index behaviour, extend the component regression tests (for example `pkg/tui/components/.../_test.go` or `pkg/tui/app/command_layout_test.go`) and run `go test ./pkg/tui/...`. - Report generation and collection-type inference tests live in `pkg/app/app_test.go` alongside the in-memory persistence fake; prefer those helpers when tweaking heuristics or adding report coverage. ## Commit & Pull Request Guidelines @@ -45,7 +45,7 @@ ## Architecture Overview - CLI flow: Cobra command → runner (`pkg/runner/...`) → store/entries → printers/UI. - The TUI is layered: - - `pkg/tui/newapp` hosts the Bubble Tea root model (`app.go`), command handling, and overlay orchestration. + - `pkg/tui/app` hosts the Bubble Tea root model (`app.go`), command handling, and overlay orchestration. - `pkg/tui/components/collectionnav` renders the left-hand index/calendar and tracks fold state. - `pkg/tui/components/collectiondetail` renders the right-hand stacked collection/day panes with natural scrolling (no sticky top). - `pkg/tui/components/bottombar` owns the contextual footer and command palette suggestions. @@ -55,26 +55,25 @@ - Collection types drive rendering: `monthly` parents (e.g., `Future`) expand into month folders, `daily` months render the calendar grid, `tracking` collections group under a synthetic footer panel. Both the CLI (`bujo collections type `) and TUI commands (`:type [collection] `, `:new-collection`) call into `Service.SetCollectionType`, which enforces naming rules before persisting. `EnsureCollections` and `EnsureCollectionOfType` infer types for legacy data, ensuring calendar folders upgrade without manual edits. - `Service.Report` groups completed entries by collection within a window; it powers both `bujo report --last ` and the TUI's scrollable `:report` overlay. (TODO: expose alternate report output formats such as JSON/Markdown.) - The TUI code now lives under `pkg/tui/`: - - `pkg/tui/newapp` supplies the runnable Bubble Tea program and overlay implementations (`add_overlay.go`, `report_overlay.go`, `move_overlay.go`, etc.). + - `pkg/tui/app` supplies the runnable Bubble Tea program and overlay implementations (`add_overlay.go`, `report_overlay.go`, `move_overlay.go`, etc.). - `pkg/tui/components/...` contains reusable panes (`index`, `detail`, `bottombar`, `calendar`, `overlaypane`, etc.) that implement the shared `pkg/tui/ui.Component` interface. - `pkg/tui/theme` owns Lip Gloss styles; `pkg/tui/uiutil` centralizes formatting helpers. - - `pkg/runner/tea` is a thin shim that calls into `pkg/tui/newapp` to keep the CLI wiring stable. + - `pkg/runner/tea` is a thin shim that calls into `pkg/tui/app` to keep the CLI wiring stable. - The testbed CLI mirrors the component structure: shared harness logic stays in `testbed/main.go`, while feature-specific commands (e.g., `calendar`) live in their own files (see `testbed/calendar_cmd.go`) so we can iterate on individual components without bloating the main entrypoint. - The TUI shares styling via `pkg/tui/theme`: extend this `Theme` struct when adding components so Lip Gloss styles stay centralized. Overlays such as the command footer, detail panel, and report view should consume these semantic styles instead of instantiating `lipgloss.NewStyle` inline. -- Leaf UI pieces should implement the lightweight `ui.Component` interface (`Init`, `Update`, `View`, `SetSize`). Overlay panels such as add-task, bullet detail, move, and report live beside the root model inside `pkg/tui/newapp`, keeping routing/mode transitions in one place. +- Leaf UI pieces should implement the lightweight `ui.Component` interface (`Init`, `Update`, `View`, `SetSize`). Overlay panels such as add-task, bullet detail, move, and report live beside the root model inside `pkg/tui/app`, keeping routing/mode transitions in one place. - Shared formatting helpers belong in `pkg/tui/uiutil` (collection labels, entry labels, day parsing, etc.) to keep rendering logic consistent between the root model and the component packages. ## Bubble Tea at scale: structuring large TUIs -- **Repo layout:** keep TEA’s root model under `internal/app` (model/update/view), generic widgets in `internal/ui`, workflow-specific “views” in `internal/views`, and platform ports/adapters separated so models remain pure. Add `theme.go` to host Lip Gloss styles and a shared `Theme` struct. (See Bubble Tea docs on Go Packages.) -- **Components:** define a tiny `Component` interface (`Init`, `Update`, `View`, `SetSize`) so parent models can compose leaf widgets. Components should emit typed messages (e.g., `SelectedMsg`) and accept dependencies via constructor options—never global state. Provide `SetSize` so only the root handles `tea.WindowSizeMsg`. -- **Model receivers:** prefer pointer receivers for stateful models so Bubble Tea updates persist across `Init`/`Update`; only switch to value receivers when you truly want immutable semantics. -- **Reusability:** wrap Bubbles primitives (list, table, textinput, viewport, help) with your theme and messages. Package reusable components under `pkg/` with `New(opts ...)`, typed messages, and versioned modules if you plan to share them across repos. -- **Styling:** centralize Lip Gloss style definitions in a `Theme` and avoid hardcoding colors. Provide layout helpers (`Gap`, `Pad`, `JoinH/V`) and keep width/height math in the root. Renderers stay stateless; pair Lip Gloss with reflow/viewport for ANSI-aware wrapping. -- **Navigation:** treat the app as a tree of models. The root routes `Msg`s to children, aggregates `Cmd`s, and manages view stacks/routes. Use typed wrapper messages (`ChildMsg{From, Msg}`) to bubble events up. Focus management is just “send key to focused child first, others can ignore”. Router patterns: single active view, stack of views, or dashboards (broadcast messages, let inactive children drop them). -- **Message routing & subscriptions:** parent handles global input (`WindowSizeMsg`, quit), broadcasts domain messages, and listens for child outputs. Commands perform IO; state updates stay fast/pure. Combine child commands with `tea.Batch`. -- **Command ordering:** `tea.Cmd`s run in their own goroutines; never assume their responses arrive in the order dispatched—tag messages and guard shared state accordingly. -- **Testing/logging:** drive interactive flows via teatest, log message streams when debugging, benchmark `View()` for large lists. Keep models pure to simplify unit tests of view-model logic (`Update` → new state + command). -- **Pitfalls to avoid:** monolithic “god” model (split into nested components), scattered layout math (centralize), blocking IO in `Update` (use `Cmd`s), inconsistent UX (share keymaps/help/theme). Bubble Tea community tips (leg100) echo these best practices. +- **MVU-first routing:** treat `Update` as a message router; no blocking IO. Use `tea.Cmd` for side effects and keep state transitions fast/pure. Messages should represent “something happened.” +- **Composable models:** prefer sub-models that implement `tea.Model`. The parent delegates `Update`/`View` and aggregates `Cmd`s. Components emit typed messages with `ComponentID` (event bridge), not direct calls into siblings. +- **Event bridge discipline:** cross-pane coordination must flow through `pkg/tui/events` messages. Avoid tight coupling (don’t reach into other components’ internals to update state). +- **Keymaps:** define keymaps with `bubbles/key` and use `key.Matches` inside `Update`. Keep key handling consistent across components. +- **Reuse Bubbles patterns:** wrap Bubbles primitives (list, textinput, viewport, help) with the project theme and typed messages rather than custom widgets. +- **Router for scale:** for multiple workflows, add a small page/router layer (single active view, stack, or dashboard). Overlays should be modeled as sub-models with explicit focus/blur. +- **Styling/layout:** centralize Lip Gloss styles in `pkg/tui/theme` and keep layout math in the root model. Renderers stay stateless. +- **Async ordering:** `tea.Cmd`s run concurrently; never assume ordering. Tag/guard responses when state can race. +- **Testing/logging:** unit-test `Update` for view-model logic, and use testbed/teatest for integration flows. Add optional message logging for complex routing/debugging. ## Debugging & Recovery Tips - When the event viewer isn’t enough, add an opt-in message logger (e.g., behind a `DEBUG` env var) that writes every `tea.Msg` to disk so you can tail interactions from another terminal. @@ -98,5 +97,5 @@ - `testbed/main.go` centers the framed component near the top and pins the event viewer directly to the bottom edge of the terminal at full width, so the log feels like a console footer. When vertical space is tight we shave rows off the frame (never the log) but `contentSize()` still reports the inner frame dimensions for components. - The testbed now targets Bubble Tea v2 cursor semantics: every model's `View` returns `(string, *tea.Cursor)` and parents are responsible for offsetting child cursor positions when adding borders, padding, or centering with `lipgloss.Place`. Use helpers such as `offsetCursor` to clone and shift coordinates rather than mutating child cursors in place. - Text inputs (e.g. `pkg/tui/components/addtask`) use `textinput.Model.Cursor()` to expose real cursors. After styling, adjust `cursor.Position.X/Y` by the number of padding and border cells you add (for our add-task frame that's +3 horizontally and +2 vertically before the testbed frame applies its own offsets). When composing nested views always add offsets in the same function that injects whitespace so the cursor stays aligned. -- Avoid running interactive Bubble Tea binaries (e.g., `go run .`, `go run . ui`) unless explicitly requested by the user; doing so can lock the terminal during automation. +- When interacting with the TUI for QA or reproduction, use the `$terminal-controller` skill to drive tmux sessions and capture output deterministically. - When adding or modifying testbed commands that launch Bubble Tea programs (including short-lived overlays), explicitly call out in your notes if they were not run, since interactive sessions are skipped unless the user requests them. diff --git a/pkg/tui/cache/cache_test.go b/pkg/tui/cache/cache_test.go new file mode 100644 index 0000000..a3f9ee2 --- /dev/null +++ b/pkg/tui/cache/cache_test.go @@ -0,0 +1,347 @@ +package cache + +import ( + "context" + "sort" + "strconv" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea/v2" + + "tableflip.dev/bujo/pkg/app" + "tableflip.dev/bujo/pkg/collection" + "tableflip.dev/bujo/pkg/entry" + "tableflip.dev/bujo/pkg/glyph" + "tableflip.dev/bujo/pkg/store" + "tableflip.dev/bujo/pkg/tui/components/collectiondetail" + "tableflip.dev/bujo/pkg/tui/events" +) + +type fakePersistence struct { + entries map[string][]*entry.Entry + metas map[string]collection.Meta + nextID int +} + +// newFakePersistence seeds an in-memory persistence store for cache tests. +func newFakePersistence() *fakePersistence { + return &fakePersistence{ + entries: make(map[string][]*entry.Entry), + metas: make(map[string]collection.Meta), + nextID: 1, + } +} + +func (f *fakePersistence) MapAll(_ context.Context) map[string][]*entry.Entry { + out := make(map[string][]*entry.Entry, len(f.entries)) + for k, v := range f.entries { + clone := make([]*entry.Entry, len(v)) + copy(clone, v) + out[k] = clone + } + return out +} + +func (f *fakePersistence) ListAll(_ context.Context) []*entry.Entry { + var all []*entry.Entry + for _, list := range f.entries { + all = append(all, list...) + } + return all +} + +func (f *fakePersistence) List(_ context.Context, collection string) []*entry.Entry { + list := f.entries[collection] + clone := make([]*entry.Entry, len(list)) + copy(clone, list) + return clone +} + +func (f *fakePersistence) Collections(_ context.Context, prefix string) []string { + var names []string + for name := range f.metas { + if prefix == "" || strings.HasPrefix(name, prefix) { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + +func (f *fakePersistence) CollectionsMeta(_ context.Context, prefix string) []collection.Meta { + var metas []collection.Meta + for name, meta := range f.metas { + if prefix == "" || strings.HasPrefix(name, prefix) { + metas = append(metas, meta) + } + } + sort.Slice(metas, func(i, j int) bool { return metas[i].Name < metas[j].Name }) + return metas +} + +func (f *fakePersistence) Store(e *entry.Entry) error { + if e == nil { + return nil + } + if e.ID == "" { + e.ID = f.newID() + } + list := f.entries[e.Collection] + for i := range list { + if list[i].ID == e.ID { + list[i] = e + f.entries[e.Collection] = list + return nil + } + } + f.entries[e.Collection] = append(list, e) + return nil +} + +func (f *fakePersistence) Delete(e *entry.Entry) error { + if e == nil { + return nil + } + list := f.entries[e.Collection] + for i := range list { + if list[i].ID == e.ID { + list = append(list[:i], list[i+1:]...) + f.entries[e.Collection] = list + return nil + } + } + return nil +} + +func (f *fakePersistence) DeleteCollection(_ context.Context, name string) error { + delete(f.entries, name) + delete(f.metas, name) + return nil +} + +func (f *fakePersistence) EnsureCollection(name string) error { + if _, ok := f.metas[name]; !ok { + f.metas[name] = collection.Meta{Name: name, Type: collection.TypeGeneric} + } + return nil +} + +func (f *fakePersistence) EnsureCollectionTyped(name string, typ collection.Type) error { + f.metas[name] = collection.Meta{Name: name, Type: typ} + return nil +} + +func (f *fakePersistence) SetCollectionType(name string, typ collection.Type) error { + f.metas[name] = collection.Meta{Name: name, Type: typ} + return nil +} + +func (f *fakePersistence) Watch(_ context.Context) (<-chan store.Event, error) { + ch := make(chan store.Event) + return ch, nil +} + +func (f *fakePersistence) newID() string { + id := f.nextID + f.nextID++ + return "id-" + strconv.Itoa(id) +} + +func TestApplySnapshotEmitsCollectionChanges(t *testing.T) { + cache := New("cache-test") + initial := Snapshot{ + Metas: []collection.Meta{ + {Name: "Alpha", Type: collection.TypeGeneric}, + }, + Sections: []collectiondetail.Section{{ID: "Alpha", Title: "Alpha", Subtitle: "generic"}}, + } + cache.ApplySnapshot(initial) + drainEvents(cache.Events()) + + next := Snapshot{ + Metas: []collection.Meta{ + {Name: "Alpha", Type: collection.TypeTracking}, + {Name: "Beta", Type: collection.TypeGeneric}, + }, + Sections: []collectiondetail.Section{{ID: "Alpha", Title: "Alpha", Subtitle: "tracking"}}, + } + cache.ApplySnapshot(next) + + var creates, updates int + for _, msg := range drainEvents(cache.Events()) { + change, ok := msg.(events.CollectionChangeMsg) + if !ok { + continue + } + switch change.Action { + case events.ChangeCreate: + creates++ + case events.ChangeUpdate: + updates++ + } + } + if creates != 1 { + t.Fatalf("expected 1 collection create, got %d", creates) + } + if updates != 1 { + t.Fatalf("expected 1 collection update, got %d", updates) + } +} + +func TestApplySnapshotEmitsBulletUpdates(t *testing.T) { + cache := New("cache-test") + initial := Snapshot{ + Metas: []collection.Meta{{Name: "Alpha", Type: collection.TypeGeneric}}, + Sections: []collectiondetail.Section{{ID: "Alpha", Bullets: []collectiondetail.Bullet{{ID: "1", Label: "old", Bullet: glyph.Task}}}}, + } + cache.ApplySnapshot(initial) + drainEvents(cache.Events()) + + next := Snapshot{ + Metas: []collection.Meta{{Name: "Alpha", Type: collection.TypeGeneric}}, + Sections: []collectiondetail.Section{{ID: "Alpha", Bullets: []collectiondetail.Bullet{{ID: "1", Label: "new", Bullet: glyph.Task}}}}, + } + cache.ApplySnapshot(next) + + var updates int + for _, msg := range drainEvents(cache.Events()) { + change, ok := msg.(events.BulletChangeMsg) + if !ok { + continue + } + if change.Action == events.ChangeUpdate { + updates++ + } + } + if updates != 1 { + t.Fatalf("expected 1 bullet update, got %d", updates) + } +} + +func TestApplySnapshotEmitsBulletCreateAndDelete(t *testing.T) { + cache := New("cache-test") + initial := Snapshot{ + Metas: []collection.Meta{{Name: "Alpha", Type: collection.TypeGeneric}}, + Sections: []collectiondetail.Section{{ID: "Alpha", Bullets: []collectiondetail.Bullet{{ID: "1", Label: "old", Bullet: glyph.Task}}}}, + } + cache.ApplySnapshot(initial) + drainEvents(cache.Events()) + + next := Snapshot{ + Metas: []collection.Meta{{Name: "Alpha", Type: collection.TypeGeneric}}, + Sections: []collectiondetail.Section{{ID: "Alpha", Bullets: []collectiondetail.Bullet{{ID: "2", Label: "new", Bullet: glyph.Task}}}}, + } + cache.ApplySnapshot(next) + + var creates, deletes int + for _, msg := range drainEvents(cache.Events()) { + change, ok := msg.(events.BulletChangeMsg) + if !ok { + continue + } + switch change.Action { + case events.ChangeCreate: + creates++ + case events.ChangeDelete: + deletes++ + } + } + if creates != 1 { + t.Fatalf("expected 1 bullet create, got %d", creates) + } + if deletes != 1 { + t.Fatalf("expected 1 bullet delete, got %d", deletes) + } +} + +func TestCreateCollectionEmitsChangeAndCreatesSection(t *testing.T) { + cache := New("cache-test") + cache.CreateCollection(collection.Meta{Name: "Alpha", Type: collection.TypeDaily}) + + if _, ok := cache.SectionSnapshot("Alpha"); !ok { + t.Fatalf("expected section snapshot for Alpha") + } + + var creates int + for _, msg := range drainEvents(cache.Events()) { + change, ok := msg.(events.CollectionChangeMsg) + if ok && change.Action == events.ChangeCreate { + creates++ + } + } + if creates != 1 { + t.Fatalf("expected 1 collection create, got %d", creates) + } +} + +func TestSyncCollectionBuildsSection(t *testing.T) { + fake := newFakePersistence() + fake.metas["Alpha"] = collection.Meta{Name: "Alpha", Type: collection.TypeGeneric} + fake.entries["Alpha"] = []*entry.Entry{newEntry("entry-1", "", time.Now())} + + svc := &app.Service{Persistence: fake} + cache := NewWithOptions(Options{Component: "cache-test", Service: svc}) + if err := cache.SyncCollection(context.Background(), "Alpha"); err != nil { + t.Fatalf("expected sync to succeed, got %v", err) + } + + section, ok := cache.SectionSnapshot("Alpha") + if !ok { + t.Fatalf("expected section snapshot for Alpha") + } + if len(section.Bullets) != 1 { + t.Fatalf("expected 1 bullet, got %d", len(section.Bullets)) + } + + var creates int + for _, msg := range drainEvents(cache.Events()) { + change, ok := msg.(events.BulletChangeMsg) + if ok && change.Action == events.ChangeCreate { + creates++ + } + } + if creates != 1 { + t.Fatalf("expected 1 bullet create event, got %d", creates) + } +} + +func TestCreateBulletPersistedValidatesInputs(t *testing.T) { + cache := New("cache-test") + + err := cache.createBulletPersisted(context.Background(), &app.Service{}, "", collectiondetail.Bullet{Label: "ok"}, nil) + if err == nil { + t.Fatalf("expected error for empty collection id") + } + + err = cache.createBulletPersisted(context.Background(), &app.Service{}, "Alpha", collectiondetail.Bullet{}, nil) + if err == nil { + t.Fatalf("expected error for empty bullet label") + } +} + +func TestCreateBulletPersistedFailsOnMissingParent(t *testing.T) { + fake := newFakePersistence() + fake.metas["Alpha"] = collection.Meta{Name: "Alpha", Type: collection.TypeGeneric} + svc := &app.Service{Persistence: fake} + cache := NewWithOptions(Options{Component: "cache-test", Service: svc}) + + err := cache.createBulletPersisted(context.Background(), svc, "Alpha", collectiondetail.Bullet{Label: "child", Bullet: glyph.Task}, map[string]string{parentMetaKey: "missing"}) + if err == nil { + t.Fatalf("expected error for missing parent") + } +} + +// drainEvents collects any pending cache events without blocking. +func drainEvents(ch <-chan tea.Msg) []tea.Msg { + var msgs []tea.Msg + for { + select { + case msg := <-ch: + msgs = append(msgs, msg) + default: + return msgs + } + } +} diff --git a/pkg/tui/cache/sync.go b/pkg/tui/cache/sync.go index 04690b7..828905d 100644 --- a/pkg/tui/cache/sync.go +++ b/pkg/tui/cache/sync.go @@ -369,6 +369,7 @@ func buildSection(meta collection.Meta, entries []*entry.Entry) collectiondetail return section } +// buildBullets converts flat entries into a bullet tree ordered by Created time. func buildBullets(entries []*entry.Entry) []collectiondetail.Bullet { if len(entries) == 0 { return nil diff --git a/pkg/tui/cache/sync_test.go b/pkg/tui/cache/sync_test.go new file mode 100644 index 0000000..50f658a --- /dev/null +++ b/pkg/tui/cache/sync_test.go @@ -0,0 +1,108 @@ +package cache + +import ( + "testing" + "time" + + "tableflip.dev/bujo/pkg/entry" + "tableflip.dev/bujo/pkg/glyph" + "tableflip.dev/bujo/pkg/tui/components/collectiondetail" +) + +func TestBuildBulletsOrdersRootsAndChildren(t *testing.T) { + t0 := time.Date(2025, 10, 1, 9, 0, 0, 0, time.UTC) + t1 := t0.Add(time.Hour) + t2 := t1.Add(time.Hour) + t3 := t2.Add(time.Hour) + + entries := []*entry.Entry{ + newEntry("child-2", "a", t3), + newEntry("b", "", t0), + newEntry("child-1", "a", t2), + newEntry("a", "", t1), + } + + bullets := buildBullets(entries) + if len(bullets) != 2 { + t.Fatalf("expected 2 root bullets, got %d", len(bullets)) + } + if bullets[0].ID != "b" { + t.Fatalf("expected first root to be b, got %q", bullets[0].ID) + } + if bullets[1].ID != "a" { + t.Fatalf("expected second root to be a, got %q", bullets[1].ID) + } + if len(bullets[1].Children) != 2 { + t.Fatalf("expected 2 children for a, got %d", len(bullets[1].Children)) + } + if bullets[1].Children[0].ID != "child-1" { + t.Fatalf("expected first child to be child-1, got %q", bullets[1].Children[0].ID) + } + if bullets[1].Children[1].ID != "child-2" { + t.Fatalf("expected second child to be child-2, got %q", bullets[1].Children[1].ID) + } +} + +func TestBuildBulletsOrphansBecomeRoots(t *testing.T) { + t0 := time.Date(2025, 11, 5, 9, 0, 0, 0, time.UTC) + t1 := t0.Add(time.Hour) + + entries := []*entry.Entry{ + newEntry("orphan", "missing", t1), + newEntry("root", "", t0), + } + + bullets := buildBullets(entries) + if len(bullets) != 2 { + t.Fatalf("expected 2 root bullets, got %d", len(bullets)) + } + if bullets[0].ID != "root" { + t.Fatalf("expected first root to be root, got %q", bullets[0].ID) + } + if bullets[1].ID != "orphan" { + t.Fatalf("expected orphan to remain a root, got %q", bullets[1].ID) + } +} + +func TestBuildBulletsHandlesCycles(t *testing.T) { + t0 := time.Date(2025, 12, 1, 9, 0, 0, 0, time.UTC) + t1 := t0.Add(time.Hour) + + entries := []*entry.Entry{ + newEntry("a", "b", t0), + newEntry("b", "a", t1), + } + + bullets := buildBullets(entries) + if len(bullets) != 2 { + t.Fatalf("expected 2 root bullets for cycle, got %d", len(bullets)) + } +} + +func TestDedupeBulletsMergesByID(t *testing.T) { + bullets := []collectiondetail.Bullet{ + {ID: "dup", Label: "first", Bullet: glyph.Note}, + {ID: "dup", Label: "second", Signifier: glyph.Priority}, + } + + result := dedupeBullets(bullets) + if len(result) != 1 { + t.Fatalf("expected 1 bullet after dedupe, got %d", len(result)) + } + if result[0].Label != "second" { + t.Fatalf("expected merged label to prefer updated value, got %q", result[0].Label) + } + if result[0].Signifier != glyph.Priority { + t.Fatalf("expected merged signifier to be preserved, got %q", result[0].Signifier) + } +} + +func newEntry(id, parent string, created time.Time) *entry.Entry { + return &entry.Entry{ + ID: id, + Bullet: glyph.Task, + Created: entry.Timestamp{Time: created}, + Message: id, + ParentID: parent, + } +} diff --git a/pkg/tui/components/collectiondetail/model_test.go b/pkg/tui/components/collectiondetail/model_test.go index 0460e4a..c8b441a 100644 --- a/pkg/tui/components/collectiondetail/model_test.go +++ b/pkg/tui/components/collectiondetail/model_test.go @@ -85,3 +85,32 @@ func TestEnsureScrollAccountsForStickyHeaderSpacing(t *testing.T) { t.Fatalf("expected focused bullet to be visible, got:\n%s", plain) } } + +func TestPlaceholderSectionStaysVisibleWhenCursorEmpty(t *testing.T) { + bullets := make([]Bullet, 0, 12) + for i := 0; i < 12; i++ { + bullets = append(bullets, makeBullet( + fmt.Sprintf("task-%02d", i), + fmt.Sprintf("Task %02d", i), + )) + } + + model := NewModel([]Section{ + {ID: "Inbox", Title: "Inbox", Bullets: bullets}, + {ID: "Today", Title: "Today", Placeholder: true}, + }) + model.SetSize(40, 6) + model.Focus() + + model.FocusCollection("Today") + model.cursor = -1 + model.ensureScroll() + + view := stripANSIString(model.View()) + if !strings.Contains(view, "Today") { + t.Fatalf("expected Today header visible, got:\n%s", view) + } + if !strings.Contains(view, "collection not yet created") { + t.Fatalf("expected placeholder message, got:\n%s", view) + } +} diff --git a/pkg/tui/components/collectionnav/model_test.go b/pkg/tui/components/collectionnav/model_test.go index 400e0b3..a9417f6 100644 --- a/pkg/tui/components/collectionnav/model_test.go +++ b/pkg/tui/components/collectionnav/model_test.go @@ -60,3 +60,31 @@ func TestViewTrimsCalendarPadding(t *testing.T) { t.Fatalf("expected 8 lines, got %d:\n%s", lines, view) } } + +func TestSelectedCalendarDayCreatesVirtualDay(t *testing.T) { + monthTime := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC) + month := &viewmodel.ParsedCollection{ + ID: "January 2024", + Name: "January 2024", + Type: collection.TypeDaily, + Exists: true, + Month: monthTime, + } + + model := NewModel([]*viewmodel.ParsedCollection{month}) + model.SetNow(monthTime) + if cal := model.ensureCalendar(month); cal != nil { + cal.SetSelected(5) + } + + day, exists := model.selectedCalendarDay(month) + if day == nil { + t.Fatalf("expected virtual day to be returned") + } + if exists { + t.Fatalf("expected virtual day to be marked missing") + } + if day.Name != "January 5, 2024" { + t.Fatalf("expected day name January 5, 2024, got %q", day.Name) + } +} diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..4d02239 --- /dev/null +++ b/todo.md @@ -0,0 +1,60 @@ +# TODO + +## Refactor / Decompose +- [ ] Split `pkg/tui/app/app.go` into smaller feature files (command handling, overlay lifecycle, migration/move flows, watch/cache, focus management). The `Update` method and multiple 80–120+ line handlers (`handleMoveSelection`, `showMigrateOverlay`, `handleMigrationMoveRequest`, etc.) are hard to reason about and should be broken into focused helpers. +- [ ] Extract selection/highlight routing logic out of `pkg/tui/components/journal/model.go` and `pkg/tui/components/collectiondetail/model.go` into a small coordinator (or dedicated methods) to reduce cross-component knowledge and duplicated focus logic. +- [ ] Split `pkg/tui/components/collectiondetail/model.go` into rendering/layout vs. event handling vs. data mutation; consider moving line-layout/scrolling into a smaller state struct (similar to `pkg/tui/components/detail/state.go`). +- [ ] Split `pkg/tui/components/collectionnav/model.go` into list view, calendar view, and sorting/selection logic. The calendar-related methods (`calendarChildren`, `selectedCalendarDay`, `virtualDay`, etc.) can live in a small sub-module. +- [ ] Break up `pkg/tui/components/detail/state.go` (820 lines) into render/layout, mutation, and navigation files; several long helpers (`formatEntryLines`, `ensureScrollVisible`, `renderSection`) are tightly coupled and difficult to test in isolation. +- [ ] Split `pkg/tui/components/addtask/model.go` into input handling, collection filtering, and rendering; overlay logic is large and mixes view/behavior. +- [ ] Split `pkg/tui/components/command/model.go` into suggestion overlay, input handling, and rendering; `refreshSuggestionOverlay` and `Update` are large and mix concerns. +- [ ] Separate `pkg/tui/cache/cache.go` (state + events) from sync/diff helpers (`pkg/tui/cache/sync.go`) into clearer subpackages or files; the sync logic is complex and deserves its own unit-test surface. +- [ ] Consider refactoring `pkg/store/diskv.go` into smaller helpers for meta, entries, and locking/error translation; file I/O paths are large and imperative. + +## Logic Cleanup / Consistency +- [ ] Consolidate collection ordering rules in one place (currently spread between `pkg/collection/viewmodel/viewmodel.go`, `pkg/tui/cache/sync.go`, and `pkg/tui/components/index/indexview.go`). Prefer a single canonical ordering function and reuse it across nav/detail/cache to avoid drift. +- [ ] Normalize “now” handling: multiple subsystems call `time.Now()` independently. Inject a shared clock (e.g., via app/model options) so UI ordering, cache build, and nav calendar stay consistent. +- [ ] Reduce repeated “overlay guard” patterns in `pkg/tui/app/app.go` by introducing a helper to handle “skip command/journal update” logic and a single overlay state machine entrypoint. +- [ ] Improve selection/highlight interplay when nav is focused but detail is not (calendar highlight vs. detail view): centralize the rules for when detail should scroll/select vs. only highlight. +- [ ] Standardize section/collection ID formatting in detail vs. nav (several helpers rely on formatted names); enforce a single canonical ID format to avoid mismatches. +- [ ] Audit status message flows and make them deterministic (currently status is set from many branches in `app.go`). + +## Tests To Add (Coverage Gaps) +- [x] Add tests for `pkg/tui/cache` (currently no tests): + - [x] `buildBullets` ordering, parent-child linking, cycles/missing parents, dedupe behavior. + - [x] `applySnapshotLocked` diff behavior (create/update/delete) and event emission. + - [x] `CreateCollection`, `SyncCollection`, and error paths in `createBulletPersisted`. +- [ ] Add tests for `pkg/tui/components/collectiondetail`: + - [x] placeholder/empty-section rendering behavior, focus/scroll behavior when `cursor == -1`. + - [ ] selection retention after section reorder and after placeholder insertions. + - [ ] highlight vs. select event handling (day vs. non-day collections). +- [ ] Add tests for `pkg/tui/components/collectionnav`: + - [x] calendar day selection for missing entries (virtual days). + - [ ] highlight/select messages for day vs. non-day rows. + - [ ] `calendarChildren` ordering and `DefaultSelectedDay` logic. +- [ ] Add tests for `pkg/tui/components/index/indexview.go`: + - [ ] `BuildItems` ordering (future, daily w/ today-first, monthly, tracking, generic). + - [ ] `RenderCalendarRows` and `DefaultSelectedDay` edge cases (month boundaries, no days). +- [ ] Add tests for `pkg/tui/components/addtask` and `pkg/tui/components/command`: + - [ ] input handling transitions and overlay focus behavior. +- [ ] Add tests for `pkg/store/diskv.go`: + - [ ] read/write round-trips, missing/corrupt file handling, concurrency edge cases. +- [ ] Add tests for `pkg/commands` + `pkg/runner/*`: + - [ ] CLI option parsing and error paths (commands + runner integration). +- [ ] Add tests for `pkg/collection/types.go` and `pkg/collection/meta.go` for type inference and path handling (minor, but currently uncovered). + +## Docs / Comments / Style +- [ ] Audit exported functions/types lacking doc comments in `pkg/tui/*` and `pkg/app/*`; add brief docs where public APIs are used across packages. +- [ ] Remove or update stale inline comments in large UI models where behavior has shifted (e.g., overlay close behavior, focus handling). + +## Architectural Follow-ups +- [ ] Consider introducing small interfaces for service/cache interactions to enable unit-testing UI components without real disk/service access. +- [ ] Move “testbed” utilities into a separate module or mark them clearly as non-production helpers to reduce noise in core packages. + +## Bubble Tea Alignment Goals +- [ ] Standardize key handling with `bubbles/key` maps in `collectionnav`, `collectiondetail`, `command`, and `addtask`; replace ad-hoc `msg.String()` switches with `key.Matches`. +- [ ] Introduce a consistent child message routing pattern (e.g., `ChildMsg{From, Msg}`) or a small router helper in `pkg/tui/app` so parent/child message flow is explicit and testable. +- [ ] Add a page/router layer in `pkg/tui/app` (single active view + overlay stack) to reduce the `Update` method size and make navigation explicit. +- [ ] Refactor overlays into sub-models that implement `tea.Model`, with consistent focus/blur and message ownership. +- [ ] Audit direct cross-component calls and replace with `pkg/tui/events` messages where practical to reinforce the event bridge. +- [ ] Centralize Lip Gloss styling in `pkg/tui/theme` and replace inline styles in components where possible. From c0e7be5870f0eeed9cf2da5b4eeb43390ee691ba Mon Sep 17 00:00:00 2001 From: Scott Nichols Date: Sat, 24 Jan 2026 08:22:45 -0800 Subject: [PATCH 2/8] post long refactor --- pkg/collection/collection_test.go | 58 + pkg/collection/viewmodel/order.go | 32 + pkg/commands/task_test.go | 30 + pkg/runner/add/add_test.go | 87 + pkg/store/diskv.go | 454 --- pkg/store/diskv_collections.go | 255 ++ pkg/store/diskv_entries.go | 172 + pkg/store/diskv_helpers.go | 56 + pkg/store/diskv_test.go | 110 + pkg/tui/app/add_overlay.go | 3 +- pkg/tui/app/add_task_handlers.go | 104 + pkg/tui/app/app.go | 2997 ++--------------- pkg/tui/app/bullet_detail_handlers.go | 209 ++ pkg/tui/app/bullet_overlay.go | 3 +- pkg/tui/app/clock.go | 14 + pkg/tui/app/command_handlers.go | 171 + pkg/tui/app/command_handlers_test.go | 48 + pkg/tui/app/command_layout_test.go | 4 +- pkg/tui/app/focus.go | 110 + pkg/tui/app/help_handlers.go | 152 + pkg/tui/app/journal_access.go | 19 + pkg/tui/app/journal_focus.go | 27 + pkg/tui/app/layout.go | 128 + pkg/tui/app/migrate_overlay.go | 5 +- pkg/tui/app/migration_handlers.go | 543 +++ pkg/tui/app/move_handlers.go | 536 +++ pkg/tui/app/move_helpers.go | 169 + pkg/tui/app/move_overlay.go | 5 +- pkg/tui/app/new_collection_handlers.go | 155 + pkg/tui/app/new_collection_overlay.go | 4 +- pkg/tui/app/overlay_guard.go | 74 + pkg/tui/app/overlay_guard_test.go | 54 + pkg/tui/app/overlay_helpers.go | 9 + pkg/tui/app/overlay_stack.go | 120 + pkg/tui/app/overlay_stack_test.go | 56 + pkg/tui/app/report_handlers.go | 141 + pkg/tui/app/report_overlay.go | 7 +- pkg/tui/app/router.go | 74 + pkg/tui/app/router_test.go | 24 + pkg/tui/app/service.go | 30 + pkg/tui/app/status.go | 64 + pkg/tui/app/status_test.go | 20 + pkg/tui/app/watch.go | 145 + pkg/tui/app/watch_test.go | 33 + pkg/tui/cache/cache.go | 140 +- pkg/tui/cache/helpers.go | 122 + pkg/tui/cache/service.go | 17 + pkg/tui/cache/sync.go | 39 +- pkg/tui/clock/clock.go | 20 + pkg/tui/components/addtask/cache.go | 13 + pkg/tui/components/addtask/data.go | 151 + pkg/tui/components/addtask/input.go | 199 ++ pkg/tui/components/addtask/keymap.go | 36 + pkg/tui/components/addtask/layout.go | 40 + pkg/tui/components/addtask/model.go | 582 +--- pkg/tui/components/addtask/model_test.go | 74 + pkg/tui/components/addtask/util.go | 54 + pkg/tui/components/addtask/view.go | 150 + pkg/tui/components/bulletdetail/model.go | 2 +- pkg/tui/components/calendar/calendar.go | 18 +- .../components/collectiondetail/identity.go | 17 + pkg/tui/components/collectiondetail/keymap.go | 42 + pkg/tui/components/collectiondetail/layout.go | 336 ++ pkg/tui/components/collectiondetail/model.go | 659 +--- .../components/collectiondetail/model_test.go | 66 + .../components/collectiondetail/nav_sync.go | 44 + .../collectiondetail/nav_sync_test.go | 45 + pkg/tui/components/collectiondetail/view.go | 274 ++ pkg/tui/components/collectionnav/calendar.go | 265 ++ pkg/tui/components/collectionnav/keymap.go | 28 + pkg/tui/components/collectionnav/model.go | 271 +- .../components/collectionnav/model_test.go | 94 + pkg/tui/components/command/input.go | 126 + pkg/tui/components/command/keymap.go | 24 + pkg/tui/components/command/layout.go | 27 + pkg/tui/components/command/model.go | 508 +-- pkg/tui/components/command/model_test.go | 106 + pkg/tui/components/command/suggestion.go | 279 ++ pkg/tui/components/command/view.go | 95 + pkg/tui/components/detail/layout.go | 115 + pkg/tui/components/detail/navigation.go | 344 ++ pkg/tui/components/detail/render.go | 216 ++ pkg/tui/components/detail/state.go | 678 ---- pkg/tui/components/detail/state_test.go | 295 +- pkg/tui/components/dummy/model.go | 8 +- pkg/tui/components/help/model.go | 6 +- pkg/tui/components/index/indexview.go | 26 +- pkg/tui/components/index/indexview_test.go | 80 + pkg/tui/components/journal/debug.go | 15 + pkg/tui/components/journal/focus_test.go | 43 + pkg/tui/components/journal/model.go | 49 +- pkg/tui/components/journal/selection.go | 38 + pkg/tui/components/journal/selection_test.go | 55 + pkg/tui/components/overlaypane/model.go | 4 +- pkg/tui/events/events.go | 30 + pkg/tui/theme/theme.go | 34 +- testbed/README.md | 4 + testbed/main.go | 1 + todo.md | 83 +- 99 files changed, 8220 insertions(+), 6378 deletions(-) create mode 100644 pkg/collection/collection_test.go create mode 100644 pkg/collection/viewmodel/order.go create mode 100644 pkg/commands/task_test.go create mode 100644 pkg/runner/add/add_test.go create mode 100644 pkg/store/diskv_collections.go create mode 100644 pkg/store/diskv_entries.go create mode 100644 pkg/store/diskv_helpers.go create mode 100644 pkg/store/diskv_test.go create mode 100644 pkg/tui/app/add_task_handlers.go create mode 100644 pkg/tui/app/bullet_detail_handlers.go create mode 100644 pkg/tui/app/clock.go create mode 100644 pkg/tui/app/command_handlers.go create mode 100644 pkg/tui/app/command_handlers_test.go create mode 100644 pkg/tui/app/focus.go create mode 100644 pkg/tui/app/help_handlers.go create mode 100644 pkg/tui/app/journal_access.go create mode 100644 pkg/tui/app/journal_focus.go create mode 100644 pkg/tui/app/layout.go create mode 100644 pkg/tui/app/migration_handlers.go create mode 100644 pkg/tui/app/move_handlers.go create mode 100644 pkg/tui/app/move_helpers.go create mode 100644 pkg/tui/app/new_collection_handlers.go create mode 100644 pkg/tui/app/overlay_guard.go create mode 100644 pkg/tui/app/overlay_guard_test.go create mode 100644 pkg/tui/app/overlay_helpers.go create mode 100644 pkg/tui/app/overlay_stack.go create mode 100644 pkg/tui/app/overlay_stack_test.go create mode 100644 pkg/tui/app/report_handlers.go create mode 100644 pkg/tui/app/router.go create mode 100644 pkg/tui/app/router_test.go create mode 100644 pkg/tui/app/service.go create mode 100644 pkg/tui/app/status.go create mode 100644 pkg/tui/app/status_test.go create mode 100644 pkg/tui/app/watch.go create mode 100644 pkg/tui/app/watch_test.go create mode 100644 pkg/tui/cache/helpers.go create mode 100644 pkg/tui/cache/service.go create mode 100644 pkg/tui/clock/clock.go create mode 100644 pkg/tui/components/addtask/cache.go create mode 100644 pkg/tui/components/addtask/data.go create mode 100644 pkg/tui/components/addtask/input.go create mode 100644 pkg/tui/components/addtask/keymap.go create mode 100644 pkg/tui/components/addtask/layout.go create mode 100644 pkg/tui/components/addtask/model_test.go create mode 100644 pkg/tui/components/addtask/util.go create mode 100644 pkg/tui/components/addtask/view.go create mode 100644 pkg/tui/components/collectiondetail/identity.go create mode 100644 pkg/tui/components/collectiondetail/keymap.go create mode 100644 pkg/tui/components/collectiondetail/layout.go create mode 100644 pkg/tui/components/collectiondetail/nav_sync.go create mode 100644 pkg/tui/components/collectiondetail/nav_sync_test.go create mode 100644 pkg/tui/components/collectiondetail/view.go create mode 100644 pkg/tui/components/collectionnav/calendar.go create mode 100644 pkg/tui/components/collectionnav/keymap.go create mode 100644 pkg/tui/components/command/input.go create mode 100644 pkg/tui/components/command/keymap.go create mode 100644 pkg/tui/components/command/layout.go create mode 100644 pkg/tui/components/command/model_test.go create mode 100644 pkg/tui/components/command/suggestion.go create mode 100644 pkg/tui/components/command/view.go create mode 100644 pkg/tui/components/detail/layout.go create mode 100644 pkg/tui/components/detail/navigation.go create mode 100644 pkg/tui/components/detail/render.go create mode 100644 pkg/tui/components/index/indexview_test.go create mode 100644 pkg/tui/components/journal/debug.go create mode 100644 pkg/tui/components/journal/focus_test.go create mode 100644 pkg/tui/components/journal/selection.go create mode 100644 pkg/tui/components/journal/selection_test.go create mode 100644 testbed/README.md diff --git a/pkg/collection/collection_test.go b/pkg/collection/collection_test.go new file mode 100644 index 0000000..ad6f6d8 --- /dev/null +++ b/pkg/collection/collection_test.go @@ -0,0 +1,58 @@ +package collection + +import ( + "testing" +) + +func TestParseTypeDefaultsAndErrors(t *testing.T) { + if typ, err := ParseType(""); err != nil || typ != TypeGeneric { + t.Fatalf("expected empty type to default to generic") + } + if _, err := ParseType("unknown"); err == nil { + t.Fatalf("expected error for unknown type") + } +} + +func TestGuessTypeUsesParentAndNames(t *testing.T) { + if typ := GuessType("Future", TypeMonthly); typ != TypeDaily { + t.Fatalf("expected monthly parent to imply daily child") + } + if typ := GuessType("January 2024", TypeGeneric); typ != TypeDaily { + t.Fatalf("expected month name to imply daily type") + } + if typ := GuessType("January 2, 2024", TypeGeneric); typ != TypeGeneric { + t.Fatalf("expected day name to imply generic type") + } +} + +func TestValidateChildName(t *testing.T) { + if err := ValidateChildName(TypeMonthly, "Future", "January 2024"); err != nil { + t.Fatalf("expected monthly child to validate") + } + if err := ValidateChildName(TypeDaily, "January 2024", "January 2, 2024"); err != nil { + t.Fatalf("expected daily child to validate") + } + if err := ValidateChildName(TypeDaily, "January 2024", "February 2, 2024"); err == nil { + t.Fatalf("expected daily child month mismatch to fail") + } +} + +func TestValidateTypeTransition(t *testing.T) { + if err := ValidateTypeTransition(TypeGeneric, ""); err == nil { + t.Fatalf("expected empty type to error") + } + if err := ValidateTypeTransition(TypeGeneric, TypeTracking); err != nil { + t.Fatalf("expected tracking type to be accepted") + } +} + +func TestUnmarshalListLegacyFormat(t *testing.T) { + data := []byte(`["Inbox","Future"]`) + metas, err := UnmarshalList(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(metas) != 2 || metas[0].Type != TypeGeneric { + t.Fatalf("expected legacy format to default types to generic") + } +} diff --git a/pkg/collection/viewmodel/order.go b/pkg/collection/viewmodel/order.go new file mode 100644 index 0000000..530aacc --- /dev/null +++ b/pkg/collection/viewmodel/order.go @@ -0,0 +1,32 @@ +package viewmodel + +import ( + "time" + + "tableflip.dev/bujo/pkg/collection" +) + +// FlattenOrder returns a pre-order traversal of the collection tree IDs. +func FlattenOrder(parsed []*ParsedCollection) []string { + order := make([]string, 0, len(parsed)) + var walk func(list []*ParsedCollection) + walk = func(list []*ParsedCollection) { + for _, node := range list { + if node == nil { + continue + } + order = append(order, node.ID) + if len(node.Children) > 0 { + walk(node.Children) + } + } + } + walk(parsed) + return order +} + +// OrderedIDs returns collection IDs ordered by the canonical sort rules. +func OrderedIDs(metas []collection.Meta, now time.Time) []string { + parsed := BuildTree(metas, WithNow(now)) + return FlattenOrder(parsed) +} diff --git a/pkg/commands/task_test.go b/pkg/commands/task_test.go new file mode 100644 index 0000000..bb5a1d0 --- /dev/null +++ b/pkg/commands/task_test.go @@ -0,0 +1,30 @@ +package commands + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestAddTaskArgsValidation(t *testing.T) { + root := &cobra.Command{Use: "root"} + addTask(root) + + var taskCmd *cobra.Command + for _, cmd := range root.Commands() { + if cmd.Name() == "task" { + taskCmd = cmd + break + } + } + if taskCmd == nil { + t.Fatalf("expected task command to be registered") + } + + if err := taskCmd.Args(taskCmd, []string{}); err == nil { + t.Fatalf("expected args validation to fail on empty args") + } + if err := taskCmd.Args(taskCmd, []string{"do", "thing"}); err != nil { + t.Fatalf("expected args validation to pass, got %v", err) + } +} diff --git a/pkg/runner/add/add_test.go b/pkg/runner/add/add_test.go new file mode 100644 index 0000000..d00673f --- /dev/null +++ b/pkg/runner/add/add_test.go @@ -0,0 +1,87 @@ +package add + +import ( + "context" + "testing" + "time" + + "tableflip.dev/bujo/pkg/collection" + "tableflip.dev/bujo/pkg/entry" + "tableflip.dev/bujo/pkg/glyph" + "tableflip.dev/bujo/pkg/store" +) + +type addTestPersistence struct { + entries []*entry.Entry +} + +// MapAll implements store.Persistence. +func (p *addTestPersistence) MapAll(context.Context) map[string][]*entry.Entry { return nil } + +// ListAll implements store.Persistence. +func (p *addTestPersistence) ListAll(context.Context) []*entry.Entry { return p.entries } + +// List implements store.Persistence. +func (p *addTestPersistence) List(_ context.Context, collection string) []*entry.Entry { + var list []*entry.Entry + for _, e := range p.entries { + if e.Collection == collection { + list = append(list, e) + } + } + return list +} + +// Collections implements store.Persistence. +func (p *addTestPersistence) Collections(context.Context, string) []string { return nil } + +// CollectionsMeta implements store.Persistence. +func (p *addTestPersistence) CollectionsMeta(context.Context, string) []collection.Meta { return nil } + +// Store implements store.Persistence. +func (p *addTestPersistence) Store(e *entry.Entry) error { + p.entries = append(p.entries, e) + return nil +} + +// Delete implements store.Persistence. +func (p *addTestPersistence) Delete(*entry.Entry) error { return nil } + +// DeleteCollection implements store.Persistence. +func (p *addTestPersistence) DeleteCollection(context.Context, string) error { return nil } + +// EnsureCollection implements store.Persistence. +func (p *addTestPersistence) EnsureCollection(string) error { return nil } + +// EnsureCollectionTyped implements store.Persistence. +func (p *addTestPersistence) EnsureCollectionTyped(string, collection.Type) error { return nil } + +// SetCollectionType implements store.Persistence. +func (p *addTestPersistence) SetCollectionType(string, collection.Type) error { return nil } + +// Watch implements store.Persistence. +func (p *addTestPersistence) Watch(context.Context) (<-chan store.Event, error) { return nil, nil } + +func TestAddDoPersistsEntryAndSignifier(t *testing.T) { + p := &addTestPersistence{} + add := Add{ + Bullet: glyph.Task, + Collection: "today", + Message: "hello", + Priority: true, + Persistence: p, + } + if err := add.Do(context.Background()); err != nil { + t.Fatalf("expected add to succeed: %v", err) + } + if len(p.entries) != 1 { + t.Fatalf("expected 1 entry stored, got %d", len(p.entries)) + } + if p.entries[0].Signifier != glyph.Priority { + t.Fatalf("expected priority signifier to be set") + } + want := time.Now().Format(layoutUS) + if p.entries[0].Collection != want { + t.Fatalf("expected collection to be today (%q), got %q", want, p.entries[0].Collection) + } +} diff --git a/pkg/store/diskv.go b/pkg/store/diskv.go index a13282d..94e841f 100644 --- a/pkg/store/diskv.go +++ b/pkg/store/diskv.go @@ -2,15 +2,6 @@ package store import ( "context" - "crypto/md5" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "strings" "github.com/peterbourgon/diskv/v3" @@ -57,448 +48,3 @@ type persistence struct { d *diskv.Diskv basePath string } - -func (p *persistence) read(key string) (*entry.Entry, error) { - val, err := p.d.Read(key) - if err != nil { - return nil, err - } - e := entry.Entry{} - target := &e - if err := json.Unmarshal(val, target); err != nil { - var list []*entry.Entry - if err2 := json.Unmarshal(val, &list); err2 == nil && len(list) > 0 && list[0] != nil { - target = list[0] - } else { - return nil, err - } - } - if target.Schema == "" { - target.Schema = entry.CurrentSchema - } - pk := keyToPathTransform(key) - target.ID = pk.FileName - target.EnsureHistorySeed() - return target, nil -} - -func (p *persistence) MapAll(ctx context.Context) map[string][]*entry.Entry { - all := make(map[string][]*entry.Entry, 0) - for key := range p.d.Keys(ctx.Done()) { - if key == collectionsIndexFile { - continue - } - pk := keyToPathTransform(key) - ck := fromCollection(pk.Path[0]) - - e, err := p.read(key) - if err != nil { - fmt.Fprintf(os.Stderr, "%s: %s\n", key, err) - continue - } - - if c, ok := all[ck]; !ok { - all[ck] = []*entry.Entry{e} - } else { - all[ck] = append(c, e) - } - } - for key := range all { - sortEntries(all[key]) - } - return all -} - -func (p *persistence) ListAll(ctx context.Context) []*entry.Entry { - all := make([]*entry.Entry, 0) - for key := range p.d.Keys(ctx.Done()) { - if key == collectionsIndexFile { - continue - } - e, err := p.read(key) - if err != nil { - fmt.Fprintf(os.Stderr, "%s: %s\n", key, err) - continue - } - all = append(all, e) - } - sortEntries(all) - return all -} - -func (p *persistence) List(ctx context.Context, collection string) []*entry.Entry { - ck := toCollection(collection) - all := make([]*entry.Entry, 0) - for key := range p.d.Keys(ctx.Done()) { - if key == collectionsIndexFile { - continue - } - if pk := keyToPathTransform(key); pk.Path[0] == ck { - e, err := p.read(key) - if err != nil { - fmt.Fprintf(os.Stderr, "%s: %s\n", key, err) - continue - } - all = append(all, e) - } - } - sortEntries(all) - return all -} - -func (p *persistence) Store(e *entry.Entry) error { - if e.Schema == "" { - e.Schema = entry.CurrentSchema - } - e.EnsureHistorySeed() - key := toKey(e) - if err := p.removeStaleCopies(e, key); err != nil { - return err - } - data, err := json.Marshal(e) - if err != nil { - return err - } - if err := p.d.Write(key, data); err != nil { - return err - } - return nil -} - -func (p *persistence) Delete(e *entry.Entry) error { - if e.Schema == "" { - e.Schema = entry.CurrentSchema - } - key := toKey(e) - return p.d.Erase(key) -} - -func (p *persistence) removeStaleCopies(e *entry.Entry, currentKey string) error { - if e == nil || strings.TrimSpace(e.ID) == "" { - return nil - } - ctx := context.Background() - for key := range p.d.Keys(ctx.Done()) { - if key == collectionsIndexFile || key == currentKey { - continue - } - pk := keyToPathTransform(key) - if pk.FileName == e.ID { - if err := p.d.Erase(key); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - } - } - return nil -} - -func (p *persistence) Collections(ctx context.Context, prefix string) []string { - metas := p.CollectionsMeta(ctx, prefix) - names := make([]string, len(metas)) - for i, meta := range metas { - names[i] = meta.Name - } - return names -} - -func (p *persistence) CollectionsMeta(ctx context.Context, prefix string) []collection.Meta { - all := make(map[string]collection.Meta) - if idx, err := p.loadCollectionsIndex(); err == nil { - for name, meta := range idx { - all[name] = meta - } - } else { - fmt.Fprintf(os.Stderr, "store: load collections index: %v\n", err) - } - - for key := range p.d.Keys(ctx.Done()) { - if key == collectionsIndexFile { - continue - } - pk := keyToPathTransform(key) - if len(pk.Path) == 0 || pk.Path[0] == "" { - continue - } - ck := fromCollection(pk.Path[0]) - - meta, ok := all[ck] - if !ok { - meta = collection.Meta{Name: ck, Type: collection.TypeGeneric} - } - if meta.Name == "" { - meta.Name = ck - } - if meta.Type == "" { - meta.Type = collection.TypeGeneric - } - all[ck] = meta - } - - list := make([]collection.Meta, 0, len(all)) - for name, meta := range all { - if prefix == "" || strings.HasPrefix(name, prefix) { - if meta.Name == "" { - meta.Name = name - } - if meta.Type == "" { - meta.Type = collection.TypeGeneric - } - list = append(list, meta) - } - } - sort.SliceStable(list, func(i, j int) bool { - return list[i].Name < list[j].Name - }) - return list -} - -func (p *persistence) EnsureCollection(name string) error { - return p.EnsureCollectionTyped(name, "") -} - -func (p *persistence) EnsureCollectionTyped(name string, typ collection.Type) error { - name = strings.TrimSpace(name) - if name == "" { - return errors.New("store: collection name required") - } - if p.basePath == "" { - return errors.New("store: base path unknown") - } - if err := os.MkdirAll(p.basePath, 0o755); err != nil { - return fmt.Errorf("store: ensure base path: %w", err) - } - encoded := toCollection(name) - if err := os.MkdirAll(filepath.Join(p.basePath, encoded), 0o755); err != nil { - return fmt.Errorf("store: ensure collection directory: %w", err) - } - index, err := p.loadCollectionsIndex() - if err != nil { - return fmt.Errorf("store: load collections index: %w", err) - } - meta := index[name] - if meta.Name == "" { - meta.Name = name - } - if typ != "" { - meta.Type = typ - } - if meta.Type == "" { - meta.Type = collection.TypeGeneric - } - index[name] = meta - if err := p.saveCollectionsIndex(index); err != nil { - return fmt.Errorf("store: save collections index: %w", err) - } - return nil -} - -func (p *persistence) SetCollectionType(name string, typ collection.Type) error { - name = strings.TrimSpace(name) - if name == "" { - return errors.New("store: collection name required") - } - index, err := p.loadCollectionsIndex() - if err != nil { - return fmt.Errorf("store: load collections index: %w", err) - } - meta := index[name] - meta.Name = name - meta.Type = typ - index[name] = meta - if err := p.saveCollectionsIndex(index); err != nil { - return fmt.Errorf("store: save collections index: %w", err) - } - return nil -} - -func (p *persistence) DeleteCollection(ctx context.Context, name string) error { - trimmed := strings.TrimSpace(name) - if trimmed == "" { - return errors.New("store: collection name required") - } - all := p.MapAll(ctx) - prefix := trimmed + "/" - removedCollections := make(map[string]struct{}) - for col, entries := range all { - if col == trimmed || strings.HasPrefix(col, prefix) { - for _, e := range entries { - if err := p.Delete(e); err != nil { - return err - } - removedCollections[col] = struct{}{} - } - } - } - index, err := p.loadCollectionsIndex() - if err != nil { - return fmt.Errorf("store: load collections index: %w", err) - } - for col := range index { - if col == trimmed || strings.HasPrefix(col, prefix) { - removedCollections[col] = struct{}{} - delete(index, col) - } - } - if len(removedCollections) == 0 { - return fmt.Errorf("store: collection %q not found", trimmed) - } - if err := p.saveCollectionsIndex(index); err != nil { - return fmt.Errorf("store: save collections index: %w", err) - } - for col := range removedCollections { - encoded := toCollection(col) - path := filepath.Join(p.basePath, encoded) - _ = os.RemoveAll(path) - } - return nil -} - -const ( - layoutISO = "2006-01-02" - collectionsIndexFile = ".collections.json" -) - -func (p *persistence) collectionsIndexPath() string { - return filepath.Join(p.basePath, collectionsIndexFile) -} - -func (p *persistence) loadCollectionsIndex() (map[string]collection.Meta, error) { - if p.basePath == "" { - return nil, errors.New("store: base path unknown") - } - if err := os.MkdirAll(p.basePath, 0o755); err != nil { - return nil, err - } - path := p.collectionsIndexPath() - data, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return make(map[string]collection.Meta), nil - } - return nil, err - } - if len(data) == 0 { - return make(map[string]collection.Meta), nil - } - list, err := collection.UnmarshalList(data) - if err != nil { - return nil, err - } - index := make(map[string]collection.Meta, len(list)) - for _, meta := range list { - name := strings.TrimSpace(meta.Name) - if name == "" { - continue - } - if meta.Type == "" { - meta.Type = collection.TypeGeneric - } - meta.Name = name - index[name] = meta - } - return index, nil -} - -func (p *persistence) saveCollectionsIndex(idx map[string]collection.Meta) error { - if p.basePath == "" { - return errors.New("store: base path unknown") - } - if err := os.MkdirAll(p.basePath, 0o755); err != nil { - return err - } - list := make([]collection.Meta, 0, len(idx)) - for name, meta := range idx { - if meta.Name == "" { - meta.Name = name - } - if meta.Type == "" { - meta.Type = collection.TypeGeneric - } - list = append(list, meta) - } - sort.Slice(list, func(i, j int) bool { - return list[i].Name < list[j].Name - }) - data, err := collection.MarshalList(list) - if err != nil { - return err - } - path := p.collectionsIndexPath() - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0o644); err != nil { - return err - } - return os.Rename(tmp, path) -} - -func (p *persistence) CollectionsIndexExists() bool { - if p == nil { - return false - } - _, err := os.Stat(p.collectionsIndexPath()) - return err == nil -} - -func sortEntries(entries []*entry.Entry) { - sort.SliceStable(entries, func(i, j int) bool { - left := entries[i] - right := entries[j] - if left == nil || right == nil { - return left != nil - } - lt := left.Created.Time - rt := right.Created.Time - switch { - case lt.IsZero() && rt.IsZero(): - return left.ID < right.ID - case lt.IsZero(): - return false - case rt.IsZero(): - return true - default: - if lt.Equal(rt) { - return left.ID < right.ID - } - return lt.Before(rt) - } - }) -} - -func keyToPathTransform(s string) *diskv.PathKey { - parts := strings.Split(s, "-") - return &diskv.PathKey{ - Path: parts[:len(parts)-1], - FileName: parts[len(parts)-1], - } -} - -func pathToKeyTransform(pathKey *diskv.PathKey) string { - return fmt.Sprintf("%s-%s", strings.Join(pathKey.Path, "-"), pathKey.FileName) -} - -// toKey makes `collection-date-id` -func toKey(e *entry.Entry) string { - collection := toCollection(e.Collection) - then := e.Created.Format(layoutISO) - - if e.ID == "" { - b, _ := json.Marshal(e) - id := md5.Sum(b) - e.ID = fmt.Sprintf("%x", id[:8]) - } - - return fmt.Sprintf("%s-%s-%s", collection, then, e.ID) -} - -func toCollection(s string) string { - collection := base64.StdEncoding.EncodeToString([]byte(s)) - return collection -} - -func fromCollection(s string) string { - collection, err := base64.StdEncoding.DecodeString(s) - if err != nil { - return fmt.Sprintf("fromCollection: %s", err) - } - return string(collection) -} diff --git a/pkg/store/diskv_collections.go b/pkg/store/diskv_collections.go new file mode 100644 index 0000000..a649fbe --- /dev/null +++ b/pkg/store/diskv_collections.go @@ -0,0 +1,255 @@ +package store + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "tableflip.dev/bujo/pkg/collection" +) + +func (p *persistence) Collections(ctx context.Context, prefix string) []string { + metas := p.CollectionsMeta(ctx, prefix) + names := make([]string, len(metas)) + for i, meta := range metas { + names[i] = meta.Name + } + return names +} + +func (p *persistence) CollectionsMeta(ctx context.Context, prefix string) []collection.Meta { + all := make(map[string]collection.Meta) + if idx, err := p.loadCollectionsIndex(); err == nil { + for name, meta := range idx { + all[name] = meta + } + } else { + fmt.Fprintf(os.Stderr, "store: load collections index: %v\n", err) + } + + for key := range p.d.Keys(ctx.Done()) { + if key == collectionsIndexFile { + continue + } + pk := keyToPathTransform(key) + if len(pk.Path) == 0 || pk.Path[0] == "" { + continue + } + ck := fromCollection(pk.Path[0]) + + meta, ok := all[ck] + if !ok { + meta = collection.Meta{Name: ck, Type: collection.TypeGeneric} + } + if meta.Name == "" { + meta.Name = ck + } + if meta.Type == "" { + meta.Type = collection.TypeGeneric + } + all[ck] = meta + } + + list := make([]collection.Meta, 0, len(all)) + for name, meta := range all { + if prefix == "" || strings.HasPrefix(name, prefix) { + if meta.Name == "" { + meta.Name = name + } + if meta.Type == "" { + meta.Type = collection.TypeGeneric + } + list = append(list, meta) + } + } + sort.SliceStable(list, func(i, j int) bool { + return list[i].Name < list[j].Name + }) + return list +} + +func (p *persistence) EnsureCollection(name string) error { + return p.EnsureCollectionTyped(name, "") +} + +func (p *persistence) EnsureCollectionTyped(name string, typ collection.Type) error { + name = strings.TrimSpace(name) + if name == "" { + return errors.New("store: collection name required") + } + if p.basePath == "" { + return errors.New("store: base path unknown") + } + if err := os.MkdirAll(p.basePath, 0o755); err != nil { + return fmt.Errorf("store: ensure base path: %w", err) + } + encoded := toCollection(name) + if err := os.MkdirAll(filepath.Join(p.basePath, encoded), 0o755); err != nil { + return fmt.Errorf("store: ensure collection directory: %w", err) + } + index, err := p.loadCollectionsIndex() + if err != nil { + return fmt.Errorf("store: load collections index: %w", err) + } + meta := index[name] + if meta.Name == "" { + meta.Name = name + } + if typ != "" { + meta.Type = typ + } + if meta.Type == "" { + meta.Type = collection.TypeGeneric + } + index[name] = meta + if err := p.saveCollectionsIndex(index); err != nil { + return fmt.Errorf("store: save collections index: %w", err) + } + return nil +} + +func (p *persistence) SetCollectionType(name string, typ collection.Type) error { + name = strings.TrimSpace(name) + if name == "" { + return errors.New("store: collection name required") + } + index, err := p.loadCollectionsIndex() + if err != nil { + return fmt.Errorf("store: load collections index: %w", err) + } + meta := index[name] + meta.Name = name + meta.Type = typ + index[name] = meta + if err := p.saveCollectionsIndex(index); err != nil { + return fmt.Errorf("store: save collections index: %w", err) + } + return nil +} + +func (p *persistence) DeleteCollection(ctx context.Context, name string) error { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return errors.New("store: collection name required") + } + all := p.MapAll(ctx) + prefix := trimmed + "/" + removedCollections := make(map[string]struct{}) + for col, entries := range all { + if col == trimmed || strings.HasPrefix(col, prefix) { + for _, e := range entries { + if err := p.Delete(e); err != nil { + return err + } + removedCollections[col] = struct{}{} + } + } + } + index, err := p.loadCollectionsIndex() + if err != nil { + return fmt.Errorf("store: load collections index: %w", err) + } + for col := range index { + if col == trimmed || strings.HasPrefix(col, prefix) { + removedCollections[col] = struct{}{} + delete(index, col) + } + } + if len(removedCollections) == 0 { + return fmt.Errorf("store: collection %q not found", trimmed) + } + if err := p.saveCollectionsIndex(index); err != nil { + return fmt.Errorf("store: save collections index: %w", err) + } + for col := range removedCollections { + encoded := toCollection(col) + path := filepath.Join(p.basePath, encoded) + _ = os.RemoveAll(path) + } + return nil +} + +func (p *persistence) collectionsIndexPath() string { + return filepath.Join(p.basePath, collectionsIndexFile) +} + +func (p *persistence) loadCollectionsIndex() (map[string]collection.Meta, error) { + if p.basePath == "" { + return nil, errors.New("store: base path unknown") + } + if err := os.MkdirAll(p.basePath, 0o755); err != nil { + return nil, err + } + path := p.collectionsIndexPath() + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return make(map[string]collection.Meta), nil + } + return nil, err + } + if len(data) == 0 { + return make(map[string]collection.Meta), nil + } + list, err := collection.UnmarshalList(data) + if err != nil { + return nil, err + } + index := make(map[string]collection.Meta, len(list)) + for _, meta := range list { + name := strings.TrimSpace(meta.Name) + if name == "" { + continue + } + if meta.Type == "" { + meta.Type = collection.TypeGeneric + } + meta.Name = name + index[name] = meta + } + return index, nil +} + +func (p *persistence) saveCollectionsIndex(idx map[string]collection.Meta) error { + if p.basePath == "" { + return errors.New("store: base path unknown") + } + if err := os.MkdirAll(p.basePath, 0o755); err != nil { + return err + } + list := make([]collection.Meta, 0, len(idx)) + for name, meta := range idx { + if meta.Name == "" { + meta.Name = name + } + if meta.Type == "" { + meta.Type = collection.TypeGeneric + } + list = append(list, meta) + } + sort.Slice(list, func(i, j int) bool { + return list[i].Name < list[j].Name + }) + data, err := collection.MarshalList(list) + if err != nil { + return err + } + path := p.collectionsIndexPath() + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func (p *persistence) CollectionsIndexExists() bool { + if p == nil { + return false + } + _, err := os.Stat(p.collectionsIndexPath()) + return err == nil +} diff --git a/pkg/store/diskv_entries.go b/pkg/store/diskv_entries.go new file mode 100644 index 0000000..7b8e761 --- /dev/null +++ b/pkg/store/diskv_entries.go @@ -0,0 +1,172 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "sort" + "strings" + + "tableflip.dev/bujo/pkg/entry" +) + +func (p *persistence) read(key string) (*entry.Entry, error) { + val, err := p.d.Read(key) + if err != nil { + return nil, err + } + e := entry.Entry{} + target := &e + if err := json.Unmarshal(val, target); err != nil { + var list []*entry.Entry + if err2 := json.Unmarshal(val, &list); err2 == nil && len(list) > 0 && list[0] != nil { + target = list[0] + } else { + return nil, err + } + } + if target.Schema == "" { + target.Schema = entry.CurrentSchema + } + pk := keyToPathTransform(key) + target.ID = pk.FileName + target.EnsureHistorySeed() + return target, nil +} + +func (p *persistence) MapAll(ctx context.Context) map[string][]*entry.Entry { + all := make(map[string][]*entry.Entry, 0) + for key := range p.d.Keys(ctx.Done()) { + if key == collectionsIndexFile { + continue + } + pk := keyToPathTransform(key) + ck := fromCollection(pk.Path[0]) + + e, err := p.read(key) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: %s\n", key, err) + continue + } + + if c, ok := all[ck]; !ok { + all[ck] = []*entry.Entry{e} + } else { + all[ck] = append(c, e) + } + } + for key := range all { + sortEntries(all[key]) + } + return all +} + +func (p *persistence) ListAll(ctx context.Context) []*entry.Entry { + all := make([]*entry.Entry, 0) + for key := range p.d.Keys(ctx.Done()) { + if key == collectionsIndexFile { + continue + } + e, err := p.read(key) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: %s\n", key, err) + continue + } + all = append(all, e) + } + sortEntries(all) + return all +} + +func (p *persistence) List(ctx context.Context, collection string) []*entry.Entry { + ck := toCollection(collection) + all := make([]*entry.Entry, 0) + for key := range p.d.Keys(ctx.Done()) { + if key == collectionsIndexFile { + continue + } + if pk := keyToPathTransform(key); pk.Path[0] == ck { + e, err := p.read(key) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: %s\n", key, err) + continue + } + all = append(all, e) + } + } + sortEntries(all) + return all +} + +func (p *persistence) Store(e *entry.Entry) error { + if e.Schema == "" { + e.Schema = entry.CurrentSchema + } + e.EnsureHistorySeed() + key := toKey(e) + if err := p.removeStaleCopies(e, key); err != nil { + return err + } + data, err := json.Marshal(e) + if err != nil { + return err + } + if err := p.d.Write(key, data); err != nil { + return err + } + return nil +} + +func (p *persistence) Delete(e *entry.Entry) error { + if e.Schema == "" { + e.Schema = entry.CurrentSchema + } + key := toKey(e) + return p.d.Erase(key) +} + +func (p *persistence) removeStaleCopies(e *entry.Entry, currentKey string) error { + if e == nil || strings.TrimSpace(e.ID) == "" { + return nil + } + ctx := context.Background() + for key := range p.d.Keys(ctx.Done()) { + if key == collectionsIndexFile || key == currentKey { + continue + } + pk := keyToPathTransform(key) + if pk.FileName == e.ID { + if err := p.d.Erase(key); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + } + return nil +} + +func sortEntries(entries []*entry.Entry) { + sort.SliceStable(entries, func(i, j int) bool { + left := entries[i] + right := entries[j] + if left == nil || right == nil { + return left != nil + } + lt := left.Created.Time + rt := right.Created.Time + switch { + case lt.IsZero() && rt.IsZero(): + return left.ID < right.ID + case lt.IsZero(): + return false + case rt.IsZero(): + return true + default: + if lt.Equal(rt) { + return left.ID < right.ID + } + return lt.Before(rt) + } + }) +} diff --git a/pkg/store/diskv_helpers.go b/pkg/store/diskv_helpers.go new file mode 100644 index 0000000..22fc2cd --- /dev/null +++ b/pkg/store/diskv_helpers.go @@ -0,0 +1,56 @@ +package store + +import ( + "crypto/md5" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "github.com/peterbourgon/diskv/v3" + + "tableflip.dev/bujo/pkg/entry" +) + +const ( + layoutISO = "2006-01-02" + collectionsIndexFile = ".collections.json" +) + +func keyToPathTransform(s string) *diskv.PathKey { + parts := strings.Split(s, "-") + return &diskv.PathKey{ + Path: parts[:len(parts)-1], + FileName: parts[len(parts)-1], + } +} + +func pathToKeyTransform(pathKey *diskv.PathKey) string { + return fmt.Sprintf("%s-%s", strings.Join(pathKey.Path, "-"), pathKey.FileName) +} + +// toKey makes `collection-date-id`. +func toKey(e *entry.Entry) string { + collection := toCollection(e.Collection) + then := e.Created.Format(layoutISO) + + if e.ID == "" { + b, _ := json.Marshal(e) + id := md5.Sum(b) + e.ID = fmt.Sprintf("%x", id[:8]) + } + + return fmt.Sprintf("%s-%s-%s", collection, then, e.ID) +} + +func toCollection(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) +} + +func fromCollection(s string) string { + collection, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return fmt.Sprintf("fromCollection: %s", err) + } + return string(collection) +} diff --git a/pkg/store/diskv_test.go b/pkg/store/diskv_test.go new file mode 100644 index 0000000..08938f0 --- /dev/null +++ b/pkg/store/diskv_test.go @@ -0,0 +1,110 @@ +package store + +import ( + "context" + "strconv" + "testing" + "time" + + "tableflip.dev/bujo/pkg/entry" + "tableflip.dev/bujo/pkg/glyph" +) + +type diskvTestConfig struct{ base string } + +func (c diskvTestConfig) BasePath() string { return c.base } + +func newTestPersistence(t *testing.T) *persistence { + t.Helper() + p, err := Load(diskvTestConfig{base: t.TempDir()}) + if err != nil { + t.Fatalf("load persistence: %v", err) + } + return p.(*persistence) +} + +func TestStoreAndListRoundTrip(t *testing.T) { + p := newTestPersistence(t) + ctx := context.Background() + entry := entry.New("Inbox", glyph.Task, "hello") + if err := p.Store(entry); err != nil { + t.Fatalf("store entry: %v", err) + } + list := p.List(ctx, "Inbox") + if len(list) != 1 { + t.Fatalf("expected 1 entry, got %d", len(list)) + } + if list[0].Message != "hello" { + t.Fatalf("expected message to round trip, got %q", list[0].Message) + } + if list[0].ID == "" { + t.Fatalf("expected stored entry to have ID") + } +} + +func TestListSkipsCorruptEntries(t *testing.T) { + p := newTestPersistence(t) + ctx := context.Background() + good := entry.New("Inbox", glyph.Task, "ok") + good.Created = entry.Timestamp{Time: time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)} + if err := p.Store(good); err != nil { + t.Fatalf("store entry: %v", err) + } + + corrupt := &entry.Entry{Collection: "Inbox", Created: entry.Timestamp{Time: time.Date(2024, time.January, 2, 0, 0, 0, 0, time.UTC)}, ID: "corrupt"} + if err := p.d.Write(toKey(corrupt), []byte("{not-json")); err != nil { + t.Fatalf("write corrupt: %v", err) + } + + list := p.ListAll(ctx) + if len(list) != 1 { + t.Fatalf("expected 1 valid entry, got %d", len(list)) + } + if list[0].Message != "ok" { + t.Fatalf("expected valid entry to remain, got %q", list[0].Message) + } +} + +func TestDeleteCollectionRemovesEntries(t *testing.T) { + p := newTestPersistence(t) + ctx := context.Background() + entryA := entry.New("Inbox", glyph.Task, "hello") + entryB := entry.New("Other", glyph.Task, "world") + if err := p.Store(entryA); err != nil { + t.Fatalf("store entry: %v", err) + } + if err := p.Store(entryB); err != nil { + t.Fatalf("store entry: %v", err) + } + if err := p.DeleteCollection(ctx, "Inbox"); err != nil { + t.Fatalf("delete collection: %v", err) + } + if list := p.List(ctx, "Inbox"); len(list) != 0 { + t.Fatalf("expected inbox to be empty after delete") + } + if list := p.List(ctx, "Other"); len(list) != 1 { + t.Fatalf("expected other collection to remain") + } +} + +func TestConcurrentStore(t *testing.T) { + p := newTestPersistence(t) + ctx := context.Background() + const total = 10 + done := make(chan struct{}, total) + for i := 0; i < total; i++ { + go func(idx int) { + defer func() { done <- struct{}{} }() + e := entry.New("Inbox", glyph.Task, "task") + e.Message = e.Message + "-" + strconv.Itoa(idx) + _ = p.Store(e) + }(i) + } + for i := 0; i < total; i++ { + <-done + } + list := p.List(ctx, "Inbox") + if len(list) != total { + t.Fatalf("expected %d entries after concurrent store, got %d", total, len(list)) + } +} diff --git a/pkg/tui/app/add_overlay.go b/pkg/tui/app/add_overlay.go index e24566d..491762a 100644 --- a/pkg/tui/app/add_overlay.go +++ b/pkg/tui/app/add_overlay.go @@ -4,7 +4,6 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "tableflip.dev/bujo/pkg/tui/components/addtask" - "tableflip.dev/bujo/pkg/tui/components/command" ) type addtaskOverlay struct { @@ -22,7 +21,7 @@ func (o *addtaskOverlay) Init() tea.Cmd { return o.model.Init() } -func (o *addtaskOverlay) Update(msg tea.Msg) (command.Overlay, tea.Cmd) { +func (o *addtaskOverlay) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if o.model == nil { return o, nil } diff --git a/pkg/tui/app/add_task_handlers.go b/pkg/tui/app/add_task_handlers.go new file mode 100644 index 0000000..7decd37 --- /dev/null +++ b/pkg/tui/app/add_task_handlers.go @@ -0,0 +1,104 @@ +package app + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + + "tableflip.dev/bujo/pkg/tui/components/addtask" + "tableflip.dev/bujo/pkg/tui/components/command" + "tableflip.dev/bujo/pkg/tui/events" +) + +func (m *Model) handleAddTaskRequest(msg events.AddTaskRequestMsg) tea.Cmd { + if msg.CollectionID == "" { + if m.command != nil { + m.setStatus("Add task unavailable: missing collection") + } + return nil + } + if m.journalCache == nil { + if m.command != nil { + m.setStatus("Add task unavailable: journal cache offline") + } + return nil + } + opts := addtask.Options{ + InitialCollectionID: msg.CollectionID, + InitialCollectionLabel: strings.TrimSpace(msg.CollectionLabel), + InitialParentBulletID: strings.TrimSpace(msg.ParentBulletID), + } + return m.openAddTaskOverlay(opts, msg) +} + +func (m *Model) openAddTaskOverlay(opts addtask.Options, req events.AddTaskRequestMsg) tea.Cmd { + m.ensureOverlayStack() + var cmds []tea.Cmd + if m.helpVisible { + if cmd := m.closeHelpOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.reportVisible { + if cmd := m.closeReportOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.addVisible { + if cmd := m.closeAddTaskOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + model := addtask.NewModel(m.journalCache, opts) + model.SetID(addTaskOverlayID) + wrapper := newAddtaskOverlay(model) + placement := command.OverlayPlacement{Fullscreen: true} + if cmd := m.overlayStack.Open(overlayKindAdd, wrapper, placement); cmd != nil { + cmds = append(cmds, cmd) + } + m.addOverlay = wrapper + m.addVisible = true + _ = m.dropFocusKind(focusKindCommand) + m.pushFocus(focusTarget{kind: focusKindOverlay, overlay: overlayKindAdd}) + cmds = append(cmds, m.blurJournalPanes()...) + if focusCmd := m.overlayStack.Focus(); focusCmd != nil { + cmds = append(cmds, focusCmd) + } + label := strings.TrimSpace(req.CollectionLabel) + if label == "" { + label = req.CollectionID + } + if m.command != nil { + m.setStatus("Add task overlay opened for " + label) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) closeAddTaskOverlay() tea.Cmd { + if !m.addVisible { + return nil + } + var cmds []tea.Cmd + if m.overlayStack != nil { + if cmd := m.overlayStack.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + m.overlayStack.Close(overlayKindAdd) + } + m.addOverlay = nil + m.addVisible = false + _, _ = m.popFocusKind(focusKindOverlay) + if cmd := m.restoreFocusAfterOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + if m.command != nil { + m.setStatusIfIdle("Add task overlay closed") + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} diff --git a/pkg/tui/app/app.go b/pkg/tui/app/app.go index ae67f0c..76579ce 100644 --- a/pkg/tui/app/app.go +++ b/pkg/tui/app/app.go @@ -4,13 +4,11 @@ import ( "context" "fmt" "io" - "math" "os" "strings" "time" tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" "github.com/davecgh/go-spew/spew" "tableflip.dev/bujo/pkg/app" @@ -19,18 +17,13 @@ import ( "tableflip.dev/bujo/pkg/entry" "tableflip.dev/bujo/pkg/glyph" "tableflip.dev/bujo/pkg/store" - "tableflip.dev/bujo/pkg/timeutil" cachepkg "tableflip.dev/bujo/pkg/tui/cache" - "tableflip.dev/bujo/pkg/tui/components/addtask" - bulletdetail "tableflip.dev/bujo/pkg/tui/components/bulletdetail" + "tableflip.dev/bujo/pkg/tui/clock" collectiondetail "tableflip.dev/bujo/pkg/tui/components/collectiondetail" collectionnav "tableflip.dev/bujo/pkg/tui/components/collectionnav" "tableflip.dev/bujo/pkg/tui/components/command" - dummyview "tableflip.dev/bujo/pkg/tui/components/dummy" "tableflip.dev/bujo/pkg/tui/components/eventviewer" - helpview "tableflip.dev/bujo/pkg/tui/components/help" journalcomponent "tableflip.dev/bujo/pkg/tui/components/journal" - overlaypane "tableflip.dev/bujo/pkg/tui/components/overlaypane" "tableflip.dev/bujo/pkg/tui/events" ) @@ -47,10 +40,6 @@ type statusClearMsg struct { type reportClosedMsg struct{} -type cacheMsg struct { - payload tea.Msg -} - type dayCheckMsg struct{} type watchStartedMsg struct { @@ -84,13 +73,15 @@ type bulletDetailLoadedMsg struct { // component and, when requested, an event viewer docked to the bottom of the // main content area. type Model struct { - service *app.Service + service JournalService + clock clock.Clock width int height int - command *command.Model - overlayPane *overlaypane.Model + command *command.Model + overlayStack *overlayStack + router *pageRouter debugEnabled bool eventViewer *eventviewer.Model @@ -123,6 +114,8 @@ type Model struct { migrateWindow migrationWindow statusText string + statusEpoch int64 + statusSetEpoch int64 statusClearPending bool statusClearActive bool statusClearToken int64 @@ -133,7 +126,6 @@ type Model struct { journalNav *collectionnav.Model journalDetail *collectiondetail.Model journalCache *cachepkg.Cache - journalView *journalcomponent.Model loadingJournal bool journalError error @@ -171,18 +163,6 @@ const ( overlayKindMigrate ) -type moveOverlayConfig struct { - detail *bulletdetail.Model - nav *collectionnav.Model - bulletID string - collectionID string - label string - status string - initialRef events.CollectionRef - futureOnly bool - navOnRight bool -} - const ( addTaskOverlayID = events.ComponentID("addtask-overlay") bulletDetailOverlayID = events.ComponentID("bulletdetail-overlay") @@ -197,8 +177,19 @@ type focusTarget struct { overlay overlayKind } +// Options configure the root TUI model. +type Options struct { + Service JournalService + Clock clock.Clock +} + // New constructs a root model with the provided service. -func New(service *app.Service) *Model { +func New(service JournalService) *Model { + return NewWithOptions(Options{Service: service}) +} + +// NewWithOptions constructs a root model with explicit configuration. +func NewWithOptions(opts Options) *Model { cachePath := os.Getenv("BUJO_CACHE_PATH") if cachePath == "" { cachePath = "(BUJO_CACHE_PATH not set)" @@ -229,15 +220,21 @@ func New(service *app.Service) *Model { {Name: "migrate", Description: "Review and migrate open tasks"}, }) ctx, cancel := context.WithCancel(context.Background()) + clk := opts.Clock + if clk == nil { + clk = clock.RealClock{} + } return &Model{ - service: service, - command: cmd, - overlayPane: overlaypane.New(1, 1), - cachePath: cachePath, - dataSource: dataSource, - ctx: ctx, - cancel: cancel, - today: startOfDay(time.Now()), + service: opts.Service, + clock: clk, + command: cmd, + overlayStack: newOverlayStack(1, 1), + router: newPageRouter(), + cachePath: cachePath, + dataSource: dataSource, + ctx: ctx, + cancel: cancel, + today: startOfDay(clk.Now()), } } @@ -290,43 +287,10 @@ func (m *Model) Init() tea.Cmd { return tea.Batch(cmds...) } -func (m *Model) closeMigrateOverlay() tea.Cmd { - return m.closeMigrateOverlayWithStatus("") -} - -func (m *Model) closeMigrateOverlayWithStatus(status string) tea.Cmd { - if !m.migrateVisible { - if status != "" && m.command != nil { - m.setStatus(status) - } - return nil - } - var cmds []tea.Cmd - if m.overlayPane != nil { - if cmd := m.overlayPane.Blur(); cmd != nil { - cmds = append(cmds, cmd) - } - m.overlayPane.ClearOverlay() - } - m.migrateOverlay = nil - m.migrateVisible = false - m.migrateWindow = migrationWindow{} - _, _ = m.popFocusKind(focusKindOverlay) - if cmd := m.restoreFocusAfterOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - if status != "" && m.command != nil { - m.setStatus(status) - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} - // Update routes Bubble Tea messages to composed components and returns the updated model with any commands to execute. func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.noteEvent(msg) + m.statusEpoch++ var cmds []tea.Cmd skipCommandUpdate := false @@ -339,20 +303,22 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch v := msg.(type) { case tea.FocusMsg: - m.refreshToday(time.Now()) + m.refreshToday(m.now()) case tea.BlurMsg: // no-op (we'll rely on periodic checks) case dayCheckMsg: - m.refreshToday(time.Now()) + m.refreshToday(m.now()) if cmd := m.scheduleDayCheck(); cmd != nil { cmds = append(cmds, cmd) } - case cacheMsg: - if cmd := cacheListenCmd(m.journalCache); cmd != nil { - cmds = append(cmds, cmd) + case events.ChildMsg: + if m.journalCache != nil && v.From == m.journalCache.ComponentID() { + if cmd := cacheListenCmd(m.journalCache); cmd != nil { + cmds = append(cmds, cmd) + } } - if v.payload != nil { - nextModel, innerCmd := m.Update(v.payload) + if v.Msg != nil { + nextModel, innerCmd := m.Update(v.Msg) if innerCmd != nil { cmds = append(cmds, innerCmd) } @@ -414,7 +380,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+c": return m, tea.Quit case "esc": - if m.overlayPane != nil && m.overlayPane.HasOverlay() { + if m.overlayStack != nil && m.overlayStack.HasOverlay() { if m.addVisible { skipJournalKey = true break @@ -427,142 +393,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case events.CommandSubmitMsg: - if m.command != nil && v.Component == m.command.ID() { - raw := strings.TrimSpace(v.Value) - if raw == "" { - m.setStatus("Commands: :quit, :today, :future, :debug, :report [window], :migrate [window], :lock, :unlock, :help") - break - } - parts := strings.Fields(raw) - if len(parts) == 0 { - m.setStatus("Commands: :quit, :today, :future, :debug, :report [window], :migrate [window], :lock, :unlock, :help") - break - } - cmdName := strings.ToLower(parts[0]) - arg := "" - if len(parts) > 1 { - arg = strings.Join(parts[1:], " ") - } - switch cmdName { - case "quit", "exit", "q": - return m, tea.Quit - case "help": - cmd, state := m.toggleHelpOverlay() - if cmd != nil { - cmds = append(cmds, cmd) - } - switch state { - case "opened": - m.setStatus("Help overlay opened (Esc or : to close)") - case "closed": - m.setStatus("Help overlay closed") - case "noop": - m.setStatus("Help unavailable") - } - m.layoutContent() - case "debug": - m.toggleDebug() - case "report": - cmd, state := m.showReportOverlay(arg) - if cmd != nil { - cmds = append(cmds, cmd) - } - switch state { - case "opened": - m.setStatus("Report overlay opened") - case "closed": - m.setStatus("Report overlay closed") - case "error": - // status set inside showReportOverlay - } - m.layoutContent() - case "migrate": - cmd, state := m.showMigrateOverlay(arg) - if cmd != nil { - cmds = append(cmds, cmd) - } - switch state { - case "opened": - m.setStatus("Migration overlay opened") - case "closed": - m.setStatus("Migration overlay closed") - case "error": - // status set within showMigrateOverlay - case "noop": - // no change - } - m.layoutContent() - case "today": - if cmd := m.jumpToToday(true); cmd != nil { - cmds = append(cmds, cmd) - } - case "future": - if cmd := m.jumpToFuture(true); cmd != nil { - cmds = append(cmds, cmd) - } - case "lock": - if cmd := m.lockSelectedBullet(); cmd != nil { - cmds = append(cmds, cmd) - } - case "unlock": - if cmd := m.unlockSelectedBullet(); cmd != nil { - cmds = append(cmds, cmd) - } - default: - m.setStatus("Unhandled command: " + cmdName) - } - _ = m.dropFocusKind(focusKindCommand) + if next, handled := m.handleCommandSubmit(v); handled { + cmds = append(cmds, next...) } case events.CommandCancelMsg: - if m.command != nil && v.Component == m.command.ID() { - m.setStatus("Ready") - if m.commandActive { - m.commandActive = false - if !m.helpVisible { - if cmd := m.focusJournalPane(m.commandReturn); cmd != nil { - cmds = append(cmds, cmd) - } - } - } - _ = m.dropFocusKind(focusKindCommand) + if next, handled := m.handleCommandCancel(v); handled { + cmds = append(cmds, next...) } case events.CommandChangeMsg: - if m.command != nil && v.Component == m.command.ID() { - if v.Mode == events.CommandModeInput { - if !m.commandActive { - if m.journalView != nil { - m.commandReturn = m.journalView.FocusedPane() - } else { - m.commandReturn = journalcomponent.FocusNav - } - m.commandActive = true - cmds = append(cmds, m.blurJournalPanes()...) - if m.overlayPane != nil && m.overlayPane.HasOverlay() { - if cmd := m.overlayPane.Blur(); cmd != nil { - cmds = append(cmds, cmd) - } - } - m.pushFocus(focusTarget{kind: focusKindCommand}) - } - } else { - if m.commandActive { - m.commandActive = false - if m.overlayPane != nil && m.overlayPane.HasOverlay() { - if cmd := m.overlayPane.Focus(); cmd != nil { - cmds = append(cmds, cmd) - } - } else if !m.helpVisible { - if cmd := m.focusJournalPane(m.commandReturn); cmd != nil { - cmds = append(cmds, cmd) - } - } - } - _ = m.dropFocusKind(focusKindCommand) - } - } else { - _ = m.dropFocusKind(focusKindCommand) + if next, handled := m.handleCommandChange(v); handled { + cmds = append(cmds, next...) } - m.layoutContent() case tea.QuitMsg: m.stopWatch() if m.cancel != nil { @@ -670,10 +511,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd := m.closeNewCollectionOverlayWithStatus("New collection creation cancelled"); cmd != nil { cmds = append(cmds, cmd) } - if m.journalView != nil { - if cmd := m.journalView.FocusNav(); cmd != nil { - cmds = append(cmds, cmd) - } + if cmd := m.journalFocusCmd(journalcomponent.FocusNav); cmd != nil { + cmds = append(cmds, cmd) } skipJournalKey = true skipCommandUpdate = true @@ -729,6 +568,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cache := cachepkg.NewWithOptions(cachepkg.Options{ Component: events.ComponentID("journal-cache"), Service: m.service, + Clock: m.clock, }) cache.SetCollections(snap.Metas) cache.SetSections(snap.Sections) @@ -736,7 +576,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { navCollections = appendNewCollectionOption(navCollections) nav := collectionnav.NewModel(navCollections) if !m.today.IsZero() { - nav.SetNow(time.Now()) + nav.SetNow(m.now()) } nav.SetID(events.ComponentID("MainNav")) detail := collectiondetail.NewModel(snap.Sections) @@ -747,7 +587,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } journal := journalcomponent.NewModel(nav, detail, cache) journal.SetID(events.ComponentID("JournalPane")) - if cmd := journal.FocusNav(); cmd != nil { + m.setJournal(journal) + if cmd := m.journalFocusCmd(journalcomponent.FocusNav); cmd != nil { cmds = append(cmds, cmd) } if m.command != nil { @@ -756,7 +597,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.journalCache = cache m.journalNav = nav m.journalDetail = detail - m.journalView = journal m.journalError = nil if cmd := cacheListenCmd(cache); cmd != nil { cmds = append(cmds, cmd) @@ -786,7 +626,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, post) } - if m.addVisible || m.detailVisible || m.moveVisible || m.newCollectionVisible || m.migrateVisible { + if m.overlayBlocksCommand() { skipCommandUpdate = true } @@ -800,38 +640,29 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - if m.overlayPane != nil { - if cmd := m.overlayPane.Update(msg); cmd != nil { + if m.overlayStack != nil { + closed, cmd := m.overlayStack.Update(msg) + if cmd != nil { cmds = append(cmds, cmd) } - if !m.overlayPane.HasOverlay() { - if m.helpVisible { - if cmd := m.closeHelpOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } else if m.reportVisible { - if cmd := m.closeReportOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } else if m.addVisible { - if cmd := m.closeAddTaskOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } + if closed != overlayKindNone { + if cmd := m.closeOverlay(closed); cmd != nil { + cmds = append(cmds, cmd) } } } - if m.journalView != nil { + if m.router != nil { skipUpdate := false if _, isKey := msg.(tea.KeyMsg); isKey { - if skipJournalKey || m.helpVisible || m.addVisible || m.detailVisible || m.moveVisible || m.newCollectionVisible || m.migrateVisible { + if skipJournalKey || m.overlayBlocksJournal() { skipUpdate = true } } if !skipUpdate { - next, cmd := m.journalView.Update(msg) - if jm, ok := next.(*journalcomponent.Model); ok { - m.journalView = jm + next, cmd := m.router.Update(msg) + if r, ok := next.(*pageRouter); ok { + m.router = r } if cmd != nil { cmds = append(cmds, cmd) @@ -853,112 +684,6 @@ func (m *Model) logf(format string, args ...interface{}) { _, _ = fmt.Fprintf(m.dump, "%s %s\n", time.Now().Format("2006-01-02T15:04:05"), fmt.Sprintf(format, args...)) } -// View renders the composed UI. -func (m *Model) View() (string, *tea.Cursor) { - if m.command == nil { - return "initializing…", nil - } - return m.command.View() -} - -func (m *Model) layoutContent() { - if m.command == nil { - return - } - if m.width <= 0 { - m.width = 1 - } - if m.height <= 0 { - m.height = 1 - } - - m.command.SetSize(m.width, m.height) - - totalRows := maxInt(1, m.height-1) - debugRows := 0 - if m.debugEnabled { - if m.eventViewer == nil { - m.eventViewer = eventviewer.NewModel(400) - } - debugRows = m.computeDebugHeight(totalRows) - if debugRows > 0 { - m.eventViewer.SetSize(m.width, debugRows) - } - } else { - m.eventViewer = nil - } - - mainRows := totalRows - if debugRows > 0 && debugRows < totalRows { - mainRows = totalRows - debugRows - } - if mainRows < 1 { - mainRows = 1 - } - mainView, mainCursor := m.mainContent(mainRows) - if m.overlayPane == nil { - m.overlayPane = overlaypane.New(m.width, mainRows) - } - m.overlayPane.SetSize(m.width, mainRows) - m.overlayPane.SetBackground(mainView, mainCursor) - composed, composedCursor := m.overlayPane.View() - body := composed - if debugRows > 0 && m.eventViewer != nil { - debugView := m.eventViewer.View() - if body != "" { - body = body + "\n" + debugView - } else { - body = debugView - } - } - m.command.SetContent(body, composedCursor) -} - -func (m *Model) mainContent(height int) (string, *tea.Cursor) { - if m.journalView != nil { - if height < 1 { - height = 1 - } - m.journalView.SetSize(m.width, height) - view, cursor := m.journalView.View() - viewLines := strings.Split(view, "\n") - if len(viewLines) > 0 && viewLines[len(viewLines)-1] == "" { - viewLines = viewLines[:len(viewLines)-1] - } - if len(viewLines) > height { - viewLines = viewLines[:height] - } - for len(viewLines) < height { - viewLines = append(viewLines, "") - } - body := strings.Join(viewLines, "\n") - return body, cursor - } - - var lines []string - if m.loadingJournal { - lines = append(lines, m.clipLine("Loading journal…")) - } else if m.journalError != nil { - lines = append(lines, m.clipLine("Journal load failed: "+m.journalError.Error())) - } else { - lines = append(lines, m.clipLine("Journal not available")) - } - return strings.Join(lines, "\n"), nil -} - -func (m *Model) clipLine(text string) string { - if m.width <= 0 { - return text - } - if len(text) <= m.width { - return text - } - if m.width <= 3 { - return text[:m.width] - } - return text[:m.width-3] + "..." -} - func (m *Model) toggleDebug() { if m.debugEnabled { m.debugEnabled = false @@ -1009,106 +734,85 @@ func (m *Model) noteEvent(msg tea.Msg) { m.layoutContent() } -func (m *Model) handleAddTaskRequest(msg events.AddTaskRequestMsg) tea.Cmd { - if msg.CollectionID == "" { +func (m *Model) handleBulletComplete(msg events.BulletCompleteMsg) tea.Cmd { + id := strings.TrimSpace(msg.Bullet.ID) + if id == "" { + return nil + } + if m.service == nil { if m.command != nil { - m.setStatus("Add task unavailable: missing collection") + m.setStatus("Complete unavailable: service offline") } return nil } - if m.journalCache == nil { + ctx := context.Background() + entry, err := m.service.Complete(ctx, id) + if err != nil { if m.command != nil { - m.setStatus("Add task unavailable: journal cache offline") + m.setStatus("Complete failed: " + err.Error()) } return nil } - opts := addtask.Options{ - InitialCollectionID: msg.CollectionID, - InitialCollectionLabel: strings.TrimSpace(msg.CollectionLabel), - InitialParentBulletID: strings.TrimSpace(msg.ParentBulletID), + label := strings.TrimSpace(msg.Bullet.Label) + if label == "" && entry != nil { + label = strings.TrimSpace(entry.Message) + } + if label == "" { + label = id + } + status := "Completed " + label + if m.command != nil { + m.setStatus(status) + } + var cmds []tea.Cmd + if cmd := m.removeMigrationBullet(id, status); cmd != nil { + cmds = append(cmds, cmd) + } + if sync := m.collectionSyncCmd(msg.Collection.ID); sync != nil { + cmds = append(cmds, sync) + } + if len(cmds) == 0 { + return nil } - return m.openAddTaskOverlay(opts, msg) + return tea.Batch(cmds...) } -func (m *Model) handleBulletDetailRequest(msg events.BulletDetailRequestMsg) tea.Cmd { - bulletID := strings.TrimSpace(msg.Bullet.ID) - if bulletID == "" { - if m.command != nil { - m.setStatus("Bullet details unavailable: missing bullet ID") - } +func (m *Model) handleBulletStrike(msg events.BulletStrikeMsg) tea.Cmd { + id := strings.TrimSpace(msg.Bullet.ID) + if id == "" { return nil } if m.service == nil { if m.command != nil { - m.setStatus("Bullet details unavailable: service offline") + m.setStatus("Strike unavailable: service offline") } return nil } - collectionID := strings.TrimSpace(msg.Collection.ID) - if collectionID == "" { - collectionID = strings.TrimSpace(msg.Bullet.Note) - } - if collectionID == "" { + ctx := context.Background() + entry, err := m.service.Strike(ctx, id) + if err != nil { if m.command != nil { - m.setStatus("Bullet details unavailable: missing collection context") + m.setStatus("Strike failed: " + err.Error()) } return nil } - if m.overlayPane == nil { - m.overlayPane = overlaypane.New(m.width, maxInt(1, m.height-1)) - } - var cmds []tea.Cmd - if m.helpVisible { - if cmd := m.closeHelpOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.reportVisible { - if cmd := m.closeReportOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.detailVisible { - if cmd := m.closeBulletDetailOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } + label := strings.TrimSpace(msg.Bullet.Label) + if label == "" && entry != nil { + label = strings.TrimSpace(entry.Message) } - if m.addVisible { - if cmd := m.closeAddTaskOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } + if label == "" { + label = id } - - title := msg.Collection.Title - if strings.TrimSpace(title) == "" { - title = collectionID + status := "Marked irrelevant: " + label + if m.command != nil { + m.setStatus(status) } - detailModel := bulletdetail.New(title, msg.Bullet.Label, collectionID, msg.Bullet.Note) - detailModel.SetLoading(true) - wrapper := newBulletdetailOverlay(detailModel) - placement := command.OverlayPlacement{Fullscreen: true} - if cmd := m.overlayPane.SetOverlay(wrapper, placement); cmd != nil { + var cmds []tea.Cmd + if cmd := m.removeMigrationBullet(id, status); cmd != nil { cmds = append(cmds, cmd) } - m.detailOverlay = wrapper - m.detailVisible = true - requestID := fmt.Sprintf("%s@%d", bulletID, time.Now().UnixNano()) - m.detailLoadID = requestID - _ = m.dropFocusKind(focusKindCommand) - m.pushFocus(focusTarget{kind: focusKindOverlay, overlay: overlayKindBulletDetail}) - cmds = append(cmds, m.blurJournalPanes()...) - if focusCmd := m.overlayPane.Focus(); focusCmd != nil { - cmds = append(cmds, focusCmd) - } - if m.command != nil { - statusLabel := strings.TrimSpace(msg.Bullet.Label) - if statusLabel == "" { - statusLabel = bulletID - } - m.setStatus("Loading details for " + statusLabel) - } - if loadCmd := m.loadBulletDetail(collectionID, bulletID, requestID); loadCmd != nil { - cmds = append(cmds, loadCmd) + if sync := m.collectionSyncCmd(msg.Collection.ID); sync != nil { + cmds = append(cmds, sync) } if len(cmds) == 0 { return nil @@ -1116,2157 +820,167 @@ func (m *Model) handleBulletDetailRequest(msg events.BulletDetailRequestMsg) tea return tea.Batch(cmds...) } -func (m *Model) handleMoveBulletRequest(msg events.MoveBulletRequestMsg) tea.Cmd { - bulletID := strings.TrimSpace(msg.Bullet.ID) - if bulletID == "" { - if m.command != nil { - m.setStatus("Move unavailable: missing bullet ID") - } +func (m *Model) handleBulletSignifier(msg events.BulletSignifierMsg) tea.Cmd { + id := strings.TrimSpace(msg.Bullet.ID) + if id == "" { return nil } if m.service == nil { if m.command != nil { - m.setStatus("Move unavailable: service offline") + m.setStatus("Signifier change unavailable: service offline") } return nil } - if m.journalCache == nil { + ctx := context.Background() + var ( + entry *entry.Entry + err error + ) + if msg.Signifier == glyph.None { + entry, err = m.service.ToggleSignifier(ctx, id, glyph.None) + } else { + entry, err = m.service.SetSignifier(ctx, id, msg.Signifier) + } + if err != nil { if m.command != nil { - m.setStatus("Move unavailable: journal cache offline") + m.setStatus("Signifier change failed: " + err.Error()) } return nil } - collectionID := strings.TrimSpace(msg.Collection.ID) - if collectionID == "" { - collectionID = strings.TrimSpace(msg.Bullet.Note) - } - if collectionID == "" { - if m.command != nil { - m.setStatus("Move unavailable: missing collection context") + if m.command != nil { + label := strings.TrimSpace(msg.Bullet.Label) + if label == "" && entry != nil { + label = strings.TrimSpace(entry.Message) + } + if label == "" { + label = id + } + desc := "cleared signifier" + if msg.Signifier != glyph.None { + if info, ok := glyph.DefaultSignifiers()[msg.Signifier]; ok { + desc = "set signifier " + strings.TrimSpace(info.Symbol+" "+info.Meaning) + } else { + desc = "set signifier" + } } + m.setStatus(desc + " for " + label) + } + return m.collectionSyncCmd(msg.Collection.ID) +} + +func (m *Model) lockSelectedBullet() tea.Cmd { + m.setStatus("") + if m.service == nil { + m.setStatus("Lock unavailable: service offline") return nil } - snapshot := m.journalCache.Snapshot() - if len(snapshot.Collections) == 0 || len(snapshot.Sections) == 0 { - if m.command != nil { - m.setStatus("Move unavailable: no collections") - } + journal := m.journal() + if journal == nil { + m.setStatus("Lock unavailable: journal cache offline") return nil } - if source := findBulletInSnapshot(snapshot.Sections, collectionID, bulletID); source != nil && source.Locked { - if m.command != nil { - m.setStatus("Move unavailable: bullet is locked") - } + section, bullet, ok := journal.CurrentSelection() + if !ok { + m.setStatus("Lock unavailable: select a task") return nil } - trimmedCollections := filterMoveCollections(snapshot.Collections) - trimmedCollections = appendNewCollectionOption(trimmedCollections) - if len(trimmedCollections) == 0 { - if m.command != nil { - m.setStatus("Move unavailable: no target collections") - } + collectionID := strings.TrimSpace(section.ID) + bulletID := strings.TrimSpace(bullet.ID) + if collectionID == "" || bulletID == "" { + m.setStatus("Lock unavailable: select a task") return nil } - title := strings.TrimSpace(msg.Collection.Title) - if title == "" { - title = collectionID + ctx := m.ctx + if ctx == nil { + ctx = context.Background() + } + if _, err := m.service.Lock(ctx, bulletID); err != nil { + m.setStatus("Lock failed: " + err.Error()) + return nil } - detailModel := bulletdetail.New(title, msg.Bullet.Label, collectionID, msg.Bullet.Note) - - nav := collectionnav.NewModel(trimmedCollections) - nav.SetBlurOnSelect(false) - label := strings.TrimSpace(msg.Bullet.Label) + label := strings.TrimSpace(bullet.Label) if label == "" { label = bulletID } - cfg := moveOverlayConfig{ - detail: detailModel, - nav: nav, - bulletID: bulletID, - collectionID: collectionID, - label: label, - status: "Choose destination for " + label, - initialRef: events.CollectionRef{ID: collectionID, Name: title}, - futureOnly: false, - navOnRight: true, - } - return m.openMoveOverlay(cfg) + m.setStatus("Locked " + label) + return m.collectionSyncCmd(collectionID) } -func (m *Model) openMoveOverlay(cfg moveOverlayConfig) tea.Cmd { - if cfg.bulletID == "" { +func (m *Model) unlockSelectedBullet() tea.Cmd { + m.setStatus("") + if m.service == nil { + m.setStatus("Unlock unavailable: service offline") return nil } - if m.overlayPane == nil { - m.overlayPane = overlaypane.New(m.width, maxInt(1, m.height-1)) - } - var cmds []tea.Cmd - if m.helpVisible { - if cmd := m.closeHelpOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.reportVisible { - if cmd := m.closeReportOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.newCollectionVisible { - if cmd := m.closeNewCollectionOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.addVisible { - if cmd := m.closeAddTaskOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.detailVisible { - if cmd := m.closeBulletDetailOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.moveVisible { - if cmd := m.closeMoveOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if cfg.detail != nil { - cfg.detail.SetLoading(true) - } - if cfg.nav != nil { - cfg.nav.SetID(moveNavID) - } - mOverlay := newMovebulletOverlay(cfg.detail, cfg.nav, cfg.navOnRight, m.dump) - placement := command.OverlayPlacement{Fullscreen: true} - if cmd := m.overlayPane.SetOverlay(mOverlay, placement); cmd != nil { - cmds = append(cmds, cmd) - } - m.moveOverlay = mOverlay - m.moveVisible = true - m.moveBulletID = cfg.bulletID - m.moveCollectionID = cfg.collectionID - m.moveFutureOnly = cfg.futureOnly - reqID := fmt.Sprintf("%s@%d", cfg.bulletID, time.Now().UnixNano()) - m.moveLoadID = reqID - _ = m.dropFocusKind(focusKindCommand) - m.pushFocus(focusTarget{kind: focusKindOverlay, overlay: overlayKindMove}) - cmds = append(cmds, m.blurJournalPanes()...) - if focusCmd := m.overlayPane.Focus(); focusCmd != nil { - cmds = append(cmds, focusCmd) - } - if cfg.nav != nil { - if cfg.initialRef.ID != "" || cfg.initialRef.Name != "" { - if cmd := cfg.nav.SelectCollection(cfg.initialRef); cmd != nil { - cmds = append(cmds, cmd) - } - } - } - if m.command != nil { - label := cfg.label - if label == "" { - label = cfg.bulletID - } - status := strings.TrimSpace(cfg.status) - if status == "" { - status = "Choose destination for " + label - } - m.setStatus(status) - } - if loadCmd := m.loadBulletDetail(cfg.collectionID, cfg.bulletID, reqID); loadCmd != nil { - cmds = append(cmds, loadCmd) - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} - -func (m *Model) futureMoveCollections(ctx context.Context, now time.Time) ([]*viewmodel.ParsedCollection, error) { - if m.service == nil { - return nil, fmt.Errorf("service offline") - } - metas, err := m.service.CollectionsMeta(ctx, "") - if err != nil { - return nil, err - } - return futureCollectionsFromMetas(metas, now), nil -} - -func futureCollectionsFromMetas(metas []collection.Meta, now time.Time) []*viewmodel.ParsedCollection { - existing := make(map[string]collection.Meta, len(metas)) - for _, meta := range metas { - name := strings.TrimSpace(meta.Name) - if name == "" { - continue - } - if meta.Type == "" { - meta.Type = collection.TypeGeneric - } - existing[name] = meta - } - futureType := collection.TypeMonthly - if meta, ok := existing["Future"]; ok && meta.Type != "" { - futureType = meta.Type - } - treeMetas := []collection.Meta{{Name: "Future", Type: futureType}} - base := startOfMonth(now) - if base.IsZero() { - base = startOfMonth(time.Now()) - } - for i := 1; i <= 12; i++ { - monthTime := base.AddDate(0, i, 0) - monthName := monthTime.Format("January 2006") - full := fmt.Sprintf("Future/%s", monthName) - treeMetas = append(treeMetas, collection.Meta{Name: full, Type: collection.TypeGeneric}) - } - roots := viewmodel.BuildTree(treeMetas) - existingSet := make(map[string]collection.Meta, len(existing)) - for name, meta := range existing { - existingSet[name] = meta - } - var futureNode *viewmodel.ParsedCollection - stack := append([]*viewmodel.ParsedCollection(nil), roots...) - for len(stack) > 0 { - last := stack[len(stack)-1] - stack = stack[:len(stack)-1] - if last == nil { - continue - } - if last.ID == "Future" { - futureNode = last - last.Type = collection.TypeMonthly - last.Exists = true - } else if last.ParentID == "Future" { - last.Type = collection.TypeGeneric - if _, ok := existingSet[last.ID]; ok { - last.Exists = true - } else { - last.Exists = false - } - } else { - last.Exists = false - } - if len(last.Children) > 0 { - stack = append(stack, last.Children...) - } - } - if futureNode != nil { - monthMap := make(map[string]*viewmodel.ParsedCollection, len(futureNode.Children)) - for _, child := range futureNode.Children { - if child == nil { - continue - } - monthMap[child.Name] = child - } - ordered := make([]*viewmodel.ParsedCollection, 0, len(monthMap)) - baseMonth := startOfMonth(now) - if baseMonth.IsZero() { - baseMonth = startOfMonth(time.Now()) - } - for i := 1; i <= 12; i++ { - slot := baseMonth.AddDate(0, i, 0) - name := slot.Format("January 2006") - if child, ok := monthMap[name]; ok { - child.Priority = i - child.SortKey = fmt.Sprintf("%02d-%s", i, strings.ToLower(name)) - ordered = append(ordered, child) - } - } - futureNode.Children = ordered - } - return roots -} - -func filterMoveCollections(collections []*viewmodel.ParsedCollection) []*viewmodel.ParsedCollection { - if len(collections) == 0 { - return nil - } - trimmed := make([]*viewmodel.ParsedCollection, 0, len(collections)) - for _, col := range collections { - if col == nil { - continue - } - if isFutureCollection(col.ID) { - continue - } - clone := cloneParsedCollection(col) - clone.Children = filterMoveCollections(clone.Children) - trimmed = append(trimmed, clone) - } - return trimmed -} - -func cloneParsedCollection(col *viewmodel.ParsedCollection) *viewmodel.ParsedCollection { - if col == nil { - return nil - } - clone := *col - if len(col.Children) > 0 { - clone.Children = make([]*viewmodel.ParsedCollection, len(col.Children)) - for i := range col.Children { - clone.Children[i] = cloneParsedCollection(col.Children[i]) - } - } - return &clone -} - -func isFutureCollection(id string) bool { - trimmed := strings.ToLower(strings.TrimSpace(id)) - if trimmed == "" { - return false - } - if trimmed == "future" { - return true - } - return strings.HasPrefix(trimmed, "future/") -} - -func (m *Model) loadBulletDetail(collectionID, bulletID, requestID string) tea.Cmd { - svc := m.service - return func() tea.Msg { - if svc == nil { - return bulletDetailLoadedMsg{requestID: requestID, err: fmt.Errorf("service unavailable")} - } - ctx := context.Background() - entries, err := svc.Entries(ctx, collectionID) - if err != nil { - return bulletDetailLoadedMsg{requestID: requestID, err: err} - } - for _, e := range entries { - if e == nil { - continue - } - if strings.TrimSpace(e.ID) == bulletID { - e.EnsureHistorySeed() - return bulletDetailLoadedMsg{requestID: requestID, entry: e} - } - } - return bulletDetailLoadedMsg{requestID: requestID, err: fmt.Errorf("entry not found")} - } -} - -func (m *Model) handleBulletDetailLoaded(msg bulletDetailLoadedMsg) tea.Cmd { - if msg.requestID == "" || msg.requestID != m.detailLoadID { - if msg.requestID != "" && msg.requestID == m.moveLoadID { - return m.handleMoveDetailLoaded(msg) - } - return nil - } - if m.detailOverlay == nil { - return nil - } - model := m.detailOverlay.Model() - if model == nil { - return nil - } - if msg.err != nil { - model.SetError(msg.err) - if m.command != nil { - m.setStatus("Bullet detail error: " + msg.err.Error()) - } - return nil - } - if msg.entry == nil { - model.SetError(fmt.Errorf("entry not available")) - return nil - } - model.SetEntry(msg.entry) - if m.command != nil { - label := strings.TrimSpace(msg.entry.Message) - if label == "" { - label = msg.entry.ID - } - m.setStatus("Loaded bullet details for " + label) - } - return nil -} - -func (m *Model) handleMoveDetailLoaded(msg bulletDetailLoadedMsg) tea.Cmd { - if m.moveOverlay == nil { - return nil - } - model := m.moveOverlay.detail - if model == nil { - return nil - } - if msg.err != nil { - model.SetError(msg.err) - if m.command != nil { - m.setStatus("Bullet detail error: " + msg.err.Error()) - } - return nil - } - if msg.entry == nil { - model.SetError(fmt.Errorf("entry not available")) - return nil - } - model.SetEntry(msg.entry) - return nil -} - -func (m *Model) handleMoveSelection(msg events.CollectionSelectMsg) tea.Cmd { - if !m.moveVisible { - return nil - } - if strings.EqualFold(strings.TrimSpace(msg.Collection.ID), newCollectionOptionID) { - return m.startMoveNewCollectionPrompt() - } - target := resolvedCollectionPath(msg.Collection) - collectionLabel := strings.TrimSpace(msg.Collection.Label()) - if strings.EqualFold(strings.TrimSpace(target), newCollectionOptionID) || - strings.EqualFold(strings.TrimSpace(msg.Collection.Name), newCollectionOptionLabel) || - strings.EqualFold(collectionLabel, newCollectionOptionLabel) || - strings.Contains(strings.ToLower(target), newCollectionOptionID) { - return m.startMoveNewCollectionPrompt() - } - if target == "" { - return nil - } - bulletID := strings.TrimSpace(m.moveBulletID) - if bulletID == "" { - return m.closeMoveOverlayWithStatus("Move unavailable: no bullet selected") - } - if target == strings.TrimSpace(m.moveCollectionID) { - return m.closeMoveOverlayWithStatus("Bullet already in selected collection") - } - if m.service == nil { - if m.command != nil { - m.setStatus("Move failed: service offline") - } - return nil - } - ctx := context.Background() - if m.moveFutureOnly && !msg.Exists { - targetType := msg.Collection.Type - if strings.HasPrefix(target, "Future/") { - targetType = collection.TypeGeneric - } - if targetType == "" { - switch { - case strings.HasPrefix(target, "Future/"): - targetType = collection.TypeGeneric - case target == "Future": - targetType = collection.TypeMonthly - default: - targetType = collection.TypeGeneric - } - } - if err := m.service.EnsureCollectionOfType(ctx, target, targetType); err != nil { - if m.command != nil { - m.setStatus("Move failed: " + err.Error()) - } - return nil - } - } - clone, err := m.service.Move(ctx, bulletID, target) - if err != nil { - if m.command != nil { - m.setStatus("Move failed: " + err.Error()) - } - return nil - } - label := collectionLabel - if label == "" { - label = target - } - var cmds []tea.Cmd - if m.journalNav != nil { - ref := events.CollectionRef{ID: target} - if idx := strings.LastIndex(target, "/"); idx >= 0 { - ref.ParentID = target[:idx] - ref.Name = strings.TrimSpace(target[idx+1:]) - } else { - ref.Name = target - } - if cmd := m.journalNav.SelectCollection(ref); cmd != nil { - cmds = append(cmds, cmd) - } - } - if cache := m.journalCache; cache != nil { - if cmd := m.collectionSyncCmd(target); cmd != nil { - cmds = append(cmds, cmd) - } - origin := strings.TrimSpace(m.moveCollectionID) - if origin == "" { - origin = clone.Collection - } - if origin != "" { - if cmd := m.collectionSyncCmd(origin); cmd != nil { - cmds = append(cmds, cmd) - } - } - } - if cmd := m.closeMoveOverlayWithStatus("Moved bullet to " + label); cmd != nil { - cmds = append(cmds, cmd) - } - if !msg.Exists { - if snap := m.snapshotSyncCmd(); snap != nil { - cmds = append(cmds, snap) - } - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} - -func resolvedCollectionPath(ref events.CollectionRef) string { - path := strings.TrimSpace(ref.ID) - if path != "" { - return path - } - name := strings.TrimSpace(ref.Name) - parent := strings.TrimSpace(ref.ParentID) - switch { - case parent != "" && name != "": - return strings.TrimSuffix(parent, "/") + "/" + name - case name != "": - return name - default: - return "" - } -} - -func (m *Model) handleBulletComplete(msg events.BulletCompleteMsg) tea.Cmd { - id := strings.TrimSpace(msg.Bullet.ID) - if id == "" { - return nil - } - if m.service == nil { - if m.command != nil { - m.setStatus("Complete unavailable: service offline") - } - return nil - } - ctx := context.Background() - entry, err := m.service.Complete(ctx, id) - if err != nil { - if m.command != nil { - m.setStatus("Complete failed: " + err.Error()) - } - return nil - } - label := strings.TrimSpace(msg.Bullet.Label) - if label == "" && entry != nil { - label = strings.TrimSpace(entry.Message) - } - if label == "" { - label = id - } - status := "Completed " + label - if m.command != nil { - m.setStatus(status) - } - var cmds []tea.Cmd - if cmd := m.removeMigrationBullet(id, status); cmd != nil { - cmds = append(cmds, cmd) - } - if sync := m.collectionSyncCmd(msg.Collection.ID); sync != nil { - cmds = append(cmds, sync) - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} - -func (m *Model) handleBulletStrike(msg events.BulletStrikeMsg) tea.Cmd { - id := strings.TrimSpace(msg.Bullet.ID) - if id == "" { - return nil - } - if m.service == nil { - if m.command != nil { - m.setStatus("Strike unavailable: service offline") - } - return nil - } - ctx := context.Background() - entry, err := m.service.Strike(ctx, id) - if err != nil { - if m.command != nil { - m.setStatus("Strike failed: " + err.Error()) - } - return nil - } - label := strings.TrimSpace(msg.Bullet.Label) - if label == "" && entry != nil { - label = strings.TrimSpace(entry.Message) - } - if label == "" { - label = id - } - status := "Marked irrelevant: " + label - if m.command != nil { - m.setStatus(status) - } - var cmds []tea.Cmd - if cmd := m.removeMigrationBullet(id, status); cmd != nil { - cmds = append(cmds, cmd) - } - if sync := m.collectionSyncCmd(msg.Collection.ID); sync != nil { - cmds = append(cmds, sync) - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} - -func (m *Model) handleBulletMoveFuture(msg events.BulletMoveFutureMsg) tea.Cmd { - bulletID := strings.TrimSpace(msg.Bullet.ID) - if bulletID == "" { - return nil - } - if m.service == nil { - if m.command != nil { - m.setStatus("Move unavailable: service offline") - } - return nil - } - collectionID := strings.TrimSpace(msg.Collection.ID) - if collectionID == "" { - collectionID = strings.TrimSpace(msg.Bullet.Note) - } - if collectionID == "" { - if m.command != nil { - m.setStatus("Move unavailable: missing collection context") - } - return nil - } - snapshot := m.journalCache.Snapshot() - if len(snapshot.Collections) == 0 || len(snapshot.Sections) == 0 { - if m.command != nil { - m.setStatus("Move unavailable: no collections") - } - return nil - } - if source := findBulletInSnapshot(snapshot.Sections, collectionID, bulletID); source != nil && source.Locked { - if m.command != nil { - m.setStatus("Move unavailable: bullet is locked") - } - return nil - } - ctx := context.Background() - now := m.today - if now.IsZero() { - now = time.Now() - } - roots, err := m.futureMoveCollections(ctx, now) - if err != nil { - if m.command != nil { - m.setStatus("Move unavailable: " + err.Error()) - } - return nil - } - if len(roots) == 0 { - if m.command != nil { - m.setStatus("Move unavailable: Future view not ready") - } - return nil - } - title := strings.TrimSpace(msg.Collection.Title) - if title == "" { - title = collectionID - } - detailModel := bulletdetail.New(title, msg.Bullet.Label, collectionID, msg.Bullet.Note) - nav := collectionnav.NewModel(roots) - nav.SetBlurOnSelect(false) - label := strings.TrimSpace(msg.Bullet.Label) - if label == "" { - label = bulletID - } - initial := events.CollectionRef{ID: "Future", Name: "Future"} - if strings.HasPrefix(collectionID, "Future/") { - initial.ID = collectionID - name := strings.TrimPrefix(collectionID, "Future/") - if name == "" { - name = collectionID - } - initial.Name = name - } else if collectionID == "Future" { - initial.ID = collectionID - initial.Name = "Future" - } - cfg := moveOverlayConfig{ - detail: detailModel, - nav: nav, - bulletID: bulletID, - collectionID: collectionID, - label: label, - status: "Choose Future destination for " + label, - initialRef: initial, - futureOnly: true, - navOnRight: false, - } - return m.openMoveOverlay(cfg) -} - -func (m *Model) handleBulletSignifier(msg events.BulletSignifierMsg) tea.Cmd { - id := strings.TrimSpace(msg.Bullet.ID) - if id == "" { - return nil - } - if m.service == nil { - if m.command != nil { - m.setStatus("Signifier change unavailable: service offline") - } - return nil - } - ctx := context.Background() - var ( - entry *entry.Entry - err error - ) - if msg.Signifier == glyph.None { - entry, err = m.service.ToggleSignifier(ctx, id, glyph.None) - } else { - entry, err = m.service.SetSignifier(ctx, id, msg.Signifier) - } - if err != nil { - if m.command != nil { - m.setStatus("Signifier change failed: " + err.Error()) - } - return nil - } - if m.command != nil { - label := strings.TrimSpace(msg.Bullet.Label) - if label == "" && entry != nil { - label = strings.TrimSpace(entry.Message) - } - if label == "" { - label = id - } - desc := "cleared signifier" - if msg.Signifier != glyph.None { - if info, ok := glyph.DefaultSignifiers()[msg.Signifier]; ok { - desc = "set signifier " + strings.TrimSpace(info.Symbol+" "+info.Meaning) - } else { - desc = "set signifier" - } - } - m.setStatus(desc + " for " + label) - } - return m.collectionSyncCmd(msg.Collection.ID) -} - -func (m *Model) lockSelectedBullet() tea.Cmd { - m.setStatus("") - if m.service == nil { - m.setStatus("Lock unavailable: service offline") - return nil - } - if m.journalView == nil { - m.setStatus("Lock unavailable: journal cache offline") - return nil - } - section, bullet, ok := m.journalView.CurrentSelection() - if !ok { - m.setStatus("Lock unavailable: select a task") - return nil - } - collectionID := strings.TrimSpace(section.ID) - bulletID := strings.TrimSpace(bullet.ID) - if collectionID == "" || bulletID == "" { - m.setStatus("Lock unavailable: select a task") - return nil - } - ctx := m.ctx - if ctx == nil { - ctx = context.Background() - } - if _, err := m.service.Lock(ctx, bulletID); err != nil { - m.setStatus("Lock failed: " + err.Error()) - return nil - } - label := strings.TrimSpace(bullet.Label) - if label == "" { - label = bulletID - } - m.setStatus("Locked " + label) - return m.collectionSyncCmd(collectionID) -} - -func (m *Model) unlockSelectedBullet() tea.Cmd { - m.setStatus("") - if m.service == nil { - m.setStatus("Unlock unavailable: service offline") - return nil - } - if m.journalView == nil { - m.setStatus("Unlock unavailable: journal cache offline") - return nil - } - section, bullet, ok := m.journalView.CurrentSelection() - if !ok { - m.setStatus("Unlock unavailable: select a task") - return nil - } - collectionID := strings.TrimSpace(section.ID) - bulletID := strings.TrimSpace(bullet.ID) - if collectionID == "" || bulletID == "" { - m.setStatus("Unlock unavailable: select a task") - return nil - } - ctx := m.ctx - if ctx == nil { - ctx = context.Background() - } - if _, err := m.service.Unlock(ctx, bulletID); err != nil { - m.setStatus("Unlock failed: " + err.Error()) - return nil - } - label := strings.TrimSpace(bullet.Label) - if label == "" { - label = bulletID - } - m.setStatus("Unlocked " + label) - return m.collectionSyncCmd(collectionID) -} - -func (m *Model) jumpToToday(showStatus bool) tea.Cmd { - if m.journalNav == nil { - if showStatus { - m.setStatus("Today unavailable: journal not ready") - } - return nil - } - now := time.Now() - if !m.today.IsZero() { - now = m.today - } - ref, _ := todayCollectionRefFromCache(m.journalCache, now) - if ref.ID == "" { - if showStatus { - m.setStatus("Today collection unavailable") - } - return nil - } - var cmds []tea.Cmd - if cmd := m.collectionSyncCmd(ref.ID); cmd != nil { - cmds = append(cmds, cmd) - } - if cmd := m.journalNav.SelectCollection(ref); cmd != nil { - cmds = append(cmds, cmd) - } - if m.journalView != nil { - if cmd := m.journalView.FocusDetail(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.journalDetail != nil { - m.journalDetail.FocusCollection(ref.ID) - } - if showStatus { - label := strings.TrimSpace(ref.Name) - if label == "" { - label = "Today" - } - m.setStatus("Selected Today (" + label + ")") - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} - -func (m *Model) handleMigrationMoveRequest(msg events.MoveBulletRequestMsg) tea.Cmd { - if !m.migrateVisible || m.migrateOverlay == nil { - return nil - } - bulletID := strings.TrimSpace(msg.Bullet.ID) - if bulletID == "" { - return nil - } - targetRef, exists, ok := m.migrateOverlay.TargetSelection() - if !ok { - if m.command != nil { - m.setStatus("Move unavailable: select a destination") - } - return nil - } - targetPath := strings.TrimSpace(resolvedCollectionPath(targetRef)) - if targetPath == "" { - targetPath = strings.TrimSpace(targetRef.Name) - } - if targetPath == "" { - if m.command != nil { - m.setStatus("Move unavailable: select a destination") - } - return nil - } - origin := strings.TrimSpace(msg.Collection.ID) - if origin == "" { - origin = strings.TrimSpace(msg.Bullet.Note) - } - label := strings.TrimSpace(msg.Bullet.Label) - if label == "" { - label = bulletID - } - if targetPath == origin { - status := "Kept " + label - return m.removeMigrationBullet(bulletID, status) - } - if m.service == nil { - if m.command != nil { - m.setStatus("Move unavailable: service offline") - } - return nil - } - ctx := context.Background() - if !exists { - targetType := targetRef.Type - if targetType == "" { - targetType = collection.TypeGeneric - } - if err := m.service.EnsureCollectionOfType(ctx, targetPath, targetType); err != nil { - if m.command != nil { - m.setStatus("Move failed: " + err.Error()) - } - return nil - } - } - clone, err := m.service.Move(ctx, bulletID, targetPath) - if err != nil { - if m.command != nil { - m.setStatus("Move failed: " + err.Error()) - } - return nil - } - if strings.TrimSpace(label) == "" && clone != nil { - label = strings.TrimSpace(clone.Message) - if label == "" { - label = bulletID - } - } - destination := targetRef.Label() - if strings.TrimSpace(destination) == "" { - destination = targetPath - } - status := "Moved " + label + " to " + destination - var cmds []tea.Cmd - if cmd := m.removeMigrationBullet(bulletID, status); cmd != nil { - cmds = append(cmds, cmd) - } - if sync := m.collectionSyncCmd(targetPath); sync != nil { - cmds = append(cmds, sync) - } - if origin != "" && origin != targetPath { - if sync := m.collectionSyncCmd(origin); sync != nil { - cmds = append(cmds, sync) - } - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} - -func (m *Model) handleMigrationMoveFuture(msg events.BulletMoveFutureMsg) tea.Cmd { - if !m.migrateVisible || m.migrateOverlay == nil { - return nil - } - bulletID := strings.TrimSpace(msg.Bullet.ID) - if bulletID == "" { - return nil - } - targetRef, exists, ok := m.migrateOverlay.FutureSelection() - if !ok { - targetRef = events.CollectionRef{ID: "Future", Name: "Future", Type: collection.TypeMonthly} - exists = true - } - targetPath := strings.TrimSpace(resolvedCollectionPath(targetRef)) - if targetPath == "" { - targetPath = "Future" - } - origin := strings.TrimSpace(msg.Collection.ID) - if origin == "" { - origin = strings.TrimSpace(msg.Bullet.Note) - } - label := strings.TrimSpace(msg.Bullet.Label) - if label == "" { - label = bulletID - } - if targetPath == origin { - status := "Kept " + label - return m.removeMigrationBullet(bulletID, status) - } - if m.service == nil { - if m.command != nil { - m.setStatus("Move unavailable: service offline") - } - return nil - } - ctx := context.Background() - if !exists { - targetType := targetRef.Type - if strings.HasPrefix(targetPath, "Future/") { - if targetType == "" { - targetType = collection.TypeGeneric - } - } else if targetType == "" { - if targetPath == "Future" { - targetType = collection.TypeMonthly - } else { - targetType = collection.TypeGeneric - } - } - if err := m.service.EnsureCollectionOfType(ctx, targetPath, targetType); err != nil { - if m.command != nil { - m.setStatus("Move failed: " + err.Error()) - } - return nil - } - } - clone, err := m.service.Move(ctx, bulletID, targetPath) - if err != nil { - if m.command != nil { - m.setStatus("Move failed: " + err.Error()) - } - return nil - } - if strings.TrimSpace(label) == "" && clone != nil { - label = strings.TrimSpace(clone.Message) - if label == "" { - label = bulletID - } - } - dest := targetRef.Label() - if strings.TrimSpace(dest) == "" { - dest = targetPath - } - status := "Moved " + label + " to " + dest - var cmds []tea.Cmd - if cmd := m.removeMigrationBullet(bulletID, status); cmd != nil { - cmds = append(cmds, cmd) - } - if sync := m.collectionSyncCmd(targetPath); sync != nil { - cmds = append(cmds, sync) - } - if origin != "" && origin != targetPath { - if sync := m.collectionSyncCmd(origin); sync != nil { - cmds = append(cmds, sync) - } - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} - -func (m *Model) handleMigrationKeep(msg events.BulletSelectMsg) tea.Cmd { - if !m.migrateVisible || m.migrateOverlay == nil { - return nil - } - if !msg.Exists { - return nil - } - bulletID := strings.TrimSpace(msg.Bullet.ID) - if bulletID == "" { - return nil - } - label := strings.TrimSpace(msg.Bullet.Label) - if label == "" { - label = bulletID - } - status := "Kept " + label - return m.removeMigrationBullet(bulletID, status) -} - -func (m *Model) removeMigrationBullet(id, status string) tea.Cmd { - id = strings.TrimSpace(id) - if id == "" { - if status != "" && m.command != nil { - m.setStatus(status) - } - return nil - } - if !m.migrateVisible || m.migrateOverlay == nil { - if status != "" && m.command != nil { - m.setStatus(status) - } - return nil - } - m.migrateOverlay.RemoveBullet(id) - if m.migrateOverlay.IsEmpty() { - final := status - if strings.TrimSpace(final) == "" { - final = "Migration complete" - } - return m.closeMigrateOverlayWithStatus(final) - } - if status != "" && m.command != nil { - m.setStatus(status) - } - if cmd := m.migrateOverlay.FocusDetail(); cmd != nil { - return cmd - } - return nil -} - -func (m *Model) handleMigrationCollectionSelect(msg events.CollectionSelectMsg) tea.Cmd { - if m.migrateOverlay == nil { - return nil - } - if msg.Component == migrateTargetNavID && strings.EqualFold(strings.TrimSpace(msg.Collection.ID), migrationNewCollectionID) { - return m.startMigrationNewCollectionPrompt() - } - section, bulletRow, item, ok := m.migrateOverlay.CurrentMigrationSelection() - if !ok || item == nil || item.Candidate.Entry == nil { - return m.migrateOverlay.FocusDetail() - } - label := strings.TrimSpace(bulletRow.Label) - if label == "" { - label = strings.TrimSpace(item.Candidate.Entry.Message) - } - if label == "" { - label = bulletRow.ID - } - sectionRef := events.CollectionViewRef{ - ID: section.ID, - Title: section.Title, - Subtitle: section.Subtitle, - } - bulletRef := events.BulletRef{ - ID: bulletRow.ID, - Label: label, - Note: item.SectionID, - Bullet: bulletRow.Bullet, - Signifier: bulletRow.Signifier, - } - switch msg.Component { - case migrateFutureNavID: - return m.handleMigrationMoveFuture(events.BulletMoveFutureMsg{ - Component: migrateDetailID, - Collection: sectionRef, - Bullet: bulletRef, - }) - case migrateTargetNavID: - return m.handleMigrationMoveRequest(events.MoveBulletRequestMsg{ - Component: migrateDetailID, - Collection: sectionRef, - Bullet: bulletRef, - }) - default: - return m.migrateOverlay.FocusDetail() - } -} - -func (m *Model) startMigrationNewCollectionPrompt() tea.Cmd { - if m.migrateOverlay == nil { - return nil - } - if m.command != nil { - m.setStatus("Enter a name for the new collection") - } - return m.migrateOverlay.BeginNewCollectionPrompt() -} - -func (m *Model) startMoveNewCollectionPrompt() tea.Cmd { - if m.moveOverlay == nil { - return nil - } - if m.command != nil { - m.setStatus("Enter a name for the new collection") - } - return m.moveOverlay.BeginNewCollectionPrompt() -} - -func (m *Model) startNewCollectionPrompt() tea.Cmd { - if m.overlayPane == nil { - m.overlayPane = overlaypane.New(m.width, maxInt(1, m.height-1)) - } - if m.newCollectionVisible { - return m.closeNewCollectionOverlay() - } - overlay := newNewCollectionOverlay(m.dump) - overlay.SetSize(m.width, maxInt(1, m.height-1)) - - var cmds []tea.Cmd - if m.helpVisible { - if cmd := m.closeHelpOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.reportVisible { - if cmd := m.closeReportOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.addVisible { - if cmd := m.closeAddTaskOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.detailVisible { - if cmd := m.closeBulletDetailOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.moveVisible { - if cmd := m.closeMoveOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.migrateVisible { - if cmd := m.closeMigrateOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - placement := command.OverlayPlacement{Fullscreen: true} - if cmd := m.overlayPane.SetOverlay(overlay, placement); cmd != nil { - cmds = append(cmds, cmd) - } - m.newCollectionOverlay = overlay - m.newCollectionVisible = true - _ = m.dropFocusKind(focusKindCommand) - m.pushFocus(focusTarget{kind: focusKindOverlay, overlay: overlayKindNewCollection}) - cmds = append(cmds, m.blurJournalPanes()...) - if focus := m.overlayPane.Focus(); focus != nil { - cmds = append(cmds, focus) - } - if m.command != nil { - m.setStatus("Enter a name for the new collection") - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} - -func (m *Model) handleNewCollectionCreate(name string) tea.Cmd { - name = strings.TrimSpace(name) - if name == "" { - if m.command != nil { - m.setStatus("Collection name cannot be empty") - } - return nil - } - if m.service == nil { - if m.command != nil { - m.setStatus("Create failed: service offline") - } - return m.closeNewCollectionOverlayWithStatus("Create failed: service offline") - } - ctx := context.Background() - if err := m.service.EnsureCollectionOfType(ctx, name, collection.TypeGeneric); err != nil { - if m.command != nil { - m.setStatus("Create failed: " + err.Error()) - } - return nil - } - - var cmds []tea.Cmd - if m.journalNav != nil { - ref := events.CollectionRef{ID: name, Name: name, Type: collection.TypeGeneric} - if cmd := m.journalNav.SelectCollection(ref); cmd != nil { - cmds = append(cmds, cmd) - } - } - if cache := m.journalCache; cache != nil { - if cmd := m.collectionSyncCmd(name); cmd != nil { - cmds = append(cmds, cmd) - } - } - if snap := m.snapshotSyncCmd(); snap != nil { - cmds = append(cmds, snap) - } - if cmd := m.closeNewCollectionOverlayWithStatus("Created " + name); cmd != nil { - cmds = append(cmds, cmd) - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} - -func (m *Model) handleMoveCreateCollection(name string) tea.Cmd { - name = strings.TrimSpace(name) - if name == "" { - if m.command != nil { - m.setStatus("Collection name cannot be empty") - } - if m.moveOverlay != nil { - return m.moveOverlay.BeginNewCollectionPrompt() - } - return nil - } - if m.service == nil { - if m.command != nil { - m.setStatus("Create failed: service offline") - } - if m.moveOverlay != nil { - return m.moveOverlay.FocusNav() - } - return nil - } - bulletID := strings.TrimSpace(m.moveBulletID) - if bulletID == "" { - return m.closeMoveOverlayWithStatus("Move unavailable: no bullet selected") - } - ctx := context.Background() - if err := m.service.EnsureCollectionOfType(ctx, name, collection.TypeGeneric); err != nil { - if m.command != nil { - m.setStatus("Create failed: " + err.Error()) - } - if m.moveOverlay != nil { - return m.moveOverlay.BeginNewCollectionPrompt() - } - return nil - } - clone, err := m.service.Move(ctx, bulletID, name) - if err != nil { - if m.command != nil { - m.setStatus("Move failed: " + err.Error()) - } - if m.moveOverlay != nil { - return m.moveOverlay.BeginNewCollectionPrompt() - } - return nil - } - - target := strings.TrimSpace(name) - if target == "" { - target = name - } - var cmds []tea.Cmd - if m.journalNav != nil { - ref := events.CollectionRef{ID: target} - if idx := strings.LastIndex(target, "/"); idx >= 0 { - ref.ParentID = strings.TrimSpace(target[:idx]) - ref.Name = strings.TrimSpace(target[idx+1:]) - } else { - ref.Name = target - } - if cmd := m.journalNav.SelectCollection(ref); cmd != nil { - cmds = append(cmds, cmd) - } - } - if cache := m.journalCache; cache != nil { - if cmd := m.collectionSyncCmd(target); cmd != nil { - cmds = append(cmds, cmd) - } - origin := strings.TrimSpace(m.moveCollectionID) - if origin == "" && clone != nil { - origin = strings.TrimSpace(clone.Collection) - } - if origin != "" && !strings.EqualFold(origin, target) { - if cmd := m.collectionSyncCmd(origin); cmd != nil { - cmds = append(cmds, cmd) - } - } - } - status := "Created " + target + " · moved bullet" - if cmd := m.closeMoveOverlayWithStatus(status); cmd != nil { - cmds = append(cmds, cmd) - } - if snap := m.snapshotSyncCmd(); snap != nil { - cmds = append(cmds, snap) - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} - -func (m *Model) handleMigrationCreateCollection(name string) tea.Cmd { - name = strings.TrimSpace(name) - if name == "" { - if m.command != nil { - m.setStatus("Collection name cannot be empty") - } - if m.migrateOverlay != nil { - return m.migrateOverlay.FocusDetail() - } - return nil - } - if m.service == nil { - if m.command != nil { - m.setStatus("Create failed: service offline") - } - if m.migrateOverlay != nil { - return m.migrateOverlay.FocusDetail() - } - return nil - } - ctx := context.Background() - if err := m.service.EnsureCollectionOfType(ctx, name, collection.TypeGeneric); err != nil { - if m.command != nil { - m.setStatus("Create failed: " + err.Error()) - } - if m.migrateOverlay != nil { - return m.migrateOverlay.FocusDetail() - } - return nil - } - cmds := []tea.Cmd{} - if cmd := m.collectionSyncCmd(name); cmd != nil { - cmds = append(cmds, cmd) - } - _, bulletRow, item, ok := m.migrateOverlay.CurrentMigrationSelection() - if !ok || item == nil || item.Candidate.Entry == nil { - if focus := m.migrateOverlay.FocusDetail(); focus != nil { - cmds = append(cmds, focus) - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) - } - bulletID := strings.TrimSpace(item.Candidate.Entry.ID) - if bulletID == "" { - if focus := m.migrateOverlay.FocusDetail(); focus != nil { - cmds = append(cmds, focus) - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) - } - clone, err := m.service.Move(ctx, bulletID, name) - if err != nil { - if m.command != nil { - m.setStatus("Move failed: " + err.Error()) - } - if focus := m.migrateOverlay.FocusDetail(); focus != nil { - cmds = append(cmds, focus) - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) - } - label := strings.TrimSpace(bulletRow.Label) - if label == "" && clone != nil { - label = strings.TrimSpace(clone.Message) - } - if label == "" { - label = bulletID - } - status := "Moved " + label + " to " + name - if cmd := m.removeMigrationBullet(bulletID, status); cmd != nil { - cmds = append(cmds, cmd) - } - origin := strings.TrimSpace(item.Candidate.Entry.Collection) - if origin != "" && !strings.EqualFold(origin, name) { - if cmd := m.collectionSyncCmd(origin); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.command != nil { - m.setStatus("Created " + name + " · moved " + label) - } - if focus := m.migrateOverlay.FocusDetail(); focus != nil { - cmds = append(cmds, focus) - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} -func (m *Model) jumpToFuture(showStatus bool) tea.Cmd { - if m.journalNav == nil { - if showStatus { - m.setStatus("Future collection unavailable") - } - return nil - } - ref := events.CollectionRef{ID: "Future", Name: "Future", Type: collection.TypeMonthly} - var cmds []tea.Cmd - if cmd := m.collectionSyncCmd(ref.ID); cmd != nil { - cmds = append(cmds, cmd) - } - if cmd := m.journalNav.SelectCollection(ref); cmd != nil { - cmds = append(cmds, cmd) - } - if m.journalView != nil { - if cmd := m.journalView.FocusDetail(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.journalDetail != nil { - m.journalDetail.FocusCollection(ref.ID) - } - if showStatus { - m.setStatus("Selected Future") - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} - -func (m *Model) setStatus(status string) { - if m.command == nil { - return - } - m.command.SetStatus(status) - m.statusText = status - m.statusClearActive = false - trim := strings.TrimSpace(status) - if trim == "" || strings.EqualFold(trim, "ready") { - m.statusClearPending = false - return - } - m.statusClearPending = true - m.statusClearToken++ -} - -func (m *Model) scheduleStatusClear() tea.Cmd { - if !m.statusClearPending || m.statusClearActive || strings.TrimSpace(m.statusText) == "" { - return nil - } - m.statusClearPending = false - m.statusClearActive = true - token := m.statusClearToken - return tea.Tick(statusClearTimeout, func(time.Time) tea.Msg { - return statusClearMsg{token: token} - }) -} - -func (m *Model) clearStatus() { - m.statusClearActive = false - m.statusClearPending = false - m.statusText = "Ready" - if m.command != nil { - m.command.SetStatus("Ready") - } -} - -func (m *Model) postInteractionStatus(msg tea.Msg) tea.Cmd { - switch msg.(type) { - case tea.KeyMsg, tea.MouseMsg: - return m.scheduleStatusClear() - } - return nil -} - -func (m *Model) showReportOverlay(arg string) (tea.Cmd, string) { - if m.command == nil { - return nil, "noop" - } - if m.overlayPane == nil { - m.overlayPane = overlaypane.New(m.width, maxInt(1, m.height-1)) - } - if m.service == nil { - m.setStatus("Report unavailable: service offline") - return nil, "error" - } - if m.reportVisible { - return m.closeReportOverlay(), "closed" - } - dur, label, err := m.parseReportWindow(arg) - if err != nil { - m.setStatus("Report: " + err.Error()) - return nil, "error" - } - var cmds []tea.Cmd - if m.helpVisible { - if cmd := m.closeHelpOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.addVisible { - if cmd := m.closeAddTaskOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.newCollectionVisible { - if cmd := m.closeNewCollectionOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - overlay := newReportOverlay(m.service, dur, label) - placement := m.reportPlacement() - width := placement.Width - if width <= 0 { - width = m.width - } - height := placement.Height - if height <= 0 { - height = maxInt(10, m.height-1) - } - m.report = overlay - overlay.SetSize(width, height) - cmd := m.overlayPane.SetOverlay(overlay, placement) - m.reportVisible = true - _ = m.dropFocusKind(focusKindCommand) - m.pushFocus(focusTarget{kind: focusKindOverlay, overlay: overlayKindReport}) - cmds = append(cmds, m.blurJournalPanes()...) - if cmd != nil { - cmds = append(cmds, cmd) - } - if focusCmd := m.overlayPane.Focus(); focusCmd != nil { - cmds = append(cmds, focusCmd) - } - if len(cmds) == 0 { - return nil, "opened" - } - return tea.Batch(cmds...), "opened" -} - -func (m *Model) showMigrateOverlay(arg string) (tea.Cmd, string) { - if m.command == nil { - return nil, "noop" - } - if m.overlayPane == nil { - m.overlayPane = overlaypane.New(m.width, maxInt(1, m.height-1)) - } - if m.service == nil { - m.setStatus("Migration unavailable: service offline") - return nil, "error" - } - if m.migrateVisible { - return m.closeMigrateOverlay(), "closed" - } - now := time.Now() - if !m.today.IsZero() { - now = now.In(m.today.Location()) - if now.Before(m.today) { - now = m.today - } - } - window, err := resolveMigrationWindow(now, arg) - if err != nil { - m.setStatus("Migration: " + err.Error()) - return nil, "error" - } - ctx := context.Background() - metas, err := m.service.CollectionsMeta(ctx, "") - if err != nil { - m.setStatus("Migration unavailable: " + err.Error()) - return nil, "error" - } - parsedRoots := viewmodel.BuildTree(metas) - futureRoots := futureCollectionsFromMetas(metas, now) - targetRoots := filterMoveCollections(parsedRoots) - targetRoots = appendNewCollectionOption(targetRoots) - targetRoots = includeNextMonthCollection(targetRoots, now) - candidates, err := m.service.MigrationCandidates(ctx, window.Since, window.Until) - if err != nil { - m.setStatus("Migration unavailable: " + err.Error()) - return nil, "error" - } - data := buildMigrationData(now, candidates, parsedRoots) - futureNav := collectionnav.NewModel(futureRoots) - futureNav.SetBlurOnSelect(false) - targetNav := collectionnav.NewModel(targetRoots) - targetNav.SetBlurOnSelect(false) - overlay := newMigrationOverlay(data, window, futureNav, targetNav, m.dump) - overlay.SetSize(m.width, maxInt(1, m.height-1)) - - if data.IsEmpty() && m.command != nil { - m.setStatus("Migration inbox empty") - } - - var cmds []tea.Cmd - if m.helpVisible { - if cmd := m.closeHelpOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.reportVisible { - if cmd := m.closeReportOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.addVisible { - if cmd := m.closeAddTaskOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.detailVisible { - if cmd := m.closeBulletDetailOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.moveVisible { - if cmd := m.closeMoveOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.newCollectionVisible { - if cmd := m.closeNewCollectionOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.migrateVisible { - if cmd := m.closeMigrateOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - placement := command.OverlayPlacement{Fullscreen: true} - if cmd := m.overlayPane.SetOverlay(overlay, placement); cmd != nil { - cmds = append(cmds, cmd) - } - m.migrateOverlay = overlay - m.migrateVisible = true - m.migrateWindow = window - _ = m.dropFocusKind(focusKindCommand) - m.pushFocus(focusTarget{kind: focusKindOverlay, overlay: overlayKindMigrate}) - cmds = append(cmds, m.blurJournalPanes()...) - if focusCmd := m.overlayPane.Focus(); focusCmd != nil { - cmds = append(cmds, focusCmd) - } - if len(cmds) == 0 { - return nil, "opened" - } - return tea.Batch(cmds...), "opened" -} - -func (m *Model) loadJournalSnapshot() tea.Cmd { - if m.service == nil { - m.journalError = fmt.Errorf("service unavailable") - return nil - } - m.loadingJournal = true - svc := m.service - return func() tea.Msg { - snapshot, err := cachepkg.BuildSnapshot(context.Background(), svc) - return journalLoadedMsg{snapshot: snapshot, err: err} - } -} - -func (m *Model) scheduleDayCheck() tea.Cmd { - return tea.Tick(dayCheckInterval, func(time.Time) tea.Msg { - return dayCheckMsg{} - }) -} - -func (m *Model) refreshToday(now time.Time) { - day := startOfDay(now) - if !m.today.IsZero() && m.today.Equal(day) { - return - } - m.today = day - if m.journalNav != nil { - m.journalNav.SetNow(now) - } - m.layoutContent() -} - -func cacheListenCmd(cache *cachepkg.Cache) tea.Cmd { - if cache == nil { - return nil - } - ch := cache.Events() - return func() tea.Msg { - msg, ok := <-ch - if !ok { - return nil - } - return cacheMsg{payload: msg} - } -} - -func startWatchCmd(parent context.Context, svc *app.Service) tea.Cmd { - if svc == nil || parent == nil { - return nil - } - return func() tea.Msg { - ctx, cancel := context.WithCancel(parent) - ch, err := svc.Watch(ctx) - if err != nil { - cancel() - return watchStartedMsg{err: err} - } - return watchStartedMsg{ch: ch, cancel: cancel} - } -} - -func (m *Model) waitForWatch() tea.Cmd { - if m.watchCh == nil { - return nil - } - ch := m.watchCh - return func() tea.Msg { - if ev, ok := <-ch; ok { - return watchEventMsg{event: ev} - } - return watchStoppedMsg{} - } -} - -func (m *Model) stopWatch() { - if m.watchCancel != nil { - m.watchCancel() - m.watchCancel = nil - } - m.watchCh = nil -} - -func (m *Model) handleWatchEvent(ev store.Event) tea.Cmd { - switch ev.Type { - case store.EventCollectionChanged: - if strings.HasPrefix(ev.Collection, "fromCollection:") { - return nil - } - return m.collectionSyncCmd(ev.Collection) - case store.EventCollectionsInvalidated: - return m.snapshotSyncCmd() - default: - if strings.HasPrefix(ev.Collection, "fromCollection:") { - return nil - } - return m.snapshotSyncCmd() - } -} - -func (m *Model) collectionSyncCmd(collectionID string) tea.Cmd { - cache := m.journalCache - if cache == nil { - return nil - } - return func() tea.Msg { - if err := cache.SyncCollection(context.Background(), collectionID); err != nil { - return watchErrorMsg{err: err} - } - return nil - } -} - -func (m *Model) snapshotSyncCmd() tea.Cmd { - cache := m.journalCache - svc := m.service - if cache == nil || svc == nil { - return nil - } - return func() tea.Msg { - snapshot, err := cachepkg.BuildSnapshot(context.Background(), svc) - if err != nil { - return watchErrorMsg{err: err} - } - cache.ApplySnapshot(snapshot) - return nil - } -} - -func startOfDay(t time.Time) time.Time { - year, month, day := t.Date() - return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) -} - -func startOfMonth(t time.Time) time.Time { - if t.IsZero() { - return time.Time{} - } - year, month, _ := t.Date() - return time.Date(year, month, 1, 0, 0, 0, 0, t.Location()) -} - -func (m *Model) reportPlacement() command.OverlayPlacement { - availableWidth := m.width - if availableWidth <= 0 { - availableWidth = 1 - } - width := int(math.Round(float64(availableWidth) * 0.9)) - if width <= 0 || width > availableWidth { - width = availableWidth - } - if width < 20 { - width = minInt(20, availableWidth) - } - availableHeight := m.height - 1 - if availableHeight <= 0 { - availableHeight = 1 - } - height := int(math.Round(float64(availableHeight) * 0.9)) - if height <= 0 || height > availableHeight { - height = availableHeight - } - if height < 5 { - height = minInt(availableHeight, 5) - } - return command.OverlayPlacement{ - Width: width, - Height: height, - Horizontal: lipgloss.Center, - Vertical: lipgloss.Top, - } -} - -func (m *Model) parseReportWindow(spec string) (time.Duration, string, error) { - trimmed := strings.TrimSpace(spec) - if trimmed == "" { - return timeutil.ParseWindow(timeutil.DefaultWindow) - } - normalized := strings.ReplaceAll(trimmed, " ", "") - return timeutil.ParseWindow(normalized) -} - -func (m *Model) appendEvent(entry eventviewer.Entry) { - if m.eventViewer == nil { - return - } - if entry.Timestamp.IsZero() { - entry.Timestamp = time.Now() - } - if entry.Source == "" { - entry.Source = "ui" - } - if entry.Summary == "" { - entry.Summary = "event" - } - m.eventViewer.Append(entry) -} - -func (m *Model) computeDebugHeight(totalRows int) int { - if totalRows <= 4 { - return 0 - } - minHeight := 5 - maxHeight := totalRows - 1 - if maxHeight < minHeight { - return maxHeight - } - desired := clamp(totalRows/3, minHeight, minInt(12, maxHeight)) - return desired -} - -func (m *Model) toggleHelpOverlay() (tea.Cmd, string) { - if m.command == nil { - return nil, "noop" - } - if m.overlayPane == nil { - m.overlayPane = overlaypane.New(m.width, maxInt(1, m.height-1)) - } - if m.helpVisible { - return m.closeHelpOverlay(), "closed" - } - cmd := m.openHelpOverlay() - if cmd == nil { - return nil, "opened" - } - return cmd, "opened" -} - -func (m *Model) helpPlacement() command.OverlayPlacement { - availableWidth := m.width - if availableWidth <= 0 { - availableWidth = 1 - } - width := int(math.Round(float64(availableWidth) * 0.75)) - if width <= 0 || width > availableWidth { - width = availableWidth - } - if width < 38 { - width = minInt(availableWidth, 38) - } - availableHeight := m.height - 1 - if availableHeight <= 0 { - availableHeight = 1 - } - height := int(math.Round(float64(availableHeight) * 0.8)) - if height <= 0 || height > availableHeight { - height = availableHeight - } - if height < 10 { - height = minInt(availableHeight, 10) - } - return command.OverlayPlacement{ - Width: width, - Height: height, - Horizontal: lipgloss.Center, - Vertical: lipgloss.Top, - } -} - -func (m *Model) openHelpOverlay() tea.Cmd { - if m.overlayPane == nil { - m.overlayPane = overlaypane.New(m.width, maxInt(1, m.height-1)) - } - var cmds []tea.Cmd - if m.addVisible { - if cmd := m.closeAddTaskOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.detailVisible { - if cmd := m.closeBulletDetailOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.newCollectionVisible { - if cmd := m.closeNewCollectionOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - placement := m.helpPlacement() - width := placement.Width - if width <= 0 { - width = m.width - } - height := placement.Height - if height <= 0 { - height = maxInt(10, m.height-1) - } - var overlay command.Overlay - if os.Getenv("BUJO_HELP_DUMMY") == "1" { - overlay = dummyview.New(width, height) - } else { - overlay = helpview.New(width, height) - } - overlay.SetSize(width, height) - - if m.reportVisible { - if cmd := m.closeReportOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.journalView != nil { - m.helpReturn = m.journalView.FocusedPane() - } else { - m.helpReturn = journalcomponent.FocusNav - } - m.helpHadFocus = true - cmds = append(cmds, m.blurJournalPanes()...) - if cmd := m.overlayPane.SetOverlay(overlay, placement); cmd != nil { - cmds = append(cmds, cmd) - } - if focusCmd := m.overlayPane.Focus(); focusCmd != nil { - cmds = append(cmds, focusCmd) - } - m.helpVisible = true - _ = m.dropFocusKind(focusKindCommand) - m.pushFocus(focusTarget{kind: focusKindOverlay, overlay: overlayKindHelp}) - if len(cmds) == 0 { + journal := m.journal() + if journal == nil { + m.setStatus("Unlock unavailable: journal cache offline") return nil } - return tea.Batch(cmds...) -} - -func (m *Model) closeHelpOverlay() tea.Cmd { - if !m.helpVisible { + section, bullet, ok := journal.CurrentSelection() + if !ok { + m.setStatus("Unlock unavailable: select a task") return nil } - var cmds []tea.Cmd - if m.overlayPane != nil { - if cmd := m.overlayPane.Blur(); cmd != nil { - cmds = append(cmds, cmd) - } - m.overlayPane.ClearOverlay() - } - m.helpVisible = false - m.helpHadFocus = false - _, _ = m.popFocusKind(focusKindOverlay) - if cmd := m.restoreFocusAfterOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } else if m.journalView != nil { - var restore tea.Cmd - switch m.helpReturn { - case journalcomponent.FocusDetail: - restore = m.journalView.FocusDetail() - default: - restore = m.journalView.FocusNav() - } - if restore != nil { - cmds = append(cmds, restore) - } + collectionID := strings.TrimSpace(section.ID) + bulletID := strings.TrimSpace(bullet.ID) + if collectionID == "" || bulletID == "" { + m.setStatus("Unlock unavailable: select a task") + return nil } - if m.command != nil { - m.setStatus("Help overlay closed") + ctx := m.ctx + if ctx == nil { + ctx = context.Background() } - if len(cmds) == 0 { + if _, err := m.service.Unlock(ctx, bulletID); err != nil { + m.setStatus("Unlock failed: " + err.Error()) return nil } - return tea.Batch(cmds...) + label := strings.TrimSpace(bullet.Label) + if label == "" { + label = bulletID + } + m.setStatus("Unlocked " + label) + return m.collectionSyncCmd(collectionID) } -func (m *Model) openAddTaskOverlay(opts addtask.Options, req events.AddTaskRequestMsg) tea.Cmd { - if m.overlayPane == nil { - m.overlayPane = overlaypane.New(m.width, maxInt(1, m.height-1)) - } - var cmds []tea.Cmd - if m.helpVisible { - if cmd := m.closeHelpOverlay(); cmd != nil { - cmds = append(cmds, cmd) +func (m *Model) jumpToToday(showStatus bool) tea.Cmd { + if m.journalNav == nil { + if showStatus { + m.setStatus("Today unavailable: journal not ready") } + return nil } - if m.reportVisible { - if cmd := m.closeReportOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } + now := m.now() + if !m.today.IsZero() { + now = m.today } - if m.addVisible { - if cmd := m.closeAddTaskOverlay(); cmd != nil { - cmds = append(cmds, cmd) + ref, _ := todayCollectionRefFromCache(m.journalCache, now) + if ref.ID == "" { + if showStatus { + m.setStatus("Today collection unavailable") } - } - model := addtask.NewModel(m.journalCache, opts) - model.SetID(addTaskOverlayID) - wrapper := newAddtaskOverlay(model) - placement := command.OverlayPlacement{Fullscreen: true} - if cmd := m.overlayPane.SetOverlay(wrapper, placement); cmd != nil { - cmds = append(cmds, cmd) - } - m.addOverlay = wrapper - m.addVisible = true - _ = m.dropFocusKind(focusKindCommand) - m.pushFocus(focusTarget{kind: focusKindOverlay, overlay: overlayKindAdd}) - cmds = append(cmds, m.blurJournalPanes()...) - if focusCmd := m.overlayPane.Focus(); focusCmd != nil { - cmds = append(cmds, focusCmd) - } - label := strings.TrimSpace(req.CollectionLabel) - if label == "" { - label = req.CollectionID - } - if m.command != nil { - m.setStatus("Add task overlay opened for " + label) - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} - -func (m *Model) closeAddTaskOverlay() tea.Cmd { - if !m.addVisible { return nil } var cmds []tea.Cmd - if m.overlayPane != nil { - if cmd := m.overlayPane.Blur(); cmd != nil { - cmds = append(cmds, cmd) - } - m.overlayPane.ClearOverlay() - } - m.addOverlay = nil - m.addVisible = false - _, _ = m.popFocusKind(focusKindOverlay) - if cmd := m.restoreFocusAfterOverlay(); cmd != nil { + if cmd := m.collectionSyncCmd(ref.ID); cmd != nil { cmds = append(cmds, cmd) } - if m.command != nil { - m.setStatus("Add task overlay closed") + if cmd := m.journalNav.SelectCollection(ref); cmd != nil { + cmds = append(cmds, cmd) } - if len(cmds) == 0 { - return nil + if cmd := m.journalFocusCmd(journalcomponent.FocusDetail); cmd != nil { + cmds = append(cmds, cmd) } - return tea.Batch(cmds...) -} - -func (m *Model) closeBulletDetailOverlay() tea.Cmd { - if !m.detailVisible { - return nil + if m.journalDetail != nil { + m.journalDetail.FocusCollection(ref.ID) } - var cmds []tea.Cmd - if m.overlayPane != nil { - if cmd := m.overlayPane.Blur(); cmd != nil { - cmds = append(cmds, cmd) + if showStatus { + label := strings.TrimSpace(ref.Name) + if label == "" { + label = "Today" } - m.overlayPane.ClearOverlay() - } - m.detailOverlay = nil - m.detailVisible = false - m.detailLoadID = "" - _, _ = m.popFocusKind(focusKindOverlay) - if cmd := m.restoreFocusAfterOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - if m.command != nil { - m.setStatus("Bullet detail overlay closed") + m.setStatus("Selected Today (" + label + ")") } if len(cmds) == 0 { return nil @@ -3274,70 +988,29 @@ func (m *Model) closeBulletDetailOverlay() tea.Cmd { return tea.Batch(cmds...) } -func (m *Model) closeMoveOverlay() tea.Cmd { - return m.closeMoveOverlayWithStatus("") -} - -func (m *Model) closeMoveOverlayWithStatus(status string) tea.Cmd { - if !m.moveVisible { - if status != "" && m.command != nil { - m.setStatus(status) +func (m *Model) jumpToFuture(showStatus bool) tea.Cmd { + if m.journalNav == nil { + if showStatus { + m.setStatus("Future collection unavailable") } return nil } + ref := events.CollectionRef{ID: "Future", Name: "Future", Type: collection.TypeMonthly} var cmds []tea.Cmd - if m.overlayPane != nil { - if cmd := m.overlayPane.Blur(); cmd != nil { - cmds = append(cmds, cmd) - } - m.overlayPane.ClearOverlay() - } - m.moveOverlay = nil - m.moveVisible = false - m.moveLoadID = "" - m.moveBulletID = "" - m.moveCollectionID = "" - m.moveFutureOnly = false - _, _ = m.popFocusKind(focusKindOverlay) - if cmd := m.restoreFocusAfterOverlay(); cmd != nil { + if cmd := m.collectionSyncCmd(ref.ID); cmd != nil { cmds = append(cmds, cmd) } - if status != "" && m.command != nil { - m.setStatus(status) - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} - -func (m *Model) closeNewCollectionOverlayWithStatus(status string) tea.Cmd { - if !m.newCollectionVisible { - if status != "" && m.command != nil { - m.setStatus(status) - } - return nil - } - var cmds []tea.Cmd - if m.overlayPane != nil { - if cmd := m.overlayPane.Blur(); cmd != nil { - cmds = append(cmds, cmd) - } - m.overlayPane.ClearOverlay() + if cmd := m.journalNav.SelectCollection(ref); cmd != nil { + cmds = append(cmds, cmd) } - m.newCollectionOverlay = nil - m.newCollectionVisible = false - _, _ = m.popFocusKind(focusKindOverlay) - if cmd := m.restoreFocusAfterOverlay(); cmd != nil { + if cmd := m.journalFocusCmd(journalcomponent.FocusDetail); cmd != nil { cmds = append(cmds, cmd) } - if status != "" && m.command != nil { - m.setStatus(status) + if m.journalDetail != nil { + m.journalDetail.FocusCollection(ref.ID) } - if m.journalView != nil { - if cmd := m.journalView.FocusNav(); cmd != nil { - cmds = append(cmds, cmd) - } + if showStatus { + m.setStatus("Selected Future") } if len(cmds) == 0 { return nil @@ -3345,62 +1018,33 @@ func (m *Model) closeNewCollectionOverlayWithStatus(status string) tea.Cmd { return tea.Batch(cmds...) } -func (m *Model) closeNewCollectionOverlay() tea.Cmd { - return m.closeNewCollectionOverlayWithStatus("") +func startOfDay(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) } -func (m *Model) closeReportOverlay() tea.Cmd { - if !m.reportVisible { - return nil - } - var cmds []tea.Cmd - if m.overlayPane != nil { - if cmd := m.overlayPane.Blur(); cmd != nil { - cmds = append(cmds, cmd) - } - m.overlayPane.ClearOverlay() - } - m.reportVisible = false - m.report = nil - _, _ = m.popFocusKind(focusKindOverlay) - if cmd := m.restoreFocusAfterOverlay(); cmd != nil { - cmds = append(cmds, cmd) - } - if len(cmds) == 0 { - return nil +func startOfMonth(t time.Time) time.Time { + if t.IsZero() { + return time.Time{} } - return tea.Batch(cmds...) + year, month, _ := t.Date() + return time.Date(year, month, 1, 0, 0, 0, 0, t.Location()) } -func (m *Model) dismissActiveOverlay() tea.Cmd { - if m.overlayPane == nil || !m.overlayPane.HasOverlay() { - return nil - } - if m.helpVisible { - return m.closeHelpOverlay() - } - if m.reportVisible { - cmd := m.closeReportOverlay() - if m.command != nil { - m.setStatus("Report overlay closed") - } - return cmd - } - if m.addVisible { - return m.closeAddTaskOverlay() +func (m *Model) appendEvent(entry eventviewer.Entry) { + if m.eventViewer == nil { + return } - if m.detailVisible { - return m.closeBulletDetailOverlay() + if entry.Timestamp.IsZero() { + entry.Timestamp = time.Now() } - if m.moveVisible { - return m.closeMoveOverlay() + if entry.Source == "" { + entry.Source = "ui" } - if m.migrateVisible { - return m.closeMigrateOverlay() + if entry.Summary == "" { + entry.Summary = "event" } - m.overlayPane.ClearOverlay() - _, _ = m.popFocusKind(focusKindOverlay) - return m.restoreFocusAfterOverlay() + m.eventViewer.Append(entry) } func (m *Model) handleFocusMsg(msg events.FocusMsg) { @@ -3426,121 +1070,6 @@ func (m *Model) handleBlurMsg(msg events.BlurMsg) tea.Cmd { return nil } -func (m *Model) restoreFocusAfterOverlay() tea.Cmd { - var cmds []tea.Cmd - for { - target, ok := m.topFocus() - if !ok { - break - } - if target.kind == focusKindCommand { - if !m.commandActive { - _ = m.dropFocusKind(focusKindCommand) - continue - } - break - } - if cmd := m.applyFocusTarget(target); cmd != nil { - cmds = append(cmds, cmd) - } - break - } - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} - -func (m *Model) pushFocus(target focusTarget) { - if target.kind == focusKindUnknown { - return - } - for i := len(m.focusStack) - 1; i >= 0; i-- { - if m.focusStack[i].kind == target.kind { - m.focusStack = m.focusStack[:i] - break - } - } - m.focusStack = append(m.focusStack, target) -} - -func (m *Model) dropFocusKind(kind focusKind) bool { - for i := len(m.focusStack) - 1; i >= 0; i-- { - if m.focusStack[i].kind == kind { - m.focusStack = append(m.focusStack[:i], m.focusStack[i+1:]...) - return true - } - } - return false -} - -func (m *Model) popFocusKind(kind focusKind) (focusTarget, bool) { - for i := len(m.focusStack) - 1; i >= 0; i-- { - if m.focusStack[i].kind == kind { - target := m.focusStack[i] - m.focusStack = append(m.focusStack[:i], m.focusStack[i+1:]...) - return target, true - } - } - return focusTarget{}, false -} - -func (m *Model) topFocus() (focusTarget, bool) { - if len(m.focusStack) == 0 { - return focusTarget{}, false - } - return m.focusStack[len(m.focusStack)-1], true -} - -func (m *Model) applyFocusTarget(target focusTarget) tea.Cmd { - switch target.kind { - case focusKindJournalNav: - if m.journalView != nil { - return m.journalView.FocusNav() - } - case focusKindJournalDetail: - if m.journalView != nil { - return m.journalView.FocusDetail() - } - case focusKindCommand: - if m.command != nil { - m.command.Focus() - } - case focusKindOverlay: - if m.overlayPane != nil && m.overlayPane.HasOverlay() { - return m.overlayPane.Focus() - } - } - return nil -} - -func (m *Model) blurJournalPanes() []tea.Cmd { - var cmds []tea.Cmd - if m.journalNav != nil { - if cmd := m.journalNav.Blur(); cmd != nil { - cmds = append(cmds, cmd) - } - } - if m.journalDetail != nil { - if cmd := m.journalDetail.Blur(); cmd != nil { - cmds = append(cmds, cmd) - } - } - return cmds -} - -func (m *Model) focusJournalPane(pane journalcomponent.FocusPane) tea.Cmd { - if m.journalView == nil { - return nil - } - switch pane { - case journalcomponent.FocusDetail: - return m.journalView.FocusDetail() - default: - return m.journalView.FocusNav() - } -} - func describeMsg(msg tea.Msg) string { if d, ok := msg.(interface{ Describe() string }); ok { return d.Describe() @@ -3577,6 +1106,8 @@ func eventSource(msg tea.Msg) (string, bool) { return string(v.Component), true case events.CommandCancelMsg: return string(v.Component), true + case events.JournalFocusMsg: + return string(v.Component), true case events.FocusMsg: return string(v.Component), true case events.BlurMsg: diff --git a/pkg/tui/app/bullet_detail_handlers.go b/pkg/tui/app/bullet_detail_handlers.go new file mode 100644 index 0000000..74c7f77 --- /dev/null +++ b/pkg/tui/app/bullet_detail_handlers.go @@ -0,0 +1,209 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea/v2" + + "tableflip.dev/bujo/pkg/tui/components/bulletdetail" + "tableflip.dev/bujo/pkg/tui/components/command" + "tableflip.dev/bujo/pkg/tui/events" +) + +// loadBulletDetail fetches an entry payload for the detail or move overlays. +func (m *Model) loadBulletDetail(collectionID, bulletID, requestID string) tea.Cmd { + svc := m.service + return func() tea.Msg { + if svc == nil { + return bulletDetailLoadedMsg{requestID: requestID, err: fmt.Errorf("service unavailable")} + } + ctx := context.Background() + entries, err := svc.Entries(ctx, collectionID) + if err != nil { + return bulletDetailLoadedMsg{requestID: requestID, err: err} + } + for _, e := range entries { + if e == nil { + continue + } + if strings.TrimSpace(e.ID) == bulletID { + e.EnsureHistorySeed() + return bulletDetailLoadedMsg{requestID: requestID, entry: e} + } + } + return bulletDetailLoadedMsg{requestID: requestID, err: fmt.Errorf("entry not found")} + } +} + +func (m *Model) handleBulletDetailRequest(msg events.BulletDetailRequestMsg) tea.Cmd { + bulletID := strings.TrimSpace(msg.Bullet.ID) + if bulletID == "" { + if m.command != nil { + m.setStatus("Bullet details unavailable: missing bullet ID") + } + return nil + } + if m.service == nil { + if m.command != nil { + m.setStatus("Bullet details unavailable: service offline") + } + return nil + } + collectionID := strings.TrimSpace(msg.Collection.ID) + if collectionID == "" { + collectionID = strings.TrimSpace(msg.Bullet.Note) + } + if collectionID == "" { + if m.command != nil { + m.setStatus("Bullet details unavailable: missing collection context") + } + return nil + } + m.ensureOverlayStack() + var cmds []tea.Cmd + if m.helpVisible { + if cmd := m.closeHelpOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.reportVisible { + if cmd := m.closeReportOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.detailVisible { + if cmd := m.closeBulletDetailOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.addVisible { + if cmd := m.closeAddTaskOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + + title := msg.Collection.Title + if strings.TrimSpace(title) == "" { + title = collectionID + } + detailModel := bulletdetail.New(title, msg.Bullet.Label, collectionID, msg.Bullet.Note) + detailModel.SetLoading(true) + wrapper := newBulletdetailOverlay(detailModel) + placement := command.OverlayPlacement{Fullscreen: true} + if cmd := m.overlayStack.Open(overlayKindBulletDetail, wrapper, placement); cmd != nil { + cmds = append(cmds, cmd) + } + m.detailOverlay = wrapper + m.detailVisible = true + requestID := fmt.Sprintf("%s@%d", bulletID, time.Now().UnixNano()) + m.detailLoadID = requestID + _ = m.dropFocusKind(focusKindCommand) + m.pushFocus(focusTarget{kind: focusKindOverlay, overlay: overlayKindBulletDetail}) + cmds = append(cmds, m.blurJournalPanes()...) + if focusCmd := m.overlayStack.Focus(); focusCmd != nil { + cmds = append(cmds, focusCmd) + } + if m.command != nil { + statusLabel := strings.TrimSpace(msg.Bullet.Label) + if statusLabel == "" { + statusLabel = bulletID + } + m.setStatus("Loading details for " + statusLabel) + } + if loadCmd := m.loadBulletDetail(collectionID, bulletID, requestID); loadCmd != nil { + cmds = append(cmds, loadCmd) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) closeBulletDetailOverlay() tea.Cmd { + if !m.detailVisible { + return nil + } + var cmds []tea.Cmd + if m.overlayStack != nil { + if cmd := m.overlayStack.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + m.overlayStack.Close(overlayKindBulletDetail) + } + m.detailOverlay = nil + m.detailVisible = false + m.detailLoadID = "" + _, _ = m.popFocusKind(focusKindOverlay) + if cmd := m.restoreFocusAfterOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + if m.command != nil { + m.setStatusIfIdle("Bullet detail overlay closed") + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) handleBulletDetailLoaded(msg bulletDetailLoadedMsg) tea.Cmd { + if msg.requestID == "" || msg.requestID != m.detailLoadID { + if msg.requestID != "" && msg.requestID == m.moveLoadID { + return m.handleMoveDetailLoaded(msg) + } + return nil + } + if m.detailOverlay == nil { + return nil + } + model := m.detailOverlay.Model() + if model == nil { + return nil + } + if msg.err != nil { + model.SetError(msg.err) + if m.command != nil { + m.setStatus("Bullet detail error: " + msg.err.Error()) + } + return nil + } + if msg.entry == nil { + model.SetError(fmt.Errorf("entry not available")) + return nil + } + model.SetEntry(msg.entry) + if m.command != nil { + label := strings.TrimSpace(msg.entry.Message) + if label == "" { + label = msg.entry.ID + } + m.setStatus("Loaded bullet details for " + label) + } + return nil +} + +func (m *Model) handleMoveDetailLoaded(msg bulletDetailLoadedMsg) tea.Cmd { + if m.moveOverlay == nil { + return nil + } + model := m.moveOverlay.detail + if model == nil { + return nil + } + if msg.err != nil { + model.SetError(msg.err) + if m.command != nil { + m.setStatus("Bullet detail error: " + msg.err.Error()) + } + return nil + } + if msg.entry == nil { + model.SetError(fmt.Errorf("entry not available")) + return nil + } + model.SetEntry(msg.entry) + return nil +} diff --git a/pkg/tui/app/bullet_overlay.go b/pkg/tui/app/bullet_overlay.go index 8f08545..86fde15 100644 --- a/pkg/tui/app/bullet_overlay.go +++ b/pkg/tui/app/bullet_overlay.go @@ -4,7 +4,6 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "tableflip.dev/bujo/pkg/tui/components/bulletdetail" - "tableflip.dev/bujo/pkg/tui/components/command" ) type bulletdetailOverlay struct { @@ -22,7 +21,7 @@ func (o *bulletdetailOverlay) Init() tea.Cmd { return o.model.Init() } -func (o *bulletdetailOverlay) Update(msg tea.Msg) (command.Overlay, tea.Cmd) { +func (o *bulletdetailOverlay) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if o.model == nil { return o, nil } diff --git a/pkg/tui/app/clock.go b/pkg/tui/app/clock.go new file mode 100644 index 0000000..39afb0a --- /dev/null +++ b/pkg/tui/app/clock.go @@ -0,0 +1,14 @@ +package app + +import ( + "time" + + "tableflip.dev/bujo/pkg/tui/clock" +) + +func (m *Model) now() time.Time { + if m.clock != nil { + return m.clock.Now() + } + return clock.RealClock{}.Now() +} diff --git a/pkg/tui/app/command_handlers.go b/pkg/tui/app/command_handlers.go new file mode 100644 index 0000000..a7526f1 --- /dev/null +++ b/pkg/tui/app/command_handlers.go @@ -0,0 +1,171 @@ +package app + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + + journalcomponent "tableflip.dev/bujo/pkg/tui/components/journal" + "tableflip.dev/bujo/pkg/tui/events" +) + +const commandUsageLine = "Commands: :quit, :today, :future, :debug, :report [window], :migrate [window], :lock, :unlock, :help" + +// commandUsageStatus returns the help text shown when the prompt is empty. +func commandUsageStatus() string { + return commandUsageLine +} + +func (m *Model) handleCommandSubmit(msg events.CommandSubmitMsg) ([]tea.Cmd, bool) { + if m.command == nil || msg.Component != m.command.ID() { + return nil, false + } + raw := strings.TrimSpace(msg.Value) + if raw == "" { + m.setStatus(commandUsageStatus()) + return nil, true + } + parts := strings.Fields(raw) + if len(parts) == 0 { + m.setStatus(commandUsageStatus()) + return nil, true + } + cmdName := strings.ToLower(parts[0]) + arg := "" + if len(parts) > 1 { + arg = strings.Join(parts[1:], " ") + } + + var cmds []tea.Cmd + switch cmdName { + case "quit", "exit", "q": + cmds = append(cmds, tea.Quit) + case "help": + cmd, state := m.toggleHelpOverlay() + if cmd != nil { + cmds = append(cmds, cmd) + } + switch state { + case "opened": + m.setStatus("Help overlay opened (Esc or : to close)") + case "closed": + m.setStatus("Help overlay closed") + case "noop": + m.setStatus("Help unavailable") + } + m.layoutContent() + case "debug": + m.toggleDebug() + case "report": + cmd, state := m.showReportOverlay(arg) + if cmd != nil { + cmds = append(cmds, cmd) + } + switch state { + case "opened": + m.setStatus("Report overlay opened") + case "closed": + m.setStatus("Report overlay closed") + case "error": + // status set inside showReportOverlay + } + m.layoutContent() + case "migrate": + cmd, state := m.showMigrateOverlay(arg) + if cmd != nil { + cmds = append(cmds, cmd) + } + switch state { + case "opened": + m.setStatus("Migration overlay opened") + case "closed": + m.setStatus("Migration overlay closed") + case "error": + // status set within showMigrateOverlay + case "noop": + // no change + } + m.layoutContent() + case "today": + if cmd := m.jumpToToday(true); cmd != nil { + cmds = append(cmds, cmd) + } + case "future": + if cmd := m.jumpToFuture(true); cmd != nil { + cmds = append(cmds, cmd) + } + case "lock": + if cmd := m.lockSelectedBullet(); cmd != nil { + cmds = append(cmds, cmd) + } + case "unlock": + if cmd := m.unlockSelectedBullet(); cmd != nil { + cmds = append(cmds, cmd) + } + default: + m.setStatus("Unhandled command: " + cmdName) + } + _ = m.dropFocusKind(focusKindCommand) + return cmds, true +} + +func (m *Model) handleCommandCancel(msg events.CommandCancelMsg) ([]tea.Cmd, bool) { + if m.command == nil || msg.Component != m.command.ID() { + return nil, false + } + m.setStatus("Ready") + var cmds []tea.Cmd + if m.commandActive { + m.commandActive = false + if !m.helpVisible { + if cmd := m.focusJournalPane(m.commandReturn); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + _ = m.dropFocusKind(focusKindCommand) + return cmds, true +} + +func (m *Model) handleCommandChange(msg events.CommandChangeMsg) ([]tea.Cmd, bool) { + if m.command == nil || msg.Component != m.command.ID() { + _ = m.dropFocusKind(focusKindCommand) + m.layoutContent() + return nil, true + } + + var cmds []tea.Cmd + if msg.Mode == events.CommandModeInput { + if !m.commandActive { + if journal := m.journal(); journal != nil { + m.commandReturn = journal.FocusedPane() + } else { + m.commandReturn = journalcomponent.FocusNav + } + m.commandActive = true + cmds = append(cmds, m.blurJournalPanes()...) + if m.overlayStack != nil && m.overlayStack.HasOverlay() { + if cmd := m.overlayStack.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + } + m.pushFocus(focusTarget{kind: focusKindCommand}) + } + } else { + if m.commandActive { + m.commandActive = false + if m.overlayStack != nil && m.overlayStack.HasOverlay() { + if cmd := m.overlayStack.Focus(); cmd != nil { + cmds = append(cmds, cmd) + } + } else if !m.helpVisible { + if cmd := m.focusJournalPane(m.commandReturn); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + _ = m.dropFocusKind(focusKindCommand) + } + m.layoutContent() + return cmds, true +} diff --git a/pkg/tui/app/command_handlers_test.go b/pkg/tui/app/command_handlers_test.go new file mode 100644 index 0000000..9c71508 --- /dev/null +++ b/pkg/tui/app/command_handlers_test.go @@ -0,0 +1,48 @@ +package app + +import ( + "testing" + + "tableflip.dev/bujo/pkg/tui/events" +) + +func TestHandleCommandSubmitEmptyShowsUsage(t *testing.T) { + m := NewWithOptions(Options{}) + msg := events.CommandSubmitMsg{ + Component: m.command.ID(), + Value: " ", + } + + if _, handled := m.handleCommandSubmit(msg); !handled { + t.Fatal("expected submit to be handled") + } + if m.statusText != commandUsageLine { + t.Fatalf("expected usage status, got %q", m.statusText) + } +} + +func TestHandleCommandChangeTogglesCommandActive(t *testing.T) { + m := NewWithOptions(Options{}) + + enter := events.CommandChangeMsg{ + Component: m.command.ID(), + Mode: events.CommandModeInput, + } + if _, handled := m.handleCommandChange(enter); !handled { + t.Fatal("expected change to be handled") + } + if !m.commandActive { + t.Fatal("expected command to be active after entering input mode") + } + + exit := events.CommandChangeMsg{ + Component: m.command.ID(), + Mode: events.CommandModePassive, + } + if _, handled := m.handleCommandChange(exit); !handled { + t.Fatal("expected change to be handled") + } + if m.commandActive { + t.Fatal("expected command to be inactive after leaving input mode") + } +} diff --git a/pkg/tui/app/command_layout_test.go b/pkg/tui/app/command_layout_test.go index 56d3cca..9070105 100644 --- a/pkg/tui/app/command_layout_test.go +++ b/pkg/tui/app/command_layout_test.go @@ -38,7 +38,7 @@ func TestViewKeepsCommandBarAnchoredAfterDetailScroll(t *testing.T) { _, _ = journal.Update(tea.KeyPressMsg{Code: tea.KeyDown}) } - m.journalView = journal + m.setJournal(journal) m.journalDetail = detail m.layoutContent() @@ -90,7 +90,7 @@ func TestColonKeyKeepsCommandBarAnchoredAfterDetailScroll(t *testing.T) { } m.journalDetail = detail - m.journalView = journal + m.setJournal(journal) m.layoutContent() for i := 0; i < 60; i++ { diff --git a/pkg/tui/app/focus.go b/pkg/tui/app/focus.go new file mode 100644 index 0000000..1338c14 --- /dev/null +++ b/pkg/tui/app/focus.go @@ -0,0 +1,110 @@ +package app + +import ( + tea "github.com/charmbracelet/bubbletea/v2" + + journalcomponent "tableflip.dev/bujo/pkg/tui/components/journal" +) + +func (m *Model) restoreFocusAfterOverlay() tea.Cmd { + var cmds []tea.Cmd + for { + target, ok := m.topFocus() + if !ok { + break + } + if target.kind == focusKindCommand { + if !m.commandActive { + _ = m.dropFocusKind(focusKindCommand) + continue + } + break + } + if cmd := m.applyFocusTarget(target); cmd != nil { + cmds = append(cmds, cmd) + } + break + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) pushFocus(target focusTarget) { + if target.kind == focusKindUnknown { + return + } + for i := len(m.focusStack) - 1; i >= 0; i-- { + if m.focusStack[i].kind == target.kind { + m.focusStack = m.focusStack[:i] + break + } + } + m.focusStack = append(m.focusStack, target) +} + +func (m *Model) dropFocusKind(kind focusKind) bool { + for i := len(m.focusStack) - 1; i >= 0; i-- { + if m.focusStack[i].kind == kind { + m.focusStack = append(m.focusStack[:i], m.focusStack[i+1:]...) + return true + } + } + return false +} + +func (m *Model) popFocusKind(kind focusKind) (focusTarget, bool) { + for i := len(m.focusStack) - 1; i >= 0; i-- { + if m.focusStack[i].kind == kind { + target := m.focusStack[i] + m.focusStack = append(m.focusStack[:i], m.focusStack[i+1:]...) + return target, true + } + } + return focusTarget{}, false +} + +func (m *Model) topFocus() (focusTarget, bool) { + if len(m.focusStack) == 0 { + return focusTarget{}, false + } + return m.focusStack[len(m.focusStack)-1], true +} + +func (m *Model) applyFocusTarget(target focusTarget) tea.Cmd { + switch target.kind { + case focusKindJournalNav: + return m.journalFocusCmd(journalcomponent.FocusNav) + case focusKindJournalDetail: + return m.journalFocusCmd(journalcomponent.FocusDetail) + case focusKindCommand: + if m.command != nil { + m.command.Focus() + } + case focusKindOverlay: + if m.overlayStack != nil && m.overlayStack.HasOverlay() { + return m.overlayStack.Focus() + } + } + return nil +} + +func (m *Model) blurJournalPanes() []tea.Cmd { + var cmds []tea.Cmd + if m.journalNav != nil { + if cmd := m.journalNav.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.journalDetail != nil { + if cmd := m.journalDetail.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + } + return cmds +} + +func (m *Model) focusJournalPane(pane journalcomponent.FocusPane) tea.Cmd { + return m.journalFocusCmd(pane) +} diff --git a/pkg/tui/app/help_handlers.go b/pkg/tui/app/help_handlers.go new file mode 100644 index 0000000..2e795e7 --- /dev/null +++ b/pkg/tui/app/help_handlers.go @@ -0,0 +1,152 @@ +package app + +import ( + "math" + "os" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + + "tableflip.dev/bujo/pkg/tui/components/command" + dummyview "tableflip.dev/bujo/pkg/tui/components/dummy" + helpview "tableflip.dev/bujo/pkg/tui/components/help" + journalcomponent "tableflip.dev/bujo/pkg/tui/components/journal" +) + +func (m *Model) toggleHelpOverlay() (tea.Cmd, string) { + if m.command == nil { + return nil, "noop" + } + m.ensureOverlayStack() + if m.helpVisible { + return m.closeHelpOverlay(), "closed" + } + cmd := m.openHelpOverlay() + if cmd == nil { + return nil, "opened" + } + return cmd, "opened" +} + +func (m *Model) helpPlacement() command.OverlayPlacement { + availableWidth := m.width + if availableWidth <= 0 { + availableWidth = 1 + } + width := int(math.Round(float64(availableWidth) * 0.75)) + if width <= 0 || width > availableWidth { + width = availableWidth + } + if width < 38 { + width = minInt(availableWidth, 38) + } + availableHeight := m.height - 1 + if availableHeight <= 0 { + availableHeight = 1 + } + height := int(math.Round(float64(availableHeight) * 0.8)) + if height <= 0 || height > availableHeight { + height = availableHeight + } + if height < 10 { + height = minInt(availableHeight, 10) + } + return command.OverlayPlacement{ + Width: width, + Height: height, + Horizontal: lipgloss.Center, + Vertical: lipgloss.Top, + } +} + +func (m *Model) openHelpOverlay() tea.Cmd { + m.ensureOverlayStack() + var cmds []tea.Cmd + if m.addVisible { + if cmd := m.closeAddTaskOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.detailVisible { + if cmd := m.closeBulletDetailOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.newCollectionVisible { + if cmd := m.closeNewCollectionOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + placement := m.helpPlacement() + width := placement.Width + if width <= 0 { + width = m.width + } + height := placement.Height + if height <= 0 { + height = maxInt(10, m.height-1) + } + var overlay command.Overlay + if os.Getenv("BUJO_HELP_DUMMY") == "1" { + overlay = dummyview.New(width, height) + } else { + overlay = helpview.New(width, height) + } + overlay.SetSize(width, height) + + if m.reportVisible { + if cmd := m.closeReportOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if journal := m.journal(); journal != nil { + m.helpReturn = journal.FocusedPane() + } else { + m.helpReturn = journalcomponent.FocusNav + } + m.helpHadFocus = true + cmds = append(cmds, m.blurJournalPanes()...) + if cmd := m.overlayStack.Open(overlayKindHelp, overlay, placement); cmd != nil { + cmds = append(cmds, cmd) + } + if focusCmd := m.overlayStack.Focus(); focusCmd != nil { + cmds = append(cmds, focusCmd) + } + m.helpVisible = true + _ = m.dropFocusKind(focusKindCommand) + m.pushFocus(focusTarget{kind: focusKindOverlay, overlay: overlayKindHelp}) + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) closeHelpOverlay() tea.Cmd { + if !m.helpVisible { + return nil + } + var cmds []tea.Cmd + if m.overlayStack != nil { + if cmd := m.overlayStack.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + m.overlayStack.Close(overlayKindHelp) + } + m.helpVisible = false + m.helpHadFocus = false + _, _ = m.popFocusKind(focusKindOverlay) + if cmd := m.restoreFocusAfterOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } else { + if restore := m.journalFocusCmd(m.helpReturn); restore != nil { + cmds = append(cmds, restore) + } + } + if m.command != nil { + m.setStatusIfIdle("Help overlay closed") + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} diff --git a/pkg/tui/app/journal_access.go b/pkg/tui/app/journal_access.go new file mode 100644 index 0000000..f42729a --- /dev/null +++ b/pkg/tui/app/journal_access.go @@ -0,0 +1,19 @@ +package app + +import journalcomponent "tableflip.dev/bujo/pkg/tui/components/journal" + +// journal returns the active journal page model when available. +func (m *Model) journal() *journalcomponent.Model { + if m.router == nil { + return nil + } + return m.router.Journal() +} + +// setJournal installs the journal page model on the router. +func (m *Model) setJournal(journal *journalcomponent.Model) { + if m.router == nil { + m.router = newPageRouter() + } + m.router.SetJournal(journal) +} diff --git a/pkg/tui/app/journal_focus.go b/pkg/tui/app/journal_focus.go new file mode 100644 index 0000000..cf7cea0 --- /dev/null +++ b/pkg/tui/app/journal_focus.go @@ -0,0 +1,27 @@ +package app + +import ( + tea "github.com/charmbracelet/bubbletea/v2" + + journalcomponent "tableflip.dev/bujo/pkg/tui/components/journal" + "tableflip.dev/bujo/pkg/tui/events" +) + +// journalFocusCmd emits a focus request event for the active journal page. +func (m *Model) journalFocusCmd(pane journalcomponent.FocusPane) tea.Cmd { + journal := m.journal() + if journal == nil { + return nil + } + target := events.JournalFocusNav + if pane == journalcomponent.FocusDetail { + target = events.JournalFocusDetail + } + id := journal.ID() + return func() tea.Msg { + return events.JournalFocusMsg{ + Component: id, + Pane: target, + } + } +} diff --git a/pkg/tui/app/layout.go b/pkg/tui/app/layout.go new file mode 100644 index 0000000..6ff4f6b --- /dev/null +++ b/pkg/tui/app/layout.go @@ -0,0 +1,128 @@ +package app + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + + "tableflip.dev/bujo/pkg/tui/components/eventviewer" +) + +// View renders the composed UI. +func (m *Model) View() (string, *tea.Cursor) { + if m.command == nil { + return "initializing…", nil + } + return m.command.View() +} + +func (m *Model) layoutContent() { + if m.command == nil { + return + } + if m.width <= 0 { + m.width = 1 + } + if m.height <= 0 { + m.height = 1 + } + + m.command.SetSize(m.width, m.height) + + totalRows := maxInt(1, m.height-1) + debugRows := 0 + if m.debugEnabled { + if m.eventViewer == nil { + m.eventViewer = eventviewer.NewModel(400) + } + debugRows = m.computeDebugHeight(totalRows) + if debugRows > 0 { + m.eventViewer.SetSize(m.width, debugRows) + } + } else { + m.eventViewer = nil + } + + mainRows := totalRows + if debugRows > 0 && debugRows < totalRows { + mainRows = totalRows - debugRows + } + if mainRows < 1 { + mainRows = 1 + } + mainView, mainCursor := m.mainContent(mainRows) + if m.overlayStack == nil { + m.overlayStack = newOverlayStack(m.width, mainRows) + } + m.overlayStack.SetSize(m.width, mainRows) + m.overlayStack.SetBackground(mainView, mainCursor) + composed, composedCursor := m.overlayStack.View() + body := composed + if debugRows > 0 && m.eventViewer != nil { + debugView := m.eventViewer.View() + if body != "" { + body = body + "\n" + debugView + } else { + body = debugView + } + } + m.command.SetContent(body, composedCursor) +} + +func (m *Model) mainContent(height int) (string, *tea.Cursor) { + if m.router != nil { + if view, cursor, ok := m.router.ActiveView(m.width, height); ok { + if height < 1 { + height = 1 + } + viewLines := strings.Split(view, "\n") + if len(viewLines) > 0 && viewLines[len(viewLines)-1] == "" { + viewLines = viewLines[:len(viewLines)-1] + } + if len(viewLines) > height { + viewLines = viewLines[:height] + } + for len(viewLines) < height { + viewLines = append(viewLines, "") + } + body := strings.Join(viewLines, "\n") + return body, cursor + } + } + + var lines []string + if m.loadingJournal { + lines = append(lines, m.clipLine("Loading journal…")) + } else if m.journalError != nil { + lines = append(lines, m.clipLine("Journal load failed: "+m.journalError.Error())) + } else { + lines = append(lines, m.clipLine("Journal not available")) + } + return strings.Join(lines, "\n"), nil +} + +func (m *Model) clipLine(text string) string { + if m.width <= 0 { + return text + } + if len(text) <= m.width { + return text + } + if m.width <= 3 { + return text[:m.width] + } + return text[:m.width-3] + "..." +} + +func (m *Model) computeDebugHeight(totalRows int) int { + if totalRows <= 4 { + return 0 + } + minHeight := 5 + maxHeight := totalRows - 1 + if maxHeight < minHeight { + return maxHeight + } + desired := clamp(totalRows/3, minHeight, minInt(12, maxHeight)) + return desired +} diff --git a/pkg/tui/app/migrate_overlay.go b/pkg/tui/app/migrate_overlay.go index bb7c01f..467adbf 100644 --- a/pkg/tui/app/migrate_overlay.go +++ b/pkg/tui/app/migrate_overlay.go @@ -13,7 +13,6 @@ import ( bulletdetail "tableflip.dev/bujo/pkg/tui/components/bulletdetail" collectiondetail "tableflip.dev/bujo/pkg/tui/components/collectiondetail" collectionnav "tableflip.dev/bujo/pkg/tui/components/collectionnav" - "tableflip.dev/bujo/pkg/tui/components/command" "tableflip.dev/bujo/pkg/tui/events" ) @@ -134,7 +133,7 @@ func (o *migrationOverlay) Init() tea.Cmd { return tea.Batch(cmds...) } -func (o *migrationOverlay) Update(msg tea.Msg) (command.Overlay, tea.Cmd) { +func (o *migrationOverlay) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if o.creatingNew { return o.updateCreateNewCollection(msg) } @@ -210,7 +209,7 @@ func (o *migrationOverlay) Update(msg tea.Msg) (command.Overlay, tea.Cmd) { return o, tea.Batch(cmds...) } -func (o *migrationOverlay) updateCreateNewCollection(msg tea.Msg) (command.Overlay, tea.Cmd) { +func (o *migrationOverlay) updateCreateNewCollection(msg tea.Msg) (tea.Model, tea.Cmd) { switch v := msg.(type) { case tea.KeyMsg: switch v.String() { diff --git a/pkg/tui/app/migration_handlers.go b/pkg/tui/app/migration_handlers.go new file mode 100644 index 0000000..7adc153 --- /dev/null +++ b/pkg/tui/app/migration_handlers.go @@ -0,0 +1,543 @@ +package app + +import ( + "context" + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + + "tableflip.dev/bujo/pkg/collection" + viewmodel "tableflip.dev/bujo/pkg/collection/viewmodel" + "tableflip.dev/bujo/pkg/tui/components/collectionnav" + "tableflip.dev/bujo/pkg/tui/components/command" + "tableflip.dev/bujo/pkg/tui/events" +) + +func (m *Model) closeMigrateOverlay() tea.Cmd { + return m.closeMigrateOverlayWithStatus("") +} + +func (m *Model) closeMigrateOverlayWithStatus(status string) tea.Cmd { + if !m.migrateVisible { + if status != "" && m.command != nil { + m.setStatus(status) + } + return nil + } + var cmds []tea.Cmd + if m.overlayStack != nil { + if cmd := m.overlayStack.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + m.overlayStack.Close(overlayKindMigrate) + } + m.migrateOverlay = nil + m.migrateVisible = false + m.migrateWindow = migrationWindow{} + _, _ = m.popFocusKind(focusKindOverlay) + if cmd := m.restoreFocusAfterOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + if status != "" && m.command != nil { + m.setStatus(status) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) showMigrateOverlay(arg string) (tea.Cmd, string) { + if m.command == nil { + return nil, "noop" + } + m.ensureOverlayStack() + if m.service == nil { + m.setStatus("Migration unavailable: service offline") + return nil, "error" + } + if m.migrateVisible { + return m.closeMigrateOverlay(), "closed" + } + now := m.now() + if !m.today.IsZero() { + now = now.In(m.today.Location()) + if now.Before(m.today) { + now = m.today + } + } + window, err := resolveMigrationWindow(now, arg) + if err != nil { + m.setStatus("Migration: " + err.Error()) + return nil, "error" + } + ctx := context.Background() + metas, err := m.service.CollectionsMeta(ctx, "") + if err != nil { + m.setStatus("Migration unavailable: " + err.Error()) + return nil, "error" + } + parsedRoots := viewmodel.BuildTree(metas) + futureRoots := futureCollectionsFromMetas(metas, now) + targetRoots := filterMoveCollections(parsedRoots) + targetRoots = appendNewCollectionOption(targetRoots) + targetRoots = includeNextMonthCollection(targetRoots, now) + candidates, err := m.service.MigrationCandidates(ctx, window.Since, window.Until) + if err != nil { + m.setStatus("Migration unavailable: " + err.Error()) + return nil, "error" + } + data := buildMigrationData(now, candidates, parsedRoots) + futureNav := collectionnav.NewModel(futureRoots) + futureNav.SetBlurOnSelect(false) + targetNav := collectionnav.NewModel(targetRoots) + targetNav.SetBlurOnSelect(false) + overlay := newMigrationOverlay(data, window, futureNav, targetNav, m.dump) + overlay.SetSize(m.width, maxInt(1, m.height-1)) + + if data.IsEmpty() && m.command != nil { + m.setStatus("Migration inbox empty") + } + + var cmds []tea.Cmd + if m.helpVisible { + if cmd := m.closeHelpOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.reportVisible { + if cmd := m.closeReportOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.addVisible { + if cmd := m.closeAddTaskOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.detailVisible { + if cmd := m.closeBulletDetailOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.moveVisible { + if cmd := m.closeMoveOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.newCollectionVisible { + if cmd := m.closeNewCollectionOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.migrateVisible { + if cmd := m.closeMigrateOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + placement := command.OverlayPlacement{Fullscreen: true} + if cmd := m.overlayStack.Open(overlayKindMigrate, overlay, placement); cmd != nil { + cmds = append(cmds, cmd) + } + m.migrateOverlay = overlay + m.migrateVisible = true + m.migrateWindow = window + _ = m.dropFocusKind(focusKindCommand) + m.pushFocus(focusTarget{kind: focusKindOverlay, overlay: overlayKindMigrate}) + cmds = append(cmds, m.blurJournalPanes()...) + if focus := m.overlayStack.Focus(); focus != nil { + cmds = append(cmds, focus) + } + if len(cmds) == 0 { + return nil, "opened" + } + return tea.Batch(cmds...), "opened" +} + +func (m *Model) handleMigrationMoveRequest(msg events.MoveBulletRequestMsg) tea.Cmd { + if !m.migrateVisible || m.migrateOverlay == nil { + return nil + } + bulletID := strings.TrimSpace(msg.Bullet.ID) + if bulletID == "" { + return nil + } + targetRef, exists, ok := m.migrateOverlay.TargetSelection() + if !ok { + if m.command != nil { + m.setStatus("Move unavailable: select a destination") + } + return nil + } + targetPath := strings.TrimSpace(resolvedCollectionPath(targetRef)) + if targetPath == "" { + targetPath = strings.TrimSpace(targetRef.Name) + } + if targetPath == "" { + if m.command != nil { + m.setStatus("Move unavailable: select a destination") + } + return nil + } + origin := strings.TrimSpace(msg.Collection.ID) + if origin == "" { + origin = strings.TrimSpace(msg.Bullet.Note) + } + label := strings.TrimSpace(msg.Bullet.Label) + if label == "" { + label = bulletID + } + if targetPath == origin { + status := "Kept " + label + return m.removeMigrationBullet(bulletID, status) + } + if m.service == nil { + if m.command != nil { + m.setStatus("Move unavailable: service offline") + } + return nil + } + ctx := context.Background() + if !exists { + targetType := targetRef.Type + if targetType == "" { + targetType = collection.TypeGeneric + } + if err := m.service.EnsureCollectionOfType(ctx, targetPath, targetType); err != nil { + if m.command != nil { + m.setStatus("Move failed: " + err.Error()) + } + return nil + } + } + clone, err := m.service.Move(ctx, bulletID, targetPath) + if err != nil { + if m.command != nil { + m.setStatus("Move failed: " + err.Error()) + } + return nil + } + if strings.TrimSpace(label) == "" && clone != nil { + label = strings.TrimSpace(clone.Message) + if label == "" { + label = bulletID + } + } + destination := targetRef.Label() + if strings.TrimSpace(destination) == "" { + destination = targetPath + } + status := "Moved " + label + " to " + destination + var cmds []tea.Cmd + if cmd := m.removeMigrationBullet(bulletID, status); cmd != nil { + cmds = append(cmds, cmd) + } + if sync := m.collectionSyncCmd(targetPath); sync != nil { + cmds = append(cmds, sync) + } + if origin != "" && origin != targetPath { + if sync := m.collectionSyncCmd(origin); sync != nil { + cmds = append(cmds, sync) + } + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) handleMigrationMoveFuture(msg events.BulletMoveFutureMsg) tea.Cmd { + if !m.migrateVisible || m.migrateOverlay == nil { + return nil + } + bulletID := strings.TrimSpace(msg.Bullet.ID) + if bulletID == "" { + return nil + } + targetRef, exists, ok := m.migrateOverlay.FutureSelection() + if !ok { + targetRef = events.CollectionRef{ID: "Future", Name: "Future", Type: collection.TypeMonthly} + exists = true + } + targetPath := strings.TrimSpace(resolvedCollectionPath(targetRef)) + if targetPath == "" { + targetPath = "Future" + } + origin := strings.TrimSpace(msg.Collection.ID) + if origin == "" { + origin = strings.TrimSpace(msg.Bullet.Note) + } + label := strings.TrimSpace(msg.Bullet.Label) + if label == "" { + label = bulletID + } + if targetPath == origin { + status := "Kept " + label + return m.removeMigrationBullet(bulletID, status) + } + if m.service == nil { + if m.command != nil { + m.setStatus("Move unavailable: service offline") + } + return nil + } + ctx := context.Background() + if !exists { + targetType := targetRef.Type + if strings.HasPrefix(targetPath, "Future/") { + if targetType == "" { + targetType = collection.TypeGeneric + } + } else if targetType == "" { + if targetPath == "Future" { + targetType = collection.TypeMonthly + } else { + targetType = collection.TypeGeneric + } + } + if err := m.service.EnsureCollectionOfType(ctx, targetPath, targetType); err != nil { + if m.command != nil { + m.setStatus("Move failed: " + err.Error()) + } + return nil + } + } + clone, err := m.service.Move(ctx, bulletID, targetPath) + if err != nil { + if m.command != nil { + m.setStatus("Move failed: " + err.Error()) + } + return nil + } + if strings.TrimSpace(label) == "" && clone != nil { + label = strings.TrimSpace(clone.Message) + if label == "" { + label = bulletID + } + } + dest := targetRef.Label() + if strings.TrimSpace(dest) == "" { + dest = targetPath + } + status := "Moved " + label + " to " + dest + var cmds []tea.Cmd + if cmd := m.removeMigrationBullet(bulletID, status); cmd != nil { + cmds = append(cmds, cmd) + } + if sync := m.collectionSyncCmd(targetPath); sync != nil { + cmds = append(cmds, sync) + } + if origin != "" && origin != targetPath { + if sync := m.collectionSyncCmd(origin); sync != nil { + cmds = append(cmds, sync) + } + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) handleMigrationKeep(msg events.BulletSelectMsg) tea.Cmd { + if !m.migrateVisible || m.migrateOverlay == nil { + return nil + } + if !msg.Exists { + return nil + } + bulletID := strings.TrimSpace(msg.Bullet.ID) + if bulletID == "" { + return nil + } + label := strings.TrimSpace(msg.Bullet.Label) + if label == "" { + label = bulletID + } + status := "Kept " + label + return m.removeMigrationBullet(bulletID, status) +} + +func (m *Model) removeMigrationBullet(id, status string) tea.Cmd { + id = strings.TrimSpace(id) + if id == "" { + if status != "" && m.command != nil { + m.setStatus(status) + } + return nil + } + if !m.migrateVisible || m.migrateOverlay == nil { + if status != "" && m.command != nil { + m.setStatus(status) + } + return nil + } + m.migrateOverlay.RemoveBullet(id) + if m.migrateOverlay.IsEmpty() { + final := status + if strings.TrimSpace(final) == "" { + final = "Migration complete" + } + return m.closeMigrateOverlayWithStatus(final) + } + if status != "" && m.command != nil { + m.setStatus(status) + } + if cmd := m.migrateOverlay.FocusDetail(); cmd != nil { + return cmd + } + return nil +} + +func (m *Model) handleMigrationCollectionSelect(msg events.CollectionSelectMsg) tea.Cmd { + if m.migrateOverlay == nil { + return nil + } + if msg.Component == migrateTargetNavID && strings.EqualFold(strings.TrimSpace(msg.Collection.ID), migrationNewCollectionID) { + return m.startMigrationNewCollectionPrompt() + } + section, bulletRow, item, ok := m.migrateOverlay.CurrentMigrationSelection() + if !ok || item == nil || item.Candidate.Entry == nil { + return m.migrateOverlay.FocusDetail() + } + label := strings.TrimSpace(bulletRow.Label) + if label == "" { + label = strings.TrimSpace(item.Candidate.Entry.Message) + } + if label == "" { + label = bulletRow.ID + } + sectionRef := events.CollectionViewRef{ + ID: section.ID, + Title: section.Title, + Subtitle: section.Subtitle, + } + bulletRef := events.BulletRef{ + ID: bulletRow.ID, + Label: label, + Note: item.SectionID, + Bullet: bulletRow.Bullet, + Signifier: bulletRow.Signifier, + } + switch msg.Component { + case migrateFutureNavID: + return m.handleMigrationMoveFuture(events.BulletMoveFutureMsg{ + Component: migrateDetailID, + Collection: sectionRef, + Bullet: bulletRef, + }) + case migrateTargetNavID: + return m.handleMigrationMoveRequest(events.MoveBulletRequestMsg{ + Component: migrateDetailID, + Collection: sectionRef, + Bullet: bulletRef, + }) + default: + return m.migrateOverlay.FocusDetail() + } +} + +func (m *Model) startMigrationNewCollectionPrompt() tea.Cmd { + if m.migrateOverlay == nil { + return nil + } + if m.command != nil { + m.setStatus("Enter a name for the new collection") + } + return m.migrateOverlay.BeginNewCollectionPrompt() +} + +func (m *Model) handleMigrationCreateCollection(name string) tea.Cmd { + name = strings.TrimSpace(name) + if name == "" { + if m.command != nil { + m.setStatus("Collection name cannot be empty") + } + if m.migrateOverlay != nil { + return m.migrateOverlay.FocusDetail() + } + return nil + } + if m.service == nil { + if m.command != nil { + m.setStatus("Create failed: service offline") + } + if m.migrateOverlay != nil { + return m.migrateOverlay.FocusDetail() + } + return nil + } + ctx := context.Background() + if err := m.service.EnsureCollectionOfType(ctx, name, collection.TypeGeneric); err != nil { + if m.command != nil { + m.setStatus("Create failed: " + err.Error()) + } + if m.migrateOverlay != nil { + return m.migrateOverlay.FocusDetail() + } + return nil + } + cmds := []tea.Cmd{} + if cmd := m.collectionSyncCmd(name); cmd != nil { + cmds = append(cmds, cmd) + } + _, bulletRow, item, ok := m.migrateOverlay.CurrentMigrationSelection() + if !ok || item == nil || item.Candidate.Entry == nil { + if focus := m.migrateOverlay.FocusDetail(); focus != nil { + cmds = append(cmds, focus) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) + } + bulletID := strings.TrimSpace(item.Candidate.Entry.ID) + if bulletID == "" { + if focus := m.migrateOverlay.FocusDetail(); focus != nil { + cmds = append(cmds, focus) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) + } + clone, err := m.service.Move(ctx, bulletID, name) + if err != nil { + if m.command != nil { + m.setStatus("Move failed: " + err.Error()) + } + if focus := m.migrateOverlay.FocusDetail(); focus != nil { + cmds = append(cmds, focus) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) + } + label := strings.TrimSpace(bulletRow.Label) + if label == "" && clone != nil { + label = strings.TrimSpace(clone.Message) + } + if label == "" { + label = bulletID + } + status := "Moved " + label + " to " + name + if cmd := m.removeMigrationBullet(bulletID, status); cmd != nil { + cmds = append(cmds, cmd) + } + origin := strings.TrimSpace(item.Candidate.Entry.Collection) + if origin != "" && !strings.EqualFold(origin, name) { + if cmd := m.collectionSyncCmd(origin); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.command != nil { + m.setStatus("Created " + name + " · moved " + label) + } + if focus := m.migrateOverlay.FocusDetail(); focus != nil { + cmds = append(cmds, focus) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} diff --git a/pkg/tui/app/move_handlers.go b/pkg/tui/app/move_handlers.go new file mode 100644 index 0000000..6258a88 --- /dev/null +++ b/pkg/tui/app/move_handlers.go @@ -0,0 +1,536 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea/v2" + + "tableflip.dev/bujo/pkg/collection" + "tableflip.dev/bujo/pkg/tui/components/bulletdetail" + "tableflip.dev/bujo/pkg/tui/components/collectionnav" + "tableflip.dev/bujo/pkg/tui/components/command" + "tableflip.dev/bujo/pkg/tui/events" +) + +// moveOverlayConfig bundles dependencies for the move bullet overlay. +type moveOverlayConfig struct { + detail *bulletdetail.Model + nav *collectionnav.Model + bulletID string + collectionID string + label string + status string + initialRef events.CollectionRef + futureOnly bool + navOnRight bool +} + +func (m *Model) handleMoveBulletRequest(msg events.MoveBulletRequestMsg) tea.Cmd { + bulletID := strings.TrimSpace(msg.Bullet.ID) + if bulletID == "" { + if m.command != nil { + m.setStatus("Move unavailable: missing bullet ID") + } + return nil + } + if m.service == nil { + if m.command != nil { + m.setStatus("Move unavailable: service offline") + } + return nil + } + if m.journalCache == nil { + if m.command != nil { + m.setStatus("Move unavailable: journal cache offline") + } + return nil + } + collectionID := strings.TrimSpace(msg.Collection.ID) + if collectionID == "" { + collectionID = strings.TrimSpace(msg.Bullet.Note) + } + if collectionID == "" { + if m.command != nil { + m.setStatus("Move unavailable: missing collection context") + } + return nil + } + snapshot := m.journalCache.Snapshot() + if len(snapshot.Collections) == 0 || len(snapshot.Sections) == 0 { + if m.command != nil { + m.setStatus("Move unavailable: no collections") + } + return nil + } + if source := findBulletInSnapshot(snapshot.Sections, collectionID, bulletID); source != nil && source.Locked { + if m.command != nil { + m.setStatus("Move unavailable: bullet is locked") + } + return nil + } + trimmedCollections := filterMoveCollections(snapshot.Collections) + trimmedCollections = appendNewCollectionOption(trimmedCollections) + if len(trimmedCollections) == 0 { + if m.command != nil { + m.setStatus("Move unavailable: no target collections") + } + return nil + } + title := strings.TrimSpace(msg.Collection.Title) + if title == "" { + title = collectionID + } + detailModel := bulletdetail.New(title, msg.Bullet.Label, collectionID, msg.Bullet.Note) + + nav := collectionnav.NewModel(trimmedCollections) + nav.SetBlurOnSelect(false) + label := strings.TrimSpace(msg.Bullet.Label) + if label == "" { + label = bulletID + } + cfg := moveOverlayConfig{ + detail: detailModel, + nav: nav, + bulletID: bulletID, + collectionID: collectionID, + label: label, + status: "Choose destination for " + label, + initialRef: events.CollectionRef{ID: collectionID, Name: title}, + futureOnly: false, + navOnRight: true, + } + return m.openMoveOverlay(cfg) +} + +func (m *Model) openMoveOverlay(cfg moveOverlayConfig) tea.Cmd { + if cfg.bulletID == "" { + return nil + } + m.ensureOverlayStack() + var cmds []tea.Cmd + if m.helpVisible { + if cmd := m.closeHelpOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.reportVisible { + if cmd := m.closeReportOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.newCollectionVisible { + if cmd := m.closeNewCollectionOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.addVisible { + if cmd := m.closeAddTaskOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.detailVisible { + if cmd := m.closeBulletDetailOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.moveVisible { + if cmd := m.closeMoveOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if cfg.detail != nil { + cfg.detail.SetLoading(true) + } + if cfg.nav != nil { + cfg.nav.SetID(moveNavID) + } + mOverlay := newMovebulletOverlay(cfg.detail, cfg.nav, cfg.navOnRight, m.dump) + placement := command.OverlayPlacement{Fullscreen: true} + if cmd := m.overlayStack.Open(overlayKindMove, mOverlay, placement); cmd != nil { + cmds = append(cmds, cmd) + } + m.moveOverlay = mOverlay + m.moveVisible = true + m.moveBulletID = cfg.bulletID + m.moveCollectionID = cfg.collectionID + m.moveFutureOnly = cfg.futureOnly + reqID := fmt.Sprintf("%s@%d", cfg.bulletID, time.Now().UnixNano()) + m.moveLoadID = reqID + _ = m.dropFocusKind(focusKindCommand) + m.pushFocus(focusTarget{kind: focusKindOverlay, overlay: overlayKindMove}) + cmds = append(cmds, m.blurJournalPanes()...) + if focusCmd := m.overlayStack.Focus(); focusCmd != nil { + cmds = append(cmds, focusCmd) + } + if cfg.nav != nil { + if cfg.initialRef.ID != "" || cfg.initialRef.Name != "" { + if cmd := cfg.nav.SelectCollection(cfg.initialRef); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + if m.command != nil { + label := cfg.label + if label == "" { + label = cfg.bulletID + } + status := strings.TrimSpace(cfg.status) + if status == "" { + status = "Choose destination for " + label + } + m.setStatus(status) + } + if loadCmd := m.loadBulletDetail(cfg.collectionID, cfg.bulletID, reqID); loadCmd != nil { + cmds = append(cmds, loadCmd) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) handleBulletMoveFuture(msg events.BulletMoveFutureMsg) tea.Cmd { + if m.migrateVisible { + return nil + } + bulletID := strings.TrimSpace(msg.Bullet.ID) + if bulletID == "" { + if m.command != nil { + m.setStatus("Move unavailable: missing bullet ID") + } + return nil + } + if m.service == nil { + if m.command != nil { + m.setStatus("Move unavailable: service offline") + } + return nil + } + if m.journalCache == nil { + if m.command != nil { + m.setStatus("Move unavailable: journal cache offline") + } + return nil + } + collectionID := strings.TrimSpace(msg.Collection.ID) + if collectionID == "" { + collectionID = strings.TrimSpace(msg.Bullet.Note) + } + if collectionID == "" { + if m.command != nil { + m.setStatus("Move unavailable: missing collection context") + } + return nil + } + snapshot := m.journalCache.Snapshot() + if len(snapshot.Collections) == 0 || len(snapshot.Sections) == 0 { + if m.command != nil { + m.setStatus("Move unavailable: no collections") + } + return nil + } + if source := findBulletInSnapshot(snapshot.Sections, collectionID, bulletID); source != nil && source.Locked { + if m.command != nil { + m.setStatus("Move unavailable: bullet is locked") + } + return nil + } + ctx := context.Background() + now := m.today + if now.IsZero() { + now = m.now() + } + roots, err := m.futureMoveCollections(ctx, now) + if err != nil { + if m.command != nil { + m.setStatus("Move unavailable: " + err.Error()) + } + return nil + } + if len(roots) == 0 { + if m.command != nil { + m.setStatus("Move unavailable: Future view not ready") + } + return nil + } + title := strings.TrimSpace(msg.Collection.Title) + if title == "" { + title = collectionID + } + detailModel := bulletdetail.New(title, msg.Bullet.Label, collectionID, msg.Bullet.Note) + nav := collectionnav.NewModel(roots) + nav.SetBlurOnSelect(false) + label := strings.TrimSpace(msg.Bullet.Label) + if label == "" { + label = bulletID + } + initial := events.CollectionRef{ID: "Future", Name: "Future"} + if strings.HasPrefix(collectionID, "Future/") { + initial.ID = collectionID + name := strings.TrimPrefix(collectionID, "Future/") + if name == "" { + name = collectionID + } + initial.Name = name + } else if collectionID == "Future" { + initial.ID = collectionID + initial.Name = "Future" + } + cfg := moveOverlayConfig{ + detail: detailModel, + nav: nav, + bulletID: bulletID, + collectionID: collectionID, + label: label, + status: "Choose Future destination for " + label, + initialRef: initial, + futureOnly: true, + navOnRight: false, + } + return m.openMoveOverlay(cfg) +} + +func (m *Model) handleMoveSelection(msg events.CollectionSelectMsg) tea.Cmd { + if !m.moveVisible { + return nil + } + if strings.EqualFold(strings.TrimSpace(msg.Collection.ID), newCollectionOptionID) { + return m.startMoveNewCollectionPrompt() + } + target := resolvedCollectionPath(msg.Collection) + collectionLabel := strings.TrimSpace(msg.Collection.Label()) + if strings.EqualFold(strings.TrimSpace(target), newCollectionOptionID) || + strings.EqualFold(strings.TrimSpace(msg.Collection.Name), newCollectionOptionLabel) || + strings.EqualFold(collectionLabel, newCollectionOptionLabel) || + strings.Contains(strings.ToLower(target), newCollectionOptionID) { + return m.startMoveNewCollectionPrompt() + } + if target == "" { + return nil + } + bulletID := strings.TrimSpace(m.moveBulletID) + if bulletID == "" { + return m.closeMoveOverlayWithStatus("Move unavailable: no bullet selected") + } + if target == strings.TrimSpace(m.moveCollectionID) { + return m.closeMoveOverlayWithStatus("Bullet already in selected collection") + } + if m.service == nil { + if m.command != nil { + m.setStatus("Move failed: service offline") + } + return nil + } + ctx := context.Background() + if m.moveFutureOnly && !msg.Exists { + targetType := msg.Collection.Type + if strings.HasPrefix(target, "Future/") { + targetType = collection.TypeGeneric + } + if targetType == "" { + switch { + case strings.HasPrefix(target, "Future/"): + targetType = collection.TypeGeneric + case target == "Future": + targetType = collection.TypeMonthly + default: + targetType = collection.TypeGeneric + } + } + if err := m.service.EnsureCollectionOfType(ctx, target, targetType); err != nil { + if m.command != nil { + m.setStatus("Move failed: " + err.Error()) + } + return nil + } + } + clone, err := m.service.Move(ctx, bulletID, target) + if err != nil { + if m.command != nil { + m.setStatus("Move failed: " + err.Error()) + } + return nil + } + label := collectionLabel + if label == "" { + label = target + } + var cmds []tea.Cmd + if m.journalNav != nil { + ref := events.CollectionRef{ID: target} + if idx := strings.LastIndex(target, "/"); idx >= 0 { + ref.ParentID = target[:idx] + ref.Name = strings.TrimSpace(target[idx+1:]) + } else { + ref.Name = target + } + if cmd := m.journalNav.SelectCollection(ref); cmd != nil { + cmds = append(cmds, cmd) + } + } + if cache := m.journalCache; cache != nil { + if cmd := m.collectionSyncCmd(target); cmd != nil { + cmds = append(cmds, cmd) + } + origin := strings.TrimSpace(m.moveCollectionID) + if origin == "" { + origin = clone.Collection + } + if origin != "" { + if cmd := m.collectionSyncCmd(origin); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + if cmd := m.closeMoveOverlayWithStatus("Moved bullet to " + label); cmd != nil { + cmds = append(cmds, cmd) + } + if !msg.Exists { + if snap := m.snapshotSyncCmd(); snap != nil { + cmds = append(cmds, snap) + } + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) startMoveNewCollectionPrompt() tea.Cmd { + if m.moveOverlay == nil { + return nil + } + if m.command != nil { + m.setStatus("Enter a name for the new collection") + } + return m.moveOverlay.BeginNewCollectionPrompt() +} + +func (m *Model) handleMoveCreateCollection(name string) tea.Cmd { + name = strings.TrimSpace(name) + if name == "" { + if m.command != nil { + m.setStatus("Collection name cannot be empty") + } + if m.moveOverlay != nil { + return m.moveOverlay.BeginNewCollectionPrompt() + } + return nil + } + if m.service == nil { + if m.command != nil { + m.setStatus("Create failed: service offline") + } + if m.moveOverlay != nil { + return m.moveOverlay.FocusNav() + } + return nil + } + bulletID := strings.TrimSpace(m.moveBulletID) + if bulletID == "" { + return m.closeMoveOverlayWithStatus("Move unavailable: no bullet selected") + } + ctx := context.Background() + if err := m.service.EnsureCollectionOfType(ctx, name, collection.TypeGeneric); err != nil { + if m.command != nil { + m.setStatus("Create failed: " + err.Error()) + } + if m.moveOverlay != nil { + return m.moveOverlay.BeginNewCollectionPrompt() + } + return nil + } + clone, err := m.service.Move(ctx, bulletID, name) + if err != nil { + if m.command != nil { + m.setStatus("Move failed: " + err.Error()) + } + if m.moveOverlay != nil { + return m.moveOverlay.BeginNewCollectionPrompt() + } + return nil + } + + target := strings.TrimSpace(name) + if target == "" { + target = name + } + var cmds []tea.Cmd + if m.journalNav != nil { + ref := events.CollectionRef{ID: target} + if idx := strings.LastIndex(target, "/"); idx >= 0 { + ref.ParentID = strings.TrimSpace(target[:idx]) + ref.Name = strings.TrimSpace(target[idx+1:]) + } else { + ref.Name = target + } + if cmd := m.journalNav.SelectCollection(ref); cmd != nil { + cmds = append(cmds, cmd) + } + } + if cache := m.journalCache; cache != nil { + if cmd := m.collectionSyncCmd(target); cmd != nil { + cmds = append(cmds, cmd) + } + origin := strings.TrimSpace(m.moveCollectionID) + if origin == "" && clone != nil { + origin = strings.TrimSpace(clone.Collection) + } + if origin != "" && !strings.EqualFold(origin, target) { + if cmd := m.collectionSyncCmd(origin); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + status := "Created " + target + " · moved bullet" + if cmd := m.closeMoveOverlayWithStatus(status); cmd != nil { + cmds = append(cmds, cmd) + } + if snap := m.snapshotSyncCmd(); snap != nil { + cmds = append(cmds, snap) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) closeMoveOverlay() tea.Cmd { + return m.closeMoveOverlayWithStatus("") +} + +func (m *Model) closeMoveOverlayWithStatus(status string) tea.Cmd { + if !m.moveVisible { + if status != "" && m.command != nil { + m.setStatus(status) + } + return nil + } + var cmds []tea.Cmd + if m.overlayStack != nil { + if cmd := m.overlayStack.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + m.overlayStack.Close(overlayKindMove) + } + m.moveVisible = false + m.moveOverlay = nil + m.moveBulletID = "" + m.moveCollectionID = "" + m.moveFutureOnly = false + m.moveLoadID = "" + _, _ = m.popFocusKind(focusKindOverlay) + if cmd := m.restoreFocusAfterOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + if status != "" && m.command != nil { + m.setStatus(status) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} diff --git a/pkg/tui/app/move_helpers.go b/pkg/tui/app/move_helpers.go new file mode 100644 index 0000000..ec4eb55 --- /dev/null +++ b/pkg/tui/app/move_helpers.go @@ -0,0 +1,169 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" + + "tableflip.dev/bujo/pkg/collection" + viewmodel "tableflip.dev/bujo/pkg/collection/viewmodel" + "tableflip.dev/bujo/pkg/tui/events" +) + +func (m *Model) futureMoveCollections(ctx context.Context, now time.Time) ([]*viewmodel.ParsedCollection, error) { + if m.service == nil { + return nil, fmt.Errorf("service offline") + } + metas, err := m.service.CollectionsMeta(ctx, "") + if err != nil { + return nil, err + } + return futureCollectionsFromMetas(metas, now), nil +} + +func futureCollectionsFromMetas(metas []collection.Meta, now time.Time) []*viewmodel.ParsedCollection { + existing := make(map[string]collection.Meta, len(metas)) + for _, meta := range metas { + name := strings.TrimSpace(meta.Name) + if name == "" { + continue + } + if meta.Type == "" { + meta.Type = collection.TypeGeneric + } + existing[name] = meta + } + futureType := collection.TypeMonthly + if meta, ok := existing["Future"]; ok && meta.Type != "" { + futureType = meta.Type + } + treeMetas := []collection.Meta{{Name: "Future", Type: futureType}} + base := startOfMonth(now) + if base.IsZero() { + base = startOfMonth(time.Now()) + } + for i := 1; i <= 12; i++ { + monthTime := base.AddDate(0, i, 0) + monthName := monthTime.Format("January 2006") + full := fmt.Sprintf("Future/%s", monthName) + treeMetas = append(treeMetas, collection.Meta{Name: full, Type: collection.TypeGeneric}) + } + roots := viewmodel.BuildTree(treeMetas) + existingSet := make(map[string]collection.Meta, len(existing)) + for name, meta := range existing { + existingSet[name] = meta + } + var futureNode *viewmodel.ParsedCollection + stack := append([]*viewmodel.ParsedCollection(nil), roots...) + for len(stack) > 0 { + last := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if last == nil { + continue + } + if last.ID == "Future" { + futureNode = last + last.Type = collection.TypeMonthly + last.Exists = true + } else if last.ParentID == "Future" { + last.Type = collection.TypeGeneric + if _, ok := existingSet[last.ID]; ok { + last.Exists = true + } else { + last.Exists = false + } + } else { + last.Exists = false + } + if len(last.Children) > 0 { + stack = append(stack, last.Children...) + } + } + if futureNode != nil { + monthMap := make(map[string]*viewmodel.ParsedCollection, len(futureNode.Children)) + for _, child := range futureNode.Children { + if child == nil { + continue + } + monthMap[child.Name] = child + } + ordered := make([]*viewmodel.ParsedCollection, 0, len(monthMap)) + baseMonth := startOfMonth(now) + if baseMonth.IsZero() { + baseMonth = startOfMonth(time.Now()) + } + for i := 1; i <= 12; i++ { + slot := baseMonth.AddDate(0, i, 0) + name := slot.Format("January 2006") + if child, ok := monthMap[name]; ok { + child.Priority = i + child.SortKey = fmt.Sprintf("%02d-%s", i, strings.ToLower(name)) + ordered = append(ordered, child) + } + } + futureNode.Children = ordered + } + return roots +} + +func filterMoveCollections(collections []*viewmodel.ParsedCollection) []*viewmodel.ParsedCollection { + if len(collections) == 0 { + return nil + } + trimmed := make([]*viewmodel.ParsedCollection, 0, len(collections)) + for _, col := range collections { + if col == nil { + continue + } + if isFutureCollection(col.ID) { + continue + } + clone := cloneParsedCollection(col) + clone.Children = filterMoveCollections(clone.Children) + trimmed = append(trimmed, clone) + } + return trimmed +} + +func cloneParsedCollection(col *viewmodel.ParsedCollection) *viewmodel.ParsedCollection { + if col == nil { + return nil + } + clone := *col + if len(col.Children) > 0 { + clone.Children = make([]*viewmodel.ParsedCollection, len(col.Children)) + for i := range col.Children { + clone.Children[i] = cloneParsedCollection(col.Children[i]) + } + } + return &clone +} + +func isFutureCollection(id string) bool { + trimmed := strings.ToLower(strings.TrimSpace(id)) + if trimmed == "" { + return false + } + if trimmed == "future" { + return true + } + return strings.HasPrefix(trimmed, "future/") +} + +func resolvedCollectionPath(ref events.CollectionRef) string { + path := strings.TrimSpace(ref.ID) + if path != "" { + return path + } + name := strings.TrimSpace(ref.Name) + parent := strings.TrimSpace(ref.ParentID) + switch { + case parent != "" && name != "": + return strings.TrimSuffix(parent, "/") + "/" + name + case name != "": + return name + default: + return "" + } +} diff --git a/pkg/tui/app/move_overlay.go b/pkg/tui/app/move_overlay.go index 07e4db3..d61cde6 100644 --- a/pkg/tui/app/move_overlay.go +++ b/pkg/tui/app/move_overlay.go @@ -11,7 +11,6 @@ import ( "tableflip.dev/bujo/pkg/tui/components/bulletdetail" collectionnav "tableflip.dev/bujo/pkg/tui/components/collectionnav" - "tableflip.dev/bujo/pkg/tui/components/command" ) type movebulletOverlay struct { @@ -58,7 +57,7 @@ func (o *movebulletOverlay) Init() tea.Cmd { return o.nav.Focus() } -func (o *movebulletOverlay) Update(msg tea.Msg) (command.Overlay, tea.Cmd) { +func (o *movebulletOverlay) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if o.creatingNew { return o.updateCreateNewCollection(msg) } @@ -224,7 +223,7 @@ func (o *movebulletOverlay) Blur() tea.Cmd { return nil } -func (o *movebulletOverlay) updateCreateNewCollection(msg tea.Msg) (command.Overlay, tea.Cmd) { +func (o *movebulletOverlay) updateCreateNewCollection(msg tea.Msg) (tea.Model, tea.Cmd) { switch v := msg.(type) { case tea.KeyMsg: switch v.String() { diff --git a/pkg/tui/app/new_collection_handlers.go b/pkg/tui/app/new_collection_handlers.go new file mode 100644 index 0000000..7a2379c --- /dev/null +++ b/pkg/tui/app/new_collection_handlers.go @@ -0,0 +1,155 @@ +package app + +import ( + "context" + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + + "tableflip.dev/bujo/pkg/collection" + "tableflip.dev/bujo/pkg/tui/components/command" + journalcomponent "tableflip.dev/bujo/pkg/tui/components/journal" + "tableflip.dev/bujo/pkg/tui/events" +) + +func (m *Model) startNewCollectionPrompt() tea.Cmd { + m.ensureOverlayStack() + if m.newCollectionVisible { + return m.closeNewCollectionOverlay() + } + overlay := newNewCollectionOverlay(m.dump) + overlay.SetSize(m.width, maxInt(1, m.height-1)) + + var cmds []tea.Cmd + if m.helpVisible { + if cmd := m.closeHelpOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.reportVisible { + if cmd := m.closeReportOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.addVisible { + if cmd := m.closeAddTaskOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.detailVisible { + if cmd := m.closeBulletDetailOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.moveVisible { + if cmd := m.closeMoveOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.migrateVisible { + if cmd := m.closeMigrateOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + placement := command.OverlayPlacement{Fullscreen: true} + if cmd := m.overlayStack.Open(overlayKindNewCollection, overlay, placement); cmd != nil { + cmds = append(cmds, cmd) + } + m.newCollectionOverlay = overlay + m.newCollectionVisible = true + _ = m.dropFocusKind(focusKindCommand) + m.pushFocus(focusTarget{kind: focusKindOverlay, overlay: overlayKindNewCollection}) + cmds = append(cmds, m.blurJournalPanes()...) + if focus := m.overlayStack.Focus(); focus != nil { + cmds = append(cmds, focus) + } + if m.command != nil { + m.setStatus("Enter a name for the new collection") + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) handleNewCollectionCreate(name string) tea.Cmd { + name = strings.TrimSpace(name) + if name == "" { + if m.command != nil { + m.setStatus("Collection name cannot be empty") + } + return nil + } + if m.service == nil { + if m.command != nil { + m.setStatus("Create failed: service offline") + } + return m.closeNewCollectionOverlayWithStatus("Create failed: service offline") + } + ctx := context.Background() + if err := m.service.EnsureCollectionOfType(ctx, name, collection.TypeGeneric); err != nil { + if m.command != nil { + m.setStatus("Create failed: " + err.Error()) + } + return nil + } + + var cmds []tea.Cmd + if m.journalNav != nil { + ref := events.CollectionRef{ID: name, Name: name, Type: collection.TypeGeneric} + if cmd := m.journalNav.SelectCollection(ref); cmd != nil { + cmds = append(cmds, cmd) + } + } + if cache := m.journalCache; cache != nil { + if cmd := m.collectionSyncCmd(name); cmd != nil { + cmds = append(cmds, cmd) + } + } + if snap := m.snapshotSyncCmd(); snap != nil { + cmds = append(cmds, snap) + } + if cmd := m.closeNewCollectionOverlayWithStatus("Created " + name); cmd != nil { + cmds = append(cmds, cmd) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) closeNewCollectionOverlayWithStatus(status string) tea.Cmd { + if !m.newCollectionVisible { + if status != "" && m.command != nil { + m.setStatus(status) + } + return nil + } + var cmds []tea.Cmd + if m.overlayStack != nil { + if cmd := m.overlayStack.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + m.overlayStack.Close(overlayKindNewCollection) + } + m.newCollectionOverlay = nil + m.newCollectionVisible = false + _, _ = m.popFocusKind(focusKindOverlay) + if cmd := m.restoreFocusAfterOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + if status != "" && m.command != nil { + m.setStatus(status) + } + if cmd := m.journalFocusCmd(journalcomponent.FocusNav); cmd != nil { + cmds = append(cmds, cmd) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) closeNewCollectionOverlay() tea.Cmd { + return m.closeNewCollectionOverlayWithStatus("") +} diff --git a/pkg/tui/app/new_collection_overlay.go b/pkg/tui/app/new_collection_overlay.go index d2d5449..a26ac0d 100644 --- a/pkg/tui/app/new_collection_overlay.go +++ b/pkg/tui/app/new_collection_overlay.go @@ -8,8 +8,6 @@ import ( "github.com/charmbracelet/bubbles/v2/textinput" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" - - "tableflip.dev/bujo/pkg/tui/components/command" ) type newCollectionCreateMsg struct { @@ -44,7 +42,7 @@ func (o *newCollectionOverlay) Init() tea.Cmd { return o.input.Focus() } -func (o *newCollectionOverlay) Update(msg tea.Msg) (command.Overlay, tea.Cmd) { +func (o *newCollectionOverlay) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch v := msg.(type) { case tea.KeyMsg: switch v.String() { diff --git a/pkg/tui/app/overlay_guard.go b/pkg/tui/app/overlay_guard.go new file mode 100644 index 0000000..6769598 --- /dev/null +++ b/pkg/tui/app/overlay_guard.go @@ -0,0 +1,74 @@ +package app + +import ( + tea "github.com/charmbracelet/bubbletea/v2" +) + +// activeOverlayKind returns the highest-priority visible overlay kind. +func (m *Model) activeOverlayKind() overlayKind { + if m.overlayStack != nil { + if kind := m.overlayStack.ActiveKind(); kind != overlayKindNone { + return kind + } + } + switch { + case m.helpVisible: + return overlayKindHelp + case m.reportVisible: + return overlayKindReport + case m.addVisible: + return overlayKindAdd + case m.detailVisible: + return overlayKindBulletDetail + case m.moveVisible: + return overlayKindMove + case m.newCollectionVisible: + return overlayKindNewCollection + case m.migrateVisible: + return overlayKindMigrate + default: + return overlayKindNone + } +} + +// overlayBlocksJournal reports whether overlays should block journal input. +func (m *Model) overlayBlocksJournal() bool { + return m.activeOverlayKind() != overlayKindNone +} + +// overlayBlocksCommand reports whether overlays should pause command updates. +func (m *Model) overlayBlocksCommand() bool { + switch m.activeOverlayKind() { + case overlayKindAdd, overlayKindBulletDetail, overlayKindMove, overlayKindNewCollection, overlayKindMigrate: + return true + default: + return false + } +} + +// closeOverlay closes the requested overlay kind if visible. +func (m *Model) closeOverlay(kind overlayKind) tea.Cmd { + switch kind { + case overlayKindHelp: + return m.closeHelpOverlay() + case overlayKindReport: + return m.closeReportOverlay() + case overlayKindAdd: + return m.closeAddTaskOverlay() + case overlayKindBulletDetail: + return m.closeBulletDetailOverlay() + case overlayKindMove: + return m.closeMoveOverlay() + case overlayKindNewCollection: + return m.closeNewCollectionOverlay() + case overlayKindMigrate: + return m.closeMigrateOverlay() + default: + return nil + } +} + +// dismissActiveOverlay closes whichever overlay is currently visible. +func (m *Model) dismissActiveOverlay() tea.Cmd { + return m.closeOverlay(m.activeOverlayKind()) +} diff --git a/pkg/tui/app/overlay_guard_test.go b/pkg/tui/app/overlay_guard_test.go new file mode 100644 index 0000000..8909e4f --- /dev/null +++ b/pkg/tui/app/overlay_guard_test.go @@ -0,0 +1,54 @@ +package app + +import "testing" + +func TestActiveOverlayKindPriority(t *testing.T) { + m := NewWithOptions(Options{}) + m.helpVisible = true + m.reportVisible = true + m.addVisible = true + + if got := m.activeOverlayKind(); got != overlayKindHelp { + t.Fatalf("expected help to win priority, got %v", got) + } +} + +func TestOverlayBlocksCommand(t *testing.T) { + tests := []struct { + name string + setup func(*Model) + expected bool + }{ + { + name: "report", + setup: func(m *Model) { + m.reportVisible = true + }, + expected: false, + }, + { + name: "help", + setup: func(m *Model) { + m.helpVisible = true + }, + expected: false, + }, + { + name: "add", + setup: func(m *Model) { + m.addVisible = true + }, + expected: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := NewWithOptions(Options{}) + tc.setup(m) + if got := m.overlayBlocksCommand(); got != tc.expected { + t.Fatalf("expected %v, got %v", tc.expected, got) + } + }) + } +} diff --git a/pkg/tui/app/overlay_helpers.go b/pkg/tui/app/overlay_helpers.go new file mode 100644 index 0000000..01b5f11 --- /dev/null +++ b/pkg/tui/app/overlay_helpers.go @@ -0,0 +1,9 @@ +package app + +// ensureOverlayStack lazily initializes the overlay stack container. +func (m *Model) ensureOverlayStack() *overlayStack { + if m.overlayStack == nil { + m.overlayStack = newOverlayStack(m.width, maxInt(1, m.height-1)) + } + return m.overlayStack +} diff --git a/pkg/tui/app/overlay_stack.go b/pkg/tui/app/overlay_stack.go new file mode 100644 index 0000000..24824c4 --- /dev/null +++ b/pkg/tui/app/overlay_stack.go @@ -0,0 +1,120 @@ +package app + +import ( + tea "github.com/charmbracelet/bubbletea/v2" + + "tableflip.dev/bujo/pkg/tui/components/command" + overlaypane "tableflip.dev/bujo/pkg/tui/components/overlaypane" +) + +// overlayStack manages the active overlay and synchronizes it with the overlay pane renderer. +type overlayStack struct { + pane *overlaypane.Model + active overlayKind +} + +func newOverlayStack(width, height int) *overlayStack { + if width <= 0 { + width = 1 + } + if height <= 0 { + height = 1 + } + return &overlayStack{ + pane: overlaypane.New(width, height), + } +} + +func (s *overlayStack) ActiveKind() overlayKind { + return s.active +} + +func (s *overlayStack) HasOverlay() bool { + return s.pane != nil && s.pane.HasOverlay() +} + +func (s *overlayStack) SetSize(width, height int) { + if width <= 0 { + width = 1 + } + if height <= 0 { + height = 1 + } + if s.pane == nil { + s.pane = overlaypane.New(width, height) + return + } + s.pane.SetSize(width, height) +} + +func (s *overlayStack) SetBackground(view string, cursor *tea.Cursor) { + if s.pane == nil { + s.pane = overlaypane.New(1, 1) + } + s.pane.SetBackground(view, cursor) +} + +func (s *overlayStack) View() (string, *tea.Cursor) { + if s.pane == nil { + return "", nil + } + return s.pane.View() +} + +func (s *overlayStack) Open(kind overlayKind, overlay command.Overlay, placement command.OverlayPlacement) tea.Cmd { + if overlay == nil { + return nil + } + if s.pane == nil { + s.pane = overlaypane.New(1, 1) + } + s.active = kind + return s.pane.SetOverlay(overlay, placement) +} + +func (s *overlayStack) Close(kind overlayKind) { + if s.active != kind { + return + } + s.active = overlayKindNone + if s.pane != nil { + s.pane.ClearOverlay() + } +} + +func (s *overlayStack) Dismiss() { + if s.active == overlayKindNone { + return + } + s.active = overlayKindNone + if s.pane != nil { + s.pane.ClearOverlay() + } +} + +func (s *overlayStack) Update(msg tea.Msg) (overlayKind, tea.Cmd) { + if s.pane == nil { + return overlayKindNone, nil + } + cmd := s.pane.Update(msg) + if s.active != overlayKindNone && !s.pane.HasOverlay() { + closed := s.active + s.active = overlayKindNone + return closed, cmd + } + return overlayKindNone, cmd +} + +func (s *overlayStack) Focus() tea.Cmd { + if s.pane == nil || !s.pane.HasOverlay() { + return nil + } + return s.pane.Focus() +} + +func (s *overlayStack) Blur() tea.Cmd { + if s.pane == nil || !s.pane.HasOverlay() { + return nil + } + return s.pane.Blur() +} diff --git a/pkg/tui/app/overlay_stack_test.go b/pkg/tui/app/overlay_stack_test.go new file mode 100644 index 0000000..99e98ad --- /dev/null +++ b/pkg/tui/app/overlay_stack_test.go @@ -0,0 +1,56 @@ +package app + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + + "tableflip.dev/bujo/pkg/tui/components/command" +) + +type closingOverlay struct { + closed bool +} + +func (o *closingOverlay) Init() tea.Cmd { return nil } + +func (o *closingOverlay) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + o.closed = true + return nil, nil +} + +func (o *closingOverlay) View() (string, *tea.Cursor) { + return lipgloss.NewStyle().Render("closing"), nil +} + +func (o *closingOverlay) SetSize(width, height int) {} + +func TestOverlayStackUpdateClosesOverlay(t *testing.T) { + stack := newOverlayStack(20, 5) + overlay := &closingOverlay{} + + if cmd := stack.Open(overlayKindHelp, overlay, command.OverlayPlacement{Fullscreen: true}); cmd != nil { + _ = cmd() + } + if stack.ActiveKind() != overlayKindHelp { + t.Fatalf("expected active kind help, got %v", stack.ActiveKind()) + } + if !stack.HasOverlay() { + t.Fatalf("expected overlay to be active") + } + + closed, _ := stack.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if closed != overlayKindHelp { + t.Fatalf("expected closed kind help, got %v", closed) + } + if stack.ActiveKind() != overlayKindNone { + t.Fatalf("expected overlay stack to be cleared") + } + if stack.HasOverlay() { + t.Fatalf("expected overlay pane to be empty") + } + if !overlay.closed { + t.Fatalf("expected overlay update to run") + } +} diff --git a/pkg/tui/app/report_handlers.go b/pkg/tui/app/report_handlers.go new file mode 100644 index 0000000..d11ec66 --- /dev/null +++ b/pkg/tui/app/report_handlers.go @@ -0,0 +1,141 @@ +package app + +import ( + "math" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + + "tableflip.dev/bujo/pkg/timeutil" + "tableflip.dev/bujo/pkg/tui/components/command" +) + +func (m *Model) showReportOverlay(arg string) (tea.Cmd, string) { + if m.command == nil { + return nil, "noop" + } + m.ensureOverlayStack() + if m.service == nil { + m.setStatus("Report unavailable: service offline") + return nil, "error" + } + if m.reportVisible { + return m.closeReportOverlay(), "closed" + } + dur, label, err := m.parseReportWindow(arg) + if err != nil { + m.setStatus("Report: " + err.Error()) + return nil, "error" + } + var cmds []tea.Cmd + if m.helpVisible { + if cmd := m.closeHelpOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.addVisible { + if cmd := m.closeAddTaskOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.newCollectionVisible { + if cmd := m.closeNewCollectionOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + overlay := newReportOverlay(m.service, dur, label) + placement := m.reportPlacement() + width := placement.Width + if width <= 0 { + width = m.width + } + height := placement.Height + if height <= 0 { + height = maxInt(10, m.height-1) + } + m.report = overlay + overlay.SetSize(width, height) + cmd := m.overlayStack.Open(overlayKindReport, overlay, placement) + m.reportVisible = true + _ = m.dropFocusKind(focusKindCommand) + m.pushFocus(focusTarget{kind: focusKindOverlay, overlay: overlayKindReport}) + cmds = append(cmds, m.blurJournalPanes()...) + if cmd != nil { + cmds = append(cmds, cmd) + } + if focusCmd := m.overlayStack.Focus(); focusCmd != nil { + cmds = append(cmds, focusCmd) + } + if len(cmds) == 0 { + return nil, "opened" + } + return tea.Batch(cmds...), "opened" +} + +func (m *Model) reportPlacement() command.OverlayPlacement { + availableWidth := m.width + if availableWidth <= 0 { + availableWidth = 1 + } + width := int(math.Round(float64(availableWidth) * 0.9)) + if width <= 0 || width > availableWidth { + width = availableWidth + } + if width < 20 { + width = minInt(20, availableWidth) + } + availableHeight := m.height - 1 + if availableHeight <= 0 { + availableHeight = 1 + } + height := int(math.Round(float64(availableHeight) * 0.9)) + if height <= 0 || height > availableHeight { + height = availableHeight + } + if height < 5 { + height = minInt(availableHeight, 5) + } + return command.OverlayPlacement{ + Width: width, + Height: height, + Horizontal: lipgloss.Center, + Vertical: lipgloss.Top, + } +} + +func (m *Model) parseReportWindow(spec string) (time.Duration, string, error) { + trimmed := strings.TrimSpace(spec) + if trimmed == "" { + return timeutil.ParseWindow(timeutil.DefaultWindow) + } + normalized := strings.ReplaceAll(trimmed, " ", "") + return timeutil.ParseWindow(normalized) +} + +func (m *Model) closeReportOverlay() tea.Cmd { + if !m.reportVisible { + return nil + } + var cmds []tea.Cmd + if m.overlayStack != nil { + if cmd := m.overlayStack.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + m.overlayStack.Close(overlayKindReport) + } + m.reportVisible = false + m.report = nil + _, _ = m.popFocusKind(focusKindOverlay) + if cmd := m.restoreFocusAfterOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + if m.command != nil { + m.setStatusIfIdle("Report overlay closed") + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} diff --git a/pkg/tui/app/report_overlay.go b/pkg/tui/app/report_overlay.go index 34b66af..fd7f542 100644 --- a/pkg/tui/app/report_overlay.go +++ b/pkg/tui/app/report_overlay.go @@ -10,10 +10,9 @@ import ( "tableflip.dev/bujo/pkg/app" "tableflip.dev/bujo/pkg/timeutil" - "tableflip.dev/bujo/pkg/tui/components/command" ) -func newReportOverlay(service *app.Service, window time.Duration, label string) *reportOverlay { +func newReportOverlay(service JournalService, window time.Duration, label string) *reportOverlay { if window <= 0 { if dur, _, err := timeutil.ParseWindow(timeutil.DefaultWindow); err == nil { window = dur @@ -33,7 +32,7 @@ func newReportOverlay(service *app.Service, window time.Duration, label string) } type reportOverlay struct { - service *app.Service + service JournalService width int height int @@ -66,7 +65,7 @@ func (o *reportOverlay) load() tea.Cmd { } } -func (o *reportOverlay) Update(msg tea.Msg) (command.Overlay, tea.Cmd) { +func (o *reportOverlay) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch v := msg.(type) { case reportLoadedMsg: o.loading = false diff --git a/pkg/tui/app/router.go b/pkg/tui/app/router.go new file mode 100644 index 0000000..3a657e4 --- /dev/null +++ b/pkg/tui/app/router.go @@ -0,0 +1,74 @@ +package app + +import ( + tea "github.com/charmbracelet/bubbletea/v2" + + journalcomponent "tableflip.dev/bujo/pkg/tui/components/journal" +) + +type pageKind int + +const ( + pageKindJournal pageKind = iota +) + +type pageModel interface { + tea.Model + SetSize(width, height int) + View() (string, *tea.Cursor) +} + +// pageRouter tracks the active main view and routes updates to it. +type pageRouter struct { + active pageKind + journal *journalcomponent.Model +} + +func newPageRouter() *pageRouter { + return &pageRouter{ + active: pageKindJournal, + } +} + +func (r *pageRouter) SetJournal(journal *journalcomponent.Model) { + r.journal = journal +} + +func (r *pageRouter) Journal() *journalcomponent.Model { + return r.journal +} + +func (r *pageRouter) Init() tea.Cmd { return nil } + +func (r *pageRouter) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch r.active { + case pageKindJournal: + if r.journal == nil { + return r, nil + } + next, cmd := r.journal.Update(msg) + if jm, ok := next.(*journalcomponent.Model); ok { + r.journal = jm + } + return r, cmd + default: + return r, nil + } +} + +func (r *pageRouter) ActiveView(width, height int) (string, *tea.Cursor, bool) { + switch r.active { + case pageKindJournal: + if r.journal == nil { + return "", nil, false + } + if height < 1 { + height = 1 + } + r.journal.SetSize(width, height) + view, cursor := r.journal.View() + return view, cursor, true + default: + return "", nil, false + } +} diff --git a/pkg/tui/app/router_test.go b/pkg/tui/app/router_test.go new file mode 100644 index 0000000..927eeda --- /dev/null +++ b/pkg/tui/app/router_test.go @@ -0,0 +1,24 @@ +package app + +import ( + "testing" + + journalcomponent "tableflip.dev/bujo/pkg/tui/components/journal" +) + +func TestPageRouterActiveView(t *testing.T) { + router := newPageRouter() + if _, _, ok := router.ActiveView(40, 10); ok { + t.Fatalf("expected no active view when journal is unset") + } + + journal := journalcomponent.NewModel(nil, nil, nil) + router.SetJournal(journal) + view, _, ok := router.ActiveView(40, 10) + if !ok { + t.Fatalf("expected active view when journal is set") + } + if view == "" { + t.Fatalf("expected non-empty view") + } +} diff --git a/pkg/tui/app/service.go b/pkg/tui/app/service.go new file mode 100644 index 0000000..9d269cd --- /dev/null +++ b/pkg/tui/app/service.go @@ -0,0 +1,30 @@ +package app + +import ( + "context" + "time" + + "tableflip.dev/bujo/pkg/app" + "tableflip.dev/bujo/pkg/collection" + "tableflip.dev/bujo/pkg/entry" + "tableflip.dev/bujo/pkg/glyph" + "tableflip.dev/bujo/pkg/store" + cachepkg "tableflip.dev/bujo/pkg/tui/cache" +) + +// JournalService captures the service calls used by the TUI. +type JournalService interface { + cachepkg.Service + + Complete(ctx context.Context, id string) (*entry.Entry, error) + EnsureCollectionOfType(ctx context.Context, name string, typ collection.Type) error + Lock(ctx context.Context, id string) (*entry.Entry, error) + Move(ctx context.Context, id, target string) (*entry.Entry, error) + MigrationCandidates(ctx context.Context, since, until time.Time) ([]app.MigrationCandidate, error) + Report(ctx context.Context, since, until time.Time) (app.ReportResult, error) + SetSignifier(ctx context.Context, id string, signifier glyph.Signifier) (*entry.Entry, error) + Strike(ctx context.Context, id string) (*entry.Entry, error) + ToggleSignifier(ctx context.Context, id string, signifier glyph.Signifier) (*entry.Entry, error) + Unlock(ctx context.Context, id string) (*entry.Entry, error) + Watch(ctx context.Context) (<-chan store.Event, error) +} diff --git a/pkg/tui/app/status.go b/pkg/tui/app/status.go new file mode 100644 index 0000000..57e3047 --- /dev/null +++ b/pkg/tui/app/status.go @@ -0,0 +1,64 @@ +package app + +import ( + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea/v2" +) + +// setStatus updates the command bar and schedules an auto-clear for transient messages. +func (m *Model) setStatus(status string) { + if m.command == nil { + return + } + m.command.SetStatus(status) + m.statusText = status + m.statusSetEpoch = m.statusEpoch + m.statusClearActive = false + trim := strings.TrimSpace(status) + if trim == "" || strings.EqualFold(trim, "ready") { + m.statusClearPending = false + return + } + m.statusClearPending = true + m.statusClearToken++ +} + +// setStatusIfIdle updates the status only if nothing else has updated it in the current cycle. +func (m *Model) setStatusIfIdle(status string) { + if m.statusSetEpoch == m.statusEpoch { + return + } + m.setStatus(status) +} + +func (m *Model) scheduleStatusClear() tea.Cmd { + if !m.statusClearPending || m.statusClearActive || strings.TrimSpace(m.statusText) == "" { + return nil + } + m.statusClearPending = false + m.statusClearActive = true + token := m.statusClearToken + return tea.Tick(statusClearTimeout, func(time.Time) tea.Msg { + return statusClearMsg{token: token} + }) +} + +func (m *Model) clearStatus() { + m.statusClearActive = false + m.statusClearPending = false + m.statusText = "Ready" + if m.command != nil { + m.command.SetStatus("Ready") + } +} + +// postInteractionStatus schedules a deferred status clear after user input. +func (m *Model) postInteractionStatus(msg tea.Msg) tea.Cmd { + switch msg.(type) { + case tea.KeyMsg, tea.MouseMsg: + return m.scheduleStatusClear() + } + return nil +} diff --git a/pkg/tui/app/status_test.go b/pkg/tui/app/status_test.go new file mode 100644 index 0000000..b45a819 --- /dev/null +++ b/pkg/tui/app/status_test.go @@ -0,0 +1,20 @@ +package app + +import "testing" + +func TestSetStatusIfIdle(t *testing.T) { + m := NewWithOptions(Options{}) + + m.statusEpoch = 1 + m.setStatus("first") + m.setStatusIfIdle("second") + if m.statusText != "first" { + t.Fatalf("expected status to remain \"first\", got %q", m.statusText) + } + + m.statusEpoch = 2 + m.setStatusIfIdle("third") + if m.statusText != "third" { + t.Fatalf("expected status to update to \"third\", got %q", m.statusText) + } +} diff --git a/pkg/tui/app/watch.go b/pkg/tui/app/watch.go new file mode 100644 index 0000000..1adb442 --- /dev/null +++ b/pkg/tui/app/watch.go @@ -0,0 +1,145 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea/v2" + + "tableflip.dev/bujo/pkg/store" + cachepkg "tableflip.dev/bujo/pkg/tui/cache" + "tableflip.dev/bujo/pkg/tui/events" +) + +func (m *Model) loadJournalSnapshot() tea.Cmd { + if m.service == nil { + m.journalError = fmt.Errorf("service unavailable") + return nil + } + m.loadingJournal = true + svc := m.service + return func() tea.Msg { + snapshot, err := cachepkg.BuildSnapshotWithClock(context.Background(), svc, m.clock) + return journalLoadedMsg{snapshot: snapshot, err: err} + } +} + +func (m *Model) scheduleDayCheck() tea.Cmd { + return tea.Tick(dayCheckInterval, func(time.Time) tea.Msg { + return dayCheckMsg{} + }) +} + +func (m *Model) refreshToday(now time.Time) { + day := startOfDay(now) + if !m.today.IsZero() && m.today.Equal(day) { + return + } + m.today = day + if m.journalNav != nil { + m.journalNav.SetNow(now) + } + m.layoutContent() +} + +func cacheListenCmd(cache *cachepkg.Cache) tea.Cmd { + if cache == nil { + return nil + } + ch := cache.Events() + component := cache.ComponentID() + return func() tea.Msg { + msg, ok := <-ch + if !ok { + return nil + } + if msg == nil { + return nil + } + return events.ChildMsg{From: component, Msg: msg} + } +} + +func startWatchCmd(parent context.Context, svc JournalService) tea.Cmd { + if svc == nil || parent == nil { + return nil + } + return func() tea.Msg { + ctx, cancel := context.WithCancel(parent) + ch, err := svc.Watch(ctx) + if err != nil { + cancel() + return watchStartedMsg{err: err} + } + return watchStartedMsg{ch: ch, cancel: cancel} + } +} + +func (m *Model) waitForWatch() tea.Cmd { + if m.watchCh == nil { + return nil + } + ch := m.watchCh + return func() tea.Msg { + if ev, ok := <-ch; ok { + return watchEventMsg{event: ev} + } + return watchStoppedMsg{} + } +} + +func (m *Model) stopWatch() { + if m.watchCancel != nil { + m.watchCancel() + m.watchCancel = nil + } + m.watchCh = nil +} + +func (m *Model) handleWatchEvent(ev store.Event) tea.Cmd { + switch ev.Type { + case store.EventCollectionChanged: + if strings.HasPrefix(ev.Collection, "fromCollection:") { + return nil + } + return m.collectionSyncCmd(ev.Collection) + case store.EventCollectionsInvalidated: + return m.snapshotSyncCmd() + default: + if strings.HasPrefix(ev.Collection, "fromCollection:") { + return nil + } + return m.snapshotSyncCmd() + } +} + +func (m *Model) collectionSyncCmd(collectionID string) tea.Cmd { + cache := m.journalCache + if cache == nil { + return nil + } + return func() tea.Msg { + if err := cache.SyncCollection(context.Background(), collectionID); err != nil { + return watchErrorMsg{err: err} + } + return nil + } +} + +func (m *Model) snapshotSyncCmd() tea.Cmd { + cache := m.journalCache + svc := m.service + if cache == nil || svc == nil { + return nil + } + return func() tea.Msg { + snapshot, err := cachepkg.BuildSnapshotWithClock(context.Background(), svc, m.clock) + if err != nil { + return watchErrorMsg{err: err} + } + cache.ApplySnapshot(snapshot) + return nil + } +} diff --git a/pkg/tui/app/watch_test.go b/pkg/tui/app/watch_test.go new file mode 100644 index 0000000..fd148e6 --- /dev/null +++ b/pkg/tui/app/watch_test.go @@ -0,0 +1,33 @@ +package app + +import ( + "testing" + + "tableflip.dev/bujo/pkg/collection" + cachepkg "tableflip.dev/bujo/pkg/tui/cache" + "tableflip.dev/bujo/pkg/tui/events" +) + +func TestCacheListenCmdWrapsChildMsg(t *testing.T) { + cache := cachepkg.New(events.ComponentID("cache-test")) + cache.SetCollections([]collection.Meta{ + {Name: "daily/2026-01-23", Type: collection.TypeDaily}, + }) + + cmd := cacheListenCmd(cache) + if cmd == nil { + t.Fatal("expected cache listen cmd") + } + + msg := cmd() + child, ok := msg.(events.ChildMsg) + if !ok { + t.Fatalf("expected ChildMsg, got %T", msg) + } + if child.From != cache.ComponentID() { + t.Fatalf("expected from %q, got %q", cache.ComponentID(), child.From) + } + if child.Msg == nil { + t.Fatal("expected child message payload") + } +} diff --git a/pkg/tui/cache/cache.go b/pkg/tui/cache/cache.go index 55dba63..06f3801 100644 --- a/pkg/tui/cache/cache.go +++ b/pkg/tui/cache/cache.go @@ -9,9 +9,9 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" - "tableflip.dev/bujo/pkg/app" "tableflip.dev/bujo/pkg/collection" "tableflip.dev/bujo/pkg/collection/viewmodel" + "tableflip.dev/bujo/pkg/tui/clock" "tableflip.dev/bujo/pkg/tui/components/collectiondetail" "tableflip.dev/bujo/pkg/tui/events" ) @@ -26,7 +26,8 @@ type Snapshot struct { // Options configure cache construction. type Options struct { Component events.ComponentID - Service *app.Service + Service Service + Clock clock.Clock } // Cache maintains in-memory collections/entries and emits typed events on @@ -48,7 +49,8 @@ type Cache struct { eventCh chan tea.Msg - service *app.Service + service Service + clock clock.Clock } type sectionTemplate struct { @@ -70,21 +72,31 @@ func NewWithOptions(opts Options) *Cache { if component == "" { component = events.ComponentID("cache") } + clk := opts.Clock + if clk == nil { + clk = clock.RealClock{} + } return &Cache{ component: component, eventCh: make(chan tea.Msg, 64), entries: make(map[string][]collectiondetail.Bullet), service: opts.Service, + clock: clk, } } +// ComponentID returns the cache instance identifier used for emitted events. +func (c *Cache) ComponentID() events.ComponentID { + return c.component +} + // Events exposes the cache event channel for Bubble Tea subscriptions. func (c *Cache) Events() <-chan tea.Msg { return c.eventCh } -// SetService wires the cache to an app.Service for write-through persistence. -func (c *Cache) SetService(svc *app.Service) { +// SetService wires the cache to a persistence-backed Service for writes. +func (c *Cache) SetService(svc Service) { c.mu.Lock() defer c.mu.Unlock() c.service = svc @@ -97,7 +109,7 @@ func (c *Cache) SetCollections(metas []collection.Meta) { c.mu.Lock() defer c.mu.Unlock() c.metas = normalizeMetas(metas) - c.collections = viewmodel.BuildTree(c.metas, viewmodel.WithNow(time.Now())) + c.collections = viewmodel.BuildTree(c.metas, viewmodel.WithNow(c.now())) c.emitOrderLocked() } @@ -175,7 +187,7 @@ func (c *Cache) CreateCollection(meta collection.Meta) []*viewmodel.ParsedCollec } else { c.metas = append(c.metas, meta) } - c.collections = viewmodel.BuildTree(c.metas, viewmodel.WithNow(time.Now())) + c.collections = viewmodel.BuildTree(c.metas, viewmodel.WithNow(c.now())) c.registerTemplate(collectiondetail.Section{ID: meta.Name, Title: leafName(meta.Name)}) c.ensureSection(meta.Name) c.emit(events.CollectionChangeMsg{ @@ -198,7 +210,7 @@ func (c *Cache) UpdateCollection(current collection.Meta, previous *collection.M prevName = prev.Name } c.upsertMeta(curr, prevName) - c.collections = viewmodel.BuildTree(c.metas, viewmodel.WithNow(time.Now())) + c.collections = viewmodel.BuildTree(c.metas, viewmodel.WithNow(c.now())) c.emit(events.CollectionChangeMsg{ Component: c.component, Action: events.ChangeUpdate, @@ -223,7 +235,7 @@ func (c *Cache) DeleteCollection(name string) []*viewmodel.ParsedCollection { return c.collections } c.removeMeta(name) - c.collections = viewmodel.BuildTree(c.metas, viewmodel.WithNow(time.Now())) + c.collections = viewmodel.BuildTree(c.metas, viewmodel.WithNow(c.now())) c.removeSectionsByPrefix(name) c.emit(events.CollectionChangeMsg{ Component: c.component, @@ -380,6 +392,13 @@ func (c *Cache) upsertMeta(meta collection.Meta, prev string) { c.metas = append(c.metas, meta) } +func (c *Cache) now() time.Time { + if c.clock != nil { + return c.clock.Now() + } + return time.Now() +} + func (c *Cache) removeMeta(name string) { if len(c.metas) == 0 { return @@ -491,7 +510,7 @@ func (c *Cache) emit(msg tea.Msg) { } } -func (c *Cache) currentService() *app.Service { +func (c *Cache) currentService() Service { c.mu.RLock() defer c.mu.RUnlock() return c.service @@ -537,107 +556,6 @@ func findMetaIndex(list []collection.Meta, name string) int { return -1 } -func cloneParsed(nodes []*viewmodel.ParsedCollection) []*viewmodel.ParsedCollection { - if len(nodes) == 0 { - return nil - } - out := make([]*viewmodel.ParsedCollection, len(nodes)) - for i, node := range nodes { - if node == nil { - continue - } - cloned := *node - cloned.Children = cloneParsed(node.Children) - out[i] = &cloned - } - return out -} - -func cloneSections(sections []collectiondetail.Section) []collectiondetail.Section { - if len(sections) == 0 { - return nil - } - out := make([]collectiondetail.Section, len(sections)) - for i := range sections { - out[i] = sections[i] - out[i].Bullets = cloneBullets(sections[i].Bullets) - } - return out -} - -func cloneMetas(metas []collection.Meta) []collection.Meta { - if len(metas) == 0 { - return nil - } - out := make([]collection.Meta, len(metas)) - copy(out, metas) - return out -} - -func cloneBullets(list []collectiondetail.Bullet) []collectiondetail.Bullet { - if len(list) == 0 { - return nil - } - out := make([]collectiondetail.Bullet, len(list)) - for i := range list { - out[i] = list[i] - out[i].Children = cloneBullets(list[i].Children) - } - return out -} - -func leafName(name string) string { - if name == "" { - return "" - } - if idx := strings.LastIndex(name, "/"); idx >= 0 { - return name[idx+1:] - } - return name -} - -func updateBulletByID(list *[]collectiondetail.Bullet, updated collectiondetail.Bullet) bool { - if list == nil || updated.ID == "" { - return false - } - items := *list - for i := range items { - if items[i].ID == updated.ID { - items[i] = mergeDetailBullet(items[i], updated) - return true - } - if len(items[i].Children) > 0 { - if updateBulletByID(&items[i].Children, updated) { - return true - } - } - } - return false -} - -func mergeDetailBullet(existing, updated collectiondetail.Bullet) collectiondetail.Bullet { - if updated.Label != "" { - existing.Label = updated.Label - } - if updated.Note != "" { - existing.Note = updated.Note - } - if updated.Bullet != "" { - existing.Bullet = updated.Bullet - } - if updated.Signifier != "" { - existing.Signifier = updated.Signifier - } - if !updated.Created.IsZero() { - existing.Created = updated.Created - } - existing.Locked = updated.Locked - if len(updated.Children) > 0 { - existing.Children = cloneBullets(updated.Children) - } - return existing -} - func removeBulletByID(list *[]collectiondetail.Bullet, id string) bool { if list == nil || id == "" { return false diff --git a/pkg/tui/cache/helpers.go b/pkg/tui/cache/helpers.go new file mode 100644 index 0000000..d5aed88 --- /dev/null +++ b/pkg/tui/cache/helpers.go @@ -0,0 +1,122 @@ +package cache + +import ( + "strings" + + "tableflip.dev/bujo/pkg/collection" + "tableflip.dev/bujo/pkg/collection/viewmodel" + "tableflip.dev/bujo/pkg/tui/components/collectiondetail" + "tableflip.dev/bujo/pkg/tui/uiutil" +) + +// cloneParsed deep-copies a parsed collection tree for safe read-only snapshots. +func cloneParsed(list []*viewmodel.ParsedCollection) []*viewmodel.ParsedCollection { + if len(list) == 0 { + return nil + } + out := make([]*viewmodel.ParsedCollection, 0, len(list)) + for _, item := range list { + if item == nil { + out = append(out, nil) + continue + } + cloned := *item + if len(item.Days) > 0 { + cloned.Days = append([]viewmodel.DaySummary(nil), item.Days...) + } + cloned.Children = cloneParsed(item.Children) + out = append(out, &cloned) + } + return out +} + +// cloneSections deep-copies detail sections, including bullets. +func cloneSections(list []collectiondetail.Section) []collectiondetail.Section { + if len(list) == 0 { + return nil + } + out := make([]collectiondetail.Section, len(list)) + for i, sec := range list { + out[i] = sec + out[i].Bullets = cloneBullets(sec.Bullets) + } + return out +} + +func cloneMetas(list []collection.Meta) []collection.Meta { + if len(list) == 0 { + return nil + } + out := make([]collection.Meta, len(list)) + copy(out, list) + return out +} + +func cloneBullets(list []collectiondetail.Bullet) []collectiondetail.Bullet { + if len(list) == 0 { + return nil + } + out := make([]collectiondetail.Bullet, len(list)) + for i, bullet := range list { + out[i] = bullet + out[i].Children = cloneBullets(bullet.Children) + } + return out +} + +func leafName(path string) string { + return uiutil.LastSegment(path) +} + +func updateBulletByID(list *[]collectiondetail.Bullet, bullet collectiondetail.Bullet) bool { + if list == nil || bullet.ID == "" { + return false + } + items := *list + for i := range items { + if items[i].ID == bullet.ID { + items[i] = mergeDetailBullet(items[i], bullet) + *list = items + return true + } + if len(items[i].Children) > 0 { + if updateBulletByID(&items[i].Children, bullet) { + *list = items + return true + } + } + } + return false +} + +// mergeDetailBullet overlays non-empty fields from updated onto base, preserving children. +func mergeDetailBullet(base, updated collectiondetail.Bullet) collectiondetail.Bullet { + merged := base + if strings.TrimSpace(updated.ID) != "" { + merged.ID = updated.ID + } + if updated.Label != "" { + merged.Label = updated.Label + } + if updated.Note != "" { + merged.Note = updated.Note + } + if updated.Bullet != "" { + merged.Bullet = updated.Bullet + } + if updated.Signifier != "" { + merged.Signifier = updated.Signifier + } + if !updated.Created.IsZero() { + merged.Created = updated.Created + } + merged.Locked = updated.Locked + if len(updated.Children) > 0 { + if len(merged.Children) == 0 { + merged.Children = cloneBullets(updated.Children) + } else { + merged.Children = dedupeBullets(append(cloneBullets(merged.Children), updated.Children...)) + } + } + return merged +} diff --git a/pkg/tui/cache/service.go b/pkg/tui/cache/service.go new file mode 100644 index 0000000..82318d8 --- /dev/null +++ b/pkg/tui/cache/service.go @@ -0,0 +1,17 @@ +package cache + +import ( + "context" + + "tableflip.dev/bujo/pkg/collection" + "tableflip.dev/bujo/pkg/entry" + "tableflip.dev/bujo/pkg/glyph" +) + +// Service describes the journal data access needed by the cache. +type Service interface { + CollectionsMeta(ctx context.Context, prefix string) ([]collection.Meta, error) + Entries(ctx context.Context, collectionID string) ([]*entry.Entry, error) + Add(ctx context.Context, collection string, bullet glyph.Bullet, msg string, sig glyph.Signifier) (*entry.Entry, error) + SetParent(ctx context.Context, id, parentID string) (*entry.Entry, error) +} diff --git a/pkg/tui/cache/sync.go b/pkg/tui/cache/sync.go index 828905d..c2629bf 100644 --- a/pkg/tui/cache/sync.go +++ b/pkg/tui/cache/sync.go @@ -8,10 +8,10 @@ import ( "strings" "time" - "tableflip.dev/bujo/pkg/app" "tableflip.dev/bujo/pkg/collection" "tableflip.dev/bujo/pkg/collection/viewmodel" "tableflip.dev/bujo/pkg/entry" + "tableflip.dev/bujo/pkg/tui/clock" "tableflip.dev/bujo/pkg/tui/components/collectiondetail" "tableflip.dev/bujo/pkg/tui/events" "tableflip.dev/bujo/pkg/tui/uiutil" @@ -19,7 +19,12 @@ import ( // BuildSnapshot loads collection metadata and entry sections from the supplied // service, assembling a cache snapshot that mirrors on-disk state. -func BuildSnapshot(ctx context.Context, svc *app.Service) (Snapshot, error) { +func BuildSnapshot(ctx context.Context, svc Service) (Snapshot, error) { + return BuildSnapshotWithClock(ctx, svc, clock.RealClock{}) +} + +// BuildSnapshotWithClock loads metadata and entries using the provided clock. +func BuildSnapshotWithClock(ctx context.Context, svc Service, clk clock.Clock) (Snapshot, error) { if svc == nil { return Snapshot{}, errors.New("cache: service unavailable") } @@ -27,7 +32,11 @@ func BuildSnapshot(ctx context.Context, svc *app.Service) (Snapshot, error) { if err != nil { return Snapshot{}, fmt.Errorf("load collection metadata: %w", err) } - parsed := viewmodel.BuildTree(metas, viewmodel.WithNow(time.Now())) + now := time.Now() + if clk != nil { + now = clk.Now() + } + parsed := viewmodel.BuildTree(metas, viewmodel.WithNow(now)) sections := make([]collectiondetail.Section, 0, len(metas)) for _, meta := range metas { collectionID := strings.TrimSpace(meta.Name) @@ -100,7 +109,7 @@ func (c *Cache) SyncCollection(ctx context.Context, collectionID string) error { func (c *Cache) applySnapshotLocked(snapshot Snapshot) { normalizedMetas := normalizeMetas(snapshot.Metas) - newCollections := viewmodel.BuildTree(normalizedMetas, viewmodel.WithNow(time.Now())) + newCollections := viewmodel.BuildTree(normalizedMetas, viewmodel.WithNow(c.now())) newSections := cloneSections(snapshot.Sections) oldMetas := cloneMetas(c.metas) @@ -309,7 +318,7 @@ func (c *Cache) emitBulletChange(action events.ChangeType, sec collectiondetail. }) } -func (c *Cache) createBulletPersisted(ctx context.Context, svc *app.Service, collectionID string, bullet collectiondetail.Bullet, meta map[string]string) error { +func (c *Cache) createBulletPersisted(ctx context.Context, svc Service, collectionID string, bullet collectiondetail.Bullet, meta map[string]string) error { if ctx == nil { ctx = context.Background() } @@ -497,7 +506,7 @@ func sortSectionsLikeCollections(sections []collectiondetail.Section, parsed []* if len(sections) == 0 || len(parsed) == 0 { return sections } - order := flattenCollectionOrder(parsed) + order := viewmodel.FlattenOrder(parsed) if len(order) == 0 { return sections } @@ -534,24 +543,6 @@ func sortSectionsLikeCollections(sections []collectiondetail.Section, parsed []* return sorted } -func flattenCollectionOrder(parsed []*viewmodel.ParsedCollection) []string { - order := make([]string, 0, len(parsed)) - var walk func(list []*viewmodel.ParsedCollection) - walk = func(list []*viewmodel.ParsedCollection) { - for _, node := range list { - if node == nil { - continue - } - order = append(order, node.ID) - if len(node.Children) > 0 { - walk(node.Children) - } - } - } - walk(parsed) - return order -} - type bulletState struct { bullet collectiondetail.Bullet parent string diff --git a/pkg/tui/clock/clock.go b/pkg/tui/clock/clock.go new file mode 100644 index 0000000..1ce2a65 --- /dev/null +++ b/pkg/tui/clock/clock.go @@ -0,0 +1,20 @@ +package clock + +import "time" + +// Clock supplies time for UI ordering and tests. +type Clock interface { + Now() time.Time +} + +// RealClock uses the system clock. +type RealClock struct{} + +func (RealClock) Now() time.Time { return time.Now() } + +// FixedClock returns a stable time for deterministic tests. +type FixedClock struct { + Fixed time.Time +} + +func (c FixedClock) Now() time.Time { return c.Fixed } diff --git a/pkg/tui/components/addtask/cache.go b/pkg/tui/components/addtask/cache.go new file mode 100644 index 0000000..3c6de98 --- /dev/null +++ b/pkg/tui/components/addtask/cache.go @@ -0,0 +1,13 @@ +package addtask + +import ( + "tableflip.dev/bujo/pkg/collection" + "tableflip.dev/bujo/pkg/tui/components/collectiondetail" +) + +// Cache describes the minimal cache API needed by the add-task overlay. +type Cache interface { + CollectionsMeta() []collection.Meta + SectionSnapshot(id string) (collectiondetail.Section, bool) + CreateBulletWithMeta(collectionID string, bullet collectiondetail.Bullet, meta map[string]string) error +} diff --git a/pkg/tui/components/addtask/data.go b/pkg/tui/components/addtask/data.go new file mode 100644 index 0000000..5ad8b6c --- /dev/null +++ b/pkg/tui/components/addtask/data.go @@ -0,0 +1,151 @@ +package addtask + +import ( + "strings" + + "tableflip.dev/bujo/pkg/collection" +) + +func (m *Model) resolveTargetCollection() string { + if len(m.collectionOptions) == 0 { + return "" + } + if m.collectionIndex >= len(m.collectionOptions) { + m.collectionIndex = len(m.collectionOptions) - 1 + } + if m.collectionIndex < 0 { + m.collectionIndex = 0 + } + meta := m.collectionOptions[m.collectionIndex].Meta + return meta.Name +} + +func (m *Model) refreshCollections() { + metas := m.cache.CollectionsMeta() + currentID := "" + if len(m.collectionOptions) > 0 && m.collectionIndex >= 0 && m.collectionIndex < len(m.collectionOptions) { + currentID = m.collectionOptions[m.collectionIndex].ID + } + opts := make([]collectionOption, 0, len(metas)+1) + placeholderID := strings.TrimSpace(m.initialCollectionID) + placeholderLabel := strings.TrimSpace(m.initialCollectionLabel) + if placeholderLabel == "" && placeholderID != "" { + placeholderLabel = collectionLabel(placeholderID) + } + + for _, meta := range metas { + option := collectionOption{ + ID: meta.Name, + Label: collectionLabel(meta.Name), + Meta: meta, + } + if strings.EqualFold(option.ID, placeholderID) { + // Prefer the real metadata label when it exists. + if placeholderLabel != "" { + option.Label = placeholderLabel + } + } + opts = append(opts, option) + } + + if placeholderID != "" { + found := false + for _, opt := range opts { + if strings.EqualFold(opt.ID, placeholderID) { + found = true + break + } + } + if !found { + opts = append(opts, collectionOption{ + ID: placeholderID, + Label: placeholderLabel, + Meta: collection.Meta{ + Name: placeholderID, + }, + }) + if currentID == "" { + currentID = placeholderID + } + } + } + + m.collectionOptions = opts + if len(m.collectionOptions) == 0 { + m.collectionIndex = 0 + m.updatePrompt() + return + } + selected := false + if currentID != "" { + selected = m.selectCollectionByID(currentID) + } + if !selected && placeholderID != "" { + selected = m.selectCollectionByID(placeholderID) + } + if !selected { + m.collectionIndex = clampIndex(m.collectionIndex, len(m.collectionOptions)) + } else if m.collectionIndex >= len(m.collectionOptions) { + m.collectionIndex = len(m.collectionOptions) - 1 + } + m.updatePrompt() +} + +func (m *Model) refreshParentOptions() { + target := m.resolveTargetCollection() + if target == "" { + m.parentOptions = []parentOption{{ID: "", Label: "(none)"}} + m.parentIndex = 0 + m.updatePrompt() + return + } + section, ok := m.cache.SectionSnapshot(target) + if !ok { + m.parentOptions = []parentOption{{ID: "", Label: "(none)"}} + m.parentIndex = 0 + m.updatePrompt() + return + } + opts := []parentOption{{ID: "", Label: "(none)"}} + for _, bullet := range section.Bullets { + label := bullet.Label + if strings.TrimSpace(label) == "" { + label = bullet.ID + } + opts = append(opts, parentOption{ + ID: bullet.ID, + Label: label, + }) + } + m.parentOptions = opts + m.parentIndex = clampIndex(m.parentIndex, len(m.parentOptions)) + m.updatePrompt() +} + +func (m *Model) selectCollectionByID(id string) bool { + id = strings.TrimSpace(id) + if id == "" { + return false + } + for idx, opt := range m.collectionOptions { + if strings.EqualFold(opt.ID, id) { + m.collectionIndex = idx + return true + } + } + return false +} + +func (m *Model) selectParentByID(id string) { + id = strings.TrimSpace(id) + if id == "" { + m.parentIndex = 0 + return + } + for idx, opt := range m.parentOptions { + if strings.EqualFold(opt.ID, id) { + m.parentIndex = idx + return + } + } +} diff --git a/pkg/tui/components/addtask/input.go b/pkg/tui/components/addtask/input.go new file mode 100644 index 0000000..f33257d --- /dev/null +++ b/pkg/tui/components/addtask/input.go @@ -0,0 +1,199 @@ +package addtask + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + + "tableflip.dev/bujo/pkg/tui/components/collectiondetail" + "tableflip.dev/bujo/pkg/tui/events" +) + +const cacheParentMetaKey = "parent_id" + +func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { + if m.confirmReset { + switch { + case key.Matches(msg, m.keys.ConfirmYes): + m.resetForm() + m.confirmReset = false + return m.blurCmd() + case key.Matches(msg, m.keys.ConfirmNo): + m.confirmReset = false + } + return nil + } + + if !m.focused { + return nil + } + + var cmds []tea.Cmd + + switch { + case key.Matches(msg, m.keys.NextField): + m.advanceFocus(1) + case key.Matches(msg, m.keys.PrevField): + m.advanceFocus(-1) + case key.Matches(msg, m.keys.MoveUp): + m.adjustSelection(-1) + case key.Matches(msg, m.keys.MoveDown): + m.adjustSelection(1) + case key.Matches(msg, m.keys.MoveLeft): + m.adjustSelection(-1) + case key.Matches(msg, m.keys.MoveRight): + m.adjustSelection(1) + case key.Matches(msg, m.keys.Submit): + if cmd, err := m.submit(); err != nil { + m.errorMsg = err.Error() + } else if cmd != nil { + cmds = appendCmd(cmds, cmd) + } + case key.Matches(msg, m.keys.Cancel): + m.confirmReset = true + } + cmds = appendCmd(cmds, m.updateInputFocus()) + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func appendCmd(cmds []tea.Cmd, cmd tea.Cmd) []tea.Cmd { + if cmd == nil { + return cmds + } + return append(cmds, cmd) +} + +func (m *Model) updateInputFocus() tea.Cmd { + if !m.focused { + m.taskInput.Blur() + return nil + } + if m.focus == fieldTaskInput { + return m.taskInput.Focus() + } + m.taskInput.Blur() + return nil +} + +func (m *Model) advanceFocus(delta int) { + seq := m.focusSequence() + if len(seq) == 0 { + return + } + current := 0 + for i, f := range seq { + if f == m.focus { + current = i + break + } + } + current = (current + len(seq) + delta) % len(seq) + m.focus = seq[current] + m.updateInputFocus() +} + +func (m *Model) focusSequence() []focusField { + return []focusField{ + fieldParentBullet, + fieldBulletType, + fieldSignifier, + fieldTaskInput, + } +} + +func (m *Model) adjustSelection(delta int) { + switch m.focus { + case fieldParentBullet: + if len(m.parentOptions) == 0 { + return + } + m.parentIndex = clampIndex(m.parentIndex+delta, len(m.parentOptions)) + case fieldBulletType: + if len(m.bulletOptions) == 0 { + return + } + m.bulletIndex = clampIndex(m.bulletIndex+delta, len(m.bulletOptions)) + m.updatePrompt() + case fieldSignifier: + if len(m.signifierOptions) == 0 { + return + } + m.signifierIndex = clampIndex(m.signifierIndex+delta, len(m.signifierOptions)) + m.updatePrompt() + } +} + +func (m *Model) submit() (tea.Cmd, error) { + label := strings.TrimSpace(m.taskInput.Value()) + if label == "" { + return nil, fmt.Errorf("task description is required") + } + targetID := m.resolveTargetCollection() + if targetID == "" { + return nil, fmt.Errorf("select a collection first") + } + parentID := "" + if m.parentIndex > 0 && m.parentIndex < len(m.parentOptions) { + parentID = m.parentOptions[m.parentIndex].ID + } + bulletID := fmt.Sprintf("tmp-%d", time.Now().UnixNano()) + bullet := collectiondetail.Bullet{ + ID: bulletID, + Label: label, + Bullet: m.bulletOptions[m.bulletIndex], + Signifier: m.signifierOptions[m.signifierIndex], + Created: time.Now(), + } + + metaMap := map[string]string{} + if parentID != "" { + metaMap[cacheParentMetaKey] = parentID + } + if len(metaMap) == 0 { + metaMap = nil + } + + if err := m.cache.CreateBulletWithMeta(targetID, bullet, metaMap); err != nil { + return nil, err + } + m.resetForm() + m.lastSubmitted = time.Now() + return m.blurCmd(), nil +} + +func (m *Model) resetForm() { + m.taskInput.SetValue("") + m.parentIndex = 0 + m.errorMsg = "" + m.confirmReset = false + m.refreshParentOptions() +} + +func (m *Model) updatePrompt() { + bullet := m.bulletOptions[m.bulletIndex] + signifier := m.signifierOptions[m.signifierIndex] + prompt := describePrompt(bullet, signifier, m.currentParentLabel()) + m.taskInput.Placeholder = prompt +} + +func (m *Model) currentParentLabel() string { + if m.parentIndex > 0 && m.parentIndex < len(m.parentOptions) { + return m.parentOptions[m.parentIndex].Label + } + return "" +} + +func (m *Model) blurCmd() tea.Cmd { + if !m.focused { + return nil + } + m.focused = false + m.updateInputFocus() + return events.BlurCmd(m.id) +} diff --git a/pkg/tui/components/addtask/keymap.go b/pkg/tui/components/addtask/keymap.go new file mode 100644 index 0000000..94bc728 --- /dev/null +++ b/pkg/tui/components/addtask/keymap.go @@ -0,0 +1,36 @@ +package addtask + +import "github.com/charmbracelet/bubbles/v2/key" + +// keyMap captures the interactive bindings for the add-task overlay. +type keyMap struct { + NextField key.Binding + PrevField key.Binding + MoveUp key.Binding + MoveDown key.Binding + MoveLeft key.Binding + MoveRight key.Binding + Submit key.Binding + Cancel key.Binding + ConfirmYes key.Binding + ConfirmNo key.Binding +} + +func defaultKeyMap() keyMap { + return keyMap{ + NextField: key.NewBinding(key.WithKeys("tab")), + PrevField: key.NewBinding(key.WithKeys("shift+tab")), + MoveUp: key.NewBinding(key.WithKeys("up", "k")), + MoveDown: key.NewBinding(key.WithKeys("down", "j")), + MoveLeft: key.NewBinding(key.WithKeys("left", "h")), + MoveRight: key.NewBinding(key.WithKeys("right", "l")), + Submit: key.NewBinding(key.WithKeys("enter")), + Cancel: key.NewBinding(key.WithKeys("esc")), + ConfirmYes: key.NewBinding( + key.WithKeys("y", "enter"), + ), + ConfirmNo: key.NewBinding( + key.WithKeys("n", "esc"), + ), + } +} diff --git a/pkg/tui/components/addtask/layout.go b/pkg/tui/components/addtask/layout.go new file mode 100644 index 0000000..2fb7df8 --- /dev/null +++ b/pkg/tui/components/addtask/layout.go @@ -0,0 +1,40 @@ +package addtask + +// SetSize configures the overlay dimensions. +func (m *Model) SetSize(width, height int) { + if width <= 0 || height <= 0 { + return + } + m.width = width + m.height = height + usable := width - 8 + if usable < 14 { + usable = width - 6 + } + if usable < 12 { + usable = 12 + } + m.fieldWidth = usable + inputWidth := usable - 16 + if inputWidth < 12 { + inputWidth = max(10, usable-4) + } + m.taskInput.SetWidth(inputWidth) +} + +func clampInt(value, minVal, maxVal int) int { + if maxVal > 0 && value > maxVal { + value = maxVal + } + if value < minVal { + value = minVal + } + return value +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/pkg/tui/components/addtask/model.go b/pkg/tui/components/addtask/model.go index 8034061..c330a76 100644 --- a/pkg/tui/components/addtask/model.go +++ b/pkg/tui/components/addtask/model.go @@ -1,7 +1,6 @@ package addtask import ( - "fmt" "strings" "time" @@ -11,8 +10,6 @@ import ( "tableflip.dev/bujo/pkg/collection" "tableflip.dev/bujo/pkg/glyph" - cachepkg "tableflip.dev/bujo/pkg/tui/cache" - "tableflip.dev/bujo/pkg/tui/components/collectiondetail" "tableflip.dev/bujo/pkg/tui/events" ) @@ -47,9 +44,10 @@ type parentOption struct { // Model renders an overlay for inserting new tasks/bullets. type Model struct { - cache *cachepkg.Cache + cache Cache id events.ComponentID focused bool + keys keyMap initialCollectionID string initialCollectionLabel string @@ -79,7 +77,7 @@ type Model struct { } // NewModel constructs the add-task overlay bound to the provided cache. -func NewModel(cache *cachepkg.Cache, opts Options) *Model { +func NewModel(cache Cache, opts Options) *Model { tInput := textinput.New() tInput.Placeholder = "Describe the task…" tInput.Focus() @@ -89,6 +87,7 @@ func NewModel(cache *cachepkg.Cache, opts Options) *Model { cache: cache, id: events.ComponentID("addtask"), focused: true, + keys: defaultKeyMap(), focus: fieldTaskInput, taskInput: tInput, initialCollectionID: strings.TrimSpace(opts.InitialCollectionID), @@ -135,15 +134,6 @@ func (m *Model) Init() tea.Cmd { ) } -func (m *Model) blurCmd() tea.Cmd { - if !m.focused { - return nil - } - m.focused = false - m.updateInputFocus() - return events.BlurCmd(m.id) -} - // Update processes Bubble Tea messages. func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { @@ -179,567 +169,3 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } - -// View renders the overlay UI. -func (m *Model) View() (string, *tea.Cursor) { - lines := []string{m.sectionTitle("Add Task")} - lines = append(lines, m.renderCollectionRow()) - lines = append(lines, m.renderParentRow(), "") - - controlRow, controlPrefix := m.renderControlRow() - controlRowIndex := len(lines) - lines = append(lines, controlRow, "", m.renderStatusLine()) - - bodyContent := lipgloss.JoinVertical(lipgloss.Left, lines...) - maxContent := m.width - 12 - if maxContent < 20 { - maxContent = m.width - 8 - } - if maxContent < 16 { - maxContent = 16 - } - contentWidth := clampInt(m.fieldWidth, 16, maxContent) - body := lipgloss.NewStyle().Width(contentWidth).Render(bodyContent) - - var cursor *tea.Cursor - if c := m.taskInput.Cursor(); c != nil { - clone := *c - clone.X += controlPrefix - clone.Y += controlRowIndex - cursor = &clone - } - - frameStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("212")). - Padding(1, 2) - if !m.focused { - frameStyle = frameStyle.BorderForeground(lipgloss.Color("240")) - } - box := frameStyle.Render(body) - - if cursor != nil { - cursor.X += 2 // left padding - cursor.Y += 1 // top padding - cursor.X += 1 // left border - cursor.Y += 1 // top border - } - - return box, cursor -} - -// SetSize configures the overlay dimensions. -func (m *Model) SetSize(width, height int) { - if width <= 0 { - width = 80 - } - if height <= 0 { - height = 20 - } - m.width = width - m.height = height - usable := width - 8 - if usable < 14 { - usable = width - 6 - } - if usable < 12 { - usable = 12 - } - m.fieldWidth = usable - inputWidth := usable - 16 - if inputWidth < 12 { - inputWidth = max(10, usable-4) - } - m.taskInput.SetWidth(inputWidth) -} - -func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { - if m.confirmReset { - switch msg.String() { - case "y", "enter": - m.resetForm() - m.confirmReset = false - return m.blurCmd() - case "n", "esc": - m.confirmReset = false - } - return nil - } - - if !m.focused { - return nil - } - - var cmds []tea.Cmd - - switch msg.String() { - case "tab", "shift+tab": - if msg.String() == "tab" { - m.advanceFocus(1) - } else { - m.advanceFocus(-1) - } - case "up", "k": - m.adjustSelection(-1) - case "down", "j": - m.adjustSelection(1) - case "left", "h": - m.adjustSelection(-1) - case "right", "l": - m.adjustSelection(1) - case "enter": - if cmd, err := m.submit(); err != nil { - m.errorMsg = err.Error() - } else if cmd != nil { - cmds = appendCmd(cmds, cmd) - } - case "esc": - m.confirmReset = true - default: - // delegate to focused input - switch m.focus { - } - } - cmds = appendCmd(cmds, m.updateInputFocus()) - if len(cmds) == 0 { - return nil - } - return tea.Batch(cmds...) -} - -func appendCmd(cmds []tea.Cmd, cmd tea.Cmd) []tea.Cmd { - if cmd == nil { - return cmds - } - return append(cmds, cmd) -} - -func (m *Model) updateInputFocus() tea.Cmd { - if !m.focused { - m.taskInput.Blur() - return nil - } - if m.focus == fieldTaskInput { - return m.taskInput.Focus() - } - m.taskInput.Blur() - return nil -} - -func (m *Model) advanceFocus(delta int) { - seq := m.focusSequence() - if len(seq) == 0 { - return - } - current := 0 - for i, f := range seq { - if f == m.focus { - current = i - break - } - } - current = (current + len(seq) + delta) % len(seq) - m.focus = seq[current] - m.updateInputFocus() -} - -func (m *Model) focusSequence() []focusField { - return []focusField{ - fieldParentBullet, - fieldBulletType, - fieldSignifier, - fieldTaskInput, - } -} - -func (m *Model) adjustSelection(delta int) { - switch m.focus { - case fieldParentBullet: - if len(m.parentOptions) == 0 { - return - } - m.parentIndex = clampIndex(m.parentIndex+delta, len(m.parentOptions)) - case fieldBulletType: - if len(m.bulletOptions) == 0 { - return - } - m.bulletIndex = clampIndex(m.bulletIndex+delta, len(m.bulletOptions)) - m.updatePrompt() - case fieldSignifier: - if len(m.signifierOptions) == 0 { - return - } - m.signifierIndex = clampIndex(m.signifierIndex+delta, len(m.signifierOptions)) - m.updatePrompt() - } -} - -func (m *Model) submit() (tea.Cmd, error) { - label := strings.TrimSpace(m.taskInput.Value()) - if label == "" { - return nil, fmt.Errorf("task description is required") - } - targetID := m.resolveTargetCollection() - if targetID == "" { - return nil, fmt.Errorf("select a collection first") - } - parentID := "" - if m.parentIndex > 0 && m.parentIndex < len(m.parentOptions) { - parentID = m.parentOptions[m.parentIndex].ID - } - bulletID := fmt.Sprintf("tmp-%d", time.Now().UnixNano()) - bullet := collectiondetail.Bullet{ - ID: bulletID, - Label: label, - Bullet: m.bulletOptions[m.bulletIndex], - Signifier: m.signifierOptions[m.signifierIndex], - Created: time.Now(), - } - - metaMap := map[string]string{} - if parentID != "" { - metaMap[cacheParentMetaKey] = parentID - } - if len(metaMap) == 0 { - metaMap = nil - } - - if err := m.cache.CreateBulletWithMeta(targetID, bullet, metaMap); err != nil { - return nil, err - } - m.resetForm() - m.lastSubmitted = time.Now() - return m.blurCmd(), nil -} - -func (m *Model) resetForm() { - m.taskInput.SetValue("") - m.parentIndex = 0 - m.errorMsg = "" - m.confirmReset = false - m.refreshParentOptions() -} - -func (m *Model) updatePrompt() { - bullet := m.bulletOptions[m.bulletIndex] - signifier := m.signifierOptions[m.signifierIndex] - prompt := describePrompt(bullet, signifier, m.currentParentLabel()) - m.taskInput.Placeholder = prompt -} - -func (m *Model) currentParentLabel() string { - if m.parentIndex > 0 && m.parentIndex < len(m.parentOptions) { - return m.parentOptions[m.parentIndex].Label - } - return "" -} - -func (m *Model) resolveTargetCollection() string { - if len(m.collectionOptions) == 0 { - return "" - } - if m.collectionIndex >= len(m.collectionOptions) { - m.collectionIndex = len(m.collectionOptions) - 1 - } - if m.collectionIndex < 0 { - m.collectionIndex = 0 - } - meta := m.collectionOptions[m.collectionIndex].Meta - return meta.Name -} - -func (m *Model) refreshCollections() { - metas := m.cache.CollectionsMeta() - currentID := "" - if len(m.collectionOptions) > 0 && m.collectionIndex >= 0 && m.collectionIndex < len(m.collectionOptions) { - currentID = m.collectionOptions[m.collectionIndex].ID - } - opts := make([]collectionOption, 0, len(metas)+1) - placeholderID := strings.TrimSpace(m.initialCollectionID) - placeholderLabel := strings.TrimSpace(m.initialCollectionLabel) - if placeholderLabel == "" && placeholderID != "" { - placeholderLabel = collectionLabel(placeholderID) - } - - for _, meta := range metas { - option := collectionOption{ - ID: meta.Name, - Label: collectionLabel(meta.Name), - Meta: meta, - } - if strings.EqualFold(option.ID, placeholderID) { - // Prefer the real metadata label when it exists. - if placeholderLabel != "" { - option.Label = placeholderLabel - } - } - opts = append(opts, option) - } - - if placeholderID != "" { - found := false - for _, opt := range opts { - if strings.EqualFold(opt.ID, placeholderID) { - found = true - break - } - } - if !found { - opts = append(opts, collectionOption{ - ID: placeholderID, - Label: placeholderLabel, - Meta: collection.Meta{ - Name: placeholderID, - }, - }) - if currentID == "" { - currentID = placeholderID - } - } - } - - m.collectionOptions = opts - if len(m.collectionOptions) == 0 { - m.collectionIndex = 0 - m.updatePrompt() - return - } - selected := false - if currentID != "" { - selected = m.selectCollectionByID(currentID) - } - if !selected && placeholderID != "" { - selected = m.selectCollectionByID(placeholderID) - } - if !selected { - m.collectionIndex = clampIndex(m.collectionIndex, len(m.collectionOptions)) - } else if m.collectionIndex >= len(m.collectionOptions) { - m.collectionIndex = len(m.collectionOptions) - 1 - } - m.updatePrompt() -} - -func (m *Model) refreshParentOptions() { - target := m.resolveTargetCollection() - if target == "" { - m.parentOptions = []parentOption{{ID: "", Label: "(none)"}} - m.parentIndex = 0 - m.updatePrompt() - return - } - section, ok := m.cache.SectionSnapshot(target) - if !ok { - m.parentOptions = []parentOption{{ID: "", Label: "(none)"}} - m.parentIndex = 0 - m.updatePrompt() - return - } - opts := []parentOption{{ID: "", Label: "(none)"}} - for _, bullet := range section.Bullets { - label := bullet.Label - if strings.TrimSpace(label) == "" { - label = bullet.ID - } - opts = append(opts, parentOption{ - ID: bullet.ID, - Label: label, - }) - } - m.parentOptions = opts - m.parentIndex = clampIndex(m.parentIndex, len(m.parentOptions)) - m.updatePrompt() -} - -func (m *Model) selectCollectionByID(id string) bool { - id = strings.TrimSpace(id) - if id == "" { - return false - } - for idx, opt := range m.collectionOptions { - if strings.EqualFold(opt.ID, id) { - m.collectionIndex = idx - return true - } - } - return false -} - -func (m *Model) selectParentByID(id string) { - id = strings.TrimSpace(id) - if id == "" { - m.parentIndex = 0 - return - } - for idx, opt := range m.parentOptions { - if strings.EqualFold(opt.ID, id) { - m.parentIndex = idx - return - } - } -} - -func (m *Model) renderStatusLine() string { - switch { - case m.confirmReset: - return lipgloss.NewStyle().Foreground(lipgloss.Color("213")).Render("Press 'y' to discard draft, 'n' to continue editing.") - case m.errorMsg != "": - return lipgloss.NewStyle().Foreground(lipgloss.Color("204")).Render(m.errorMsg) - default: - return "Enter to submit • Esc to clear • Tab between fields" - } -} - -func (m *Model) sectionTitle(title string) string { - return lipgloss.NewStyle().Bold(true).Render(title) -} - -func (m *Model) renderCollectionRow() string { - if len(m.collectionOptions) == 0 { - return m.renderRow("Collection:", "(none)", false) - } - if m.collectionIndex >= len(m.collectionOptions) { - m.collectionIndex = len(m.collectionOptions) - 1 - } - if m.collectionIndex < 0 { - m.collectionIndex = 0 - } - value := m.collectionOptions[m.collectionIndex].Label - return m.renderRow("Collection:", value, false) -} - -func (m *Model) renderParentRow() string { - value := "(none)" - if len(m.parentOptions) > 0 && m.parentIndex < len(m.parentOptions) { - value = m.parentOptions[m.parentIndex].Label - } - return m.renderRow("Parent bullet:", value, m.focus == fieldParentBullet) -} - -func (m *Model) renderControlRow() (string, int) { - signifier := m.signifierOptions[m.signifierIndex] - signifierLabel := symbolForSignifier(signifier) - signifierBox := m.renderBox(signifierLabel, m.focus == fieldSignifier) - - bullet := m.bulletOptions[m.bulletIndex] - bulletLabel := symbolForBullet(bullet) - bulletBox := m.renderBox(bulletLabel, m.focus == fieldBulletType) - - cursorGlyph := "➤" - if m.focus == fieldTaskInput && m.focused { - cursorGlyph = lipgloss.NewStyle().Foreground(focusColor).Render(cursorGlyph) - } - - prefix := fmt.Sprintf(" %s %s %s ", signifierBox, bulletBox, cursorGlyph) - line := prefix + m.taskInput.View() - return line, lipgloss.Width(prefix) -} - -func (m *Model) renderRow(label, value string, focused bool) string { - indicator := " " - labelStyle := lipgloss.NewStyle().Bold(false) - valueStyle := lipgloss.NewStyle() - if focused { - style := lipgloss.NewStyle().Foreground(focusColor) - indicator = style.Render("➤ ") - labelStyle = labelStyle.Foreground(focusColor) - valueStyle = valueStyle.Foreground(focusColor) - } - return indicator + labelStyle.Render(fmt.Sprintf("%-13s", label)) + " " + valueStyle.Render(value) -} - -func (m *Model) renderBox(content string, focused bool) string { - if strings.TrimSpace(content) == "" { - content = " " - } - box := fmt.Sprintf("[%s]", content) - if focused { - return lipgloss.NewStyle().Foreground(focusColor).Render(box) - } - return box -} - -func symbolForBullet(b glyph.Bullet) string { - if info, ok := glyph.DefaultBullets()[b]; ok { - if s := strings.TrimSpace(info.Symbol); s != "" { - return s - } - } - return string(b) -} - -func symbolForSignifier(s glyph.Signifier) string { - if info, ok := glyph.DefaultSignifiers()[s]; ok { - if sym := strings.TrimSpace(info.Symbol); sym != "" { - return sym - } - } - return " " -} - -func clampIndex(value, length int) int { - if length <= 0 { - return 0 - } - if value < 0 { - return 0 - } - if value >= length { - return length - 1 - } - return value -} - -func collectionLabel(name string) string { - name = strings.TrimSpace(name) - if name == "" { - return "(unnamed)" - } - parts := strings.Split(name, "/") - return parts[len(parts)-1] -} - -const cacheParentMetaKey = "parent_id" - -func clampInt(value, minVal, maxVal int) int { - if maxVal > 0 && value > maxVal { - value = maxVal - } - if value < minVal { - value = minVal - } - return value -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} - -func describePrompt(b glyph.Bullet, s glyph.Signifier, parent string) string { - base := "Describe the task..." - parent = strings.ToLower(parent) - switch b { - case glyph.Event: - base = "Describe the event..." - case glyph.Note: - base = "Describe the note..." - } - if strings.Contains(parent, "event") { - base = strings.Replace(base, "task", "event", 1) - } - if strings.Contains(parent, "note") { - base = strings.Replace(base, "task", "note", 1) - } - switch s { - case glyph.Priority: - return strings.Replace(base, "Describe", "Describe the important", 1) - case glyph.Inspiration: - return strings.Replace(base, "Describe", "Describe the inspiration", 1) - case glyph.Investigation: - return strings.Replace(base, "Describe", "Describe the investigation", 1) - default: - return base - } -} diff --git a/pkg/tui/components/addtask/model_test.go b/pkg/tui/components/addtask/model_test.go new file mode 100644 index 0000000..b817bde --- /dev/null +++ b/pkg/tui/components/addtask/model_test.go @@ -0,0 +1,74 @@ +package addtask + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea/v2" + + "tableflip.dev/bujo/pkg/collection" + "tableflip.dev/bujo/pkg/tui/cache" + "tableflip.dev/bujo/pkg/tui/components/collectiondetail" + "tableflip.dev/bujo/pkg/tui/events" +) + +func TestFocusCyclesAcrossFields(t *testing.T) { + model := newTestModel() + if model.focus != fieldTaskInput { + t.Fatalf("expected initial focus on task input") + } + + model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + if model.focus != fieldParentBullet { + t.Fatalf("expected focus to wrap to parent bullet, got %v", model.focus) + } + + model.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}) + if model.focus != fieldTaskInput { + t.Fatalf("expected focus to wrap back to task input, got %v", model.focus) + } +} + +func TestBlurPreventsKeyHandling(t *testing.T) { + model := newTestModel() + model.Update(events.BlurMsg{Component: model.id}) + model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + if model.focus != fieldTaskInput { + t.Fatalf("expected focus to remain unchanged while blurred") + } +} + +func TestConfirmResetFlow(t *testing.T) { + model := newTestModel() + model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if !model.confirmReset { + t.Fatalf("expected confirm reset to be set") + } + model.Update(tea.KeyPressMsg{Text: "n", Code: 'n'}) + if model.confirmReset { + t.Fatalf("expected confirm reset to be cleared") + } + + model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + model.Update(tea.KeyPressMsg{Text: "y", Code: 'y'}) + if model.confirmReset { + t.Fatalf("expected confirm reset to be cleared after confirmation") + } + if model.focused { + t.Fatalf("expected model to blur after confirmation") + } +} + +// newTestModel wires a cache with a single collection + bullet for addtask tests. +func newTestModel() *Model { + cache := cache.New("addtask-test") + cache.SetCollections([]collection.Meta{{Name: "Inbox", Type: collection.TypeGeneric}}) + cache.SetSections([]collectiondetail.Section{{ + ID: "Inbox", + Title: "Inbox", + Bullets: []collectiondetail.Bullet{{ + ID: "b1", + Label: "First", + }}, + }}) + return NewModel(cache, Options{InitialCollectionID: "Inbox"}) +} diff --git a/pkg/tui/components/addtask/util.go b/pkg/tui/components/addtask/util.go new file mode 100644 index 0000000..35f103c --- /dev/null +++ b/pkg/tui/components/addtask/util.go @@ -0,0 +1,54 @@ +package addtask + +import "strings" + +import "tableflip.dev/bujo/pkg/glyph" + +func clampIndex(value, length int) int { + if length <= 0 { + return 0 + } + if value < 0 { + return 0 + } + if value >= length { + return length - 1 + } + return value +} + +func collectionLabel(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "(unnamed)" + } + parts := strings.Split(name, "/") + return parts[len(parts)-1] +} + +func describePrompt(b glyph.Bullet, s glyph.Signifier, parent string) string { + base := "Describe the task..." + parent = strings.ToLower(parent) + switch b { + case glyph.Event: + base = "Describe the event..." + case glyph.Note: + base = "Describe the note..." + } + if strings.Contains(parent, "event") { + base = strings.Replace(base, "task", "event", 1) + } + if strings.Contains(parent, "note") { + base = strings.Replace(base, "task", "note", 1) + } + switch s { + case glyph.Priority: + return strings.Replace(base, "Describe", "Describe the important", 1) + case glyph.Inspiration: + return strings.Replace(base, "Describe", "Describe the inspiration", 1) + case glyph.Investigation: + return strings.Replace(base, "Describe", "Describe the investigation", 1) + default: + return base + } +} diff --git a/pkg/tui/components/addtask/view.go b/pkg/tui/components/addtask/view.go new file mode 100644 index 0000000..a1c0187 --- /dev/null +++ b/pkg/tui/components/addtask/view.go @@ -0,0 +1,150 @@ +package addtask + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + + "tableflip.dev/bujo/pkg/glyph" +) + +// View renders the overlay UI. +func (m *Model) View() (string, *tea.Cursor) { + lines := []string{m.sectionTitle("Add Task")} + lines = append(lines, m.renderCollectionRow()) + lines = append(lines, m.renderParentRow(), "") + + controlRow, controlPrefix := m.renderControlRow() + controlRowIndex := len(lines) + lines = append(lines, controlRow, "", m.renderStatusLine()) + + bodyContent := lipgloss.JoinVertical(lipgloss.Left, lines...) + maxContent := m.width - 12 + if maxContent < 20 { + maxContent = m.width - 8 + } + if maxContent < 16 { + maxContent = 16 + } + contentWidth := clampInt(m.fieldWidth, 16, maxContent) + body := lipgloss.NewStyle().Width(contentWidth).Render(bodyContent) + + var cursor *tea.Cursor + if c := m.taskInput.Cursor(); c != nil { + clone := *c + clone.X += controlPrefix + clone.Y += controlRowIndex + cursor = &clone + } + + frameStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("212")). + Padding(1, 2) + if !m.focused { + frameStyle = frameStyle.BorderForeground(lipgloss.Color("240")) + } + box := frameStyle.Render(body) + + return box, cursor +} + +func (m *Model) renderStatusLine() string { + switch { + case m.confirmReset: + return lipgloss.NewStyle().Foreground(lipgloss.Color("213")).Render("Press 'y' to discard draft, 'n' to continue editing.") + case m.errorMsg != "": + return lipgloss.NewStyle().Foreground(lipgloss.Color("204")).Render(m.errorMsg) + default: + return "Enter to submit • Esc to clear • Tab between fields" + } +} + +func (m *Model) sectionTitle(title string) string { + return lipgloss.NewStyle().Bold(true).Render(title) +} + +func (m *Model) renderCollectionRow() string { + if len(m.collectionOptions) == 0 { + return m.renderRow("Collection:", "(none)", false) + } + if m.collectionIndex >= len(m.collectionOptions) { + m.collectionIndex = len(m.collectionOptions) - 1 + } + if m.collectionIndex < 0 { + m.collectionIndex = 0 + } + value := m.collectionOptions[m.collectionIndex].Label + return m.renderRow("Collection:", value, false) +} + +func (m *Model) renderParentRow() string { + value := "(none)" + if len(m.parentOptions) > 0 && m.parentIndex < len(m.parentOptions) { + value = m.parentOptions[m.parentIndex].Label + } + return m.renderRow("Parent bullet:", value, m.focus == fieldParentBullet) +} + +func (m *Model) renderControlRow() (string, int) { + signifier := m.signifierOptions[m.signifierIndex] + signifierLabel := symbolForSignifier(signifier) + signifierBox := m.renderBox(signifierLabel, m.focus == fieldSignifier) + + bullet := m.bulletOptions[m.bulletIndex] + bulletLabel := symbolForBullet(bullet) + bulletBox := m.renderBox(bulletLabel, m.focus == fieldBulletType) + + cursorGlyph := "➤" + if m.focus == fieldTaskInput && m.focused { + cursorGlyph = lipgloss.NewStyle().Foreground(focusColor).Render(cursorGlyph) + } + + prefix := fmt.Sprintf(" %s %s %s ", signifierBox, bulletBox, cursorGlyph) + line := prefix + m.taskInput.View() + return line, lipgloss.Width(prefix) +} + +func (m *Model) renderRow(label, value string, focused bool) string { + indicator := " " + labelStyle := lipgloss.NewStyle().Bold(false) + valueStyle := lipgloss.NewStyle() + if focused { + style := lipgloss.NewStyle().Foreground(focusColor) + indicator = style.Render("➤ ") + labelStyle = labelStyle.Foreground(focusColor) + valueStyle = valueStyle.Foreground(focusColor) + } + return indicator + labelStyle.Render(fmt.Sprintf("%-13s", label)) + " " + valueStyle.Render(value) +} + +func (m *Model) renderBox(content string, focused bool) string { + if strings.TrimSpace(content) == "" { + content = " " + } + box := fmt.Sprintf("[%s]", content) + if focused { + return lipgloss.NewStyle().Foreground(focusColor).Render(box) + } + return box +} + +func symbolForBullet(b glyph.Bullet) string { + if info, ok := glyph.DefaultBullets()[b]; ok { + if s := strings.TrimSpace(info.Symbol); s != "" { + return s + } + } + return string(b) +} + +func symbolForSignifier(s glyph.Signifier) string { + if info, ok := glyph.DefaultSignifiers()[s]; ok { + if sym := strings.TrimSpace(info.Symbol); sym != "" { + return sym + } + } + return " " +} diff --git a/pkg/tui/components/bulletdetail/model.go b/pkg/tui/components/bulletdetail/model.go index 5fdc342..78de708 100644 --- a/pkg/tui/components/bulletdetail/model.go +++ b/pkg/tui/components/bulletdetail/model.go @@ -36,7 +36,7 @@ func New(collectionTitle, bulletLabel, collectionID, parentLabel string) *Model } } -// Init implements command.Overlay. +// Init implements tea.Model. func (m *Model) Init() tea.Cmd { return nil } // Update consumes messages for completeness; currently read-only. diff --git a/pkg/tui/components/calendar/calendar.go b/pkg/tui/components/calendar/calendar.go index 6fb30c7..c80c919 100644 --- a/pkg/tui/components/calendar/calendar.go +++ b/pkg/tui/components/calendar/calendar.go @@ -7,6 +7,8 @@ import ( "time" "github.com/charmbracelet/lipgloss/v2" + + "tableflip.dev/bujo/pkg/tui/theme" ) // Day describes a single day rendered in the calendar. @@ -138,17 +140,13 @@ func ParseMonth(name string) (time.Time, bool) { // DefaultOptions returns the styling used for calendar rendering. func DefaultOptions() Options { - header := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Bold(true) - empty := lipgloss.NewStyle().Foreground(lipgloss.Color("244")) - entry := lipgloss.NewStyle().Foreground(lipgloss.Color("15")) - today := lipgloss.NewStyle().Underline(true) - selected := lipgloss.NewStyle().Background(lipgloss.Color("63")).Foreground(lipgloss.Color("0")) + calendarTheme := theme.Default().Calendar return Options{ - HeaderStyle: header, - EmptyStyle: empty, - EntryStyle: entry, - TodayStyle: today, - SelectedStyle: selected, + HeaderStyle: calendarTheme.Header, + EmptyStyle: calendarTheme.Empty, + EntryStyle: calendarTheme.Entry, + TodayStyle: calendarTheme.Today, + SelectedStyle: calendarTheme.Selected, ShowHeader: true, } } diff --git a/pkg/tui/components/collectiondetail/identity.go b/pkg/tui/components/collectiondetail/identity.go new file mode 100644 index 0000000..40a636c --- /dev/null +++ b/pkg/tui/components/collectiondetail/identity.go @@ -0,0 +1,17 @@ +package collectiondetail + +import "tableflip.dev/bujo/pkg/tui/events" + +// SetID overrides the emitted component identifier. +func (m *Model) SetID(id events.ComponentID) { + if id == "" { + m.id = events.ComponentID("collectiondetail") + return + } + m.id = id +} + +// ID returns the component identifier. +func (m *Model) ID() events.ComponentID { + return m.id +} diff --git a/pkg/tui/components/collectiondetail/keymap.go b/pkg/tui/components/collectiondetail/keymap.go new file mode 100644 index 0000000..4370966 --- /dev/null +++ b/pkg/tui/components/collectiondetail/keymap.go @@ -0,0 +1,42 @@ +package collectiondetail + +import "github.com/charmbracelet/bubbles/v2/key" + +// keyMap captures navigation and action bindings for detail rows. +type keyMap struct { + MoveUp key.Binding + MoveDown key.Binding + PageUp key.Binding + PageDown key.Binding + MoveTop key.Binding + MoveBottom key.Binding + Select key.Binding + MoveCollection key.Binding + MoveFuture key.Binding + Complete key.Binding + Strike key.Binding + SignifyInvestigate key.Binding + SignifyInspire key.Binding + SignifyPriority key.Binding + SignifyNone key.Binding +} + +func defaultKeyMap() keyMap { + return keyMap{ + MoveUp: key.NewBinding(key.WithKeys("up", "k")), + MoveDown: key.NewBinding(key.WithKeys("down", "j")), + PageUp: key.NewBinding(key.WithKeys("pgup", "b")), + PageDown: key.NewBinding(key.WithKeys("pgdown", "f")), + MoveTop: key.NewBinding(key.WithKeys("home", "g")), + MoveBottom: key.NewBinding(key.WithKeys("end", "G")), + Select: key.NewBinding(key.WithKeys("enter", " ")), + MoveCollection: key.NewBinding(key.WithKeys(">")), + MoveFuture: key.NewBinding(key.WithKeys("<")), + Complete: key.NewBinding(key.WithKeys("x")), + Strike: key.NewBinding(key.WithKeys("delete", "backspace")), + SignifyInvestigate: key.NewBinding(key.WithKeys("?")), + SignifyInspire: key.NewBinding(key.WithKeys("!")), + SignifyPriority: key.NewBinding(key.WithKeys("*")), + SignifyNone: key.NewBinding(key.WithKeys("|")), + } +} diff --git a/pkg/tui/components/collectiondetail/layout.go b/pkg/tui/components/collectiondetail/layout.go new file mode 100644 index 0000000..88824c3 --- /dev/null +++ b/pkg/tui/components/collectiondetail/layout.go @@ -0,0 +1,336 @@ +package collectiondetail + +import ( + "sort" + "strings" +) + +func (m *Model) visibleSection() (int, bool) { + if len(m.lines) == 0 || len(m.sections) == 0 { + return -1, false + } + start := m.scroll + if start < 0 { + start = 0 + } + if start >= len(m.lines) { + start = len(m.lines) - 1 + } + for i := start; i < len(m.lines); i++ { + info := m.lines[i] + if info.section < 0 || info.section >= len(m.sections) { + continue + } + if info.kind == lineSpacer { + continue + } + return info.section, true + } + return -1, false +} + +func (m *Model) moveCursor(delta int) { + if len(m.bulletLines) == 0 { + m.cursor = -1 + return + } + if m.cursor < 0 { + m.cursor = 0 + } + m.cursor += delta + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor >= len(m.bulletLines) { + m.cursor = len(m.bulletLines) - 1 + } + m.ensureScroll() + m.refreshActiveSection() +} + +func (m *Model) ensureScroll() { + if len(m.lines) == 0 { + m.scroll = 0 + return + } + curLine := m.currentLineIndex() + if curLine < 0 { + if m.activeSection >= 0 && m.activeSection < len(m.sections) { + for idx, info := range m.lines { + if info.section == m.activeSection && info.kind == lineHeader { + m.scrollToLine(idx) + return + } + } + } + m.scroll = 0 + m.clampScroll() + return + } + m.ensureLineVisible(curLine) +} + +func (m *Model) pageSize() int { + height := m.viewportContentHeight() + if height <= 0 { + return 10 + } + if height <= 1 { + return 1 + } + return height - 1 +} + +func (m *Model) ensureLineVisible(target int) { + if len(m.lines) == 0 { + m.scroll = 0 + return + } + if target < 0 { + target = 0 + } + if target >= len(m.lines) { + target = len(m.lines) - 1 + } + contentHeight := m.viewportContentHeight() + if contentHeight <= 0 { + contentHeight = 1 + } + topIdx := m.scroll + if topIdx < 0 { + topIdx = 0 + } + if topIdx >= len(m.lines) { + topIdx = len(m.lines) - 1 + } + topOffset := m.lineOffset(topIdx) + bottomOffset := topOffset + remaining := contentHeight + idx := topIdx + for idx < len(m.lines) && remaining > 0 { + h := m.lineHeight(idx) + if h >= remaining { + bottomOffset = m.lineOffset(idx) + remaining - 1 + remaining = 0 + break + } + remaining -= h + bottomOffset = m.lineOffset(idx) + h - 1 + idx++ + } + if remaining > 0 { + bottomOffset = m.totalHeight - 1 + } + lineTop := m.lineOffset(target) + lineBottom := lineTop + m.lineHeight(target) - 1 + if lineTop < topOffset { + m.scroll = target + m.clampScroll() + return + } + if lineBottom > bottomOffset { + start := target + total := m.lineHeight(target) + if total <= 0 { + total = 1 + } + for start > 0 { + prev := start - 1 + nextTotal := total + m.lineHeight(prev) + if nextTotal > contentHeight { + break + } + start = prev + total = nextTotal + } + m.scroll = start + m.clampScroll() + return + } + m.clampScroll() +} + +func (m *Model) viewportContentHeight() int { + if m.height <= 0 { + return 0 + } + height := m.height - m.stickyHeaderHeight() + if height <= 0 { + return 1 + } + return height +} + +func (m *Model) stickyHeaderHeight() int { + if m.height <= 0 { + return 0 + } + section, ok := m.visibleSection() + if !ok { + return 0 + } + header := m.renderSectionHeader(section, m.sectionActive(section)) + lines := strings.Count(header, "\n") + 1 + if lines < 0 { + return 0 + } + if lines >= m.height { + return m.height - 1 + } + return lines +} + +func (m *Model) rebuildLines() { + m.lines = m.lines[:0] + m.bulletLines = m.bulletLines[:0] + for si, sec := range m.sections { + m.lines = append(m.lines, lineInfo{section: si, kind: lineHeader}) + if len(sec.Bullets) == 0 { + lineIdx := len(m.lines) + m.lines = append(m.lines, lineInfo{section: si, kind: lineEmpty}) + m.bulletLines = append(m.bulletLines, lineIdx) + } else { + m.appendBulletLines(si, sec.Bullets, 0) + } + m.lines = append(m.lines, lineInfo{section: si, kind: lineSpacer}) + } + if len(m.lines) > 0 { + m.lines = m.lines[:len(m.lines)-1] + } + m.recomputeLineMetrics() +} + +func (m *Model) appendBulletLines(section int, bullets []Bullet, depth int) { + for bi := range bullets { + lineIdx := len(m.lines) + bullet := bullets[bi] + info := lineInfo{section: section, kind: lineItem, indent: depth, bullet: bullet} + m.lines = append(m.lines, info) + m.bulletLines = append(m.bulletLines, lineIdx) + if len(bullet.Children) > 0 { + m.appendBulletLines(section, bullet.Children, depth+1) + } + } +} + +func (m *Model) recomputeLineMetrics() { + n := len(m.lines) + if n == 0 { + m.lineHeights = m.lineHeights[:0] + m.lineOffsets = m.lineOffsets[:0] + m.totalHeight = 0 + m.scroll = 0 + return + } + if cap(m.lineHeights) < n { + m.lineHeights = make([]int, n) + } else { + m.lineHeights = m.lineHeights[:n] + } + if cap(m.lineOffsets) < n { + m.lineOffsets = make([]int, n) + } else { + m.lineOffsets = m.lineOffsets[:n] + } + offset := 0 + for i := 0; i < n; i++ { + h := m.measureLineHeight(m.lines[i]) + if h <= 0 { + h = 1 + } + m.lineHeights[i] = h + m.lineOffsets[i] = offset + offset += h + } + m.totalHeight = offset + m.clampScroll() +} + +func (m *Model) measureLineHeight(info lineInfo) int { + switch info.kind { + case lineHeader: + header := m.renderSectionHeader(info.section, false) + return strings.Count(header, "\n") + 1 + case lineSpacer: + return 1 + case lineEmpty: + text := m.renderEmptyLine(info.section, false) + return strings.Count(text, "\n") + 1 + case lineItem: + prefix := m.composeBulletPrefix(info.indent, info.bullet, false) + lines := m.renderBulletLines(prefix, info.bullet) + if len(lines) == 0 { + return 1 + } + return len(lines) + default: + return 1 + } +} + +func (m *Model) lineHeight(idx int) int { + if idx < 0 || idx >= len(m.lineHeights) { + return 0 + } + h := m.lineHeights[idx] + if h <= 0 { + h = m.measureLineHeight(m.lines[idx]) + if h <= 0 { + h = 1 + } + m.lineHeights[idx] = h + } + return h +} + +func (m *Model) lineOffset(idx int) int { + if idx < 0 || idx >= len(m.lineOffsets) { + return 0 + } + return m.lineOffsets[idx] +} + +func (m *Model) clampScroll() { + if len(m.lines) == 0 { + m.scroll = 0 + return + } + if m.scroll < 0 { + m.scroll = 0 + } + if m.scroll >= len(m.lines) { + m.scroll = len(m.lines) - 1 + } + maxIdx := m.maxScrollIndex() + if m.scroll > maxIdx { + m.scroll = maxIdx + } +} + +func (m *Model) maxScrollIndex() int { + if len(m.lines) == 0 { + return 0 + } + visible := m.viewportContentHeight() + if visible <= 0 { + return 0 + } + if m.totalHeight <= visible { + return 0 + } + maxOffset := m.totalHeight - visible + idx := sort.Search(len(m.lineOffsets), func(i int) bool { + return m.lineOffsets[i] > maxOffset + }) - 1 + if idx < 0 { + idx = 0 + } + return idx +} + +func (m *Model) currentLineIndex() int { + if m.cursor < 0 || m.cursor >= len(m.bulletLines) { + return -1 + } + return m.bulletLines[m.cursor] +} diff --git a/pkg/tui/components/collectiondetail/model.go b/pkg/tui/components/collectiondetail/model.go index 1124b07..8d3dbf3 100644 --- a/pkg/tui/components/collectiondetail/model.go +++ b/pkg/tui/components/collectiondetail/model.go @@ -7,9 +7,8 @@ import ( "strings" "time" + "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/muesli/reflow/wordwrap" "tableflip.dev/bujo/pkg/collection" "tableflip.dev/bujo/pkg/glyph" @@ -46,6 +45,7 @@ type Model struct { width int height int debugLog io.Writer + keys keyMap cursor int // index into bulletLines, -1 when nothing selectable scroll int @@ -81,7 +81,7 @@ type lineInfo struct { // NewModel constructs the detail component with the provided sections. func NewModel(sections []Section) *Model { - m := &Model{cursor: -1, activeSection: -1, id: events.ComponentID("collectiondetail")} + m := &Model{cursor: -1, activeSection: -1, id: events.ComponentID("collectiondetail"), keys: defaultKeyMap()} m.SetSections(sections) return m } @@ -185,71 +185,66 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.focused { return m, nil } - switch msg.String() { - case "up", "k": + switch { + case key.Matches(msg, m.keys.MoveUp): m.moveCursor(-1) - case "down", "j": + case key.Matches(msg, m.keys.MoveDown): m.moveCursor(1) - case "pgup", "b": + case key.Matches(msg, m.keys.PageUp): m.moveCursor(-m.pageSize()) - case "pgdown", "f": + case key.Matches(msg, m.keys.PageDown): m.moveCursor(m.pageSize()) - case "home", "g": + case key.Matches(msg, m.keys.MoveTop): if len(m.bulletLines) > 0 { m.cursor = 0 m.ensureScroll() m.refreshActiveSection() } - case "end", "G": + case key.Matches(msg, m.keys.MoveBottom): if len(m.bulletLines) > 0 { m.cursor = len(m.bulletLines) - 1 m.ensureScroll() m.refreshActiveSection() } - case "enter", " ": + case key.Matches(msg, m.keys.Select): if cmd := m.selectCmd(); cmd != nil { cmds = append(cmds, cmd) } - case ">": + case key.Matches(msg, m.keys.MoveCollection): if cmd := m.moveCmd(); cmd != nil { cmds = append(cmds, cmd) } - case "x": + case key.Matches(msg, m.keys.Complete): if cmd := m.completeCmd(); cmd != nil { cmds = append(cmds, cmd) } - case "delete", "backspace": + case key.Matches(msg, m.keys.Strike): if cmd := m.strikeCmd(); cmd != nil { cmds = append(cmds, cmd) } - case "<": + case key.Matches(msg, m.keys.MoveFuture): if cmd := m.moveFutureCmd(); cmd != nil { cmds = append(cmds, cmd) } - case "?": + case key.Matches(msg, m.keys.SignifyInvestigate): if cmd := m.signifierCmd(glyph.Investigation); cmd != nil { cmds = append(cmds, cmd) } - case "!": + case key.Matches(msg, m.keys.SignifyInspire): if cmd := m.signifierCmd(glyph.Inspiration); cmd != nil { cmds = append(cmds, cmd) } - case "*": + case key.Matches(msg, m.keys.SignifyPriority): if cmd := m.signifierCmd(glyph.Priority); cmd != nil { cmds = append(cmds, cmd) } - case "|": + case key.Matches(msg, m.keys.SignifyNone): if cmd := m.signifierCmd(glyph.None); cmd != nil { cmds = append(cmds, cmd) } } case events.CollectionHighlightMsg: - if m.sourceNav == "" || m.sourceNav == msg.Component { - if msg.RowKind == "day" { - m.ensurePlaceholderSection(msg.Collection) - } - m.focusSectionForCollection(msg.Collection) - } + m.handleNavHighlight(msg) case events.CollectionChangeMsg: if m.applyCollectionChange(msg) { m.rebuildLookup() @@ -260,12 +255,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshFromSections(false) } case events.CollectionSelectMsg: - if m.sourceNav == "" || m.sourceNav == msg.Component { - if !msg.Exists { - m.ensurePlaceholderSection(msg.Collection) - m.focusSectionForCollection(msg.Collection) - } - } + m.handleNavSelect(msg) case events.CollectionOrderMsg: if m.reorderSections(msg.Order) { m.rebuildLookup() @@ -282,613 +272,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -// View renders the component. -func (m *Model) View() string { - if m.height <= 0 { - m.height = 20 - } - if m.width <= 0 { - m.width = 80 - } - - lines := m.renderVisibleLines() - return strings.Join(lines, "\n") -} - -func (m *Model) visibleSection() (int, bool) { - if len(m.lines) == 0 || len(m.sections) == 0 { - return -1, false - } - start := m.scroll - if start < 0 { - start = 0 - } - if start >= len(m.lines) { - start = len(m.lines) - 1 - } - for i := start; i < len(m.lines); i++ { - info := m.lines[i] - if info.section < 0 || info.section >= len(m.sections) { - continue - } - if info.kind == lineSpacer { - continue - } - return info.section, true - } - return -1, false -} - -func (m *Model) moveCursor(delta int) { - if len(m.bulletLines) == 0 { - m.cursor = -1 - return - } - if m.cursor < 0 { - m.cursor = 0 - } - m.cursor += delta - if m.cursor < 0 { - m.cursor = 0 - } - if m.cursor >= len(m.bulletLines) { - m.cursor = len(m.bulletLines) - 1 - } - m.ensureScroll() - m.refreshActiveSection() -} - -func (m *Model) ensureScroll() { - if len(m.lines) == 0 { - m.scroll = 0 - return - } - curLine := m.currentLineIndex() - if curLine < 0 { - if m.activeSection >= 0 && m.activeSection < len(m.sections) { - for idx, info := range m.lines { - if info.section == m.activeSection && info.kind == lineHeader { - m.scrollToLine(idx) - return - } - } - } - m.scroll = 0 - m.clampScroll() - return - } - m.ensureLineVisible(curLine) -} - -func (m *Model) pageSize() int { - height := m.viewportContentHeight() - if height <= 0 { - return 10 - } - if height <= 1 { - return 1 - } - return height - 1 -} - -func (m *Model) ensureLineVisible(target int) { - if len(m.lines) == 0 { - m.scroll = 0 - return - } - if target < 0 { - target = 0 - } - if target >= len(m.lines) { - target = len(m.lines) - 1 - } - contentHeight := m.viewportContentHeight() - if contentHeight <= 0 { - contentHeight = 1 - } - topIdx := m.scroll - if topIdx < 0 { - topIdx = 0 - } - if topIdx >= len(m.lines) { - topIdx = len(m.lines) - 1 - } - topOffset := m.lineOffset(topIdx) - bottomOffset := topOffset - remaining := contentHeight - idx := topIdx - for idx < len(m.lines) && remaining > 0 { - h := m.lineHeight(idx) - if h >= remaining { - bottomOffset = m.lineOffset(idx) + remaining - 1 - remaining = 0 - break - } - remaining -= h - bottomOffset = m.lineOffset(idx) + h - 1 - idx++ - } - if remaining > 0 { - bottomOffset = m.totalHeight - 1 - } - lineTop := m.lineOffset(target) - lineBottom := lineTop + m.lineHeight(target) - 1 - if lineTop < topOffset { - m.scroll = target - m.clampScroll() - return - } - if lineBottom > bottomOffset { - start := target - total := m.lineHeight(target) - if total <= 0 { - total = 1 - } - for start > 0 { - prev := start - 1 - nextTotal := total + m.lineHeight(prev) - if nextTotal > contentHeight { - break - } - start = prev - total = nextTotal - } - m.scroll = start - m.clampScroll() - return - } - m.clampScroll() -} - -func (m *Model) viewportContentHeight() int { - if m.height <= 0 { - return 0 - } - height := m.height - m.stickyHeaderHeight() - if height <= 0 { - return 1 - } - return height -} - -func (m *Model) stickyHeaderHeight() int { - if m.height <= 0 { - return 0 - } - section, ok := m.visibleSection() - if !ok { - return 0 - } - header := m.renderSectionHeader(section, m.sectionActive(section)) - lines := strings.Count(header, "\n") + 1 - if lines < 0 { - return 0 - } - if lines >= m.height { - return m.height - 1 - } - return lines -} - -func (m *Model) rebuildLines() { - m.lines = m.lines[:0] - m.bulletLines = m.bulletLines[:0] - for si, sec := range m.sections { - m.lines = append(m.lines, lineInfo{section: si, kind: lineHeader}) - if len(sec.Bullets) == 0 { - lineIdx := len(m.lines) - m.lines = append(m.lines, lineInfo{section: si, kind: lineEmpty}) - m.bulletLines = append(m.bulletLines, lineIdx) - } else { - m.appendBulletLines(si, sec.Bullets, 0) - } - m.lines = append(m.lines, lineInfo{section: si, kind: lineSpacer}) - } - if len(m.lines) > 0 { - m.lines = m.lines[:len(m.lines)-1] - } - m.recomputeLineMetrics() -} - -func (m *Model) appendBulletLines(section int, bullets []Bullet, depth int) { - for bi := range bullets { - lineIdx := len(m.lines) - bullet := bullets[bi] - info := lineInfo{section: section, kind: lineItem, indent: depth, bullet: bullet} - m.lines = append(m.lines, info) - m.bulletLines = append(m.bulletLines, lineIdx) - if len(bullet.Children) > 0 { - m.appendBulletLines(section, bullet.Children, depth+1) - } - } -} - -func (m *Model) recomputeLineMetrics() { - n := len(m.lines) - if n == 0 { - m.lineHeights = m.lineHeights[:0] - m.lineOffsets = m.lineOffsets[:0] - m.totalHeight = 0 - m.scroll = 0 - return - } - if cap(m.lineHeights) < n { - m.lineHeights = make([]int, n) - } else { - m.lineHeights = m.lineHeights[:n] - } - if cap(m.lineOffsets) < n { - m.lineOffsets = make([]int, n) - } else { - m.lineOffsets = m.lineOffsets[:n] - } - offset := 0 - for i := 0; i < n; i++ { - h := m.measureLineHeight(m.lines[i]) - if h <= 0 { - h = 1 - } - m.lineHeights[i] = h - m.lineOffsets[i] = offset - offset += h - } - m.totalHeight = offset - m.clampScroll() -} - -func (m *Model) measureLineHeight(info lineInfo) int { - switch info.kind { - case lineHeader: - header := m.renderSectionHeader(info.section, false) - return strings.Count(header, "\n") + 1 - case lineSpacer: - return 1 - case lineEmpty: - text := m.renderEmptyLine(info.section, false) - return strings.Count(text, "\n") + 1 - case lineItem: - prefix := m.composeBulletPrefix(info.indent, info.bullet, false) - lines := m.renderBulletLines(prefix, info.bullet) - if len(lines) == 0 { - return 1 - } - return len(lines) - default: - return 1 - } -} - -func (m *Model) lineHeight(idx int) int { - if idx < 0 || idx >= len(m.lineHeights) { - return 0 - } - h := m.lineHeights[idx] - if h <= 0 { - h = m.measureLineHeight(m.lines[idx]) - if h <= 0 { - h = 1 - } - m.lineHeights[idx] = h - } - return h -} - -func (m *Model) lineOffset(idx int) int { - if idx < 0 || idx >= len(m.lineOffsets) { - return 0 - } - return m.lineOffsets[idx] -} - -func (m *Model) clampScroll() { - if len(m.lines) == 0 { - m.scroll = 0 - return - } - if m.scroll < 0 { - m.scroll = 0 - } - if m.scroll >= len(m.lines) { - m.scroll = len(m.lines) - 1 - } - maxIdx := m.maxScrollIndex() - if m.scroll > maxIdx { - m.scroll = maxIdx - } -} - -func (m *Model) maxScrollIndex() int { - if len(m.lines) == 0 { - return 0 - } - visible := m.viewportContentHeight() - if visible <= 0 { - return 0 - } - if m.totalHeight <= visible { - return 0 - } - maxOffset := m.totalHeight - visible - idx := sort.Search(len(m.lineOffsets), func(i int) bool { - return m.lineOffsets[i] > maxOffset - }) - 1 - if idx < 0 { - idx = 0 - } - return idx -} - -// SetID overrides the emitted component identifier. -func (m *Model) SetID(id events.ComponentID) { - if id == "" { - m.id = events.ComponentID("collectiondetail") - return - } - m.id = id -} - -// ID returns the component identifier. -func (m *Model) ID() events.ComponentID { - return m.id -} - -func (m *Model) renderLine(idx int, selected bool) string { - if idx < 0 || idx >= len(m.lines) { - return "" - } - info := m.lines[idx] - if info.section < 0 || info.section >= len(m.sections) { - return "" - } - switch info.kind { - case lineHeader: - return m.renderSectionHeader(info.section, m.sectionActive(info.section)) - case lineSpacer: - return "" - case lineEmpty: - return m.renderEmptyLine(info.section, m.sectionActive(info.section)) - case lineItem: - return m.renderBulletInfo(info, selected) - default: - return "" - } -} - -func (m *Model) renderSectionHeader(section int, highlight bool) string { - sec := m.sections[section] - style := lipgloss.NewStyle().Bold(true).Underline(true) - if sec.Placeholder { - style = style.Italic(true).Foreground(lipgloss.Color("244")) - } - if highlight { - style = style.Foreground(lipgloss.Color("213")) - } - title := sec.Title - if title == "" { - title = "(untitled)" - } - if sec.Subtitle != "" { - title = title + " ▸ " + sec.Subtitle - } - return style.Width(m.width).Render(title) -} - -func (m *Model) renderEmptyLine(section int, highlight bool) string { - if section < 0 || section >= len(m.sections) { - return "" - } - sec := m.sections[section] - message := " " - style := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - if sec.Placeholder { - message = " (collection not yet created — add a bullet to save it)" - style = style.Italic(true).Foreground(lipgloss.Color("244")) - } - if highlight { - style = style.Foreground(lipgloss.Color("213")) - } - return style.Render(message) -} - -func (m *Model) renderBulletInfo(info lineInfo, selected bool) string { - item := info.bullet - prefix := m.composeBulletPrefix(info.indent, item, selected && m.focused) - lines := m.renderBulletLines(prefix, item) - prefixStyle, messageStyle := m.bulletStyles(item) - for i, line := range lines { - if i == 0 { - lines[i] = prefixStyle.Render(prefix) + messageStyle.Render(strings.TrimPrefix(line, prefix)) - } else { - lines[i] = messageStyle.Render(line) - } - } - return strings.Join(lines, "\n") -} - -func (m *Model) wrapBulletLines(prefix, text string) []string { - prefixWidth := lipgloss.Width(prefix) - if prefixWidth <= 0 { - prefixWidth = 2 - } - available := m.width - prefixWidth - if available < 10 { - available = 10 - } - - wrapLine := func(s string) []string { - if strings.TrimSpace(s) == "" { - return []string{""} - } - wrapped := wordwrap.String(s, available) - if wrapped == "" { - return []string{""} - } - return strings.Split(wrapped, "\n") - } - - padding := strings.Repeat(" ", prefixWidth) - lines := make([]string, 0, 4) - firstLine := true - for _, raw := range strings.Split(text, "\n") { - segments := wrapLine(raw) - for i, seg := range segments { - if firstLine && i == 0 { - lines = append(lines, prefix+seg) - continue - } - lines = append(lines, padding+seg) - } - firstLine = false - } - if len(lines) == 0 { - lines = append(lines, prefix) - } - return lines -} - -func (m *Model) renderBulletLabel(item Bullet) string { - label := stripBulletDecorations(item.Label, item) - if strings.TrimSpace(label) == "" { - label = "" - } - return label -} - -func stripBulletDecorations(label string, item Bullet) string { - trimmed := strings.TrimLeft(label, " \t") - signifierGlyph := item.Signifier.Glyph() - trimmed = stripLeadingToken(trimmed, item.Signifier.String()) - trimmed = stripLeadingToken(trimmed, signifierGlyph.Symbol) - for _, alias := range signifierGlyph.Aliases { - if len([]rune(strings.TrimSpace(alias))) == 1 { - trimmed = stripLeadingToken(trimmed, alias) - } - } - bulletGlyph := item.Bullet.Glyph() - trimmed = stripLeadingToken(trimmed, bulletGlyph.Symbol) - for _, alias := range bulletGlyph.Aliases { - if len([]rune(strings.TrimSpace(alias))) == 1 { - trimmed = stripLeadingToken(trimmed, alias) - } - } - trimmed = stripLeadingToken(trimmed, item.Bullet.String()) - return strings.TrimLeft(trimmed, " \t") -} - -func stripLeadingToken(label, token string) string { - token = strings.TrimSpace(token) - if token == "" { - return label - } - candidates := []string{ - token, - token + " ", - token + "\t", - } - for _, candidate := range candidates { - if strings.HasPrefix(label, candidate) { - remaining := strings.TrimPrefix(label, candidate) - return strings.TrimLeft(remaining, " \t") - } - } - return label -} - -func (m *Model) renderBulletLines(prefix string, item Bullet) []string { - text := m.renderBulletLabel(item) - return m.wrapBulletLines(prefix, text) -} - -func (m *Model) bulletStyles(item Bullet) (lipgloss.Style, lipgloss.Style) { - prefixStyle := lipgloss.NewStyle() - messageStyle := lipgloss.NewStyle() - switch item.Bullet { - case glyph.Completed, glyph.Irrelevant, glyph.MovedCollection, glyph.MovedFuture: - prefixStyle = prefixStyle.Foreground(lipgloss.Color("241")) - messageStyle = messageStyle.Foreground(lipgloss.Color("241")) - } - if item.Bullet == glyph.Irrelevant { - messageStyle = messageStyle.Strikethrough(true) - } - return prefixStyle, messageStyle -} - -func (m *Model) composeBulletPrefix(depth int, item Bullet, selected bool) string { - caret := " " - if selected { - caret = lipgloss.NewStyle().Foreground(lipgloss.Color("213")).Render("→") - } - signifier := item.Signifier.String() - if signifier == "" { - signifier = " " - } - indent := strings.Repeat(" ", depth) - symbol := item.Bullet.Glyph().Symbol - if symbol == "" { - symbol = item.Bullet.String() - } - if symbol == "" { - symbol = "-" - } - return caret + signifier + " " + indent + symbol + " " -} - -func (m *Model) renderVisibleLines() []string { - height := m.height - if height <= 0 { - height = 1 - } - lines := make([]string, 0, height) - - stickySection, hasSticky := m.visibleSection() - - appendLines := func(text string) { - if len(lines) >= height { - return - } - if text == "" { - lines = append(lines, "") - return - } - for _, part := range strings.Split(text, "\n") { - if len(lines) >= height { - break - } - lines = append(lines, part) - } - } - - skippedHeader := hasSticky - if hasSticky { - header := m.renderSectionHeader(stickySection, m.sectionActive(stickySection)) - appendLines(header) - } - - start := m.scroll - activeLine := m.currentLineIndex() - for i := start; i < len(m.lines) && len(lines) < height; i++ { - info := m.lines[i] - if hasSticky && skippedHeader && info.kind == lineHeader && info.section == stickySection { - skippedHeader = false - continue - } - appendLines(m.renderLine(i, i == activeLine)) - } - - for len(lines) < height { - lines = append(lines, "") - } - - return lines -} - -func (m *Model) currentLineIndex() int { - if m.cursor < 0 || m.cursor >= len(m.bulletLines) { - return -1 - } - return m.bulletLines[m.cursor] -} - func (m *Model) parentForLine(lineIdx int, sectionIdx int, indent int) (Bullet, bool) { if lineIdx <= 0 || lineIdx > len(m.lines) { return Bullet{}, false diff --git a/pkg/tui/components/collectiondetail/model_test.go b/pkg/tui/components/collectiondetail/model_test.go index c8b441a..f58ff05 100644 --- a/pkg/tui/components/collectiondetail/model_test.go +++ b/pkg/tui/components/collectiondetail/model_test.go @@ -8,6 +8,7 @@ import ( "github.com/muesli/reflow/ansi" "tableflip.dev/bujo/pkg/glyph" + "tableflip.dev/bujo/pkg/tui/events" ) func stripANSIString(s string) string { @@ -114,3 +115,68 @@ func TestPlaceholderSectionStaysVisibleWhenCursorEmpty(t *testing.T) { t.Fatalf("expected placeholder message, got:\n%s", view) } } + +func TestSelectionRetainedAfterReorderAndPlaceholderInsert(t *testing.T) { + model := NewModel([]Section{ + {ID: "A", Title: "A", Bullets: []Bullet{makeBullet("a1", "A1")}}, + {ID: "B", Title: "B", Bullets: []Bullet{makeBullet("b1", "B1")}}, + }) + model.SetSize(40, 8) + model.Focus() + if ok := model.focusBulletByID("B", "b1"); !ok { + t.Fatalf("expected to focus bullet b1") + } + + if !model.reorderSections([]string{"B", "A"}) { + t.Fatalf("expected reorder to change section order") + } + model.rebuildLookup() + model.refreshFromSections(false) + section, bullet, ok := model.CurrentSelection() + if !ok || section.ID != "B" || bullet.ID != "b1" { + t.Fatalf("expected selection to remain on b1, got section=%q bullet=%q", section.ID, bullet.ID) + } + + model.ensurePlaceholderSection(events.CollectionRef{ID: "C", Name: "C"}) + section, bullet, ok = model.CurrentSelection() + if !ok || section.ID != "B" || bullet.ID != "b1" { + t.Fatalf("expected selection to remain on b1 after placeholder insert, got section=%q bullet=%q", section.ID, bullet.ID) + } +} + +func TestHighlightAndSelectPlaceholderBehavior(t *testing.T) { + model := NewModel([]Section{ + {ID: "Inbox", Title: "Inbox", Bullets: []Bullet{makeBullet("a1", "A1")}}, + }) + model.SetSize(40, 8) + model.Focus() + + _, _ = model.Update(events.CollectionHighlightMsg{ + Component: "nav", + Collection: events.CollectionRef{ID: "Day/One", Name: "One"}, + RowKind: "day", + }) + if idx := model.sectionIndexForCollection(events.CollectionRef{ID: "Day/One", Name: "One"}); idx < 0 { + t.Fatalf("expected placeholder section to be created for day highlight") + } + + before := len(model.sections) + _, _ = model.Update(events.CollectionHighlightMsg{ + Component: "nav", + Collection: events.CollectionRef{ID: "Missing", Name: "Missing"}, + RowKind: "generic", + }) + if len(model.sections) != before { + t.Fatalf("expected non-day highlight to avoid creating placeholder section") + } + + _, _ = model.Update(events.CollectionSelectMsg{ + Component: "nav", + Collection: events.CollectionRef{ID: "Select/Me", Name: "Select"}, + RowKind: "day", + Exists: false, + }) + if idx := model.sectionIndexForCollection(events.CollectionRef{ID: "Select/Me", Name: "Select"}); idx < 0 { + t.Fatalf("expected placeholder section to be created for missing select") + } +} diff --git a/pkg/tui/components/collectiondetail/nav_sync.go b/pkg/tui/components/collectiondetail/nav_sync.go new file mode 100644 index 0000000..b6182b2 --- /dev/null +++ b/pkg/tui/components/collectiondetail/nav_sync.go @@ -0,0 +1,44 @@ +package collectiondetail + +import "tableflip.dev/bujo/pkg/tui/events" + +// handleNavHighlight syncs nav highlighting into the detail pane selection. +func (m *Model) handleNavHighlight(msg events.CollectionHighlightMsg) { + if m.sourceNav != "" && m.sourceNav != msg.Component { + return + } + if msg.RowKind == "day" { + m.ensurePlaceholderSection(msg.Collection) + } + if m.focused { + m.focusSectionForCollection(msg.Collection) + return + } + m.previewSectionForCollection(msg.Collection) +} + +// handleNavSelect ensures missing day collections are materialized on selection. +func (m *Model) handleNavSelect(msg events.CollectionSelectMsg) { + if m.sourceNav != "" && m.sourceNav != msg.Component { + return + } + if !msg.Exists { + m.ensurePlaceholderSection(msg.Collection) + m.focusSectionForCollection(msg.Collection) + } +} + +func (m *Model) previewSectionForCollection(ref events.CollectionRef) bool { + sectionIdx := m.sectionIndexForCollection(ref) + if sectionIdx < 0 { + return false + } + m.activeSection = sectionIdx + for idx, info := range m.lines { + if info.section == sectionIdx && info.kind == lineHeader { + m.scrollToLine(idx) + break + } + } + return true +} diff --git a/pkg/tui/components/collectiondetail/nav_sync_test.go b/pkg/tui/components/collectiondetail/nav_sync_test.go new file mode 100644 index 0000000..bbb52a3 --- /dev/null +++ b/pkg/tui/components/collectiondetail/nav_sync_test.go @@ -0,0 +1,45 @@ +package collectiondetail + +import ( + "testing" + + "tableflip.dev/bujo/pkg/glyph" + "tableflip.dev/bujo/pkg/tui/events" +) + +func TestNavHighlightPreviewDoesNotMoveCursorWhenBlurred(t *testing.T) { + model := NewModel([]Section{ + { + ID: "Inbox", + Title: "Inbox", + Bullets: []Bullet{ + {ID: "b1", Label: "First", Bullet: glyph.Task}, + }, + }, + { + ID: "Today", + Title: "Today", + Bullets: []Bullet{ + {ID: "b2", Label: "Second", Bullet: glyph.Task}, + }, + }, + }) + model.SetSize(40, 6) + model.cursor = 0 + model.activeSection = 0 + + model.handleNavHighlight(events.CollectionHighlightMsg{ + Component: "", + Collection: events.CollectionRef{ + ID: "Today", + Name: "Today", + }, + }) + + if model.activeSection != 1 { + t.Fatalf("expected active section 1, got %d", model.activeSection) + } + if model.cursor != 0 { + t.Fatalf("expected cursor to remain 0, got %d", model.cursor) + } +} diff --git a/pkg/tui/components/collectiondetail/view.go b/pkg/tui/components/collectiondetail/view.go new file mode 100644 index 0000000..be6f38c --- /dev/null +++ b/pkg/tui/components/collectiondetail/view.go @@ -0,0 +1,274 @@ +package collectiondetail + +import ( + "strings" + + "github.com/charmbracelet/lipgloss/v2" + "github.com/muesli/reflow/wordwrap" + + "tableflip.dev/bujo/pkg/glyph" + "tableflip.dev/bujo/pkg/tui/theme" +) + +// View renders the component. +func (m *Model) View() string { + if m.height <= 0 { + m.height = 20 + } + if m.width <= 0 { + m.width = 80 + } + + lines := m.renderVisibleLines() + return strings.Join(lines, "\n") +} + +func (m *Model) renderLine(idx int, selected bool) string { + if idx < 0 || idx >= len(m.lines) { + return "" + } + info := m.lines[idx] + if info.section < 0 || info.section >= len(m.sections) { + return "" + } + switch info.kind { + case lineHeader: + return m.renderSectionHeader(info.section, m.sectionActive(info.section)) + case lineSpacer: + return "" + case lineEmpty: + return m.renderEmptyLine(info.section, m.sectionActive(info.section)) + case lineItem: + return m.renderBulletInfo(info, selected) + default: + return "" + } +} + +func (m *Model) renderSectionHeader(section int, highlight bool) string { + sec := m.sections[section] + style := lipgloss.NewStyle().Bold(true).Underline(true) + if sec.Placeholder { + style = style.Italic(true).Foreground(lipgloss.Color("244")) + } + if highlight { + style = style.Inherit(theme.Default().Accent) + } + title := sec.Title + if title == "" { + title = "(untitled)" + } + if sec.Subtitle != "" { + title = title + " ▸ " + sec.Subtitle + } + return style.Width(m.width).Render(title) +} + +func (m *Model) renderEmptyLine(section int, highlight bool) string { + if section < 0 || section >= len(m.sections) { + return "" + } + sec := m.sections[section] + message := " " + style := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + if sec.Placeholder { + message = " (collection not yet created — add a bullet to save it)" + style = style.Italic(true).Foreground(lipgloss.Color("244")) + } + if highlight { + style = style.Inherit(theme.Default().Accent) + } + return style.Render(message) +} + +func (m *Model) renderBulletInfo(info lineInfo, selected bool) string { + item := info.bullet + prefix := m.composeBulletPrefix(info.indent, item, selected && m.focused) + lines := m.renderBulletLines(prefix, item) + prefixStyle, messageStyle := m.bulletStyles(item) + for i, line := range lines { + if i == 0 { + lines[i] = prefixStyle.Render(prefix) + messageStyle.Render(strings.TrimPrefix(line, prefix)) + } else { + lines[i] = messageStyle.Render(line) + } + } + return strings.Join(lines, "\n") +} + +func (m *Model) wrapBulletLines(prefix, text string) []string { + prefixWidth := lipgloss.Width(prefix) + if prefixWidth <= 0 { + prefixWidth = 2 + } + available := m.width - prefixWidth + if available < 10 { + available = 10 + } + + wrapLine := func(s string) []string { + if strings.TrimSpace(s) == "" { + return []string{""} + } + wrapped := wordwrap.String(s, available) + if wrapped == "" { + return []string{""} + } + return strings.Split(wrapped, "\n") + } + + padding := strings.Repeat(" ", prefixWidth) + lines := make([]string, 0, 4) + firstLine := true + for _, raw := range strings.Split(text, "\n") { + segments := wrapLine(raw) + for i, seg := range segments { + if firstLine && i == 0 { + lines = append(lines, prefix+seg) + continue + } + lines = append(lines, padding+seg) + } + firstLine = false + } + if len(lines) == 0 { + lines = append(lines, prefix) + } + return lines +} + +func (m *Model) renderBulletLabel(item Bullet) string { + label := stripBulletDecorations(item.Label, item) + if strings.TrimSpace(label) == "" { + label = "" + } + return label +} + +func stripBulletDecorations(label string, item Bullet) string { + trimmed := strings.TrimLeft(label, " \t") + signifierGlyph := item.Signifier.Glyph() + trimmed = stripLeadingToken(trimmed, item.Signifier.String()) + trimmed = stripLeadingToken(trimmed, signifierGlyph.Symbol) + for _, alias := range signifierGlyph.Aliases { + if len([]rune(strings.TrimSpace(alias))) == 1 { + trimmed = stripLeadingToken(trimmed, alias) + } + } + bulletGlyph := item.Bullet.Glyph() + trimmed = stripLeadingToken(trimmed, bulletGlyph.Symbol) + for _, alias := range bulletGlyph.Aliases { + if len([]rune(strings.TrimSpace(alias))) == 1 { + trimmed = stripLeadingToken(trimmed, alias) + } + } + trimmed = stripLeadingToken(trimmed, item.Bullet.String()) + return strings.TrimLeft(trimmed, " \t") +} + +func stripLeadingToken(label, token string) string { + token = strings.TrimSpace(token) + if token == "" { + return label + } + candidates := []string{ + token, + token + " ", + token + "\t", + } + for _, candidate := range candidates { + if strings.HasPrefix(label, candidate) { + remaining := strings.TrimPrefix(label, candidate) + return strings.TrimLeft(remaining, " \t") + } + } + return label +} + +func (m *Model) renderBulletLines(prefix string, item Bullet) []string { + text := m.renderBulletLabel(item) + return m.wrapBulletLines(prefix, text) +} + +func (m *Model) bulletStyles(item Bullet) (lipgloss.Style, lipgloss.Style) { + prefixStyle := lipgloss.NewStyle() + messageStyle := lipgloss.NewStyle() + switch item.Bullet { + case glyph.Completed, glyph.Irrelevant, glyph.MovedCollection, glyph.MovedFuture: + prefixStyle = prefixStyle.Foreground(lipgloss.Color("241")) + messageStyle = messageStyle.Foreground(lipgloss.Color("241")) + } + if item.Bullet == glyph.Irrelevant { + messageStyle = messageStyle.Strikethrough(true) + } + return prefixStyle, messageStyle +} + +func (m *Model) composeBulletPrefix(depth int, item Bullet, selected bool) string { + caret := " " + if selected { + caret = theme.Default().Accent.Render("→") + } + signifier := item.Signifier.String() + if signifier == "" { + signifier = " " + } + indent := strings.Repeat(" ", depth) + symbol := item.Bullet.Glyph().Symbol + if symbol == "" { + symbol = item.Bullet.String() + } + if symbol == "" { + symbol = "-" + } + return caret + signifier + " " + indent + symbol + " " +} + +func (m *Model) renderVisibleLines() []string { + height := m.height + if height <= 0 { + height = 1 + } + lines := make([]string, 0, height) + + stickySection, hasSticky := m.visibleSection() + + appendLines := func(text string) { + if len(lines) >= height { + return + } + if text == "" { + lines = append(lines, "") + return + } + for _, part := range strings.Split(text, "\n") { + if len(lines) >= height { + break + } + lines = append(lines, part) + } + } + + skippedHeader := hasSticky + if hasSticky { + header := m.renderSectionHeader(stickySection, m.sectionActive(stickySection)) + appendLines(header) + } + + start := m.scroll + activeLine := m.currentLineIndex() + for i := start; i < len(m.lines) && len(lines) < height; i++ { + info := m.lines[i] + if hasSticky && skippedHeader && info.kind == lineHeader && info.section == stickySection { + skippedHeader = false + continue + } + appendLines(m.renderLine(i, i == activeLine)) + } + + for len(lines) < height { + lines = append(lines, "") + } + + return lines +} diff --git a/pkg/tui/components/collectionnav/calendar.go b/pkg/tui/components/collectionnav/calendar.go new file mode 100644 index 0000000..95c4e6f --- /dev/null +++ b/pkg/tui/components/collectionnav/calendar.go @@ -0,0 +1,265 @@ +package collectionnav + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + + "tableflip.dev/bujo/pkg/collection" + "tableflip.dev/bujo/pkg/collection/viewmodel" + "tableflip.dev/bujo/pkg/tui/components/index" +) + +func (m *Model) pruneCalendars() { + if len(m.calendars) == 0 { + return + } + valid := make(map[string]struct{}) + var stack []*viewmodel.ParsedCollection + stack = append(stack, m.roots...) + for len(stack) > 0 { + last := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if last == nil { + continue + } + valid[last.ID] = struct{}{} + if len(last.Children) > 0 { + stack = append(stack, last.Children...) + } + } + for id := range m.calendars { + if _, ok := valid[id]; !ok { + delete(m.calendars, id) + } + } + if len(m.calendarExtras) > 0 { + for id := range m.calendarExtras { + if _, ok := valid[id]; !ok { + delete(m.calendarExtras, id) + } + } + } +} + +func (m *Model) ensureCalendar(col *viewmodel.ParsedCollection) *index.CalendarModel { + if col == nil { + return nil + } + cal, ok := m.calendars[col.ID] + if !ok { + cal = index.NewCalendarModel(col.Name, 0, m.now()) + m.calendars[col.ID] = cal + } + cal.SetNow(m.now()) + cal.SetMonth(col.Name) + cal.SetChildren(m.calendarChildren(col)) + return cal +} + +func (m *Model) calendarChildren(col *viewmodel.ParsedCollection) []index.CollectionItem { + if col == nil { + return nil + } + items := make(map[int]index.CollectionItem) + addItem := func(name, resolved string, date time.Time) { + if resolved == "" { + return + } + dayNum := 0 + if !date.IsZero() { + dayNum = date.Day() + } + if dayNum <= 0 { + if parsed := parseDayFromPath(resolved); parsed > 0 { + dayNum = parsed + } + } + if dayNum <= 0 { + return + } + if _, exists := items[dayNum]; !exists { + items[dayNum] = index.CollectionItem{Name: name, Resolved: resolved} + } + } + if len(col.Days) > 0 { + for _, day := range col.Days { + addItem(day.Name, day.ID, day.Date) + } + } + if len(col.Children) > 0 { + for _, child := range col.Children { + addItem(child.Name, child.ID, child.Day) + } + } + if extra := m.calendarExtras[col.ID]; extra != nil { + for day, item := range extra { + if _, exists := items[day]; !exists { + items[day] = item + } + } + } + if len(items) == 0 { + return nil + } + keys := make([]int, 0, len(items)) + for day := range items { + keys = append(keys, day) + } + sort.Ints(keys) + result := make([]index.CollectionItem, 0, len(keys)) + for _, day := range keys { + result = append(result, items[day]) + } + return result +} + +func (m *Model) handleCalendarMovement(msg tea.KeyMsg) (bool, tea.Cmd) { + if !(key.Matches(msg, m.keys.MoveLeft) || key.Matches(msg, m.keys.MoveRight) || + key.Matches(msg, m.keys.MoveUp) || key.Matches(msg, m.keys.MoveDown)) { + return false, nil + } + item, ok := m.selectedNavItem() + if !ok || item.collection == nil || item.kind != RowKindDaily || item.folded { + return false, nil + } + cal := m.ensureCalendar(item.collection) + if cal == nil { + return false, nil + } + next, cmd := cal.Update(msg) + if model, ok := next.(*index.CalendarModel); ok { + m.calendars[item.collection.ID] = model + } + m.refreshItems(item.collection.ID) + return true, cmd +} + +func (m *Model) handleCalendarFocusMsg(msg index.CalendarFocusMsg) { + if msg.Direction == 0 { + return + } + idx := m.list.Index() + if idx < 0 { + return + } + if msg.Direction < 0 && idx > 0 { + m.list.Select(idx - 1) + } else if msg.Direction > 0 && idx < len(m.list.Items())-1 { + m.list.Select(idx + 1) + } + m.syncCalendarFocus() +} + +func (m *Model) now() time.Time { + if m.nowFn != nil { + return m.nowFn() + } + return time.Now() +} + +func (m *Model) syncCalendarFocus() { + item, ok := m.selectedNavItem() + var nextID string + var nextCol *viewmodel.ParsedCollection + var canFocus bool + if ok && item.collection != nil && item.kind == RowKindDaily && !item.folded { + nextID = item.collection.ID + nextCol = item.collection + canFocus = true + } + if m.activeCal == nextID { + if canFocus && nextID != "" { + if cal := m.ensureCalendar(nextCol); cal != nil && !cal.Focused() { + cal.SetFocused(true) + } + } + return + } + if prev := m.activeCal; prev != "" { + if cal, ok := m.calendars[prev]; ok { + cal.SetFocused(false) + } + } + m.activeCal = nextID + if !canFocus || nextID == "" { + return + } + if cal := m.ensureCalendar(nextCol); cal != nil { + cal.SetFocused(true) + } +} + +func (m *Model) selectedCalendarDay(col *viewmodel.ParsedCollection) (*viewmodel.ParsedCollection, bool) { + if col == nil { + return nil, false + } + cal := m.calendars[col.ID] + if cal == nil { + return nil, false + } + dayNum := cal.SelectedDay() + if dayNum <= 0 { + return nil, false + } + for _, child := range col.Children { + if child == nil || child.Day.IsZero() { + continue + } + if child.Day.Day() == dayNum { + return child, true + } + } + virtual := m.virtualDay(col, dayNum) + if virtual == nil { + return nil, false + } + return virtual, false +} + +func (m *Model) virtualDay(col *viewmodel.ParsedCollection, day int) *viewmodel.ParsedCollection { + if col == nil || day <= 0 { + return nil + } + monthTime := m.monthTime(col) + if monthTime.IsZero() { + return nil + } + lastOfMonth := time.Date(monthTime.Year(), monthTime.Month()+1, 0, 0, 0, 0, 0, monthTime.Location()) + if day > lastOfMonth.Day() { + return nil + } + dayTime := time.Date(monthTime.Year(), monthTime.Month(), day, 0, 0, 0, 0, monthTime.Location()) + dayName := dayTime.Format(dayLayout) + return &viewmodel.ParsedCollection{ + ID: fmt.Sprintf("%s/%s", col.ID, dayName), + Name: dayName, + Type: collection.TypeGeneric, + Exists: false, + ParentID: col.ID, + Depth: col.Depth + 1, + Priority: col.Priority + 1, + SortKey: strings.ToLower(dayName), + Month: monthTime, + Day: dayTime, + } +} + +func (m *Model) monthTime(col *viewmodel.ParsedCollection) time.Time { + if col == nil { + return time.Time{} + } + if !col.Month.IsZero() { + return col.Month + } + if collection.IsMonthName(col.Name) { + if t, err := time.Parse(monthLayout, col.Name); err == nil { + return t + } + } + return time.Time{} +} diff --git a/pkg/tui/components/collectionnav/keymap.go b/pkg/tui/components/collectionnav/keymap.go new file mode 100644 index 0000000..7da55ee --- /dev/null +++ b/pkg/tui/components/collectionnav/keymap.go @@ -0,0 +1,28 @@ +package collectionnav + +import "github.com/charmbracelet/bubbles/v2/key" + +// keyMap captures navigation bindings for the collection list. +type keyMap struct { + Quit key.Binding + Select key.Binding + Expand key.Binding + Collapse key.Binding + MoveUp key.Binding + MoveDown key.Binding + MoveLeft key.Binding + MoveRight key.Binding +} + +func defaultKeyMap() keyMap { + return keyMap{ + Quit: key.NewBinding(key.WithKeys("q")), + Select: key.NewBinding(key.WithKeys("enter", " ")), + Expand: key.NewBinding(key.WithKeys("right", "l", "]")), + Collapse: key.NewBinding(key.WithKeys("left", "h", "[")), + MoveUp: key.NewBinding(key.WithKeys("up", "k")), + MoveDown: key.NewBinding(key.WithKeys("down", "j")), + MoveLeft: key.NewBinding(key.WithKeys("left", "h")), + MoveRight: key.NewBinding(key.WithKeys("right", "l")), + } +} diff --git a/pkg/tui/components/collectionnav/model.go b/pkg/tui/components/collectionnav/model.go index 2451fe1..c232857 100644 --- a/pkg/tui/components/collectionnav/model.go +++ b/pkg/tui/components/collectionnav/model.go @@ -64,6 +64,7 @@ type SelectionMsg = events.CollectionSelectMsg type Model struct { list list.Model focused bool + keys keyMap roots []*viewmodel.ParsedCollection metas []collection.Meta @@ -155,6 +156,7 @@ func NewModel(collections []*viewmodel.ParsedCollection) *Model { index: make(map[string]*viewmodel.ParsedCollection), nowFn: time.Now, id: events.ComponentID("collectionnav"), + keys: defaultKeyMap(), blurOnSelect: true, } delegate := newNavDelegateWithFocus(m) @@ -284,7 +286,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.focused { return m, nil } - if keyMsg.String() == "q" { + if key.Matches(keyMsg, m.keys.Quit) { return m, nil } if handled, cmd := m.handleCalendarMovement(keyMsg); handled { @@ -569,17 +571,17 @@ func (m *Model) SelectedCollection() (*viewmodel.ParsedCollection, RowKind, bool } func (m *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { - switch msg.String() { - case "enter", " ": + switch { + case key.Matches(msg, m.keys.Select): if cmd := m.selectionCmd(); cmd != nil { return cmd } - case "left", "h", "[": + case key.Matches(msg, m.keys.Collapse): if col := m.collapseSelected(); col != nil { m.refreshItems(col.ID) return nil } - case "right", "l", "]": + case key.Matches(msg, m.keys.Expand): if col := m.expandSelected(); col != nil { m.refreshItems(col.ID) return nil @@ -884,38 +886,6 @@ func (m *Model) pruneFoldState() { } } -func (m *Model) pruneCalendars() { - if len(m.calendars) == 0 { - return - } - valid := make(map[string]struct{}) - var stack []*viewmodel.ParsedCollection - stack = append(stack, m.roots...) - for len(stack) > 0 { - last := stack[len(stack)-1] - stack = stack[:len(stack)-1] - if last == nil { - continue - } - valid[last.ID] = struct{}{} - if len(last.Children) > 0 { - stack = append(stack, last.Children...) - } - } - for id := range m.calendars { - if _, ok := valid[id]; !ok { - delete(m.calendars, id) - } - } - if len(m.calendarExtras) > 0 { - for id := range m.calendarExtras { - if _, ok := valid[id]; !ok { - delete(m.calendarExtras, id) - } - } - } -} - func (m *Model) rebuildIndex() { if m.index == nil { m.index = make(map[string]*viewmodel.ParsedCollection) @@ -1004,13 +974,7 @@ func dayInfoFromCollection(id, title string) (string, string, int) { } func leafName(path string) string { - if path == "" { - return "" - } - if idx := strings.LastIndex(path, "/"); idx >= 0 { - return path[idx+1:] - } - return path + return uiutil.LastSegment(path) } func selectCmd(component events.ComponentID, col *viewmodel.ParsedCollection, kind RowKind, exists bool) tea.Cmd { @@ -1156,155 +1120,6 @@ func rowKindFor(col *viewmodel.ParsedCollection, depth int) RowKind { return RowKindGeneric } -func (m *Model) ensureCalendar(col *viewmodel.ParsedCollection) *index.CalendarModel { - if col == nil { - return nil - } - cal, ok := m.calendars[col.ID] - if !ok { - cal = index.NewCalendarModel(col.Name, 0, m.now()) - m.calendars[col.ID] = cal - } - cal.SetNow(m.now()) - cal.SetMonth(col.Name) - cal.SetChildren(m.calendarChildren(col)) - return cal -} - -func (m *Model) calendarChildren(col *viewmodel.ParsedCollection) []index.CollectionItem { - if col == nil { - return nil - } - items := make(map[int]index.CollectionItem) - addItem := func(name, resolved string, date time.Time) { - if resolved == "" { - return - } - dayNum := 0 - if !date.IsZero() { - dayNum = date.Day() - } - if dayNum <= 0 { - if parsed := parseDayFromPath(resolved); parsed > 0 { - dayNum = parsed - } - } - if dayNum <= 0 { - return - } - if _, exists := items[dayNum]; !exists { - items[dayNum] = index.CollectionItem{Name: name, Resolved: resolved} - } - } - if len(col.Days) > 0 { - for _, day := range col.Days { - addItem(day.Name, day.ID, day.Date) - } - } - if len(col.Children) > 0 { - for _, child := range col.Children { - addItem(child.Name, child.ID, child.Day) - } - } - if extra := m.calendarExtras[col.ID]; extra != nil { - for day, item := range extra { - if _, exists := items[day]; !exists { - items[day] = item - } - } - } - if len(items) == 0 { - return nil - } - keys := make([]int, 0, len(items)) - for day := range items { - keys = append(keys, day) - } - sort.Ints(keys) - result := make([]index.CollectionItem, 0, len(keys)) - for _, day := range keys { - result = append(result, items[day]) - } - return result -} - -func (m *Model) handleCalendarMovement(msg tea.KeyMsg) (bool, tea.Cmd) { - switch msg.String() { - case "left", "right", "up", "down", "h", "j", "k", "l": - item, ok := m.selectedNavItem() - if !ok || item.collection == nil || item.kind != RowKindDaily || item.folded { - return false, nil - } - cal := m.ensureCalendar(item.collection) - if cal == nil { - return false, nil - } - next, cmd := cal.Update(msg) - if model, ok := next.(*index.CalendarModel); ok { - m.calendars[item.collection.ID] = model - } - m.refreshItems(item.collection.ID) - return true, cmd - default: - return false, nil - } -} - -func (m *Model) handleCalendarFocusMsg(msg index.CalendarFocusMsg) { - if msg.Direction == 0 { - return - } - idx := m.list.Index() - if idx < 0 { - return - } - if msg.Direction < 0 && idx > 0 { - m.list.Select(idx - 1) - } else if msg.Direction > 0 && idx < len(m.list.Items())-1 { - m.list.Select(idx + 1) - } - m.syncCalendarFocus() -} - -func (m *Model) now() time.Time { - if m.nowFn != nil { - return m.nowFn() - } - return time.Now() -} - -func (m *Model) syncCalendarFocus() { - item, ok := m.selectedNavItem() - var nextID string - var nextCol *viewmodel.ParsedCollection - var canFocus bool - if ok && item.collection != nil && item.kind == RowKindDaily && !item.folded { - nextID = item.collection.ID - nextCol = item.collection - canFocus = true - } - if m.activeCal == nextID { - if canFocus && nextID != "" { - if cal := m.ensureCalendar(nextCol); cal != nil && !cal.Focused() { - cal.SetFocused(true) - } - } - return - } - if prev := m.activeCal; prev != "" { - if cal, ok := m.calendars[prev]; ok { - cal.SetFocused(false) - } - } - m.activeCal = nextID - if !canFocus || nextID == "" { - return - } - if cal := m.ensureCalendar(nextCol); cal != nil { - cal.SetFocused(true) - } -} - func (m *Model) selectionTarget(item navItem) (*viewmodel.ParsedCollection, RowKind, bool) { if item.collection == nil { return nil, RowKindGeneric, false @@ -1317,76 +1132,6 @@ func (m *Model) selectionTarget(item navItem) (*viewmodel.ParsedCollection, RowK return item.collection, item.kind, item.exists } -func (m *Model) selectedCalendarDay(col *viewmodel.ParsedCollection) (*viewmodel.ParsedCollection, bool) { - if col == nil { - return nil, false - } - cal := m.calendars[col.ID] - if cal == nil { - return nil, false - } - dayNum := cal.SelectedDay() - if dayNum <= 0 { - return nil, false - } - for _, child := range col.Children { - if child == nil || child.Day.IsZero() { - continue - } - if child.Day.Day() == dayNum { - return child, true - } - } - virtual := m.virtualDay(col, dayNum) - if virtual == nil { - return nil, false - } - return virtual, false -} - -func (m *Model) virtualDay(col *viewmodel.ParsedCollection, day int) *viewmodel.ParsedCollection { - if col == nil || day <= 0 { - return nil - } - monthTime := m.monthTime(col) - if monthTime.IsZero() { - return nil - } - lastOfMonth := time.Date(monthTime.Year(), monthTime.Month()+1, 0, 0, 0, 0, 0, monthTime.Location()) - if day > lastOfMonth.Day() { - return nil - } - dayTime := time.Date(monthTime.Year(), monthTime.Month(), day, 0, 0, 0, 0, monthTime.Location()) - dayName := dayTime.Format(dayLayout) - return &viewmodel.ParsedCollection{ - ID: fmt.Sprintf("%s/%s", col.ID, dayName), - Name: dayName, - Type: collection.TypeGeneric, - Exists: false, - ParentID: col.ID, - Depth: col.Depth + 1, - Priority: col.Priority + 1, - SortKey: strings.ToLower(dayName), - Month: monthTime, - Day: dayTime, - } -} - -func (m *Model) monthTime(col *viewmodel.ParsedCollection) time.Time { - if col == nil { - return time.Time{} - } - if !col.Month.IsZero() { - return col.Month - } - if collection.IsMonthName(col.Name) { - if t, err := time.Parse(monthLayout, col.Name); err == nil { - return t - } - } - return time.Time{} -} - func (m *Model) handleCollectionChange(msg events.CollectionChangeMsg) bool { m.ensureMetaSnapshot() var changed bool diff --git a/pkg/tui/components/collectionnav/model_test.go b/pkg/tui/components/collectionnav/model_test.go index a9417f6..19d247f 100644 --- a/pkg/tui/components/collectionnav/model_test.go +++ b/pkg/tui/components/collectionnav/model_test.go @@ -7,6 +7,7 @@ import ( "tableflip.dev/bujo/pkg/collection" "tableflip.dev/bujo/pkg/collection/viewmodel" + indexview "tableflip.dev/bujo/pkg/tui/components/index" "tableflip.dev/bujo/pkg/tui/events" ) @@ -88,3 +89,96 @@ func TestSelectedCalendarDayCreatesVirtualDay(t *testing.T) { t.Fatalf("expected day name January 5, 2024, got %q", day.Name) } } + +func TestCalendarChildrenSortedByDay(t *testing.T) { + monthTime := time.Date(2024, time.March, 1, 0, 0, 0, 0, time.UTC) + day2 := time.Date(2024, time.March, 2, 0, 0, 0, 0, time.UTC) + day5 := time.Date(2024, time.March, 5, 0, 0, 0, 0, time.UTC) + day10 := time.Date(2024, time.March, 10, 0, 0, 0, 0, time.UTC) + + month := &viewmodel.ParsedCollection{ + ID: "March 2024", + Name: "March 2024", + Type: collection.TypeDaily, + Exists: true, + Month: monthTime, + Days: []viewmodel.DaySummary{ + {ID: "March 2024/March 10, 2024", Name: "March 10, 2024", Date: day10}, + {ID: "March 2024/March 2, 2024", Name: "March 2, 2024", Date: day2}, + }, + Children: []*viewmodel.ParsedCollection{ + {ID: "March 2024/March 5, 2024", Name: "March 5, 2024", Day: day5}, + }, + } + + model := NewModel([]*viewmodel.ParsedCollection{month}) + model.calendarExtras = map[string]map[int]indexview.CollectionItem{ + month.ID: { + 3: {Name: "March 3, 2024", Resolved: "March 2024/March 3, 2024"}, + }, + } + + children := model.calendarChildren(month) + if len(children) != 4 { + t.Fatalf("expected 4 calendar children, got %d", len(children)) + } + got := []string{children[0].Name, children[1].Name, children[2].Name, children[3].Name} + want := []string{"March 2, 2024", "March 3, 2024", "March 5, 2024", "March 10, 2024"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("expected day %d to be %q, got %q", i, want[i], got[i]) + } + } +} + +func TestHighlightAndSelectMessagesForDayAndGeneric(t *testing.T) { + monthTime := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC) + month := &viewmodel.ParsedCollection{ + ID: "January 2024", + Name: "January 2024", + Type: collection.TypeDaily, + Exists: true, + Month: monthTime, + } + generic := &viewmodel.ParsedCollection{ + ID: "Inbox", + Name: "Inbox", + Type: collection.TypeGeneric, + Exists: true, + } + + model := NewModel([]*viewmodel.ParsedCollection{month, generic}) + model.SetNow(monthTime) + _ = model.Focus() + if cal := model.ensureCalendar(month); cal != nil { + cal.SetSelected(3) + } + + model.list.Select(0) + if cmd := model.highlightCmd(); cmd != nil { + msg := cmd().(events.CollectionHighlightMsg) + if msg.RowKind != "day" { + t.Fatalf("expected day highlight, got %q", msg.RowKind) + } + } + + item, ok := model.selectedNavItem() + if !ok { + t.Fatalf("expected selected nav item") + } + target, kind, exists := model.selectionTarget(item) + selectMsg := selectCmd(model.id, target, kind, exists)().(events.CollectionSelectMsg) + if selectMsg.RowKind != "day" { + t.Fatalf("expected day select, got %q", selectMsg.RowKind) + } + + model.list.Select(1) + item, ok = model.selectedNavItem() + if !ok { + t.Fatalf("expected selected nav item") + } + _, kind, _ = model.selectionTarget(item) + if kind.String() != "generic" { + t.Fatalf("expected generic kind, got %q", kind.String()) + } +} diff --git a/pkg/tui/components/command/input.go b/pkg/tui/components/command/input.go new file mode 100644 index 0000000..84b7f7f --- /dev/null +++ b/pkg/tui/components/command/input.go @@ -0,0 +1,126 @@ +package command + +import ( + "strings" + + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + + "tableflip.dev/bujo/pkg/tui/events" +) + +// Focus gives the command prompt keyboard focus. +func (m *Model) Focus() { + m.focused = true + m.prompt.Focus() +} + +// Blur releases focus. +func (m *Model) Blur() { + m.focused = false + m.prompt.Blur() +} + +// BeginInput switches the command bar into input mode. +func (m *Model) BeginInput(initial string) tea.Cmd { + m.mode = ModeInput + m.prompt.SetValue(initial) + m.lastPromptValue = initial + m.prompt.CursorEnd() + m.Focus() + m.applySuggestionFilter(initial, true) + return tea.Batch(m.prompt.Focus(), events.CommandChangeCmd(m.id, initial, events.CommandModeInput)) +} + +// ExitInput returns the command bar to passive mode. +func (m *Model) ExitInput() tea.Cmd { + m.mode = ModePassive + m.prompt.Blur() + m.lastPromptValue = "" + m.filteredSuggestions = nil + m.suggestionOverlay = "" + m.suggestionIndex = -1 + m.suggestionOriginal = "" + m.suggestionWindowStart = 0 + return tea.Batch(events.CommandChangeCmd(m.id, "", events.CommandModePassive)) +} + +// Update routes messages to the command prompt and suggestion overlay. +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + handledKey := false + + switch msg := msg.(type) { + case tea.KeyMsg: + if handledKey { + break + } + switch { + case key.Matches(msg, m.keys.CancelInput): + if m.mode == ModeInput { + if m.clearSuggestionSelection() { + handledKey = true + newVal := m.prompt.Value() + if newVal != m.lastPromptValue { + m.lastPromptValue = newVal + cmds = append(cmds, events.CommandChangeCmd(m.id, newVal, events.CommandModeInput)) + } + break + } + handledKey = true + cmds = append(cmds, m.ExitInput(), events.CommandCancelCmd(m.id)) + m.prompt.Blur() + } + case key.Matches(msg, m.keys.Submit): + if m.mode == ModeInput { + handledKey = true + value := strings.TrimSpace(m.prompt.Value()) + if value != "" { + cmds = append(cmds, events.CommandSubmitCmd(m.id, value)) + m.SetStatus(value) + } + cmds = append(cmds, m.ExitInput()) + } + case key.Matches(msg, m.keys.SuggestPrev): + if m.mode == ModeInput && m.cycleSuggestion(-1) { + handledKey = true + newVal := m.prompt.Value() + if newVal != m.lastPromptValue { + m.lastPromptValue = newVal + cmds = append(cmds, events.CommandChangeCmd(m.id, newVal, events.CommandModeInput)) + } + } + case key.Matches(msg, m.keys.SuggestNext): + if m.mode == ModeInput && m.cycleSuggestion(1) { + handledKey = true + newVal := m.prompt.Value() + if newVal != m.lastPromptValue { + m.lastPromptValue = newVal + cmds = append(cmds, events.CommandChangeCmd(m.id, newVal, events.CommandModeInput)) + } + } + case m.mode == ModePassive && key.Matches(msg, m.keys.StartInput): + cmds = append(cmds, m.BeginInput("")) + return m, tea.Batch(cmds...) + } + } + + if !handledKey && m.mode == ModeInput { + prev := m.prompt.Value() + var cmd tea.Cmd + m.prompt, cmd = m.prompt.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + if newVal := m.prompt.Value(); newVal != prev { + m.lastPromptValue = newVal + m.applySuggestionFilter(newVal, true) + cmds = append(cmds, events.CommandChangeCmd(m.id, newVal, events.CommandModeInput)) + } + } + + if len(cmds) == 0 { + return m, nil + } + return m, tea.Batch(cmds...) +} diff --git a/pkg/tui/components/command/keymap.go b/pkg/tui/components/command/keymap.go new file mode 100644 index 0000000..408a03c --- /dev/null +++ b/pkg/tui/components/command/keymap.go @@ -0,0 +1,24 @@ +package command + +import "github.com/charmbracelet/bubbles/v2/key" + +// keyMap captures command prompt input bindings. +type keyMap struct { + StartInput key.Binding + CancelInput key.Binding + Submit key.Binding + SuggestPrev key.Binding + SuggestNext key.Binding + ConfirmSuggest key.Binding +} + +func defaultKeyMap() keyMap { + return keyMap{ + StartInput: key.NewBinding(key.WithKeys(":")), + CancelInput: key.NewBinding(key.WithKeys("esc")), + Submit: key.NewBinding(key.WithKeys("enter")), + SuggestPrev: key.NewBinding(key.WithKeys("up", "shift+tab")), + SuggestNext: key.NewBinding(key.WithKeys("down", "tab")), + ConfirmSuggest: key.NewBinding(key.WithKeys("enter")), + } +} diff --git a/pkg/tui/components/command/layout.go b/pkg/tui/components/command/layout.go new file mode 100644 index 0000000..043abb8 --- /dev/null +++ b/pkg/tui/components/command/layout.go @@ -0,0 +1,27 @@ +package command + +// SetSize configures the viewport dimensions the component manages. +func (m *Model) SetSize(width, height int) { + if width <= 0 { + width = 1 + } + if height <= 1 { + height = 2 + } + m.width = width + m.height = height + m.contentHeight = height - 1 + if m.contentHeight <= 0 { + m.contentHeight = 1 + } + promptWidth := width - len(m.promptPrefix) + if promptWidth < 5 { + promptWidth = width - 1 + if promptWidth < 1 { + promptWidth = 1 + } + } + m.prompt.SetWidth(promptWidth) + m.updateSuggestionWindow() + m.refreshSuggestionOverlay() +} diff --git a/pkg/tui/components/command/model.go b/pkg/tui/components/command/model.go index d8f6b34..3560ffd 100644 --- a/pkg/tui/components/command/model.go +++ b/pkg/tui/components/command/model.go @@ -1,8 +1,6 @@ package command import ( - "strings" - "github.com/charmbracelet/bubbles/v2/textinput" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" @@ -13,9 +11,8 @@ import ( // Overlay defines the interface command overlays must satisfy. type Overlay interface { - Init() tea.Cmd - Update(tea.Msg) (Overlay, tea.Cmd) - View() (string, *tea.Cursor) + tea.Model + tea.CursorModel SetSize(width, height int) } @@ -60,6 +57,7 @@ type Model struct { id events.ComponentID mode Mode focused bool + keys keyMap width int height int @@ -106,6 +104,7 @@ func NewModel(opts Options) *Model { status: opts.StatusText, prompt: prompt, promptPrefix: opts.PromptPrefix, + keys: defaultKeyMap(), suggestionIndex: -1, suggestionLimit: 8, suggestionPlacement: overlaymgr.Placement{ @@ -121,32 +120,6 @@ func (m *Model) ID() events.ComponentID { return m.id } // Init implements tea.Model. func (m *Model) Init() tea.Cmd { return nil } -// SetSize configures the viewport dimensions the component manages. -func (m *Model) SetSize(width, height int) { - if width <= 0 { - width = 1 - } - if height <= 1 { - height = 2 - } - m.width = width - m.height = height - m.contentHeight = height - 1 - if m.contentHeight <= 0 { - m.contentHeight = 1 - } - promptWidth := width - len(m.promptPrefix) - if promptWidth < 5 { - promptWidth = width - 1 - if promptWidth < 1 { - promptWidth = 1 - } - } - m.prompt.SetWidth(promptWidth) - m.updateSuggestionWindow() - m.refreshSuggestionOverlay() -} - // SetContent stores the content view that should appear above the command bar. func (m *Model) SetContent(view string, cursor *tea.Cursor) { m.contentView = view @@ -182,312 +155,6 @@ func (m *Model) SetSuggestionLimit(limit int) { m.refreshSuggestionOverlay() } -func (m *Model) applySuggestionFilter(value string, resetSelection bool) { - if m.mode != ModeInput { - m.filteredSuggestions = nil - m.suggestionOverlay = "" - m.suggestionWindowStart = 0 - return - } - - prefix := strings.TrimSpace(strings.ToLower(value)) - matches := make([]SuggestionOption, 0, len(m.suggestions)) - if prefix == "" { - matches = append(matches, m.suggestions...) - } else { - seen := make(map[string]struct{}, len(m.suggestions)) - for _, opt := range m.suggestions { - name := strings.ToLower(opt.Name) - if strings.HasPrefix(name, prefix) { - matches = append(matches, opt) - seen[opt.Name] = struct{}{} - } - } - for _, opt := range m.suggestions { - if _, ok := seen[opt.Name]; ok { - continue - } - name := strings.ToLower(opt.Name) - if strings.Contains(name, prefix) { - matches = append(matches, opt) - } - } - } - - if cap(m.filteredSuggestions) < len(matches) { - m.filteredSuggestions = make([]SuggestionOption, 0, len(matches)) - } - m.filteredSuggestions = m.filteredSuggestions[:0] - m.filteredSuggestions = append(m.filteredSuggestions, matches...) - - if resetSelection { - m.suggestionIndex = -1 - m.suggestionWindowStart = 0 - m.suggestionOriginal = value - } else { - if m.suggestionIndex >= len(m.filteredSuggestions) { - m.suggestionIndex = len(m.filteredSuggestions) - 1 - } - if m.suggestionIndex < -1 { - m.suggestionIndex = -1 - } - } - - m.updateSuggestionWindow() - m.refreshSuggestionOverlay() -} - -func (m *Model) effectiveSuggestionLimit() int { - total := len(m.filteredSuggestions) - if total == 0 { - return 0 - } - limit := m.suggestionLimit - if limit <= 0 || limit > total { - limit = total - } - maxRows := m.height - 1 - if maxRows < 0 { - maxRows = 0 - } - if limit > maxRows { - limit = maxRows - } - if limit < 0 { - limit = 0 - } - return limit -} - -func (m *Model) updateSuggestionWindow() { - total := len(m.filteredSuggestions) - if total == 0 { - m.suggestionWindowStart = 0 - return - } - - limit := m.effectiveSuggestionLimit() - if limit <= 0 { - m.suggestionWindowStart = 0 - return - } - - if m.suggestionWindowStart > total-limit { - m.suggestionWindowStart = total - limit - } - if m.suggestionWindowStart < 0 { - m.suggestionWindowStart = 0 - } - - if m.suggestionIndex >= 0 { - if m.suggestionIndex < m.suggestionWindowStart { - m.suggestionWindowStart = m.suggestionIndex - } else if m.suggestionIndex >= m.suggestionWindowStart+limit { - m.suggestionWindowStart = m.suggestionIndex - limit + 1 - } - } -} - -func (m *Model) refreshSuggestionOverlay() { - if m.mode != ModeInput || len(m.filteredSuggestions) == 0 { - m.suggestionOverlay = "" - return - } - limit := m.effectiveSuggestionLimit() - if limit <= 0 { - m.suggestionOverlay = "" - return - } - start := m.suggestionWindowStart - if start < 0 { - start = 0 - } - maxStart := len(m.filteredSuggestions) - limit - if maxStart < 0 { - maxStart = 0 - } - if start > maxStart { - start = maxStart - } - end := start + limit - if end > len(m.filteredSuggestions) { - end = len(m.filteredSuggestions) - } - - maxWidth := 0 - count := end - start - if count <= 0 { - m.suggestionOverlay = "" - return - } - rows := make([]string, count) - nameStyle := lipgloss.NewStyle().Bold(true) - descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("244")) - primaryStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")) - primaryDesc := lipgloss.NewStyle().Foreground(lipgloss.Color("213")) - - for i := start; i < end; i++ { - opt := m.filteredSuggestions[i] - marker := " " - nameRender := nameStyle.Render(opt.Name) - descRender := descStyle.Render(strings.TrimSpace(opt.Description)) - if i == m.suggestionIndex { - marker = "→ " - nameRender = primaryStyle.Render(opt.Name) - descRender = primaryDesc.Render(strings.TrimSpace(opt.Description)) - } - line := marker + strings.TrimSpace(nameRender) - if dr := strings.TrimSpace(descRender); dr != "" { - line += " " + dr - } - rowIdx := i - start - rows[rowIdx] = line - if w := lipgloss.Width(line); w > maxWidth { - maxWidth = w - } - } - - availableWidth := m.width - if availableWidth <= 0 { - availableWidth = 10 - } - if maxWidth > availableWidth { - maxWidth = availableWidth - } - if maxWidth <= 0 { - maxWidth = availableWidth - } - - padding := 2 - maxWidthWithPadding := maxWidth + padding - if maxWidthWithPadding > availableWidth { - maxWidthWithPadding = availableWidth - if maxWidthWithPadding > maxWidth { - padding = maxWidthWithPadding - maxWidth - } else { - padding = 0 - } - } - // TODO: there is an interaction between the collection detail viewport scroll - // and the command suggestions overlay that causes the overlay to jump; render - // the overlay full width for now while we investigate a tighter fix. - styleWidth := m.width - if styleWidth <= 0 { - styleWidth = maxWidthWithPadding - } - contentStyle := lipgloss.NewStyle().Width(styleWidth).Align(lipgloss.Left) - for i := range rows { - if padding > 0 { - rows[i] += strings.Repeat(" ", padding) - } - rows[i] = contentStyle.Render(rows[i]) - } - - m.suggestionOverlay = strings.Join(rows, "\n") - height := strings.Count(m.suggestionOverlay, "\n") + 1 - if height <= 0 { - height = len(rows) - if height <= 0 { - height = 1 - } - } - placementWidth := styleWidth - m.suggestionPlacement = overlaymgr.Placement{ - Horizontal: overlayAlignLeft, - Vertical: lipgloss.Bottom, - MarginX: 0, - MarginY: 0, - Width: placementWidth, - Height: height, - } -} - -func (m *Model) cycleSuggestion(delta int) bool { - if m.mode != ModeInput { - return false - } - total := len(m.filteredSuggestions) - if total == 0 { - return false - } - if m.effectiveSuggestionLimit() == 0 { - return false - } - if m.suggestionIndex == -1 { - if delta > 0 { - m.suggestionIndex = 0 - } else { - m.suggestionIndex = total - 1 - } - m.suggestionOriginal = m.prompt.Value() - } else { - m.suggestionIndex = (m.suggestionIndex + delta) % total - if m.suggestionIndex < 0 { - m.suggestionIndex += total - } - } - if m.suggestionIndex < 0 || m.suggestionIndex >= total { - m.clearSuggestionSelection() - return false - } - choice := m.filteredSuggestions[m.suggestionIndex] - m.prompt.SetValue(choice.Name) - m.prompt.CursorEnd() - m.updateSuggestionWindow() - m.refreshSuggestionOverlay() - return true -} - -func (m *Model) clearSuggestionSelection() bool { - if m.suggestionIndex == -1 { - return false - } - m.prompt.SetValue(m.suggestionOriginal) - m.prompt.CursorEnd() - m.suggestionIndex = -1 - m.updateSuggestionWindow() - m.refreshSuggestionOverlay() - return true -} - -// Focus ensures the command component receives focus. -func (m *Model) Focus() { - m.focused = true - if m.mode == ModeInput { - m.prompt.Focus() - } -} - -// Blur releases focus. -func (m *Model) Blur() { - m.focused = false - m.prompt.Blur() -} - -// BeginInput switches the command bar into input mode. -func (m *Model) BeginInput(initial string) tea.Cmd { - m.mode = ModeInput - m.prompt.SetValue(initial) - m.lastPromptValue = initial - m.prompt.CursorEnd() - m.Focus() - m.applySuggestionFilter(initial, true) - return tea.Batch(m.prompt.Focus(), events.CommandChangeCmd(m.id, initial, events.CommandModeInput)) -} - -// ExitInput returns the command bar to passive mode. -func (m *Model) ExitInput() tea.Cmd { - m.mode = ModePassive - m.prompt.Blur() - m.lastPromptValue = "" - m.filteredSuggestions = nil - m.suggestionOverlay = "" - m.suggestionIndex = -1 - m.suggestionOriginal = "" - m.suggestionWindowStart = 0 - return tea.Batch(events.CommandChangeCmd(m.id, "", events.CommandModePassive)) -} - // InInputMode reports if the prompt is active. func (m *Model) InInputMode() bool { return m.mode == ModeInput } @@ -495,170 +162,3 @@ func (m *Model) InInputMode() bool { return m.mode == ModeInput } func (m *Model) Value() string { return m.prompt.Value() } - -// Update routes messages to the command prompt and suggestion overlay. -func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - handledKey := false - - switch msg := msg.(type) { - case tea.KeyMsg: - if handledKey { - break - } - switch msg.String() { - case "esc": - if m.mode == ModeInput { - if m.clearSuggestionSelection() { - handledKey = true - newVal := m.prompt.Value() - if newVal != m.lastPromptValue { - m.lastPromptValue = newVal - cmds = append(cmds, events.CommandChangeCmd(m.id, newVal, events.CommandModeInput)) - } - break - } - handledKey = true - cmds = append(cmds, m.ExitInput(), events.CommandCancelCmd(m.id)) - m.prompt.Blur() - } - case "enter": - if m.mode == ModeInput { - handledKey = true - value := strings.TrimSpace(m.prompt.Value()) - if value != "" { - cmds = append(cmds, events.CommandSubmitCmd(m.id, value)) - m.SetStatus(value) - } - cmds = append(cmds, m.ExitInput()) - } - case "up", "shift+tab": - if m.mode == ModeInput && m.cycleSuggestion(-1) { - handledKey = true - newVal := m.prompt.Value() - if newVal != m.lastPromptValue { - m.lastPromptValue = newVal - cmds = append(cmds, events.CommandChangeCmd(m.id, newVal, events.CommandModeInput)) - } - } - case "down", "tab": - if m.mode == ModeInput && m.cycleSuggestion(1) { - handledKey = true - newVal := m.prompt.Value() - if newVal != m.lastPromptValue { - m.lastPromptValue = newVal - cmds = append(cmds, events.CommandChangeCmd(m.id, newVal, events.CommandModeInput)) - } - } - default: - if m.mode == ModePassive && msg.String() == ":" { - cmds = append(cmds, m.BeginInput("")) - return m, tea.Batch(cmds...) - } - } - } - - if !handledKey && m.mode == ModeInput { - prev := m.prompt.Value() - var cmd tea.Cmd - m.prompt, cmd = m.prompt.Update(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } - if newVal := m.prompt.Value(); newVal != prev { - m.lastPromptValue = newVal - m.applySuggestionFilter(newVal, true) - cmds = append(cmds, events.CommandChangeCmd(m.id, newVal, events.CommandModeInput)) - } - } - - if len(cmds) == 0 { - return m, nil - } - return m, tea.Batch(cmds...) -} - -// View renders the combined content, overlay, and command bar. -func (m *Model) View() (string, *tea.Cursor) { - content := normalizeHeight(m.contentView, m.width, m.contentHeight) - - var contentCursor *tea.Cursor - if m.contentCursor != nil { - copy := *m.contentCursor - contentCursor = © - } - - if m.suggestionOverlay != "" { - content = overlaymgr.Compose(content, m.width, m.contentHeight, m.suggestionOverlay, m.suggestionPlacement) - } - - bar, barCursor := m.renderCommandBar() - if barCursor != nil { - contentCursor = barCursor - } - - if content != "" { - content = content + "\n" + bar - } else { - content = bar - } - - return content, contentCursor -} - -func (m *Model) renderCommandBar() (string, *tea.Cursor) { - var line string - var cursor *tea.Cursor - switch m.mode { - case ModeInput: - inputView := m.prompt.View() - line = m.promptPrefix + inputView - if c := m.prompt.Cursor(); c != nil { - copy := *c - copy.X += len(m.promptPrefix) - copy.Y = m.contentHeight - cursor = © - } - default: - status := m.status - if status == "" { - status = "Ready" - } - statusStyle := lipgloss.NewStyle().Italic(true).Foreground(lipgloss.Color("214")) - value := statusStyle.Render(status) - available := m.width - if available < 0 { - available = 0 - } - line = lipgloss.NewStyle().Width(available).Align(lipgloss.Right).Render(value) - } - - line = padToWidth(line, m.width) - return line, cursor -} - -func normalizeHeight(body string, width, height int) string { - lines := strings.Split(body, "\n") - if len(lines) > height { - lines = lines[len(lines)-height:] - } - for len(lines) < height { - lines = append(lines, "") - } - if width > 0 { - for i := range lines { - lines[i] = padToWidth(lines[i], width) - } - } - return strings.Join(lines, "\n") -} - -func padToWidth(s string, width int) string { - current := lipgloss.Width(s) - if current >= width { - return lipgloss.NewStyle().Width(width).Render(s) - } - padding := strings.Repeat(" ", width-current) - return s + padding -} diff --git a/pkg/tui/components/command/model_test.go b/pkg/tui/components/command/model_test.go new file mode 100644 index 0000000..de3f523 --- /dev/null +++ b/pkg/tui/components/command/model_test.go @@ -0,0 +1,106 @@ +package command + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea/v2" + + "tableflip.dev/bujo/pkg/tui/events" +) + +func TestColonStartsInputMode(t *testing.T) { + model := NewModel(Options{PromptPrefix: ":"}) + _, cmd := model.Update(tea.KeyPressMsg{Text: ":", Code: ':'}) + if model.mode != ModeInput { + t.Fatalf("expected mode input after ':'") + } + if cmd == nil { + t.Fatalf("expected command change cmd") + } + change := findCommandChange(runCmd(cmd)) + if change == nil { + t.Fatalf("expected command change message") + } + if change.Mode != events.CommandModeInput { + t.Fatalf("expected command mode input, got %q", change.Mode) + } +} + +func TestBeginAndExitInput(t *testing.T) { + model := NewModel(Options{PromptPrefix: ":"}) + cmd := model.BeginInput(":today") + if model.mode != ModeInput { + t.Fatalf("expected mode input") + } + if model.prompt.Value() != ":today" { + t.Fatalf("expected prompt value to be set") + } + change := findCommandChange(runCmd(cmd)) + if change == nil || change.Value != ":today" { + t.Fatalf("expected change msg value :today, got %#v", change) + } + + cmd = model.ExitInput() + if model.mode != ModePassive { + t.Fatalf("expected mode passive") + } + change = findCommandChange(runCmd(cmd)) + if change == nil || change.Mode != events.CommandModePassive { + t.Fatalf("expected command mode passive, got %#v", change) + } +} + +func TestSuggestionCycleAndEscClearsSelection(t *testing.T) { + model := NewModel(Options{PromptPrefix: ":"}) + model.SetSize(40, 5) + model.BeginInput("") + model.SetSuggestions([]SuggestionOption{{Name: ":today"}, {Name: ":future"}}) + model.applySuggestionFilter("", true) + + if len(model.filteredSuggestions) == 0 { + t.Fatalf("expected filtered suggestions to be populated") + } + + model.cycleSuggestion(1) + if model.suggestionIndex < 0 { + t.Fatalf("expected suggestion to be selected") + } + if model.prompt.Value() == "" { + t.Fatalf("expected prompt to be filled by suggestion") + } + + model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.suggestionIndex != -1 { + t.Fatalf("expected suggestion selection to be cleared") + } + if model.mode != ModeInput { + t.Fatalf("expected to remain in input mode after clearing suggestion") + } +} + +// runCmd executes a command and unwraps any batch messages. +func runCmd(cmd tea.Cmd) []tea.Msg { + if cmd == nil { + return nil + } + msg := cmd() + switch v := msg.(type) { + case tea.BatchMsg: + var msgs []tea.Msg + for _, inner := range v { + msgs = append(msgs, runCmd(inner)...) + } + return msgs + default: + return []tea.Msg{msg} + } +} + +func findCommandChange(msgs []tea.Msg) *events.CommandChangeMsg { + for _, msg := range msgs { + if change, ok := msg.(events.CommandChangeMsg); ok { + return &change + } + } + return nil +} diff --git a/pkg/tui/components/command/suggestion.go b/pkg/tui/components/command/suggestion.go new file mode 100644 index 0000000..358e2cb --- /dev/null +++ b/pkg/tui/components/command/suggestion.go @@ -0,0 +1,279 @@ +package command + +import ( + "strings" + + "github.com/charmbracelet/lipgloss/v2" + + "tableflip.dev/bujo/pkg/tui/theme" + overlaymgr "tableflip.dev/bujo/pkg/tui/ui/overlay" +) + +func (m *Model) applySuggestionFilter(value string, resetSelection bool) { + if m.mode != ModeInput { + m.filteredSuggestions = nil + m.suggestionOverlay = "" + m.suggestionWindowStart = 0 + return + } + + prefix := strings.TrimSpace(strings.ToLower(value)) + matches := make([]SuggestionOption, 0, len(m.suggestions)) + if prefix == "" { + matches = append(matches, m.suggestions...) + } else { + seen := make(map[string]struct{}, len(m.suggestions)) + for _, opt := range m.suggestions { + name := strings.ToLower(opt.Name) + if strings.HasPrefix(name, prefix) { + matches = append(matches, opt) + seen[opt.Name] = struct{}{} + } + } + for _, opt := range m.suggestions { + if _, ok := seen[opt.Name]; ok { + continue + } + name := strings.ToLower(opt.Name) + if strings.Contains(name, prefix) { + matches = append(matches, opt) + } + } + } + + if cap(m.filteredSuggestions) < len(matches) { + m.filteredSuggestions = make([]SuggestionOption, 0, len(matches)) + } + m.filteredSuggestions = m.filteredSuggestions[:0] + m.filteredSuggestions = append(m.filteredSuggestions, matches...) + + if resetSelection { + m.suggestionIndex = -1 + m.suggestionWindowStart = 0 + m.suggestionOriginal = value + } else { + if m.suggestionIndex >= len(m.filteredSuggestions) { + m.suggestionIndex = len(m.filteredSuggestions) - 1 + } + if m.suggestionIndex < -1 { + m.suggestionIndex = -1 + } + } + + m.updateSuggestionWindow() + m.refreshSuggestionOverlay() +} + +func (m *Model) effectiveSuggestionLimit() int { + total := len(m.filteredSuggestions) + if total == 0 { + return 0 + } + limit := m.suggestionLimit + if limit <= 0 || limit > total { + limit = total + } + maxRows := m.height - 1 + if maxRows < 0 { + maxRows = 0 + } + if limit > maxRows { + limit = maxRows + } + if limit < 0 { + limit = 0 + } + return limit +} + +func (m *Model) updateSuggestionWindow() { + total := len(m.filteredSuggestions) + if total == 0 { + m.suggestionWindowStart = 0 + return + } + + limit := m.effectiveSuggestionLimit() + if limit <= 0 { + m.suggestionWindowStart = 0 + return + } + + if m.suggestionWindowStart > total-limit { + m.suggestionWindowStart = total - limit + } + if m.suggestionWindowStart < 0 { + m.suggestionWindowStart = 0 + } + + if m.suggestionIndex >= 0 { + if m.suggestionIndex < m.suggestionWindowStart { + m.suggestionWindowStart = m.suggestionIndex + } else if m.suggestionIndex >= m.suggestionWindowStart+limit { + m.suggestionWindowStart = m.suggestionIndex - limit + 1 + } + } +} + +func (m *Model) refreshSuggestionOverlay() { + if m.mode != ModeInput || len(m.filteredSuggestions) == 0 { + m.suggestionOverlay = "" + return + } + limit := m.effectiveSuggestionLimit() + if limit <= 0 { + m.suggestionOverlay = "" + return + } + start := m.suggestionWindowStart + if start < 0 { + start = 0 + } + maxStart := len(m.filteredSuggestions) - limit + if maxStart < 0 { + maxStart = 0 + } + if start > maxStart { + start = maxStart + } + end := start + limit + if end > len(m.filteredSuggestions) { + end = len(m.filteredSuggestions) + } + + maxWidth := 0 + count := end - start + if count <= 0 { + m.suggestionOverlay = "" + return + } + rows := make([]string, count) + footerTheme := theme.Default().Footer + nameStyle := footerTheme.CommandName + descStyle := footerTheme.CommandDescription + primaryStyle := footerTheme.CommandSelectedName + primaryDesc := footerTheme.CommandSelectedDesc + + for i := start; i < end; i++ { + opt := m.filteredSuggestions[i] + marker := " " + nameRender := nameStyle.Render(opt.Name) + descRender := descStyle.Render(strings.TrimSpace(opt.Description)) + if i == m.suggestionIndex { + marker = "→ " + nameRender = primaryStyle.Render(opt.Name) + descRender = primaryDesc.Render(strings.TrimSpace(opt.Description)) + } + line := marker + strings.TrimSpace(nameRender) + if dr := strings.TrimSpace(descRender); dr != "" { + line += " " + dr + } + rowIdx := i - start + rows[rowIdx] = line + if w := lipgloss.Width(line); w > maxWidth { + maxWidth = w + } + } + + availableWidth := m.width + if availableWidth <= 0 { + availableWidth = 10 + } + if maxWidth > availableWidth { + maxWidth = availableWidth + } + if maxWidth <= 0 { + maxWidth = availableWidth + } + + padding := 2 + maxWidthWithPadding := maxWidth + padding + if maxWidthWithPadding > availableWidth { + maxWidthWithPadding = availableWidth + if maxWidthWithPadding > maxWidth { + padding = maxWidthWithPadding - maxWidth + } else { + padding = 0 + } + } + // TODO: there is an interaction between the collection detail viewport scroll + // and the command suggestions overlay that causes the overlay to jump; render + // the overlay full width for now while we investigate a tighter fix. + styleWidth := m.width + if styleWidth <= 0 { + styleWidth = maxWidthWithPadding + } + contentStyle := lipgloss.NewStyle().Width(styleWidth).Align(lipgloss.Left) + for i := range rows { + if padding > 0 { + rows[i] += strings.Repeat(" ", padding) + } + rows[i] = contentStyle.Render(rows[i]) + } + + m.suggestionOverlay = strings.Join(rows, "\n") + height := strings.Count(m.suggestionOverlay, "\n") + 1 + if height <= 0 { + height = len(rows) + if height <= 0 { + height = 1 + } + } + placementWidth := styleWidth + m.suggestionPlacement = overlaymgr.Placement{ + Horizontal: overlayAlignLeft, + Vertical: lipgloss.Bottom, + MarginX: 0, + MarginY: 0, + Width: placementWidth, + Height: height, + } +} + +func (m *Model) cycleSuggestion(delta int) bool { + if m.mode != ModeInput { + return false + } + total := len(m.filteredSuggestions) + if total == 0 { + return false + } + if m.effectiveSuggestionLimit() == 0 { + return false + } + if m.suggestionIndex == -1 { + if delta > 0 { + m.suggestionIndex = 0 + } else { + m.suggestionIndex = total - 1 + } + m.suggestionOriginal = m.prompt.Value() + } else { + m.suggestionIndex = (m.suggestionIndex + delta) % total + if m.suggestionIndex < 0 { + m.suggestionIndex += total + } + } + if m.suggestionIndex < 0 || m.suggestionIndex >= total { + m.clearSuggestionSelection() + return false + } + choice := m.filteredSuggestions[m.suggestionIndex] + m.prompt.SetValue(choice.Name) + m.prompt.CursorEnd() + m.updateSuggestionWindow() + m.refreshSuggestionOverlay() + return true +} + +func (m *Model) clearSuggestionSelection() bool { + if m.suggestionIndex == -1 { + return false + } + m.prompt.SetValue(m.suggestionOriginal) + m.prompt.CursorEnd() + m.suggestionIndex = -1 + m.updateSuggestionWindow() + m.refreshSuggestionOverlay() + return true +} diff --git a/pkg/tui/components/command/view.go b/pkg/tui/components/command/view.go new file mode 100644 index 0000000..470debc --- /dev/null +++ b/pkg/tui/components/command/view.go @@ -0,0 +1,95 @@ +package command + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + + "tableflip.dev/bujo/pkg/tui/theme" + overlaymgr "tableflip.dev/bujo/pkg/tui/ui/overlay" +) + +// View renders the combined content, overlay, and command bar. +func (m *Model) View() (string, *tea.Cursor) { + content := normalizeHeight(m.contentView, m.width, m.contentHeight) + + var contentCursor *tea.Cursor + if m.contentCursor != nil { + copy := *m.contentCursor + contentCursor = © + } + + if m.suggestionOverlay != "" { + content = overlaymgr.Compose(content, m.width, m.contentHeight, m.suggestionOverlay, m.suggestionPlacement) + } + + bar, barCursor := m.renderCommandBar() + if barCursor != nil { + contentCursor = barCursor + } + + if content != "" { + content = content + "\n" + bar + } else { + content = bar + } + + return content, contentCursor +} + +func (m *Model) renderCommandBar() (string, *tea.Cursor) { + var line string + var cursor *tea.Cursor + switch m.mode { + case ModeInput: + inputView := m.prompt.View() + line = m.promptPrefix + inputView + if c := m.prompt.Cursor(); c != nil { + copy := *c + copy.X += len(m.promptPrefix) + copy.Y = m.contentHeight + cursor = © + } + default: + status := m.status + if status == "" { + status = "Ready" + } + statusStyle := theme.Default().Footer.Status + value := statusStyle.Render(status) + available := m.width + if available < 0 { + available = 0 + } + line = lipgloss.NewStyle().Width(available).Align(lipgloss.Right).Render(value) + } + + line = padToWidth(line, m.width) + return line, cursor +} + +func normalizeHeight(body string, width, height int) string { + lines := strings.Split(body, "\n") + if len(lines) > height { + lines = lines[len(lines)-height:] + } + for len(lines) < height { + lines = append(lines, "") + } + if width > 0 { + for i := range lines { + lines[i] = padToWidth(lines[i], width) + } + } + return strings.Join(lines, "\n") +} + +func padToWidth(s string, width int) string { + current := lipgloss.Width(s) + if current >= width { + return lipgloss.NewStyle().Width(width).Render(s) + } + padding := strings.Repeat(" ", width-current) + return s + padding +} diff --git a/pkg/tui/components/detail/layout.go b/pkg/tui/components/detail/layout.go new file mode 100644 index 0000000..0e762d9 --- /dev/null +++ b/pkg/tui/components/detail/layout.go @@ -0,0 +1,115 @@ +package detail + +// ensureScrollVisible adjusts scroll offset so active row is visible in viewport. +func (s *State) ensureScrollVisible() { + height := s.viewHeight + if height <= 0 { + return + } + if len(s.sections) == 0 || s.sectionIndex < 0 || s.sectionIndex >= len(s.sections) { + return + } + contentTop := 0 + for i := 0; i < s.sectionIndex; i++ { + contentTop += s.sectionHeight(i) + } + section := s.sections[s.sectionIndex] + var entryTop, entryBottom int + if len(section.Entries) == 0 { + entryTop = contentTop + 1 + entryBottom = entryTop + } else { + if s.sectionIndex >= len(s.entryOffsets) || s.entryOffsets[s.sectionIndex] == nil { + s.sectionHeight(s.sectionIndex) + } + lineOffset := 1 + entryHeight := 1 + if s.sectionIndex < len(s.entryOffsets) { + offsets := s.entryOffsets[s.sectionIndex] + if s.entryIndex >= 0 && s.entryIndex < len(offsets) { + if offsets[s.entryIndex] > 0 { + lineOffset = offsets[s.entryIndex] + } + } + } + if s.sectionIndex < len(s.entryHeights) { + heights := s.entryHeights[s.sectionIndex] + if s.entryIndex >= 0 && s.entryIndex < len(heights) { + if heights[s.entryIndex] > 0 { + entryHeight = heights[s.entryIndex] + } + } + } + entryTop = contentTop + lineOffset + entryBottom = entryTop + entryHeight - 1 + } + if entryTop < s.scrollOffset { + s.scrollOffset = entryTop + } + viewBottom := s.scrollOffset + height - 1 + if entryBottom > viewBottom { + s.scrollOffset = entryBottom - height + 1 + if s.scrollOffset < 0 { + s.scrollOffset = 0 + } + } + sectionTop := contentTop + if s.scrollOffset > sectionTop { + if entryTop-sectionTop <= 1 { + s.scrollOffset = sectionTop + } + } +} + +func (s *State) sectionHeight(idx int) int { + if idx < 0 || idx >= len(s.sections) { + return 0 + } + if s.cachedHeights[idx] >= 0 { + return s.cachedHeights[idx] + } + lines := s.renderSection(idx) + return len(lines) +} + +func (s *State) maxScrollOffset(height int) int { + if height <= 0 { + return 0 + } + total := 0 + for i := range s.sections { + total += s.sectionHeight(i) + } + maxOffset := total - height + if maxOffset < 0 { + return 0 + } + return maxOffset +} + +func (s *State) viewHeightFor(height int) int { + if height <= 0 { + return 0 + } + result := height + if result > s.viewHeight { + result = s.viewHeight + } + if result < 0 { + result = 0 + } + return result +} + +func clampScrollOffset(offset, limit int) int { + if offset < 0 { + offset = 0 + } + if limit < 0 { + limit = 0 + } + if offset > limit { + return limit + } + return offset +} diff --git a/pkg/tui/components/detail/navigation.go b/pkg/tui/components/detail/navigation.go new file mode 100644 index 0000000..9c8ac6b --- /dev/null +++ b/pkg/tui/components/detail/navigation.go @@ -0,0 +1,344 @@ +package detail + +// MoveEntry moves the cursor within entries, adjusting section when crossing boundaries. +func (s *State) MoveEntry(delta int) bool { + if len(s.sections) == 0 { + return false + } + if len(s.sections[s.sectionIndex].Entries) == 0 { + return false + } + attempts := 0 + maxAttempts := len(s.sections) * 4 + for { + section := &s.sections[s.sectionIndex] + s.entryIndex += delta + for s.entryIndex < 0 || s.entryIndex >= len(section.Entries) { + if s.entryIndex < 0 { + if s.sectionIndex == 0 { + s.entryIndex = 0 + break + } + s.sectionIndex-- + section = &s.sections[s.sectionIndex] + s.entryIndex = len(section.Entries) - 1 + if s.entryIndex < 0 { + s.entryIndex = 0 + break + } + } else { + if s.sectionIndex == len(s.sections)-1 { + s.entryIndex = len(section.Entries) - 1 + if s.entryIndex < 0 { + s.entryIndex = 0 + } + break + } + s.sectionIndex++ + section = &s.sections[s.sectionIndex] + s.entryIndex = 0 + } + } + if s.isVisibleEntry(s.sectionIndex, s.entryIndex) { + break + } + deltaSign := 1 + if delta < 0 { + deltaSign = -1 + } + s.entryIndex += deltaSign + attempts++ + if attempts > maxAttempts { + return false + } + } + s.ensureScrollVisible() + return true +} + +// MoveSection moves to another section and resets entry index. +func (s *State) MoveSection(delta int) bool { + if len(s.sections) == 0 { + return false + } + s.sectionIndex += delta + if s.sectionIndex < 0 { + s.sectionIndex = 0 + } + if s.sectionIndex >= len(s.sections) { + s.sectionIndex = len(s.sections) - 1 + } + if len(s.sections[s.sectionIndex].Entries) == 0 { + s.entryIndex = 0 + } else { + if idx := s.firstVisibleIndex(s.sectionIndex); idx >= 0 { + s.entryIndex = idx + } else { + s.entryIndex = s.clampedEntryIndex() + } + } + s.ensureScrollVisible() + return true +} + +// SetCursor positions the cursor, clamping to available entries. +func (s *State) SetCursor(sectionIdx, entryIdx int) { + if len(s.sections) == 0 { + s.sectionIndex = 0 + s.entryIndex = 0 + return + } + if sectionIdx < 0 { + sectionIdx = 0 + } + if sectionIdx >= len(s.sections) { + sectionIdx = len(s.sections) - 1 + } + s.sectionIndex = sectionIdx + s.entryIndex = entryIdx + s.clampEntry() + s.ensureVisibleCurrent() + s.ensureScrollVisible() +} + +// SetActive moves the cursor to the given collection and entry identifiers. +func (s *State) SetActive(collectionID, entryID string) { + if len(s.sections) == 0 { + s.sectionIndex = 0 + s.entryIndex = 0 + return + } + if collectionID != "" { + if idx := s.indexOfCollection(collectionID); idx >= 0 { + s.sectionIndex = idx + } + } + s.clampEntry() + if entryID != "" { + section := s.sections[s.sectionIndex] + for i, it := range section.Entries { + if it.ID == entryID { + s.entryIndex = i + break + } + } + } + s.clampEntry() + if len(s.sections[s.sectionIndex].Entries) > 0 { + entry := s.sections[s.sectionIndex].Entries[s.entryIndex] + if entry != nil { + s.unfoldAncestors(s.sectionIndex, entry.ID) + } + } + s.ensureVisibleCurrent() + s.ensureScrollVisible() +} + +func (s *State) indexOfCollection(collectionID string) int { + for i, sec := range s.sections { + if sec.CollectionID == collectionID { + return i + } + } + return -1 +} + +func (s *State) clampEntry() { + if len(s.sections) == 0 { + s.entryIndex = 0 + return + } + s.entryIndex = s.clampedEntryIndex() +} + +func (s *State) invalidateHeights() { + for i := range s.cachedHeights { + s.cachedHeights[i] = -1 + } + for i := range s.entryOffsets { + s.entryOffsets[i] = nil + } + for i := range s.entryHeights { + s.entryHeights[i] = nil + } +} + +func (s *State) clampedEntryIndex() int { + if len(s.sections) == 0 { + return 0 + } + entries := len(s.sections[s.sectionIndex].Entries) + if entries == 0 { + return 0 + } + idx := s.entryIndex + if idx < 0 { + idx = 0 + } + if idx >= entries { + idx = entries - 1 + } + return idx +} + +// ScrollToTop resets viewport to the first line. +func (s *State) ScrollToTop() { + s.scrollOffset = 0 + s.sectionIndex = 0 + s.entryIndex = 0 +} + +// ClearSelection removes any active entry/section selection. +func (s *State) ClearSelection() { + s.sectionIndex = -1 + s.entryIndex = -1 +} + +func (s *State) isVisibleEntry(sectionIdx, entryIdx int) bool { + if sectionIdx < 0 || sectionIdx >= len(s.sections) { + return false + } + section := s.sections[sectionIdx] + if entryIdx < 0 || entryIdx >= len(section.Entries) { + return false + } + item := section.Entries[entryIdx] + if item == nil { + return false + } + visited := make(map[string]bool) + parentID := item.ParentID + for parentID != "" { + if visited[parentID] { + break + } + visited[parentID] = true + if s.folded[parentID] { + return false + } + next, ok := s.parents[sectionIdx][parentID] + if !ok { + break + } + parentID = next + } + return true +} + +func (s *State) depthOf(sectionIdx int, entryID string) int { + depth := 0 + visited := make(map[string]bool) + current := entryID + for { + parentID, ok := s.parents[sectionIdx][current] + if !ok || parentID == "" { + break + } + if visited[parentID] { + break + } + visited[parentID] = true + depth++ + current = parentID + } + return depth +} + +func (s *State) hasChildren(sectionIdx int, entryID string) bool { + if sectionIdx < 0 || sectionIdx >= len(s.children) { + return false + } + return len(s.children[sectionIdx][entryID]) > 0 +} + +// EntryHasChildren reports whether the entry has visible children in the section. +func (s *State) EntryHasChildren(sectionIdx int, entryID string) bool { + return s.hasChildren(sectionIdx, entryID) +} + +// ToggleEntryFold records a fold state for an entry subtree. +func (s *State) ToggleEntryFold(entryID string, collapsed bool) { + if s.folded == nil { + s.folded = make(map[string]bool) + } + if collapsed { + s.folded[entryID] = true + } else { + delete(s.folded, entryID) + } + s.invalidateHeights() +} + +// EntryFolded reports whether an entry is currently collapsed. +func (s *State) EntryFolded(entryID string) bool { + return s.folded[entryID] +} + +func (s *State) ensureVisibleCurrent() { + if len(s.sections) == 0 { + s.sectionIndex = 0 + s.entryIndex = 0 + return + } + if len(s.sections[s.sectionIndex].Entries) == 0 { + s.entryIndex = 0 + return + } + entry := s.sections[s.sectionIndex].Entries[s.entryIndex] + if entry == nil { + return + } + s.unfoldAncestors(s.sectionIndex, entry.ID) +} + +func (s *State) firstVisibleIndex(sectionIdx int) int { + if sectionIdx < 0 || sectionIdx >= len(s.sections) { + return -1 + } + section := s.sections[sectionIdx] + for i, entry := range section.Entries { + if entry == nil { + continue + } + if s.isVisibleEntry(sectionIdx, i) { + return i + } + } + return -1 +} + +func (s *State) unfoldAncestors(sectionIdx int, entryID string) { + if entryID == "" { + return + } + visited := make(map[string]bool) + parentID := entryID + for parentID != "" { + if visited[parentID] { + break + } + visited[parentID] = true + delete(s.folded, parentID) + parentID = s.parents[sectionIdx][parentID] + } +} + +// RevealCollection scrolls to the collection by ID. +func (s *State) RevealCollection(collectionID string, preferFull bool, height int) { + idx := s.indexOfCollection(collectionID) + if idx == -1 { + return + } + top := s.sectionTop(idx) + viewport := s.viewHeightFor(height) + sectionHeight := s.sectionHeight(idx) + if preferFull && sectionHeight <= viewport { + target := top + sectionHeight - viewport + if target < 0 { + target = 0 + } + s.scrollOffset = target + return + } + s.scrollOffset = clampScrollOffset(top, s.maxScrollOffset(viewport)) +} diff --git a/pkg/tui/components/detail/render.go b/pkg/tui/components/detail/render.go new file mode 100644 index 0000000..cdbe03a --- /dev/null +++ b/pkg/tui/components/detail/render.go @@ -0,0 +1,216 @@ +package detail + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss/v2" + "github.com/muesli/reflow/wordwrap" + + "tableflip.dev/bujo/pkg/entry" + "tableflip.dev/bujo/pkg/glyph" + "tableflip.dev/bujo/pkg/tui/theme" +) + +// Viewport renders sections within height, returning string lines and content height. +func (s *State) Viewport(height int) (string, int) { + if height <= 0 { + return "", 0 + } + s.viewHeight = height + s.ensureScrollVisible() + content := s.renderAll() + if s.scrollOffset < 0 { + s.scrollOffset = 0 + } + if s.scrollOffset >= len(content) { + s.scrollOffset = 0 + } + end := s.scrollOffset + height + if end > len(content) { + end = len(content) + } + view := append([]string(nil), content[s.scrollOffset:end]...) + for len(view) < height { + view = append(view, "") + } + return strings.Join(view, "\n"), len(content) +} + +func (s *State) renderAll() []string { + var lines []string + for idx := range s.sections { + lines = append(lines, s.renderSection(idx)...) + } + return lines +} + +func (s *State) sectionTop(idx int) int { + top := 0 + for i := 0; i < idx && i < len(s.sections); i++ { + top += s.sectionHeight(i) + } + return top +} + +func (s *State) renderSection(idx int) []string { + if idx < 0 || idx >= len(s.sections) { + return nil + } + section := s.sections[idx] + header := formatCollectionTitle(section.CollectionName, section.ResolvedName) + selected := idx == s.sectionIndex + + headerStyle := lipgloss.NewStyle().Bold(true) + if selected { + headerStyle = headerStyle.Inherit(theme.Default().Accent) + } + + lines := []string{headerStyle.Render(header)} + + offsets := make([]int, len(section.Entries)) + heights := make([]int, len(section.Entries)) + lineOffset := 1 + if len(section.Entries) == 0 { + lines = append(lines, " ") + } else { + for entryIdx, item := range section.Entries { + if !s.isVisibleEntry(idx, entryIdx) { + offsets[entryIdx] = -1 + heights[entryIdx] = 0 + continue + } + caret := " " + if selected && entryIdx == s.entryIndex { + caret = theme.Default().Accent.Render("→") + } + indent := strings.Repeat(" ", s.depthOf(idx, item.ID)) + itemLines := formatEntryLines(item, caret, indent, s.wrapWidth) + offsets[entryIdx] = lineOffset + heights[entryIdx] = len(itemLines) + lines = append(lines, itemLines...) + lineOffset += len(itemLines) + } + } + lines = append(lines, "") // spacer between sections + s.cachedHeights[idx] = len(lines) + if len(section.Entries) == 0 { + s.entryOffsets[idx] = nil + s.entryHeights[idx] = nil + } else { + s.entryOffsets[idx] = offsets + s.entryHeights[idx] = heights + } + return lines +} + +func formatCollectionTitle(name, resolved string) string { + if resolved != "" { + if strings.Contains(resolved, "/") { + parts := strings.SplitN(resolved, "/", 2) + if len(parts) == 2 { + if t, err := time.Parse("January 2, 2006", parts[1]); err == nil { + return t.Format("Monday, January 2, 2006") + } + if mt, err := time.Parse("January 2006", parts[0]); err == nil { + return mt.Format("January, 2006") + } + } + } + if t, err := time.Parse("January 2, 2006", resolved); err == nil { + return t.Format("Monday, January 2, 2006") + } + if t, err := time.Parse("January 2006", resolved); err == nil { + return t.Format("January, 2006") + } + } + if t, err := time.Parse("January 2, 2006", name); err == nil { + return t.Format("Monday, January 2, 2006") + } + if t, err := time.Parse("January 2006", name); err == nil { + return t.Format("January, 2006") + } + return name +} + +func formatEntryLines(e *entry.Entry, caret, indent string, wrapWidth int) []string { + signifier := e.Signifier.String() + if signifier == "" { + signifier = " " + } + bulletGlyph := e.Bullet.Glyph() + bullet := bulletGlyph.Symbol + if bullet == "" { + bullet = e.Bullet.String() + } + message := e.Message + if strings.TrimSpace(message) == "" { + message = "" + } + msgLines := strings.Split(message, "\n") + indentStr := indent + bulletWithIndent := bullet + if indentStr != "" { + bulletWithIndent = indentStr + bullet + } + prefix := fmt.Sprintf("%s%s %s ", caret, signifier, bulletWithIndent) + prefixStyle := lipgloss.NewStyle() + messageStyle := lipgloss.NewStyle() + if e.Bullet == glyph.Completed || e.Bullet == glyph.Irrelevant { + prefixStyle = prefixStyle.Foreground(lipgloss.Color("241")) + messageStyle = messageStyle.Foreground(lipgloss.Color("241")) + } + if e.Immutable { + prefixStyle = prefixStyle.Foreground(lipgloss.Color("244")).Faint(true) + messageStyle = messageStyle.Foreground(lipgloss.Color("244")).Faint(true).Italic(true) + } + if e.Bullet == glyph.Irrelevant { + messageStyle = messageStyle.Strikethrough(true) + } + width := wrapWidth + if width <= 0 { + width = 80 + } + available := width - lipgloss.Width(prefix) + if available < 10 { + available = 10 + } + wrapLine := func(text string) []string { + if strings.TrimSpace(text) == "" { + return []string{text} + } + wrapped := wordwrap.String(text, available) + if wrapped == "" { + return []string{""} + } + return strings.Split(wrapped, "\n") + } + lines := make([]string, 0, len(msgLines)) + padding := strings.Repeat(" ", lipgloss.Width(prefix)) + paddingStyled := prefixStyle.Render(padding) + firstLine := true + lockedSuffix := "" + if e.Immutable { + lockedSuffix = " · locked" + } + for _, msgLine := range msgLines { + segments := wrapLine(msgLine) + for i, seg := range segments { + content := seg + if firstLine && i == 0 && lockedSuffix != "" { + content = content + lockedSuffix + } + if firstLine && i == 0 { + lines = append(lines, prefixStyle.Render(prefix)+messageStyle.Render(content)) + firstLine = false + continue + } + lines = append(lines, paddingStyled+messageStyle.Render(content)) + } + } + if len(lines) == 0 { + lines = append(lines, prefixStyle.Render(prefix)) + } + return lines +} diff --git a/pkg/tui/components/detail/state.go b/pkg/tui/components/detail/state.go index 3da7f7c..45ed678 100644 --- a/pkg/tui/components/detail/state.go +++ b/pkg/tui/components/detail/state.go @@ -2,15 +2,7 @@ package detail import ( - "fmt" - "strings" - "time" - - "github.com/charmbracelet/lipgloss/v2" - "github.com/muesli/reflow/wordwrap" - "tableflip.dev/bujo/pkg/entry" - "tableflip.dev/bujo/pkg/glyph" ) // Section represents a collection and its entries rendered in the detail pane. @@ -93,19 +85,6 @@ func (s *State) SetWrapWidth(width int) { s.invalidateHeights() } -func clampScrollOffset(offset, limit int) int { - if offset < 0 { - offset = 0 - } - if limit < 0 { - limit = 0 - } - if offset > limit { - return limit - } - return offset -} - // Sections returns the currently loaded sections. func (s *State) Sections() []Section { return s.sections @@ -116,273 +95,6 @@ func (s *State) Cursor() (int, int) { return s.sectionIndex, s.entryIndex } -// MoveEntry moves the cursor within entries, adjusting section when crossing boundaries. -func (s *State) MoveEntry(delta int) bool { - if len(s.sections) == 0 { - return false - } - if len(s.sections[s.sectionIndex].Entries) == 0 { - return false - } - attempts := 0 - maxAttempts := len(s.sections) * 4 - for { - section := &s.sections[s.sectionIndex] - s.entryIndex += delta - for s.entryIndex < 0 || s.entryIndex >= len(section.Entries) { - if s.entryIndex < 0 { - if s.sectionIndex == 0 { - s.entryIndex = 0 - break - } - s.sectionIndex-- - section = &s.sections[s.sectionIndex] - s.entryIndex = len(section.Entries) - 1 - if s.entryIndex < 0 { - s.entryIndex = 0 - break - } - } else { - if s.sectionIndex == len(s.sections)-1 { - s.entryIndex = len(section.Entries) - 1 - if s.entryIndex < 0 { - s.entryIndex = 0 - } - break - } - s.sectionIndex++ - section = &s.sections[s.sectionIndex] - s.entryIndex = 0 - } - } - if s.isVisibleEntry(s.sectionIndex, s.entryIndex) { - break - } - deltaSign := 1 - if delta < 0 { - deltaSign = -1 - } - s.entryIndex += deltaSign - attempts++ - if attempts > maxAttempts { - return false - } - } - s.ensureScrollVisible() - return true -} - -// MoveSection moves to another section and resets entry index. -func (s *State) MoveSection(delta int) bool { - if len(s.sections) == 0 { - return false - } - s.sectionIndex += delta - if s.sectionIndex < 0 { - s.sectionIndex = 0 - } - if s.sectionIndex >= len(s.sections) { - s.sectionIndex = len(s.sections) - 1 - } - if len(s.sections[s.sectionIndex].Entries) == 0 { - s.entryIndex = 0 - } else { - if idx := s.firstVisibleIndex(s.sectionIndex); idx >= 0 { - s.entryIndex = idx - } else { - s.entryIndex = s.clampedEntryIndex() - } - } - s.ensureScrollVisible() - return true -} - -// SetCursor positions the cursor, clamping to available entries. -func (s *State) SetCursor(sectionIdx, entryIdx int) { - if len(s.sections) == 0 { - s.sectionIndex = 0 - s.entryIndex = 0 - return - } - if sectionIdx < 0 { - sectionIdx = 0 - } - if sectionIdx >= len(s.sections) { - sectionIdx = len(s.sections) - 1 - } - s.sectionIndex = sectionIdx - s.entryIndex = entryIdx - s.clampEntry() - s.ensureVisibleCurrent() - s.ensureScrollVisible() -} - -// SetActive moves the cursor to the given collection and entry identifiers. -func (s *State) SetActive(collectionID, entryID string) { - if len(s.sections) == 0 { - s.sectionIndex = 0 - s.entryIndex = 0 - return - } - if collectionID != "" { - if idx := s.indexOfCollection(collectionID); idx >= 0 { - s.sectionIndex = idx - } - } - s.clampEntry() - if entryID != "" { - section := s.sections[s.sectionIndex] - for i, it := range section.Entries { - if it.ID == entryID { - s.entryIndex = i - break - } - } - } - s.clampEntry() - if len(s.sections[s.sectionIndex].Entries) > 0 { - entry := s.sections[s.sectionIndex].Entries[s.entryIndex] - if entry != nil { - s.unfoldAncestors(s.sectionIndex, entry.ID) - } - } - s.ensureVisibleCurrent() - s.ensureScrollVisible() -} - -func (s *State) indexOfCollection(collectionID string) int { - for i, sec := range s.sections { - if sec.CollectionID == collectionID { - return i - } - } - return -1 -} - -func (s *State) clampEntry() { - if len(s.sections) == 0 { - s.entryIndex = 0 - return - } - s.entryIndex = s.clampedEntryIndex() -} - -func (s *State) invalidateHeights() { - for i := range s.cachedHeights { - s.cachedHeights[i] = -1 - } - for i := range s.entryOffsets { - s.entryOffsets[i] = nil - } - for i := range s.entryHeights { - s.entryHeights[i] = nil - } -} - -func (s *State) clampedEntryIndex() int { - if len(s.sections) == 0 { - return 0 - } - entries := len(s.sections[s.sectionIndex].Entries) - if entries == 0 { - return 0 - } - idx := s.entryIndex - if idx < 0 { - idx = 0 - } - if idx >= entries { - idx = entries - 1 - } - return idx -} - -// ensureScrollVisible adjusts scroll offset so active row is visible in viewport. -func (s *State) ensureScrollVisible() { - height := s.viewHeight - if height <= 0 { - return - } - if len(s.sections) == 0 || s.sectionIndex < 0 || s.sectionIndex >= len(s.sections) { - return - } - contentTop := 0 - for i := 0; i < s.sectionIndex; i++ { - contentTop += s.sectionHeight(i) - } - section := s.sections[s.sectionIndex] - var entryTop, entryBottom int - if len(section.Entries) == 0 { - entryTop = contentTop + 1 - entryBottom = entryTop - } else { - if s.sectionIndex >= len(s.entryOffsets) || s.entryOffsets[s.sectionIndex] == nil { - s.sectionHeight(s.sectionIndex) - } - lineOffset := 1 - entryHeight := 1 - if s.sectionIndex < len(s.entryOffsets) { - offsets := s.entryOffsets[s.sectionIndex] - if s.entryIndex >= 0 && s.entryIndex < len(offsets) { - if offsets[s.entryIndex] > 0 { - lineOffset = offsets[s.entryIndex] - } - } - } - if s.sectionIndex < len(s.entryHeights) { - heights := s.entryHeights[s.sectionIndex] - if s.entryIndex >= 0 && s.entryIndex < len(heights) { - if heights[s.entryIndex] > 0 { - entryHeight = heights[s.entryIndex] - } - } - } - entryTop = contentTop + lineOffset - entryBottom = entryTop + entryHeight - 1 - } - if entryTop < s.scrollOffset { - s.scrollOffset = entryTop - } - viewBottom := s.scrollOffset + height - 1 - if entryBottom > viewBottom { - s.scrollOffset = entryBottom - height + 1 - if s.scrollOffset < 0 { - s.scrollOffset = 0 - } - } - sectionTop := contentTop - if s.scrollOffset > sectionTop { - if entryTop-sectionTop <= 1 { - s.scrollOffset = sectionTop - } - } -} - -// Viewport renders sections within height, returning string lines and content height. -func (s *State) Viewport(height int) (string, int) { - if height <= 0 { - return "", 0 - } - s.viewHeight = height - s.ensureScrollVisible() - content := s.renderAll() - if s.scrollOffset < 0 { - s.scrollOffset = 0 - } - if s.scrollOffset >= len(content) { - s.scrollOffset = 0 - } - end := s.scrollOffset + height - if end > len(content) { - end = len(content) - } - view := append([]string(nil), content[s.scrollOffset:end]...) - for len(view) < height { - view = append(view, "") - } - return strings.Join(view, "\n"), len(content) -} - // ActiveEntryID returns the entry ID currently highlighted. func (s *State) ActiveEntryID() string { if len(s.sections) == 0 { @@ -403,112 +115,6 @@ func (s *State) ActiveCollectionID() string { return s.sections[s.sectionIndex].CollectionID } -// ScrollToTop resets viewport to the first line. -func (s *State) ScrollToTop() { - s.scrollOffset = 0 - s.sectionIndex = 0 - s.entryIndex = 0 -} - -// ClearSelection removes any active entry/section selection. -func (s *State) ClearSelection() { - s.sectionIndex = -1 - s.entryIndex = -1 -} - -func (s *State) renderAll() []string { - var lines []string - for idx := range s.sections { - lines = append(lines, s.renderSection(idx)...) - } - return lines -} - -func (s *State) sectionTop(idx int) int { - top := 0 - for i := 0; i < idx && i < len(s.sections); i++ { - top += s.sectionHeight(i) - } - return top -} - -func (s *State) renderSection(idx int) []string { - if idx < 0 || idx >= len(s.sections) { - return nil - } - section := s.sections[idx] - header := formatCollectionTitle(section.CollectionName, section.ResolvedName) - selected := idx == s.sectionIndex - - headerStyle := lipgloss.NewStyle().Bold(true) - if selected { - headerStyle = headerStyle.Foreground(lipgloss.Color("213")) - } - - lines := []string{headerStyle.Render(header)} - - offsets := make([]int, len(section.Entries)) - heights := make([]int, len(section.Entries)) - lineOffset := 1 - if len(section.Entries) == 0 { - lines = append(lines, " ") - } else { - for entryIdx, item := range section.Entries { - if !s.isVisibleEntry(idx, entryIdx) { - offsets[entryIdx] = -1 - heights[entryIdx] = 0 - continue - } - caret := " " - if selected && entryIdx == s.entryIndex { - caret = lipgloss.NewStyle().Foreground(lipgloss.Color("213")).Render("→") - } - indent := strings.Repeat(" ", s.depthOf(idx, item.ID)) - itemLines := formatEntryLines(item, caret, indent, s.wrapWidth) - offsets[entryIdx] = lineOffset - heights[entryIdx] = len(itemLines) - lines = append(lines, itemLines...) - lineOffset += len(itemLines) - } - } - lines = append(lines, "") // spacer between sections - s.cachedHeights[idx] = len(lines) - if len(section.Entries) == 0 { - s.entryOffsets[idx] = nil - s.entryHeights[idx] = nil - } else { - s.entryOffsets[idx] = offsets - s.entryHeights[idx] = heights - } - return lines -} - -func (s *State) sectionHeight(idx int) int { - if idx < 0 || idx >= len(s.sections) { - return 0 - } - if s.cachedHeights[idx] >= 0 { - return s.cachedHeights[idx] - } - lines := s.renderSection(idx) - return len(lines) -} - -func (s *State) maxScrollOffset(height int) int { - if height <= 0 { - return 0 - } - total := 0 - for i := range s.sections { - total += s.sectionHeight(i) - } - maxOffset := total - height - if maxOffset < 0 { - return 0 - } - return maxOffset -} - func buildRelations(entries []*entry.Entry) (map[string]string, map[string][]*entry.Entry) { parents := make(map[string]string, len(entries)) children := make(map[string][]*entry.Entry) @@ -534,287 +140,3 @@ func buildRelations(entries []*entry.Entry) (map[string]string, map[string][]*en } return parents, children } - -func (s *State) isVisibleEntry(sectionIdx, entryIdx int) bool { - if sectionIdx < 0 || sectionIdx >= len(s.sections) { - return false - } - section := s.sections[sectionIdx] - if entryIdx < 0 || entryIdx >= len(section.Entries) { - return false - } - item := section.Entries[entryIdx] - if item == nil { - return false - } - visited := make(map[string]bool) - parentID := item.ParentID - for parentID != "" { - if visited[parentID] { - break - } - visited[parentID] = true - if s.folded[parentID] { - return false - } - next, ok := s.parents[sectionIdx][parentID] - if !ok { - break - } - parentID = next - } - return true -} - -func (s *State) depthOf(sectionIdx int, entryID string) int { - depth := 0 - visited := make(map[string]bool) - current := entryID - for { - parentID, ok := s.parents[sectionIdx][current] - if !ok || parentID == "" { - break - } - if visited[parentID] { - break - } - visited[parentID] = true - depth++ - current = parentID - } - return depth -} - -func (s *State) hasChildren(sectionIdx int, entryID string) bool { - if sectionIdx < 0 || sectionIdx >= len(s.children) { - return false - } - return len(s.children[sectionIdx][entryID]) > 0 -} - -// EntryHasChildren reports whether the entry has visible children in the section. -func (s *State) EntryHasChildren(sectionIdx int, entryID string) bool { - return s.hasChildren(sectionIdx, entryID) -} - -// ToggleEntryFold records a fold state for an entry subtree. -func (s *State) ToggleEntryFold(entryID string, collapsed bool) { - if s.folded == nil { - s.folded = make(map[string]bool) - } - if collapsed { - s.folded[entryID] = true - } else { - delete(s.folded, entryID) - } - s.invalidateHeights() -} - -// EntryFolded reports whether an entry is currently collapsed. -func (s *State) EntryFolded(entryID string) bool { - return s.folded[entryID] -} - -func (s *State) ensureVisibleCurrent() { - if len(s.sections) == 0 { - s.sectionIndex = 0 - s.entryIndex = 0 - return - } - if s.sectionIndex < 0 { - s.sectionIndex = 0 - } - if s.sectionIndex >= len(s.sections) { - s.sectionIndex = len(s.sections) - 1 - } - section := s.sections[s.sectionIndex] - if len(section.Entries) == 0 { - s.entryIndex = 0 - return - } - if s.entryIndex < 0 { - s.entryIndex = 0 - } - if s.entryIndex >= len(section.Entries) { - s.entryIndex = len(section.Entries) - 1 - } - if s.isVisibleEntry(s.sectionIndex, s.entryIndex) { - return - } - if idx := s.firstVisibleIndex(s.sectionIndex); idx >= 0 { - s.entryIndex = idx - } -} - -func (s *State) firstVisibleIndex(sectionIdx int) int { - if sectionIdx < 0 || sectionIdx >= len(s.sections) { - return -1 - } - section := s.sections[sectionIdx] - for i := range section.Entries { - if s.isVisibleEntry(sectionIdx, i) { - return i - } - } - return -1 -} - -func (s *State) unfoldAncestors(sectionIdx int, entryID string) { - visited := make(map[string]bool) - current := entryID - for { - parentID, ok := s.parents[sectionIdx][current] - if !ok || parentID == "" { - break - } - if visited[parentID] { - break - } - visited[parentID] = true - delete(s.folded, parentID) - current = parentID - } -} - -func (s *State) viewHeightFor(height int) int { - if height > 0 { - return height - } - if s.viewHeight > 0 { - return s.viewHeight - } - return 25 -} - -// RevealCollection adjusts scrollOffset so the requested collection comes into -// view. When preferFull is true and the collection fits within the viewport we -// align the bottom edge so the entire section is visible; otherwise the header -// is pinned to the top once revealed. -func (s *State) RevealCollection(collectionID string, preferFull bool, height int) { - idx := s.indexOfCollection(collectionID) - if idx < 0 { - return - } - viewport := s.viewHeightFor(height) - top := s.sectionTop(idx) - sectionHeight := s.sectionHeight(idx) - if preferFull && sectionHeight <= viewport { - target := top + sectionHeight - viewport - if target < 0 { - target = 0 - } - s.scrollOffset = target - return - } - s.scrollOffset = clampScrollOffset(top, s.maxScrollOffset(viewport)) -} - -func formatCollectionTitle(name, resolved string) string { - if resolved != "" { - if strings.Contains(resolved, "/") { - parts := strings.SplitN(resolved, "/", 2) - if len(parts) == 2 { - if t, err := time.Parse("January 2, 2006", parts[1]); err == nil { - return t.Format("Monday, January 2, 2006") - } - if mt, err := time.Parse("January 2006", parts[0]); err == nil { - return mt.Format("January, 2006") - } - } - } - if t, err := time.Parse("January 2, 2006", resolved); err == nil { - return t.Format("Monday, January 2, 2006") - } - if t, err := time.Parse("January 2006", resolved); err == nil { - return t.Format("January, 2006") - } - } - if t, err := time.Parse("January 2, 2006", name); err == nil { - return t.Format("Monday, January 2, 2006") - } - if t, err := time.Parse("January 2006", name); err == nil { - return t.Format("January, 2006") - } - return name -} - -func formatEntryLines(e *entry.Entry, caret, indent string, wrapWidth int) []string { - signifier := e.Signifier.String() - if signifier == "" { - signifier = " " - } - bulletGlyph := e.Bullet.Glyph() - bullet := bulletGlyph.Symbol - if bullet == "" { - bullet = e.Bullet.String() - } - message := e.Message - if strings.TrimSpace(message) == "" { - message = "" - } - msgLines := strings.Split(message, "\n") - indentStr := indent - bulletWithIndent := bullet - if indentStr != "" { - bulletWithIndent = indentStr + bullet - } - prefix := fmt.Sprintf("%s%s %s ", caret, signifier, bulletWithIndent) - prefixStyle := lipgloss.NewStyle() - messageStyle := lipgloss.NewStyle() - if e.Bullet == glyph.Completed || e.Bullet == glyph.Irrelevant { - prefixStyle = prefixStyle.Foreground(lipgloss.Color("241")) - messageStyle = messageStyle.Foreground(lipgloss.Color("241")) - } - if e.Immutable { - prefixStyle = prefixStyle.Foreground(lipgloss.Color("244")).Faint(true) - messageStyle = messageStyle.Foreground(lipgloss.Color("244")).Faint(true).Italic(true) - } - if e.Bullet == glyph.Irrelevant { - messageStyle = messageStyle.Strikethrough(true) - } - width := wrapWidth - if width <= 0 { - width = 80 - } - available := width - lipgloss.Width(prefix) - if available < 10 { - available = 10 - } - wrapLine := func(text string) []string { - if strings.TrimSpace(text) == "" { - return []string{text} - } - wrapped := wordwrap.String(text, available) - if wrapped == "" { - return []string{""} - } - return strings.Split(wrapped, "\n") - } - lines := make([]string, 0, len(msgLines)) - padding := strings.Repeat(" ", lipgloss.Width(prefix)) - paddingStyled := prefixStyle.Render(padding) - firstLine := true - lockedSuffix := "" - if e.Immutable { - lockedSuffix = " · locked" - } - for _, msgLine := range msgLines { - segments := wrapLine(msgLine) - for i, seg := range segments { - content := seg - if firstLine && i == 0 && lockedSuffix != "" { - content = content + lockedSuffix - } - if firstLine && i == 0 { - lines = append(lines, prefixStyle.Render(prefix)+messageStyle.Render(content)) - firstLine = false - continue - } - lines = append(lines, paddingStyled+messageStyle.Render(content)) - } - } - if len(lines) == 0 { - lines = append(lines, prefixStyle.Render(prefix)) - } - return lines -} diff --git a/pkg/tui/components/detail/state_test.go b/pkg/tui/components/detail/state_test.go index 56eb327..f11584b 100644 --- a/pkg/tui/components/detail/state_test.go +++ b/pkg/tui/components/detail/state_test.go @@ -1,290 +1,55 @@ package detail -import ( - "fmt" - "strings" - "testing" +import "testing" - "github.com/muesli/reflow/ansi" - - "tableflip.dev/bujo/pkg/entry" - "tableflip.dev/bujo/pkg/glyph" -) - -func stripANSIString(s string) string { - var b strings.Builder - ansiSeq := false - for _, r := range s { - if r == ansi.Marker { - ansiSeq = true - continue - } - if ansiSeq { - if ansi.IsTerminator(r) { - ansiSeq = false - } - continue - } - b.WriteRune(r) - } - return b.String() -} - -func makeEntries(count int) []*entry.Entry { - entries := make([]*entry.Entry, count) - for i := 0; i < count; i++ { - e := &entry.Entry{ - ID: formatID(i), - Message: "item", - Bullet: glyph.Task, - } - e.EnsureHistorySeed() - entries[i] = e - } - return entries -} - -func formatID(i int) string { - return fmt.Sprintf("%03d", i) -} - -func TestSetSectionsPreservesScrollOffset(t *testing.T) { - entries := makeEntries(10) - sections := []Section{{ - CollectionID: "A", - CollectionName: "A", - Entries: entries, - }} +import "tableflip.dev/bujo/pkg/entry" +func TestMoveEntryAcrossSections(t *testing.T) { state := NewState() - state.SetSections(sections) - state.SetActive("A", entries[0].ID) - state.Viewport(4) - - for i := 0; i < 6; i++ { - state.MoveEntry(1) - } - state.Viewport(4) - - if state.scrollOffset == 0 { - t.Fatalf("expected scroll offset to advance after moving, got 0") - } - before := state.scrollOffset - currentID := state.ActiveEntryID() - - state.SetSections(sections) - state.SetActive("A", currentID) - state.Viewport(4) - - if state.scrollOffset != before { - t.Fatalf("expected scroll offset %d after reload, got %d", before, state.scrollOffset) - } - - state.MoveEntry(-1) - state.Viewport(4) - if state.scrollOffset > before { - t.Fatalf("scroll offset increased after moving up: before %d, after %d", before, state.scrollOffset) - } -} - -func TestRevealCollectionPrefersFullView(t *testing.T) { - secA := Section{CollectionID: "A", CollectionName: "A", Entries: makeEntries(2)} - secB := Section{CollectionID: "B", CollectionName: "B", Entries: makeEntries(1)} - secC := Section{CollectionID: "C", CollectionName: "C", Entries: makeEntries(6)} - state := NewState() - state.SetSections([]Section{secA, secB, secC}) - state.SetActive("C", "000") - state.Viewport(6) - - state.RevealCollection("B", true, 6) - if state.scrollOffset != 1 { - t.Fatalf("expected scroll offset 1 to show entire section B, got %d", state.scrollOffset) - } - - state.RevealCollection("C", true, 6) - if state.scrollOffset != 7 { - t.Fatalf("expected scroll offset 7 to pin header for large section, got %d", state.scrollOffset) - } -} - -func TestEnsureScrollVisibleKeepsCursorVisible(t *testing.T) { - sections := []Section{ + state.SetSections([]Section{ { CollectionID: "A", CollectionName: "A", Entries: []*entry.Entry{ - { - ID: "A-0", - Message: "first line\nsecond line", - Bullet: glyph.Task, - }, + {ID: "a1", Message: "first"}, + {ID: "a2", Message: "second"}, }, }, { CollectionID: "B", CollectionName: "B", Entries: []*entry.Entry{ - { - ID: "B-0", - Message: "item", - Bullet: glyph.Task, - }, - }, - }, - { - CollectionID: "C", - CollectionName: "C", - Entries: []*entry.Entry{ - { - ID: "C-0", - Message: "target", - Bullet: glyph.Task, - }, + {ID: "b1", Message: "third"}, }, }, + }) + state.SetCursor(0, 1) + if !state.MoveEntry(1) { + t.Fatalf("expected move to succeed") } - for _, sec := range sections { - for _, e := range sec.Entries { - e.EnsureHistorySeed() - } - } - - state := NewState() - state.SetSections(sections) - state.SetActive("C", "C-0") - - view, _ := state.Viewport(4) - plain := stripANSIString(view) - if !strings.Contains(plain, "→") { - t.Fatalf("expected caret to be visible in viewport, got:\n%s", plain) + if got := state.ActiveEntryID(); got != "b1" { + t.Fatalf("expected to land on b1, got %q", got) } } -func TestEnsureScrollVisibleAccountsForSectionSpacing(t *testing.T) { - longLine := "Write an extra long review comment about the storage refactor PR so we can verify wrapping works correctly." - wrapEntries := []*entry.Entry{ - {ID: "wrap-1", Message: longLine, Bullet: glyph.Task}, - {ID: "wrap-2", Message: "! Email Alex about the demo", Bullet: glyph.Note}, - {ID: "wrap-3", Message: longLine + " Even more wrapping to ensure the section pushes the next header down.", Bullet: glyph.Task}, - } - listEntries := make([]*entry.Entry, 0, 12) - for i := 0; i < 12; i++ { - listEntries = append(listEntries, &entry.Entry{ - ID: fmt.Sprintf("list-%02d", i), - Message: fmt.Sprintf("Metrics dashboard polish%02d", i), - Bullet: glyph.Task, - }) - } - for _, e := range append(wrapEntries, listEntries...) { - e.EnsureHistorySeed() - } - - sections := []Section{ - { - CollectionID: "Inbox", - CollectionName: "Inbox", - Entries: wrapEntries, - }, +func TestToggleEntryFoldInvalidatesHeights(t *testing.T) { + state := NewState() + state.SetSections([]Section{ { - CollectionID: "Projects", - CollectionName: "Projects", - Entries: listEntries, + CollectionID: "A", + CollectionName: "A", + Entries: []*entry.Entry{ + {ID: "p", Message: "parent"}, + {ID: "c", ParentID: "p", Message: "child"}, + }, }, - } - - state := NewState() - state.SetWrapWidth(40) - state.SetSections(sections) - state.SetActive("Projects", "list-11") - - view, _ := state.Viewport(9) - plain := stripANSIString(view) - if !strings.Contains(plain, "Metrics dashboard polish11") { - t.Fatalf("expected bottom entry to be visible in viewport, got:\n%s", plain) - } -} -func TestFormatEntryLinesIndentRendering(t *testing.T) { - parent := &entry.Entry{ID: "p", Message: "Parent", Bullet: glyph.Task} - child := &entry.Entry{ID: "c", Message: "Child", Bullet: glyph.Event, ParentID: "p"} - grand := &entry.Entry{ID: "g", Message: "Grandchild", Bullet: glyph.Completed, ParentID: "c"} - - sections := []Section{{ - CollectionID: "Demo", - Entries: []*entry.Entry{parent, child, grand}, - }} - - state := NewState() - state.SetWrapWidth(80) - state.SetSections(sections) - state.SetActive("Demo", "g") - - view, _ := state.Viewport(10) - lines := strings.Split(view, "\n") - if len(lines) < 3 { - t.Fatalf("expected at least 3 rendered lines, got %d", len(lines)) - } - - var cleaned []string - for _, line := range lines { - plain := strings.TrimSpace(stripANSIString(line)) - if plain == "" { - continue - } - cleaned = append(cleaned, plain) - } - if len(cleaned) < 3 { - t.Fatalf("not enough rendered lines after stripping: %v", cleaned) - } - if !strings.Contains(cleaned[0], "⦁ Parent") { - t.Fatalf("unexpected parent line: %q", cleaned[0]) - } - if !strings.Contains(cleaned[1], "○ Child") { - t.Fatalf("unexpected child line: %q", cleaned[1]) - } - if !strings.Contains(cleaned[2], "✘ Grandchild") { - t.Fatalf("unexpected grandchild line: %q", cleaned[2]) - } -} - -func TestViewportPadsTrailingLines(t *testing.T) { - entries := makeEntries(3) - section := Section{ - CollectionID: "A", - CollectionName: "A", - Entries: entries, - } - - state := NewState() - state.SetSections([]Section{section}) - state.SetActive("A", entries[len(entries)-1].ID) - - height := 5 - view, _ := state.Viewport(height) - lines := strings.Split(view, "\n") - if len(lines) != height { - t.Fatalf("expected viewport to yield %d lines, got %d", height, len(lines)) - } - if lines[height-1] != "" { - t.Fatalf("expected viewport to pad trailing lines with blanks, got %q", lines[height-1]) - } -} - -func TestFormatEntryLinesAnnotatesLocked(t *testing.T) { - locked := &entry.Entry{ - ID: "locked", - Message: "Legacy task", - Bullet: glyph.MovedCollection, - Immutable: true, - } - lines := formatEntryLines(locked, " ", "", 60) - if len(lines) == 0 { - t.Fatalf("expected at least one line for locked entry") - } - first := strings.TrimSpace(stripANSIString(lines[0])) - if !strings.Contains(first, "Legacy task") { - t.Fatalf("expected message in output, got %q", first) - } - if !strings.Contains(first, "locked") { - t.Fatalf("expected locked annotation in output, got %q", first) + }) + _, _ = state.Viewport(8) + if len(state.cachedHeights) == 0 || state.cachedHeights[0] == -1 { + t.Fatalf("expected cached heights to be computed before fold") + } + state.ToggleEntryFold("p", true) + if state.cachedHeights[0] != -1 { + t.Fatalf("expected cached heights to invalidate after fold") } } diff --git a/pkg/tui/components/dummy/model.go b/pkg/tui/components/dummy/model.go index 90e0302..0b832f2 100644 --- a/pkg/tui/components/dummy/model.go +++ b/pkg/tui/components/dummy/model.go @@ -6,8 +6,6 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" - - "tableflip.dev/bujo/pkg/tui/components/command" ) // Model renders placeholder lines showing the current size. @@ -30,11 +28,11 @@ func New(width, height int) *Model { return m } -// Init implements command.Overlay. +// Init implements tea.Model. func (m *Model) Init() tea.Cmd { return nil } -// Update implements command.Overlay. -func (m *Model) Update(msg tea.Msg) (command.Overlay, tea.Cmd) { return m, nil } +// Update implements tea.Model. +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } // View renders the placeholder content. func (m *Model) View() (string, *tea.Cursor) { diff --git a/pkg/tui/components/help/model.go b/pkg/tui/components/help/model.go index c63017f..0035d37 100644 --- a/pkg/tui/components/help/model.go +++ b/pkg/tui/components/help/model.go @@ -8,8 +8,6 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss/v2" - - "tableflip.dev/bujo/pkg/tui/components/command" ) //go:embed help.md @@ -44,11 +42,11 @@ func New(width, height int) *Model { return model } -// Init implements command.Overlay. +// Init implements tea.Model. func (m *Model) Init() tea.Cmd { return nil } // Update handles Bubble Tea messages and forwards scrolling to the viewport. -func (m *Model) Update(msg tea.Msg) (command.Overlay, tea.Cmd) { +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { vp, cmd := m.viewport.Update(msg) m.viewport = vp return m, cmd diff --git a/pkg/tui/components/index/indexview.go b/pkg/tui/components/index/indexview.go index 92b4fea..72fb74f 100644 --- a/pkg/tui/components/index/indexview.go +++ b/pkg/tui/components/index/indexview.go @@ -8,9 +8,9 @@ import ( "time" "github.com/charmbracelet/bubbles/v2/list" - "github.com/charmbracelet/lipgloss/v2" "tableflip.dev/bujo/pkg/collection" + "tableflip.dev/bujo/pkg/collection/viewmodel" "tableflip.dev/bujo/pkg/tui/components/calendar" ) @@ -155,6 +155,16 @@ func BuildItems(state *State, metas []collection.Meta, currentResolved string, n cols = append(cols, name) } + ordered := viewmodel.OrderedIDs(metas, now) + if len(ordered) > 0 { + cols = ordered + for _, id := range ordered { + if _, ok := metaLookup[id]; !ok { + metaLookup[id] = collection.Meta{Name: id} + } + } + } + todayMonth := now.Format("January 2006") type monthEntry struct { @@ -494,19 +504,7 @@ func parseFriendlyDate(s string) time.Time { // DefaultCalendarOptions returns styling used for calendar rendering. func DefaultCalendarOptions() calendar.Options { - header := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Bold(true) - empty := lipgloss.NewStyle().Foreground(lipgloss.Color("244")) - entry := lipgloss.NewStyle().Foreground(lipgloss.Color("15")) - today := lipgloss.NewStyle().Underline(true) - selected := lipgloss.NewStyle().Background(lipgloss.Color("63")).Foreground(lipgloss.Color("0")) - return calendar.Options{ - HeaderStyle: header, - EmptyStyle: empty, - EntryStyle: entry, - TodayStyle: today, - SelectedStyle: selected, - ShowHeader: true, - } + return calendar.DefaultOptions() } // DefaultSelectedDay determines which day should be highlighted by default. diff --git a/pkg/tui/components/index/indexview_test.go b/pkg/tui/components/index/indexview_test.go new file mode 100644 index 0000000..052ddbe --- /dev/null +++ b/pkg/tui/components/index/indexview_test.go @@ -0,0 +1,80 @@ +package index + +import ( + "testing" + "time" + + "github.com/charmbracelet/bubbles/v2/list" + + "tableflip.dev/bujo/pkg/collection" +) + +func TestBuildItemsOrdering(t *testing.T) { + state := NewState() + now := time.Date(2024, time.January, 15, 0, 0, 0, 0, time.UTC) + metas := []collection.Meta{ + {Name: "Future", Type: collection.TypeMonthly}, + {Name: "January 2024", Type: collection.TypeDaily}, + {Name: "December 2023", Type: collection.TypeDaily}, + {Name: "Inbox", Type: collection.TypeGeneric}, + {Name: "Track", Type: collection.TypeTracking}, + } + + items := BuildItems(state, metas, "", now) + order := collectionItemNames(items) + + want := []string{"Future", "January 2024", "December 2023", "Inbox", "Tracking", "Track"} + if len(order) < len(want) { + t.Fatalf("expected at least %d collection items, got %d", len(want), len(order)) + } + for i := range want { + if order[i] != want[i] { + t.Fatalf("expected item %d to be %q, got %q", i, want[i], order[i]) + } + } +} + +func TestDefaultSelectedDay(t *testing.T) { + now := time.Date(2024, time.January, 10, 0, 0, 0, 0, time.UTC) + month := "January 2024" + monthTime := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC) + + selected := DefaultSelectedDay(month, monthTime, nil, "January 2024/January 5, 2024", now) + if selected != 5 { + t.Fatalf("expected currentResolved to win, got %d", selected) + } + + selected = DefaultSelectedDay(month, monthTime, nil, "", now) + if selected != 10 { + t.Fatalf("expected current month to select today, got %d", selected) + } + + otherMonth := "February 2024" + otherTime := time.Date(2024, time.February, 1, 0, 0, 0, 0, time.UTC) + children := []CollectionItem{{Name: "February 2, 2024", Resolved: "February 2024/February 2, 2024"}} + selected = DefaultSelectedDay(otherMonth, otherTime, children, "", now) + if selected != 2 { + t.Fatalf("expected first child day to be selected, got %d", selected) + } +} + +func TestRenderCalendarRowsRowCount(t *testing.T) { + monthTime := time.Date(2024, time.March, 1, 0, 0, 0, 0, time.UTC) + header, rows := RenderCalendarRows("March 2024", monthTime, nil, 0, monthTime, DefaultCalendarOptions()) + if header == nil { + t.Fatalf("expected header to be returned") + } + if len(rows) != 6 { + t.Fatalf("expected 6 rows for March 2024, got %d", len(rows)) + } +} + +func collectionItemNames(items []list.Item) []string { + var names []string + for _, item := range items { + if col, ok := item.(CollectionItem); ok { + names = append(names, col.Name) + } + } + return names +} diff --git a/pkg/tui/components/journal/debug.go b/pkg/tui/components/journal/debug.go new file mode 100644 index 0000000..bc68d2f --- /dev/null +++ b/pkg/tui/components/journal/debug.go @@ -0,0 +1,15 @@ +package journal + +import ( + tea "github.com/charmbracelet/bubbletea/v2" + + "tableflip.dev/bujo/pkg/tui/events" +) + +// debugCmd emits a debug event when journal routing needs to log context. +func (m *Model) debugCmd(context, detail string) tea.Cmd { + if context == "" && detail == "" { + return nil + } + return events.DebugCmd(m.id, context, detail) +} diff --git a/pkg/tui/components/journal/focus_test.go b/pkg/tui/components/journal/focus_test.go new file mode 100644 index 0000000..be43d5d --- /dev/null +++ b/pkg/tui/components/journal/focus_test.go @@ -0,0 +1,43 @@ +package journal + +import ( + "testing" + + "tableflip.dev/bujo/pkg/tui/events" +) + +func TestJournalFocusMsgUpdatesFocus(t *testing.T) { + model := NewModel(nil, nil, nil) + model.SetID(events.ComponentID("journal")) + + next, _ := model.Update(events.JournalFocusMsg{ + Component: model.ID(), + Pane: events.JournalFocusDetail, + }) + updated, ok := next.(*Model) + if !ok { + t.Fatalf("expected *Model, got %T", next) + } + if updated.FocusedPane() != FocusDetail { + t.Fatalf("expected focus detail, got %v", updated.FocusedPane()) + } + + next, _ = updated.Update(events.JournalFocusMsg{ + Component: updated.ID(), + Pane: events.JournalFocusNav, + }) + updated, _ = next.(*Model) + if updated.FocusedPane() != FocusNav { + t.Fatalf("expected focus nav, got %v", updated.FocusedPane()) + } + + other, _ := updated.Update(events.JournalFocusMsg{ + Component: events.ComponentID("other"), + Pane: events.JournalFocusDetail, + }) + updated, _ = other.(*Model) + if updated.FocusedPane() != FocusNav { + t.Fatalf("expected focus to remain nav") + } + +} diff --git a/pkg/tui/components/journal/model.go b/pkg/tui/components/journal/model.go index 90af4fd..664796e 100644 --- a/pkg/tui/components/journal/model.go +++ b/pkg/tui/components/journal/model.go @@ -1,7 +1,6 @@ package journal import ( - "fmt" "strings" tea "github.com/charmbracelet/bubbletea/v2" @@ -161,16 +160,21 @@ func (m *Model) FocusedPane() FocusPane { return m.focus } -// Update routes messages between the child panes and any active overlay. +// Update routes messages between the child panes and handles focus/selection events. func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd - dbg := func(ctx, detail string) { - if ctx == "" && detail == "" { - return + switch focus := msg.(type) { + case events.JournalFocusMsg: + if focus.Component != "" && focus.Component != m.id { + return m, nil + } + switch focus.Pane { + case events.JournalFocusDetail: + return m, m.FocusDetail() + default: + return m, m.FocusNav() } - cmds = appendCmd(cmds, events.DebugCmd(m.id, ctx, detail)) } - keyMsg, isKey := msg.(tea.KeyMsg) blockKeys := false if isKey { @@ -228,29 +232,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - switch evt := msg.(type) { - case events.BulletHighlightMsg: - if m.detailID != "" && evt.Component == m.detailID && m.nav != nil { - ref := events.CollectionRef{ID: evt.Collection.ID, Name: evt.Collection.Title} - dbg("bullet-highlight", fmt.Sprintf("select nav collection %s", ref.Label())) - if cmd := m.nav.SelectCollection(ref); cmd != nil { - cmds = appendCmd(cmds, cmd) - } - } - case events.BulletSelectMsg: - if evt.Component == m.detailID { - if cmd := events.BulletDetailRequestCmd(m.id, evt.Collection, evt.Bullet); cmd != nil { - cmds = appendCmd(cmds, cmd) - } - } - case events.CollectionSelectMsg: - if evt.Component == m.navID { - dbg("collection-select", fmt.Sprintf("focus detail for %s", evt.Collection.Label())) - if cmd := m.FocusDetail(); cmd != nil { - cmds = appendCmd(cmds, cmd) - } - } - } + cmds = appendCmds(cmds, m.handleSelectionEvent(msg)) if len(cmds) == 0 { return m, nil @@ -368,6 +350,13 @@ func appendCmd(cmds []tea.Cmd, cmd tea.Cmd) []tea.Cmd { return append(cmds, cmd) } +func appendCmds(cmds []tea.Cmd, next []tea.Cmd) []tea.Cmd { + if len(next) == 0 { + return cmds + } + return append(cmds, next...) +} + func collectionLabelFromID(id string) string { id = strings.TrimSpace(id) if id == "" { diff --git a/pkg/tui/components/journal/selection.go b/pkg/tui/components/journal/selection.go new file mode 100644 index 0000000..105a9f2 --- /dev/null +++ b/pkg/tui/components/journal/selection.go @@ -0,0 +1,38 @@ +package journal + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea/v2" + + "tableflip.dev/bujo/pkg/tui/events" +) + +// handleSelectionEvent routes selection/highlight events between nav and detail panes. +func (m *Model) handleSelectionEvent(msg tea.Msg) []tea.Cmd { + var cmds []tea.Cmd + switch evt := msg.(type) { + case events.BulletHighlightMsg: + if m.detailID != "" && evt.Component == m.detailID && m.nav != nil { + ref := events.CollectionRef{ID: evt.Collection.ID, Name: evt.Collection.Title} + cmds = appendCmd(cmds, m.debugCmd("bullet-highlight", fmt.Sprintf("select nav collection %s", ref.Label()))) + if cmd := m.nav.SelectCollection(ref); cmd != nil { + cmds = appendCmd(cmds, cmd) + } + } + case events.BulletSelectMsg: + if evt.Component == m.detailID { + if cmd := events.BulletDetailRequestCmd(m.id, evt.Collection, evt.Bullet); cmd != nil { + cmds = appendCmd(cmds, cmd) + } + } + case events.CollectionSelectMsg: + if evt.Component == m.navID { + cmds = appendCmd(cmds, m.debugCmd("collection-select", fmt.Sprintf("focus detail for %s", evt.Collection.Label()))) + if cmd := m.FocusDetail(); cmd != nil { + cmds = appendCmd(cmds, cmd) + } + } + } + return cmds +} diff --git a/pkg/tui/components/journal/selection_test.go b/pkg/tui/components/journal/selection_test.go new file mode 100644 index 0000000..b30bc92 --- /dev/null +++ b/pkg/tui/components/journal/selection_test.go @@ -0,0 +1,55 @@ +package journal + +import ( + "testing" + + "tableflip.dev/bujo/pkg/tui/components/collectiondetail" + "tableflip.dev/bujo/pkg/tui/components/collectionnav" + "tableflip.dev/bujo/pkg/tui/events" +) + +func TestHandleSelectionEventBulletSelect(t *testing.T) { + nav := collectionnav.NewModel(nil) + detail := collectiondetail.NewModel(nil) + model := NewModel(nav, detail, nil) + + cmds := model.handleSelectionEvent(events.BulletSelectMsg{ + Component: detail.ID(), + Collection: events.CollectionViewRef{ + ID: "daily/2026-01-23", + Title: "2026-01-23", + }, + Bullet: events.BulletRef{ID: "bullet-1", Label: "Test"}, + }) + if len(cmds) != 1 { + t.Fatalf("expected 1 command, got %d", len(cmds)) + } + msg := cmds[0]() + req, ok := msg.(events.BulletDetailRequestMsg) + if !ok { + t.Fatalf("expected BulletDetailRequestMsg, got %T", msg) + } + if req.Collection.ID != "daily/2026-01-23" { + t.Fatalf("expected collection ID to match, got %q", req.Collection.ID) + } +} + +func TestHandleSelectionEventCollectionSelect(t *testing.T) { + nav := collectionnav.NewModel(nil) + detail := collectiondetail.NewModel(nil) + model := NewModel(nav, detail, nil) + + cmds := model.handleSelectionEvent(events.CollectionSelectMsg{ + Component: nav.ID(), + Collection: events.CollectionRef{ + ID: "daily/2026-01-23", + Name: "2026-01-23", + }, + }) + if len(cmds) == 0 { + t.Fatal("expected focus command for collection select") + } + if cmds[0] == nil { + t.Fatal("expected non-nil focus command") + } +} diff --git a/pkg/tui/components/overlaypane/model.go b/pkg/tui/components/overlaypane/model.go index cb74320..42078ea 100644 --- a/pkg/tui/components/overlaypane/model.go +++ b/pkg/tui/components/overlaypane/model.go @@ -93,8 +93,10 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { next, cmd := m.overlay.Update(msg) if next == nil { m.overlay = nil + } else if overlay, ok := next.(command.Overlay); ok { + m.overlay = overlay } else { - m.overlay = next + m.overlay = nil } return cmd } diff --git a/pkg/tui/events/events.go b/pkg/tui/events/events.go index bc6c93e..ae20831 100644 --- a/pkg/tui/events/events.go +++ b/pkg/tui/events/events.go @@ -14,6 +14,36 @@ import ( // ComponentID uniquely identifies a component instance emitting events. type ComponentID string +// ChildMsg wraps a child component message with its origin. +type ChildMsg struct { + From ComponentID + Msg tea.Msg +} + +// JournalFocusPane identifies which journal pane should receive focus. +type JournalFocusPane string + +const ( + // JournalFocusNav requests focus for the navigation pane. + JournalFocusNav JournalFocusPane = "nav" + // JournalFocusDetail requests focus for the detail pane. + JournalFocusDetail JournalFocusPane = "detail" +) + +// JournalFocusMsg requests a journal component to shift focus to a specific pane. +type JournalFocusMsg struct { + Component ComponentID + Pane JournalFocusPane +} + +// Describe renders the focus change for logging. +func (m JournalFocusMsg) Describe() string { + if m.Pane == "" { + return "pane=unknown" + } + return "pane=" + string(m.Pane) +} + // CollectionRef captures the metadata required to identify a collection in // cross-component events. type CollectionRef struct { diff --git a/pkg/tui/theme/theme.go b/pkg/tui/theme/theme.go index d1dc029..a71c635 100644 --- a/pkg/tui/theme/theme.go +++ b/pkg/tui/theme/theme.go @@ -4,10 +4,12 @@ import "github.com/charmbracelet/lipgloss/v2" // Theme centralizes Lip Gloss styles for the Bubble Tea UI. type Theme struct { - Footer FooterTheme - Panel PanelTheme - Report ReportTheme - Modal ModalTheme + Footer FooterTheme + Panel PanelTheme + Report ReportTheme + Modal ModalTheme + Calendar CalendarTheme + Accent lipgloss.Style } // FooterTheme groups styles used by the bottom status/command bar. @@ -21,6 +23,15 @@ type FooterTheme struct { CommandSelectedDesc lipgloss.Style } +// CalendarTheme styles the calendar grid used in nav/index views. +type CalendarTheme struct { + Header lipgloss.Style + Empty lipgloss.Style + Entry lipgloss.Style + Today lipgloss.Style + Selected lipgloss.Style +} + // PanelTheme styles framed panels and headings. type PanelTheme struct { Frame lipgloss.Style @@ -51,11 +62,16 @@ func Default() Theme { commandSelectedName := commandName.Reverse(true) commandSelectedDesc := commandDesc.Reverse(true) + calendarHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Bold(true) + calendarEmpty := lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + calendarEntry := lipgloss.NewStyle().Foreground(lipgloss.Color("15")) + calendarToday := lipgloss.NewStyle().Underline(true) + calendarSelected := lipgloss.NewStyle().Background(lipgloss.Color("63")).Foreground(lipgloss.Color("0")) return Theme{ Footer: FooterTheme{ Help: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), - Status: lipgloss.NewStyle().Foreground(lipgloss.Color("244")), + Status: lipgloss.NewStyle().Italic(true).Foreground(lipgloss.Color("214")), Bullet: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), CommandName: commandName, CommandDescription: commandDesc, @@ -81,5 +97,13 @@ func Default() Theme { Title: lipgloss.NewStyle().Bold(true), Body: lipgloss.NewStyle(), }, + Calendar: CalendarTheme{ + Header: calendarHeader, + Empty: calendarEmpty, + Entry: calendarEntry, + Today: calendarToday, + Selected: calendarSelected, + }, + Accent: lipgloss.NewStyle().Foreground(lipgloss.Color("213")), } } diff --git a/testbed/README.md b/testbed/README.md new file mode 100644 index 0000000..cd7fb35 --- /dev/null +++ b/testbed/README.md @@ -0,0 +1,4 @@ +# Testbed (Non-Production) + +The `testbed` binary is a local UI harness for experimenting with TUI components. +It is not shipped with the production CLI and should only be used during development. diff --git a/testbed/main.go b/testbed/main.go index 2a6ec89..45331e0 100644 --- a/testbed/main.go +++ b/testbed/main.go @@ -1,3 +1,4 @@ +// Package main hosts the non-production TUI testbed harness. package main import ( diff --git a/todo.md b/todo.md index 4d02239..d678792 100644 --- a/todo.md +++ b/todo.md @@ -1,60 +1,61 @@ # TODO ## Refactor / Decompose -- [ ] Split `pkg/tui/app/app.go` into smaller feature files (command handling, overlay lifecycle, migration/move flows, watch/cache, focus management). The `Update` method and multiple 80–120+ line handlers (`handleMoveSelection`, `showMigrateOverlay`, `handleMigrationMoveRequest`, etc.) are hard to reason about and should be broken into focused helpers. -- [ ] Extract selection/highlight routing logic out of `pkg/tui/components/journal/model.go` and `pkg/tui/components/collectiondetail/model.go` into a small coordinator (or dedicated methods) to reduce cross-component knowledge and duplicated focus logic. -- [ ] Split `pkg/tui/components/collectiondetail/model.go` into rendering/layout vs. event handling vs. data mutation; consider moving line-layout/scrolling into a smaller state struct (similar to `pkg/tui/components/detail/state.go`). -- [ ] Split `pkg/tui/components/collectionnav/model.go` into list view, calendar view, and sorting/selection logic. The calendar-related methods (`calendarChildren`, `selectedCalendarDay`, `virtualDay`, etc.) can live in a small sub-module. -- [ ] Break up `pkg/tui/components/detail/state.go` (820 lines) into render/layout, mutation, and navigation files; several long helpers (`formatEntryLines`, `ensureScrollVisible`, `renderSection`) are tightly coupled and difficult to test in isolation. -- [ ] Split `pkg/tui/components/addtask/model.go` into input handling, collection filtering, and rendering; overlay logic is large and mixes view/behavior. -- [ ] Split `pkg/tui/components/command/model.go` into suggestion overlay, input handling, and rendering; `refreshSuggestionOverlay` and `Update` are large and mix concerns. -- [ ] Separate `pkg/tui/cache/cache.go` (state + events) from sync/diff helpers (`pkg/tui/cache/sync.go`) into clearer subpackages or files; the sync logic is complex and deserves its own unit-test surface. -- [ ] Consider refactoring `pkg/store/diskv.go` into smaller helpers for meta, entries, and locking/error translation; file I/O paths are large and imperative. +- [x] Split `pkg/tui/app/app.go` into smaller feature files (command handling, overlay lifecycle, migration/move flows, watch/cache, focus management). The `Update` method and multiple 80–120+ line handlers (`handleMoveSelection`, `showMigrateOverlay`, `handleMigrationMoveRequest`, etc.) are hard to reason about and should be broken into focused helpers. +- [x] Extract selection/highlight routing logic out of `pkg/tui/components/journal/model.go` and `pkg/tui/components/collectiondetail/model.go` into a small coordinator (or dedicated methods) to reduce cross-component knowledge and duplicated focus logic. +- [x] Split `pkg/tui/components/collectiondetail/model.go` into rendering/layout vs. event handling vs. data mutation; consider moving line-layout/scrolling into a smaller state struct (similar to `pkg/tui/components/detail/state.go`). +- [x] Split `pkg/tui/components/collectionnav/model.go` into list view, calendar view, and sorting/selection logic. The calendar-related methods (`calendarChildren`, `selectedCalendarDay`, `virtualDay`, etc.) can live in a small sub-module. +- [x] Break up `pkg/tui/components/detail/state.go` (820 lines) into render/layout, mutation, and navigation files; several long helpers (`formatEntryLines`, `ensureScrollVisible`, `renderSection`) are tightly coupled and difficult to test in isolation. +- [x] Split `pkg/tui/components/addtask/model.go` into input handling, collection filtering, and rendering; overlay logic is large and mixes view/behavior. +- [x] Split `pkg/tui/components/command/model.go` into suggestion overlay, input handling, and rendering; `refreshSuggestionOverlay` and `Update` are large and mix concerns. +- [x] Separate `pkg/tui/cache/cache.go` (state + events) from sync/diff helpers (`pkg/tui/cache/sync.go`) into clearer subpackages or files; the sync logic is complex and deserves its own unit-test surface. +- [x] Consider refactoring `pkg/store/diskv.go` into smaller helpers for meta, entries, and locking/error translation; file I/O paths are large and imperative. ## Logic Cleanup / Consistency -- [ ] Consolidate collection ordering rules in one place (currently spread between `pkg/collection/viewmodel/viewmodel.go`, `pkg/tui/cache/sync.go`, and `pkg/tui/components/index/indexview.go`). Prefer a single canonical ordering function and reuse it across nav/detail/cache to avoid drift. -- [ ] Normalize “now” handling: multiple subsystems call `time.Now()` independently. Inject a shared clock (e.g., via app/model options) so UI ordering, cache build, and nav calendar stay consistent. -- [ ] Reduce repeated “overlay guard” patterns in `pkg/tui/app/app.go` by introducing a helper to handle “skip command/journal update” logic and a single overlay state machine entrypoint. -- [ ] Improve selection/highlight interplay when nav is focused but detail is not (calendar highlight vs. detail view): centralize the rules for when detail should scroll/select vs. only highlight. -- [ ] Standardize section/collection ID formatting in detail vs. nav (several helpers rely on formatted names); enforce a single canonical ID format to avoid mismatches. -- [ ] Audit status message flows and make them deterministic (currently status is set from many branches in `app.go`). +- [x] Consolidate collection ordering rules in one place (currently spread between `pkg/collection/viewmodel/viewmodel.go`, `pkg/tui/cache/sync.go`, and `pkg/tui/components/index/indexview.go`). Prefer a single canonical ordering function and reuse it across nav/detail/cache to avoid drift. +- [x] Normalize “now” handling: multiple subsystems call `time.Now()` independently. Inject a shared clock (e.g., via app/model options) so UI ordering, cache build, and nav calendar stay consistent. +- [x] Reduce repeated “overlay guard” patterns in `pkg/tui/app/app.go` by introducing a helper to handle “skip command/journal update” logic and a single overlay state machine entrypoint. +- [x] Improve selection/highlight interplay when nav is focused but detail is not (calendar highlight vs. detail view): centralize the rules for when detail should scroll/select vs. only highlight. +- [x] Standardize section/collection ID formatting in detail vs. nav (several helpers rely on formatted names); enforce a single canonical ID format to avoid mismatches. +- [x] Audit status message flows and make them deterministic (currently status is set from many branches in `app.go`). ## Tests To Add (Coverage Gaps) - [x] Add tests for `pkg/tui/cache` (currently no tests): - [x] `buildBullets` ordering, parent-child linking, cycles/missing parents, dedupe behavior. - [x] `applySnapshotLocked` diff behavior (create/update/delete) and event emission. - [x] `CreateCollection`, `SyncCollection`, and error paths in `createBulletPersisted`. -- [ ] Add tests for `pkg/tui/components/collectiondetail`: +- [x] Add tests for `pkg/tui/components/collectiondetail`: - [x] placeholder/empty-section rendering behavior, focus/scroll behavior when `cursor == -1`. - - [ ] selection retention after section reorder and after placeholder insertions. - - [ ] highlight vs. select event handling (day vs. non-day collections). -- [ ] Add tests for `pkg/tui/components/collectionnav`: + - [x] selection retention after section reorder and after placeholder insertions. + - [x] highlight vs. select event handling (day vs. non-day collections). +- [x] Add tests for `pkg/tui/components/collectionnav`: - [x] calendar day selection for missing entries (virtual days). - - [ ] highlight/select messages for day vs. non-day rows. - - [ ] `calendarChildren` ordering and `DefaultSelectedDay` logic. -- [ ] Add tests for `pkg/tui/components/index/indexview.go`: - - [ ] `BuildItems` ordering (future, daily w/ today-first, monthly, tracking, generic). - - [ ] `RenderCalendarRows` and `DefaultSelectedDay` edge cases (month boundaries, no days). -- [ ] Add tests for `pkg/tui/components/addtask` and `pkg/tui/components/command`: - - [ ] input handling transitions and overlay focus behavior. -- [ ] Add tests for `pkg/store/diskv.go`: - - [ ] read/write round-trips, missing/corrupt file handling, concurrency edge cases. -- [ ] Add tests for `pkg/commands` + `pkg/runner/*`: - - [ ] CLI option parsing and error paths (commands + runner integration). -- [ ] Add tests for `pkg/collection/types.go` and `pkg/collection/meta.go` for type inference and path handling (minor, but currently uncovered). + - [x] highlight/select messages for day vs. non-day rows. + - [x] `calendarChildren` ordering. +- [x] Add tests for `pkg/tui/components/index/indexview.go`: + - [x] `BuildItems` ordering (future, daily w/ today-first, monthly, tracking, generic). + - [x] `RenderCalendarRows` and `DefaultSelectedDay` edge cases. +- [x] Add tests for `pkg/tui/components/addtask` and `pkg/tui/components/command`: + - [x] input handling transitions and overlay focus behavior. +- [x] Add tests for `pkg/store/diskv.go`: + - [x] read/write round-trips, missing/corrupt file handling, concurrency edge cases. +- [x] Add tests for `pkg/commands` + `pkg/runner/*`: + - [x] CLI option parsing and error paths (commands + runner integration). +- [x] Add tests for `pkg/collection/types.go` and `pkg/collection/meta.go` for type inference and path handling (minor, but currently uncovered). + - [x] type inference and metadata (ParseType, GuessType, ValidateChildName, UnmarshalList). ## Docs / Comments / Style -- [ ] Audit exported functions/types lacking doc comments in `pkg/tui/*` and `pkg/app/*`; add brief docs where public APIs are used across packages. -- [ ] Remove or update stale inline comments in large UI models where behavior has shifted (e.g., overlay close behavior, focus handling). +- [x] Audit exported functions/types lacking doc comments in `pkg/tui/*` and `pkg/app/*`; add brief docs where public APIs are used across packages. +- [x] Remove or update stale inline comments in large UI models where behavior has shifted (e.g., overlay close behavior, focus handling). ## Architectural Follow-ups -- [ ] Consider introducing small interfaces for service/cache interactions to enable unit-testing UI components without real disk/service access. -- [ ] Move “testbed” utilities into a separate module or mark them clearly as non-production helpers to reduce noise in core packages. +- [x] Consider introducing small interfaces for service/cache interactions to enable unit-testing UI components without real disk/service access. +- [x] Move “testbed” utilities into a separate module or mark them clearly as non-production helpers to reduce noise in core packages. ## Bubble Tea Alignment Goals -- [ ] Standardize key handling with `bubbles/key` maps in `collectionnav`, `collectiondetail`, `command`, and `addtask`; replace ad-hoc `msg.String()` switches with `key.Matches`. -- [ ] Introduce a consistent child message routing pattern (e.g., `ChildMsg{From, Msg}`) or a small router helper in `pkg/tui/app` so parent/child message flow is explicit and testable. -- [ ] Add a page/router layer in `pkg/tui/app` (single active view + overlay stack) to reduce the `Update` method size and make navigation explicit. -- [ ] Refactor overlays into sub-models that implement `tea.Model`, with consistent focus/blur and message ownership. -- [ ] Audit direct cross-component calls and replace with `pkg/tui/events` messages where practical to reinforce the event bridge. -- [ ] Centralize Lip Gloss styling in `pkg/tui/theme` and replace inline styles in components where possible. +- [x] Standardize key handling with `bubbles/key` maps in `collectionnav`, `collectiondetail`, `command`, and `addtask`; replace ad-hoc `msg.String()` switches with `key.Matches`. +- [x] Introduce a consistent child message routing pattern (e.g., `ChildMsg{From, Msg}`) or a small router helper in `pkg/tui/app` so parent/child message flow is explicit and testable. +- [x] Add a page/router layer in `pkg/tui/app` (single active view + overlay stack) to reduce the `Update` method size and make navigation explicit. +- [x] Refactor overlays into sub-models that implement `tea.Model`, with consistent focus/blur and message ownership. +- [x] Audit direct cross-component calls and replace with `pkg/tui/events` messages where practical to reinforce the event bridge. +- [x] Centralize Lip Gloss styling in `pkg/tui/theme` and replace inline styles in components where possible. From 5148268a798287445280fb8206fab7833c594110 Mon Sep 17 00:00:00 2001 From: Scott Nichols Date: Sat, 24 Jan 2026 08:46:30 -0800 Subject: [PATCH 3/8] add QA reporting --- QA.md | 177 ++++++++++++++++++++++++ qa/reports/QA_REPORT_TEST.md | 252 +++++++++++++++++++++++++++++++++++ 2 files changed, 429 insertions(+) create mode 100644 QA.md create mode 100644 qa/reports/QA_REPORT_TEST.md diff --git a/QA.md b/QA.md new file mode 100644 index 0000000..84b1edc --- /dev/null +++ b/QA.md @@ -0,0 +1,177 @@ +# Bujo TUI Manual QA Checklist + +This checklist aims for ~90% coverage of common TUI interactions. It uses a fresh, deterministic QA dataset each run, seeded via CLI. Keep notes of any failures, visual glitches, or unexpected behavior (especially around cursor alignment and list ordering). + +## Setup (Fresh QA Database) + +### 1) Create QA config + clean DB + +```bash +mkdir -p qa +export BUJO_PATH="$(pwd)/qa/qa.db" +export BUJO_CONFIG_PATH="$(pwd)/qa/.bujo.qa.yaml" + +cat > "$BUJO_CONFIG_PATH" <` to open move overlay. +- [ ] Use nav to pick a different collection and press `Enter`; overlay closes and bullet moves. +- [ ] In move overlay, select “+ New Collection...” and create one; confirm move. +- [ ] Select a bullet and press `<` to move to Future; verify status update and bullet removed from current list. + +### H) Migration Overlay +- [ ] Run `:migrate 14d` (or `:migrate 7d`). +- [ ] Use `<` to focus Future nav, `>` to focus target nav; ensure focus indicator changes. +- [ ] Select a candidate and move it to a target; entry disappears from migration list. +- [ ] Select “+ New Collection...” to create a target; verify it is created. +- [ ] `Esc` exits overlay and restores prior focus. + +### I) Lock / Unlock +- [ ] Select a bullet and run `:lock`; verify status update. +- [ ] Run `:unlock`; verify status update and bullet becomes movable again. + +### J) Quit +- [ ] Run `:quit` to exit cleanly. + +## Known Issues to Validate (Explicit Checks) +- [ ] Add Task overlay cursor alignment (should match input field). +- [ ] Down-arrow in detail should not loop to top or change day unexpectedly. +- [ ] Detail list ordering should match nav order for daily collections. + +## Notes / Observations + +Use this space to log failures, glitches, and reproduction steps: + +- … diff --git a/qa/reports/QA_REPORT_TEST.md b/qa/reports/QA_REPORT_TEST.md new file mode 100644 index 0000000..e7ec932 --- /dev/null +++ b/qa/reports/QA_REPORT_TEST.md @@ -0,0 +1,252 @@ +# Bujo TUI QA Report + +- Date: 2026-01-24 08:41:45 +- QA source: /Users/n3wscott/src/n3wscott/bujo/QA.md +- BUJO_PATH: (not set) +- BUJO_CONFIG_PATH: (not set) + +## Results Checklist + +### A) Startup / Layout Smoke + +- [ ] App launches without errors, shows nav (left) and detail (right), status bar at bottom. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Status line shows “Journal loaded”. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Resize terminal wider and narrower; layout should adjust (no overlap or truncated bottom bar). + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +### B) Command Bar + Overlays + +- [ ] Press `:` to open command input; suggestions show and update. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Use `Tab` / `Shift+Tab` or `Down` / `Up` to move suggestion selection. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Press `Enter` to accept a suggestion, and `Esc` to exit command input. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] `:help` opens overlay; `Esc` closes and focus restores. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] `:debug` toggles the event viewer; the body should resize and the viewer appears/disappears. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] `:report 7d` opens report overlay; `Esc` closes it. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] `:today` and `:future` jump to the expected collection (verify status updates). + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +### C) Navigation List (Left Pane) + +- [ ] Arrow keys / `j`/`k` move selection line-by-line. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] `Right` / `l` expands a folded node; `Left` / `h` collapses it. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Current month appears even if it has only placeholders; today is visible near the top of daily list. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Daily collections are sorted with today on top, then prior days (verify order). + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Selecting a month shows its days; selecting a day updates the detail pane. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Rapidly press `Down` 10+ times in nav: detail should update to the corresponding collection each time without jumps or skipped sections. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Move `Up` and `Down` across the boundary between months; detail should follow without resetting to an unexpected section. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] When hovering over a day in the nav (without Enter), detail should preview that day without changing selection in the nav. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +### D) Detail Pane (Right Pane) + +- [ ] `Tab` moves focus to detail; `Shift+Tab` returns focus to nav. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Arrow keys / `j`/`k` move between bullets. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] `PageUp`/`PageDown` or `b`/`f` scrolls pages without losing selection. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] `Home`/`g` jumps to top; `End`/`G` jumps to bottom. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] If a day has no entries (e.g., `$QA_TOMORROW`), detail shows the placeholder message. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] **Known issue check:** pressing `Down` should not loop to the top or jump to a different day. If it does, record the collection name and steps. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] With detail focused, press `Down` repeatedly from the first bullet to the last; the cursor should advance sequentially with no skips. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] After reaching the last bullet, press `Down` once more; selection should stay at the last item (no jump to top). + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Press `Up` repeatedly from the first bullet; selection should stay at the first item (no wrap). + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Verify detail section order matches nav order for the current month (same day sequence as nav list). + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +### E) Add Task Overlay + +- [ ] With nav focused on today, press `i` to open the Add Task overlay. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Cursor should align with the input field (no offset/ghost cursor). **Known issue check.** + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] `Tab` cycles fields; `Shift+Tab` goes backward. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Enter a task message, submit with `Enter`. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] On return, focus should land on the newly created entry (not a different day). **Known issue check.** + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Press `i` again, then `Esc` → confirmation prompt; `y` discards and exits. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +### F) Bullet Actions (Detail Pane) + +- [ ] Select a bullet and press `x` to complete; bullet updates to completed glyph. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Select a bullet and press `Backspace` to strike; bullet updates to dropped glyph. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Press `*`, `!`, `?` to set signifiers; press `|` to clear. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Press `Enter`/`Space` on a bullet to open the bullet detail overlay; verify info renders; `Esc` closes. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +### G) Move / Future / New Collection + +- [ ] Select a bullet and press `>` to open move overlay. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Use nav to pick a different collection and press `Enter`; overlay closes and bullet moves. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] In move overlay, select “+ New Collection...” and create one; confirm move. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Select a bullet and press `<` to move to Future; verify status update and bullet removed from current list. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +### H) Migration Overlay + +- [ ] Run `:migrate 14d` (or `:migrate 7d`). + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Use `<` to focus Future nav, `>` to focus target nav; ensure focus indicator changes. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Select a candidate and move it to a target; entry disappears from migration list. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Select “+ New Collection...” to create a target; verify it is created. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] `Esc` exits overlay and restores prior focus. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +### I) Lock / Unlock + +- [ ] Select a bullet and run `:lock`; verify status update. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Run `:unlock`; verify status update and bullet becomes movable again. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +### J) Quit + +- [ ] Run `:quit` to exit cleanly. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +## Known Issues to Validate (Explicit Checks) + +- [ ] Add Task overlay cursor alignment (should match input field). + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Down-arrow in detail should not loop to top or change day unexpectedly. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +- [ ] Detail list ordering should match nav order for daily collections. + Result: [ ] Pass [ ] Fail [ ] N/A + Notes: + +## Notes / Observations + +## Summary + +- Passed: +- Failed: +- N/A: + +## Follow-ups From 03e505dd781d36b526a6aa1240c2105e5bcd468a Mon Sep 17 00:00:00 2001 From: Scott Nichols Date: Sat, 24 Jan 2026 09:12:02 -0800 Subject: [PATCH 4/8] better qa reports --- .gitignore | 2 ++ QA.md | 30 +++++++++++++++++++----------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 990c06b..5418d17 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ *.dylib .gocache/ +qa/ + # Test binary, built with `go test -c` *.test .bujo.db diff --git a/QA.md b/QA.md index 84b1edc..11c80a4 100644 --- a/QA.md +++ b/QA.md @@ -49,25 +49,33 @@ print(f'export QA_MONTH="{today.strftime("%B %Y")}"') print(f'export QA_NEXT_MONTH="{next_month.strftime("%B %Y")}"') print(f'export QA_PREV_MONTH="{prev_month.strftime("%B %Y")}"') print(f'export QA_NEXT_MONTH_DAY="{fmt(next_month_day)}"') +print(f'export QA_TODAY_MONTH="{today.strftime("%B %Y")}"') +print(f'export QA_YESTERDAY_MONTH="{yesterday.strftime("%B %Y")}"') +print(f'export QA_WEEK_AGO_MONTH="{week_ago.strftime("%B %Y")}"') +print(f'export QA_TEN_DAYS_AGO_MONTH="{ten_days_ago.strftime("%B %Y")}"') +print(f'export QA_TOMORROW_MONTH="{tomorrow.strftime("%B %Y")}"') PY )" # Types / parents go run . collections type "Future" monthly -go run . collections type "$QA_MONTH" daily +go run . collections type "$QA_TODAY_MONTH" daily +go run . collections type "$QA_YESTERDAY_MONTH" daily +go run . collections type "$QA_WEEK_AGO_MONTH" daily +go run . collections type "$QA_TEN_DAYS_AGO_MONTH" daily go run . collections type "$QA_NEXT_MONTH" daily go run . collections type "$QA_PREV_MONTH" daily go run . collections type "Habits" tracking -# Daily collections -go run . add task "QA Today task A" -c "$QA_TODAY" -go run . add task "QA Today task B" -c "$QA_TODAY" -go run . add note "QA Today note" -c "$QA_TODAY" -go run . add event "QA Today event" -c "$QA_TODAY" -go run . add task "QA Yesterday task" -c "$QA_YESTERDAY" -go run . add task "QA Week-ago task (migration)" -c "$QA_WEEK_AGO" -go run . add task "QA Ten-days-ago task (migration)" -c "$QA_TEN_DAYS_AGO" -go run . add task "QA Next-month day task" -c "$QA_NEXT_MONTH_DAY" +# Daily collections (nest under month parent) +go run . add task "QA Today task A" -c "$QA_TODAY_MONTH/$QA_TODAY" +go run . add task "QA Today task B" -c "$QA_TODAY_MONTH/$QA_TODAY" +go run . add note "QA Today note" -c "$QA_TODAY_MONTH/$QA_TODAY" +go run . add event "QA Today event" -c "$QA_TODAY_MONTH/$QA_TODAY" +go run . add task "QA Yesterday task" -c "$QA_YESTERDAY_MONTH/$QA_YESTERDAY" +go run . add task "QA Week-ago task (migration)" -c "$QA_WEEK_AGO_MONTH/$QA_WEEK_AGO" +go run . add task "QA Ten-days-ago task (migration)" -c "$QA_TEN_DAYS_AGO_MONTH/$QA_TEN_DAYS_AGO" +go run . add task "QA Next-month day task" -c "$QA_NEXT_MONTH/$QA_NEXT_MONTH_DAY" # Generic collections go run . add task "Inbox task 1" -c "Inbox" @@ -81,7 +89,7 @@ go run . track "Habits" # Scroll stress (today) for i in $(seq 1 15); do - go run . add task "QA Scroll item $i" -c "$QA_TODAY" + go run . add task "QA Scroll item $i" -c "$QA_TODAY_MONTH/$QA_TODAY" done ``` From 8532beb4582b27ccc30b5ea3116287b731ebc420 Mon Sep 17 00:00:00 2001 From: Scott Nichols Date: Sat, 24 Jan 2026 09:59:09 -0800 Subject: [PATCH 5/8] post qa sweep --- pkg/collection/viewmodel/viewmodel.go | 58 ++++++++++++++++------ pkg/collection/viewmodel/viewmodel_test.go | 44 ++++++++++++++++ 2 files changed, 87 insertions(+), 15 deletions(-) diff --git a/pkg/collection/viewmodel/viewmodel.go b/pkg/collection/viewmodel/viewmodel.go index e4874fb..f8ce316 100644 --- a/pkg/collection/viewmodel/viewmodel.go +++ b/pkg/collection/viewmodel/viewmodel.go @@ -168,20 +168,7 @@ func sortCollections(nodes []*ParsedCollection, opts *buildOptions) { func sortCollectionsWithParent(nodes []*ParsedCollection, parent *ParsedCollection, opts *buildOptions) { sort.Slice(nodes, func(i, j int) bool { if parent != nil && parent.Type == collection.TypeDaily { - di := nodes[i].Day - dj := nodes[j].Day - if !di.IsZero() || !dj.IsZero() { - if di.Equal(dj) { - return nodes[i].Name < nodes[j].Name - } - if di.IsZero() { - return false - } - if dj.IsZero() { - return true - } - return di.Before(dj) - } + return compareDailyChildren(nodes[i], nodes[j], parent, opts) } if parent == nil && opts != nil && opts.useNow { now := opts.now @@ -207,6 +194,47 @@ func sortCollectionsWithParent(nodes []*ParsedCollection, parent *ParsedCollecti } } +// compareDailyChildren keeps today at the top for the current month, then sorts by newest day first. +func compareDailyChildren(a, b *ParsedCollection, parent *ParsedCollection, opts *buildOptions) bool { + if a == nil || b == nil { + return a != nil + } + da := a.Day + db := b.Day + if da.IsZero() && db.IsZero() { + return a.Name < b.Name + } + if da.IsZero() { + return false + } + if db.IsZero() { + return true + } + if opts != nil && opts.useNow && parent != nil { + now := opts.now + if now.IsZero() { + now = time.Now() + } + month := monthForCollection(parent) + if !month.IsZero() && month.Year() == now.Year() && month.Month() == now.Month() { + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + isTodayA := sameDay(da, today) + isTodayB := sameDay(db, today) + if isTodayA != isTodayB { + return isTodayA + } + } + } + if da.Equal(db) { + return a.Name < b.Name + } + return da.After(db) +} + +func sameDay(a, b time.Time) bool { + return a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day() +} + func daySummaries(children []*ParsedCollection) []DaySummary { if len(children) == 0 { return nil @@ -229,7 +257,7 @@ func daySummaries(children []*ParsedCollection) []DaySummary { if days[i].Date.Equal(days[j].Date) { return days[i].Name < days[j].Name } - return days[i].Date.Before(days[j].Date) + return days[i].Date.After(days[j].Date) }) return days } diff --git a/pkg/collection/viewmodel/viewmodel_test.go b/pkg/collection/viewmodel/viewmodel_test.go index 28697c7..0d75830 100644 --- a/pkg/collection/viewmodel/viewmodel_test.go +++ b/pkg/collection/viewmodel/viewmodel_test.go @@ -107,3 +107,47 @@ func TestBuildTreeWithNowInjectsCurrentMonthAndOrdersDaily(t *testing.T) { t.Fatalf("expected future month after past months, got %s", roots[3].ID) } } + +func TestBuildTreeOrdersDailyChildrenWithTodayFirst(t *testing.T) { + now := time.Date(2026, time.January, 24, 12, 0, 0, 0, time.UTC) + metas := []collection.Meta{ + {Name: "January 2026", Type: collection.TypeDaily}, + {Name: "January 2026/January 14, 2026", Type: collection.TypeGeneric}, + {Name: "January 2026/January 17, 2026", Type: collection.TypeGeneric}, + {Name: "January 2026/January 23, 2026", Type: collection.TypeGeneric}, + {Name: "January 2026/January 24, 2026", Type: collection.TypeGeneric}, + } + + roots := BuildTree(metas, WithNow(now)) + var january *ParsedCollection + for _, root := range roots { + if root.ID == "January 2026" { + january = root + break + } + } + if january == nil { + t.Fatalf("expected January 2026 root") + } + + got := make([]string, 0, len(january.Children)) + for _, child := range january.Children { + got = append(got, child.ID) + } + + want := []string{ + "January 2026/January 24, 2026", + "January 2026/January 23, 2026", + "January 2026/January 17, 2026", + "January 2026/January 14, 2026", + } + + if len(got) != len(want) { + t.Fatalf("expected %d children, got %d (%v)", len(want), len(got), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("expected child %d to be %q, got %q", i, want[i], got[i]) + } + } +} From 9ef0fbee7fa5afbe50e17f455f87de3cd0956c61 Mon Sep 17 00:00:00 2001 From: Scott Nichols Date: Sat, 24 Jan 2026 10:10:03 -0800 Subject: [PATCH 6/8] fix test --- testbed/help_cmd.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/testbed/help_cmd.go b/testbed/help_cmd.go index 5a6d204..b9abe40 100644 --- a/testbed/help_cmd.go +++ b/testbed/help_cmd.go @@ -66,7 +66,11 @@ func (m *helpTestModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if next == nil { m.overlay = nil } else { - m.overlay = next + if overlayNext, ok := next.(command.Overlay); ok { + m.overlay = overlayNext + } else { + m.overlay = nil + } } } } From e5d1bf3c83522facaab4a0fa2f7bf310f2f744bd Mon Sep 17 00:00:00 2001 From: Scott Nichols Date: Sat, 24 Jan 2026 10:11:00 -0800 Subject: [PATCH 7/8] update net --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1ca8695..0d246c5 100644 --- a/go.mod +++ b/go.mod @@ -49,7 +49,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.5.4 // indirect github.com/yuin/goldmark-emoji v1.0.2 // indirect - golang.org/x/net v0.26.0 // indirect + golang.org/x/net v0.38.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.30.0 // indirect diff --git a/go.sum b/go.sum index 83a54d5..c1fd4ad 100644 --- a/go.sum +++ b/go.sum @@ -353,8 +353,8 @@ golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= From 704db6bb471a76b0a59f420dd86f9bcf918441e0 Mon Sep 17 00:00:00 2001 From: Scott Nichols Date: Sat, 24 Jan 2026 10:18:39 -0800 Subject: [PATCH 8/8] fix lint --- pkg/tui/app/router.go | 6 ------ pkg/tui/clock/clock.go | 2 ++ pkg/tui/components/collectionnav/calendar.go | 5 +++-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/pkg/tui/app/router.go b/pkg/tui/app/router.go index 3a657e4..0052f6f 100644 --- a/pkg/tui/app/router.go +++ b/pkg/tui/app/router.go @@ -12,12 +12,6 @@ const ( pageKindJournal pageKind = iota ) -type pageModel interface { - tea.Model - SetSize(width, height int) - View() (string, *tea.Cursor) -} - // pageRouter tracks the active main view and routes updates to it. type pageRouter struct { active pageKind diff --git a/pkg/tui/clock/clock.go b/pkg/tui/clock/clock.go index 1ce2a65..78e4e0a 100644 --- a/pkg/tui/clock/clock.go +++ b/pkg/tui/clock/clock.go @@ -10,6 +10,7 @@ type Clock interface { // RealClock uses the system clock. type RealClock struct{} +// Now returns the current system time. func (RealClock) Now() time.Time { return time.Now() } // FixedClock returns a stable time for deterministic tests. @@ -17,4 +18,5 @@ type FixedClock struct { Fixed time.Time } +// Now returns the fixed time. func (c FixedClock) Now() time.Time { return c.Fixed } diff --git a/pkg/tui/components/collectionnav/calendar.go b/pkg/tui/components/collectionnav/calendar.go index 95c4e6f..6110dde 100644 --- a/pkg/tui/components/collectionnav/calendar.go +++ b/pkg/tui/components/collectionnav/calendar.go @@ -119,8 +119,9 @@ func (m *Model) calendarChildren(col *viewmodel.ParsedCollection) []index.Collec } func (m *Model) handleCalendarMovement(msg tea.KeyMsg) (bool, tea.Cmd) { - if !(key.Matches(msg, m.keys.MoveLeft) || key.Matches(msg, m.keys.MoveRight) || - key.Matches(msg, m.keys.MoveUp) || key.Matches(msg, m.keys.MoveDown)) { + isMove := key.Matches(msg, m.keys.MoveLeft) || key.Matches(msg, m.keys.MoveRight) || + key.Matches(msg, m.keys.MoveUp) || key.Matches(msg, m.keys.MoveDown) + if !isMove { return false, nil } item, ok := m.selectedNavItem()