1use 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
39pub const STATUSLINE_HEIGHT: u32 = 30;
44
45pub use buffr_modal::Mode;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum CertState {
52 Secure,
53 Insecure,
54 Unknown,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct FindStatus {
62 pub query: String,
63 pub current: u32,
64 pub total: u32,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum UpdateIndicator {
73 Available,
75 Stale,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct HintStatus {
85 pub typed: String,
86 pub match_count: u32,
87 pub background: bool,
88}
89
90#[derive(Debug, Clone)]
93pub struct Statusline {
94 pub mode: PageMode,
95 pub url: String,
96 pub progress: f32,
100 pub cert_state: CertState,
101 pub count_buffer: Option<u32>,
105 pub private: bool,
106 pub find_query: Option<FindStatus>,
107 pub hint_state: Option<HintStatus>,
109 pub update_indicator: Option<UpdateIndicator>,
114 pub zoom_level: f64,
118 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 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 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 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 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 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 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 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 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 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
316pub(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 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
372fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405pub struct Palette {
406 pub accent: u32,
409 pub bg: u32,
411 pub fg: u32,
414 pub bg_lifted: u32,
417 pub fg_dim: u32,
419 pub cert_secure: u32,
421 pub cert_insecure: u32,
423 pub private: u32,
425 pub progress: u32,
427 pub update: u32,
429}
430
431impl Palette {
432 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 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 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 Self::from_accent(0xFF_7A_A2_F7)
494 }
495}
496
497pub(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
512const 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 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 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
553fn 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 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 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 assert!(buf.iter().all(|&p| p == 0));
664 }
665
666 #[test]
667 fn mode_accents_pairwise_distinct() {
668 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 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 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 let idx = (h - 1) * w + (w - 1);
784 assert_ne!(buf_default[idx], buf_hc[idx]);
785 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}