use crate::event::Rect;
use crate::types::WidgetId;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandLine {
pub id: WidgetId,
pub text: String,
#[serde(default)]
pub cursor_offset: Option<usize>,
#[serde(default)]
pub right_align: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CommandLineMeasure {
pub char_width: f32,
}
impl CommandLineMeasure {
pub fn new(char_width: f32) -> Self {
Self { char_width }
}
pub fn from_metrics(m: &crate::backend::Metrics) -> Self {
Self::new(m.char_width)
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct CommandLineLayout {
pub bounds: Rect,
pub text_origin_x: f32,
pub char_width: f32,
char_byte_offsets: Vec<usize>,
}
impl CommandLineLayout {
pub fn hit_test(&self, x: f32) -> usize {
let last = self.char_byte_offsets.last().copied().unwrap_or(0);
if self.char_width <= 0.0 || self.char_byte_offsets.len() <= 1 {
return last;
}
if x <= self.text_origin_x {
return self.char_byte_offsets[0];
}
let col = ((x - self.text_origin_x) / self.char_width).floor() as usize;
let max_col = self.char_byte_offsets.len() - 1;
self.char_byte_offsets[col.min(max_col)]
}
pub fn char_bounds(&self, byte_offset: usize) -> Rect {
let col = self.column_for_byte_offset(byte_offset);
Rect::new(
self.text_origin_x + col as f32 * self.char_width,
self.bounds.y,
self.char_width,
self.bounds.height,
)
}
pub fn selection_bounds(&self, sel: (usize, usize)) -> Option<Rect> {
let (lo, hi) = if sel.0 <= sel.1 { sel } else { (sel.1, sel.0) };
if lo == hi {
return None;
}
let lo_col = self.column_for_byte_offset(lo);
let hi_col = self.column_for_byte_offset(hi);
if hi_col <= lo_col {
return None;
}
Some(Rect::new(
self.text_origin_x + lo_col as f32 * self.char_width,
self.bounds.y,
(hi_col - lo_col) as f32 * self.char_width,
self.bounds.height,
))
}
fn column_for_byte_offset(&self, byte_offset: usize) -> usize {
match self.char_byte_offsets.binary_search(&byte_offset) {
Ok(i) => i,
Err(0) => 0,
Err(i) => i - 1,
}
}
}
impl CommandLine {
pub fn layout(&self, rect: Rect, measure: CommandLineMeasure) -> CommandLineLayout {
let char_width = measure.char_width.max(0.0);
let mut char_byte_offsets: Vec<usize> = self.text.char_indices().map(|(b, _)| b).collect();
char_byte_offsets.push(self.text.len());
let n_chars = char_byte_offsets.len().saturating_sub(1);
let text_origin_x = if self.right_align && char_width > 0.0 {
let text_w = n_chars as f32 * char_width;
(rect.x + rect.width - text_w).max(rect.x)
} else {
rect.x
};
CommandLineLayout {
bounds: rect,
text_origin_x,
char_width,
char_byte_offsets,
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{CommandLine, CommandLineLayout, CommandLineMeasure};
use crate::event::Rect;
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
const CURSOR_WIDTH: f32 = 2.0;
pub(crate) fn paint(
cmd: &CommandLine,
surface: &mut dyn PaintSurface,
theme: &Theme,
rect: Rect,
char_width: f32,
selection: Option<(usize, usize)>,
) -> CommandLineLayout {
let layout = cmd.layout(rect, CommandLineMeasure::new(char_width));
if rect.width <= 0.0 || rect.height <= 0.0 {
return layout;
}
surface.surface_push_clip(rect);
surface.surface_fill_rect(rect, theme.command_line_bg);
if let Some(sel) = selection {
if let Some(r) = layout.selection_bounds(sel) {
surface.surface_fill_rect_alpha(r, theme.selection, theme.selection_alpha);
}
}
if !cmd.text.is_empty() {
let text_rect = Rect::new(
layout.text_origin_x,
rect.y,
(rect.x + rect.width - layout.text_origin_x).max(0.0),
rect.height,
);
surface.surface_draw_text_run(text_rect, &cmd.text, theme.command_line_fg);
if let Some(offset) = cmd.cursor_offset {
let col_rect = layout.char_bounds(offset);
let cursor_rect = Rect::new(col_rect.x, col_rect.y, CURSOR_WIDTH, col_rect.height);
surface.surface_fill_rect(cursor_rect, theme.cursor);
}
}
surface.surface_pop_clip();
layout
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::types::{Color, WidgetId};
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
text_runs: Vec<(Rect, String, Color)>,
clip_pushes: Vec<Rect>,
clip_pops: usize,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: crate::Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> crate::Viewport {
crate::Viewport::new(200.0, 100.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 8.0, 16.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, _rect: Rect, _color: Color, _stroke_width: f32) {}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.text_runs.push((rect, text.to_string(), color));
}
fn surface_draw_line(
&mut self,
_from: crate::Point,
_to: crate::Point,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_push_clip(&mut self, rect: Rect) {
self.clip_pushes.push(rect);
}
fn surface_pop_clip(&mut self) {
self.clip_pops += 1;
}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn sample(text: &str, cursor: Option<usize>, right_align: bool) -> CommandLine {
CommandLine {
id: WidgetId::new("cmdline"),
text: text.into(),
cursor_offset: cursor,
right_align,
}
}
#[test]
fn fills_background_then_text_then_cursor() {
let cmd = sample(":wq", Some(1), false);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let rect = Rect::new(0.0, 0.0, 100.0, 20.0);
let layout = paint(&cmd, &mut surface, &theme, rect, 8.0, None);
assert_eq!(surface.fills[0], (rect, theme.command_line_bg));
assert_eq!(surface.text_runs.len(), 1);
assert_eq!(surface.text_runs[0].1, ":wq");
assert_eq!(surface.text_runs[0].2, theme.command_line_fg);
let cursor_rect = layout.char_bounds(1);
assert_eq!(surface.fills[1].0.x, cursor_rect.x);
assert_eq!(surface.fills[1].1, theme.cursor);
}
#[test]
fn no_selection_paints_no_highlight() {
let cmd = sample(":wq", None, false);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&cmd,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 100.0, 20.0),
8.0,
None,
);
assert_eq!(surface.fills.len(), 1);
}
#[test]
fn selection_paints_highlight_before_text() {
let cmd = sample(":wq!", None, false);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let rect = Rect::new(0.0, 0.0, 100.0, 20.0);
let layout = paint(&cmd, &mut surface, &theme, rect, 8.0, Some((0, 3)));
let sel_rect = layout.selection_bounds((0, 3)).unwrap();
assert_eq!(surface.fills.len(), 2);
assert_eq!(surface.fills[1].0, sel_rect);
assert_eq!(
surface.fills[1].1,
theme.selection.with_alpha(theme.selection_alpha as f64)
);
}
#[test]
fn empty_selection_range_paints_no_highlight() {
let cmd = sample(":wq!", None, false);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&cmd,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 100.0, 20.0),
8.0,
Some((2, 2)),
);
assert_eq!(surface.fills.len(), 1);
}
#[test]
fn cursor_respects_right_align_shift() {
let cmd = sample("3/17", Some(2), true);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let rect = Rect::new(0.0, 0.0, 20.0, 20.0);
let layout = paint(&cmd, &mut surface, &theme, rect, 1.0, None);
let cursor_rect = layout.char_bounds(2);
assert!(
cursor_rect.x > rect.x,
"right-aligned text's cursor must shift right of the bar origin"
);
let cursor_fill = surface
.fills
.iter()
.find(|(r, c)| *c == theme.cursor && r.x == cursor_rect.x)
.expect("cursor should paint at char_bounds(2)");
let _ = cursor_fill;
}
#[test]
fn zero_size_rect_paints_nothing() {
let cmd = sample(":wq", Some(1), false);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&cmd,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 0.0, 20.0),
8.0,
Some((0, 1)),
);
assert!(surface.fills.is_empty());
assert!(surface.text_runs.is_empty());
assert!(surface.clip_pushes.is_empty());
assert_eq!(surface.clip_pops, 0);
}
#[test]
fn clip_pushed_and_popped_once_around_paint() {
let cmd = sample(":wq", None, false);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let rect = Rect::new(3.0, 4.0, 50.0, 20.0);
paint(&cmd, &mut surface, &theme, rect, 8.0, None);
assert_eq!(surface.clip_pushes, vec![rect]);
assert_eq!(surface.clip_pops, 1);
}
#[test]
fn multibyte_cursor_offset_does_not_panic() {
let text = ":éditer";
assert!(!text.is_char_boundary(2));
let cmd = sample(text, Some(2), false);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&cmd,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 100.0, 20.0),
8.0,
None,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_serde() {
let cmd = CommandLine {
id: "cmd".into(),
text: ":wq".into(),
cursor_offset: Some(3),
right_align: false,
};
let json = serde_json::to_string(&cmd).unwrap();
let back: CommandLine = serde_json::from_str(&json).unwrap();
assert_eq!(back.text, ":wq");
assert_eq!(back.cursor_offset, Some(3));
}
#[test]
fn defaults_via_serde() {
let json = r#"{"id":"cmd","text":"hello"}"#;
let cmd: CommandLine = serde_json::from_str(json).unwrap();
assert_eq!(cmd.cursor_offset, None);
assert!(!cmd.right_align);
}
fn cmd(text: &str, right_align: bool) -> CommandLine {
CommandLine {
id: WidgetId::new("cmd"),
text: text.into(),
cursor_offset: None,
right_align,
}
}
#[test]
fn hit_test_maps_x_to_byte_offset_left_aligned() {
let c = cmd(":wq", false);
let layout = c.layout(Rect::new(0.0, 0.0, 20.0, 1.0), CommandLineMeasure::new(1.0));
assert_eq!(layout.hit_test(0.0), 0); assert_eq!(layout.hit_test(1.5), 1); assert_eq!(layout.hit_test(100.0), 3); }
#[test]
fn hit_test_at_nonzero_origin() {
let c = cmd(":wq", false);
let layout = c.layout(
Rect::new(10.0, 5.0, 20.0, 1.0),
CommandLineMeasure::new(1.0),
);
assert_eq!(layout.hit_test(3.0), 0);
assert_eq!(layout.hit_test(10.0), 0);
assert_eq!(layout.hit_test(11.5), 1);
}
#[test]
fn hit_test_accounts_for_multibyte_chars() {
let c = cmd(":éditer", false);
let layout = c.layout(Rect::new(0.0, 0.0, 20.0, 1.0), CommandLineMeasure::new(1.0));
assert_eq!(layout.hit_test(0.5), 0);
assert_eq!(layout.hit_test(1.5), 1); assert_eq!(layout.hit_test(2.5), 3); }
#[test]
fn hit_test_right_aligned_shifts_text_origin() {
let c = cmd("3/17", true);
let layout = c.layout(Rect::new(0.0, 0.0, 10.0, 1.0), CommandLineMeasure::new(1.0));
assert_eq!(layout.text_origin_x, 6.0);
assert_eq!(layout.hit_test(0.0), 0); assert_eq!(layout.hit_test(6.5), 0);
assert_eq!(layout.hit_test(7.5), 1);
}
#[test]
fn hit_test_empty_text_always_zero() {
let c = cmd("", false);
let layout = c.layout(Rect::new(0.0, 0.0, 10.0, 1.0), CommandLineMeasure::new(1.0));
assert_eq!(layout.hit_test(0.0), 0);
assert_eq!(layout.hit_test(9.0), 0);
}
#[test]
fn selection_bounds_spans_the_selected_columns() {
let c = cmd(":wq!", false);
let layout = c.layout(Rect::new(0.0, 0.0, 20.0, 1.0), CommandLineMeasure::new(1.0));
let r = layout.selection_bounds((0, 3)).unwrap();
assert_eq!((r.x, r.width), (0.0, 3.0));
}
#[test]
fn selection_bounds_order_independent() {
let c = cmd(":wq!", false);
let layout = c.layout(Rect::new(0.0, 0.0, 20.0, 1.0), CommandLineMeasure::new(1.0));
assert_eq!(
layout.selection_bounds((3, 0)),
layout.selection_bounds((0, 3))
);
}
#[test]
fn selection_bounds_empty_range_is_none() {
let c = cmd(":wq!", false);
let layout = c.layout(Rect::new(0.0, 0.0, 20.0, 1.0), CommandLineMeasure::new(1.0));
assert!(layout.selection_bounds((2, 2)).is_none());
}
#[test]
fn selection_bounds_at_nonzero_origin() {
let c = cmd(":wq!", false);
let layout = c.layout(
Rect::new(10.0, 5.0, 20.0, 1.0),
CommandLineMeasure::new(1.0),
);
let r = layout.selection_bounds((0, 3)).unwrap();
assert_eq!((r.x, r.y, r.width), (10.0, 5.0, 3.0));
}
#[test]
fn char_bounds_snaps_mid_char_offset_to_its_column() {
let c = cmd(":éditer", false);
let layout = c.layout(Rect::new(0.0, 0.0, 20.0, 1.0), CommandLineMeasure::new(1.0));
let snapped = layout.char_bounds(2);
let exact = layout.char_bounds(1);
assert_eq!(snapped, exact);
}
#[test]
fn roundtrip_hit_test_then_selection_bounds() {
let c = cmd(":wq!", false);
let layout = c.layout(Rect::new(0.0, 0.0, 20.0, 1.0), CommandLineMeasure::new(1.0));
let start = layout.hit_test(1.0);
let end = layout.hit_test(3.0);
let r = layout.selection_bounds((start, end)).unwrap();
assert_eq!((r.x, r.width), (1.0, 2.0));
}
}