2d3db458fb
The rejected/cancelled checkbox glyph was a hardcoded `const REJECTED = '-'`. ListSyms now carries a `rejected` field (new_with_rejected); a per-wiki `listsym_rejected` key (default `-`) is added to WikiConfig, and WikiConfig::list_syms() builds the palette with it. Routed the three config-driven ListSyms construction sites (parse path in lib.rs + the toggle/cycle commands) through list_syms(), so a custom rejected glyph both lexes and round-trips. Tests: listsyms::custom_rejected_glyph_is_honoured + config round-trip. README/doc/lua config comment updated. fmt clean; 0 Rust test failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
199 lines
6.6 KiB
Rust
199 lines
6.6 KiB
Rust
//! 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;
|
|
|
|
/// Default rejected/cancelled marker (vimwiki's `listsym_rejected`).
|
|
const REJECTED: char = '-';
|
|
|
|
/// An ordered checkbox progress palette. Always holds at least two glyphs.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ListSyms {
|
|
progression: Vec<char>,
|
|
rejected: char,
|
|
}
|
|
|
|
impl Default for ListSyms {
|
|
fn default() -> Self {
|
|
Self {
|
|
progression: " .oOX".chars().collect(),
|
|
rejected: REJECTED,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ListSyms {
|
|
/// Build a palette from a glyph string, with the default rejected marker.
|
|
/// 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 {
|
|
Self::new_with_rejected(s, REJECTED)
|
|
}
|
|
|
|
/// Like [`new`](Self::new) but with a custom rejected/cancelled glyph
|
|
/// (vimwiki's `listsym_rejected`).
|
|
pub fn new_with_rejected(s: &str, rejected: char) -> Self {
|
|
let progression: Vec<char> = s.chars().collect();
|
|
if progression.len() < 2 {
|
|
Self {
|
|
rejected,
|
|
..Self::default()
|
|
}
|
|
} else {
|
|
Self {
|
|
progression,
|
|
rejected,
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
self.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<usize> {
|
|
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<CheckboxState> {
|
|
if c == self.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 self.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_rejected_glyph_is_honoured() {
|
|
// `listsym_rejected = '✗'`: that glyph lexes/snaps as Rejected and the
|
|
// default `-` no longer does.
|
|
let s = ListSyms::new_with_rejected(" .oOX", '✗');
|
|
assert_eq!(s.rejected_glyph(), '✗');
|
|
assert_eq!(s.state_of('✗'), Some(CheckboxState::Rejected));
|
|
assert_eq!(s.glyph_for_rate(-1), '✗');
|
|
assert_eq!(s.state_of('-'), None); // no longer the rejected marker
|
|
// A short palette still keeps the custom rejected glyph.
|
|
assert_eq!(ListSyms::new_with_rejected("x", '✗').rejected_glyph(), '✗');
|
|
}
|
|
|
|
#[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), '✓');
|
|
}
|
|
}
|