Skip to main content

linesmith_core/segments/
context_bar.rs

1//! `context_bar` segment: visual bar for context-window fill.
2//!
3//! Renders a fixed-width Unicode block-character bar (e.g. `████▓░░░░░`)
4//! colored by fill threshold (green/yellow/red). Companion to the
5//! `context_window` segment, which renders the textual `42% · 200k`.
6//! Hidden when the payload doesn't carry context-window data.
7
8use std::collections::BTreeMap;
9
10use unicode_width::UnicodeWidthStr;
11
12use super::{RenderContext, RenderResult, RenderedSegment, Segment, SegmentDefaults};
13use crate::data_context::DataContext;
14use crate::theme::Role;
15
16/// Drops earlier than `rate_limit_5h` (96) and `context_window` (32):
17/// the bar is redundant with the textual percentage, so it should
18/// disappear before actionable rate-limit telemetry or the percentage
19/// itself.
20const PRIORITY: u8 = 112;
21
22const ID: &str = "context_bar";
23
24const DEFAULT_WIDTH: u16 = 10;
25const DEFAULT_GREEN_THRESHOLD: u8 = 50;
26const DEFAULT_YELLOW_THRESHOLD: u8 = 80;
27
28const DEFAULT_FULL: char = '█';
29const DEFAULT_PARTIAL: char = '▓';
30const DEFAULT_EMPTY: char = '░';
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub(crate) struct Config {
34    pub(crate) width: u16,
35    pub(crate) thresholds: Thresholds,
36    pub(crate) chars: BarChars,
37}
38
39/// Threshold percentages for the green→yellow→red color ramp.
40/// `pct < green` → `Role::Success`; `green <= pct < yellow` →
41/// `Role::Warning`; `pct >= yellow` → `Role::Error`. Construction
42/// goes through `Self::new` which enforces `green <= yellow`, so the
43/// ramp is monotonic by type-system guarantee — no caller can mint
44/// inverted thresholds, even from inside the crate.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub(crate) struct Thresholds {
47    green: u8,
48    yellow: u8,
49}
50
51impl Thresholds {
52    /// `None` when `green > yellow` or either bound exceeds 100.
53    pub(crate) fn new(green: u8, yellow: u8) -> Option<Self> {
54        if green <= yellow && yellow <= 100 {
55            Some(Self { green, yellow })
56        } else {
57            None
58        }
59    }
60
61    pub(crate) fn green(self) -> u8 {
62        self.green
63    }
64
65    pub(crate) fn yellow(self) -> u8 {
66        self.yellow
67    }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub(crate) struct BarChars {
72    pub(crate) full: String,
73    pub(crate) partial: String,
74    pub(crate) empty: String,
75}
76
77impl Default for Config {
78    fn default() -> Self {
79        Self {
80            width: DEFAULT_WIDTH,
81            thresholds: Thresholds::new(DEFAULT_GREEN_THRESHOLD, DEFAULT_YELLOW_THRESHOLD)
82                .expect("DEFAULT_GREEN_THRESHOLD <= DEFAULT_YELLOW_THRESHOLD by construction"),
83            chars: BarChars {
84                full: DEFAULT_FULL.to_string(),
85                partial: DEFAULT_PARTIAL.to_string(),
86                empty: DEFAULT_EMPTY.to_string(),
87            },
88        }
89    }
90}
91
92#[derive(Default)]
93pub struct ContextBarSegment {
94    pub(crate) cfg: Config,
95}
96
97impl ContextBarSegment {
98    /// Parse the `[segments.context_bar]` extras bag. Unknown values
99    /// warn and fall back to defaults. Thresholds are parsed as a pair
100    /// and validated together — supplying a monotonic pair like
101    /// `green = 90, yellow = 95` is accepted regardless of declaration
102    /// order, and the whole pair is rejected (with the bad fields
103    /// warned individually) if any field is out of range or the pair
104    /// inverts the ramp.
105    pub fn from_extras(
106        extras: &BTreeMap<String, toml::Value>,
107        warn: &mut impl FnMut(&str),
108    ) -> Self {
109        let mut cfg = Config::default();
110
111        if let Some(v) = extras.get("cells") {
112            match v.as_integer().and_then(|n| u16::try_from(n).ok()) {
113                Some(n) if n >= 1 => cfg.width = n,
114                _ => warn(&format!(
115                    "segments.{ID}.cells: expected 1..=65535; ignoring"
116                )),
117            }
118        }
119
120        if let Some(t) = extras.get("thresholds").and_then(|v| v.as_table()) {
121            // Parse both fields against the table first, then validate
122            // as a pair. Per-field-against-just-applied is order-
123            // dependent and silently rejects valid monotonic pairs that
124            // raise both above the defaults (e.g. green=90 yellow=95
125            // would reject green against the default yellow=80 even
126            // though the user-supplied yellow=95 makes the pair fine).
127            let parse_field = |field: &str, warn: &mut dyn FnMut(&str)| -> Option<u8> {
128                let v = t.get(field)?;
129                match v.as_integer().and_then(|n| u8::try_from(n).ok()) {
130                    Some(n) if n <= 100 => Some(n),
131                    _ => {
132                        warn(&format!(
133                            "segments.{ID}.thresholds.{field}: expected 0..=100; ignoring"
134                        ));
135                        None
136                    }
137                }
138            };
139            let green = parse_field("green", &mut |m| warn(m));
140            let yellow = parse_field("yellow", &mut |m| warn(m));
141            let candidate = (
142                green.unwrap_or(cfg.thresholds.green()),
143                yellow.unwrap_or(cfg.thresholds.yellow()),
144            );
145            match Thresholds::new(candidate.0, candidate.1) {
146                Some(t) => cfg.thresholds = t,
147                None => warn(&format!(
148                    "segments.{ID}.thresholds: green ({}) must be <= yellow ({}); ignoring both",
149                    candidate.0, candidate.1
150                )),
151            }
152        }
153
154        if let Some(c) = extras.get("characters").and_then(|v| v.as_table()) {
155            for (field, slot) in [
156                ("full", &mut cfg.chars.full),
157                ("partial", &mut cfg.chars.partial),
158                ("empty", &mut cfg.chars.empty),
159            ] {
160                let Some(v) = c.get(field) else { continue };
161                // `width(s) == 1` (not `<= 1`) is intentional: ZWJ
162                // sequences and combining marks render as 1 cell on
163                // most terminals but `unicode-width` reports 0 because
164                // they have no advance width on their own. Accepting
165                // them here would let the bar desync from `cfg.width`
166                // on terminals that treat them differently.
167                match v.as_str() {
168                    Some(s) if UnicodeWidthStr::width(s) == 1 => *slot = s.to_string(),
169                    Some(s) => warn(&format!(
170                        "segments.{ID}.characters.{field}: expected a single-cell string, got {} cell(s); ignoring",
171                        UnicodeWidthStr::width(s)
172                    )),
173                    None => warn(&format!(
174                        "segments.{ID}.characters.{field}: expected string; ignoring"
175                    )),
176                }
177            }
178        }
179
180        Self { cfg }
181    }
182}
183
184impl Segment for ContextBarSegment {
185    fn render(&self, ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
186        let Some(cw) = ctx.status.context_window.as_ref() else {
187            crate::lsm_debug!("context_bar: status.context_window absent; hiding");
188            return Ok(None);
189        };
190        // Per ADR-0014, `used` is per-leaf Option: the bar can't
191        // render without a percentage, so hide when null (mirrors the
192        // text `context_window` segment).
193        let Some(used) = cw.used else {
194            crate::lsm_debug!("context_bar: used null; hiding");
195            return Ok(None);
196        };
197        // Round-ties-to-even to match the textual `context_window`
198        // segment's `{pct:.0}` formatting, which uses Rust's
199        // round-half-to-even (banker's). Plain `f32::round` rounds
200        // half-away-from-zero, which would diverge at exact halves
201        // (e.g. 50.5 → 51 vs format! → "50") and break the contract
202        // that text and bar agree at every boundary.
203        let pct = used.value().round_ties_even();
204        let bar = render_bar(pct, &self.cfg);
205        let role = role_for_pct(pct, self.cfg.thresholds);
206        Ok(Some(RenderedSegment::new(bar).with_role(role)))
207    }
208
209    fn defaults(&self) -> SegmentDefaults {
210        SegmentDefaults::with_priority(PRIORITY)
211    }
212}
213
214fn role_for_pct(pct: f32, t: Thresholds) -> Role {
215    if pct < f32::from(t.green()) {
216        Role::Success
217    } else if pct < f32::from(t.yellow()) {
218        Role::Warning
219    } else {
220        Role::Error
221    }
222}
223
224fn render_bar(pct: f32, cfg: &Config) -> String {
225    let width = usize::from(cfg.width);
226    let cells_filled = (f32::from(cfg.width) * pct / 100.0).clamp(0.0, f32::from(cfg.width));
227    let full = cells_filled.floor() as usize;
228    let frac = cells_filled - cells_filled.floor();
229    let partial = if full < width && frac >= 0.5 { 1 } else { 0 };
230    let empty = width.saturating_sub(full).saturating_sub(partial);
231
232    let mut out = String::with_capacity(
233        full * cfg.chars.full.len()
234            + partial * cfg.chars.partial.len()
235            + empty * cfg.chars.empty.len(),
236    );
237    for _ in 0..full {
238        out.push_str(&cfg.chars.full);
239    }
240    for _ in 0..partial {
241        out.push_str(&cfg.chars.partial);
242    }
243    for _ in 0..empty {
244        out.push_str(&cfg.chars.empty);
245    }
246    out
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::input::{ContextWindow, ModelInfo, Percent, StatusContext, Tool, WorkspaceInfo};
253    use std::path::PathBuf;
254    use std::sync::Arc;
255
256    fn rc() -> RenderContext {
257        RenderContext::new(80)
258    }
259
260    fn ctx(window: Option<ContextWindow>) -> DataContext {
261        DataContext::new(StatusContext {
262            tool: Tool::ClaudeCode,
263            model: Some(ModelInfo {
264                display_name: "X".into(),
265            }),
266            workspace: Some(WorkspaceInfo {
267                project_dir: PathBuf::from("/repo"),
268                git_worktree: None,
269            }),
270            context_window: window,
271            cost: None,
272            effort: None,
273            vim: None,
274            output_style: None,
275            agent_name: None,
276            version: None,
277            raw: Arc::new(serde_json::Value::Null),
278        })
279    }
280
281    fn window(used: f32, size: u32) -> ContextWindow {
282        ContextWindow {
283            used: Some(Percent::new(used).expect("in range")),
284            size: Some(size),
285            total_input_tokens: Some(0),
286            total_output_tokens: Some(0),
287            current_usage: None,
288        }
289    }
290
291    #[test]
292    fn renders_zero_percent_as_all_empty() {
293        let r = ContextBarSegment::default()
294            .render(&ctx(Some(window(0.0, 200_000))), &rc())
295            .unwrap()
296            .expect("rendered");
297        assert_eq!(r.text(), "░░░░░░░░░░");
298        assert_eq!(r.style().role, Some(Role::Success));
299    }
300
301    #[test]
302    fn renders_full_at_one_hundred() {
303        let r = ContextBarSegment::default()
304            .render(&ctx(Some(window(100.0, 200_000))), &rc())
305            .unwrap()
306            .expect("rendered");
307        assert_eq!(r.text(), "██████████");
308        assert_eq!(r.style().role, Some(Role::Error));
309    }
310
311    #[test]
312    fn renders_partial_block_when_fraction_geq_half() {
313        // 45% of 10 cells = 4.5 → 4 full + 1 partial + 5 empty
314        let r = ContextBarSegment::default()
315            .render(&ctx(Some(window(45.0, 200_000))), &rc())
316            .unwrap()
317            .expect("rendered");
318        assert_eq!(r.text(), "████▓░░░░░");
319    }
320
321    #[test]
322    fn rounds_down_when_fraction_lt_half() {
323        // 42% of 10 cells = 4.2 → 4 full + 0 partial + 6 empty
324        let r = ContextBarSegment::default()
325            .render(&ctx(Some(window(42.0, 200_000))), &rc())
326            .unwrap()
327            .expect("rendered");
328        assert_eq!(r.text(), "████░░░░░░");
329    }
330
331    #[test]
332    fn renders_fifty_percent_at_threshold_boundary_yellow() {
333        // pct >= green (50) → Warning; 50% of 10 = 5.0 → 5 full + 5 empty.
334        let r = ContextBarSegment::default()
335            .render(&ctx(Some(window(50.0, 200_000))), &rc())
336            .unwrap()
337            .expect("rendered");
338        assert_eq!(r.text(), "█████░░░░░");
339        assert_eq!(r.style().role, Some(Role::Warning));
340    }
341
342    #[test]
343    fn red_threshold_at_eighty_percent() {
344        // pct >= yellow (80) → Error.
345        let r = ContextBarSegment::default()
346            .render(&ctx(Some(window(80.0, 200_000))), &rc())
347            .unwrap()
348            .expect("rendered");
349        assert_eq!(r.style().role, Some(Role::Error));
350    }
351
352    #[test]
353    fn green_at_one_below_threshold() {
354        let r = ContextBarSegment::default()
355            .render(&ctx(Some(window(49.0, 200_000))), &rc())
356            .unwrap()
357            .expect("rendered");
358        assert_eq!(r.style().role, Some(Role::Success));
359    }
360
361    #[test]
362    fn yellow_at_one_below_red_threshold() {
363        let r = ContextBarSegment::default()
364            .render(&ctx(Some(window(79.0, 200_000))), &rc())
365            .unwrap()
366            .expect("rendered");
367        assert_eq!(r.style().role, Some(Role::Warning));
368    }
369
370    #[test]
371    fn hidden_when_context_window_absent() {
372        assert_eq!(
373            ContextBarSegment::default()
374                .render(&ctx(None), &rc())
375                .unwrap(),
376            None
377        );
378    }
379
380    #[test]
381    fn defaults_use_expected_priority() {
382        assert_eq!(ContextBarSegment::default().defaults().priority, PRIORITY);
383    }
384
385    #[test]
386    fn rendered_width_matches_configured_cells_for_default_chars() {
387        // Default chars are all single-cell, so cell-width == bar-width.
388        let r = ContextBarSegment::default()
389            .render(&ctx(Some(window(45.0, 200_000))), &rc())
390            .unwrap()
391            .expect("rendered");
392        assert_eq!(r.width(), 10);
393    }
394
395    #[test]
396    fn from_extras_sets_width() {
397        let extras = BTreeMap::from([("cells".to_string(), toml::Value::Integer(5))]);
398        let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
399        assert_eq!(seg.cfg.width, 5);
400    }
401
402    #[test]
403    fn from_extras_warns_on_zero_width() {
404        let extras = BTreeMap::from([("cells".to_string(), toml::Value::Integer(0))]);
405        let mut warnings = vec![];
406        let seg = ContextBarSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
407        assert_eq!(seg.cfg.width, DEFAULT_WIDTH);
408        assert!(warnings.iter().any(|w| w.contains("cells")));
409    }
410
411    #[test]
412    fn from_extras_warns_on_negative_width() {
413        let extras = BTreeMap::from([("cells".to_string(), toml::Value::Integer(-1))]);
414        let mut warnings = vec![];
415        let seg = ContextBarSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
416        assert_eq!(seg.cfg.width, DEFAULT_WIDTH);
417        assert!(warnings.iter().any(|w| w.contains("cells")));
418    }
419
420    #[test]
421    fn from_extras_reads_thresholds_table() {
422        let mut t = toml::value::Table::new();
423        t.insert("green".to_string(), toml::Value::Integer(30));
424        t.insert("yellow".to_string(), toml::Value::Integer(70));
425        let extras = BTreeMap::from([("thresholds".to_string(), toml::Value::Table(t))]);
426        let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
427        assert_eq!(seg.cfg.thresholds.green(), 30);
428        assert_eq!(seg.cfg.thresholds.yellow(), 70);
429    }
430
431    #[test]
432    fn from_extras_accepts_high_pair_above_defaults() {
433        // green=90 yellow=95 is a monotonic pair. The parser must read
434        // both fields before validating, otherwise green would be
435        // checked against the default yellow=80 and silently dropped.
436        let mut t = toml::value::Table::new();
437        t.insert("green".to_string(), toml::Value::Integer(90));
438        t.insert("yellow".to_string(), toml::Value::Integer(95));
439        let extras = BTreeMap::from([("thresholds".to_string(), toml::Value::Table(t))]);
440        let mut warnings = vec![];
441        let seg = ContextBarSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
442        assert_eq!(seg.cfg.thresholds.green(), 90);
443        assert_eq!(seg.cfg.thresholds.yellow(), 95);
444        assert!(
445            warnings.is_empty(),
446            "no warnings expected; got {warnings:?}"
447        );
448    }
449
450    #[test]
451    fn from_extras_accepts_low_pair_below_defaults() {
452        // Symmetric to high-pair: green=10 yellow=20 inverts against
453        // the default green=50, would be wrongly rejected without
454        // pair-based parsing.
455        let mut t = toml::value::Table::new();
456        t.insert("green".to_string(), toml::Value::Integer(10));
457        t.insert("yellow".to_string(), toml::Value::Integer(20));
458        let extras = BTreeMap::from([("thresholds".to_string(), toml::Value::Table(t))]);
459        let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
460        assert_eq!(seg.cfg.thresholds.green(), 10);
461        assert_eq!(seg.cfg.thresholds.yellow(), 20);
462    }
463
464    #[test]
465    fn from_extras_rejects_inverted_pair_and_keeps_defaults() {
466        // green=80 yellow=50 inverts the ramp. Both fields revert to
467        // defaults; one warning describes the pair failure.
468        let mut t = toml::value::Table::new();
469        t.insert("green".to_string(), toml::Value::Integer(80));
470        t.insert("yellow".to_string(), toml::Value::Integer(50));
471        let extras = BTreeMap::from([("thresholds".to_string(), toml::Value::Table(t))]);
472        let mut warnings = vec![];
473        let seg = ContextBarSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
474        assert_eq!(seg.cfg.thresholds.green(), DEFAULT_GREEN_THRESHOLD);
475        assert_eq!(seg.cfg.thresholds.yellow(), DEFAULT_YELLOW_THRESHOLD);
476        assert!(warnings
477            .iter()
478            .any(|w| w.contains("must be <=") && w.contains("ignoring both")));
479    }
480
481    #[test]
482    fn from_extras_rejects_lone_green_against_default_yellow() {
483        // Only green is set, and 90 > default yellow=80. Pair (90, 80)
484        // is invalid; both revert to defaults.
485        let mut t = toml::value::Table::new();
486        t.insert("green".to_string(), toml::Value::Integer(90));
487        let extras = BTreeMap::from([("thresholds".to_string(), toml::Value::Table(t))]);
488        let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
489        assert_eq!(seg.cfg.thresholds.green(), DEFAULT_GREEN_THRESHOLD);
490        assert_eq!(seg.cfg.thresholds.yellow(), DEFAULT_YELLOW_THRESHOLD);
491    }
492
493    #[test]
494    fn from_extras_warns_when_threshold_out_of_range() {
495        // Bad-typed/out-of-range field is warned individually; the
496        // remaining good field plus the unchanged default still form
497        // a valid pair, so the rest of the config survives.
498        let mut t = toml::value::Table::new();
499        t.insert("green".to_string(), toml::Value::Integer(150));
500        let extras = BTreeMap::from([("thresholds".to_string(), toml::Value::Table(t))]);
501        let mut warnings = vec![];
502        let seg = ContextBarSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
503        assert_eq!(seg.cfg.thresholds.green(), DEFAULT_GREEN_THRESHOLD);
504        assert_eq!(seg.cfg.thresholds.yellow(), DEFAULT_YELLOW_THRESHOLD);
505        assert!(warnings.iter().any(|w| w.contains("green")));
506    }
507
508    #[test]
509    fn thresholds_new_rejects_inverted() {
510        assert!(Thresholds::new(80, 50).is_none());
511        assert!(Thresholds::new(50, 80).is_some());
512        assert!(Thresholds::new(50, 50).is_some());
513        assert!(Thresholds::new(0, 100).is_some());
514        assert!(Thresholds::new(0, 101).is_none());
515    }
516
517    #[test]
518    fn from_extras_reads_characters_table() {
519        let mut c = toml::value::Table::new();
520        c.insert("full".to_string(), toml::Value::String("#".to_string()));
521        c.insert("partial".to_string(), toml::Value::String("=".to_string()));
522        c.insert("empty".to_string(), toml::Value::String("-".to_string()));
523        let extras = BTreeMap::from([("characters".to_string(), toml::Value::Table(c))]);
524        let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
525        let r = seg
526            .render(&ctx(Some(window(45.0, 200_000))), &rc())
527            .unwrap()
528            .expect("rendered");
529        assert_eq!(r.text(), "####=-----");
530    }
531
532    #[test]
533    fn custom_width_changes_bar_length() {
534        let extras = BTreeMap::from([("cells".to_string(), toml::Value::Integer(5))]);
535        let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
536        let r = seg
537            .render(&ctx(Some(window(40.0, 200_000))), &rc())
538            .unwrap()
539            .expect("rendered");
540        // 40% of 5 = 2.0 → 2 full + 3 empty.
541        assert_eq!(r.text(), "██░░░");
542    }
543
544    #[test]
545    fn pct_is_rounded_before_threshold_so_text_and_bar_agree() {
546        // 49.9 rounds to 50 → Warning. Without rounding, `pct < 50`
547        // would keep the bar green while the textual segment renders
548        // "50%" — the two segments must agree at every boundary.
549        let r = ContextBarSegment::default()
550            .render(&ctx(Some(window(49.9, 200_000))), &rc())
551            .unwrap()
552            .expect("rendered");
553        assert_eq!(r.style().role, Some(Role::Warning));
554    }
555
556    #[test]
557    fn pct_is_rounded_so_high_fractional_paints_red_with_full_bar() {
558        // 99.9 → rounds to 100; bar fully filled, role Error.
559        let r = ContextBarSegment::default()
560            .render(&ctx(Some(window(99.9, 200_000))), &rc())
561            .unwrap()
562            .expect("rendered");
563        assert_eq!(r.text(), "██████████");
564        assert_eq!(r.style().role, Some(Role::Error));
565    }
566
567    #[test]
568    fn frac_above_half_renders_partial_distinct_from_round() {
569        // 47% of 10 cells = 4.7 → floor=4 + partial=1 = `████▓░░░░░`.
570        // If render_bar regressed to `round()` instead of `floor()`,
571        // the same input would produce 5 full blocks and no partial:
572        // `█████░░░░░`. The exact-string assertion catches that.
573        let r = ContextBarSegment::default()
574            .render(&ctx(Some(window(47.0, 200_000))), &rc())
575            .unwrap()
576            .expect("rendered");
577        assert_eq!(r.text(), "████▓░░░░░");
578    }
579
580    #[test]
581    fn pct_round_ties_to_even_matches_format_rounding() {
582        // 50.5 rounds to 50 under banker's (matches `format!("{:.0}",
583        // 50.5_f32) == "50"`); plain `f32::round` would give 51 and
584        // diverge from the textual `context_window` segment.
585        let r = ContextBarSegment::default()
586            .render(&ctx(Some(window(50.5, 200_000))), &rc())
587            .unwrap()
588            .expect("rendered");
589        // 50% → Warning band (50 = green threshold), and 5/10 cells
590        // filled exactly (frac=0).
591        assert_eq!(r.text(), "█████░░░░░");
592        assert_eq!(r.style().role, Some(Role::Warning));
593    }
594
595    #[test]
596    fn cells_one_below_half_renders_empty_with_color_role() {
597        // cells=1 + pct=30 → 0.3 cells filled → 0 full + 0 partial =
598        // `░`. The bar is visually empty but the color role still
599        // reflects fill (Success here). Pinning so a future change
600        // that "fixes" empty bars by hiding them doesn't break the
601        // contract that color is always set on a rendered bar.
602        let extras = BTreeMap::from([("cells".to_string(), toml::Value::Integer(1))]);
603        let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
604        let r = seg
605            .render(&ctx(Some(window(30.0, 200_000))), &rc())
606            .unwrap()
607            .expect("rendered");
608        assert_eq!(r.text(), "░");
609        assert_eq!(r.style().role, Some(Role::Success));
610    }
611
612    #[test]
613    fn frac_just_below_half_drops_partial_block() {
614        // After rounding, integer pct only: 44 -> 4.4 cells -> 4 full + 0
615        // partial. Locks the `>= 0.5` rule against a regression to `> 0.5`.
616        let r = ContextBarSegment::default()
617            .render(&ctx(Some(window(44.0, 200_000))), &rc())
618            .unwrap()
619            .expect("rendered");
620        assert_eq!(r.text(), "████░░░░░░");
621    }
622
623    #[test]
624    fn from_extras_warns_on_non_string_character() {
625        let mut c = toml::value::Table::new();
626        c.insert("full".to_string(), toml::Value::Integer(42));
627        let extras = BTreeMap::from([("characters".to_string(), toml::Value::Table(c))]);
628        let mut warnings = vec![];
629        let seg = ContextBarSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
630        assert_eq!(seg.cfg.chars.full, DEFAULT_FULL.to_string());
631        assert!(warnings
632            .iter()
633            .any(|w| w.contains("full") && w.contains("string")));
634    }
635
636    #[test]
637    fn from_extras_rejects_multi_cell_glyph() {
638        // Wide glyphs would desync RenderedSegment::width() from cfg.width.
639        let mut c = toml::value::Table::new();
640        c.insert("full".to_string(), toml::Value::String("漢".to_string()));
641        let extras = BTreeMap::from([("characters".to_string(), toml::Value::Table(c))]);
642        let mut warnings = vec![];
643        let seg = ContextBarSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
644        assert_eq!(seg.cfg.chars.full, DEFAULT_FULL.to_string());
645        assert!(warnings.iter().any(|w| w.contains("single-cell")));
646    }
647
648    #[test]
649    fn from_extras_partial_characters_override_leaves_others_default() {
650        let mut c = toml::value::Table::new();
651        c.insert("full".to_string(), toml::Value::String("#".to_string()));
652        let extras = BTreeMap::from([("characters".to_string(), toml::Value::Table(c))]);
653        let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
654        assert_eq!(seg.cfg.chars.full, "#");
655        assert_eq!(seg.cfg.chars.partial, DEFAULT_PARTIAL.to_string());
656        assert_eq!(seg.cfg.chars.empty, DEFAULT_EMPTY.to_string());
657    }
658
659    #[test]
660    fn priority_drops_before_context_window() {
661        // Higher number drops first; the bar must drop before the textual
662        // segment so users keep the canonical health metric under pressure.
663        let bar_pri = ContextBarSegment::default().defaults().priority;
664        let window_pri = super::super::context_window::ContextWindowSegment
665            .defaults()
666            .priority;
667        assert!(bar_pri > window_pri);
668    }
669
670    #[test]
671    fn one_cell_width_renders_single_char() {
672        let extras = BTreeMap::from([("cells".to_string(), toml::Value::Integer(1))]);
673        let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
674        let empty = seg
675            .render(&ctx(Some(window(0.0, 200_000))), &rc())
676            .unwrap()
677            .expect("rendered");
678        assert_eq!(empty.text(), "░");
679        let full = seg
680            .render(&ctx(Some(window(100.0, 200_000))), &rc())
681            .unwrap()
682            .expect("rendered");
683        assert_eq!(full.text(), "█");
684    }
685}