use gtk4::cairo::Context;
use gtk4::pango;
use super::{cairo_rgb, rounded_rect_path};
use crate::primitives::list::{ListView, ListViewLayout};
use crate::theme::Theme;
pub fn gtk_list_layout(
w: f64,
h: f64,
list: &ListView,
line_height: f64,
char_width: f64,
) -> ListViewLayout {
let border_inset: f64 = if list.bordered { 1.0 } else { 0.0 };
crate::primitives::layout_metrics::list_layout(
list,
w,
h,
line_height,
char_width,
border_inset,
)
}
#[allow(clippy::too_many_arguments)]
pub fn draw_list(
cr: &Context,
layout: &pango::Layout,
x: f64,
y: f64,
w: f64,
h: f64,
list: &ListView,
theme: &Theme,
line_height: f64,
nerd_fonts_enabled: bool,
) {
if w <= 0.0 || h <= 0.0 {
return;
}
layout.set_attributes(None);
layout.set_text("M");
let (cw_px, _) = layout.pixel_size();
let char_w = cw_px.max(1) as f64;
let list_layout = gtk_list_layout(w, h, list, line_height, char_w);
if list.bordered {
cr.save().ok();
rounded_rect_path(cr, x, y, w, h, 3.0);
cr.clip();
}
let area = crate::event::Rect::new(x as f32, y as f32, w as f32, h as f32);
let mut surface = super::surface::CairoSurface {
cr,
layout: Some(layout),
translucent_fill: true,
};
crate::primitives::list::native_surface_paint::paint(
list,
area,
&list_layout,
line_height as f32,
nerd_fonts_enabled,
true,
true,
&mut surface,
theme,
);
if list.bordered {
cr.restore().ok();
rounded_rect_path(cr, x + 0.5, y + 0.5, w - 1.0, h - 1.0, 3.0);
let border_color = cairo_rgb(theme.border_fg);
cr.set_source_rgb(border_color.0, border_color.1, border_color.2);
cr.set_line_width(1.0);
cr.stroke().ok();
}
layout.set_attributes(None);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::list::ListItem;
use crate::types::{Color, Decoration, StyledText, WidgetId};
use pangocairo::cairo::{Context, Format, ImageSurface};
const W: i32 = 120;
const H: i32 = 100;
const LINE_HEIGHT: f64 = 10.0;
fn test_theme() -> Theme {
Theme {
background: Color::rgb(255, 255, 255),
surface_bg: Color::rgb(255, 255, 255),
..Theme::default()
}
}
fn item(label: &str) -> ListItem {
ListItem {
text: StyledText::plain(label.to_string()),
icon: None,
detail: None,
decoration: Decoration::Normal,
}
}
fn vlist(n: usize) -> ListView {
ListView {
id: WidgetId::new("l"),
title: None,
items: (0..n).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: true,
}
}
fn pixel(data: &[u8], stride: usize, x: i32, y: i32) -> (u8, u8, u8) {
let off = y as usize * stride + x as usize * 4;
(data[off + 2], data[off + 1], data[off])
}
#[test]
fn gtk_paints_vertical_scrollbar_track_when_enabled() {
let list = vlist(30); let area = crate::event::Rect::new(0.0, 0.0, W as f32, H as f32);
let expected = list
.vscrollbar(area, LINE_HEIGHT as f32)
.expect("30 rows in a 10-row viewport must need a v-scrollbar");
let mut surface = ImageSurface::create(Format::ARgb32, W, H).expect("create ImageSurface");
{
let cr = Context::new(&surface).expect("Context::new");
cr.set_source_rgb(1.0, 1.0, 1.0);
cr.paint().ok();
let pango_layout = pangocairo::functions::create_layout(&cr);
draw_list(
&cr,
&pango_layout,
0.0,
0.0,
W as f64,
H as f64,
&list,
&test_theme(),
LINE_HEIGHT,
false,
);
}
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let probe_x = (expected.track.x + expected.track.width / 2.0).round() as i32;
let probe_y = (expected.track.y + expected.track.height / 2.0).round() as i32;
let (r, g, b) = pixel(
&data,
stride,
probe_x.clamp(0, W - 1),
probe_y.clamp(0, H - 1),
);
assert_ne!(
(r, g, b),
(255, 255, 255),
"expected the v-scrollbar track at ({probe_x}, {probe_y}) to be painted \
(non-background), got white — the #1075 regression this test guards"
);
}
}
#[cfg(test)]
mod multi_select_driver_tests {
use crate::gtk::testing::GtkDriver;
use crate::runner::{AppLogic, Reaction};
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 = GtkDriver::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 = GtkDriver::new(ListApp::new(), 80, 40);
driver.set_double_click_folding(false);
driver.dispatch(mouse_down(2.0, 0.0, Modifiers::default()));
let ctrl = Modifiers {
ctrl: true,
..Default::default()
};
driver.dispatch(mouse_down(2.0, 2.0, ctrl));
assert_eq!(driver.app().selected, vec![0, 2]);
driver.dispatch(mouse_down(2.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 = GtkDriver::new(ListApp::new(), 80, 40);
driver.set_double_click_folding(false);
driver.dispatch(mouse_down(2.0, 0.0, Modifiers::default()));
let shift = Modifiers {
shift: true,
..Default::default()
};
driver.dispatch(mouse_down(2.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 = GtkDriver::new(ListApp::new(), 80, 40);
driver.set_double_click_folding(false);
driver.dispatch(mouse_down(2.0, 1.0, Modifiers::default()));
driver.dispatch(mouse_down(
2.0,
4.0,
Modifiers {
cmd: true,
..Default::default()
},
));
assert_eq!(driver.app().selected, vec![1, 4]);
}
}