use buffr_modal::PageMode;
pub mod confirm_prompt;
pub mod download_notice;
pub mod font;
pub mod input_bar;
pub mod permissions_prompt;
pub mod tab_strip;
pub use confirm_prompt::{CONFIRM_PROMPT_HEIGHT, ConfirmPrompt, ConfirmRect, rect_contains};
pub use download_notice::{DOWNLOAD_NOTICE_HEIGHT, DownloadNoticeKind, DownloadNoticeStrip};
pub use input_bar::{
INPUT_HEIGHT, InputBar, MAX_SUGGESTIONS, Palette as InputPalette, SUGGESTION_ROW_HEIGHT,
Suggestion, SuggestionKind,
};
pub use permissions_prompt::{ACTION_HINT, PERMISSIONS_PROMPT_HEIGHT, PermissionsPrompt};
pub use tab_strip::{MAX_TAB_WIDTH, MIN_TAB_WIDTH, TAB_STRIP_HEIGHT, TabStrip, TabView};
pub const STATUSLINE_HEIGHT: u32 = 30;
pub use buffr_modal::Mode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CertState {
Secure,
Insecure,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FindStatus {
pub query: String,
pub current: u32,
pub total: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateIndicator {
Available,
Stale,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HintStatus {
pub typed: String,
pub match_count: u32,
pub background: bool,
}
#[derive(Debug, Clone)]
pub struct Statusline {
pub mode: PageMode,
pub url: String,
pub progress: f32,
pub cert_state: CertState,
pub count_buffer: Option<u32>,
pub private: bool,
pub find_query: Option<FindStatus>,
pub hint_state: Option<HintStatus>,
pub update_indicator: Option<UpdateIndicator>,
pub high_contrast: bool,
pub zoom_level: f64,
}
impl Default for Statusline {
fn default() -> Self {
Self {
mode: PageMode::Normal,
url: String::new(),
progress: 1.0,
cert_state: CertState::Unknown,
count_buffer: None,
private: false,
find_query: None,
hint_state: None,
update_indicator: None,
high_contrast: false,
zoom_level: 0.0,
}
}
}
impl Statusline {
pub fn paint(&self, buffer: &mut [u32], width: usize, height: usize) {
let strip_h = STATUSLINE_HEIGHT as usize;
if width == 0 || height < strip_h {
return;
}
if buffer.len() < width * height {
return;
}
let strip_y = height - strip_h;
let bg = if self.high_contrast {
HC_BG
} else {
mode_bg(self.mode)
};
let fg = if self.high_contrast {
HC_FG
} else {
mode_fg(self.mode)
};
let accent = if self.high_contrast {
HC_ACCENT
} else {
mode_accent(self.mode)
};
fill_rect(buffer, width, height, 0, strip_y as i32, width, strip_h, bg);
let mode_text = mode_label(self.mode);
let mode_w = font::text_width(mode_text) + 12;
fill_rect(
buffer,
width,
height,
0,
strip_y as i32,
mode_w,
strip_h,
accent,
);
let text_y = strip_y as i32 + ((strip_h as i32 - font::glyph_h() as i32) / 2);
font::draw_text(buffer, width, height, 6, text_y, mode_text, fg);
let mut right_pen = width as i32 - 6;
if self.private {
let s = "PRIVATE";
let w = font::text_width(s) as i32;
right_pen -= w;
let private_colour = if self.high_contrast {
HC_FG
} else {
COLOUR_PRIVATE
};
font::draw_text(buffer, width, height, right_pen, text_y, s, private_colour);
right_pen -= 8;
}
if let Some(ind) = self.update_indicator {
let s = match ind {
UpdateIndicator::Available => "* upd",
UpdateIndicator::Stale => "* upd?",
};
let w = font::text_width(s) as i32;
right_pen -= w;
let upd_colour = if self.high_contrast {
HC_FG
} else {
COLOUR_UPDATE
};
font::draw_text(buffer, width, height, right_pen, text_y, s, upd_colour);
right_pen -= 8;
}
if let Some(find) = self.find_query.as_ref() {
let s = format_find(find);
let w = font::text_width(&s) as i32;
right_pen -= w;
font::draw_text(buffer, width, height, right_pen, text_y, &s, fg);
right_pen -= 8;
}
if let Some(hint) = self.hint_state.as_ref() {
let s = format_hint(hint);
let w = font::text_width(&s) as i32;
right_pen -= w;
font::draw_text(buffer, width, height, right_pen, text_y, &s, fg);
right_pen -= 8;
}
if let Some(count) = self.count_buffer
&& count > 0
{
let s = format!("{count}");
let w = font::text_width(&s) as i32;
right_pen -= w;
font::draw_text(buffer, width, height, right_pen, text_y, &s, fg);
right_pen -= 8;
}
if self.zoom_level.abs() > f64::EPSILON {
let pct = (1.2_f64.powf(self.zoom_level) * 100.0).round() as i64;
let s = format!("{pct}%");
let w = font::text_width(&s) as i32;
right_pen -= w;
font::draw_text(buffer, width, height, right_pen, text_y, &s, fg);
right_pen -= 8;
}
let url_x = mode_w as i32 + 6;
let url_max_px = (right_pen - url_x).max(0) as usize;
let url_text = truncate_to_width(&self.url, url_max_px);
let cert_colour = match self.cert_state {
CertState::Secure => COLOUR_CERT_SECURE,
CertState::Insecure => COLOUR_CERT_INSECURE,
CertState::Unknown => fg,
};
fill_rect(
buffer,
width,
height,
url_x,
strip_y as i32 + 8,
2,
font::glyph_h(),
cert_colour,
);
font::draw_text(buffer, width, height, url_x + 6, text_y, url_text, fg);
let progress = self.progress.clamp(0.0, 1.0);
if progress > 0.0 && progress < 1.0 {
let bar_w = (width as f32 * progress) as usize;
fill_rect(
buffer,
width,
height,
0,
strip_y as i32,
bar_w,
2,
COLOUR_PROGRESS,
);
}
}
}
fn format_hint(h: &HintStatus) -> String {
let prefix = if h.background { "F" } else { "f" };
if h.typed.is_empty() {
format!("{prefix}: {} hints", h.match_count)
} else {
format!(
"{prefix}: {} ({}/{})",
h.typed,
h.match_count,
h.match_count.max(1)
)
}
}
fn format_find(f: &FindStatus) -> String {
if f.total == 0 {
format!("/{}: no matches", f.query)
} else {
format!("/{} {}/{}", f.query, f.current, f.total)
}
}
pub(crate) fn truncate_to_width(s: &str, max_px: usize) -> &str {
if font::text_width(s) <= max_px {
return s;
}
if max_px < font::text_width("..") {
return "";
}
let mut end = s.len();
while end > 0 {
if !s.is_char_boundary(end) {
end -= 1;
continue;
}
let prefix = &s[..end];
if font::text_width(prefix) + font::text_width("..") <= max_px {
return prefix;
}
end -= 1;
}
""
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn fill_rect(
buffer: &mut [u32],
width: usize,
height: usize,
x: i32,
y: i32,
w: usize,
h: usize,
colour: u32,
) {
let x0 = x.max(0) as usize;
let y0 = y.max(0) as usize;
let x1 = (x.saturating_add(w as i32)).max(0) as usize;
let y1 = (y.saturating_add(h as i32)).max(0) as usize;
let x1 = x1.min(width);
let y1 = y1.min(height);
if x0 >= x1 || y0 >= y1 {
return;
}
for row in y0..y1 {
let start = row * width + x0;
let end = row * width + x1;
if let Some(slice) = buffer.get_mut(start..end) {
for pixel in slice {
*pixel = colour;
}
}
}
}
fn mode_label(mode: PageMode) -> &'static str {
match mode {
PageMode::Normal => "NORMAL",
PageMode::Visual => "VISUAL",
PageMode::Command => "COMMAND",
PageMode::Hint => "HINT",
PageMode::Insert => "INSERT",
PageMode::Pending => "PENDING",
}
}
const COLOUR_PROGRESS: u32 = 0xFF_66_C2_FF;
const COLOUR_PRIVATE: u32 = 0xFF_FF_C8_C8;
const COLOUR_CERT_SECURE: u32 = 0xFF_66_E0_8A;
const COLOUR_CERT_INSECURE: u32 = 0xFF_E0_5A_5A;
const COLOUR_UPDATE: u32 = 0xFF_E0_C8_5A;
pub const HC_BG: u32 = 0xFF_00_00_00;
pub const HC_FG: u32 = 0xFF_FF_FF_FF;
pub const HC_ACCENT: u32 = 0xFF_FF_FF_00;
pub const HC_ACCENT_DIM: u32 = 0xFF_C0_C0_C0;
const fn mode_bg(mode: PageMode) -> u32 {
match mode {
PageMode::Normal | PageMode::Pending => 0xFF_16_30_18,
PageMode::Visual => 0xFF_33_22_06,
PageMode::Command => 0xFF_1A_1F_2E,
PageMode::Hint => 0xFF_2A_1A_2E,
PageMode::Insert => 0xFF_10_1F_30,
}
}
const fn mode_accent(mode: PageMode) -> u32 {
match mode {
PageMode::Normal | PageMode::Pending => 0xFF_4A_C9_5C,
PageMode::Visual => 0xFF_E0_8B_2A,
PageMode::Command => 0xFF_55_88_FF,
PageMode::Hint => 0xFF_C8_5A_E0,
PageMode::Insert => 0xFF_5A_AA_E0,
}
}
const fn mode_fg(_mode: PageMode) -> u32 {
0xFF_EE_EE_EE
}
#[cfg(test)]
mod tests {
use super::*;
fn make_buf(w: usize, h: usize) -> Vec<u32> {
vec![0u32; w * h]
}
#[test]
fn paint_fills_strip_row_with_mode_bg() {
let w = 200;
let h = STATUSLINE_HEIGHT as usize;
let mut buf = make_buf(w, h);
let s = Statusline {
url: "https://example.com".into(),
..Statusline::default()
};
s.paint(&mut buf, w, h);
assert_eq!(buf[0], mode_accent(PageMode::Normal));
}
#[test]
fn paint_strip_pixel_outside_mode_block_uses_strip_bg() {
let w = 400;
let h = STATUSLINE_HEIGHT as usize;
let mut buf = make_buf(w, h);
let s = Statusline {
url: "x".into(),
..Statusline::default()
};
s.paint(&mut buf, w, h);
let idx = (h - 1) * w + (w - 1);
assert_eq!(buf[idx], mode_bg(PageMode::Normal));
}
#[test]
fn paint_skips_when_height_less_than_strip() {
let w = 100;
let h = 10;
let mut buf = make_buf(w, h);
let s = Statusline::default();
s.paint(&mut buf, w, h);
assert!(buf.iter().all(|&p| p == 0));
}
#[test]
fn mode_colours_differ() {
let modes = [
PageMode::Normal,
PageMode::Visual,
PageMode::Command,
PageMode::Hint,
PageMode::Insert,
];
for (i, a) in modes.iter().enumerate() {
for b in &modes[i + 1..] {
assert_ne!(mode_accent(*a), mode_accent(*b), "{a:?} vs {b:?}");
}
}
}
#[test]
fn truncate_to_width_short_string_unchanged() {
let s = "hi";
let max = 1000;
assert_eq!(truncate_to_width(s, max), "hi");
}
#[test]
fn truncate_to_width_returns_empty_when_too_narrow() {
assert_eq!(truncate_to_width("abcd", 1), "");
}
#[test]
fn truncate_to_width_drops_chars_until_fit() {
let dotdot = font::text_width("..");
let one_a = font::text_width("a");
let budget = one_a + dotdot;
let s = "abcd";
let out = truncate_to_width(s, budget);
assert_eq!(out, "a");
}
#[test]
fn format_find_no_matches() {
let f = FindStatus {
query: "foo".into(),
current: 0,
total: 0,
};
assert_eq!(format_find(&f), "/foo: no matches");
}
#[test]
fn format_find_with_matches() {
let f = FindStatus {
query: "foo".into(),
current: 2,
total: 5,
};
assert_eq!(format_find(&f), "/foo 2/5");
}
#[test]
fn format_hint_no_typed() {
let h = HintStatus {
typed: String::new(),
match_count: 12,
background: false,
};
assert_eq!(format_hint(&h), "f: 12 hints");
}
#[test]
fn format_hint_with_typed_background() {
let h = HintStatus {
typed: "as".into(),
match_count: 3,
background: true,
};
assert!(format_hint(&h).starts_with("F:"));
assert!(format_hint(&h).contains("as"));
}
#[test]
fn high_contrast_uses_distinct_palette() {
let w = 400;
let h = STATUSLINE_HEIGHT as usize;
let mut buf_default = make_buf(w, h);
let mut buf_hc = make_buf(w, h);
let default_s = Statusline {
url: "https://x".into(),
..Statusline::default()
};
let hc_s = Statusline {
url: "https://x".into(),
high_contrast: true,
..Statusline::default()
};
default_s.paint(&mut buf_default, w, h);
hc_s.paint(&mut buf_hc, w, h);
let idx = (h - 1) * w + (w - 1);
assert_ne!(buf_default[idx], buf_hc[idx]);
assert_eq!(buf_hc[idx], HC_BG);
}
#[test]
fn high_contrast_palette_distinct_from_default_modes() {
let modes = [
PageMode::Normal,
PageMode::Visual,
PageMode::Command,
PageMode::Hint,
PageMode::Insert,
];
for m in modes {
assert_ne!(HC_ACCENT, mode_accent(m));
assert_ne!(HC_BG, mode_bg(m));
}
}
#[test]
fn update_indicator_renders_when_set() {
let w = 600;
let h = STATUSLINE_HEIGHT as usize;
let mut buf_off = make_buf(w, h);
let mut buf_on = make_buf(w, h);
let off_s = Statusline {
url: "x".into(),
..Statusline::default()
};
let on_s = Statusline {
url: "x".into(),
update_indicator: Some(UpdateIndicator::Available),
..Statusline::default()
};
off_s.paint(&mut buf_off, w, h);
on_s.paint(&mut buf_on, w, h);
assert_ne!(buf_off, buf_on);
}
#[test]
fn private_marker_renders_distinctly() {
let w = 400;
let h = STATUSLINE_HEIGHT as usize;
let mut buf_priv = make_buf(w, h);
let mut buf_norm = make_buf(w, h);
let priv_s = Statusline {
url: "https://x".into(),
private: true,
..Statusline::default()
};
let norm_s = Statusline {
url: "https://x".into(),
private: false,
..Statusline::default()
};
priv_s.paint(&mut buf_priv, w, h);
norm_s.paint(&mut buf_norm, w, h);
assert_ne!(buf_priv, buf_norm);
}
}