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::{MAX_TAB_WIDTH, MIN_TAB_WIDTH, TAB_STRIP_HEIGHT, TabStrip, TabView};
30
31pub const STATUSLINE_HEIGHT: u32 = 30;
36
37pub use buffr_modal::Mode;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum CertState {
44 Secure,
45 Insecure,
46 Unknown,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct FindStatus {
54 pub query: String,
55 pub current: u32,
56 pub total: u32,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum UpdateIndicator {
65 Available,
67 Stale,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct HintStatus {
77 pub typed: String,
78 pub match_count: u32,
79 pub background: bool,
80}
81
82#[derive(Debug, Clone)]
85pub struct Statusline {
86 pub mode: PageMode,
87 pub url: String,
88 pub progress: f32,
92 pub cert_state: CertState,
93 pub count_buffer: Option<u32>,
97 pub private: bool,
98 pub find_query: Option<FindStatus>,
99 pub hint_state: Option<HintStatus>,
101 pub update_indicator: Option<UpdateIndicator>,
106 pub high_contrast: bool,
109 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 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 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 fill_rect(buffer, width, height, 0, strip_y as i32, width, strip_h, bg);
170
171 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 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 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 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 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 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
320pub(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 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
376fn 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
390const 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
403pub 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 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 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 assert!(buf.iter().all(|&p| p == 0));
490 }
491
492 #[test]
493 fn mode_colours_differ() {
494 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 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 let idx = (h - 1) * w + (w - 1);
594 assert_ne!(buf_default[idx], buf_hc[idx]);
595 assert_eq!(buf_hc[idx], HC_BG);
597 }
598
599 #[test]
600 fn high_contrast_palette_distinct_from_default_modes() {
601 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}