use crate::event::Rect;
use crate::primitives::list::{ListView, ListViewLayout};
const BORDER_DIP: f32 = 1.0;
pub fn win_list_layout(
list: &ListView,
rect: Rect,
line_height: f32,
char_width: f32,
) -> ListViewLayout {
let border_inset = if list.bordered {
BORDER_DIP as f64
} else {
0.0
};
crate::primitives::layout_metrics::list_layout(
list,
rect.width as f64,
rect.height as f64,
line_height as f64,
char_width as f64,
border_inset,
)
}
#[cfg(target_os = "windows")]
pub fn draw_list(
target: &windows::Win32::Graphics::Direct2D::ID2D1RenderTarget,
dwrite: &super::text::DWrite,
rect: Rect,
list: &ListView,
line_height: f32,
) -> ListViewLayout {
use super::text::fill_rect;
use crate::theme::Theme;
let theme = Theme::default();
let char_width = dwrite
.measure_text("M")
.map(|(w, _)| w)
.unwrap_or(0.0)
.max(1.0);
let layout = win_list_layout(list, rect, line_height, char_width);
let mut surface = super::surface::D2dSurface {
target,
dwrite: Some(dwrite),
};
crate::primitives::list::native_surface_paint::paint(
list,
rect,
&layout,
line_height,
false,
true,
true,
&mut surface,
&theme,
);
if list.bordered {
let br = theme.border_fg;
let _ = fill_rect(
target,
Rect::new(rect.x, rect.y, rect.width, BORDER_DIP),
br,
);
let _ = fill_rect(
target,
Rect::new(
rect.x,
rect.y + rect.height - BORDER_DIP,
rect.width,
BORDER_DIP,
),
br,
);
let _ = fill_rect(
target,
Rect::new(rect.x, rect.y, BORDER_DIP, rect.height),
br,
);
let _ = fill_rect(
target,
Rect::new(
rect.x + rect.width - BORDER_DIP,
rect.y,
BORDER_DIP,
rect.height,
),
br,
);
}
layout
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Decoration, StyledText, WidgetId};
const W: f32 = 200.0;
const H: f32 = 100.0;
const LINE_HEIGHT: f32 = 14.0;
const CHAR_WIDTH: f32 = 8.0;
fn item(label: &str) -> crate::primitives::list::ListItem {
crate::primitives::list::ListItem {
text: StyledText::plain(label.to_string()),
icon: None,
detail: None,
decoration: Decoration::Normal,
}
}
fn make_list(items: Vec<crate::primitives::list::ListItem>) -> ListView {
ListView {
id: WidgetId::new("list"),
title: None,
items,
selected_idx: 0,
scroll_offset: 0,
has_focus: true,
bordered: false,
h_scroll: 0,
max_content_width: None,
show_v_scrollbar: false,
}
}
fn make_bordered_list(items: Vec<crate::primitives::list::ListItem>) -> ListView {
ListView {
id: WidgetId::new("list"),
title: None,
items,
selected_idx: 0,
scroll_offset: 0,
has_focus: true,
bordered: true,
h_scroll: 0,
max_content_width: None,
show_v_scrollbar: false,
}
}
#[test]
fn win_list_layout_matches_layout_metrics_list_layout_border_inset() {
let list = make_bordered_list(vec![item("alpha"), item("beta"), item("gamma")]);
let rect = Rect::new(0.0, 0.0, W, H);
let via_win = win_list_layout(&list, rect, LINE_HEIGHT, CHAR_WIDTH);
let via_shared = crate::primitives::layout_metrics::list_layout(
&list,
W as f64,
H as f64,
LINE_HEIGHT as f64,
CHAR_WIDTH as f64,
BORDER_DIP as f64,
);
assert_eq!(
via_win, via_shared,
"win_list_layout must delegate to layout_metrics::list_layout \
with border_inset == BORDER_DIP for a bordered list"
);
}
#[test]
fn win_list_layout_unbordered_has_no_inset_and_differs_from_bordered() {
let bordered = make_bordered_list(vec![item("alpha"), item("beta")]);
let unbordered = make_list(vec![item("alpha"), item("beta")]);
let rect = Rect::new(0.0, 0.0, W, H);
let via_win_unbordered = win_list_layout(&unbordered, rect, LINE_HEIGHT, CHAR_WIDTH);
let via_shared_unbordered = crate::primitives::layout_metrics::list_layout(
&unbordered,
W as f64,
H as f64,
LINE_HEIGHT as f64,
CHAR_WIDTH as f64,
0.0,
);
assert_eq!(via_win_unbordered, via_shared_unbordered);
let via_win_bordered = win_list_layout(&bordered, rect, LINE_HEIGHT, CHAR_WIDTH);
assert_ne!(
via_win_unbordered.visible_items[0].bounds, via_win_bordered.visible_items[0].bounds,
"a bordered list must actually inset its rows relative to an \
unbordered one"
);
}
#[test]
fn win_list_layout_reserves_hscrollbar_row_when_content_overflows() {
let mut list = make_list((0..12).map(|i| item(&format!("row-{i}"))).collect());
list.max_content_width = Some(1000); let rect = Rect::new(0.0, 0.0, W, H);
let overflowing = win_list_layout(&list, rect, LINE_HEIGHT, CHAR_WIDTH);
list.max_content_width = None;
let fitting = win_list_layout(&list, rect, LINE_HEIGHT, CHAR_WIDTH);
assert!(
overflowing.visible_items.len() < fitting.visible_items.len(),
"an overflowing max_content_width must reserve a row, leaving \
fewer visible items than the non-overflowing layout"
);
}
#[cfg(target_os = "windows")]
mod windows_only {
use super::*;
use crate::primitives::list::ListViewHit;
use crate::win::testing::HeadlessSurface;
use crate::win::text::DWrite;
#[test]
fn paint_and_hit_test_round_trip() {
let surface = HeadlessSurface::new(W as u32, H as u32).expect("create surface");
let (dwrite, _, _) = DWrite::new("Segoe UI", 10.0, None).expect("create DWrite");
let list = make_list(vec![item("alpha"), item("beta"), item("gamma")]);
let rect = Rect::new(0.0, 0.0, W, H);
let layout = surface
.paint(|target| {
draw_list(target, &dwrite, rect, &list, LINE_HEIGHT);
})
.map(|_| win_list_layout(&list, rect, LINE_HEIGHT, CHAR_WIDTH))
.expect("paint list");
assert_eq!(layout.visible_items.len(), 3);
for vis in &layout.visible_items {
let hit = layout.hit_test(
vis.bounds.x + vis.bounds.width / 2.0,
vis.bounds.y + vis.bounds.height / 2.0,
);
assert_eq!(hit, ListViewHit::Item(vis.item_idx));
}
let theme = crate::theme::Theme::default();
let sel_bounds = layout.visible_items[0].bounds;
let px = surface.pixel_at((sel_bounds.x + 1.0) as u32, (sel_bounds.y + 1.0) as u32);
assert_eq!(
(px.r, px.g, px.b),
(
theme.selected_bg.r,
theme.selected_bg.g,
theme.selected_bg.b
),
"selected row (idx 0) should paint selected_bg at its own bounds"
);
}
#[test]
fn scroll_offset_paint_and_click_agree() {
let mut list = make_list((0..6).map(|i| item(&format!("row-{i}"))).collect());
list.scroll_offset = 2;
let rect = Rect::new(0.0, 0.0, W, H);
let layout = win_list_layout(&list, rect, LINE_HEIGHT, CHAR_WIDTH);
let first = layout.visible_items.first().expect("has items");
assert_eq!(first.item_idx, 2);
let hit = layout.hit_test(
first.bounds.x + 5.0,
first.bounds.y + first.bounds.height / 2.0,
);
assert_eq!(hit, ListViewHit::Item(2));
}
#[test]
fn click_below_last_item_returns_empty() {
let list = make_list(vec![item("a"), item("b")]);
let rect = Rect::new(0.0, 0.0, W, H);
let layout = win_list_layout(&list, rect, LINE_HEIGHT, CHAR_WIDTH);
let last = layout.visible_items.last().expect("has items");
let hit = layout.hit_test(10.0, last.bounds.y + last.bounds.height + 5.0);
assert_eq!(hit, ListViewHit::Empty);
}
#[test]
fn no_paint_layout_matches_paint_layout() {
let mut list = make_list(vec![item("alpha"), item("beta")]);
list.title = Some(StyledText::plain("Files"));
let rect = Rect::new(0.0, 0.0, W, H);
let (dwrite, _, _) = DWrite::new("Segoe UI", 10.0, None).expect("create DWrite");
let surface = HeadlessSurface::new(W as u32, H as u32).expect("create surface");
let painted = surface
.paint(|target| {
draw_list(target, &dwrite, rect, &list, LINE_HEIGHT);
})
.map(|_| {
let char_width = dwrite
.measure_text("M")
.map(|(w, _)| w)
.unwrap_or(0.0)
.max(1.0);
win_list_layout(&list, rect, LINE_HEIGHT, char_width)
})
.expect("paint");
let char_width = dwrite
.measure_text("M")
.map(|(w, _)| w)
.unwrap_or(0.0)
.max(1.0);
let no_paint = win_list_layout(&list, rect, LINE_HEIGHT, char_width);
assert_eq!(painted, no_paint);
}
#[test]
fn paints_vertical_scrollbar_track_when_enabled() {
let mut list = make_list((0..30).map(|i| item(&format!("row-{i}"))).collect());
list.show_v_scrollbar = true;
let rect = Rect::new(0.0, 0.0, W, H);
let expected = list
.vscrollbar(rect, LINE_HEIGHT)
.expect("30 rows in a 100px / 14px viewport must need a v-scrollbar");
let (dwrite, _, _) = DWrite::new("Segoe UI", 10.0, None).expect("create DWrite");
let surface = HeadlessSurface::new(W as u32, H as u32).expect("create surface");
surface
.paint(|target| {
draw_list(target, &dwrite, rect, &list, LINE_HEIGHT);
})
.expect("paint list");
let theme = crate::theme::Theme::default();
let probe_x = (expected.track.x + expected.track.width / 2.0) as u32;
let probe_y = (expected.track.y + expected.track.height / 2.0) as u32;
let px = surface.pixel_at(probe_x.min(W as u32 - 1), probe_y.min(H as u32 - 1));
assert_ne!(
(px.r, px.g, px.b),
(theme.background.r, theme.background.g, theme.background.b),
"expected the v-scrollbar track at ({probe_x}, {probe_y}) to be painted \
(non-background) — the #1075 regression this test guards"
);
}
#[test]
fn bordered_frame_survives_shared_content_paint() {
let list = make_bordered_list(vec![item("alpha"), item("beta"), item("gamma")]);
let rect = Rect::new(0.0, 0.0, W, H);
let (dwrite, _, _) = DWrite::new("Segoe UI", 10.0, None).expect("create DWrite");
let surface = HeadlessSurface::new(W as u32, H as u32).expect("create surface");
surface
.paint(|target| {
draw_list(target, &dwrite, rect, &list, LINE_HEIGHT);
})
.expect("paint list");
let theme = crate::theme::Theme::default();
let border = (theme.border_fg.r, theme.border_fg.g, theme.border_fg.b);
let mid_x = (W / 2.0) as u32;
let mid_y = (H / 2.0) as u32;
let top = surface.pixel_at(mid_x, 0);
let bottom = surface.pixel_at(mid_x, H as u32 - 1);
let left = surface.pixel_at(0, mid_y);
let right = surface.pixel_at(W as u32 - 1, mid_y);
for (label, px) in [
("top", top),
("bottom", bottom),
("left", left),
("right", right),
] {
assert_eq!(
(px.r, px.g, px.b),
border,
"{label} border edge should still be theme.border_fg after the shared \
content paint ran (#1075 review regression: the border was being \
painted before, then erased by native_surface_paint::paint's initial \
full-rect background fill)"
);
}
}
}
}
#[cfg(all(test, target_os = "windows"))]
mod multi_select_driver_tests {
use crate::runner::{AppLogic, Reaction};
use crate::win::testing::WinDriver;
use crate::{
apply_selection_gesture, Backend, Decoration, ListItem, ListItemMeasure, ListView,
ListViewEvent, ListViewHit, Modifiers, MouseButton, Point, Rect, SelectionGesture,
StyledText, UiEvent, WidgetId,
};
const VIEWPORT_W: f32 = 40.0;
const VIEWPORT_H: f32 = 10.0;
const ROW_H: f32 = 1.0;
fn item(label: &str) -> ListItem {
ListItem {
text: StyledText::plain(label.to_string()),
icon: None,
detail: None,
decoration: Decoration::Normal,
}
}
fn sample_list() -> ListView {
ListView {
id: WidgetId::new("l"),
title: None,
items: (0..5).map(|i| item(&format!("row {i}"))).collect(),
selected_idx: 0,
scroll_offset: 0,
has_focus: true,
bordered: false,
h_scroll: 0,
max_content_width: None,
show_v_scrollbar: false,
}
}
struct ListApp {
list: ListView,
selected: Vec<usize>,
anchor: usize,
events: Vec<ListViewEvent>,
}
impl ListApp {
fn new() -> Self {
Self {
list: sample_list(),
selected: Vec::new(),
anchor: 0,
events: Vec::new(),
}
}
}
impl AppLogic for ListApp {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.draw_list(Rect::new(0.0, 0.0, VIEWPORT_W, VIEWPORT_H), &self.list);
}
fn handle(&mut self, event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
let UiEvent::MouseDown {
position,
modifiers,
..
} = event
else {
return Reaction::Continue;
};
let layout = self
.list
.layout(VIEWPORT_W, VIEWPORT_H, 0.0, |_| ListItemMeasure::new(ROW_H));
let ListViewHit::Item(idx) = layout.hit_test(position.x, position.y) else {
return Reaction::Continue;
};
let gesture = SelectionGesture::from_modifiers(modifiers);
let indices = apply_selection_gesture(&self.selected, self.anchor, idx, gesture);
self.selected = indices.clone();
if !matches!(gesture, SelectionGesture::Range) {
self.anchor = idx;
}
self.events.push(ListViewEvent::SelectionSet { indices });
Reaction::Redraw
}
}
fn mouse_down(x: f32, y: f32, modifiers: Modifiers) -> UiEvent {
UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
position: Point::new(x, y),
modifiers,
}
}
#[test]
fn plain_click_replaces_selection() {
let mut driver = WinDriver::new(ListApp::new(), 80, 40);
driver.dispatch(mouse_down(2.0, 2.0, Modifiers::default()));
assert_eq!(driver.app().selected, vec![2]);
}
#[test]
fn ctrl_click_toggles_rows_into_a_multi_selection() {
let mut driver = WinDriver::new(ListApp::new(), 80, 40);
driver.dispatch(mouse_down(2.0, 0.0, Modifiers::default()));
let ctrl = Modifiers {
ctrl: true,
..Default::default()
};
driver.dispatch(mouse_down(10.0, 2.0, ctrl));
assert_eq!(driver.app().selected, vec![0, 2]);
driver.dispatch(mouse_down(18.0, 2.0, ctrl));
assert_eq!(driver.app().selected, vec![0]);
assert_eq!(
driver.app().events.last(),
Some(&ListViewEvent::SelectionSet { indices: vec![0] })
);
}
#[test]
fn shift_click_selects_the_range_from_the_anchor() {
let mut driver = WinDriver::new(ListApp::new(), 80, 40);
driver.dispatch(mouse_down(2.0, 0.0, Modifiers::default()));
let shift = Modifiers {
shift: true,
..Default::default()
};
driver.dispatch(mouse_down(10.0, 3.0, shift));
assert_eq!(driver.app().selected, vec![0, 1, 2, 3]);
}
#[test]
fn cmd_click_toggles_the_same_as_ctrl_click() {
let mut driver = WinDriver::new(ListApp::new(), 80, 40);
driver.dispatch(mouse_down(2.0, 1.0, Modifiers::default()));
driver.dispatch(mouse_down(
10.0,
4.0,
Modifiers {
cmd: true,
..Default::default()
},
));
assert_eq!(driver.app().selected, vec![1, 4]);
}
}