use std::{
collections::{HashMap, HashSet},
hash::Hash,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use ratatui::{
Frame,
crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
layout::{Constraint, Layout, Position, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::Paragraph,
};
use crate::{
config::{self, KeyPattern, PickerKeys},
deck::{self, Deck, DeckState},
parser,
recent::RecentDecks,
session,
store::{Store, default_store_path},
workspace,
};
fn key_pattern(code: KeyCode, ctrl: bool) -> Option<KeyPattern> {
let key = match code {
KeyCode::Char(c) => config::Key::Char(c),
KeyCode::Enter => config::Key::Enter,
KeyCode::Tab => config::Key::Tab,
KeyCode::Esc => config::Key::Esc,
KeyCode::Backspace => config::Key::Backspace,
_ => return None,
};
Some(KeyPattern { key, ctrl })
}
const HEADER_STYLE: Style = Style::new().fg(Color::Black).bg(Color::Cyan);
#[derive(Clone, Copy, PartialEq, Eq)]
enum Section {
None,
Workspaces,
Recent,
Folders,
}
impl Section {
fn header(self) -> Option<&'static str> {
match self {
Section::None => None,
Section::Workspaces => Some("Workspaces"),
Section::Recent => Some("Recent"),
Section::Folders => Some("Folders"),
}
}
}
enum DisplayRow {
Blank,
Header(&'static str),
Item(usize),
Subtitle(usize),
}
#[derive(Clone)]
struct Item<K> {
key: K,
label: String,
meta: Option<String>,
subtitle: Option<String>,
locked: bool,
state: Option<DeckState>,
is_workspace: bool,
is_trace: bool,
reviewable: bool,
hint: Option<String>,
section: Section,
default_shown: bool,
tree_prefix: String,
}
struct Candidate {
path: PathBuf,
name: String,
last_used_ms: Option<u64>,
is_workspace: bool,
}
fn dir_candidates(decks_dir: &Path) -> Vec<Candidate> {
let mut cands: Vec<Candidate> = match std::fs::read_dir(decks_dir) {
Ok(read_dir) => read_dir
.filter_map(|r| r.ok().map(|d| d.path()))
.filter_map(|path| {
if path.is_file() && path.extension().is_some_and(|e| e == "txt") {
Some((path, false))
} else if workspace::has_decks(&path) {
Some((path, true))
} else {
None
}
})
.map(|(path, is_workspace)| Candidate {
name: file_name(&path),
path,
last_used_ms: None,
is_workspace,
})
.collect(),
Err(_) => Vec::new(),
};
cands.sort_by(|a, b| a.name.cmp(&b.name));
cands
}
fn build_candidates(decks_dir: &Path, recent: &RecentDecks) -> Vec<Candidate> {
let mut out = Vec::new();
let mut seen = HashSet::new();
for entry in recent.entries() {
let is_workspace = workspace::has_decks(&entry.path);
if entry.path.is_file() || is_workspace {
out.push(Candidate {
name: file_name(&entry.path),
path: entry.path.clone(),
last_used_ms: Some(entry.last_used_ms),
is_workspace,
});
seen.insert(entry.path.clone());
}
}
for candidate in dir_candidates(decks_dir) {
if !seen.contains(&candidate.path) {
out.push(candidate);
}
}
out
}
pub struct DeckStatus {
pub state: DeckState,
pub badge: String,
pub locked: bool,
pub reviewable: bool,
pub mastered: bool,
pub is_trace: bool,
pub examable: bool,
}
pub fn deck_status(
deck: &Deck,
store: &Store,
decks_dir: Option<&Path>,
enforce_locks: bool,
) -> DeckStatus {
let state = deck.state(store);
let total = deck.cards.len();
let retired = deck
.cards
.iter()
.filter(|card| session::is_retired(card, store))
.count();
let mastered = matches!(state, DeckState::Finished) && store.deck_mastered(&deck.subject);
let badge = match state {
DeckState::Finished if mastered => "mastered 🎉".to_string(),
DeckState::Finished => "done ✓".to_string(),
DeckState::ExamDue => "exam due".to_string(),
DeckState::NotStarted => "new".to_string(),
DeckState::Started => format!("{retired}/{total}"),
};
let actually_locked = deck::is_locked(deck, decks_dir, store);
let locked = enforce_locks && actually_locked;
let examable = !deck.is_trace() && !deck.sources.is_empty() && !actually_locked;
let scheduler = deck.settings.scheduler.unwrap_or_default().scheduler();
let reviewable = deck.is_trace()
|| matches!(state, DeckState::ExamDue)
|| session::has_reviewable(&deck.cards, store, scheduler.as_ref(), session::now_ms());
DeckStatus {
state,
badge,
locked,
reviewable,
mastered,
is_trace: deck.is_trace(),
examable,
}
}
fn deck_item(
c: Candidate,
store: &Store,
decks_dir: &Path,
enforce_locks: bool,
show_kind: bool,
) -> Item<PathBuf> {
let hint = location_hint(&c.path, decks_dir);
if c.is_workspace {
let (label, meta, subtitle) = match workspace::Workspace::load(&c.path) {
Ok(ws) => {
let mut parts = format!("· {} decks", ws.members.len());
if workspace::is_workspace(&c.path)
&& let Some(when) = workspace_last_progress(&c.path)
{
parts.push_str(&format!(" · {when}"));
}
(ws.display_name(), Some(parts), ws.description)
}
Err(_) => (c.name, None, None),
};
return Item {
key: c.path,
label,
meta,
subtitle,
locked: false,
state: None,
is_workspace: true,
is_trace: false,
reviewable: true, hint,
section: Section::None,
default_shown: true,
tree_prefix: String::new(),
};
}
let (label, meta, locked, state, is_trace, reviewable) = match Deck::load(&c.path) {
Ok(deck) => {
let status = deck_status(&deck, store, Some(decks_dir), enforce_locks);
let meta = if show_kind {
let kind = if status.is_trace { "trace" } else { "deck" };
format!("· {kind} · {}", status.badge)
} else {
format!("· {}", status.badge)
};
let label = deck.title.clone().unwrap_or_else(|| stem(&c.name));
(
label,
Some(meta),
status.locked,
Some(status.state),
status.is_trace,
status.reviewable,
)
}
Err(_) => (c.name, None, false, None, false, true),
};
Item {
key: c.path,
label,
meta,
subtitle: None,
locked,
state,
is_workspace: false,
is_trace,
reviewable,
hint,
section: Section::None,
default_shown: true,
tree_prefix: String::new(),
}
}
fn file_name(path: &Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}
pub fn workspace_last_progress(folder: &Path) -> Option<String> {
let ts = Store::open(workspace::store_path(folder))
.ok()?
.last_review_ms()?;
let now = crate::time::now_ms();
Some(if now > ts {
format!("{} ago", crate::time::humanize_ms(now - ts))
} else {
"just now".to_string()
})
}
fn stem(name: &str) -> String {
name.strip_suffix(".txt").unwrap_or(name).to_string()
}
fn location_hint(path: &Path, decks_dir: &Path) -> Option<String> {
let parent = path.parent()?;
if parent == decks_dir {
return None;
}
Some(abbreviate_home(parent))
}
fn disambiguate(items: &mut [Item<PathBuf>]) {
let dups: HashSet<String> = {
let mut seen: HashMap<&str, usize> = HashMap::new();
for item in items.iter() {
*seen.entry(item.label.as_str()).or_default() += 1;
}
seen.into_iter()
.filter(|(_, n)| *n > 1)
.map(|(label, _)| label.to_string())
.collect()
};
for item in items.iter_mut() {
if dups.contains(&item.label) {
item.hint = Some(abbreviate_home(&item.key));
}
}
}
fn abbreviate_home(path: &Path) -> String {
directories::BaseDirs::new()
.and_then(|dirs| {
path.strip_prefix(dirs.home_dir()).ok().map(|rest| {
if rest.as_os_str().is_empty() {
"~".to_string()
} else {
format!("~/{}", rest.display())
}
})
})
.unwrap_or_else(|| path.display().to_string())
}
pub struct DeckEntry {
pub name: String,
pub label: String,
pub path: PathBuf,
pub last_used_ms: Option<u64>,
pub is_workspace: bool,
pub description: Option<String>,
pub members: Vec<DeckEntry>,
pub path_hint: Option<String>,
}
pub fn catalog(decks_dir: &Path, recent: &RecentDecks) -> Vec<DeckEntry> {
build_candidates(decks_dir, recent)
.into_iter()
.map(|c| {
if c.is_workspace {
let (label, description, members) = match workspace::Workspace::load(&c.path) {
Ok(ws) => {
let members = ws
.members
.iter()
.map(|m| {
let file = file_name(m);
DeckEntry {
name: format!("{}/{}", c.name, file),
label: deck_title(m).unwrap_or_else(|| stem(&file)),
path: m.clone(),
last_used_ms: None,
is_workspace: false,
description: None,
members: Vec::new(),
path_hint: None, }
})
.collect();
(ws.display_name(), ws.description, members)
}
Err(_) => (c.name.clone(), None, Vec::new()),
};
DeckEntry {
path_hint: location_hint(&c.path, decks_dir),
name: c.name,
label,
path: c.path,
last_used_ms: c.last_used_ms,
is_workspace: true,
description,
members,
}
} else {
DeckEntry {
label: deck_title(&c.path).unwrap_or_else(|| stem(&c.name)),
path_hint: location_hint(&c.path, decks_dir),
name: c.name,
path: c.path,
last_used_ms: c.last_used_ms,
is_workspace: false,
description: None,
members: Vec::new(),
}
}
})
.collect()
}
fn deck_title(path: &Path) -> Option<String> {
std::fs::read_to_string(path)
.ok()
.and_then(|text| parser::parse_title(&text))
}
pub struct Picked {
pub decks: Vec<PathBuf>,
pub workspace: Option<PathBuf>,
}
#[expect(clippy::too_many_arguments)] pub fn pick(
terminal: &mut ratatui::DefaultTerminal,
decks_dir: &Path,
recent: &RecentDecks,
store: &Store,
enforce_locks: bool,
gate_reviewable: bool,
start_in: Option<&Path>,
keys: &PickerKeys,
) -> Result<Picked> {
let mut top = top_picker(
decks_dir,
recent,
store,
enforce_locks,
gate_reviewable,
keys,
);
navigate(
terminal,
&mut top,
decks_dir,
enforce_locks,
gate_reviewable,
start_in,
)
}
fn top_picker(
decks_dir: &Path,
recent: &RecentDecks,
store: &Store,
enforce_locks: bool,
gate_reviewable: bool,
keys: &PickerKeys,
) -> Picker<PathBuf> {
let (mut workspaces, mut loose, mut folders) = (Vec::new(), Vec::new(), Vec::new());
for c in build_candidates(decks_dir, recent) {
if !c.is_workspace && c.path.parent().is_some_and(workspace::is_workspace) {
continue;
}
let section = if c.is_workspace {
if workspace::is_workspace(&c.path) {
Section::Workspaces
} else {
Section::Folders
}
} else {
Section::Recent
};
let is_recent = c.last_used_ms.is_some();
let mut item = deck_item(c, store, decks_dir, enforce_locks, false);
item.section = section;
item.default_shown = section != Section::Recent
|| (is_recent && !matches!(item.state, Some(DeckState::Finished)) && !item.locked);
match section {
Section::Workspaces => workspaces.push(item),
Section::Folders => folders.push(item),
_ => loose.push(item),
}
}
disambiguate(&mut workspaces);
disambiguate(&mut loose);
disambiguate(&mut folders);
let items = workspaces
.into_iter()
.chain(loose)
.chain(folders)
.collect::<Vec<_>>();
let mut picker = Picker::new(
items,
HashSet::new(),
String::new(),
String::new(),
false,
no_decks_message(decks_dir),
);
picker.launcher = true;
picker.multi_select = false;
picker.gate_reviewable = gate_reviewable;
picker.keys = keys.clone();
picker
}
fn navigate(
terminal: &mut ratatui::DefaultTerminal,
top: &mut Picker<PathBuf>,
decks_dir: &Path,
enforce_locks: bool,
gate_reviewable: bool,
start_in: Option<&Path>,
) -> Result<Picked> {
if let Some(ws) = start_in {
let mut sub = workspace_picker(ws, decks_dir, enforce_locks, gate_reviewable, &top.keys)?;
if let Some(decks) = sub.run(terminal)? {
return Ok(Picked {
decks,
workspace: Some(ws.to_path_buf()),
});
}
}
loop {
top.rearm(); let chosen = top.run(terminal)?;
if std::mem::take(&mut top.request_mastered) {
if let Some(decks) = run_mastered_window(terminal, top)?
&& !decks.is_empty()
{
return Ok(Picked {
decks,
workspace: None,
});
}
continue; }
let Some(chosen) = chosen else {
return Ok(Picked {
decks: Vec::new(),
workspace: None,
}); };
if let [path] = chosen.as_slice()
&& workspace::has_decks(path)
{
let mut sub =
workspace_picker(path, decks_dir, enforce_locks, gate_reviewable, &top.keys)?;
match sub.run(terminal)? {
Some(decks) => {
return Ok(Picked {
decks,
workspace: Some(path.clone()),
});
}
None => continue, }
}
return Ok(Picked {
decks: chosen,
workspace: None,
});
}
}
fn run_mastered_window(
terminal: &mut ratatui::DefaultTerminal,
top: &Picker<PathBuf>,
) -> Result<Option<Vec<PathBuf>>> {
let items: Vec<Item<PathBuf>> = top
.all
.iter()
.filter(|item| item.meta.as_deref().is_some_and(|m| m.contains("mastered")))
.cloned()
.map(|mut item| {
item.default_shown = true; item.section = Section::None;
item
})
.collect();
let mut picker = Picker::new(
items,
HashSet::new(),
"mastered 🎉".to_string(),
String::new(),
false,
vec![Line::from(
" No mastered decks yet — pass an exam to earn one. 🎉",
)],
);
picker.launcher = true;
picker.multi_select = false;
picker.keys = top.keys.clone();
picker.run(terminal)
}
pub fn pick_workspace(folder: &Path, enforce_locks: bool) -> Result<Option<Vec<PathBuf>>> {
launch(workspace_picker(
folder,
folder,
enforce_locks,
enforce_locks,
&PickerKeys::default(),
)?)
}
pub fn member_parents(members: &[PathBuf], decks_dir: &Path) -> Vec<Option<usize>> {
let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
let canonical: Vec<PathBuf> = members.iter().map(|m| canon(m)).collect();
members
.iter()
.enumerate()
.map(|(i, m)| {
let deck = Deck::load(m).ok()?;
deck.requires.iter().find_map(|req| {
let dep = canon(&deck::resolve_dep(req, Some(decks_dir), m.parent())?);
canonical.iter().position(|c| *c == dep).filter(|&j| j != i)
})
})
.collect()
}
pub fn dependency_forest<K: Ord>(parent: &[Option<usize>], key: &[K]) -> Vec<(usize, String)> {
let n = parent.len();
let mut children = vec![Vec::new(); n];
let mut roots = Vec::new();
for (i, p) in parent.iter().enumerate() {
match *p {
Some(p) if p < n && p != i => children[p].push(i),
_ => roots.push(i),
}
}
roots.sort_by(|a, b| key[*a].cmp(&key[*b]));
for kids in &mut children {
kids.sort_by(|a, b| key[*a].cmp(&key[*b]));
}
let mut out = Vec::new();
let mut visited = vec![false; n];
let root_count = roots.len();
for (k, &r) in roots.iter().enumerate() {
visit_node(
r,
"",
k + 1 == root_count,
true,
&children,
&mut visited,
&mut out,
);
}
for i in 0..n {
if !visited[i] {
visit_node(i, "", true, true, &children, &mut visited, &mut out);
}
}
out
}
fn visit_node(
i: usize,
ancestor: &str,
is_last: bool,
is_root: bool,
children: &[Vec<usize>],
visited: &mut [bool],
out: &mut Vec<(usize, String)>,
) {
if visited[i] {
return;
}
visited[i] = true;
let connector = if is_root {
""
} else if is_last {
"└─ "
} else {
"├─ "
};
out.push((i, format!("{ancestor}{connector}")));
let child_ancestor = if is_root {
String::new()
} else if is_last {
format!("{ancestor} ")
} else {
format!("{ancestor}│ ")
};
let kids = &children[i];
for (k, &c) in kids.iter().enumerate() {
visit_node(
c,
&child_ancestor,
k + 1 == kids.len(),
false,
children,
visited,
out,
);
}
}
fn workspace_picker(
folder: &Path,
decks_dir: &Path,
enforce_locks: bool,
gate_reviewable: bool,
keys: &PickerKeys,
) -> Result<Picker<PathBuf>> {
let ws = workspace::Workspace::load(folder)?;
let store_path = if workspace::is_workspace(folder) {
workspace::store_path(folder)
} else {
default_store_path().context("cannot determine the data directory")?
};
let store = Store::open(&store_path)?;
let items: Vec<Item<PathBuf>> = ws
.members
.iter()
.map(|m| {
let c = Candidate {
name: file_name(m),
path: m.clone(),
last_used_ms: None,
is_workspace: false,
};
let mut item = deck_item(c, &store, decks_dir, enforce_locks, true);
item.hint = None; item
})
.collect();
let parent = member_parents(&ws.members, decks_dir);
let key: Vec<(bool, String)> = items
.iter()
.map(|item| {
let blocked = item.locked || (gate_reviewable && !item.reviewable);
(blocked, item.label.clone())
})
.collect();
let mut slots: Vec<Option<Item<PathBuf>>> = items.into_iter().map(Some).collect();
let ordered: Vec<Item<PathBuf>> = dependency_forest(&parent, &key)
.into_iter()
.map(|(i, prefix)| {
let mut item = slots[i].take().expect("each member placed once");
item.tree_prefix = prefix;
item
})
.collect();
let mut picker = Picker::new(
ordered,
HashSet::new(),
ws.display_name(),
String::new(),
false,
vec![Line::from(" This workspace has no decks.")],
);
picker.launcher = true;
picker.multi_select = false; picker.gate_reviewable = gate_reviewable;
picker.keys = keys.clone();
Ok(picker)
}
pub fn pick_to_reset(
decks_dir: &Path,
recent: &RecentDecks,
store: &Store,
) -> Result<Vec<PathBuf>> {
let items = build_candidates(decks_dir, recent)
.into_iter()
.map(|c| deck_item(c, store, decks_dir, false, false))
.collect();
let picker = Picker::new(
items,
HashSet::new(),
"select decks to reset".to_string(),
" SPACE select │ ENTER reset │ ↑↓ move │ type to filter │ ESC cancel".to_string(),
true,
no_decks_message(decks_dir),
);
Ok(launch(picker)?.unwrap_or_default())
}
pub fn pick_cards(items: Vec<(u64, String, Option<String>)>, title: &str) -> Result<Vec<u64>> {
let items = items
.into_iter()
.map(|(key, label, meta)| Item {
key,
label,
meta,
subtitle: None,
locked: false,
state: None,
is_workspace: false,
is_trace: false,
reviewable: true,
hint: None,
section: Section::None,
default_shown: true,
tree_prefix: String::new(),
})
.collect();
let picker = Picker::new(
items,
HashSet::new(),
title.to_string(),
" SPACE select │ ENTER reset │ ↑↓ move │ type to filter │ ESC cancel".to_string(),
true,
vec![Line::from(" No cards.")],
);
Ok(launch(picker)?.unwrap_or_default())
}
pub fn edit_dependencies(
decks_dir: &Path,
target: &Path,
requires: &[String],
) -> Result<Option<Vec<PathBuf>>> {
let target_name = file_name(target);
let mut candidates = dir_candidates(decks_dir);
candidates.retain(|c| !c.is_workspace && c.name != target_name);
let listed: HashSet<String> = candidates.iter().map(|c| stem(&c.name)).collect();
for req in requires {
if !listed.contains(&stem(req)) {
candidates.push(Candidate {
name: req.clone(),
path: PathBuf::from(req),
last_used_ms: None,
is_workspace: false,
});
}
}
let want: HashSet<String> = requires.iter().map(|r| stem(r)).collect();
let preselected: HashSet<PathBuf> = candidates
.iter()
.filter(|c| want.contains(&stem(&c.name)))
.map(|c| c.path.clone())
.collect();
let items = candidates
.into_iter()
.map(|c| Item {
key: c.path,
label: c.name,
meta: None,
subtitle: None,
locked: false,
state: None,
is_workspace: false,
is_trace: false,
reviewable: true,
hint: None,
section: Section::None,
default_shown: true,
tree_prefix: String::new(),
})
.collect();
let picker = Picker::new(
items,
preselected,
format!("prerequisites for {target_name}"),
" SPACE toggle │ ENTER save │ ↑↓ move │ type to filter │ ESC cancel".to_string(),
true,
vec![
Line::default(),
Line::from(format!(" No other decks in {}.", decks_dir.display())),
],
);
launch(picker)
}
fn no_decks_message(decks_dir: &Path) -> Vec<Line<'static>> {
vec![
Line::default(),
Line::from(format!(" No decks found in {}.", decks_dir.display())),
Line::default(),
Line::from(" Put .txt decks there, or pass deck files on the command line.".dim()),
]
}
fn launch<K: Clone + Eq + Hash>(mut picker: Picker<K>) -> Result<Option<Vec<K>>> {
let mut terminal = ratatui::init();
let result = picker.run(&mut terminal);
ratatui::restore();
result
}
struct Picker<K> {
all: Vec<Item<K>>,
keys: PickerKeys,
filter: String,
filtered: Vec<usize>,
cursor: usize,
offset: usize,
selected: HashSet<K>,
title: String,
footer: String,
exact: bool,
empty: Vec<Line<'static>>,
launcher: bool,
filtering: bool,
request_mastered: bool,
multi_select: bool,
gate_reviewable: bool,
confirming: bool,
done: bool,
cancelled: bool,
}
impl<K: Clone + Eq + Hash> Picker<K> {
fn new(
all: Vec<Item<K>>,
selected: HashSet<K>,
title: String,
footer: String,
exact: bool,
empty: Vec<Line<'static>>,
) -> Self {
let filtered = (0..all.len()).filter(|&i| all[i].default_shown).collect();
Self {
all,
keys: PickerKeys::default(),
filter: String::new(),
filtered,
cursor: 0,
offset: 0,
selected,
title,
footer,
exact,
empty,
launcher: false,
filtering: false,
request_mastered: false,
multi_select: true,
gate_reviewable: false,
confirming: false,
done: false,
cancelled: false,
}
}
fn run(&mut self, terminal: &mut ratatui::DefaultTerminal) -> Result<Option<Vec<K>>> {
while !self.done {
terminal.draw(|frame| self.draw(frame))?;
match event::read()? {
Event::Resize(w, h) => terminal.resize(Rect::new(0, 0, w, h))?,
Event::Key(key) if key.kind == KeyEventKind::Press => {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let nav = self.launcher && !self.filtering;
let take_text = self.filtering || !self.launcher;
let pattern = key_pattern(key.code, ctrl);
let hit = |list: &[KeyPattern]| pattern.is_some_and(|p| list.contains(&p));
match key.code {
KeyCode::Char('c') if ctrl => self.cancel(),
KeyCode::Esc if self.confirming => {
self.confirming = false;
self.refilter();
}
KeyCode::Esc if self.filtering => {
self.filtering = false;
self.cursor = 0;
}
KeyCode::Esc if self.launcher && !self.filter.is_empty() => {
self.stop_filtering()
}
KeyCode::Esc => self.cancel(),
KeyCode::Enter if self.launcher && !self.confirming => {
self.launch_focused()
}
KeyCode::Enter => self.done = true,
KeyCode::Tab if self.launcher && self.multi_select && !self.confirming => {
if !self.selected.is_empty() {
self.enter_confirm();
}
}
KeyCode::Up => self.move_cursor(-1),
KeyCode::Down => self.move_cursor(1),
KeyCode::Char('p') if ctrl => self.move_cursor(-1),
KeyCode::Char('n') if ctrl => self.move_cursor(1),
_ if nav && hit(&self.keys.down) => self.move_cursor(1),
_ if nav && hit(&self.keys.up) => self.move_cursor(-1),
_ if nav && hit(&self.keys.open) => self.launch_focused(),
_ if nav && hit(&self.keys.back) => self.cancel(),
KeyCode::Home => self.cursor = 0,
KeyCode::End => self.cursor = self.filtered.len().saturating_sub(1),
KeyCode::Char('g') if nav => self.cursor = 0,
KeyCode::Char('G') if nav => {
self.cursor = self.filtered.len().saturating_sub(1);
}
_ if nav && hit(&self.keys.filter) => self.filtering = true,
_ if nav && hit(&self.keys.mastered) => {
self.request_mastered = true;
self.done = true;
}
KeyCode::Char(' ') if self.multi_select && !self.confirming => {
self.toggle()
}
KeyCode::Backspace if self.confirming => {
self.confirming = false;
self.refilter();
}
KeyCode::Backspace if nav => self.cancel(),
KeyCode::Backspace if self.filter.is_empty() => {
if self.filtering {
self.stop_filtering();
} else {
self.cancel();
}
}
KeyCode::Backspace => {
self.filter.pop();
self.refilter();
}
KeyCode::Char(c) if !ctrl && !self.confirming && take_text => {
self.filter.push(c);
self.refilter();
}
_ => {}
}
}
_ => {}
}
}
if self.cancelled {
return Ok(None);
}
Ok(Some(self.result()))
}
fn focused(&self) -> Option<&Item<K>> {
self.filtered.get(self.cursor).map(|&i| &self.all[i])
}
fn ticked(&self) -> Vec<K> {
self.all
.iter()
.filter(|item| self.selected.contains(&item.key))
.map(|item| item.key.clone())
.collect()
}
fn result(&self) -> Vec<K> {
if self.launcher {
return if self.confirming {
self.ticked()
} else {
self.focused()
.map(|item| vec![item.key.clone()])
.unwrap_or_default()
};
}
let chosen = self.ticked();
if self.exact || !chosen.is_empty() {
return chosen;
}
self.focused()
.map(|item| vec![item.key.clone()])
.unwrap_or_default()
}
fn enter_confirm(&mut self) {
self.confirming = true;
self.filtered = (0..self.all.len())
.filter(|&i| self.selected.contains(&self.all[i].key))
.collect();
self.cursor = 0;
self.offset = 0;
}
fn cancel(&mut self) {
self.cancelled = true;
self.done = true;
}
fn rearm(&mut self) {
self.done = false;
self.cancelled = false;
self.confirming = false;
}
fn stop_filtering(&mut self) {
self.filtering = false;
self.filter.clear();
self.refilter();
}
fn launch_focused(&mut self) {
let gate = self.gate_reviewable;
if self
.focused()
.is_some_and(|item| !item.locked && (item.reviewable || !gate))
{
self.done = true;
}
}
fn move_cursor(&mut self, delta: isize) {
if self.filtered.is_empty() {
return;
}
let last = self.filtered.len() - 1;
self.cursor = (self.cursor as isize + delta).clamp(0, last as isize) as usize;
}
fn toggle(&mut self) {
if !self.multi_select {
return; }
if let Some(&i) = self.filtered.get(self.cursor) {
if self.all[i].locked
|| self.all[i].state == Some(DeckState::ExamDue)
|| self.all[i].is_workspace
|| self.all[i].is_trace
|| (self.gate_reviewable && !self.all[i].reviewable)
{
return;
}
let key = &self.all[i].key;
if !self.selected.remove(key) {
self.selected.insert(key.clone());
}
}
}
fn refilter(&mut self) {
let needle = self.filter.to_lowercase();
let filtering = !needle.is_empty();
self.filtered = self
.all
.iter()
.enumerate()
.filter(|(_, item)| {
(filtering || item.default_shown) && item.label.to_lowercase().contains(&needle)
})
.map(|(i, _)| i)
.collect();
self.cursor = self.cursor.min(self.filtered.len().saturating_sub(1));
}
fn draw(&mut self, frame: &mut Frame) {
let [header, filter, list, footer] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.areas(frame.area());
let title = if self.confirming {
"start these decks?"
} else {
&self.title
};
let left = if title.is_empty() {
" alix".to_string()
} else {
format!(" alix — {title}")
};
let right = if self.multi_select {
format!("{} selected ", self.selected.len())
} else {
String::new()
};
frame.render_widget(bar(&left, &right, header.width), header);
if !self.confirming {
let editing = self.filtering || !self.launcher;
if editing || !self.filter.is_empty() {
frame.render_widget(Paragraph::new(format!(" filter: {}", self.filter)), filter);
if editing {
frame.set_cursor_position(Position::new(
filter.x + 9 + self.filter.chars().count() as u16,
filter.y,
));
}
} else {
frame.render_widget(
Paragraph::new(" / or Ctrl-F to filter")
.style(Style::new().fg(Color::DarkGray)),
filter,
);
}
}
self.draw_list(frame, list);
let footer_text = self.footer_text();
frame.render_widget(bar(&footer_text, "", footer.width), footer);
}
fn footer_text(&self) -> String {
if !self.launcher {
return self.footer.clone();
}
if self.filtering {
return " ENTER open │ ↑↓ move │ ESC clear filter".to_string();
}
if !self.multi_select {
return " ENTER/l open │ j/k move │ / filter │ m mastered │ h/ESC back".to_string();
}
if self.confirming {
" ENTER start merged │ ↑↓ move │ ESC back".to_string()
} else if self.selected.is_empty() {
" ENTER start │ SPACE tick │ ↑↓ move │ / filter │ ESC cancel".to_string()
} else {
" ENTER start │ SPACE tick │ TAB confirm │ ↑↓ move │ ESC cancel".to_string()
}
}
fn display_rows(&self) -> Vec<DisplayRow> {
let mut rows = Vec::new();
let mut prev: Option<Section> = None;
for (pos, &i) in self.filtered.iter().enumerate() {
if !self.confirming {
let section = self.all[i].section;
if Some(section) != prev
&& let Some(header) = section.header()
{
rows.push(DisplayRow::Blank);
rows.push(DisplayRow::Header(header));
}
prev = Some(section);
}
rows.push(DisplayRow::Item(pos));
if !self.confirming && self.all[i].subtitle.is_some() {
rows.push(DisplayRow::Subtitle(i));
}
}
rows
}
fn draw_list(&mut self, frame: &mut Frame, area: Rect) {
if self.all.is_empty() {
frame.render_widget(Paragraph::new(self.empty.clone()), area);
return;
}
let display = self.display_rows();
let cursor_line = display
.iter()
.position(|row| matches!(row, DisplayRow::Item(pos) if *pos == self.cursor))
.unwrap_or(0);
let height = area.height as usize;
if cursor_line < self.offset {
self.offset = cursor_line;
} else if cursor_line >= self.offset + height {
self.offset = cursor_line + 1 - height;
}
let width = area.width as usize;
let lines: Vec<Line> = display
.iter()
.skip(self.offset)
.take(height)
.map(|row| match row {
DisplayRow::Blank => Line::default(),
DisplayRow::Header(header) => Line::from(Span::styled(
format!(" {header}"),
Style::new()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)),
DisplayRow::Item(pos) => {
self.item_line(self.filtered[*pos], *pos == self.cursor, width)
}
DisplayRow::Subtitle(i) => {
let text = self.all[*i].subtitle.as_deref().unwrap_or_default();
let cap = width.saturating_sub(6).clamp(12, 72);
Line::from(Span::styled(
format!(" {}", truncate(text, cap)),
Style::new().fg(Color::DarkGray),
))
}
})
.collect();
frame.render_widget(Paragraph::new(lines), area);
}
fn item_line(&self, i: usize, on_cursor: bool, width: usize) -> Line<'static> {
let item = &self.all[i];
let checked = self.selected.contains(&item.key);
let unreviewable = self.gate_reviewable && !item.reviewable;
let marker = if on_cursor { "›" } else { " " };
let glyph = if item.locked {
"🔒 "
} else if unreviewable && !matches!(item.state, Some(DeckState::Finished)) {
"🕒 "
} else {
""
};
let tree = if self.filter.is_empty() {
item.tree_prefix.as_str()
} else {
""
};
let show_check = self.multi_select && !self.confirming;
let fixed = 2 + if show_check { 4 } else { 0 } + tree.chars().count()
+ glyph.chars().count()
+ item.meta.as_ref().map_or(0, |m| 2 + m.chars().count())
+ if self.confirming {
0
} else {
item.hint.as_ref().map_or(0, |h| 2 + h.chars().count())
};
const LABEL_CAP: usize = 48;
let budget = width.saturating_sub(fixed + 2).clamp(12, LABEL_CAP);
let label = truncate(&item.label, budget);
let main = if show_check {
let check = if checked { "[x]" } else { "[ ]" };
format!("{marker} {check} {tree}{glyph}{label}")
} else {
format!("{marker} {tree}{glyph}{label}")
};
let mut style = Style::new();
if on_cursor {
style = style.fg(Color::Black).bg(Color::Cyan);
} else if checked {
style = style.fg(Color::Cyan);
} else if item.locked || unreviewable {
style = style.fg(Color::DarkGray);
}
let mut spans = vec![Span::styled(main, style)];
if let Some(hint) = &item.hint
&& !self.confirming
{
let hint_style = if on_cursor {
style
} else {
Style::new().fg(Color::DarkGray)
};
spans.push(Span::styled(format!(" {hint}"), hint_style));
}
if let Some(meta) = &item.meta {
let meta_style = if on_cursor || item.locked || unreviewable {
style
} else {
match item.state {
Some(DeckState::Finished) => Style::new().fg(Color::Green),
Some(DeckState::ExamDue) => Style::new().fg(Color::Yellow),
_ => style,
}
};
spans.push(Span::styled(format!(" {meta}"), meta_style));
}
Line::from(spans)
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
fn bar(left: &str, right: &str, width: u16) -> Paragraph<'static> {
let pad = (width as usize)
.saturating_sub(left.chars().count())
.saturating_sub(right.chars().count());
Paragraph::new(format!("{left}{}{right}", " ".repeat(pad))).style(HEADER_STYLE)
}
#[cfg(test)]
mod tests {
use super::*;
fn labeled(label: &str, key: &str) -> Item<PathBuf> {
Item {
key: PathBuf::from(key),
label: label.to_string(),
meta: None,
subtitle: None,
locked: false,
state: None,
is_workspace: false,
is_trace: false,
reviewable: true,
hint: None,
section: Section::None,
default_shown: true,
tree_prefix: String::new(),
}
}
#[test]
fn disambiguate_adds_a_path_hint_to_same_titled_rows() {
let mut items = vec![
labeled("Scheduling", "/srv/decks/sched-a"),
labeled("Scheduling", "/srv/decks/sched-b"),
labeled("Unique", "/srv/decks/unique"),
];
disambiguate(&mut items);
assert!(items[0].hint.is_some());
assert!(items[1].hint.is_some());
assert_ne!(items[0].hint, items[1].hint);
assert!(items[2].hint.is_none());
}
fn picker_with(labels: &[&str]) -> Picker<PathBuf> {
let all = labels
.iter()
.map(|n| Item {
key: PathBuf::from(n),
label: n.to_string(),
meta: None,
subtitle: None,
locked: false,
state: None,
is_workspace: false,
is_trace: false,
reviewable: true,
hint: None,
section: Section::None,
default_shown: true,
tree_prefix: String::new(),
})
.collect();
Picker::new(
all,
HashSet::new(),
"t".to_string(),
"f".to_string(),
false,
Vec::new(),
)
}
fn launcher_with(items: &[(&str, bool)]) -> Picker<PathBuf> {
let all = items
.iter()
.map(|(n, locked)| Item {
key: PathBuf::from(n),
label: n.to_string(),
meta: None,
subtitle: None,
locked: *locked,
state: None,
is_workspace: false,
is_trace: false,
reviewable: true,
hint: None,
section: Section::None,
default_shown: true,
tree_prefix: String::new(),
})
.collect();
let mut p = Picker::new(
all,
HashSet::new(),
"t".to_string(),
String::new(),
false,
Vec::new(),
);
p.launcher = true;
p
}
fn sectioned(rows: &[(&str, Section, bool)]) -> Picker<PathBuf> {
let all = rows
.iter()
.map(|(n, section, default_shown)| Item {
key: PathBuf::from(n),
label: n.to_string(),
meta: None,
subtitle: None,
locked: false,
state: None,
is_workspace: false,
is_trace: false,
reviewable: true,
hint: None,
section: *section,
default_shown: *default_shown,
tree_prefix: String::new(),
})
.collect();
Picker::new(
all,
HashSet::new(),
"t".to_string(),
"f".to_string(),
false,
Vec::new(),
)
}
fn headers(p: &Picker<PathBuf>) -> Vec<&'static str> {
p.display_rows()
.into_iter()
.filter_map(|row| match row {
DisplayRow::Header(h) => Some(h),
DisplayRow::Blank | DisplayRow::Item(_) | DisplayRow::Subtitle(_) => None,
})
.collect()
}
#[test]
fn display_rows_insert_a_header_above_each_section() {
let p = sectioned(&[
("proj", Section::Workspaces, true),
("rec", Section::Recent, true),
("fold", Section::Folders, true),
]);
assert_eq!(vec!["Workspaces", "Recent", "Folders"], headers(&p));
let rows = p.display_rows();
assert_eq!(9, rows.len());
let blanks = rows
.iter()
.filter(|r| matches!(r, DisplayRow::Blank))
.count();
assert_eq!(3, blanks);
}
#[test]
fn display_rows_group_consecutive_items_under_one_header() {
let p = sectioned(&[
("a", Section::Recent, true),
("b", Section::Recent, true),
("c", Section::Folders, true),
]);
assert_eq!(vec!["Recent", "Folders"], headers(&p));
}
#[test]
fn confirm_view_drops_section_headers() {
let mut p = sectioned(&[("a", Section::Recent, true), ("b", Section::Recent, true)]);
p.launcher = true;
p.selected.insert(PathBuf::from("a"));
p.enter_confirm();
assert!(headers(&p).is_empty());
}
#[test]
fn non_default_rows_appear_only_when_filtering() {
let mut p = sectioned(&[
("recent-deck", Section::Recent, true),
("old-deck", Section::Recent, false),
]);
assert_eq!(1, p.filtered.len());
p.filter = "old".to_string();
p.refilter();
assert_eq!(1, p.filtered.len());
assert_eq!(PathBuf::from("old-deck"), p.all[p.filtered[0]].key);
p.filter.clear();
p.refilter();
assert_eq!(1, p.filtered.len());
assert_eq!(PathBuf::from("recent-deck"), p.all[p.filtered[0]].key);
}
#[test]
fn cursor_position_maps_to_its_display_line_skipping_headers() {
let mut p = sectioned(&[
("proj", Section::Workspaces, true),
("rec", Section::Recent, true),
("fold", Section::Folders, true),
]);
p.cursor = 1; let line = p
.display_rows()
.iter()
.position(|row| matches!(row, DisplayRow::Item(pos) if *pos == p.cursor));
assert_eq!(Some(5), line);
}
#[test]
fn launcher_enter_returns_focused_deck() {
let mut p = launcher_with(&[("a.txt", false), ("b.txt", false)]);
p.cursor = 1;
assert_eq!(vec![PathBuf::from("b.txt")], p.result());
}
#[test]
fn launcher_confirm_returns_ticked_set() {
let mut p = launcher_with(&[("a.txt", false), ("b.txt", false), ("c.txt", false)]);
p.selected.insert(PathBuf::from("a.txt"));
p.selected.insert(PathBuf::from("c.txt"));
p.enter_confirm();
assert!(p.confirming);
assert_eq!(2, p.filtered.len()); assert_eq!(
vec![PathBuf::from("a.txt"), PathBuf::from("c.txt")],
p.result()
);
}
#[test]
fn launcher_does_not_tick_locked_decks() {
let mut p = launcher_with(&[("locked.txt", true)]);
p.cursor = 0;
p.toggle();
assert!(p.selected.is_empty());
}
#[test]
fn launcher_does_not_tick_traces() {
let mut p = launcher_with(&[("walk.txt", false)]);
p.all[0].is_trace = true;
p.cursor = 0;
p.toggle();
assert!(p.selected.is_empty());
}
#[test]
fn dependency_forest_nests_dependents_under_prerequisites() {
let names: Vec<String> = ["data-model", "leitner", "grading", "sm2", "queue-building"]
.iter()
.map(|s| s.to_string())
.collect();
let parent = vec![None, Some(0), Some(1), Some(0), Some(0)];
let order = dependency_forest(&parent, &names);
assert_eq!(
vec![
(0, "".to_string()),
(1, "├─ ".to_string()),
(2, "│ └─ ".to_string()),
(4, "├─ ".to_string()),
(3, "└─ ".to_string()),
],
order
);
}
#[test]
fn truncate_caps_long_labels_with_ellipsis() {
assert_eq!("short", truncate("short", 10)); assert_eq!("hello", truncate("hello", 5)); assert_eq!("abcd…", truncate("abcdefghij", 5)); }
#[test]
fn dependency_forest_survives_a_cycle() {
let names = vec!["a".to_string(), "b".to_string()];
let parent = vec![Some(1), Some(0)];
let order = dependency_forest(&parent, &names);
let mut indices: Vec<usize> = order.iter().map(|(i, _)| *i).collect();
indices.sort();
assert_eq!(vec![0, 1], indices);
}
#[test]
fn single_launch_list_has_nothing_to_tick() {
let mut p = launcher_with(&[("a.txt", false), ("b.txt", false)]);
p.multi_select = false;
p.cursor = 0;
p.toggle();
assert!(p.selected.is_empty());
}
#[test]
fn gating_disables_an_unreviewable_deck() {
let mut p = launcher_with(&[("done.txt", false)]);
p.all[0].reviewable = false;
p.gate_reviewable = true;
p.cursor = 0;
p.toggle();
assert!(p.selected.is_empty());
p.gate_reviewable = false;
p.toggle();
assert_eq!(1, p.selected.len());
}
#[test]
fn filter_narrows_the_list() {
let mut p = picker_with(&["rust.txt", "ruby.txt", "go.txt"]);
assert_eq!(3, p.filtered.len());
p.filter = "ru".to_string();
p.refilter();
assert_eq!(2, p.filtered.len());
p.filter = "rust".to_string();
p.refilter();
assert_eq!(1, p.filtered.len());
}
#[test]
fn filter_is_case_insensitive() {
let mut p = picker_with(&["Rust.txt", "go.txt"]);
p.filter = "RUST".to_string();
p.refilter();
assert_eq!(1, p.filtered.len());
}
#[test]
fn cursor_clamps_after_filtering() {
let mut p = picker_with(&["a.txt", "b.txt", "c.txt"]);
p.cursor = 2;
p.filter = "a".to_string();
p.refilter();
assert_eq!(0, p.cursor);
}
#[test]
fn toggle_selects_under_cursor_returns_selection() {
let mut p = picker_with(&["a.txt", "b.txt", "c.txt"]);
p.toggle(); p.move_cursor(2);
p.toggle(); let chosen: Vec<PathBuf> = p
.all
.iter()
.filter(|item| p.selected.contains(&item.key))
.map(|item| item.key.clone())
.collect();
assert_eq!(vec![PathBuf::from("a.txt"), PathBuf::from("c.txt")], chosen);
}
#[test]
fn toggle_is_idempotent_pair() {
let mut p = picker_with(&["a.txt"]);
p.toggle();
assert!(p.selected.contains(&PathBuf::from("a.txt")));
p.toggle();
assert!(p.selected.is_empty());
}
#[test]
fn build_candidates_orders_recent_first_then_alpha() {
let dir = tempfile::tempdir().unwrap();
for n in ["zeta.txt", "alpha.txt", "mid.txt"] {
std::fs::write(dir.path().join(n), "# f\n\tb\n").unwrap();
}
let recent_path = dir.path().join("recent.json");
let mut recent = RecentDecks::load(&recent_path);
recent.record(&[dir.path().join("mid.txt")], 1000);
let cands = build_candidates(dir.path(), &recent);
let names: Vec<&str> = cands.iter().map(|c| c.name.as_str()).collect();
assert_eq!(vec!["mid.txt", "alpha.txt", "zeta.txt"], names);
assert!(cands[0].last_used_ms.is_some());
assert!(cands[1].last_used_ms.is_none());
}
#[test]
fn catalog_mirrors_candidate_order_and_paths() {
let dir = tempfile::tempdir().unwrap();
for n in ["zeta.txt", "alpha.txt"] {
std::fs::write(dir.path().join(n), "# f\n\tb\n").unwrap();
}
let mut recent = RecentDecks::load(dir.path().join("recent.json"));
recent.record(&[dir.path().join("zeta.txt")], 1000);
let entries = catalog(dir.path(), &recent);
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(vec!["zeta.txt", "alpha.txt"], names); assert_eq!(dir.path().join("zeta.txt"), entries[0].path);
assert!(entries[0].last_used_ms.is_some());
}
#[test]
fn location_hint_only_for_entries_outside_the_decks_dir() {
let home = directories::BaseDirs::new()
.unwrap()
.home_dir()
.to_path_buf();
let decks = home.join("decks");
assert_eq!(None, location_hint(&decks.join("foo.txt"), &decks));
assert_eq!(None, location_hint(&decks.join("english"), &decks));
assert_eq!(
Some("~/other".to_string()),
location_hint(&home.join("other").join("x.txt"), &decks)
);
assert_eq!(
Some("/tmp".to_string()),
location_hint(Path::new("/tmp/x.txt"), &decks)
);
}
#[test]
fn catalog_surfaces_workspace_with_qualified_members() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().join("english");
std::fs::create_dir(&ws).unwrap();
std::fs::write(ws.join("a.txt"), "# a\n\tb\n").unwrap();
std::fs::write(ws.join("b.txt"), "# c\n\td\n").unwrap();
std::fs::write(ws.join(workspace::MANIFEST), "title = \"English\"\n").unwrap();
let recent = RecentDecks::load(dir.path().join("recent.json"));
let entries = catalog(dir.path(), &recent);
let w = entries
.iter()
.find(|e| e.is_workspace)
.expect("workspace entry");
assert_eq!("english", w.name); assert_eq!("English", w.label); let members: Vec<&str> = w.members.iter().map(|m| m.name.as_str()).collect();
assert_eq!(vec!["english/a.txt", "english/b.txt"], members);
}
#[test]
fn build_candidates_skips_missing_recent_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("real.txt"), "# f\n\tb\n").unwrap();
let mut recent = RecentDecks::load(dir.path().join("recent.json"));
recent.record(&[dir.path().join("deleted.txt")], 1000);
let cands = build_candidates(dir.path(), &recent);
let names: Vec<&str> = cands.iter().map(|c| c.name.as_str()).collect();
assert_eq!(vec!["real.txt"], names);
}
}