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::{MAX_TAB_WIDTH, MIN_TAB_WIDTH, TAB_STRIP_HEIGHT, TabStrip, TabView};
30
31/// Statusline strip height in pixels. 30 px fits a 14-px glyph row
32/// with comfortable padding above + below; matches the recommendation
33/// in `docs/ui-stack.md`. Bumping this requires the host window to
34/// re-layout the CEF child rect.
35pub const STATUSLINE_HEIGHT: u32 = 30;
36
37/// Public re-export so embedders can pull `Mode` from one place.
38pub use buffr_modal::Mode;
39
40/// Coarse certificate state for the URL display. Phase 3 wires only
41/// `Unknown`; CEF cert plumbing lands later.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum CertState {
44    Secure,
45    Insecure,
46    Unknown,
47}
48
49/// Find-in-page status. Mirrors what CEF's `OnFindResult` callback
50/// hands us, projected into a pair of `u32`s for the right-hand
51/// statusline cell.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct FindStatus {
54    pub query: String,
55    pub current: u32,
56    pub total: u32,
57}
58
59/// Update channel indicator surfaced in the right-hand statusline cell.
60/// Mirrors `buffr_core::UpdateStatus` projected onto two render modes
61/// (`* upd` for `Available`, `* upd?` for `Stale`). `None` hides the
62/// cell entirely; the chrome doesn't paint a placeholder.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum UpdateIndicator {
65    /// Newer release available — `* upd`.
66    Available,
67    /// Cache is older than `check_interval_hours` — `* upd?`.
68    Stale,
69}
70
71/// Snapshot of hint mode state. Rendered next to the cert pip when a
72/// hint session is active. Mirrors `buffr_core::host::HintStatus` —
73/// the indirection exists so `buffr-ui` doesn't pull `buffr-core` as a
74/// dependency (would create a cycle).
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct HintStatus {
77    pub typed: String,
78    pub match_count: u32,
79    pub background: bool,
80}
81
82/// Rendering input for the statusline. Re-create per frame; the
83/// widget owns nothing.
84#[derive(Debug, Clone)]
85pub struct Statusline {
86    pub mode: PageMode,
87    pub url: String,
88    /// Page load progress 0.0..=1.0. Drawn as a thin progress bar at
89    /// the very top edge of the strip. Phase 3 doesn't yet wire CEF's
90    /// `OnLoadingProgressChange` — keep at 1.0 until that lands.
91    pub progress: f32,
92    pub cert_state: CertState,
93    /// Pending count buffer from the engine. `Some(0)` is treated as
94    /// "no count"; the modal engine never emits a literal zero count
95    /// here.
96    pub count_buffer: Option<u32>,
97    pub private: bool,
98    pub find_query: Option<FindStatus>,
99    /// Hint mode indicator. `Some(...)` while a hint session is live.
100    pub hint_state: Option<HintStatus>,
101    /// Phase 6 update channel indicator. `Some(...)` flags the user
102    /// that an update is available (`* upd`) or that the cache is
103    /// stale (`* upd?`). Click/tap doesn't go anywhere yet — the user
104    /// runs `buffr --check-for-updates` manually.
105    pub update_indicator: Option<UpdateIndicator>,
106    /// Phase 6 a11y: when `true`, the strip uses the high-contrast
107    /// palette (white on black) instead of the accent-tinted defaults.
108    pub high_contrast: bool,
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}
114
115impl Default for Statusline {
116    fn default() -> Self {
117        Self {
118            mode: PageMode::Normal,
119            url: String::new(),
120            progress: 1.0,
121            cert_state: CertState::Unknown,
122            count_buffer: None,
123            private: false,
124            find_query: None,
125            hint_state: None,
126            update_indicator: None,
127            high_contrast: false,
128            zoom_level: 0.0,
129        }
130    }
131}
132
133impl Statusline {
134    /// Paint the statusline into the bottom `STATUSLINE_HEIGHT` rows
135    /// of `buffer`. `buffer` is the *full* window buffer (one `u32`
136    /// per pixel, row-major); we touch only the strip rows so the CEF
137    /// child window above is undisturbed.
138    ///
139    /// `width` and `height` are the full window's pixel dimensions.
140    /// If `height < STATUSLINE_HEIGHT` we draw nothing.
141    pub fn paint(&self, buffer: &mut [u32], width: usize, height: usize) {
142        let strip_h = STATUSLINE_HEIGHT as usize;
143        if width == 0 || height < strip_h {
144            return;
145        }
146        if buffer.len() < width * height {
147            return;
148        }
149
150        // Strip starts at this row.
151        let strip_y = height - strip_h;
152        let bg = if self.high_contrast {
153            HC_BG
154        } else {
155            mode_bg(self.mode)
156        };
157        let fg = if self.high_contrast {
158            HC_FG
159        } else {
160            mode_fg(self.mode)
161        };
162        let accent = if self.high_contrast {
163            HC_ACCENT
164        } else {
165            mode_accent(self.mode)
166        };
167
168        // Background fill — only the strip rows.
169        fill_rect(buffer, width, height, 0, strip_y as i32, width, strip_h, bg);
170
171        // Mode block — leftmost cell, slightly darker accent so the
172        // text reads even when the rest of the strip shares the same
173        // background colour.
174        let mode_text = mode_label(self.mode);
175        let mode_w = font::text_width(mode_text) + 12;
176        fill_rect(
177            buffer,
178            width,
179            height,
180            0,
181            strip_y as i32,
182            mode_w,
183            strip_h,
184            accent,
185        );
186        let text_y = strip_y as i32 + ((strip_h as i32 - font::glyph_h() as i32) / 2);
187        font::draw_text(buffer, width, height, 6, text_y, mode_text, fg);
188
189        // Right-side cell: count buffer, find status, update channel,
190        // and PRIVATE marker. Drawn right-to-left so each piece pads
191        // naturally.
192        let mut right_pen = width as i32 - 6;
193        if self.private {
194            let s = "PRIVATE";
195            let w = font::text_width(s) as i32;
196            right_pen -= w;
197            let private_colour = if self.high_contrast {
198                HC_FG
199            } else {
200                COLOUR_PRIVATE
201            };
202            font::draw_text(buffer, width, height, right_pen, text_y, s, private_colour);
203            right_pen -= 8;
204        }
205        if let Some(ind) = self.update_indicator {
206            let s = match ind {
207                UpdateIndicator::Available => "* upd",
208                UpdateIndicator::Stale => "* upd?",
209            };
210            let w = font::text_width(s) as i32;
211            right_pen -= w;
212            let upd_colour = if self.high_contrast {
213                HC_FG
214            } else {
215                COLOUR_UPDATE
216            };
217            font::draw_text(buffer, width, height, right_pen, text_y, s, upd_colour);
218            right_pen -= 8;
219        }
220        if let Some(find) = self.find_query.as_ref() {
221            let s = format_find(find);
222            let w = font::text_width(&s) as i32;
223            right_pen -= w;
224            font::draw_text(buffer, width, height, right_pen, text_y, &s, fg);
225            right_pen -= 8;
226        }
227        if let Some(hint) = self.hint_state.as_ref() {
228            let s = format_hint(hint);
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, fg);
232            right_pen -= 8;
233        }
234        if let Some(count) = self.count_buffer
235            && count > 0
236        {
237            let s = format!("{count}");
238            let w = font::text_width(&s) as i32;
239            right_pen -= w;
240            font::draw_text(buffer, width, height, right_pen, text_y, &s, fg);
241            right_pen -= 8;
242        }
243        // Zoom indicator. Hidden at default (0.0). CEF uses ~1.2^level
244        // so 1 step ≈ 120%, -1 ≈ 83%. Round to nearest percent.
245        if self.zoom_level.abs() > f64::EPSILON {
246            let pct = (1.2_f64.powf(self.zoom_level) * 100.0).round() as i64;
247            let s = format!("{pct}%");
248            let w = font::text_width(&s) as i32;
249            right_pen -= w;
250            font::draw_text(buffer, width, height, right_pen, text_y, &s, fg);
251            right_pen -= 8;
252        }
253
254        // URL middle cell. Truncate from the right if it would
255        // overflow into the right-hand cell. Uses the cert state
256        // colour as a left padlock byte.
257        let url_x = mode_w as i32 + 6;
258        let url_max_px = (right_pen - url_x).max(0) as usize;
259        let url_text = truncate_to_width(&self.url, url_max_px);
260        let cert_colour = match self.cert_state {
261            CertState::Secure => COLOUR_CERT_SECURE,
262            CertState::Insecure => COLOUR_CERT_INSECURE,
263            CertState::Unknown => fg,
264        };
265        // Cert pip — single 2x6 vertical bar at the URL's left edge.
266        fill_rect(
267            buffer,
268            width,
269            height,
270            url_x,
271            strip_y as i32 + 8,
272            2,
273            font::glyph_h(),
274            cert_colour,
275        );
276        font::draw_text(buffer, width, height, url_x + 6, text_y, url_text, fg);
277
278        // Progress bar — top 2 px of the strip. 0.0 = invisible,
279        // 1.0 = full width. Phase 3 will animate this off CEF's
280        // `OnLoadingProgressChange`.
281        let progress = self.progress.clamp(0.0, 1.0);
282        if progress > 0.0 && progress < 1.0 {
283            let bar_w = (width as f32 * progress) as usize;
284            fill_rect(
285                buffer,
286                width,
287                height,
288                0,
289                strip_y as i32,
290                bar_w,
291                2,
292                COLOUR_PROGRESS,
293            );
294        }
295    }
296}
297
298fn format_hint(h: &HintStatus) -> String {
299    let prefix = if h.background { "F" } else { "f" };
300    if h.typed.is_empty() {
301        format!("{prefix}: {} hints", h.match_count)
302    } else {
303        format!(
304            "{prefix}: {} ({}/{})",
305            h.typed,
306            h.match_count,
307            h.match_count.max(1)
308        )
309    }
310}
311
312fn format_find(f: &FindStatus) -> String {
313    if f.total == 0 {
314        format!("/{}: no matches", f.query)
315    } else {
316        format!("/{} {}/{}", f.query, f.current, f.total)
317    }
318}
319
320/// Truncate `s` to at most `max_px` pixels of rendered width. Adds a
321/// trailing `..` ellipsis when the original didn't fit.
322pub(crate) fn truncate_to_width(s: &str, max_px: usize) -> &str {
323    if font::text_width(s) <= max_px {
324        return s;
325    }
326    if max_px < font::text_width("..") {
327        return "";
328    }
329    // Walk backwards by char until the prefix + ".." fits.
330    let mut end = s.len();
331    while end > 0 {
332        if !s.is_char_boundary(end) {
333            end -= 1;
334            continue;
335        }
336        let prefix = &s[..end];
337        if font::text_width(prefix) + font::text_width("..") <= max_px {
338            return prefix;
339        }
340        end -= 1;
341    }
342    ""
343}
344
345#[allow(clippy::too_many_arguments)]
346pub(crate) fn fill_rect(
347    buffer: &mut [u32],
348    width: usize,
349    height: usize,
350    x: i32,
351    y: i32,
352    w: usize,
353    h: usize,
354    colour: u32,
355) {
356    let x0 = x.max(0) as usize;
357    let y0 = y.max(0) as usize;
358    let x1 = (x.saturating_add(w as i32)).max(0) as usize;
359    let y1 = (y.saturating_add(h as i32)).max(0) as usize;
360    let x1 = x1.min(width);
361    let y1 = y1.min(height);
362    if x0 >= x1 || y0 >= y1 {
363        return;
364    }
365    for row in y0..y1 {
366        let start = row * width + x0;
367        let end = row * width + x1;
368        if let Some(slice) = buffer.get_mut(start..end) {
369            for pixel in slice {
370                *pixel = colour;
371            }
372        }
373    }
374}
375
376/// Mode label rendered into the leftmost statusline cell. Matches the
377/// strings used by `apps/buffr` for the window title — keep these in
378/// sync.
379fn mode_label(mode: PageMode) -> &'static str {
380    match mode {
381        PageMode::Normal => "NORMAL",
382        PageMode::Visual => "VISUAL",
383        PageMode::Command => "COMMAND",
384        PageMode::Hint => "HINT",
385        PageMode::Insert => "INSERT",
386        PageMode::Pending => "PENDING",
387    }
388}
389
390// ---- colour table (BGRA packed, alpha = 0xFF) -----------------------
391//
392// Pixels are u32 with layout `0xFF_RR_GG_BB` on little-endian: the byte
393// sequence in memory is [B, G, R, A], matching `wgpu::TextureFormat::Bgra8Unorm`.
394// The alpha byte is 0xFF (fully opaque) so the GPU alpha-blend pass
395// composites chrome strips correctly over the OSR texture.
396
397const COLOUR_PROGRESS: u32 = 0xFF_66_C2_FF;
398const COLOUR_PRIVATE: u32 = 0xFF_FF_C8_C8;
399const COLOUR_CERT_SECURE: u32 = 0xFF_66_E0_8A;
400const COLOUR_CERT_INSECURE: u32 = 0xFF_E0_5A_5A;
401const COLOUR_UPDATE: u32 = 0xFF_E0_C8_5A;
402
403// Phase 6 high-contrast palette. Documented in `docs/accessibility.md`.
404// Picked for WCAG-style contrast against the chrome's dark mode: pure
405// white-on-black for body, a saturated yellow accent that survives
406// black + white backgrounds, and a dimmed accent for secondary text.
407//
408// Colour values (opaque BGRA):
409// - HC_BG:        0xFF000000  (pure black, fully opaque)
410// - HC_FG:        0xFFFFFFFF  (pure white, fully opaque)
411// - HC_ACCENT:    0xFFFFFF00  (high-contrast yellow, fully opaque)
412// - HC_ACCENT_DIM:0xFFC0C0C0  (light grey, fully opaque)
413pub const HC_BG: u32 = 0xFF_00_00_00;
414pub const HC_FG: u32 = 0xFF_FF_FF_FF;
415pub const HC_ACCENT: u32 = 0xFF_FF_FF_00;
416pub const HC_ACCENT_DIM: u32 = 0xFF_C0_C0_C0;
417
418const fn mode_bg(mode: PageMode) -> u32 {
419    match mode {
420        PageMode::Normal | PageMode::Pending => 0xFF_16_30_18,
421        PageMode::Visual => 0xFF_33_22_06,
422        PageMode::Command => 0xFF_1A_1F_2E,
423        PageMode::Hint => 0xFF_2A_1A_2E,
424        PageMode::Insert => 0xFF_10_1F_30,
425    }
426}
427
428const fn mode_accent(mode: PageMode) -> u32 {
429    match mode {
430        PageMode::Normal | PageMode::Pending => 0xFF_4A_C9_5C,
431        PageMode::Visual => 0xFF_E0_8B_2A,
432        PageMode::Command => 0xFF_55_88_FF,
433        PageMode::Hint => 0xFF_C8_5A_E0,
434        PageMode::Insert => 0xFF_5A_AA_E0,
435    }
436}
437
438const fn mode_fg(_mode: PageMode) -> u32 {
439    0xFF_EE_EE_EE
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    fn make_buf(w: usize, h: usize) -> Vec<u32> {
447        vec![0u32; w * h]
448    }
449
450    #[test]
451    fn paint_fills_strip_row_with_mode_bg() {
452        let w = 200;
453        let h = STATUSLINE_HEIGHT as usize;
454        let mut buf = make_buf(w, h);
455        let s = Statusline {
456            url: "https://example.com".into(),
457            ..Statusline::default()
458        };
459        s.paint(&mut buf, w, h);
460        // The leftmost column of the strip is owned by the mode-accent
461        // cell — pixel (0,0) sits on the strip top row. Alpha is 0xFF (opaque).
462        assert_eq!(buf[0], mode_accent(PageMode::Normal));
463    }
464
465    #[test]
466    fn paint_strip_pixel_outside_mode_block_uses_strip_bg() {
467        let w = 400;
468        let h = STATUSLINE_HEIGHT as usize;
469        let mut buf = make_buf(w, h);
470        let s = Statusline {
471            url: "x".into(),
472            ..Statusline::default()
473        };
474        s.paint(&mut buf, w, h);
475        // Far-right column on the bottom row — past mode block, past
476        // URL text, almost certainly bg.
477        let idx = (h - 1) * w + (w - 1);
478        assert_eq!(buf[idx], mode_bg(PageMode::Normal));
479    }
480
481    #[test]
482    fn paint_skips_when_height_less_than_strip() {
483        let w = 100;
484        let h = 10;
485        let mut buf = make_buf(w, h);
486        let s = Statusline::default();
487        s.paint(&mut buf, w, h);
488        // Buffer untouched — sentinel zero.
489        assert!(buf.iter().all(|&p| p == 0));
490    }
491
492    #[test]
493    fn mode_colours_differ() {
494        // Smoke check that the palette assigns distinct accents per
495        // mode — guards against future copy-paste regressions.
496        let modes = [
497            PageMode::Normal,
498            PageMode::Visual,
499            PageMode::Command,
500            PageMode::Hint,
501            PageMode::Insert,
502        ];
503        for (i, a) in modes.iter().enumerate() {
504            for b in &modes[i + 1..] {
505                assert_ne!(mode_accent(*a), mode_accent(*b), "{a:?} vs {b:?}");
506            }
507        }
508    }
509
510    #[test]
511    fn truncate_to_width_short_string_unchanged() {
512        let s = "hi";
513        let max = 1000;
514        assert_eq!(truncate_to_width(s, max), "hi");
515    }
516
517    #[test]
518    fn truncate_to_width_returns_empty_when_too_narrow() {
519        assert_eq!(truncate_to_width("abcd", 1), "");
520    }
521
522    #[test]
523    fn truncate_to_width_drops_chars_until_fit() {
524        // Width budget for "a" + ".." = 6 + 1 + (6+1+6) = 20 px.
525        let dotdot = font::text_width("..");
526        let one_a = font::text_width("a");
527        let budget = one_a + dotdot;
528        let s = "abcd";
529        let out = truncate_to_width(s, budget);
530        assert_eq!(out, "a");
531    }
532
533    #[test]
534    fn format_find_no_matches() {
535        let f = FindStatus {
536            query: "foo".into(),
537            current: 0,
538            total: 0,
539        };
540        assert_eq!(format_find(&f), "/foo: no matches");
541    }
542
543    #[test]
544    fn format_find_with_matches() {
545        let f = FindStatus {
546            query: "foo".into(),
547            current: 2,
548            total: 5,
549        };
550        assert_eq!(format_find(&f), "/foo 2/5");
551    }
552
553    #[test]
554    fn format_hint_no_typed() {
555        let h = HintStatus {
556            typed: String::new(),
557            match_count: 12,
558            background: false,
559        };
560        assert_eq!(format_hint(&h), "f: 12 hints");
561    }
562
563    #[test]
564    fn format_hint_with_typed_background() {
565        let h = HintStatus {
566            typed: "as".into(),
567            match_count: 3,
568            background: true,
569        };
570        assert!(format_hint(&h).starts_with("F:"));
571        assert!(format_hint(&h).contains("as"));
572    }
573
574    #[test]
575    fn high_contrast_uses_distinct_palette() {
576        let w = 400;
577        let h = STATUSLINE_HEIGHT as usize;
578        let mut buf_default = make_buf(w, h);
579        let mut buf_hc = make_buf(w, h);
580        let default_s = Statusline {
581            url: "https://x".into(),
582            ..Statusline::default()
583        };
584        let hc_s = Statusline {
585            url: "https://x".into(),
586            high_contrast: true,
587            ..Statusline::default()
588        };
589        default_s.paint(&mut buf_default, w, h);
590        hc_s.paint(&mut buf_hc, w, h);
591        // Far-right pixel on the bottom row should differ — the
592        // strip background paint sits there.
593        let idx = (h - 1) * w + (w - 1);
594        assert_ne!(buf_default[idx], buf_hc[idx]);
595        // High-contrast strip background must be pure black.
596        assert_eq!(buf_hc[idx], HC_BG);
597    }
598
599    #[test]
600    fn high_contrast_palette_distinct_from_default_modes() {
601        // Guard: HC values are not accidentally equal to any per-mode
602        // default. (Catches a future palette refactor that picks the
603        // same accent.)
604        let modes = [
605            PageMode::Normal,
606            PageMode::Visual,
607            PageMode::Command,
608            PageMode::Hint,
609            PageMode::Insert,
610        ];
611        for m in modes {
612            assert_ne!(HC_ACCENT, mode_accent(m));
613            assert_ne!(HC_BG, mode_bg(m));
614        }
615    }
616
617    #[test]
618    fn update_indicator_renders_when_set() {
619        let w = 600;
620        let h = STATUSLINE_HEIGHT as usize;
621        let mut buf_off = make_buf(w, h);
622        let mut buf_on = make_buf(w, h);
623        let off_s = Statusline {
624            url: "x".into(),
625            ..Statusline::default()
626        };
627        let on_s = Statusline {
628            url: "x".into(),
629            update_indicator: Some(UpdateIndicator::Available),
630            ..Statusline::default()
631        };
632        off_s.paint(&mut buf_off, w, h);
633        on_s.paint(&mut buf_on, w, h);
634        assert_ne!(buf_off, buf_on);
635    }
636
637    #[test]
638    fn private_marker_renders_distinctly() {
639        let w = 400;
640        let h = STATUSLINE_HEIGHT as usize;
641        let mut buf_priv = make_buf(w, h);
642        let mut buf_norm = make_buf(w, h);
643        let priv_s = Statusline {
644            url: "https://x".into(),
645            private: true,
646            ..Statusline::default()
647        };
648        let norm_s = Statusline {
649            url: "https://x".into(),
650            private: false,
651            ..Statusline::default()
652        };
653        priv_s.paint(&mut buf_priv, w, h);
654        norm_s.paint(&mut buf_norm, w, h);
655        assert_ne!(buf_priv, buf_norm);
656    }
657}