use buffr_modal::PageMode;
pub mod confirm_prompt;
pub mod context_menu;
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 context_menu::{
CONTEXT_MENU_MIN_WIDTH, CONTEXT_MENU_PADDING_X, CONTEXT_MENU_ROW_HEIGHT,
CONTEXT_MENU_SEP_HEIGHT, ContextMenuEntry, ContextMenuOverlay,
};
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::{
FAVICON_RENDER_SIZE, MAX_TAB_WIDTH, MIN_TAB_WIDTH, TAB_STRIP_HEIGHT, TabFavicon, 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 zoom_level: f64,
pub palette: Palette,
}
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,
zoom_level: 0.0,
palette: Palette::default(),
}
}
}
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 p = &self.palette;
let mode_bg = p.mode_bg(self.mode);
let mode_accent = p.mode_accent(self.mode);
fill_rect(
buffer,
width,
height,
0,
strip_y as i32,
width,
strip_h,
mode_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,
mode_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, mode_bg);
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;
font::draw_text(buffer, width, height, right_pen, text_y, s, p.private);
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;
font::draw_text(buffer, width, height, right_pen, text_y, s, p.update);
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, p.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, p.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, p.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, p.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 => p.cert_secure,
CertState::Insecure => p.cert_insecure,
CertState::Unknown => p.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, p.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,
p.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",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Palette {
pub accent: u32,
pub bg: u32,
pub fg: u32,
pub bg_lifted: u32,
pub fg_dim: u32,
pub cert_secure: u32,
pub cert_insecure: u32,
pub private: u32,
pub progress: u32,
pub update: u32,
}
impl Palette {
pub fn from_accent(accent: u32) -> Self {
Self {
accent,
bg: blend(accent, 0xFF_00_00_00, 0.92),
fg: 0xFF_EE_EE_EE,
bg_lifted: blend(accent, 0xFF_00_00_00, 0.85),
fg_dim: 0xFF_A0_A8_AC,
cert_secure: 0xFF_66_E0_8A,
cert_insecure: 0xFF_E0_5A_5A,
private: 0xFF_FF_C8_C8,
progress: 0xFF_66_C2_FF,
update: 0xFF_E0_C8_5A,
}
}
pub fn with_signals(
mut self,
cert_secure: u32,
cert_insecure: u32,
private: u32,
progress: u32,
update: u32,
) -> Self {
self.cert_secure = cert_secure;
self.cert_insecure = cert_insecure;
self.private = private;
self.progress = progress;
self.update = update;
self
}
pub fn high_contrast() -> Self {
Self {
accent: 0xFF_FF_FF_00,
bg: 0xFF_00_00_00,
fg: 0xFF_FF_FF_FF,
bg_lifted: 0xFF_10_10_10,
fg_dim: 0xFF_C0_C0_C0,
cert_secure: 0xFF_FF_FF_FF,
cert_insecure: 0xFF_FF_FF_FF,
private: 0xFF_FF_FF_FF,
progress: 0xFF_FF_FF_FF,
update: 0xFF_FF_FF_FF,
}
}
}
impl Default for Palette {
fn default() -> Self {
Self::from_accent(0xFF_7A_A2_F7)
}
}
pub(crate) fn blend(a: u32, b: u32, t: f32) -> u32 {
let extract = |c: u32, shift: u32| -> u8 { ((c >> shift) & 0xFF) as u8 };
let lerp = |x: u8, y: u8| -> u8 {
((x as f32) * (1.0 - t) + (y as f32) * t)
.round()
.clamp(0.0, 255.0) as u8
};
let r = lerp(extract(a, 16), extract(b, 16));
let g = lerp(extract(a, 8), extract(b, 8));
let bb = lerp(extract(a, 0), extract(b, 0));
0xFF_00_00_00 | ((r as u32) << 16) | ((g as u32) << 8) | (bb as u32)
}
const HUE_NORMAL: f32 = 0.0;
const HUE_INSERT: f32 = -40.0;
const HUE_VISUAL: f32 = 180.0;
const HUE_COMMAND: f32 = 80.0;
const HUE_HINT: f32 = 240.0;
fn mode_hue_offset(mode: PageMode) -> f32 {
match mode {
PageMode::Normal | PageMode::Pending => HUE_NORMAL,
PageMode::Insert => HUE_INSERT,
PageMode::Visual => HUE_VISUAL,
PageMode::Command => HUE_COMMAND,
PageMode::Hint => HUE_HINT,
}
}
impl Palette {
pub fn mode_accent(&self, mode: PageMode) -> u32 {
if *self == Self::high_contrast() {
return self.accent;
}
rotate_hue(self.accent, mode_hue_offset(mode))
}
pub fn mode_bg(&self, mode: PageMode) -> u32 {
if *self == Self::high_contrast() {
return self.bg;
}
blend(self.mode_accent(mode), 0xFF_00_00_00, 0.92)
}
}
fn rotate_hue(c: u32, degrees: f32) -> u32 {
let r = ((c >> 16) & 0xFF) as f32 / 255.0;
let g = ((c >> 8) & 0xFF) as f32 / 255.0;
let b = (c & 0xFF) as f32 / 255.0;
let (h, s, l) = rgb_to_hsl(r, g, b);
let h2 = (h + degrees).rem_euclid(360.0);
let (r2, g2, b2) = hsl_to_rgb(h2, s, l);
let to_byte = |v: f32| (v * 255.0).round().clamp(0.0, 255.0) as u32;
0xFF_00_00_00 | (to_byte(r2) << 16) | (to_byte(g2) << 8) | to_byte(b2)
}
fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
if (max - min).abs() < f32::EPSILON {
return (0.0, 0.0, l);
}
let d = max - min;
let s = if l > 0.5 {
d / (2.0 - max - min)
} else {
d / (max + min)
};
let h = if (max - r).abs() < f32::EPSILON {
((g - b) / d) + if g < b { 6.0 } else { 0.0 }
} else if (max - g).abs() < f32::EPSILON {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
};
(h * 60.0, s, l)
}
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
if s.abs() < f32::EPSILON {
return (l, l, l);
}
let q = if l < 0.5 {
l * (1.0 + s)
} else {
l + s - l * s
};
let p = 2.0 * l - q;
let h = h / 360.0;
let hue = |t: f32| {
let t = t.rem_euclid(1.0);
if t < 1.0 / 6.0 {
p + (q - p) * 6.0 * t
} else if t < 0.5 {
q
} else if t < 2.0 / 3.0 {
p + (q - p) * (2.0 / 3.0 - t) * 6.0
} else {
p
}
};
(hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0))
}
#[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], Palette::default().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], Palette::default().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_accents_pairwise_distinct() {
let p = Palette::default();
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!(p.mode_accent(*a), p.mode_accent(*b), "{a:?} vs {b:?}");
}
}
}
#[test]
fn palette_from_accent_derives_dark_bg() {
let p = Palette::from_accent(0xFF_7A_A2_F7);
let extract = |c: u32, shift: u32| (c >> shift) & 0xFF;
for shift in [0, 8, 16] {
assert!(
extract(p.bg, shift) < extract(p.accent, shift),
"bg channel {shift} not darker than accent"
);
}
}
#[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(),
palette: Palette::high_contrast(),
..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], Palette::high_contrast().bg);
}
#[test]
fn high_contrast_palette_distinct_from_default_accent() {
let hc = Palette::high_contrast();
let dflt = Palette::default();
assert_ne!(hc.accent, dflt.accent);
assert_ne!(hc.bg, dflt.bg);
}
#[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);
}
}