use crate::font;
use crate::{STATUSLINE_HEIGHT, fill_rect};
pub const INPUT_HEIGHT: u32 = 28;
pub const SUGGESTION_ROW_HEIGHT: u32 = STATUSLINE_HEIGHT;
pub const MAX_SUGGESTIONS: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SuggestionKind {
History,
Bookmark,
Command,
SearchSuggestion,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Suggestion {
pub display: String,
pub value: String,
pub kind: SuggestionKind,
}
#[derive(Debug, Clone, Copy)]
pub struct Palette {
pub bg: u32,
pub fg: u32,
pub accent: u32,
pub border: u32,
pub dropdown_bg: u32,
pub dropdown_selected_bg: u32,
pub dropdown_kind_history: u32,
pub dropdown_kind_bookmark: u32,
pub dropdown_kind_command: u32,
pub dropdown_kind_search: u32,
}
impl Default for Palette {
fn default() -> Self {
Self {
bg: 0xFF_1A_1F_2E,
fg: 0xFF_EE_EE_EE,
accent: 0xFF_55_88_FF,
border: 0xFF_33_3D_55,
dropdown_bg: 0xFF_14_18_24,
dropdown_selected_bg: 0xFF_22_2D_45,
dropdown_kind_history: 0xFF_88_AA_FF,
dropdown_kind_bookmark: 0xFF_E0_C8_5A,
dropdown_kind_command: 0xFF_4A_C9_5C,
dropdown_kind_search: 0xFF_C8_5A_E0,
}
}
}
#[derive(Debug, Clone)]
pub struct InputBar {
pub prefix: String,
pub buffer: String,
pub cursor: usize,
pub suggestions: Vec<Suggestion>,
pub selected: Option<usize>,
pub palette: Palette,
pub cursor_visible: bool,
}
impl Default for InputBar {
fn default() -> Self {
Self::with_prefix(":")
}
}
impl InputBar {
pub fn with_prefix(prefix: impl Into<String>) -> Self {
Self {
prefix: prefix.into(),
buffer: String::new(),
cursor: 0,
suggestions: Vec::new(),
selected: None,
palette: Palette::default(),
cursor_visible: true,
}
}
pub fn current_value(&self) -> &str {
if let Some(idx) = self.selected
&& let Some(s) = self.suggestions.get(idx)
{
return s.value.as_str();
}
&self.buffer
}
pub fn clear(&mut self) {
self.buffer.clear();
self.cursor = 0;
self.suggestions.clear();
self.selected = None;
}
pub fn handle_text(&mut self, ch: char) {
if ch.is_control() {
return;
}
self.buffer.insert(self.cursor, ch);
self.cursor += ch.len_utf8();
self.selected = None;
}
pub fn handle_back(&mut self) {
if self.cursor == 0 {
return;
}
let mut prev = self.cursor - 1;
while !self.buffer.is_char_boundary(prev) && prev > 0 {
prev -= 1;
}
self.buffer.replace_range(prev..self.cursor, "");
self.cursor = prev;
self.selected = None;
}
pub fn handle_delete_word(&mut self) {
if self.cursor == 0 {
return;
}
let bytes = self.buffer.as_bytes();
let mut end = self.cursor;
while end > 0 && bytes[end - 1].is_ascii_whitespace() {
end -= 1;
}
while end > 0 && !bytes[end - 1].is_ascii_whitespace() {
end -= 1;
}
self.buffer.replace_range(end..self.cursor, "");
self.cursor = end;
self.selected = None;
}
pub fn handle_clear_line(&mut self) {
self.buffer.clear();
self.cursor = 0;
self.selected = None;
}
pub fn handle_left(&mut self) {
if self.cursor == 0 {
return;
}
let mut prev = self.cursor - 1;
while !self.buffer.is_char_boundary(prev) && prev > 0 {
prev -= 1;
}
self.cursor = prev;
}
pub fn handle_right(&mut self) {
if self.cursor >= self.buffer.len() {
return;
}
let mut next = self.cursor + 1;
while next < self.buffer.len() && !self.buffer.is_char_boundary(next) {
next += 1;
}
self.cursor = next;
}
pub fn handle_up(&mut self) {
if self.suggestions.is_empty() {
self.selected = None;
return;
}
self.selected = match self.selected {
None => None,
Some(0) => None,
Some(n) => Some(n - 1),
};
}
pub fn handle_down(&mut self) {
if self.suggestions.is_empty() {
self.selected = None;
return;
}
let max = self.suggestions.len() - 1;
self.selected = match self.selected {
None => Some(0),
Some(n) if n >= max => Some(max),
Some(n) => Some(n + 1),
};
}
pub fn set_suggestions(&mut self, suggestions: Vec<Suggestion>) {
self.suggestions = suggestions;
if self.suggestions.len() > MAX_SUGGESTIONS {
self.suggestions.truncate(MAX_SUGGESTIONS);
}
self.selected = None;
}
pub fn total_height(&self) -> u32 {
let rows = self.suggestions.len().min(MAX_SUGGESTIONS) as u32;
INPUT_HEIGHT + rows * SUGGESTION_ROW_HEIGHT
}
pub fn paint(&self, buffer: &mut [u32], width: usize, height: usize) {
self.paint_at(buffer, width, height, 0, 0, width, height);
}
#[allow(clippy::too_many_arguments)]
pub fn paint_at(
&self,
buffer: &mut [u32],
buf_w: usize,
buf_h: usize,
x: usize,
y: usize,
w: usize,
h: usize,
) {
if w == 0 || h < INPUT_HEIGHT as usize {
return;
}
if buffer.len() < buf_w * buf_h {
return;
}
let p = &self.palette;
let bar_h = INPUT_HEIGHT as usize;
fill_rect(buffer, buf_w, buf_h, x as i32, y as i32, w, bar_h, p.bg);
fill_rect(
buffer,
buf_w,
buf_h,
x as i32,
(y + bar_h) as i32 - 1,
w,
1,
p.border,
);
let text_y = y as i32 + ((bar_h as i32) - font::glyph_h() as i32) / 2;
let prefix_x = x as i32 + 6;
font::draw_text(
buffer,
buf_w,
buf_h,
prefix_x,
text_y,
&self.prefix,
p.accent,
);
let prefix_w = font::text_width(&self.prefix) as i32;
let buffer_x = prefix_x + prefix_w + 6;
let glyph_advance = font::glyph_w() + 1;
let inner_w = (x as i32 + w as i32 - 6 - buffer_x).max(0) as usize;
let chars_visible = (inner_w / glyph_advance).max(1);
let cursor_chars = self.buffer[..self.cursor].chars().count();
let total_chars = self.buffer.chars().count();
let mut scroll_chars: usize = if cursor_chars >= chars_visible {
cursor_chars + 1 - chars_visible
} else {
0
};
let max_scroll = total_chars.saturating_sub(chars_visible.saturating_sub(1));
if scroll_chars > max_scroll {
scroll_chars = max_scroll;
}
let visible: String = self
.buffer
.chars()
.skip(scroll_chars)
.take(chars_visible)
.collect();
font::draw_text(buffer, buf_w, buf_h, buffer_x, text_y, &visible, p.fg);
if self.cursor_visible && self.selected.is_none() {
let cursor_offset = cursor_chars.saturating_sub(scroll_chars);
let cursor_px = cursor_offset * glyph_advance;
let cursor_x = buffer_x + cursor_px as i32;
fill_rect(
buffer,
buf_w,
buf_h,
cursor_x,
text_y - 1,
2,
font::glyph_h() + 2,
p.fg,
);
}
if self.suggestions.is_empty() {
return;
}
let row_h = SUGGESTION_ROW_HEIGHT as usize;
for (i, sug) in self.suggestions.iter().take(MAX_SUGGESTIONS).enumerate() {
let row_y = y + bar_h + i * row_h;
if row_y + row_h > y + h {
break;
}
let bg = if Some(i) == self.selected {
p.dropdown_selected_bg
} else {
p.dropdown_bg
};
fill_rect(buffer, buf_w, buf_h, x as i32, row_y as i32, w, row_h, bg);
let pip_colour = match sug.kind {
SuggestionKind::History => p.dropdown_kind_history,
SuggestionKind::Bookmark => p.dropdown_kind_bookmark,
SuggestionKind::Command => p.dropdown_kind_command,
SuggestionKind::SearchSuggestion => p.dropdown_kind_search,
};
fill_rect(
buffer,
buf_w,
buf_h,
x as i32 + 6,
row_y as i32 + 8,
3,
font::glyph_h(),
pip_colour,
);
let row_text_y = row_y as i32 + ((row_h as i32 - font::glyph_h() as i32) / 2);
let text_left = x + 16;
let text_max_px = (x + w).saturating_sub(text_left + 8);
let display = crate::truncate_to_width(&sug.display, text_max_px);
font::draw_text(
buffer,
buf_w,
buf_h,
text_left as i32,
row_text_y,
display,
p.fg,
);
}
}
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::*;
fn s(d: &str) -> Suggestion {
Suggestion {
display: d.into(),
value: d.into(),
kind: SuggestionKind::History,
}
}
#[test]
fn handle_text_appends_at_cursor() {
let mut b = InputBar::default();
b.handle_text('h');
b.handle_text('i');
assert_eq!(b.buffer, "hi");
assert_eq!(b.cursor, 2);
}
#[test]
fn handle_text_inserts_in_middle() {
let mut b = InputBar::default();
b.buffer = "hi".into();
b.cursor = 1;
b.handle_text('e');
assert_eq!(b.buffer, "hei");
assert_eq!(b.cursor, 2);
}
#[test]
fn handle_text_rejects_control_chars() {
let mut b = InputBar::default();
b.handle_text('\n');
b.handle_text('\t');
b.handle_text('\x07');
assert_eq!(b.buffer, "");
}
#[test]
fn handle_back_at_zero_is_noop() {
let mut b = InputBar::default();
b.handle_back();
assert_eq!(b.buffer, "");
assert_eq!(b.cursor, 0);
}
#[test]
fn handle_back_deletes_codepoint() {
let mut b = InputBar::default();
b.buffer = "hi".into();
b.cursor = 2;
b.handle_back();
assert_eq!(b.buffer, "h");
assert_eq!(b.cursor, 1);
}
#[test]
fn handle_delete_word_word_only() {
let mut b = InputBar::default();
b.buffer = "hello world".into();
b.cursor = 11;
b.handle_delete_word();
assert_eq!(b.buffer, "hello ");
assert_eq!(b.cursor, 6);
}
#[test]
fn handle_delete_word_with_trailing_space() {
let mut b = InputBar::default();
b.buffer = "hello world ".into();
b.cursor = 13;
b.handle_delete_word();
assert_eq!(b.buffer, "hello ");
}
#[test]
fn handle_clear_line_empties() {
let mut b = InputBar::default();
b.buffer = "stuff".into();
b.cursor = 5;
b.handle_clear_line();
assert_eq!(b.buffer, "");
assert_eq!(b.cursor, 0);
}
#[test]
fn handle_left_clamps_at_zero() {
let mut b = InputBar::default();
b.handle_left();
assert_eq!(b.cursor, 0);
b.buffer = "hi".into();
b.cursor = 1;
b.handle_left();
assert_eq!(b.cursor, 0);
b.handle_left();
assert_eq!(b.cursor, 0);
}
#[test]
fn handle_right_clamps_at_end() {
let mut b = InputBar::default();
b.buffer = "hi".into();
b.cursor = 0;
b.handle_right();
assert_eq!(b.cursor, 1);
b.handle_right();
assert_eq!(b.cursor, 2);
b.handle_right();
assert_eq!(b.cursor, 2);
}
#[test]
fn up_down_clamp_at_boundaries() {
let mut b = InputBar::default();
b.set_suggestions(vec![s("a"), s("b"), s("c")]);
assert_eq!(b.selected, None);
b.handle_down();
assert_eq!(b.selected, Some(0));
b.handle_down();
assert_eq!(b.selected, Some(1));
b.handle_down();
assert_eq!(b.selected, Some(2));
b.handle_down(); assert_eq!(b.selected, Some(2));
b.handle_up();
assert_eq!(b.selected, Some(1));
b.handle_up();
assert_eq!(b.selected, Some(0));
b.handle_up(); assert_eq!(b.selected, None);
b.handle_up(); assert_eq!(b.selected, None);
}
#[test]
fn current_value_uses_selection_when_set() {
let mut b = InputBar::default();
b.buffer = "typed".into();
b.set_suggestions(vec![Suggestion {
display: "first".into(),
value: "first-value".into(),
kind: SuggestionKind::History,
}]);
assert_eq!(b.current_value(), "typed");
b.handle_down();
assert_eq!(b.current_value(), "first-value");
}
#[test]
fn current_value_falls_back_when_no_suggestions() {
let mut b = InputBar::default();
b.buffer = "raw".into();
assert_eq!(b.current_value(), "raw");
}
#[test]
fn set_suggestions_truncates_to_max() {
let mut b = InputBar::default();
let many: Vec<_> = (0..20).map(|i| s(&format!("row{i}"))).collect();
b.set_suggestions(many);
assert_eq!(b.suggestions.len(), MAX_SUGGESTIONS);
}
#[test]
fn total_height_grows_with_suggestion_count() {
let mut b = InputBar::default();
assert_eq!(b.total_height(), INPUT_HEIGHT);
b.set_suggestions(vec![s("a"), s("b")]);
assert_eq!(b.total_height(), INPUT_HEIGHT + 2 * SUGGESTION_ROW_HEIGHT);
}
#[test]
fn paint_smoke_no_crash_with_dropdown() {
let w = 400;
let h = 200;
let mut buf = vec![0u32; w * h];
let mut b = InputBar::default();
b.buffer = "hello".into();
b.cursor = 5;
b.set_suggestions(vec![s("first"), s("second")]);
b.handle_down();
b.paint(&mut buf, w, h);
assert_eq!(buf[0], b.palette.bg);
}
#[test]
fn editing_resets_selection() {
let mut b = InputBar::default();
b.set_suggestions(vec![s("a"), s("b")]);
b.handle_down();
assert_eq!(b.selected, Some(0));
b.handle_text('x');
assert_eq!(b.selected, None);
}
#[test]
fn clear_resets_state() {
let mut b = InputBar::default();
b.buffer = "stuff".into();
b.cursor = 5;
b.set_suggestions(vec![s("a")]);
b.handle_down();
b.clear();
assert_eq!(b.buffer, "");
assert_eq!(b.cursor, 0);
assert_eq!(b.selected, None);
assert!(b.suggestions.is_empty());
}
}