use std::collections::HashMap;
use metatheca::Uuid;
use ratatui::widgets::ListState;
use crate::cmdline::{CmdLine, ExCommand};
use crate::highlight::{self, Rendered};
use crate::keymap::Command;
use crate::query::{self, FindSpec, SearchEngine};
use crate::snapshot::{JotRow, Snapshot};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
Normal,
Command,
Help,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Focus {
List,
View,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Level {
Info,
Warn,
Error,
}
pub enum ListSource {
All,
Find {
spec: String,
entries: Vec<Uuid>,
},
Search {
query: String,
hits: Vec<(Uuid, f64)>,
engine: SearchEngine,
k: usize,
},
Backlinks {
of_ref: String,
entries: Vec<Uuid>,
},
}
impl ListSource {
pub fn is_all(&self) -> bool {
matches!(self, ListSource::All)
}
pub fn title(&self, count: usize, scope: Option<&str>) -> String {
match self {
ListSource::All => {
format!(" jots — {} ({count}) ", scope.unwrap_or("all"))
}
ListSource::Find { spec, .. } => format!(" find: {spec} ({count}) "),
ListSource::Search { query, engine, .. } => match engine {
SearchEngine::Hybrid => format!(" search: {query} ({count}) "),
SearchEngine::Bm25 => format!(" search (bm25): {query} ({count}) "),
},
ListSource::Backlinks { of_ref, .. } => {
format!(" backlinks: {of_ref} ({count}) ")
}
}
}
pub fn score_of(&self, entry: Uuid) -> Option<f64> {
match self {
ListSource::Search { hits, .. } => {
hits.iter().find(|(e, _)| *e == entry).map(|(_, s)| *s)
}
_ => None,
}
}
}
pub struct AppState {
pub app: cuj::App,
pub snapshot: Snapshot,
pub profile: Option<String>,
pub source: ListSource,
pub visible: Vec<usize>,
pub list: ListState,
pub focus: Focus,
pub mode: Mode,
pub view_scroll: u16,
pub content_lines: usize,
pub content_cache: HashMap<Uuid, Rendered>,
pub cmdline: CmdLine,
pub expand: bool,
pub message: Option<(Level, String)>,
pub stale_warned: bool,
pub quit: bool,
pub list_height: u16,
pub view_height: u16,
}
impl AppState {
pub fn new(app: cuj::App) -> crate::Result<AppState> {
let snapshot = Snapshot::build(&app)?;
let active = app.client.active_profile.clone();
let profile = snapshot
.profiles
.iter()
.any(|(name, _)| *name == active)
.then_some(active);
let mut state = AppState {
app,
snapshot,
profile,
source: ListSource::All,
visible: Vec::new(),
list: ListState::default(),
focus: Focus::List,
mode: Mode::Normal,
view_scroll: 0,
content_lines: 0,
content_cache: HashMap::new(),
cmdline: CmdLine::default(),
expand: false,
message: None,
stale_warned: false,
quit: false,
list_height: 0,
view_height: 0,
};
state.recompute_visible();
Ok(state)
}
pub fn scope(&self) -> Option<&str> {
self.profile.as_deref()
}
pub fn recompute_visible(&mut self) {
let scope = self.profile.clone();
let scope = scope.as_deref();
let snap = &self.snapshot;
let in_scope = |i: &usize| scope.is_none_or(|p| snap.rows[*i].profile == p);
self.visible = match &self.source {
ListSource::All => (0..snap.rows.len()).filter(in_scope).collect(),
ListSource::Find { entries, .. } | ListSource::Backlinks { entries, .. } => entries
.iter()
.filter_map(|e| snap.by_entry.get(e).copied())
.filter(in_scope)
.collect(),
ListSource::Search { hits, .. } => hits
.iter()
.filter_map(|(e, _)| snap.by_entry.get(e).copied())
.filter(in_scope)
.collect(),
};
match (self.list.selected(), self.visible.len()) {
(_, 0) => self.list.select(None),
(None, _) => self.select_index(0),
(Some(s), n) if s >= n => self.select_index(n - 1),
_ => {}
}
}
pub fn selected_row(&self) -> Option<&JotRow> {
let i = self.list.selected()?;
self.visible.get(i).map(|&r| &self.snapshot.rows[r])
}
pub fn select_index(&mut self, i: usize) {
if self.visible.is_empty() {
self.list.select(None);
return;
}
let i = i.min(self.visible.len() - 1);
if self.list.selected() != Some(i) {
self.view_scroll = 0;
}
self.list.select(Some(i));
}
pub fn select_entry(&mut self, entry: Uuid) -> bool {
let pos = self
.visible
.iter()
.position(|&r| self.snapshot.rows[r].entry == entry);
match pos {
Some(i) => {
self.select_index(i);
true
}
None => false,
}
}
pub fn set_message(&mut self, level: Level, text: impl Into<String>) {
self.message = Some((level, text.into()));
}
pub fn ensure_content(&mut self) {
let Some((entry, profile, id)) = self
.selected_row()
.map(|r| (r.entry, r.profile.clone(), r.id))
else {
self.content_lines = 0;
self.view_scroll = 0;
return;
};
if !self.content_cache.contains_key(&entry) {
let raw = || match self.app.vault.get_bytes(&entry.hyphenated().to_string()) {
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
Err(e) => format!("(content unavailable: {e})"),
};
let text = if self.expand {
let r = cuj::RefArg {
profile: Some(profile.clone()),
id,
suffix: None,
};
match cuj::queries::show(&self.app, &cuj::Opts::default(), &r, true) {
Ok(cuj::queries::ShowOutcome::Shown { content, .. }) => content,
_ => raw(),
}
} else {
raw()
};
let config = self
.snapshot
.configs
.get(&profile)
.cloned()
.unwrap_or_else(|| cuj::facts::ProfileConfig::new(&profile));
self.content_cache
.insert(entry, highlight::render(&text, &config));
}
self.content_lines = self.content_cache[&entry].lines.len();
let max = self
.content_lines
.saturating_sub(self.view_height.max(1) as usize)
.min(u16::MAX as usize) as u16;
if self.view_scroll > max {
self.view_scroll = max;
}
}
pub fn content_of(&self, entry: Uuid) -> Option<&Rendered> {
self.content_cache.get(&entry)
}
}
pub fn apply(state: &mut AppState, cmd: Command) {
state.message = None;
match cmd {
Command::Quit => state.quit = true,
Command::Help => state.mode = Mode::Help,
Command::Reload => reload(state),
Command::CycleFocus => {
state.focus = match state.focus {
Focus::List => Focus::View,
Focus::View => Focus::List,
};
}
Command::Open => {
if state.list.selected().is_some() {
state.focus = Focus::View;
}
}
Command::Back => back(state),
Command::MoveDown => move_by(state, 1),
Command::MoveUp => move_by(state, -1),
Command::MoveTop => move_top(state),
Command::MoveBottom => move_bottom(state),
Command::HalfPageDown => move_by(state, half_page(state)),
Command::HalfPageUp => move_by(state, -half_page(state)),
Command::NextProfile => next_profile(state),
Command::StartCommand(prefill) => {
state.mode = Mode::Command;
state.cmdline.start(prefill);
}
Command::Backlinks => backlinks_selected(state),
Command::MoreResults => more_results(state),
Command::ToggleExpand => {
state.expand = !state.expand;
state.content_cache.clear();
state.set_message(
Level::Info,
if state.expand {
"transclusion on"
} else {
"transclusion off"
},
);
}
Command::Edit => state.set_message(Level::Error, "edit needs a terminal"),
Command::Ex(ex) => apply_ex(state, ex),
}
}
fn more_results(state: &mut AppState) {
match &state.source {
ListSource::Search { query, k, .. } => {
let (query, k) = (query.clone(), k.saturating_mul(2));
run_search_k(state, query, k);
}
_ => state.set_message(Level::Info, "more applies to search results"),
}
}
pub fn edit_target(state: &AppState) -> Option<(cuj::RefArg, String)> {
let row = state.selected_row()?;
let r = cuj::RefArg {
profile: Some(row.profile.clone()),
id: row.id,
suffix: None,
};
Some((r, format!("--{}--{}", row.profile, row.id)))
}
fn apply_ex(state: &mut AppState, ex: ExCommand) {
match ex {
ExCommand::Quit => state.quit = true,
ExCommand::Help => state.mode = Mode::Help,
ExCommand::Reload => reload(state),
ExCommand::Profile(name) => set_profile(state, name),
ExCommand::Find(spec) => run_find(state, spec),
ExCommand::Search(query) => run_search(state, query),
ExCommand::Goto(r) => goto(state, &r),
ExCommand::Backlinks(Some(r)) => match resolve_ref(state, &r) {
Ok(i) => {
let row = &state.snapshot.rows[i];
let (entry, label) = (row.entry, row.short_ref(state.scope()));
run_backlinks(state, entry, label);
}
Err(msg) => state.set_message(Level::Error, msg),
},
ExCommand::Backlinks(None) => backlinks_selected(state),
}
}
fn half_page(state: &AppState) -> i64 {
let h = match state.focus {
Focus::List => state.list_height,
Focus::View => state.view_height,
};
(h / 2).max(1) as i64
}
fn move_by(state: &mut AppState, delta: i64) {
match state.focus {
Focus::List => {
let Some(sel) = state.list.selected() else {
return;
};
let n = state.visible.len() as i64;
let next = (sel as i64 + delta).clamp(0, (n - 1).max(0));
state.select_index(next as usize);
}
Focus::View => {
let next = (state.view_scroll as i64 + delta).max(0);
state.view_scroll = next.min(u16::MAX as i64) as u16;
}
}
}
fn move_top(state: &mut AppState) {
match state.focus {
Focus::List => state.select_index(0),
Focus::View => state.view_scroll = 0,
}
}
fn move_bottom(state: &mut AppState) {
match state.focus {
Focus::List => {
if !state.visible.is_empty() {
state.select_index(state.visible.len() - 1);
}
}
Focus::View => state.view_scroll = u16::MAX,
}
}
fn back(state: &mut AppState) {
if state.focus == Focus::View {
state.focus = Focus::List;
return;
}
if !state.source.is_all() {
let keep = state.selected_row().map(|r| r.entry);
state.source = ListSource::All;
state.recompute_visible();
if let Some(e) = keep {
state.select_entry(e);
}
}
}
fn next_profile(state: &mut AppState) {
let mut options: Vec<Option<String>> = state
.snapshot
.profiles
.iter()
.map(|(name, _)| Some(name.clone()))
.collect();
options.push(None);
let cur = options
.iter()
.position(|o| *o == state.profile)
.unwrap_or(options.len() - 1);
let next = options[(cur + 1) % options.len()].clone();
set_scope(state, next);
}
fn set_profile(state: &mut AppState, name: String) {
if name == "all" {
set_scope(state, None);
} else if state.snapshot.profiles.iter().any(|(n, _)| *n == name) {
set_scope(state, Some(name));
} else {
state.set_message(Level::Error, format!("not found: profile {name:?}"));
}
}
fn set_scope(state: &mut AppState, scope: Option<String>) {
state.profile = scope;
state.recompute_visible();
state.select_index(0);
let label = match state.scope() {
Some(p) => format!("profile: {p} ({} jots)", state.visible.len()),
None => format!("profile: all ({} jots)", state.visible.len()),
};
state.set_message(Level::Info, label);
}
fn reload(state: &mut AppState) {
let keep = state.selected_row().map(|r| r.entry);
match Snapshot::build(&state.app) {
Ok(s) => {
state.snapshot = s;
state.content_cache.clear();
state.stale_warned = false;
if let Some(p) = state.profile.clone() {
if !state.snapshot.profiles.iter().any(|(n, _)| *n == p) {
state.profile = None;
}
}
state.recompute_visible();
if let Some(e) = keep {
state.select_entry(e);
}
state.set_message(Level::Info, "reloaded");
}
Err(e) => state.set_message(Level::Error, e.to_string()),
}
}
fn run_find(state: &mut AppState, spec: FindSpec) {
match query::find(&state.app, &spec) {
Ok((entries, stale)) => {
state.source = ListSource::Find {
spec: spec.raw.clone(),
entries,
};
finish_query(state, stale, None);
}
Err(e) => state.set_message(Level::Error, e.to_string()),
}
}
fn run_search(state: &mut AppState, query_text: String) {
run_search_k(state, query_text, 50);
}
fn run_search_k(state: &mut AppState, query_text: String, k: usize) {
match query::search(&state.app, &state.snapshot, &query_text, k) {
Ok((hits, stale, engine)) => {
state.source = ListSource::Search {
query: query_text,
hits,
engine,
k,
};
let note = match engine {
SearchEngine::Hybrid => None,
SearchEngine::Bm25 => Some(" — bm25 only (no zetetes chain; run 'zetetes init')"),
};
finish_query(state, stale, note);
}
Err(e) => state.set_message(Level::Error, e.to_string()),
}
}
fn backlinks_selected(state: &mut AppState) {
let Some(row) = state.selected_row() else {
state.set_message(Level::Error, "no jot selected");
return;
};
let (entry, label) = (row.entry, row.short_ref(state.scope()));
run_backlinks(state, entry, label);
}
fn run_backlinks(state: &mut AppState, of: Uuid, label: String) {
match query::backlinks(&state.app, of) {
Ok((entries, stale)) => {
state.source = ListSource::Backlinks {
of_ref: label,
entries,
};
finish_query(state, stale, None);
}
Err(e) => state.set_message(Level::Error, e.to_string()),
}
}
fn finish_query(state: &mut AppState, stale: bool, note: Option<&str>) {
state.recompute_visible();
state.select_index(0);
state.focus = Focus::List;
if stale && !state.stale_warned {
state.stale_warned = true;
state.set_message(
Level::Warn,
"index stale — results may lag; run 'cuj reindex'",
);
} else {
let n = state.visible.len();
state.set_message(
Level::Info,
format!("{n} result{}{}", plural(n), note.unwrap_or("")),
);
}
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
fn resolve_ref(state: &AppState, r: &cuj::RefArg) -> Result<usize, String> {
let profile = match r.profile.clone().or_else(|| state.profile.clone()) {
Some(p) => p,
None => {
return Err("bare --N is ambiguous under profile all; use --profile--N".into());
}
};
state
.snapshot
.by_ident
.get(&(profile.clone(), r.id))
.copied()
.ok_or_else(|| format!("not found: --{profile}--{}", r.id))
}
fn goto(state: &mut AppState, r: &cuj::RefArg) {
let row_idx = match resolve_ref(state, r) {
Ok(i) => i,
Err(msg) => {
state.set_message(Level::Error, msg);
return;
}
};
let (entry, profile) = {
let row = &state.snapshot.rows[row_idx];
(row.entry, row.profile.clone())
};
if state.scope().is_some_and(|p| p != profile) {
state.profile = Some(profile);
state.recompute_visible();
}
if !state.select_entry(entry) {
state.source = ListSource::All;
state.recompute_visible();
state.select_entry(entry);
}
state.focus = Focus::View;
if let Some(suffix) = r.suffix.clone() {
scroll_to_suffix(state, entry, &suffix);
}
}
fn scroll_to_suffix(state: &mut AppState, entry: Uuid, suffix: &cuj::app::Suffix) {
let text = match state.app.vault.get_bytes(&entry.hyphenated().to_string()) {
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
Err(_) => return,
};
let line = match suffix {
cuj::app::Suffix::Lines(a, _) => a.saturating_sub(1),
cuj::app::Suffix::Bookmark(name) => {
let anchor = format!("!!{name}");
match text
.lines()
.position(|l| l.split_whitespace().any(|w| w == anchor))
{
Some(i) => i,
None => {
state.set_message(Level::Warn, format!("bookmark #{name} not found"));
return;
}
}
}
};
state.view_scroll = line.min(u16::MAX as usize) as u16;
}