//! Configurable checkbox progress palette (vimwiki's `g:vimwiki_listsyms`). //! //! A palette is a progression of glyphs from "empty" (index 0) to "done" //! (the last index). The default `" .oOX"` is the stock vimwiki palette. //! A separate `rejected` glyph (`-`) is fixed by convention. //! //! The palette maps glyphs to a normalised five-bucket [`CheckboxState`] for //! the AST (so the HTML renderer keeps its fixed `done0..done4` classes) while //! still letting the LSP cycle/toggle commands operate on the real glyphs. use crate::ast::CheckboxState; const REJECTED: char = '-'; /// An ordered checkbox progress palette. Always holds at least two glyphs. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListSyms { progression: Vec, } impl Default for ListSyms { fn default() -> Self { Self { progression: " .oOX".chars().collect(), } } } impl ListSyms { /// Build a palette from a glyph string. Falls back to the default when /// fewer than two glyphs are supplied (a one-symbol palette can't express /// both "empty" and "done"). pub fn new(s: &str) -> Self { let progression: Vec = s.chars().collect(); if progression.len() < 2 { Self::default() } else { Self { progression } } } pub fn len(&self) -> usize { self.progression.len() } pub fn is_empty(&self) -> bool { self.progression.is_empty() } pub fn empty_glyph(&self) -> char { self.progression[0] } pub fn done_glyph(&self) -> char { *self.progression.last().unwrap() } pub fn rejected_glyph(&self) -> char { REJECTED } pub fn glyph_at(&self, index: usize) -> char { self.progression[index.min(self.progression.len() - 1)] } pub fn index_of(&self, c: char) -> Option { self.progression.iter().position(|&g| g == c) } /// Progress rate (0..=100) for a glyph index. pub fn rate(&self, index: usize) -> i32 { let last = (self.progression.len() - 1) as i32; (index as i32) * 100 / last } /// Normalised five-bucket [`CheckboxState`] for a glyph, or `None` when the /// glyph is not part of this palette (and is not the rejected marker). pub fn state_of(&self, c: char) -> Option { if c == REJECTED { return Some(CheckboxState::Rejected); } let index = self.index_of(c)?; let rate = self.rate(index); // Round the rate to the nearest 25% bucket. Some(match (rate + 12) / 25 { 0 => CheckboxState::Empty, 1 => CheckboxState::Quarter, 2 => CheckboxState::Half, 3 => CheckboxState::ThreeQuarters, _ => CheckboxState::Done, }) } /// Snap a rate (`-1` = rejected, else 0..=100) to a glyph. Intermediate /// rates distribute over the `n - 2` non-terminal glyphs using vimwiki's /// `ceil` rule, matching the stock five-symbol behaviour. pub fn glyph_for_rate(&self, rate: i32) -> char { if rate < 0 { return REJECTED; } if rate <= 0 { return self.empty_glyph(); } if rate >= 100 { return self.done_glyph(); } let mids = (self.progression.len().saturating_sub(2)).max(1) as f64; let index = ((rate as f64) / 100.0 * mids).ceil() as usize; self.glyph_at(index) } } #[cfg(test)] mod tests { use super::*; #[test] fn default_palette_matches_stock_vimwiki() { let s = ListSyms::default(); assert_eq!(s.len(), 5); assert_eq!(s.empty_glyph(), ' '); assert_eq!(s.done_glyph(), 'X'); assert_eq!(s.rejected_glyph(), '-'); assert_eq!(s.rate(0), 0); assert_eq!(s.rate(1), 25); assert_eq!(s.rate(2), 50); assert_eq!(s.rate(3), 75); assert_eq!(s.rate(4), 100); } #[test] fn default_state_of_round_trips_each_glyph() { let s = ListSyms::default(); assert_eq!(s.state_of(' '), Some(CheckboxState::Empty)); assert_eq!(s.state_of('.'), Some(CheckboxState::Quarter)); assert_eq!(s.state_of('o'), Some(CheckboxState::Half)); assert_eq!(s.state_of('O'), Some(CheckboxState::ThreeQuarters)); assert_eq!(s.state_of('X'), Some(CheckboxState::Done)); assert_eq!(s.state_of('-'), Some(CheckboxState::Rejected)); assert_eq!(s.state_of('?'), None); } #[test] fn default_glyph_for_rate_matches_legacy_buckets() { let s = ListSyms::default(); assert_eq!(s.glyph_for_rate(-1), '-'); assert_eq!(s.glyph_for_rate(0), ' '); assert_eq!(s.glyph_for_rate(25), '.'); assert_eq!(s.glyph_for_rate(50), 'o'); assert_eq!(s.glyph_for_rate(75), 'O'); assert_eq!(s.glyph_for_rate(100), 'X'); } #[test] fn short_input_falls_back_to_default() { assert_eq!(ListSyms::new(""), ListSyms::default()); assert_eq!(ListSyms::new("x"), ListSyms::default()); } #[test] fn custom_palette_buckets_to_nearest_state() { // 3 glyphs → rates 0 / 50 / 100. let s = ListSyms::new(" x✓"); assert_eq!(s.state_of(' '), Some(CheckboxState::Empty)); assert_eq!(s.state_of('x'), Some(CheckboxState::Half)); assert_eq!(s.state_of('✓'), Some(CheckboxState::Done)); assert_eq!(s.glyph_for_rate(50), 'x'); assert_eq!(s.glyph_for_rate(100), '✓'); } }