1use 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
34pub const STATUSLINE_HEIGHT: u32 = 30;
39
40pub use buffr_modal::Mode;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum CertState {
47 Secure,
48 Insecure,
49 Unknown,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct FindStatus {
57 pub query: String,
58 pub current: u32,
59 pub total: u32,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum UpdateIndicator {
68 Available,
70 Stale,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct HintStatus {
80 pub typed: String,
81 pub match_count: u32,
82 pub background: bool,
83}
84
85#[derive(Debug, Clone)]
88pub struct Statusline {
89 pub mode: PageMode,
90 pub url: String,
91 pub progress: f32,
95 pub cert_state: CertState,
96 pub count_buffer: Option<u32>,
100 pub private: bool,
101 pub find_query: Option<FindStatus>,
102 pub hint_state: Option<HintStatus>,
104 pub update_indicator: Option<UpdateIndicator>,
109 pub zoom_level: f64,
113 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 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 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 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 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 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 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 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 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 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
311pub(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 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
367fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400pub struct Palette {
401 pub accent: u32,
404 pub bg: u32,
406 pub fg: u32,
409 pub bg_lifted: u32,
412 pub fg_dim: u32,
414 pub cert_secure: u32,
416 pub cert_insecure: u32,
418 pub private: u32,
420 pub progress: u32,
422 pub update: u32,
424}
425
426impl Palette {
427 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 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 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 Self::from_accent(0xFF_7A_A2_F7)
489 }
490}
491
492pub(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
507const 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 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 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
548fn 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 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 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 assert!(buf.iter().all(|&p| p == 0));
659 }
660
661 #[test]
662 fn mode_accents_pairwise_distinct() {
663 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 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 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 let idx = (h - 1) * w + (w - 1);
779 assert_ne!(buf_default[idx], buf_hc[idx]);
780 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}