1use 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
37pub 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 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 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 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 pub profile: Option<String>,
95 pub source: ListSource,
96 pub visible: Vec<usize>,
98 pub list: ListState,
99 pub focus: Focus,
100 pub mode: Mode,
101 pub view_scroll: u16,
102 pub content_lines: usize,
104 pub content_cache: HashMap<Uuid, Rendered>,
105 pub cmdline: CmdLine,
106 pub expand: bool,
109 pub message: Option<(Level, String)>,
110 pub stale_warned: bool,
112 pub quit: bool,
113 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 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 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 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 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 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 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
278pub fn apply(state: &mut AppState, cmd: Command) {
281 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 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
342pub 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
375fn 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 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 Focus::View => state.view_scroll = u16::MAX,
420 }
421}
422
423fn 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
439fn 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
478fn 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
502fn 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
585fn 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 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 state.source = ListSource::All;
625 state.recompute_visible();
626 state.select_entry(entry);
627 }
628 state.focus = Focus::View;
629 if let Some(suffix) = r.suffix.clone() {
631 scroll_to_suffix(state, entry, &suffix);
632 }
633}
634
635fn 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}