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