Skip to main content

quadraui/
types.rs

1//! Shared primitive types used across widgets: colours, icons, styled text,
2//! widget identifiers, modifier flags, and tree paths.
3//!
4//! Design invariants (from `docs/UI_CRATE_DESIGN.md` §10, plugin-driven UI):
5//! - Every type is owned (no `&'static` or borrowed data in public API).
6//! - Every type is `Serialize + Deserialize` so Lua plugins can produce them
7//!   via JSON → Rust struct conversion.
8//! - No Rust closures cross the Rust/plugin boundary; events are plain data.
9
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::fmt;
13use std::sync::{OnceLock, RwLock};
14
15/// RGBA colour, 0-255 each channel.
16///
17/// Matches the shape of `vimcode::Color` conceptually. Adapter code in apps
18/// (e.g. vimcode) maps app-specific colour types to this.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub struct Color {
21    pub r: u8,
22    pub g: u8,
23    pub b: u8,
24    pub a: u8,
25}
26
27impl Color {
28    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
29        Self { r, g, b, a: 255 }
30    }
31
32    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
33        Self { r, g, b, a }
34    }
35
36    /// Parse `"#rrggbb"` or `"#rrggbbaa"`. Returns `None` on malformed input.
37    pub fn from_hex(s: &str) -> Option<Self> {
38        let s = s.strip_prefix('#')?;
39        // Every byte must be an ASCII hex digit before we index into `s` by
40        // byte offset below — this also guarantees `s` is all-ASCII, so
41        // `s.len()` (bytes) matches char count and every `&s[i..j]` slice
42        // below lands on a char boundary. A non-ASCII (or otherwise
43        // non-hex) input like "#\u{20ac}abc" is rejected here instead of
44        // panicking on the slice.
45        if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
46            return None;
47        }
48        let (r, g, b, a) = match s.len() {
49            6 => (
50                u8::from_str_radix(&s[0..2], 16).ok()?,
51                u8::from_str_radix(&s[2..4], 16).ok()?,
52                u8::from_str_radix(&s[4..6], 16).ok()?,
53                255,
54            ),
55            8 => (
56                u8::from_str_radix(&s[0..2], 16).ok()?,
57                u8::from_str_radix(&s[2..4], 16).ok()?,
58                u8::from_str_radix(&s[4..6], 16).ok()?,
59                u8::from_str_radix(&s[6..8], 16).ok()?,
60            ),
61            _ => return None,
62        };
63        Some(Self { r, g, b, a })
64    }
65
66    /// Parse `"#rrggbbaa"` and alpha-composite it over `bg`, discarding the
67    /// alpha channel in the result (`a` is always `255`). Falls back to
68    /// [`Self::from_hex`] — including its 6-digit-only, no-alpha shape —
69    /// when `s` carries no alpha channel, so this is a strict superset of
70    /// `from_hex` for callers that always have a background to composite
71    /// against. Returns `None` on malformed input, same rules as
72    /// `from_hex`.
73    ///
74    /// Lifted from vimcode's `render::Color::try_from_hex_over`,
75    /// where it exists because VS Code theme JSON commonly expresses
76    /// translucent overlay colours (`editor.lineHighlightBackground`,
77    /// diff backgrounds, bracket-match highlights) as `#rrggbbaa` meant
78    /// to be composited over the editor background — a rasteriser that
79    /// just took the raw (translucent) colour at face value would ignore
80    /// the intended blend.
81    pub fn try_from_hex_over(s: &str, bg: Self) -> Option<Self> {
82        let stripped = s.strip_prefix('#')?;
83        if stripped.len() != 8 {
84            return Self::from_hex(s);
85        }
86        // Same ASCII-hex-digit guard as `from_hex`, for the same reason:
87        // guarantees byte offsets below land on char boundaries.
88        if !stripped.bytes().all(|b| b.is_ascii_hexdigit()) {
89            return None;
90        }
91        let r = u8::from_str_radix(&stripped[0..2], 16).ok()?;
92        let g = u8::from_str_radix(&stripped[2..4], 16).ok()?;
93        let b = u8::from_str_radix(&stripped[4..6], 16).ok()?;
94        let a = u8::from_str_radix(&stripped[6..8], 16).ok()?;
95        let alpha = a as f64 / 255.0;
96        let blend = |fg: u8, bg: u8| -> u8 {
97            (fg as f64 * alpha + bg as f64 * (1.0 - alpha)).round() as u8
98        };
99        Some(Self {
100            r: blend(r, bg.r),
101            g: blend(g, bg.g),
102            b: blend(b, bg.b),
103            a: 255,
104        })
105    }
106
107    /// Linearly interpolate each channel toward white by `amount`
108    /// (clamped to `[0.0, 1.0]`). Used by primitives that derive a
109    /// hover/highlight tint from a base background colour.
110    pub fn lighten(self, amount: f64) -> Self {
111        let f = amount.clamp(0.0, 1.0);
112        Self {
113            r: (self.r as f64 + (255.0 - self.r as f64) * f) as u8,
114            g: (self.g as f64 + (255.0 - self.g as f64) * f) as u8,
115            b: (self.b as f64 + (255.0 - self.b as f64) * f) as u8,
116            a: self.a,
117        }
118    }
119
120    /// Linearly interpolate each channel toward black by `amount`
121    /// (clamped to `[0.0, 1.0]`). Symmetric counterpart of [`Self::lighten`].
122    pub fn darken(self, amount: f64) -> Self {
123        let f = amount.clamp(0.0, 1.0);
124        Self {
125            r: (self.r as f64 * (1.0 - f)) as u8,
126            g: (self.g as f64 * (1.0 - f)) as u8,
127            b: (self.b as f64 * (1.0 - f)) as u8,
128            a: self.a,
129        }
130    }
131
132    /// Return this colour with the alpha channel scaled to `alpha`
133    /// (clamped to `[0.0, 1.0]`) on the `0..=255` byte scale, leaving
134    /// `r`/`g`/`b` untouched.
135    pub fn with_alpha(self, alpha: f64) -> Self {
136        Self {
137            a: (255.0 * alpha.clamp(0.0, 1.0)).round() as u8,
138            ..self
139        }
140    }
141
142    /// Alpha-blend `over` on top of `self` at `alpha` (clamped to
143    /// `[0.0, 1.0]`), discarding both inputs' own alpha channels — the
144    /// result is always fully opaque (`a: 255`).
145    ///
146    /// Used by rasterisers on backends whose fill/line primitives don't
147    /// honour `Color::a`, as a CPU-side alpha pre-mix. Consolidates the
148    /// byte-identical `win::text::blend` and
149    /// `primitives::chart::native_surface_paint::blend` free functions
150    pub fn blend(self, over: Self, alpha: f64) -> Self {
151        let alpha = alpha.clamp(0.0, 1.0);
152        let mix =
153            |b: u8, o: u8| -> u8 { (b as f64 * (1.0 - alpha) + o as f64 * alpha).round() as u8 };
154        Self::rgb(
155            mix(self.r, over.r),
156            mix(self.g, over.g),
157            mix(self.b, over.b),
158        )
159    }
160}
161
162/// A contiguous run of text sharing a single style.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct StyledSpan {
165    pub text: String,
166    pub fg: Option<Color>,
167    pub bg: Option<Color>,
168    #[serde(default)]
169    pub bold: bool,
170    #[serde(default)]
171    pub italic: bool,
172    #[serde(default)]
173    pub underline: bool,
174}
175
176impl StyledSpan {
177    pub fn plain(text: impl Into<String>) -> Self {
178        Self {
179            text: text.into(),
180            fg: None,
181            bg: None,
182            bold: false,
183            italic: false,
184            underline: false,
185        }
186    }
187
188    pub fn with_fg(text: impl Into<String>, fg: Color) -> Self {
189        Self {
190            text: text.into(),
191            fg: Some(fg),
192            bg: None,
193            bold: false,
194            italic: false,
195            underline: false,
196        }
197    }
198}
199
200/// A sequence of styled spans rendered inline on one line.
201#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
202pub struct StyledText {
203    pub spans: Vec<StyledSpan>,
204}
205
206impl StyledText {
207    pub fn plain(text: impl Into<String>) -> Self {
208        Self {
209            spans: vec![StyledSpan::plain(text)],
210        }
211    }
212
213    pub fn colored(text: impl Into<String>, fg: Color) -> Self {
214        Self {
215            spans: vec![StyledSpan::with_fg(text, fg)],
216        }
217    }
218
219    /// Terminal display width of the concatenated span text, in cells.
220    ///
221    /// Sums each span's [`crate::text_util::display_width`] — CJK and
222    /// wide-emoji glyphs count as 2, combining marks count as 0, so this
223    /// is not the same as `chars().count()`.
224    pub fn visible_width(&self) -> usize {
225        self.spans
226            .iter()
227            .map(|s| crate::text_util::display_width(&s.text))
228            .sum()
229    }
230}
231
232/// Icon reference. Each app supplies both the glyph (for icon fonts) and
233/// an ASCII/Unicode fallback (for TUI or when the icon font is unavailable).
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct Icon {
236    /// Nerd Font / icon-font glyph string.
237    pub glyph: String,
238    /// ASCII or basic-Unicode fallback used when `glyph` cannot render.
239    pub fallback: String,
240    /// Glyph colour. `None` paints the icon in the surrounding row's
241    /// default foreground. `Some` paints both the Nerd Font glyph and
242    /// its ASCII fallback in this colour — honoured by tree renderers
243    /// (see [`crate::TreeStyle::chevron_expanded`]). The TUI chrome-glyph
244    /// override registries
245    /// ([`crate::primitives::context_menu::ContextMenuGlyphs`],
246    /// [`crate::primitives::data_table::DataTableGlyphs`],
247    /// [`crate::primitives::tab_bar::TabBarGlyphs`]) resolve an `Icon`
248    /// via [`Self::resolve`] but do not paint `color`: their glyph cells
249    /// always use the surrounding theme foreground.
250    #[serde(default)]
251    pub color: Option<Color>,
252}
253
254impl Icon {
255    pub fn new(glyph: impl Into<String>, fallback: impl Into<String>) -> Self {
256        Self {
257            glyph: glyph.into(),
258            fallback: fallback.into(),
259            color: None,
260        }
261    }
262
263    /// Attach a glyph colour. Painted by tree renderers in place of
264    /// the row's default foreground; see the `color` field docs.
265    pub fn with_color(mut self, color: Color) -> Self {
266        self.color = Some(color);
267        self
268    }
269
270    /// The Nerd Font glyph when `nerd_fonts_enabled` is set, otherwise
271    /// the ASCII/Unicode fallback. Centralises the
272    /// `if nerd_fonts_enabled { &self.glyph } else { &self.fallback }`
273    /// choice every rasteriser otherwise repeats inline; see
274    /// [`crate::Backend::nerd_fonts_enabled`].
275    #[must_use]
276    pub fn resolve(&self, nerd_fonts_enabled: bool) -> &str {
277        if nerd_fonts_enabled {
278            &self.glyph
279        } else {
280            &self.fallback
281        }
282    }
283}
284
285/// Collapse a bare string into an [`Icon`] with `glyph == fallback` —
286/// the common case when the glyph and plain-text fallback are the same.
287impl From<&str> for Icon {
288    fn from(s: &str) -> Self {
289        Self::new(s, s)
290    }
291}
292
293/// See [`impl From<&str> for Icon`] — same collapse, owned-`String` input.
294impl From<String> for Icon {
295    fn from(s: String) -> Self {
296        Self::new(s.clone(), s)
297    }
298}
299
300/// Process-global string interner backing [`WidgetId`].
301///
302/// A widget's label is looked up once per distinct string and then shared:
303/// every `WidgetId::new("same-label")` call after the first returns a
304/// `Copy` handle into this table instead of allocating again. Interned
305/// strings are leaked (never freed) for the process lifetime, which is the
306/// standard trade-off for this pattern — apps generate widget IDs from a
307/// bounded set of labels (one per widget, maybe with a handful of runtime
308/// suffixes), not an unbounded one per frame.
309struct WidgetIdTable {
310    strings: Vec<&'static str>,
311    index: HashMap<&'static str, u32>,
312}
313
314impl WidgetIdTable {
315    fn new() -> Self {
316        Self {
317            strings: Vec::new(),
318            index: HashMap::new(),
319        }
320    }
321}
322
323impl Default for WidgetIdTable {
324    fn default() -> Self {
325        Self::new()
326    }
327}
328
329fn widget_id_table() -> &'static RwLock<WidgetIdTable> {
330    static TABLE: OnceLock<RwLock<WidgetIdTable>> = OnceLock::new();
331    TABLE.get_or_init(|| RwLock::new(WidgetIdTable::new()))
332}
333
334fn intern_widget_id(label: impl Into<String>) -> u32 {
335    let label = label.into();
336    {
337        let table = widget_id_table()
338            .read()
339            .unwrap_or_else(|poisoned| poisoned.into_inner());
340        if let Some(&idx) = table.index.get(label.as_str()) {
341            return idx;
342        }
343    }
344    let mut table = widget_id_table()
345        .write()
346        .unwrap_or_else(|poisoned| poisoned.into_inner());
347    // Re-check: another thread may have interned the same label while we
348    // were waiting for the write lock.
349    if let Some(&idx) = table.index.get(label.as_str()) {
350        return idx;
351    }
352    let leaked: &'static str = Box::leak(label.into_boxed_str());
353    let idx = table.strings.len() as u32;
354    table.strings.push(leaked);
355    table.index.insert(leaked, idx);
356    idx
357}
358
359fn resolve_widget_id(idx: u32) -> &'static str {
360    widget_id_table()
361        .read()
362        .unwrap_or_else(|poisoned| poisoned.into_inner())
363        .strings[idx as usize]
364}
365
366/// Stable identifier for a widget across frames.
367///
368/// `Copy` and cheap to pass around: the label lives once in a process-global
369/// interning table (see [`WidgetIdTable`]) and a `WidgetId` is just an index
370/// into it. Construct one from any string — `WidgetId::new("my-id")` or
371/// `"my-id".into()` — and read the label back with [`WidgetId::as_str`].
372/// Apps should namespace labels (e.g. `"plugin:my-ext:main-form"`) to avoid
373/// collisions with core widget IDs.
374///
375/// Serializes and deserializes as its plain string label — the wire form is
376/// unchanged by interning, so JSON/plugin boundaries that produce or consume
377/// a `WidgetId` keep working unmodified.
378#[derive(Clone, Copy, PartialEq, Eq, Hash)]
379pub struct WidgetId(u32);
380
381impl WidgetId {
382    pub fn new(id: impl Into<String>) -> Self {
383        Self(intern_widget_id(id.into()))
384    }
385
386    /// The label this handle was interned from.
387    pub fn as_str(&self) -> &'static str {
388        resolve_widget_id(self.0)
389    }
390}
391
392impl fmt::Debug for WidgetId {
393    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394        f.debug_tuple("WidgetId").field(&self.as_str()).finish()
395    }
396}
397
398impl fmt::Display for WidgetId {
399    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400        f.write_str(self.as_str())
401    }
402}
403
404impl Serialize for WidgetId {
405    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
406    where
407        S: serde::Serializer,
408    {
409        serializer.serialize_str(self.as_str())
410    }
411}
412
413impl<'de> Deserialize<'de> for WidgetId {
414    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
415    where
416        D: serde::Deserializer<'de>,
417    {
418        String::deserialize(deserializer).map(WidgetId::new)
419    }
420}
421
422impl From<&str> for WidgetId {
423    fn from(s: &str) -> Self {
424        Self::new(s)
425    }
426}
427
428impl From<String> for WidgetId {
429    fn from(s: String) -> Self {
430        Self::new(s)
431    }
432}
433
434/// Keyboard modifier state carried alongside input events.
435#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
436pub struct Modifiers {
437    #[serde(default)]
438    pub shift: bool,
439    #[serde(default)]
440    pub ctrl: bool,
441    #[serde(default)]
442    pub alt: bool,
443    #[serde(default)]
444    pub cmd: bool,
445}
446
447/// Path to a row in a tree-structured widget.
448///
449/// `vec![]` is the root; `vec![3, 1]` is the second child of the fourth
450/// top-level row. Indices are `u16` to fit comfortably in small fixed-size
451/// arrays while still accommodating trees with thousands of siblings.
452pub type TreePath = Vec<u16>;
453
454/// Selection behaviour for row-oriented widgets (`TreeView`, `ListView`,
455/// `DataTable`).
456#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
457pub enum SelectionMode {
458    /// Rows cannot be selected.
459    None,
460    /// At most one row is selected at a time.
461    #[default]
462    Single,
463    /// Multiple rows may be selected (shift/ctrl click).
464    Multi,
465}
466
467/// How a click or keyboard-extension input should combine with the current
468/// multi-selection, derived from the modifier keys held at the time.
469///
470/// Row-oriented widgets (`ListView`, `TreeView`, `DataTable`) stay purely
471/// declarative: they do not own selection state. The backend/app layer
472/// tracks the selected-index set and an "anchor" (the index of the last
473/// non-extending selection), calls [`SelectionGesture::from_modifiers`] on
474/// the incoming click/key `Modifiers`, then feeds both into
475/// [`apply_selection_gesture`] to compute the next selection set.
476#[derive(Debug, Clone, Copy, PartialEq, Eq)]
477pub enum SelectionGesture {
478    /// Plain click / move: the new selection is just the target index.
479    Replace,
480    /// Ctrl (or Cmd on macOS) held: flip the target index's membership,
481    /// leaving every other selected index untouched.
482    Toggle,
483    /// Shift held: select the contiguous range between the anchor and the
484    /// target index (inclusive), replacing any prior selection.
485    Range,
486}
487
488impl SelectionGesture {
489    /// Derives the gesture a click or keyboard-extension input should use
490    /// from its modifier state. Shift takes priority over Ctrl/Cmd when
491    /// both are held: the result is always [`SelectionGesture::Range`], the
492    /// same as Shift alone. This matches Shift-only and Ctrl/Cmd-only
493    /// conventions on every major desktop platform; the combined
494    /// Ctrl/Cmd+Shift chord has no single cross-platform convention (some
495    /// apps use it for an *additive* range that preserves the existing
496    /// selection, which this function does not model), so treat that case
497    /// as an intentional simplification rather than a claim of parity with
498    /// any specific app.
499    pub fn from_modifiers(modifiers: Modifiers) -> Self {
500        if modifiers.shift {
501            SelectionGesture::Range
502        } else if modifiers.ctrl || modifiers.cmd {
503            SelectionGesture::Toggle
504        } else {
505            SelectionGesture::Replace
506        }
507    }
508}
509
510/// Computes the next multi-selection set for a row-oriented widget.
511///
512/// `current` is the selection before this input, `anchor` is the index the
513/// current range gesture should extend from (typically the last `Replace`
514/// or `Toggle` target), and `index` is the row the click/key targeted.
515///
516/// The result is always sorted and de-duplicated so callers can compare it
517/// directly or render it without extra bookkeeping.
518pub fn apply_selection_gesture(
519    current: &[usize],
520    anchor: usize,
521    index: usize,
522    gesture: SelectionGesture,
523) -> Vec<usize> {
524    let mut next: Vec<usize> = match gesture {
525        SelectionGesture::Replace => vec![index],
526        SelectionGesture::Toggle => {
527            let mut set = current.to_vec();
528            if let Some(pos) = set.iter().position(|&i| i == index) {
529                set.remove(pos);
530            } else {
531                set.push(index);
532            }
533            set
534        }
535        SelectionGesture::Range => {
536            let (lo, hi) = if anchor <= index {
537                (anchor, index)
538            } else {
539                (index, anchor)
540            };
541            (lo..=hi).collect()
542        }
543    };
544    next.sort_unstable();
545    next.dedup();
546    next
547}
548
549/// Optional visual decoration applied to a row beyond its text colour.
550///
551/// Backends map each variant to an appropriate visual treatment. For row
552/// widgets like `TreeView`, `Header` typically gets a distinct background
553/// (section-header styling); `Muted` dims the foreground; `Error` /
554/// `Warning` override the foreground with theme error/warning colours;
555/// `Modified` implies italic but otherwise normal colouring.
556#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
557pub enum Decoration {
558    /// Rendered with default foreground and background.
559    #[default]
560    Normal,
561    /// Section-header styling: distinct (typically darker or accent) background,
562    /// often with bold text. Use for rows that group others rather than
563    /// represent a leaf entity. Row tree-hierarchy status (`is_expanded`
564    /// Some vs. None) is orthogonal: not all branches are headers, and a
565    /// leaf row can be a header.
566    Header,
567    /// Dimmed (e.g. gitignored file, stale git log entry).
568    Muted,
569    /// Red / error-toned (e.g. lint error, merge conflict).
570    Error,
571    /// Amber / warning-toned (e.g. unsaved changes badge).
572    Warning,
573    /// Italicised, typically unchanged foreground colour.
574    Modified,
575}
576
577/// Right-aligned status indicator on a row: short text plus optional
578/// foreground/background colouring. Used by `TreeView` / `ListView` /
579/// `DataTable` for git-status letters, item counts, conflict markers, etc.
580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
581pub struct Badge {
582    pub text: String,
583    pub fg: Option<Color>,
584    pub bg: Option<Color>,
585}
586
587impl Badge {
588    pub fn plain(text: impl Into<String>) -> Self {
589        Self {
590            text: text.into(),
591            fg: None,
592            bg: None,
593        }
594    }
595
596    pub fn colored(text: impl Into<String>, fg: Color) -> Self {
597        Self {
598            text: text.into(),
599            fg: Some(fg),
600            bg: None,
601        }
602    }
603}
604
605/// Visual style configuration for a `TreeView`.
606#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
607pub struct TreeStyle {
608    /// Cells / pixels per visual indent level.
609    pub indent: u16,
610    /// Draw expand/collapse chevrons on branches.
611    pub show_chevrons: bool,
612    /// Chevron drawn for an expanded branch. Painted verbatim on TUI.
613    /// On GTK / macOS / Win-GUI, the *default* value (`"▾"`) is instead
614    /// painted as a sharper codicon glyph
615    /// (`crate::codicon::CHEVRON_DOWN`); any non-default override an
616    /// app sets here is still honoured and painted verbatim on every
617    /// backend, GUI included.
618    pub chevron_expanded: String,
619    /// Chevron drawn for a collapsed branch. Painted verbatim on TUI.
620    /// On GTK / macOS / Win-GUI, the *default* value (`"▸"`) is instead
621    /// painted as a sharper codicon glyph
622    /// (`crate::codicon::CHEVRON_RIGHT`); any non-default override an
623    /// app sets here is still honoured and painted verbatim on every
624    /// backend, GUI included.
625    pub chevron_collapsed: String,
626    /// Host override for non-header row height, in the backend's native
627    /// unit (pixels for GTK / macOS / Win-GUI). `Some(22)` by default,
628    /// matching VS Code's tree row pitch, independent of editor font size.
629    /// Header rows (`Decoration::Header`) are unaffected; they stay pitched
630    /// off `line_height`. Set to `None` to derive the pitch from
631    /// `line_height` instead (`line_height * 1.4` for leaves and
632    /// branches).
633    ///
634    /// **TUI ignores this field** — a cell grid has no literal pixels, so
635    /// rows there stay a fixed 1 cell regardless of this override.
636    ///
637    /// Integral because both GUI rasterisers `.round()` the row pitch
638    /// to whole native units before painting.
639    ///
640    /// Both `gtk_tree_layout` / `mac_tree_layout` (hit-test) and
641    /// `draw_tree` (paint) read this same field, so paint and hit-test
642    /// stay in sync by construction.
643    #[serde(default = "TreeStyle::default_row_height")]
644    pub row_height: Option<u16>,
645}
646
647impl TreeStyle {
648    fn default_row_height() -> Option<u16> {
649        Some(22)
650    }
651}
652
653impl Default for TreeStyle {
654    fn default() -> Self {
655        Self {
656            indent: 2,
657            show_chevrons: true,
658            chevron_expanded: "▾".into(),
659            chevron_collapsed: "▸".into(),
660            row_height: Self::default_row_height(),
661        }
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    #[test]
670    fn with_alpha_scales_a_leaves_rgb_untouched() {
671        let c = Color::rgb(10, 20, 30).with_alpha(0.5);
672        assert_eq!((c.r, c.g, c.b, c.a), (10, 20, 30, 128));
673    }
674
675    #[test]
676    fn with_alpha_clamps_out_of_range_input() {
677        assert_eq!(Color::rgb(1, 2, 3).with_alpha(-1.0).a, 0);
678        assert_eq!(Color::rgb(1, 2, 3).with_alpha(2.0).a, 255);
679    }
680
681    #[test]
682    fn blend_zero_alpha_is_base_one_alpha_is_over() {
683        let base = Color::rgb(0, 0, 0);
684        let over = Color::rgb(200, 100, 50);
685        assert_eq!(base.blend(over, 0.0), Color::rgb(0, 0, 0));
686        assert_eq!(base.blend(over, 1.0), Color::rgb(200, 100, 50));
687    }
688
689    #[test]
690    fn blend_midpoint_averages_channels_and_is_opaque() {
691        let base = Color::rgba(0, 0, 0, 10);
692        let over = Color::rgba(200, 100, 50, 20);
693        let mixed = base.blend(over, 0.5);
694        assert_eq!((mixed.r, mixed.g, mixed.b, mixed.a), (100, 50, 25, 255));
695    }
696
697    #[test]
698    fn from_hex_parses_rgb() {
699        assert_eq!(
700            Color::from_hex("#ff0080"),
701            Some(Color::rgb(0xff, 0x00, 0x80))
702        );
703    }
704
705    #[test]
706    fn from_hex_parses_rgba() {
707        assert_eq!(
708            Color::from_hex("#ff008040"),
709            Some(Color::rgba(0xff, 0x00, 0x80, 0x40))
710        );
711    }
712
713    #[test]
714    fn from_hex_rejects_missing_prefix() {
715        assert_eq!(Color::from_hex("ff0080"), None);
716    }
717
718    #[test]
719    fn from_hex_rejects_wrong_length() {
720        assert_eq!(Color::from_hex("#ff008"), None);
721    }
722
723    #[test]
724    fn from_hex_rejects_non_hex_ascii() {
725        assert_eq!(Color::from_hex("#zzzzzz"), None);
726    }
727
728    /// A multibyte character inside the hex digits used to make `s.len()`
729    /// (byte length) satisfy the `6`/`8` length check while slicing
730    /// mid-character, panicking instead of returning `None`.
731    #[test]
732    fn from_hex_multibyte_char_returns_none_instead_of_panicking() {
733        // "\u{20ac}abc" ('€' + "abc") is 3 + 3 = 6 bytes, matching the RGB
734        // length branch by byte count alone.
735        assert_eq!(Color::from_hex("#\u{20ac}abc"), None);
736    }
737
738    #[test]
739    fn visible_width_ascii_matches_char_count() {
740        assert_eq!(StyledText::plain("hello").visible_width(), 5);
741        assert_eq!(StyledText::plain("").visible_width(), 0);
742    }
743
744    /// CJK/emoji glyphs occupy two terminal cells each, so `visible_width`
745    /// must account for double-width characters, not just count `chars()`.
746    #[test]
747    fn visible_width_cjk_counts_double_width() {
748        let text = StyledText::plain("日本語");
749        assert_eq!(text.visible_width(), 6);
750        assert_ne!(text.visible_width(), text.spans[0].text.chars().count());
751    }
752
753    #[test]
754    fn visible_width_sums_across_spans() {
755        let text = StyledText {
756            spans: vec![
757                StyledSpan::plain("中"),
758                StyledSpan::plain("a"),
759                StyledSpan::plain("文"),
760            ],
761        };
762        // "中" (2) + "a" (1) + "文" (2) = 5, vs. a char count of 3.
763        assert_eq!(text.visible_width(), 5);
764    }
765
766    #[test]
767    fn visible_width_combining_mark_counts_zero() {
768        // "e" + U+0301 COMBINING ACUTE ACCENT renders as one visual
769        // column even though it is two `char`s.
770        let text = StyledText::plain("e\u{0301}");
771        assert_eq!(text.visible_width(), 1);
772    }
773
774    #[test]
775    fn color_from_hex_rgb() {
776        let c = Color::from_hex("#1a2b3c").unwrap();
777        assert_eq!(c, Color::rgb(0x1a, 0x2b, 0x3c));
778    }
779
780    #[test]
781    fn color_from_hex_rgba() {
782        let c = Color::from_hex("#1a2b3c80").unwrap();
783        assert_eq!(c, Color::rgba(0x1a, 0x2b, 0x3c, 0x80));
784    }
785
786    #[test]
787    fn color_from_hex_invalid() {
788        assert!(Color::from_hex("not a hex").is_none());
789        assert!(Color::from_hex("#xyz").is_none());
790        assert!(Color::from_hex("#12345").is_none()); // wrong length
791    }
792
793    #[test]
794    fn try_from_hex_over_blends_alpha_against_background() {
795        let bg = Color::rgb(0, 0, 0);
796        // Full alpha (`ff`) over black should equal the opaque colour.
797        let c = Color::try_from_hex_over("#ff008040", bg).unwrap();
798        // alpha byte 0x40 = 64/255 ≈ 0.251
799        let alpha = 0x40 as f64 / 255.0;
800        let blend = |fg: u8| -> u8 { (fg as f64 * alpha).round() as u8 };
801        assert_eq!(c, Color::rgba(blend(0xff), blend(0x00), blend(0x80), 255));
802    }
803
804    #[test]
805    fn try_from_hex_over_full_alpha_ignores_background() {
806        let bg = Color::rgb(10, 20, 30);
807        let c = Color::try_from_hex_over("#ff0080ff", bg).unwrap();
808        assert_eq!(c, Color::rgba(0xff, 0x00, 0x80, 255));
809    }
810
811    #[test]
812    fn try_from_hex_over_zero_alpha_equals_background() {
813        let bg = Color::rgb(10, 20, 30);
814        let c = Color::try_from_hex_over("#ff008000", bg).unwrap();
815        assert_eq!(c, Color::rgba(10, 20, 30, 255));
816    }
817
818    #[test]
819    fn try_from_hex_over_falls_back_to_from_hex_without_alpha() {
820        let bg = Color::rgb(10, 20, 30);
821        assert_eq!(
822            Color::try_from_hex_over("#1a2b3c", bg),
823            Color::from_hex("#1a2b3c")
824        );
825    }
826
827    #[test]
828    fn try_from_hex_over_rejects_malformed_input() {
829        let bg = Color::rgb(0, 0, 0);
830        assert_eq!(Color::try_from_hex_over("not a hex", bg), None);
831        assert_eq!(Color::try_from_hex_over("#zzzzzzzz", bg), None);
832        assert_eq!(Color::try_from_hex_over("#12345", bg), None);
833    }
834
835    #[test]
836    fn widget_id_roundtrip() {
837        let id = WidgetId::new("sc-tree");
838        assert_eq!(id.as_str(), "sc-tree");
839        let id2: WidgetId = "sc-tree".into();
840        assert_eq!(id, id2);
841    }
842
843    #[test]
844    fn widget_id_is_copy_not_just_clone() {
845        // `WidgetId` derives `Copy`; this compiles only because passing
846        // `id` by value to the first `as_str()` call doesn't move it out
847        // from under the second.
848        let id = WidgetId::new("copy-check");
849        assert_eq!(id.as_str(), "copy-check");
850        assert_eq!(id.as_str(), "copy-check");
851    }
852
853    #[test]
854    fn widget_id_serializes_as_plain_string() {
855        let id = WidgetId::new("wire-format-check");
856        let json = serde_json::to_string(&id).unwrap();
857        assert_eq!(json, "\"wire-format-check\"");
858        let back: WidgetId = serde_json::from_str(&json).unwrap();
859        assert_eq!(back, id);
860    }
861
862    #[test]
863    fn widget_id_interns_equal_labels_to_equal_handles() {
864        // Two handles built from the same label are `==` and share the
865        // same resolved label, whether or not they came from the same
866        // `intern_widget_id` call — the interning table is process-global.
867        let a = WidgetId::new("shared-label");
868        let b = WidgetId::new("shared-label".to_string());
869        assert_eq!(a, b);
870        assert_eq!(a.as_str(), b.as_str());
871    }
872
873    #[test]
874    fn styled_text_visible_width() {
875        let text = StyledText {
876            spans: vec![
877                StyledSpan::plain("hello "),
878                StyledSpan::with_fg("world", Color::rgb(255, 0, 0)),
879            ],
880        };
881        assert_eq!(text.visible_width(), 11);
882    }
883
884    #[test]
885    fn selection_gesture_from_modifiers_prefers_shift_over_ctrl() {
886        let mut m = Modifiers::default();
887        assert_eq!(
888            SelectionGesture::from_modifiers(m),
889            SelectionGesture::Replace
890        );
891        m.ctrl = true;
892        assert_eq!(
893            SelectionGesture::from_modifiers(m),
894            SelectionGesture::Toggle
895        );
896        m.shift = true;
897        assert_eq!(SelectionGesture::from_modifiers(m), SelectionGesture::Range);
898        let cmd_only = Modifiers {
899            cmd: true,
900            ..Default::default()
901        };
902        assert_eq!(
903            SelectionGesture::from_modifiers(cmd_only),
904            SelectionGesture::Toggle
905        );
906    }
907
908    #[test]
909    fn apply_selection_gesture_replace_ignores_current_selection() {
910        let next = apply_selection_gesture(&[1, 2, 3], 1, 5, SelectionGesture::Replace);
911        assert_eq!(next, vec![5]);
912    }
913
914    #[test]
915    fn apply_selection_gesture_toggle_adds_and_removes() {
916        let added = apply_selection_gesture(&[1, 3], 1, 2, SelectionGesture::Toggle);
917        assert_eq!(added, vec![1, 2, 3]);
918
919        let removed = apply_selection_gesture(&[1, 2, 3], 1, 2, SelectionGesture::Toggle);
920        assert_eq!(removed, vec![1, 3]);
921    }
922
923    #[test]
924    fn apply_selection_gesture_range_spans_anchor_to_index_inclusive() {
925        let forward = apply_selection_gesture(&[], 2, 5, SelectionGesture::Range);
926        assert_eq!(forward, vec![2, 3, 4, 5]);
927
928        let backward = apply_selection_gesture(&[9], 5, 2, SelectionGesture::Range);
929        assert_eq!(backward, vec![2, 3, 4, 5]);
930    }
931
932    #[test]
933    fn apply_selection_gesture_result_is_sorted_and_deduped() {
934        let next = apply_selection_gesture(&[5, 5, 1], 1, 1, SelectionGesture::Toggle);
935        // Toggling an index already present (even if duplicated in input)
936        // removes every occurrence and leaves the rest sorted.
937        assert_eq!(next, vec![5]);
938    }
939}