feat(config): make listsym_rejected configurable (P2)

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>
This commit is contained in:
2026-05-31 18:33:25 +00:00
parent 624bccbe50
commit 2d3db458fb
9 changed files with 76 additions and 17 deletions
+2
View File
@@ -234,6 +234,7 @@ require('nuwiki').setup({
exclude_files = {}, -- glob patterns exclude_files = {}, -- glob patterns
-- Checkbox progression characters: " .oOX" by default. -- Checkbox progression characters: " .oOX" by default.
listsyms = ' .oOX', listsyms = ' .oOX',
listsym_rejected = '-', -- glyph for a cancelled checkbox [-]
listsyms_propagate = true, listsyms_propagate = true,
}, },
}, },
@@ -327,6 +328,7 @@ Each is a boolean. All default to `true` except `mouse`.
| `diary_sort` | string | `'desc'` | `'desc'` \| `'asc'` | | `diary_sort` | string | `'desc'` | `'desc'` \| `'asc'` |
| `diary_header` | string | `'Diary'` | any heading text | | `diary_header` | string | `'Diary'` | any heading text |
| `listsyms` | string | `' .oOX'` | checkbox progression chars | | `listsyms` | string | `' .oOX'` | checkbox progression chars |
| `listsym_rejected` | string | `'-'` | glyph for a cancelled checkbox (`[-]`); first char used |
| `listsyms_propagate` | bool | `true` | `true` \| `false` | | `listsyms_propagate` | bool | `true` | `true` \| `false` |
| `list_margin` | int | `-1` | `-1` (auto) or `≥ 0` | | `list_margin` | int | `-1` | `-1` (auto) or `≥ 0` |
| `links_space_char` | string | `' '` | char that replaces spaces in synthesised link paths | | `links_space_char` | string | `' '` | char that replaces spaces in synthesised link paths |
+36 -8
View File
@@ -10,32 +10,47 @@
use crate::ast::CheckboxState; use crate::ast::CheckboxState;
/// Default rejected/cancelled marker (vimwiki's `listsym_rejected`).
const REJECTED: char = '-'; const REJECTED: char = '-';
/// An ordered checkbox progress palette. Always holds at least two glyphs. /// An ordered checkbox progress palette. Always holds at least two glyphs.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListSyms { pub struct ListSyms {
progression: Vec<char>, progression: Vec<char>,
rejected: char,
} }
impl Default for ListSyms { impl Default for ListSyms {
fn default() -> Self { fn default() -> Self {
Self { Self {
progression: " .oOX".chars().collect(), progression: " .oOX".chars().collect(),
rejected: REJECTED,
} }
} }
} }
impl ListSyms { impl ListSyms {
/// Build a palette from a glyph string. Falls back to the default when /// Build a palette from a glyph string, with the default rejected marker.
/// fewer than two glyphs are supplied (a one-symbol palette can't express /// Falls back to the default when fewer than two glyphs are supplied (a
/// both "empty" and "done"). /// one-symbol palette can't express both "empty" and "done").
pub fn new(s: &str) -> Self { 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(); let progression: Vec<char> = s.chars().collect();
if progression.len() < 2 { if progression.len() < 2 {
Self::default() Self {
rejected,
..Self::default()
}
} else { } else {
Self { progression } Self {
progression,
rejected,
}
} }
} }
@@ -56,7 +71,7 @@ impl ListSyms {
} }
pub fn rejected_glyph(&self) -> char { pub fn rejected_glyph(&self) -> char {
REJECTED self.rejected
} }
pub fn glyph_at(&self, index: usize) -> char { pub fn glyph_at(&self, index: usize) -> char {
@@ -76,7 +91,7 @@ impl ListSyms {
/// Normalised five-bucket [`CheckboxState`] for a glyph, or `None` when the /// Normalised five-bucket [`CheckboxState`] for a glyph, or `None` when the
/// glyph is not part of this palette (and is not the rejected marker). /// glyph is not part of this palette (and is not the rejected marker).
pub fn state_of(&self, c: char) -> Option<CheckboxState> { pub fn state_of(&self, c: char) -> Option<CheckboxState> {
if c == REJECTED { if c == self.rejected {
return Some(CheckboxState::Rejected); return Some(CheckboxState::Rejected);
} }
let index = self.index_of(c)?; let index = self.index_of(c)?;
@@ -96,7 +111,7 @@ impl ListSyms {
/// `ceil` rule, matching the stock five-symbol behaviour. /// `ceil` rule, matching the stock five-symbol behaviour.
pub fn glyph_for_rate(&self, rate: i32) -> char { pub fn glyph_for_rate(&self, rate: i32) -> char {
if rate < 0 { if rate < 0 {
return REJECTED; return self.rejected;
} }
if rate <= 0 { if rate <= 0 {
return self.empty_glyph(); return self.empty_glyph();
@@ -157,6 +172,19 @@ mod tests {
assert_eq!(ListSyms::new("x"), 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] #[test]
fn custom_palette_buckets_to_nearest_state() { fn custom_palette_buckets_to_nearest_state() {
// 3 glyphs → rates 0 / 50 / 100. // 3 glyphs → rates 0 / 50 / 100.
+3 -5
View File
@@ -250,7 +250,7 @@ fn list_checkbox(
let wiki = backend.wiki_for_uri(&p.uri); let wiki = backend.wiki_for_uri(&p.uri);
let syms = wiki let syms = wiki
.as_ref() .as_ref()
.map(|w| nuwiki_core::listsyms::ListSyms::new(&w.config.listsyms)) .map(|w| w.config.list_syms())
.unwrap_or_default(); .unwrap_or_default();
let propagate = wiki.map(|w| w.config.listsyms_propagate).unwrap_or(true); let propagate = wiki.map(|w| w.config.listsyms_propagate).unwrap_or(true);
Ok(ops::checkbox_edit( Ok(ops::checkbox_edit(
@@ -1184,10 +1184,8 @@ fn export_all(backend: &Backend, args: Vec<Value>, force: bool) -> Result<Option
let Ok(text) = std::fs::read_to_string(path) else { let Ok(text) = std::fs::read_to_string(path) else {
continue; continue;
}; };
let ast = nuwiki_core::syntax::vimwiki::VimwikiSyntax::new().parse_with_listsyms( let ast = nuwiki_core::syntax::vimwiki::VimwikiSyntax::new()
&text, .parse_with_listsyms(&text, &cfg.list_syms());
&nuwiki_core::listsyms::ListSyms::new(&cfg.listsyms),
);
(text, ast) (text, ast)
} }
}; };
+21
View File
@@ -61,6 +61,9 @@ pub struct WikiConfig {
/// glyphs and the toggle/cycle commands walk them. Any length ≥ 2 is /// glyphs and the toggle/cycle commands walk them. Any length ≥ 2 is
/// honoured. Default: `" .oOX"`. /// honoured. Default: `" .oOX"`.
pub listsyms: String, pub listsyms: String,
/// Vimwiki's `g:vimwiki_listsym_rejected` — the glyph for a cancelled
/// checkbox (`[-]` by default). First character is used.
pub listsym_rejected: String,
/// When toggling a parent list item, also cascade to descendants. /// When toggling a parent list item, also cascade to descendants.
pub listsyms_propagate: bool, pub listsyms_propagate: bool,
/// `-1` keeps tight lists; positive integers indent list items by /// `-1` keeps tight lists; positive integers indent list items by
@@ -188,6 +191,7 @@ impl WikiConfig {
diary_sort: d.diary_sort, diary_sort: d.diary_sort,
diary_header: d.diary_header, diary_header: d.diary_header,
listsyms: d.listsyms, listsyms: d.listsyms,
listsym_rejected: d.listsym_rejected,
listsyms_propagate: d.listsyms_propagate, listsyms_propagate: d.listsyms_propagate,
list_margin: d.list_margin, list_margin: d.list_margin,
links_space_char: d.links_space_char, links_space_char: d.links_space_char,
@@ -217,6 +221,7 @@ impl WikiConfig {
diary_sort: d.diary_sort, diary_sort: d.diary_sort,
diary_header: d.diary_header, diary_header: d.diary_header,
listsyms: d.listsyms, listsyms: d.listsyms,
listsym_rejected: d.listsym_rejected,
listsyms_propagate: d.listsyms_propagate, listsyms_propagate: d.listsyms_propagate,
list_margin: d.list_margin, list_margin: d.list_margin,
links_space_char: d.links_space_char, links_space_char: d.links_space_char,
@@ -258,6 +263,13 @@ impl WikiConfig {
nuwiki_core::date::DiaryFrequency::parse(&self.diary_frequency) nuwiki_core::date::DiaryFrequency::parse(&self.diary_frequency)
} }
/// The checkbox palette for this wiki — the `listsyms` progression plus
/// the configured `listsym_rejected` glyph (default `-`).
pub fn list_syms(&self) -> nuwiki_core::listsyms::ListSyms {
let rejected = self.listsym_rejected.chars().next().unwrap_or('-');
nuwiki_core::listsyms::ListSyms::new_with_rejected(&self.listsyms, rejected)
}
/// The diary navigation policy (frequency + weekly naming/week-start), /// The diary navigation policy (frequency + weekly naming/week-start),
/// used by the diary commands to compute today / next / prev. /// used by the diary commands to compute today / next / prev.
pub fn diary_calendar(&self) -> nuwiki_core::date::DiaryCalendar { pub fn diary_calendar(&self) -> nuwiki_core::date::DiaryCalendar {
@@ -306,6 +318,10 @@ fn default_listsyms() -> String {
" .oOX".to_string() " .oOX".to_string()
} }
fn default_listsym_rejected() -> String {
"-".to_string()
}
fn default_links_space_char() -> String { fn default_links_space_char() -> String {
" ".to_string() " ".to_string()
} }
@@ -327,6 +343,7 @@ fn wiki_defaults() -> WikiDefaults {
diary_sort: default_diary_sort(), diary_sort: default_diary_sort(),
diary_header: default_diary_header(), diary_header: default_diary_header(),
listsyms: default_listsyms(), listsyms: default_listsyms(),
listsym_rejected: default_listsym_rejected(),
listsyms_propagate: true, listsyms_propagate: true,
list_margin: -1, list_margin: -1,
links_space_char: default_links_space_char(), links_space_char: default_links_space_char(),
@@ -345,6 +362,7 @@ struct WikiDefaults {
diary_sort: String, diary_sort: String,
diary_header: String, diary_header: String,
listsyms: String, listsyms: String,
listsym_rejected: String,
listsyms_propagate: bool, listsyms_propagate: bool,
list_margin: i32, list_margin: i32,
links_space_char: String, links_space_char: String,
@@ -565,6 +583,8 @@ struct RawWiki {
diary_header: Option<String>, diary_header: Option<String>,
#[serde(default)] #[serde(default)]
listsyms: Option<String>, listsyms: Option<String>,
#[serde(default)]
listsym_rejected: Option<String>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
listsyms_propagate: Option<bool>, listsyms_propagate: Option<bool>,
#[serde(default)] #[serde(default)]
@@ -629,6 +649,7 @@ impl From<RawWiki> for WikiConfig {
diary_sort: r.diary_sort.unwrap_or(d.diary_sort), diary_sort: r.diary_sort.unwrap_or(d.diary_sort),
diary_header: r.diary_header.unwrap_or(d.diary_header), diary_header: r.diary_header.unwrap_or(d.diary_header),
listsyms: r.listsyms.unwrap_or(d.listsyms), listsyms: r.listsyms.unwrap_or(d.listsyms),
listsym_rejected: r.listsym_rejected.unwrap_or(d.listsym_rejected),
listsyms_propagate: r.listsyms_propagate.unwrap_or(d.listsyms_propagate), listsyms_propagate: r.listsyms_propagate.unwrap_or(d.listsyms_propagate),
list_margin: r.list_margin.unwrap_or(d.list_margin), list_margin: r.list_margin.unwrap_or(d.list_margin),
links_space_char: r.links_space_char.unwrap_or(d.links_space_char), links_space_char: r.links_space_char.unwrap_or(d.links_space_char),
+1 -1
View File
@@ -193,7 +193,7 @@ impl Backend {
fn parse_for_uri(&self, uri: &Url, text: &str) -> nuwiki_core::ast::DocumentNode { fn parse_for_uri(&self, uri: &Url, text: &str) -> nuwiki_core::ast::DocumentNode {
let syms = self let syms = self
.wiki_for_uri(uri) .wiki_for_uri(uri)
.map(|w| nuwiki_core::listsyms::ListSyms::new(&w.config.listsyms)) .map(|w| w.config.list_syms())
.unwrap_or_default(); .unwrap_or_default();
nuwiki_core::syntax::vimwiki::VimwikiSyntax::new().parse_with_listsyms(text, &syms) nuwiki_core::syntax::vimwiki::VimwikiSyntax::new().parse_with_listsyms(text, &syms)
} }
@@ -290,6 +290,7 @@ fn raw_wiki_parses_every_new_key() {
"diary_sort": "asc", "diary_sort": "asc",
"diary_header": "Journal", "diary_header": "Journal",
"listsyms": "abcde", "listsyms": "abcde",
"listsym_rejected": "",
"listsyms_propagate": false, "listsyms_propagate": false,
"list_margin": 2, "list_margin": 2,
"links_space_char": "_", "links_space_char": "_",
@@ -305,6 +306,8 @@ fn raw_wiki_parses_every_new_key() {
assert_eq!(w.diary_sort, "asc"); assert_eq!(w.diary_sort, "asc");
assert_eq!(w.diary_header, "Journal"); assert_eq!(w.diary_header, "Journal");
assert_eq!(w.listsyms, "abcde"); assert_eq!(w.listsyms, "abcde");
assert_eq!(w.listsym_rejected, "");
assert_eq!(w.list_syms().rejected_glyph(), '✗');
assert!(!w.listsyms_propagate); assert!(!w.listsyms_propagate);
assert_eq!(w.list_margin, 2); assert_eq!(w.list_margin, 2);
assert_eq!(w.links_space_char, "_"); assert_eq!(w.links_space_char, "_");
+8 -2
View File
@@ -93,8 +93,14 @@ fix site.
- [ ] **On-save autoregen family**`auto_diary_index`, `auto_generate_links`, - [ ] **On-save autoregen family**`auto_diary_index`, `auto_generate_links`,
`auto_generate_tags`, `auto_tags` (only `auto_toc` / `auto_export` exist). `auto_generate_tags`, `auto_tags` (only `auto_toc` / `auto_export` exist).
_Fix:_ `config.rs` `RawWiki`/`WikiConfig` + the `didSave` path. _Fix:_ `config.rs` `RawWiki`/`WikiConfig` + the `didSave` path.
- [ ] **`listsym_rejected`** — rejected glyph `-` is hardcoded. - [x] **`listsym_rejected`** — the rejected glyph was a hardcoded `const '-'`.
_Fix:_ `config.rs` `WikiConfig`. _Fix:_ `ListSyms` gained a `rejected` field + `new_with_rejected()`
(`crates/nuwiki-core/src/listsyms.rs`); per-wiki `listsym_rejected` key (default
`-`) added to `WikiConfig`; `WikiConfig::list_syms()` builds the palette with
it, used by the parse path (`lib.rs`) and the toggle/cycle commands — so a
custom rejected glyph both lexes and round-trips. Tests:
`listsyms::custom_rejected_glyph_is_honoured` + config round-trip in
`index_and_config.rs`.
- [ ] **`custom_wiki2html` (+`_args`)** and **`base_url`** — external HTML - [ ] **`custom_wiki2html` (+`_args`)** and **`base_url`** — external HTML
converter hook + export URL prefix. _Fix:_ `config.rs` `HtmlConfig`. converter hook + export URL prefix. _Fix:_ `config.rs` `HtmlConfig`.
- [ ] **`map_prefix`** — `<Leader>w` hardcoded across the map layer. - [ ] **`map_prefix`** — `<Leader>w` hardcoded across the map layer.
+1
View File
@@ -198,6 +198,7 @@ match upstream vimwiki.
`auto_toc` `false` — refresh TOC on save. `auto_toc` `false` — refresh TOC on save.
`exclude_files` List of glob patterns excluded from export. `exclude_files` List of glob patterns excluded from export.
`listsyms` `' .oOX'` — checkbox progression characters. `listsyms` `' .oOX'` — checkbox progression characters.
`listsym_rejected` `'-'` — glyph for a cancelled checkbox `[-]`.
`listsyms_propagate` `true` `listsyms_propagate` `true`
`links_space_char` `' '` `links_space_char` `' '`
+1 -1
View File
@@ -26,7 +26,7 @@ M.defaults = {
-- html_path, template_path, template_default, template_ext, -- html_path, template_path, template_default, template_ext,
-- template_date_format, css_name, auto_export, auto_toc, -- template_date_format, css_name, auto_export, auto_toc,
-- html_filename_parameterization, exclude_files, color_dic -- html_filename_parameterization, exclude_files, color_dic
-- listsyms, listsyms_propagate, list_margin, -- listsyms, listsym_rejected, listsyms_propagate, list_margin,
-- links_space_char -- links_space_char
wikis = nil, wikis = nil,