Skip to main content

buffr_ui/
lib.rs

1//! Browser chrome — Phase 3 statusline (the rest is deferred).
2//!
3//! See `docs/ui-stack.md` for the rendering decision: chrome lives in
4//! a `softbuffer` strip docked to the bottom of the buffr window, in
5//! the same `winit` window as the CEF child window above it.
6//!
7//! This crate stays free of `winit` and `softbuffer` types in its
8//! public API — callers (`apps/buffr`) own surface lifecycle and pass
9//! us a raw `&mut [u32]` slice each frame. That keeps the unit tests
10//! trivial (build a `Vec<u32>`, paint, assert pixels) and avoids
11//! coupling chrome rendering to any one window backend.
12
13use buffr_modal::PageMode;
14
15pub mod confirm_prompt;
16pub mod download_notice;
17pub mod font;
18pub mod input_bar;
19pub mod permissions_prompt;
20pub mod tab_strip;
21
22pub use confirm_prompt::{CONFIRM_PROMPT_HEIGHT, ConfirmPrompt, ConfirmRect, rect_contains};
23pub use download_notice::{DOWNLOAD_NOTICE_HEIGHT, DownloadNoticeKind, DownloadNoticeStrip};
24pub use input_bar::{
25    INPUT_HEIGHT, InputBar, MAX_SUGGESTIONS, Palette as InputPalette, SUGGESTION_ROW_HEIGHT,
26    Suggestion, SuggestionKind,
27};
28pub use permissions_prompt::{ACTION_HINT, PERMISSIONS_PROMPT_HEIGHT, PermissionsPrompt};
29pub use tab_strip::{
30    FAVICON_RENDER_SIZE, MAX_TAB_WIDTH, MIN_TAB_WIDTH, TAB_STRIP_HEIGHT, TabFavicon, TabStrip,
31    TabView,
32};
33
34/// Statusline strip height in pixels. 30 px fits a 14-px glyph row
35/// with comfortable padding above + below; matches the recommendation
36/// in `docs/ui-stack.md`. Bumping this requires the host window to
37/// re-layout the CEF child rect.
38pub const STATUSLINE_HEIGHT: u32 = 30;
39
40/// Public re-export so embedders can pull `Mode` from one place.
41pub use buffr_modal::Mode;
42
43/// Coarse certificate state for the URL display. Phase 3 wires only
44/// `Unknown`; CEF cert plumbing lands later.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum CertState {
47    Secure,
48    Insecure,
49    Unknown,
50}
51
52/// Find-in-page status. Mirrors what CEF's `OnFindResult` callback
53/// hands us, projected into a pair of `u32`s for the right-hand
54/// statusline cell.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct FindStatus {
57    pub query: String,
58    pub current: u32,
59    pub total: u32,
60}
61
62/// Update channel indicator surfaced in the right-hand statusline cell.
63/// Mirrors `buffr_core::UpdateStatus` projected onto two render modes
64/// (`* upd` for `Available`, `* upd?` for `Stale`). `None` hides the
65/// cell entirely; the chrome doesn't paint a placeholder.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum UpdateIndicator {
68    /// Newer release available — `* upd`.
69    Available,
70    /// Cache is older than `check_interval_hours` — `* upd?`.
71    Stale,
72}
73
74/// Snapshot of hint mode state. Rendered next to the cert pip when a
75/// hint session is active. Mirrors `buffr_core::host::HintStatus` —
76/// the indirection exists so `buffr-ui` doesn't pull `buffr-core` as a
77/// dependency (would create a cycle).
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct HintStatus {
80    pub typed: String,
81    pub match_count: u32,
82    pub background: bool,
83}
84
85/// Rendering input for the statusline. Re-create per frame; the
86/// widget owns nothing.
87#[derive(Debug, Clone)]
88pub struct Statusline {
89    pub mode: PageMode,
90    pub url: String,
91    /// Page load progress 0.0..=1.0. Drawn as a thin progress bar at
92    /// the very top edge of the strip. Phase 3 doesn't yet wire CEF's
93    /// `OnLoadingProgressChange` — keep at 1.0 until that lands.
94    pub progress: f32,
95    pub cert_state: CertState,
96    /// Pending count buffer from the engine. `Some(0)` is treated as
97    /// "no count"; the modal engine never emits a literal zero count
98    /// here.
99    pub count_buffer: Option<u32>,
100    pub private: bool,
101    pub find_query: Option<FindStatus>,
102    /// Hint mode indicator. `Some(...)` while a hint session is live.
103    pub hint_state: Option<HintStatus>,
104    /// Phase 6 update channel indicator. `Some(...)` flags the user
105    /// that an update is available (`* upd`) or that the cache is
106    /// stale (`* upd?`). Click/tap doesn't go anywhere yet — the user
107    /// runs `buffr --check-for-updates` manually.
108    pub update_indicator: Option<UpdateIndicator>,
109    /// Active tab's CEF zoom level. 0.0 is the page default; positive
110    /// values zoom in, negative out. Rendered as a percentage in the
111    /// statusline ("125%"). Hidden when at default.
112    pub zoom_level: f64,
113    /// Chrome colours. Set once on startup from `config.theme`; flip
114    /// to [`Palette::high_contrast`] when `theme.high_contrast = true`.
115    pub palette: Palette,
116}
117
118impl Default for Statusline {
119    fn default() -> Self {
120        Self {
121            mode: PageMode::Normal,
122            url: String::new(),
123            progress: 1.0,
124            cert_state: CertState::Unknown,
125            count_buffer: None,
126            private: false,
127            find_query: None,
128            hint_state: None,
129            update_indicator: None,
130            zoom_level: 0.0,
131            palette: Palette::default(),
132        }
133    }
134}
135
136impl Statusline {
137    /// Paint the statusline into the bottom `STATUSLINE_HEIGHT` rows
138    /// of `buffer`. `buffer` is the *full* window buffer (one `u32`
139    /// per pixel, row-major); we touch only the strip rows so the CEF
140    /// child window above is undisturbed.
141    ///
142    /// `width` and `height` are the full window's pixel dimensions.
143    /// If `height < STATUSLINE_HEIGHT` we draw nothing.
144    pub fn paint(&self, buffer: &mut [u32], width: usize, height: usize) {
145        let strip_h = STATUSLINE_HEIGHT as usize;
146        if width == 0 || height < strip_h {
147            return;
148        }
149        if buffer.len() < width * height {
150            return;
151        }
152
153        // Strip starts at this row.
154        let strip_y = height - strip_h;
155        let p = &self.palette;
156        let mode_bg = p.mode_bg(self.mode);
157        let mode_accent = p.mode_accent(self.mode);
158
159        // Background fill — only the strip rows. Tinted per-mode so
160        // the whole strip (not just the leftmost cell) carries the
161        // chromatic mode signal.
162        fill_rect(
163            buffer,
164            width,
165            height,
166            0,
167            strip_y as i32,
168            width,
169            strip_h,
170            mode_bg,
171        );
172
173        // Mode block — leftmost cell, accent-on-dark so the label
174        // pops against the rest of the strip.
175        let mode_text = mode_label(self.mode);
176        let mode_w = font::text_width(mode_text) + 12;
177        fill_rect(
178            buffer,
179            width,
180            height,
181            0,
182            strip_y as i32,
183            mode_w,
184            strip_h,
185            mode_accent,
186        );
187        let text_y = strip_y as i32 + ((strip_h as i32 - font::glyph_h() as i32) / 2);
188        font::draw_text(buffer, width, height, 6, text_y, mode_text, mode_bg);
189
190        // Right-side cell: count buffer, find status, update channel,
191        // and PRIVATE marker. Drawn right-to-left so each piece pads
192        // naturally.
193        let mut right_pen = width as i32 - 6;
194        if self.private {
195            let s = "PRIVATE";
196            let w = font::text_width(s) as i32;
197            right_pen -= w;
198            font::draw_text(buffer, width, height, right_pen, text_y, s, p.private);
199            right_pen -= 8;
200        }
201        if let Some(ind) = self.update_indicator {
202            let s = match ind {
203                UpdateIndicator::Available => "* upd",
204                UpdateIndicator::Stale => "* upd?",
205            };
206            let w = font::text_width(s) as i32;
207            right_pen -= w;
208            font::draw_text(buffer, width, height, right_pen, text_y, s, p.update);
209            right_pen -= 8;
210        }
211        if let Some(find) = self.find_query.as_ref() {
212            let s = format_find(find);
213            let w = font::text_width(&s) as i32;
214            right_pen -= w;
215            font::draw_text(buffer, width, height, right_pen, text_y, &s, p.fg);
216            right_pen -= 8;
217        }
218        if let Some(hint) = self.hint_state.as_ref() {
219            let s = format_hint(hint);
220            let w = font::text_width(&s) as i32;
221            right_pen -= w;
222            font::draw_text(buffer, width, height, right_pen, text_y, &s, p.fg);
223            right_pen -= 8;
224        }
225        if let Some(count) = self.count_buffer
226            && count > 0
227        {
228            let s = format!("{count}");
229            let w = font::text_width(&s) as i32;
230            right_pen -= w;
231            font::draw_text(buffer, width, height, right_pen, text_y, &s, p.fg);
232            right_pen -= 8;
233        }
234        // Zoom indicator. Hidden at default (0.0). CEF uses ~1.2^level
235        // so 1 step ≈ 120%, -1 ≈ 83%. Round to nearest percent.
236        if self.zoom_level.abs() > f64::EPSILON {
237            let pct = (1.2_f64.powf(self.zoom_level) * 100.0).round() as i64;
238            let s = format!("{pct}%");
239            let w = font::text_width(&s) as i32;
240            right_pen -= w;
241            font::draw_text(buffer, width, height, right_pen, text_y, &s, p.fg);
242            right_pen -= 8;
243        }
244
245        // URL middle cell. Truncate from the right if it would
246        // overflow into the right-hand cell. Uses the cert state
247        // colour as a left padlock byte.
248        let url_x = mode_w as i32 + 6;
249        let url_max_px = (right_pen - url_x).max(0) as usize;
250        let url_text = truncate_to_width(&self.url, url_max_px);
251        let cert_colour = match self.cert_state {
252            CertState::Secure => p.cert_secure,
253            CertState::Insecure => p.cert_insecure,
254            CertState::Unknown => p.fg,
255        };
256        // Cert pip — single 2x6 vertical bar at the URL's left edge.
257        fill_rect(
258            buffer,
259            width,
260            height,
261            url_x,
262            strip_y as i32 + 8,
263            2,
264            font::glyph_h(),
265            cert_colour,
266        );
267        font::draw_text(buffer, width, height, url_x + 6, text_y, url_text, p.fg);
268
269        // Progress bar — top 2 px of the strip. 0.0 = invisible,
270        // 1.0 = full width. Phase 3 will animate this off CEF's
271        // `OnLoadingProgressChange`.
272        let progress = self.progress.clamp(0.0, 1.0);
273        if progress > 0.0 && progress < 1.0 {
274            let bar_w = (width as f32 * progress) as usize;
275            fill_rect(
276                buffer,
277                width,
278                height,
279                0,
280                strip_y as i32,
281                bar_w,
282                2,
283                p.progress,
284            );
285        }
286    }
287}
288
289fn format_hint(h: &HintStatus) -> String {
290    let prefix = if h.background { "F" } else { "f" };
291    if h.typed.is_empty() {
292        format!("{prefix}: {} hints", h.match_count)
293    } else {
294        format!(
295            "{prefix}: {} ({}/{})",
296            h.typed,
297            h.match_count,
298            h.match_count.max(1)
299        )
300    }
301}
302
303fn format_find(f: &FindStatus) -> String {
304    if f.total == 0 {
305        format!("/{}: no matches", f.query)
306    } else {
307        format!("/{} {}/{}", f.query, f.current, f.total)
308    }
309}
310
311/// Truncate `s` to at most `max_px` pixels of rendered width. Adds a
312/// trailing `..` ellipsis when the original didn't fit.
313pub(crate) fn truncate_to_width(s: &str, max_px: usize) -> &str {
314    if font::text_width(s) <= max_px {
315        return s;
316    }
317    if max_px < font::text_width("..") {
318        return "";
319    }
320    // Walk backwards by char until the prefix + ".." fits.
321    let mut end = s.len();
322    while end > 0 {
323        if !s.is_char_boundary(end) {
324            end -= 1;
325            continue;
326        }
327        let prefix = &s[..end];
328        if font::text_width(prefix) + font::text_width("..") <= max_px {
329            return prefix;
330        }
331        end -= 1;
332    }
333    ""
334}
335
336#[allow(clippy::too_many_arguments)]
337pub(crate) fn fill_rect(
338    buffer: &mut [u32],
339    width: usize,
340    height: usize,
341    x: i32,
342    y: i32,
343    w: usize,
344    h: usize,
345    colour: u32,
346) {
347    let x0 = x.max(0) as usize;
348    let y0 = y.max(0) as usize;
349    let x1 = (x.saturating_add(w as i32)).max(0) as usize;
350    let y1 = (y.saturating_add(h as i32)).max(0) as usize;
351    let x1 = x1.min(width);
352    let y1 = y1.min(height);
353    if x0 >= x1 || y0 >= y1 {
354        return;
355    }
356    for row in y0..y1 {
357        let start = row * width + x0;
358        let end = row * width + x1;
359        if let Some(slice) = buffer.get_mut(start..end) {
360            for pixel in slice {
361                *pixel = colour;
362            }
363        }
364    }
365}
366
367/// Mode label rendered into the leftmost statusline cell. Matches the
368/// strings used by `apps/buffr` for the window title — keep these in
369/// sync.
370fn mode_label(mode: PageMode) -> &'static str {
371    match mode {
372        PageMode::Normal => "NORMAL",
373        PageMode::Visual => "VISUAL",
374        PageMode::Command => "COMMAND",
375        PageMode::Hint => "HINT",
376        PageMode::Insert => "INSERT",
377        PageMode::Pending => "PENDING",
378    }
379}
380
381// ---- colour table (BGRA packed, alpha = 0xFF) -----------------------
382//
383// Pixels are u32 with layout `0xFF_RR_GG_BB` on little-endian: the byte
384// sequence in memory is [B, G, R, A], matching `wgpu::TextureFormat::Bgra8Unorm`.
385// The alpha byte is 0xFF (fully opaque) so the GPU alpha-blend pass
386// composites chrome strips correctly over the OSR texture.
387//
388// Every chrome surface (statusline, tab strip, popup address bar) reads
389// from a single [`Palette`], built once on startup from `config.theme`
390// and re-derived when the theme reloads. Mode is communicated by the
391// label glyph in the leftmost cell, not by colour — all five modes
392// share the same accent so the chrome stays visually cohesive.
393
394/// Single source of truth for chrome colours. Built from a base
395/// accent plus a handful of semantic signals (cert state, private
396/// marker, update indicator, progress bar). The non-accent fields
397/// default to the historical fixed signals but are configurable via
398/// `config.theme.*`.
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400pub struct Palette {
401    /// Base accent. Drives the mode block, active-tab indicator,
402    /// and (darkened) the strip background.
403    pub accent: u32,
404    /// Strip / tab background — accent mixed heavily with black.
405    pub bg: u32,
406    /// Body text. Held at near-white for legibility regardless of
407    /// accent hue; only the high-contrast palette overrides this.
408    pub fg: u32,
409    /// Inactive-tab background — slightly lifted from `bg` so tabs
410    /// read as distinct without being visually loud.
411    pub bg_lifted: u32,
412    /// Inactive-tab foreground — dimmed `fg`.
413    pub fg_dim: u32,
414    /// Cert-state secure (lock icon, find counts).
415    pub cert_secure: u32,
416    /// Cert-state insecure.
417    pub cert_insecure: u32,
418    /// PRIVATE marker on the right-hand statusline cell.
419    pub private: u32,
420    /// Page-load progress bar.
421    pub progress: u32,
422    /// Update channel indicator (`* upd`).
423    pub update: u32,
424}
425
426impl Palette {
427    /// Derive a palette from a single base accent. `bg` is the accent
428    /// mixed 92% with black; `bg_lifted` is the accent mixed 80%;
429    /// `fg_dim` is `fg` mixed 35% with black. Semantic colours fall
430    /// back to fixed signal values — callers override via
431    /// [`Palette::with_signals`].
432    pub fn from_accent(accent: u32) -> Self {
433        Self {
434            accent,
435            bg: blend(accent, 0xFF_00_00_00, 0.92),
436            fg: 0xFF_EE_EE_EE,
437            bg_lifted: blend(accent, 0xFF_00_00_00, 0.85),
438            fg_dim: 0xFF_A0_A8_AC,
439            cert_secure: 0xFF_66_E0_8A,
440            cert_insecure: 0xFF_E0_5A_5A,
441            private: 0xFF_FF_C8_C8,
442            progress: 0xFF_66_C2_FF,
443            update: 0xFF_E0_C8_5A,
444        }
445    }
446
447    /// Override the semantic-signal colours. Used when wiring
448    /// `config.theme.{cert_secure,cert_insecure,private,progress,update}`.
449    pub fn with_signals(
450        mut self,
451        cert_secure: u32,
452        cert_insecure: u32,
453        private: u32,
454        progress: u32,
455        update: u32,
456    ) -> Self {
457        self.cert_secure = cert_secure;
458        self.cert_insecure = cert_insecure;
459        self.private = private;
460        self.progress = progress;
461        self.update = update;
462        self
463    }
464
465    /// Phase 6 high-contrast palette. Documented in `docs/accessibility.md`.
466    /// Pure white-on-black + saturated yellow accent that survives both
467    /// black and white backgrounds. Semantic signals collapse to white
468    /// so the chrome stays legible for low-vision users.
469    pub fn high_contrast() -> Self {
470        Self {
471            accent: 0xFF_FF_FF_00,
472            bg: 0xFF_00_00_00,
473            fg: 0xFF_FF_FF_FF,
474            bg_lifted: 0xFF_10_10_10,
475            fg_dim: 0xFF_C0_C0_C0,
476            cert_secure: 0xFF_FF_FF_FF,
477            cert_insecure: 0xFF_FF_FF_FF,
478            private: 0xFF_FF_FF_FF,
479            progress: 0xFF_FF_FF_FF,
480            update: 0xFF_FF_FF_FF,
481        }
482    }
483}
484
485impl Default for Palette {
486    fn default() -> Self {
487        // Match the page accent on `https://buffr.kryptic.sh/`.
488        Self::from_accent(0xFF_7A_A2_F7)
489    }
490}
491
492/// Linear blend between two BGRA pixels. `t = 0.0` returns `a`,
493/// `t = 1.0` returns `b`. Alpha is held at `0xFF`.
494pub(crate) fn blend(a: u32, b: u32, t: f32) -> u32 {
495    let extract = |c: u32, shift: u32| -> u8 { ((c >> shift) & 0xFF) as u8 };
496    let lerp = |x: u8, y: u8| -> u8 {
497        ((x as f32) * (1.0 - t) + (y as f32) * t)
498            .round()
499            .clamp(0.0, 255.0) as u8
500    };
501    let r = lerp(extract(a, 16), extract(b, 16));
502    let g = lerp(extract(a, 8), extract(b, 8));
503    let bb = lerp(extract(a, 0), extract(b, 0));
504    0xFF_00_00_00 | ((r as u32) << 16) | ((g as u32) << 8) | (bb as u32)
505}
506
507/// Hue-rotation per-mode offsets (degrees). Picked so the five modes
508/// land on visually distinct hues regardless of where the base accent
509/// sits on the colour wheel — Normal stays at the user's accent, the
510/// others fan out across roughly 360°.
511const HUE_NORMAL: f32 = 0.0;
512const HUE_INSERT: f32 = -40.0;
513const HUE_VISUAL: f32 = 180.0;
514const HUE_COMMAND: f32 = 80.0;
515const HUE_HINT: f32 = 240.0;
516
517fn mode_hue_offset(mode: PageMode) -> f32 {
518    match mode {
519        PageMode::Normal | PageMode::Pending => HUE_NORMAL,
520        PageMode::Insert => HUE_INSERT,
521        PageMode::Visual => HUE_VISUAL,
522        PageMode::Command => HUE_COMMAND,
523        PageMode::Hint => HUE_HINT,
524    }
525}
526
527impl Palette {
528    /// Per-mode accent — base accent hue-rotated so each mode lands on
529    /// a distinct colour. High-contrast palette returns its yellow
530    /// accent across all modes (mode is signaled by the label glyph).
531    pub fn mode_accent(&self, mode: PageMode) -> u32 {
532        if *self == Self::high_contrast() {
533            return self.accent;
534        }
535        rotate_hue(self.accent, mode_hue_offset(mode))
536    }
537
538    /// Per-mode strip background — mode accent darkened the same 92%
539    /// as the base `bg` so mode-tinted backgrounds stay subtle.
540    pub fn mode_bg(&self, mode: PageMode) -> u32 {
541        if *self == Self::high_contrast() {
542            return self.bg;
543        }
544        blend(self.mode_accent(mode), 0xFF_00_00_00, 0.92)
545    }
546}
547
548/// Rotate the hue of a BGRA pixel by `degrees`. Saturation and
549/// lightness are preserved, so the rotated colour reads as "the same
550/// vibe, different hue" — which is what we want for per-mode chrome.
551fn rotate_hue(c: u32, degrees: f32) -> u32 {
552    let r = ((c >> 16) & 0xFF) as f32 / 255.0;
553    let g = ((c >> 8) & 0xFF) as f32 / 255.0;
554    let b = (c & 0xFF) as f32 / 255.0;
555    let (h, s, l) = rgb_to_hsl(r, g, b);
556    let h2 = (h + degrees).rem_euclid(360.0);
557    let (r2, g2, b2) = hsl_to_rgb(h2, s, l);
558    let to_byte = |v: f32| (v * 255.0).round().clamp(0.0, 255.0) as u32;
559    0xFF_00_00_00 | (to_byte(r2) << 16) | (to_byte(g2) << 8) | to_byte(b2)
560}
561
562fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
563    let max = r.max(g).max(b);
564    let min = r.min(g).min(b);
565    let l = (max + min) / 2.0;
566    if (max - min).abs() < f32::EPSILON {
567        return (0.0, 0.0, l);
568    }
569    let d = max - min;
570    let s = if l > 0.5 {
571        d / (2.0 - max - min)
572    } else {
573        d / (max + min)
574    };
575    let h = if (max - r).abs() < f32::EPSILON {
576        ((g - b) / d) + if g < b { 6.0 } else { 0.0 }
577    } else if (max - g).abs() < f32::EPSILON {
578        (b - r) / d + 2.0
579    } else {
580        (r - g) / d + 4.0
581    };
582    (h * 60.0, s, l)
583}
584
585fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
586    if s.abs() < f32::EPSILON {
587        return (l, l, l);
588    }
589    let q = if l < 0.5 {
590        l * (1.0 + s)
591    } else {
592        l + s - l * s
593    };
594    let p = 2.0 * l - q;
595    let h = h / 360.0;
596    let hue = |t: f32| {
597        let t = t.rem_euclid(1.0);
598        if t < 1.0 / 6.0 {
599            p + (q - p) * 6.0 * t
600        } else if t < 0.5 {
601            q
602        } else if t < 2.0 / 3.0 {
603            p + (q - p) * (2.0 / 3.0 - t) * 6.0
604        } else {
605            p
606        }
607    };
608    (hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0))
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    fn make_buf(w: usize, h: usize) -> Vec<u32> {
616        vec![0u32; w * h]
617    }
618
619    #[test]
620    fn paint_fills_strip_row_with_mode_bg() {
621        let w = 200;
622        let h = STATUSLINE_HEIGHT as usize;
623        let mut buf = make_buf(w, h);
624        let s = Statusline {
625            url: "https://example.com".into(),
626            ..Statusline::default()
627        };
628        s.paint(&mut buf, w, h);
629        // The leftmost column of the strip is owned by the mode-accent
630        // cell — pixel (0,0) sits on the strip top row. Alpha is 0xFF (opaque).
631        assert_eq!(buf[0], Palette::default().mode_accent(PageMode::Normal));
632    }
633
634    #[test]
635    fn paint_strip_pixel_outside_mode_block_uses_strip_bg() {
636        let w = 400;
637        let h = STATUSLINE_HEIGHT as usize;
638        let mut buf = make_buf(w, h);
639        let s = Statusline {
640            url: "x".into(),
641            ..Statusline::default()
642        };
643        s.paint(&mut buf, w, h);
644        // Far-right column on the bottom row — past mode block, past
645        // URL text, almost certainly bg.
646        let idx = (h - 1) * w + (w - 1);
647        assert_eq!(buf[idx], Palette::default().mode_bg(PageMode::Normal));
648    }
649
650    #[test]
651    fn paint_skips_when_height_less_than_strip() {
652        let w = 100;
653        let h = 10;
654        let mut buf = make_buf(w, h);
655        let s = Statusline::default();
656        s.paint(&mut buf, w, h);
657        // Buffer untouched — sentinel zero.
658        assert!(buf.iter().all(|&p| p == 0));
659    }
660
661    #[test]
662    fn mode_accents_pairwise_distinct() {
663        // Hue rotation must produce a distinct accent for every mode
664        // pair — guards against future copy-paste regressions on the
665        // HUE_* offsets.
666        let p = Palette::default();
667        let modes = [
668            PageMode::Normal,
669            PageMode::Visual,
670            PageMode::Command,
671            PageMode::Hint,
672            PageMode::Insert,
673        ];
674        for (i, a) in modes.iter().enumerate() {
675            for b in &modes[i + 1..] {
676                assert_ne!(p.mode_accent(*a), p.mode_accent(*b), "{a:?} vs {b:?}");
677            }
678        }
679    }
680
681    #[test]
682    fn palette_from_accent_derives_dark_bg() {
683        // The strip bg is the accent darkened ~92%, so it must be
684        // strictly darker than the accent on every channel.
685        let p = Palette::from_accent(0xFF_7A_A2_F7);
686        let extract = |c: u32, shift: u32| (c >> shift) & 0xFF;
687        for shift in [0, 8, 16] {
688            assert!(
689                extract(p.bg, shift) < extract(p.accent, shift),
690                "bg channel {shift} not darker than accent"
691            );
692        }
693    }
694
695    #[test]
696    fn truncate_to_width_short_string_unchanged() {
697        let s = "hi";
698        let max = 1000;
699        assert_eq!(truncate_to_width(s, max), "hi");
700    }
701
702    #[test]
703    fn truncate_to_width_returns_empty_when_too_narrow() {
704        assert_eq!(truncate_to_width("abcd", 1), "");
705    }
706
707    #[test]
708    fn truncate_to_width_drops_chars_until_fit() {
709        // Width budget for "a" + ".." = 6 + 1 + (6+1+6) = 20 px.
710        let dotdot = font::text_width("..");
711        let one_a = font::text_width("a");
712        let budget = one_a + dotdot;
713        let s = "abcd";
714        let out = truncate_to_width(s, budget);
715        assert_eq!(out, "a");
716    }
717
718    #[test]
719    fn format_find_no_matches() {
720        let f = FindStatus {
721            query: "foo".into(),
722            current: 0,
723            total: 0,
724        };
725        assert_eq!(format_find(&f), "/foo: no matches");
726    }
727
728    #[test]
729    fn format_find_with_matches() {
730        let f = FindStatus {
731            query: "foo".into(),
732            current: 2,
733            total: 5,
734        };
735        assert_eq!(format_find(&f), "/foo 2/5");
736    }
737
738    #[test]
739    fn format_hint_no_typed() {
740        let h = HintStatus {
741            typed: String::new(),
742            match_count: 12,
743            background: false,
744        };
745        assert_eq!(format_hint(&h), "f: 12 hints");
746    }
747
748    #[test]
749    fn format_hint_with_typed_background() {
750        let h = HintStatus {
751            typed: "as".into(),
752            match_count: 3,
753            background: true,
754        };
755        assert!(format_hint(&h).starts_with("F:"));
756        assert!(format_hint(&h).contains("as"));
757    }
758
759    #[test]
760    fn high_contrast_uses_distinct_palette() {
761        let w = 400;
762        let h = STATUSLINE_HEIGHT as usize;
763        let mut buf_default = make_buf(w, h);
764        let mut buf_hc = make_buf(w, h);
765        let default_s = Statusline {
766            url: "https://x".into(),
767            ..Statusline::default()
768        };
769        let hc_s = Statusline {
770            url: "https://x".into(),
771            palette: Palette::high_contrast(),
772            ..Statusline::default()
773        };
774        default_s.paint(&mut buf_default, w, h);
775        hc_s.paint(&mut buf_hc, w, h);
776        // Far-right pixel on the bottom row should differ — the
777        // strip background paint sits there.
778        let idx = (h - 1) * w + (w - 1);
779        assert_ne!(buf_default[idx], buf_hc[idx]);
780        // High-contrast strip background must be pure black.
781        assert_eq!(buf_hc[idx], Palette::high_contrast().bg);
782    }
783
784    #[test]
785    fn high_contrast_palette_distinct_from_default_accent() {
786        let hc = Palette::high_contrast();
787        let dflt = Palette::default();
788        assert_ne!(hc.accent, dflt.accent);
789        assert_ne!(hc.bg, dflt.bg);
790    }
791
792    #[test]
793    fn update_indicator_renders_when_set() {
794        let w = 600;
795        let h = STATUSLINE_HEIGHT as usize;
796        let mut buf_off = make_buf(w, h);
797        let mut buf_on = make_buf(w, h);
798        let off_s = Statusline {
799            url: "x".into(),
800            ..Statusline::default()
801        };
802        let on_s = Statusline {
803            url: "x".into(),
804            update_indicator: Some(UpdateIndicator::Available),
805            ..Statusline::default()
806        };
807        off_s.paint(&mut buf_off, w, h);
808        on_s.paint(&mut buf_on, w, h);
809        assert_ne!(buf_off, buf_on);
810    }
811
812    #[test]
813    fn private_marker_renders_distinctly() {
814        let w = 400;
815        let h = STATUSLINE_HEIGHT as usize;
816        let mut buf_priv = make_buf(w, h);
817        let mut buf_norm = make_buf(w, h);
818        let priv_s = Statusline {
819            url: "https://x".into(),
820            private: true,
821            ..Statusline::default()
822        };
823        let norm_s = Statusline {
824            url: "https://x".into(),
825            private: false,
826            ..Statusline::default()
827        };
828        priv_s.paint(&mut buf_priv, w, h);
829        norm_s.paint(&mut buf_norm, w, h);
830        assert_ne!(buf_priv, buf_norm);
831    }
832}