Skip to main content

cui/
state.rs

1//! Application state and the command reducer. The reducer is pure
2//! state manipulation (it may read the vault and run index
3//! queries) and never touches the terminal, so every transition is
4//! testable headless.
5
6use std::collections::HashMap;
7
8use metatheca::Uuid;
9use ratatui::widgets::ListState;
10
11use crate::cmdline::{CmdLine, ExCommand};
12use crate::highlight::{self, Rendered};
13use crate::keymap::Command;
14use crate::query::{self, FindSpec, SearchEngine};
15use crate::snapshot::{JotRow, Snapshot};
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum Mode {
19    Normal,
20    Command,
21    Help,
22}
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum Focus {
26    List,
27    View,
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum Level {
32    Info,
33    Warn,
34    Error,
35}
36
37/// What the left list is currently showing.
38pub enum ListSource {
39    All,
40    Find {
41        spec: String,
42        entries: Vec<Uuid>,
43    },
44    Search {
45        query: String,
46        hits: Vec<(Uuid, f64)>,
47        engine: SearchEngine,
48        /// The result cap this search ran with (`m` doubles it).
49        k: usize,
50    },
51    Backlinks {
52        of_ref: String,
53        entries: Vec<Uuid>,
54    },
55}
56
57impl ListSource {
58    pub fn is_all(&self) -> bool {
59        matches!(self, ListSource::All)
60    }
61
62    /// The list panel's block title.
63    pub fn title(&self, count: usize, scope: Option<&str>) -> String {
64        match self {
65            ListSource::All => {
66                format!(" jots — {} ({count}) ", scope.unwrap_or("all"))
67            }
68            ListSource::Find { spec, .. } => format!(" find: {spec} ({count}) "),
69            ListSource::Search { query, engine, .. } => match engine {
70                SearchEngine::Hybrid => format!(" search: {query} ({count}) "),
71                SearchEngine::Bm25 => format!(" search (bm25): {query} ({count}) "),
72            },
73            ListSource::Backlinks { of_ref, .. } => {
74                format!(" backlinks: {of_ref} ({count}) ")
75            }
76        }
77    }
78
79    /// Score column for search results.
80    pub fn score_of(&self, entry: Uuid) -> Option<f64> {
81        match self {
82            ListSource::Search { hits, .. } => {
83                hits.iter().find(|(e, _)| *e == entry).map(|(_, s)| *s)
84            }
85            _ => None,
86        }
87    }
88}
89
90pub struct AppState {
91    pub app: cuj::App,
92    pub snapshot: Snapshot,
93    /// Profile scope; None = all profiles.
94    pub profile: Option<String>,
95    pub source: ListSource,
96    /// Indices into snapshot.rows: current source ∩ profile scope.
97    pub visible: Vec<usize>,
98    pub list: ListState,
99    pub focus: Focus,
100    pub mode: Mode,
101    pub view_scroll: u16,
102    /// Pre-wrap line count of the selected jot's content.
103    pub content_lines: usize,
104    pub content_cache: HashMap<Uuid, Rendered>,
105    pub cmdline: CmdLine,
106    /// Transclusion in the content view (`x` toggles): expanded
107    /// content comes from cuj's show --expand path.
108    pub expand: bool,
109    pub message: Option<(Level, String)>,
110    /// The stale-index warning fires once per snapshot.
111    pub stale_warned: bool,
112    pub quit: bool,
113    /// Viewport heights, updated by the draw pass.
114    pub list_height: u16,
115    pub view_height: u16,
116}
117
118impl AppState {
119    pub fn new(app: cuj::App) -> crate::Result<AppState> {
120        let snapshot = Snapshot::build(&app)?;
121        let active = app.client.active_profile.clone();
122        let profile = snapshot
123            .profiles
124            .iter()
125            .any(|(name, _)| *name == active)
126            .then_some(active);
127        let mut state = AppState {
128            app,
129            snapshot,
130            profile,
131            source: ListSource::All,
132            visible: Vec::new(),
133            list: ListState::default(),
134            focus: Focus::List,
135            mode: Mode::Normal,
136            view_scroll: 0,
137            content_lines: 0,
138            content_cache: HashMap::new(),
139            cmdline: CmdLine::default(),
140            expand: false,
141            message: None,
142            stale_warned: false,
143            quit: false,
144            list_height: 0,
145            view_height: 0,
146        };
147        state.recompute_visible();
148        Ok(state)
149    }
150
151    pub fn scope(&self) -> Option<&str> {
152        self.profile.as_deref()
153    }
154
155    /// Rebuild `visible` from source ∩ scope and clamp selection.
156    pub fn recompute_visible(&mut self) {
157        let scope = self.profile.clone();
158        let scope = scope.as_deref();
159        let snap = &self.snapshot;
160        let in_scope = |i: &usize| scope.is_none_or(|p| snap.rows[*i].profile == p);
161        self.visible = match &self.source {
162            ListSource::All => (0..snap.rows.len()).filter(in_scope).collect(),
163            ListSource::Find { entries, .. } | ListSource::Backlinks { entries, .. } => entries
164                .iter()
165                .filter_map(|e| snap.by_entry.get(e).copied())
166                .filter(in_scope)
167                .collect(),
168            ListSource::Search { hits, .. } => hits
169                .iter()
170                .filter_map(|(e, _)| snap.by_entry.get(e).copied())
171                .filter(in_scope)
172                .collect(),
173        };
174        match (self.list.selected(), self.visible.len()) {
175            (_, 0) => self.list.select(None),
176            (None, _) => self.select_index(0),
177            (Some(s), n) if s >= n => self.select_index(n - 1),
178            _ => {}
179        }
180    }
181
182    pub fn selected_row(&self) -> Option<&JotRow> {
183        let i = self.list.selected()?;
184        self.visible.get(i).map(|&r| &self.snapshot.rows[r])
185    }
186
187    /// Select by position in `visible`, resetting the view scroll.
188    pub fn select_index(&mut self, i: usize) {
189        if self.visible.is_empty() {
190            self.list.select(None);
191            return;
192        }
193        let i = i.min(self.visible.len() - 1);
194        if self.list.selected() != Some(i) {
195            self.view_scroll = 0;
196        }
197        self.list.select(Some(i));
198    }
199
200    /// Select the row holding `entry`, if visible.
201    pub fn select_entry(&mut self, entry: Uuid) -> bool {
202        let pos = self
203            .visible
204            .iter()
205            .position(|&r| self.snapshot.rows[r].entry == entry);
206        match pos {
207            Some(i) => {
208                self.select_index(i);
209                true
210            }
211            None => false,
212        }
213    }
214
215    pub fn set_message(&mut self, level: Level, text: impl Into<String>) {
216        self.message = Some((level, text.into()));
217    }
218
219    /// Load the selected jot's content into the cache (head-only,
220    /// so `Vault::get_bytes` suffices — no transient Reads needed),
221    /// highlighted under its profile's parser configuration, and
222    /// clamp the view scroll. Called once per loop iteration,
223    /// before drawing.
224    pub fn ensure_content(&mut self) {
225        let Some((entry, profile, id)) = self
226            .selected_row()
227            .map(|r| (r.entry, r.profile.clone(), r.id))
228        else {
229            self.content_lines = 0;
230            self.view_scroll = 0;
231            return;
232        };
233        if !self.content_cache.contains_key(&entry) {
234            let raw = || match self.app.vault.get_bytes(&entry.hyphenated().to_string()) {
235                Ok(b) => String::from_utf8_lossy(&b).into_owned(),
236                Err(e) => format!("(content unavailable: {e})"),
237            };
238            // Transclusion goes through cuj's expanding show path;
239            // plain view reads the blob directly.
240            let text = if self.expand {
241                let r = cuj::RefArg {
242                    profile: Some(profile.clone()),
243                    id,
244                    suffix: None,
245                };
246                match cuj::queries::show(&self.app, &cuj::Opts::default(), &r, true) {
247                    Ok(cuj::queries::ShowOutcome::Shown { content, .. }) => content,
248                    _ => raw(),
249                }
250            } else {
251                raw()
252            };
253            let config = self
254                .snapshot
255                .configs
256                .get(&profile)
257                .cloned()
258                .unwrap_or_else(|| cuj::facts::ProfileConfig::new(&profile));
259            self.content_cache
260                .insert(entry, highlight::render(&text, &config));
261        }
262        self.content_lines = self.content_cache[&entry].lines.len();
263        // Approximate clamp: scroll offsets are pre-wrap lines.
264        let max = self
265            .content_lines
266            .saturating_sub(self.view_height.max(1) as usize)
267            .min(u16::MAX as usize) as u16;
268        if self.view_scroll > max {
269            self.view_scroll = max;
270        }
271    }
272
273    pub fn content_of(&self, entry: Uuid) -> Option<&Rendered> {
274        self.content_cache.get(&entry)
275    }
276}
277
278// ── the reducer ────────────────────────────────────────────────────
279
280pub fn apply(state: &mut AppState, cmd: Command) {
281    // Any new command clears the previous message.
282    state.message = None;
283    match cmd {
284        Command::Quit => state.quit = true,
285        Command::Help => state.mode = Mode::Help,
286        Command::Reload => reload(state),
287        Command::CycleFocus => {
288            state.focus = match state.focus {
289                Focus::List => Focus::View,
290                Focus::View => Focus::List,
291            };
292        }
293        Command::Open => {
294            if state.list.selected().is_some() {
295                state.focus = Focus::View;
296            }
297        }
298        Command::Back => back(state),
299        Command::MoveDown => move_by(state, 1),
300        Command::MoveUp => move_by(state, -1),
301        Command::MoveTop => move_top(state),
302        Command::MoveBottom => move_bottom(state),
303        Command::HalfPageDown => move_by(state, half_page(state)),
304        Command::HalfPageUp => move_by(state, -half_page(state)),
305        Command::NextProfile => next_profile(state),
306        Command::StartCommand(prefill) => {
307            state.mode = Mode::Command;
308            state.cmdline.start(prefill);
309        }
310        Command::Backlinks => backlinks_selected(state),
311        Command::MoreResults => more_results(state),
312        Command::ToggleExpand => {
313            state.expand = !state.expand;
314            state.content_cache.clear();
315            state.set_message(
316                Level::Info,
317                if state.expand {
318                    "transclusion on"
319                } else {
320                    "transclusion off"
321                },
322            );
323        }
324        // Edit needs the terminal (suspend, $EDITOR, resume), so
325        // the run loop intercepts it before the reducer; reaching
326        // here means a context without a terminal.
327        Command::Edit => state.set_message(Level::Error, "edit needs a terminal"),
328        Command::Ex(ex) => apply_ex(state, ex),
329    }
330}
331
332fn more_results(state: &mut AppState) {
333    match &state.source {
334        ListSource::Search { query, k, .. } => {
335            let (query, k) = (query.clone(), k.saturating_mul(2));
336            run_search_k(state, query, k);
337        }
338        _ => state.set_message(Level::Info, "more applies to search results"),
339    }
340}
341
342/// The selected jot as an edit target: an explicit-profile RefArg
343/// plus its display label.
344pub fn edit_target(state: &AppState) -> Option<(cuj::RefArg, String)> {
345    let row = state.selected_row()?;
346    let r = cuj::RefArg {
347        profile: Some(row.profile.clone()),
348        id: row.id,
349        suffix: None,
350    };
351    Some((r, format!("--{}--{}", row.profile, row.id)))
352}
353
354fn apply_ex(state: &mut AppState, ex: ExCommand) {
355    match ex {
356        ExCommand::Quit => state.quit = true,
357        ExCommand::Help => state.mode = Mode::Help,
358        ExCommand::Reload => reload(state),
359        ExCommand::Profile(name) => set_profile(state, name),
360        ExCommand::Find(spec) => run_find(state, spec),
361        ExCommand::Search(query) => run_search(state, query),
362        ExCommand::Goto(r) => goto(state, &r),
363        ExCommand::Backlinks(Some(r)) => match resolve_ref(state, &r) {
364            Ok(i) => {
365                let row = &state.snapshot.rows[i];
366                let (entry, label) = (row.entry, row.short_ref(state.scope()));
367                run_backlinks(state, entry, label);
368            }
369            Err(msg) => state.set_message(Level::Error, msg),
370        },
371        ExCommand::Backlinks(None) => backlinks_selected(state),
372    }
373}
374
375// ── movement ───────────────────────────────────────────────────────
376
377fn half_page(state: &AppState) -> i64 {
378    let h = match state.focus {
379        Focus::List => state.list_height,
380        Focus::View => state.view_height,
381    };
382    (h / 2).max(1) as i64
383}
384
385fn move_by(state: &mut AppState, delta: i64) {
386    match state.focus {
387        Focus::List => {
388            let Some(sel) = state.list.selected() else {
389                return;
390            };
391            let n = state.visible.len() as i64;
392            let next = (sel as i64 + delta).clamp(0, (n - 1).max(0));
393            state.select_index(next as usize);
394        }
395        Focus::View => {
396            let next = (state.view_scroll as i64 + delta).max(0);
397            // Upper clamp happens in ensure_content, which knows
398            // the content length.
399            state.view_scroll = next.min(u16::MAX as i64) as u16;
400        }
401    }
402}
403
404fn move_top(state: &mut AppState) {
405    match state.focus {
406        Focus::List => state.select_index(0),
407        Focus::View => state.view_scroll = 0,
408    }
409}
410
411fn move_bottom(state: &mut AppState) {
412    match state.focus {
413        Focus::List => {
414            if !state.visible.is_empty() {
415                state.select_index(state.visible.len() - 1);
416            }
417        }
418        // Clamped down to the real maximum by ensure_content.
419        Focus::View => state.view_scroll = u16::MAX,
420    }
421}
422
423/// Staged retreat: View → List, then results → all jots.
424fn back(state: &mut AppState) {
425    if state.focus == Focus::View {
426        state.focus = Focus::List;
427        return;
428    }
429    if !state.source.is_all() {
430        let keep = state.selected_row().map(|r| r.entry);
431        state.source = ListSource::All;
432        state.recompute_visible();
433        if let Some(e) = keep {
434            state.select_entry(e);
435        }
436    }
437}
438
439// ── profile scope ──────────────────────────────────────────────────
440
441fn next_profile(state: &mut AppState) {
442    let mut options: Vec<Option<String>> = state
443        .snapshot
444        .profiles
445        .iter()
446        .map(|(name, _)| Some(name.clone()))
447        .collect();
448    options.push(None);
449    let cur = options
450        .iter()
451        .position(|o| *o == state.profile)
452        .unwrap_or(options.len() - 1);
453    let next = options[(cur + 1) % options.len()].clone();
454    set_scope(state, next);
455}
456
457fn set_profile(state: &mut AppState, name: String) {
458    if name == "all" {
459        set_scope(state, None);
460    } else if state.snapshot.profiles.iter().any(|(n, _)| *n == name) {
461        set_scope(state, Some(name));
462    } else {
463        state.set_message(Level::Error, format!("not found: profile {name:?}"));
464    }
465}
466
467fn set_scope(state: &mut AppState, scope: Option<String>) {
468    state.profile = scope;
469    state.recompute_visible();
470    state.select_index(0);
471    let label = match state.scope() {
472        Some(p) => format!("profile: {p} ({} jots)", state.visible.len()),
473        None => format!("profile: all ({} jots)", state.visible.len()),
474    };
475    state.set_message(Level::Info, label);
476}
477
478// ── snapshot reload ────────────────────────────────────────────────
479
480fn reload(state: &mut AppState) {
481    let keep = state.selected_row().map(|r| r.entry);
482    match Snapshot::build(&state.app) {
483        Ok(s) => {
484            state.snapshot = s;
485            state.content_cache.clear();
486            state.stale_warned = false;
487            if let Some(p) = state.profile.clone() {
488                if !state.snapshot.profiles.iter().any(|(n, _)| *n == p) {
489                    state.profile = None;
490                }
491            }
492            state.recompute_visible();
493            if let Some(e) = keep {
494                state.select_entry(e);
495            }
496            state.set_message(Level::Info, "reloaded");
497        }
498        Err(e) => state.set_message(Level::Error, e.to_string()),
499    }
500}
501
502// ── queries ────────────────────────────────────────────────────────
503
504fn run_find(state: &mut AppState, spec: FindSpec) {
505    match query::find(&state.app, &spec) {
506        Ok((entries, stale)) => {
507            state.source = ListSource::Find {
508                spec: spec.raw.clone(),
509                entries,
510            };
511            finish_query(state, stale, None);
512        }
513        Err(e) => state.set_message(Level::Error, e.to_string()),
514    }
515}
516
517fn run_search(state: &mut AppState, query_text: String) {
518    run_search_k(state, query_text, 50);
519}
520
521fn run_search_k(state: &mut AppState, query_text: String, k: usize) {
522    match query::search(&state.app, &state.snapshot, &query_text, k) {
523        Ok((hits, stale, engine)) => {
524            state.source = ListSource::Search {
525                query: query_text,
526                hits,
527                engine,
528                k,
529            };
530            let note = match engine {
531                SearchEngine::Hybrid => None,
532                SearchEngine::Bm25 => Some(" — bm25 only (no zetetes chain; run 'zetetes init')"),
533            };
534            finish_query(state, stale, note);
535        }
536        Err(e) => state.set_message(Level::Error, e.to_string()),
537    }
538}
539
540fn backlinks_selected(state: &mut AppState) {
541    let Some(row) = state.selected_row() else {
542        state.set_message(Level::Error, "no jot selected");
543        return;
544    };
545    let (entry, label) = (row.entry, row.short_ref(state.scope()));
546    run_backlinks(state, entry, label);
547}
548
549fn run_backlinks(state: &mut AppState, of: Uuid, label: String) {
550    match query::backlinks(&state.app, of) {
551        Ok((entries, stale)) => {
552            state.source = ListSource::Backlinks {
553                of_ref: label,
554                entries,
555            };
556            finish_query(state, stale, None);
557        }
558        Err(e) => state.set_message(Level::Error, e.to_string()),
559    }
560}
561
562fn finish_query(state: &mut AppState, stale: bool, note: Option<&str>) {
563    state.recompute_visible();
564    state.select_index(0);
565    state.focus = Focus::List;
566    if stale && !state.stale_warned {
567        state.stale_warned = true;
568        state.set_message(
569            Level::Warn,
570            "index stale — results may lag; run 'cuj reindex'",
571        );
572    } else {
573        let n = state.visible.len();
574        state.set_message(
575            Level::Info,
576            format!("{n} result{}{}", plural(n), note.unwrap_or("")),
577        );
578    }
579}
580
581fn plural(n: usize) -> &'static str {
582    if n == 1 { "" } else { "s" }
583}
584
585// ── goto ───────────────────────────────────────────────────────────
586
587/// Resolve a `--ref` against the snapshot: explicit profile, else
588/// the scoped profile (bare `--N` is ambiguous under `all`, as in
589/// the CLI).
590fn resolve_ref(state: &AppState, r: &cuj::RefArg) -> Result<usize, String> {
591    let profile = match r.profile.clone().or_else(|| state.profile.clone()) {
592        Some(p) => p,
593        None => {
594            return Err("bare --N is ambiguous under profile all; use --profile--N".into());
595        }
596    };
597    state
598        .snapshot
599        .by_ident
600        .get(&(profile.clone(), r.id))
601        .copied()
602        .ok_or_else(|| format!("not found: --{profile}--{}", r.id))
603}
604
605fn goto(state: &mut AppState, r: &cuj::RefArg) {
606    let row_idx = match resolve_ref(state, r) {
607        Ok(i) => i,
608        Err(msg) => {
609            state.set_message(Level::Error, msg);
610            return;
611        }
612    };
613    let (entry, profile) = {
614        let row = &state.snapshot.rows[row_idx];
615        (row.entry, row.profile.clone())
616    };
617    // Widen scope if the target lives in another profile.
618    if state.scope().is_some_and(|p| p != profile) {
619        state.profile = Some(profile);
620        state.recompute_visible();
621    }
622    if !state.select_entry(entry) {
623        // Not in the current result list: fall back to all jots.
624        state.source = ListSource::All;
625        state.recompute_visible();
626        state.select_entry(entry);
627    }
628    state.focus = Focus::View;
629    // A fragment or bookmark suffix scrolls the view to its line.
630    if let Some(suffix) = r.suffix.clone() {
631        scroll_to_suffix(state, entry, &suffix);
632    }
633}
634
635/// The pre-wrap line a reference suffix points at; ensure_content
636/// clamps against the real content length afterwards.
637fn scroll_to_suffix(state: &mut AppState, entry: Uuid, suffix: &cuj::app::Suffix) {
638    let text = match state.app.vault.get_bytes(&entry.hyphenated().to_string()) {
639        Ok(b) => String::from_utf8_lossy(&b).into_owned(),
640        Err(_) => return,
641    };
642    let line = match suffix {
643        cuj::app::Suffix::Lines(a, _) => a.saturating_sub(1),
644        cuj::app::Suffix::Bookmark(name) => {
645            let anchor = format!("!!{name}");
646            match text
647                .lines()
648                .position(|l| l.split_whitespace().any(|w| w == anchor))
649            {
650                Some(i) => i,
651                None => {
652                    state.set_message(Level::Warn, format!("bookmark #{name} not found"));
653                    return;
654                }
655            }
656        }
657    };
658    state.view_scroll = line.min(u16::MAX as usize) as u16;
659}