use crate::event::Rect;
use crate::primitives::scrollbar::fit_thumb;
use crate::types::{Icon, Modifiers, StyledText, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum PaletteMode {
#[default]
List,
Input,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Palette {
pub id: WidgetId,
pub title: String,
pub query: String,
#[serde(default)]
pub query_cursor: usize,
pub items: Vec<PaletteItem>,
pub selected_idx: usize,
#[serde(default)]
pub scroll_offset: usize,
#[serde(default)]
pub total_count: usize,
#[serde(default)]
pub has_focus: bool,
#[serde(default = "default_true")]
pub show_query: bool,
#[serde(default)]
pub create_label: Option<String>,
#[serde(default)]
pub preview: Option<PalettePreview>,
#[serde(default)]
pub mode: PaletteMode,
}
impl Default for Palette {
fn default() -> Self {
Palette {
id: WidgetId::new(""),
title: String::new(),
query: String::new(),
query_cursor: 0,
items: Vec::new(),
selected_idx: 0,
scroll_offset: 0,
total_count: 0,
has_focus: false,
show_query: true,
create_label: None,
preview: None,
mode: PaletteMode::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaletteItem {
pub text: StyledText,
#[serde(default)]
pub detail: Option<StyledText>,
#[serde(default)]
pub icon: Option<Icon>,
#[serde(default)]
pub match_positions: Vec<usize>,
#[serde(default)]
pub depth: usize,
#[serde(default)]
pub expandable: bool,
#[serde(default)]
pub expanded: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PalettePreview {
pub lines: Vec<StyledText>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub scroll_offset: usize,
#[serde(default)]
pub highlight_line: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PaletteItemMeasure {
pub height: f32,
}
impl PaletteItemMeasure {
pub fn new(height: f32) -> Self {
Self { height }
}
pub fn from_metrics(m: &crate::backend::Metrics) -> Self {
Self::new(m.line_height)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VisiblePaletteItem {
pub item_idx: usize,
pub bounds: Rect,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaletteHit {
Title,
Query,
Item(usize),
ExpandToggle(usize),
CreateAction,
Preview,
ScrollbarThumb,
ScrollbarTrack,
Empty,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PaletteScrollbar {
pub track: Rect,
pub thumb: Rect,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PaletteLayout {
pub viewport_width: f32,
pub viewport_height: f32,
pub title_bounds: Option<Rect>,
pub query_bounds: Option<Rect>,
pub visible_items: Vec<VisiblePaletteItem>,
pub hit_regions: Vec<(Rect, PaletteHit)>,
pub resolved_scroll_offset: usize,
pub item_list_width: f32,
pub create_bounds: Option<Rect>,
pub preview_bounds: Option<Rect>,
pub scrollbar: Option<PaletteScrollbar>,
}
impl PaletteLayout {
pub fn hit_test(&self, x: f32, y: f32) -> PaletteHit {
for (rect, hit) in &self.hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return hit.clone();
}
}
PaletteHit::Empty
}
}
impl Palette {
pub fn resolved_scroll_offset(&self, visible_rows: usize) -> usize {
let total = self.items.len();
let max_offset = total.saturating_sub(visible_rows);
let effective = if visible_rows == 0 {
0
} else if self.selected_idx < self.scroll_offset {
self.selected_idx
} else if self.selected_idx >= self.scroll_offset + visible_rows {
self.selected_idx + 1 - visible_rows
} else {
self.scroll_offset
};
effective.min(max_offset)
}
#[allow(clippy::too_many_arguments)]
pub fn layout<F>(
&self,
viewport_width: f32,
viewport_height: f32,
title_height: f32,
query_height: f32,
scrollbar_width: f32,
min_thumb_len: f32,
measure_item: F,
) -> PaletteLayout
where
F: Fn(usize) -> PaletteItemMeasure,
{
let has_preview = self.preview.is_some();
let item_list_width = if has_preview {
(viewport_width * 0.4).round()
} else {
viewport_width
};
let mut visible_items: Vec<VisiblePaletteItem> = Vec::new();
let mut hit_regions: Vec<(Rect, PaletteHit)> = Vec::new();
let mut y = 0.0_f32;
let title_bounds = if title_height > 0.0 && y < viewport_height {
let h = title_height.min(viewport_height - y);
let bounds = Rect::new(0.0, y, viewport_width, h);
hit_regions.push((bounds, PaletteHit::Title));
y += h;
Some(bounds)
} else {
None
};
let query_bounds = if query_height > 0.0 && y < viewport_height {
let h = query_height.min(viewport_height - y);
let bounds = Rect::new(0.0, y, viewport_width, h);
hit_regions.push((bounds, PaletteHit::Query));
y += h;
Some(bounds)
} else {
None
};
let items_top = y;
let create_row_h = if self.create_label.is_some() {
measure_item(0).height
} else {
0.0
};
let items_bottom = viewport_height - create_row_h;
let item_height = if self.items.is_empty() {
0.0
} else {
measure_item(0).height
};
let estimated_visible_rows = if item_height > 0.0 {
((items_bottom - items_top).max(0.0) / item_height) as usize
} else {
0
};
let resolved_scroll_offset = self.resolved_scroll_offset(estimated_visible_rows);
for i in resolved_scroll_offset..self.items.len() {
if y >= items_bottom {
break;
}
let m = measure_item(i);
let remaining = items_bottom - y;
let height = m.height.min(remaining).max(0.0);
if height <= 0.0 {
break;
}
let bounds = Rect::new(0.0, y, item_list_width, height);
visible_items.push(VisiblePaletteItem {
item_idx: i,
bounds,
});
hit_regions.push((bounds, PaletteHit::Item(i)));
y += m.height;
}
let total = self.items.len();
let visible_count = visible_items.len();
let has_scrollbar = scrollbar_width > 0.0 && total > visible_count;
let scrollbar = if has_scrollbar {
let track_h = items_bottom - items_top;
let track = Rect::new(
item_list_width - scrollbar_width,
items_top,
scrollbar_width,
track_h,
);
let (thumb_start, thumb_len) = fit_thumb(
resolved_scroll_offset as f32,
total as f32,
visible_count as f32,
track_h,
min_thumb_len,
);
let thumb = Rect::new(track.x, items_top + thumb_start, scrollbar_width, thumb_len);
let content_width = item_list_width - scrollbar_width;
for vi in &mut visible_items {
vi.bounds.width = content_width;
}
for (rect, hit) in &mut hit_regions {
if matches!(hit, PaletteHit::Item(_) | PaletteHit::ExpandToggle(_)) {
rect.width = content_width;
}
}
hit_regions.push((thumb, PaletteHit::ScrollbarThumb));
hit_regions.push((track, PaletteHit::ScrollbarTrack));
Some(PaletteScrollbar { track, thumb })
} else {
None
};
let create_bounds = if self.create_label.is_some() && items_bottom < viewport_height {
let bounds = Rect::new(0.0, items_bottom, item_list_width, create_row_h);
hit_regions.push((bounds, PaletteHit::CreateAction));
Some(bounds)
} else {
None
};
let preview_bounds = if has_preview {
let preview_x = item_list_width;
let preview_w = (viewport_width - item_list_width).max(0.0);
let preview_h = (viewport_height - items_top).max(0.0);
let bounds = Rect::new(preview_x, items_top, preview_w, preview_h);
hit_regions.push((bounds, PaletteHit::Preview));
Some(bounds)
} else {
None
};
PaletteLayout {
viewport_width,
viewport_height,
title_bounds,
query_bounds,
visible_items,
hit_regions,
create_bounds,
resolved_scroll_offset,
item_list_width,
preview_bounds,
scrollbar,
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{Palette, PaletteItemMeasure, PaletteLayout, PaletteMode};
use crate::paint_surface::PaintSurface;
use crate::style::Style;
use crate::text_util::safe_prefix;
use crate::theme::Theme;
use crate::types::Color;
use crate::Rect;
use std::collections::HashSet;
pub(crate) const SB_W: f32 = 6.0;
pub(crate) const MIN_THUMB_LEN: f32 = 8.0;
const BOTTOM_INSET: f32 = 4.0;
pub(crate) fn layout(
w: f32,
h: f32,
palette: &Palette,
line_height: f32,
) -> (PaletteLayout, f32) {
let title_h = line_height;
let query_h = if palette.show_query {
line_height + 1.0
} else {
0.0
};
let has_create = palette.create_label.is_some();
let create_reserved = if has_create { line_height } else { 0.0 };
let items_top = title_h + query_h;
let raw_items_h = (h - items_top - BOTTOM_INSET - create_reserved).max(0.0);
let visible_rows = (raw_items_h / line_height) as usize;
let rows_h = visible_rows as f32 * line_height;
let viewport_h = items_top + rows_h + create_reserved;
let resolved = palette.layout(w, viewport_h, title_h, query_h, SB_W, MIN_THUMB_LEN, |_| {
PaletteItemMeasure::new(line_height)
});
(resolved, rows_h)
}
fn matched_runs(text: &str, match_positions: &[usize]) -> Vec<(String, bool)> {
if match_positions.is_empty() {
return vec![(text.to_string(), false)];
}
let matches: HashSet<usize> = match_positions.iter().copied().collect();
let mut runs: Vec<(String, bool)> = Vec::new();
let mut cur = String::new();
let mut cur_hi = false;
let mut first = true;
for (byte_idx, ch) in text.char_indices() {
let hi = matches.contains(&byte_idx);
if first {
cur_hi = hi;
first = false;
} else if hi != cur_hi {
runs.push((std::mem::take(&mut cur), cur_hi));
cur_hi = hi;
}
cur.push(ch);
}
if !cur.is_empty() {
runs.push((cur, cur_hi));
}
runs
}
fn draw_matched_text(
surface: &mut dyn PaintSurface,
text: &str,
match_positions: &[usize],
row: Rect,
fg: crate::Color,
match_fg: crate::Color,
) -> f32 {
let mut cursor_x = row.x;
for (run, hi) in matched_runs(text, match_positions) {
if run.is_empty() {
continue;
}
let (w, h) = surface.surface_measure_text(&run);
let color = if hi { match_fg } else { fg };
surface.surface_draw_text_run(
Rect::new(cursor_x, row.y + (row.height - h) / 2.0, w.max(1.0), h),
&run,
color,
);
cursor_x += w;
}
cursor_x - row.x
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint(
palette: &Palette,
area: Rect,
palette_layout: &PaletteLayout,
rows_h: f32,
line_height: f32,
nerd_fonts_enabled: bool,
surface: &mut dyn PaintSurface,
theme: &Theme,
style: &Style,
) {
if area.width < 20.0 || area.height < line_height * 4.0 {
return;
}
surface.surface_draw_shadow(
area,
style.corner_radius,
style.shadow_elevation,
Color::rgba(0, 0, 0, 100),
);
surface.surface_push_clip(area);
let backdrop = theme.surface_bg.with_alpha(style.backdrop_opacity as f64);
if style.backdrop_opacity >= 1.0 {
surface.surface_fill_rect(area, backdrop);
}
surface.surface_fill_rounded_rect(area, style.corner_radius, backdrop);
surface.surface_stroke_rounded_rect(
area,
style.corner_radius,
theme.border_fg,
style.border_width,
);
if let Some(tb) = palette_layout.title_bounds {
let title_text = if palette.total_count > 0 {
format!(
" {} {}/{} ",
palette.title,
palette.items.len(),
palette.total_count
)
} else {
format!(" {} ", palette.title)
};
let (_, th) = surface.surface_measure_text(&title_text);
surface.surface_draw_text_run(
Rect::new(
area.x + tb.x + 8.0,
area.y + tb.y + (tb.height - th) / 2.0,
tb.width - 8.0,
th,
),
&title_text,
theme.title_fg,
);
}
if let Some(qb) = palette_layout.query_bounds {
let qx = area.x + qb.x;
let qy = area.y + qb.y;
let prompt = "> ";
let (pw, ph) = surface.surface_measure_text(prompt);
surface.surface_draw_text_run(
Rect::new(qx + 8.0, qy + (qb.height - ph) / 2.0, pw, ph),
prompt,
theme.query_fg,
);
let query_text_x = qx + 8.0 + pw;
let (_, qh) = surface.surface_measure_text(&palette.query);
surface.surface_draw_text_run(
Rect::new(query_text_x, qy + (qb.height - qh) / 2.0, qb.width, qh),
&palette.query,
theme.query_fg,
);
let cursor_prefix = safe_prefix(&palette.query, palette.query_cursor);
let (cursor_prefix_w, _) = surface.surface_measure_text(cursor_prefix);
let cursor_x = query_text_x + cursor_prefix_w;
let cursor_char: String = palette
.query
.get(palette.query_cursor..)
.and_then(|s| s.chars().next())
.map(|c| c.to_string())
.unwrap_or_else(|| " ".to_string());
let (cursor_w, _) = surface.surface_measure_text(&cursor_char);
let cursor_w = cursor_w.max(line_height * 0.45);
surface.surface_fill_rect(Rect::new(cursor_x, qy, cursor_w, qb.height), theme.query_fg);
if !cursor_char.trim().is_empty() {
let (_, ch) = surface.surface_measure_text(&cursor_char);
surface.surface_draw_text_run(
Rect::new(cursor_x, qy + (qb.height - ch) / 2.0, cursor_w, ch),
&cursor_char,
theme.surface_bg,
);
}
}
if palette.show_query && palette.mode != PaletteMode::Input {
if let Some(qb) = palette_layout.query_bounds {
let sep_y = area.y + qb.y + (qb.height - 1.0).max(0.0);
surface
.surface_fill_rect(Rect::new(area.x, sep_y, area.width, 1.0), theme.border_fg);
}
}
if palette.mode == PaletteMode::Input {
surface.surface_pop_clip();
return;
}
let items_top = palette_layout
.query_bounds
.map(|b| b.y + b.height)
.or_else(|| palette_layout.title_bounds.map(|b| b.y + b.height))
.unwrap_or(0.0);
let rows_y = area.y + items_top;
let content_w = palette_layout.item_list_width
- if palette_layout.scrollbar.is_some() {
SB_W
} else {
0.0
};
surface.surface_push_clip(Rect::new(area.x, rows_y, content_w, rows_h));
for vis in &palette_layout.visible_items {
let item = &palette.items[vis.item_idx];
let row_x = area.x + vis.bounds.x;
let row_y = area.y + vis.bounds.y;
let row_w = vis.bounds.width;
let row_h = vis.bounds.height;
let is_selected = vis.item_idx == palette.selected_idx && palette.has_focus;
if is_selected {
surface.surface_fill_rect(Rect::new(row_x, row_y, row_w, row_h), theme.selected_bg);
}
let mut cursor_x = row_x + 8.0;
let prefix = if is_selected { "\u{25b6} " } else { " " };
let (pw, ph) = surface.surface_measure_text(prefix);
surface.surface_draw_text_run(
Rect::new(cursor_x, row_y + (row_h - ph) / 2.0, pw, ph),
prefix,
theme.surface_fg,
);
cursor_x += pw;
if let Some(ref icon) = item.icon {
let glyph = if nerd_fonts_enabled {
icon.glyph.as_str()
} else {
icon.fallback.as_str()
};
let (iw, ih) = surface.surface_measure_text(glyph);
surface.surface_draw_icon_glyph(
Rect::new(cursor_x, row_y + (row_h - ih) / 2.0, iw, ih),
glyph,
theme.surface_fg,
);
cursor_x += iw + 6.0;
}
let detail_info = item.detail.as_ref().map(|detail| {
let detail_text: String = detail.spans.iter().map(|s| s.text.as_str()).collect();
let (dw, _) = surface.surface_measure_text(&detail_text);
(detail_text, dw)
});
let detail_reserve = detail_info.as_ref().map(|(_, dw)| *dw + 8.0).unwrap_or(0.0);
let text_right_limit = row_x + row_w - detail_reserve - 4.0;
let full_text: String = item.text.spans.iter().map(|s| s.text.as_str()).collect();
draw_matched_text(
surface,
&full_text,
&item.match_positions,
Rect::new(
cursor_x,
row_y,
(text_right_limit - cursor_x).max(0.0),
row_h,
),
theme.surface_fg,
theme.match_fg,
);
if let Some((detail_text, dw)) = detail_info {
let dx = row_x + row_w - dw - 8.0;
if dx > cursor_x {
let (_, dh) = surface.surface_measure_text(&detail_text);
surface.surface_draw_text_run(
Rect::new(dx, row_y + (row_h - dh) / 2.0, dw, dh),
&detail_text,
theme.muted_fg,
);
}
}
}
surface.surface_pop_clip();
if let Some(sb) = &palette_layout.scrollbar {
let track = Rect::new(
area.x + sb.track.x,
area.y + sb.track.y,
sb.track.width,
sb.track.height,
);
surface.surface_fill_rect(track, theme.scrollbar_track.with_alpha(0.4));
let thumb = Rect::new(
area.x + sb.thumb.x + 1.0,
area.y + sb.thumb.y,
(sb.thumb.width - 2.0).max(0.0),
sb.thumb.height,
);
surface.surface_fill_rect(thumb, theme.scrollbar_thumb.with_alpha(0.8));
}
if let Some(ref label) = palette.create_label {
let create_y = rows_y + rows_h;
surface.surface_fill_rect(
Rect::new(
area.x,
create_y,
palette_layout.item_list_width,
line_height,
),
theme.hover_bg,
);
let prefix = "+ ";
let (pw, ph) = surface.surface_measure_text(prefix);
surface.surface_draw_text_run(
Rect::new(area.x + 8.0, create_y + (line_height - ph) / 2.0, pw, ph),
prefix,
theme.accent_fg,
);
let (lw, lh) = surface.surface_measure_text(label);
surface.surface_draw_text_run(
Rect::new(
area.x + 8.0 + pw,
create_y + (line_height - lh) / 2.0,
lw,
lh,
),
label,
theme.accent_fg,
);
}
if let (Some(pb), Some(preview)) = (palette_layout.preview_bounds, palette.preview.as_ref())
{
let preview_x = area.x + pb.x;
let preview_y = area.y + pb.y;
let preview_w = pb.width;
let preview_h = pb.height;
surface.surface_draw_line(
crate::Point::new(preview_x, preview_y),
crate::Point::new(preview_x, preview_y + preview_h),
theme.border_fg,
1.0,
);
surface.surface_push_clip(Rect::new(preview_x, preview_y, preview_w, preview_h));
let content_x = preview_x + 8.0;
let content_right = preview_x + preview_w - 8.0;
let mut cursor_y = preview_y;
if let Some(ref title) = preview.title {
let (_, th) = surface.surface_measure_text(title);
surface.surface_draw_text_run(
Rect::new(
content_x,
cursor_y + (line_height - th) / 2.0,
content_right - content_x,
th,
),
title,
theme.muted_fg,
);
cursor_y += line_height;
}
let preview_visible =
((preview_y + preview_h - cursor_y) / line_height).max(0.0) as usize;
for (vi, line_idx) in (preview.scroll_offset..).take(preview_visible).enumerate() {
let row_y = cursor_y + vi as f32 * line_height;
if row_y + line_height > preview_y + preview_h + 0.5 {
break;
}
if line_idx >= preview.lines.len() {
break;
}
if preview.highlight_line == Some(line_idx) {
surface.surface_fill_rect(
Rect::new(preview_x, row_y, preview_w, line_height),
theme.selected_bg,
);
}
let line = &preview.lines[line_idx];
let mut cx = content_x;
for span in &line.spans {
if cx > content_right {
break;
}
let span_fg = span.fg.unwrap_or(theme.foreground);
let (sw, sh) = surface.surface_measure_text(&span.text);
surface.surface_draw_text_run(
Rect::new(cx, row_y + (line_height - sh) / 2.0, sw, sh),
&span.text,
span_fg,
);
cx += sw;
}
}
surface.surface_pop_clip();
}
surface.surface_pop_clip();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::{Point, Viewport};
use crate::primitives::palette::PaletteItem;
use crate::types::{Color, StyledText, WidgetId};
use crate::Image;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FillKind {
Rect,
Rounded,
}
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
fill_kinds: Vec<FillKind>,
texts: Vec<(Rect, String, Color)>,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(200.0, 120.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, 14.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
self.fill_kinds.push(FillKind::Rect);
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
self.fill_kinds.push(FillKind::Rounded);
}
fn surface_stroke_rect(&mut self, _rect: Rect, _color: Color, _stroke_width: f32) {}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_draw_shadow(
&mut self,
_rect: Rect,
_radius: f32,
_elevation: u8,
_color: Color,
) {
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.texts.push((rect, text.to_string(), color));
}
fn surface_draw_line(&mut self, _from: Point, _to: Point, _color: Color, _sw: f32) {}
fn surface_push_clip(&mut self, _rect: Rect) {}
fn surface_pop_clip(&mut self) {}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
const LINE_HEIGHT: f32 = 16.0;
const AREA: Rect = Rect::new(0.0, 0.0, 200.0, 120.0);
fn item(label: &str) -> PaletteItem {
PaletteItem {
text: StyledText::plain(label.to_string()),
detail: None,
icon: None,
match_positions: vec![],
depth: 0,
expandable: false,
expanded: false,
}
}
fn sample() -> Palette {
Palette {
id: WidgetId::new("pal"),
title: "Commands".into(),
query: "op".into(),
query_cursor: 2,
items: vec![item("open file"), item("close"), item("quit")],
selected_idx: 0,
scroll_offset: 0,
total_count: 0,
has_focus: true,
show_query: true,
create_label: None,
preview: None,
mode: PaletteMode::List,
}
}
fn paint_sample(palette: &Palette) -> (RecordingSurface, PaletteLayout) {
let (palette_layout, rows_h) = layout(AREA.width, AREA.height, palette, LINE_HEIGHT);
let mut surface = RecordingSurface::default();
paint(
palette,
AREA,
&palette_layout,
rows_h,
LINE_HEIGHT,
false,
&mut surface,
&Theme::default(),
&Style::default(),
);
(surface, palette_layout)
}
#[test]
fn query_band_reserves_one_extra_pixel_for_the_separator() {
let p = sample();
let (palette_layout, _) = layout(AREA.width, AREA.height, &p, LINE_HEIGHT);
let qb = palette_layout.query_bounds.expect("query bounds present");
assert_eq!(qb.height, LINE_HEIGHT + 1.0);
assert_eq!(qb.y + qb.height, LINE_HEIGHT * 2.0 + 1.0);
}
#[test]
fn separator_sits_in_the_reserved_query_pixel_not_the_item_row() {
let p = sample();
let (surface, palette_layout) = paint_sample(&p);
let theme = Theme::default();
let qb = palette_layout.query_bounds.expect("query bounds present");
let first_row = palette_layout.visible_items[0].bounds;
let sep_idx = surface
.fills
.iter()
.position(|(r, c)| {
*c == theme.border_fg && r.height == 1.0 && r.width == AREA.width
})
.expect("separator fill emitted");
let sep = surface.fills[sep_idx].0;
assert_eq!(
sep.y,
qb.y + qb.height - 1.0,
"separator must occupy the last pixel of the query band"
);
assert_eq!(
first_row.y,
qb.y + qb.height,
"first item row starts immediately below the query band"
);
assert!(
sep.y + sep.height <= first_row.y,
"separator ({sep:?}) must not overlap the first item row ({first_row:?})"
);
let sel = surface
.fills
.iter()
.skip(sep_idx)
.find(|(_, c)| *c == theme.selected_bg)
.map(|(r, _)| *r)
.expect("selected row fill emitted after the separator");
assert_eq!(sel.y, first_row.y);
assert!(
sel.y >= sep.y + sep.height,
"row 0's selection fill ({sel:?}) overpaints the separator ({sep:?})"
);
}
#[test]
fn popup_background_covers_full_bounds_before_the_rounded_fill() {
let p = sample();
let (surface, _) = paint_sample(&p);
let theme = Theme::default();
assert_eq!(
surface.fills.first(),
Some(&(AREA, theme.surface_bg)),
"first fill must be an opaque, full-bounds background fill"
);
assert_eq!(surface.fill_kinds.first(), Some(&FillKind::Rect));
assert_eq!(
surface.fills.get(1),
Some(&(AREA, theme.surface_bg)),
"the rounded-corner fill must follow the full-bounds fill, not replace it"
);
assert_eq!(surface.fill_kinds.get(1), Some(&FillKind::Rounded));
}
#[test]
fn backdrop_fill_composites_exactly_once_when_translucent() {
let p = sample();
let (palette_layout, rows_h) = layout(AREA.width, AREA.height, &p, LINE_HEIGHT);
let mut surface = RecordingSurface::default();
let theme = Theme::default();
let style = Style::default().with_backdrop_opacity(0.4);
paint(
&p,
AREA,
&palette_layout,
rows_h,
LINE_HEIGHT,
false,
&mut surface,
&theme,
&style,
);
let expected = theme.surface_bg.with_alpha(0.4);
let backdrop_fills: Vec<&FillKind> = surface
.fills
.iter()
.zip(surface.fill_kinds.iter())
.filter(|((rect, color), _)| *rect == AREA && *color == expected)
.map(|(_, kind)| kind)
.collect();
assert_eq!(
backdrop_fills,
vec![&FillKind::Rounded],
"a translucent backdrop must paint `area` with the backdrop color exactly \
once, via the rounded fill — the plain rect pre-fill must be skipped so \
the two passes don't double-composite"
);
}
#[test]
fn input_mode_paints_no_separator() {
let mut p = sample();
p.mode = PaletteMode::Input;
let (surface, _) = paint_sample(&p);
let theme = Theme::default();
assert!(
!surface
.fills
.iter()
.any(|(r, c)| *c == theme.border_fg && r.height == 1.0),
"Input mode must not paint a query/list separator"
);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PaletteEvent {
QueryChanged { value: String },
SelectionChanged { idx: usize },
ItemConfirmed { idx: usize },
ExpandToggled { idx: usize, expanded: bool },
Closed,
KeyPressed { key: String, modifiers: Modifiers },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn palette_roundtrip_serde() {
let palette = Palette {
id: WidgetId::new("cmd-palette"),
title: "Commands".to_string(),
query: "open".to_string(),
query_cursor: 4,
items: vec![
PaletteItem {
text: StyledText::plain("Open File"),
detail: Some(StyledText::plain("Ctrl+O")),
icon: None,
match_positions: vec![0, 1, 2, 3],
depth: 0,
expandable: false,
expanded: false,
},
PaletteItem {
text: StyledText::plain("Open Recent"),
detail: None,
icon: None,
match_positions: vec![0, 1, 2, 3],
depth: 0,
expandable: false,
expanded: false,
},
],
selected_idx: 0,
scroll_offset: 0,
total_count: 42,
has_focus: true,
show_query: true,
create_label: None,
preview: None,
mode: PaletteMode::List,
};
let json = serde_json::to_string(&palette).unwrap();
let back: Palette = serde_json::from_str(&json).unwrap();
assert_eq!(palette, back);
}
#[test]
fn palette_event_roundtrip_serde() {
let events = vec![
PaletteEvent::QueryChanged {
value: "foo".to_string(),
},
PaletteEvent::SelectionChanged { idx: 3 },
PaletteEvent::ItemConfirmed { idx: 0 },
PaletteEvent::Closed,
PaletteEvent::KeyPressed {
key: "Ctrl+P".to_string(),
modifiers: Modifiers {
ctrl: true,
..Modifiers::default()
},
},
];
for event in &events {
let json = serde_json::to_string(event).unwrap();
let back: PaletteEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, &back);
}
}
fn make_palette_item(text: &str) -> PaletteItem {
PaletteItem {
text: StyledText::plain(text),
detail: None,
icon: None,
match_positions: vec![],
depth: 0,
expandable: false,
expanded: false,
}
}
fn make_palette(
title: &str,
query: &str,
items: Vec<PaletteItem>,
selected: usize,
scroll: usize,
) -> Palette {
Palette {
id: WidgetId::new("p"),
title: title.to_string(),
query: query.to_string(),
query_cursor: 0,
items,
selected_idx: selected,
scroll_offset: scroll,
total_count: 0,
has_focus: true,
show_query: true,
create_label: None,
preview: None,
mode: PaletteMode::List,
}
}
#[test]
fn palette_layout_empty() {
let p = make_palette("Commands", "", vec![], 0, 0);
let layout = p.layout(40.0, 20.0, 0.0, 0.0, 0.0, 1.0, |_| {
PaletteItemMeasure::new(1.0)
});
assert!(layout.title_bounds.is_none());
assert!(layout.query_bounds.is_none());
assert_eq!(layout.visible_items.len(), 0);
assert_eq!(layout.hit_test(10.0, 5.0), PaletteHit::Empty);
}
#[test]
fn palette_layout_stacks_title_query_items() {
let p = make_palette(
"Commands",
"open",
(0..3)
.map(|i| make_palette_item(&format!("cmd{i}")))
.collect(),
0,
0,
);
let layout = p.layout(40.0, 10.0, 1.0, 1.0, 0.0, 1.0, |_| {
PaletteItemMeasure::new(1.0)
});
assert_eq!(layout.title_bounds.unwrap().y, 0.0);
assert_eq!(layout.query_bounds.unwrap().y, 1.0);
assert_eq!(layout.visible_items[0].bounds.y, 2.0);
assert_eq!(layout.visible_items[2].bounds.y, 4.0);
assert_eq!(layout.hit_test(10.0, 0.5), PaletteHit::Title);
assert_eq!(layout.hit_test(10.0, 1.5), PaletteHit::Query);
assert_eq!(layout.hit_test(10.0, 2.5), PaletteHit::Item(0));
}
#[test]
fn palette_layout_no_title_query_only() {
let p = make_palette("", "", vec![make_palette_item("a")], 0, 0);
let layout = p.layout(40.0, 10.0, 0.0, 1.0, 0.0, 1.0, |_| {
PaletteItemMeasure::new(1.0)
});
assert!(layout.title_bounds.is_none());
assert_eq!(layout.query_bounds.unwrap().y, 0.0);
assert_eq!(layout.visible_items[0].bounds.y, 1.0);
}
#[test]
fn palette_layout_scroll_offset_skips_items() {
let p = make_palette(
"",
"",
(0..20)
.map(|i| make_palette_item(&format!("i{i}")))
.collect(),
2,
2,
);
let layout = p.layout(40.0, 5.0, 0.0, 1.0, 0.0, 1.0, |_| {
PaletteItemMeasure::new(1.0)
});
assert_eq!(layout.visible_items[0].item_idx, 2);
assert_eq!(layout.visible_items[0].bounds.y, 1.0);
}
#[test]
fn resolved_scroll_offset_scrolls_forward_past_bottom() {
let p = make_palette(
"",
"",
(0..20)
.map(|i| make_palette_item(&format!("i{i}")))
.collect(),
15,
0,
);
assert_eq!(p.resolved_scroll_offset(9), 7);
}
#[test]
fn resolved_scroll_offset_scrolls_backward_past_top() {
let p = make_palette(
"",
"",
(0..20)
.map(|i| make_palette_item(&format!("i{i}")))
.collect(),
1,
10,
);
assert_eq!(p.resolved_scroll_offset(9), 1);
}
#[test]
fn palette_layout_keeps_selection_visible_past_bottom() {
let items: Vec<_> = (0..20)
.map(|i| make_palette_item(&format!("i{i}")))
.collect();
let p = make_palette("", "", items, 15, 0);
let layout = p.layout(40.0, 10.0, 0.0, 1.0, 0.0, 1.0, |_| {
PaletteItemMeasure::new(1.0)
});
assert_eq!(layout.resolved_scroll_offset, 7);
assert!(
layout.visible_items.iter().any(|vi| vi.item_idx == 15),
"selected item 15 should be visible in the resolved window"
);
}
#[test]
fn palette_layout_pixel_units() {
let p = make_palette(
"Commands",
"",
(0..3)
.map(|i| make_palette_item(&format!("c{i}")))
.collect(),
0,
0,
);
let layout = p.layout(400.0, 300.0, 32.0, 40.0, 0.0, 1.0, |_| {
PaletteItemMeasure::new(24.0)
});
assert_eq!(layout.title_bounds.unwrap().height, 32.0);
assert_eq!(layout.query_bounds.unwrap().y, 32.0);
assert_eq!(layout.query_bounds.unwrap().height, 40.0);
assert_eq!(layout.visible_items[0].bounds.y, 72.0);
assert_eq!(layout.visible_items[0].bounds.height, 24.0);
}
#[test]
fn palette_layout_with_preview_splits_width() {
let mut p = make_palette("Files", "", vec![make_palette_item("main.rs")], 0, 0);
p.preview = Some(PalettePreview {
lines: vec![StyledText::plain("fn main() {}")],
title: Some("main.rs".into()),
scroll_offset: 0,
highlight_line: None,
});
let layout = p.layout(100.0, 50.0, 1.0, 1.0, 0.0, 1.0, |_| {
PaletteItemMeasure::new(1.0)
});
assert_eq!(layout.item_list_width, 40.0);
assert_eq!(layout.visible_items[0].bounds.width, 40.0);
let pb = layout.preview_bounds.unwrap();
assert_eq!(pb.x, 40.0);
assert_eq!(pb.width, 60.0);
assert_eq!(pb.y, 2.0);
assert_eq!(pb.height, 48.0);
}
#[test]
fn palette_layout_without_preview_full_width() {
let p = make_palette("Cmd", "", vec![make_palette_item("foo")], 0, 0);
let layout = p.layout(100.0, 50.0, 1.0, 1.0, 0.0, 1.0, |_| {
PaletteItemMeasure::new(1.0)
});
assert_eq!(layout.item_list_width, 100.0);
assert!(layout.preview_bounds.is_none());
assert_eq!(layout.visible_items[0].bounds.width, 100.0);
}
#[test]
fn palette_preview_hit_test() {
let mut p = make_palette("Files", "", vec![make_palette_item("a.rs")], 0, 0);
p.preview = Some(PalettePreview {
lines: vec![],
title: None,
scroll_offset: 0,
highlight_line: None,
});
let layout = p.layout(100.0, 50.0, 1.0, 1.0, 0.0, 1.0, |_| {
PaletteItemMeasure::new(1.0)
});
assert_eq!(layout.hit_test(50.0, 10.0), PaletteHit::Preview);
assert_eq!(layout.hit_test(10.0, 2.5), PaletteHit::Item(0));
}
#[test]
fn palette_scrollbar_present_when_overflow() {
let p = make_palette(
"",
"",
(0..20)
.map(|i| make_palette_item(&format!("i{i}")))
.collect(),
0,
0,
);
let layout = p.layout(40.0, 10.0, 0.0, 1.0, 2.0, 1.0, |_| {
PaletteItemMeasure::new(1.0)
});
let sb = layout
.scrollbar
.as_ref()
.expect("scrollbar should be present");
assert_eq!(sb.track.x, 38.0); assert_eq!(sb.track.y, 1.0); assert_eq!(sb.track.width, 2.0);
assert_eq!(sb.track.height, 9.0); assert!(sb.thumb.height > 0.0);
assert!(sb.thumb.height <= sb.track.height);
assert_eq!(layout.visible_items[0].bounds.width, 38.0);
}
#[test]
fn palette_scrollbar_none_when_no_overflow() {
let p = make_palette("", "", vec![make_palette_item("a")], 0, 0);
let layout = p.layout(40.0, 10.0, 0.0, 1.0, 2.0, 1.0, |_| {
PaletteItemMeasure::new(1.0)
});
assert!(layout.scrollbar.is_none());
assert_eq!(layout.visible_items[0].bounds.width, 40.0);
}
#[test]
fn palette_scrollbar_none_when_width_zero() {
let p = make_palette(
"",
"",
(0..20)
.map(|i| make_palette_item(&format!("i{i}")))
.collect(),
0,
0,
);
let layout = p.layout(40.0, 10.0, 0.0, 1.0, 0.0, 1.0, |_| {
PaletteItemMeasure::new(1.0)
});
assert!(layout.scrollbar.is_none());
}
#[test]
fn palette_scrollbar_hit_test() {
let p = make_palette(
"",
"",
(0..20)
.map(|i| make_palette_item(&format!("i{i}")))
.collect(),
0,
0,
);
let layout = p.layout(40.0, 10.0, 0.0, 1.0, 2.0, 1.0, |_| {
PaletteItemMeasure::new(1.0)
});
let sb = layout.scrollbar.as_ref().unwrap();
let thumb_center_y = sb.thumb.y + sb.thumb.height / 2.0;
assert_eq!(
layout.hit_test(39.0, thumb_center_y),
PaletteHit::ScrollbarThumb,
);
let below_thumb = sb.thumb.y + sb.thumb.height + 0.5;
if below_thumb < sb.track.y + sb.track.height {
assert_eq!(
layout.hit_test(39.0, below_thumb),
PaletteHit::ScrollbarTrack,
);
}
assert_ne!(layout.hit_test(37.0, 1.5), PaletteHit::ScrollbarThumb);
}
#[test]
fn palette_scrollbar_thumb_tracks_scroll_offset() {
let items: Vec<_> = (0..20)
.map(|i| make_palette_item(&format!("i{i}")))
.collect();
let p0 = make_palette("", "", items.clone(), 0, 0);
let l0 = p0.layout(40.0, 10.0, 0.0, 1.0, 2.0, 1.0, |_| {
PaletteItemMeasure::new(1.0)
});
let sb0 = l0.scrollbar.as_ref().unwrap();
let p1 = make_palette("", "", items, 15, 11);
let l1 = p1.layout(40.0, 10.0, 0.0, 1.0, 2.0, 1.0, |_| {
PaletteItemMeasure::new(1.0)
});
let sb1 = l1.scrollbar.as_ref().unwrap();
assert!(
sb1.thumb.y > sb0.thumb.y,
"thumb should move down with scroll"
);
}
#[test]
fn palette_scrollbar_pixel_units() {
let p = make_palette(
"Files",
"main",
(0..50)
.map(|i| make_palette_item(&format!("f{i}")))
.collect(),
0,
0,
);
let layout = p.layout(400.0, 600.0, 32.0, 40.0, 6.0, 8.0, |_| {
PaletteItemMeasure::new(24.0)
});
let sb = layout.scrollbar.as_ref().expect("scrollbar present");
assert_eq!(sb.track.width, 6.0);
assert!(sb.thumb.height >= 8.0, "thumb respects min_thumb_len");
assert_eq!(sb.track.y, 72.0); let content_w = 400.0 - 6.0;
assert_eq!(layout.visible_items[0].bounds.width, content_w);
}
#[test]
fn palette_tree_item_serde_round_trip() {
let item = PaletteItem {
text: StyledText::plain("src/"),
detail: None,
icon: None,
match_positions: vec![],
depth: 2,
expandable: true,
expanded: true,
};
let json = serde_json::to_string(&item).unwrap();
let back: PaletteItem = serde_json::from_str(&json).unwrap();
assert_eq!(back.depth, 2);
assert!(back.expandable);
assert!(back.expanded);
}
}