use std::hash::Hash;
use std::panic::Location;
use std::sync::{Mutex, OnceLock, PoisonError};
use egui::accesskit::Role;
use egui::text::{LayoutJob, TextWrapping};
use egui::{Color32, Response, Sense, StrokeKind, TextStyle, Ui, Widget, WidgetInfo, WidgetType, pos2, vec2};
use crate::automation::{self, Details};
use crate::cache;
#[must_use = "add it with `ui.add(row)`"]
pub struct Row<'a> {
id_salt: u64,
text: &'a str,
selected: bool,
height: Option<f32>,
automation_id: Option<&'a str>,
indent: f32,
text_style: Option<TextStyle>,
weak: bool,
icon: Option<crate::controls::IconPainter<'a>>,
detail: Option<&'a str>,
badge: Option<&'a str>,
focusable: bool,
}
impl<'a> Row<'a> {
pub fn new(id_salt: impl Hash, text: &'a str) -> Self {
Self {
id_salt: cache::hash_of(&id_salt),
text,
selected: false,
height: None,
automation_id: None,
indent: 0.0,
text_style: None,
weak: false,
icon: None,
detail: None,
badge: None,
focusable: true,
}
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn height(mut self, height: f32) -> Self {
self.height = Some(height);
self
}
pub fn automation_id(mut self, id: &'a str) -> Self {
self.automation_id = Some(id);
self
}
pub fn indent(mut self, points: f32) -> Self {
self.indent = if points.is_finite() { points.max(0.0) } else { 0.0 };
self
}
pub fn text_style(mut self, style: TextStyle) -> Self {
self.text_style = Some(style);
self
}
pub fn weak(mut self, weak: bool) -> Self {
self.weak = weak;
self
}
pub fn icon(mut self, paint: crate::controls::IconPainter<'a>) -> Self {
self.icon = Some(paint);
self
}
pub fn detail(mut self, text: &'a str) -> Self {
self.detail = Some(text).filter(|t| !t.is_empty());
self
}
pub fn badge(mut self, text: &'a str) -> Self {
self.badge = Some(text).filter(|t| !t.is_empty());
self
}
pub fn focusable(mut self, focusable: bool) -> Self {
self.focusable = focusable;
self
}
}
impl Widget for Row<'_> {
fn ui(self, ui: &mut Ui) -> Response {
let default_height = ui.spacing().interact_size.y;
let height = self.height.filter(|h| h.is_finite() && *h > 0.0).unwrap_or(default_height);
let width = Some(ui.available_width()).filter(|w| w.is_finite()).unwrap_or(0.0).max(0.0);
let (_, rect) = ui.allocate_space(vec2(width, height));
let id = ui.id().with(self.id_salt);
let response = ui.interact(rect, id, if self.focusable { Sense::click() } else { Sense::CLICK });
let enabled = ui.is_enabled();
let (text, selected) = (self.text, self.selected);
response.widget_info(|| WidgetInfo::selected(WidgetType::SelectableLabel, enabled, selected, text));
ui.ctx().accesskit_node_builder(id, |node| {
node.set_role(Role::ListItem);
node.clear_toggled();
node.set_selected(selected);
});
if let Some(aid) = self.automation_id {
let details = Details { selected: Some(selected), ..Details::default() };
automation::register_with(ui, &response, aid, text, automation::Role::Row, details);
}
if !ui.is_rect_visible(rect) {
return response;
}
let visuals = ui.style().interact_selectable(&response, selected);
if selected || response.hovered() || response.highlighted() || response.has_focus() {
ui.painter().rect(rect, visuals.corner_radius, visuals.weak_bg_fill, visuals.bg_stroke, StrokeKind::Inside);
}
let pad = ui.spacing().button_padding.x;
let font = self.text_style.unwrap_or(TextStyle::Button).resolve(ui.style());
let color = if self.weak && !selected { ui.visuals().weak_text_color() } else { visuals.text_color() };
let mut left = (pad + self.indent).min(rect.width());
if let Some(paint) = self.icon {
let side = font.size.round().max(4.0).min(rect.height());
let icon = egui::Rect::from_min_size(pos2(rect.left() + left, rect.center().y - side / 2.0), vec2(side, side));
if icon.right() < rect.right() {
paint(ui.painter(), icon, color);
}
left = (left + side + pad * 0.75).min(rect.width());
}
let mut right = rect.right() - pad;
if let Some(badge) = self.badge {
let small = TextStyle::Small.resolve(ui.style());
let g = cache::galley(ui, id.with("badge"), cache::hash_of(&(badge, &small)), || {
LayoutJob::simple_singleline(badge.to_owned(), small, Color32::PLACEHOLDER)
});
let h = (g.size().y + 2.0).min(rect.height());
let w = (g.size().x + h * 0.8).max(h);
let pill = egui::Rect::from_min_size(pos2(right - w, rect.center().y - h / 2.0), vec2(w, h));
if pill.left() > rect.left() + left {
let (fill, text) = if selected {
(visuals.text_color(), visuals.weak_bg_fill)
} else {
(ui.visuals().widgets.inactive.bg_fill, ui.visuals().text_color())
};
ui.painter().rect_filled(pill, h / 2.0, fill);
ui.painter().galley(pill.center() - g.size() / 2.0, g, text);
right = pill.left() - pad * 0.75;
}
}
if let Some(detail) = self.detail {
let g = cache::galley(ui, id.with("detail"), cache::hash_of(&(detail, &font)), || {
LayoutJob::simple_singleline(detail.to_owned(), font.clone(), Color32::PLACEHOLDER)
});
if right - g.size().x > rect.left() + left {
let detail_color = if selected { visuals.text_color() } else { ui.visuals().weak_text_color() };
ui.painter().galley(pos2(right - g.size().x, rect.center().y - g.size().y / 2.0), g.clone(), detail_color);
right -= g.size().x + pad;
}
}
let max_width = (right - rect.left() - left).max(0.0);
let revision = cache::hash_of(&(text, &font, max_width.to_bits()));
let galley = cache::galley(ui, id, revision, || {
let mut job = LayoutJob::simple_singleline(text.to_owned(), font, Color32::PLACEHOLDER);
job.wrap = TextWrapping::truncate_at_width(max_width);
job
});
let pos = pos2(rect.left() + left, rect.center().y - galley.size().y / 2.0);
ui.painter().galley(pos, galley, color);
response
}
}
pub const MANY_WIDGETS: usize = 2_000;
pub const QUIET_ENV: &str = "ASHLAR_QUIET_LISTS";
#[track_caller]
pub fn checked<R>(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R {
if !cfg!(debug_assertions) {
return add_contents(ui);
}
let site = Location::caller();
let (out, count) = count_interactive(ui, add_contents);
if count > MANY_WIDGETS && !quiet() && first_warning(site) {
log::warn!(
"{site}: this list built {count} interactive widgets in one frame; every row costs time each frame. \
Use ashlar_ui::virtual_list::VirtualList to build only the visible rows (set {QUIET_ENV}=1 to silence)."
);
}
out
}
pub(crate) fn count_interactive<R>(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> (R, usize) {
let layer = ui.layer_id();
let len = |ctx: &egui::Context| ctx.viewport(|vp| vp.this_pass.widgets.get_layer(layer).count());
let before = len(ui.ctx());
let out = add_contents(ui);
let count = ui.ctx().viewport(|vp| vp.this_pass.widgets.get_layer(layer).skip(before).filter(|w| w.sense.interactive()).count());
(out, count)
}
fn quiet() -> bool {
static QUIET: OnceLock<bool> = OnceLock::new();
*QUIET.get_or_init(|| std::env::var_os(QUIET_ENV).is_some())
}
fn first_warning(site: &'static Location<'static>) -> bool {
static WARNED: Mutex<Vec<&'static Location<'static>>> = Mutex::new(Vec::new());
let mut warned = WARNED.lock().unwrap_or_else(PoisonError::into_inner);
if warned.contains(&site) {
return false;
}
warned.push(site);
true
}
#[cfg(test)]
mod tests {
use super::*;
use egui::Key;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT as _, Queryable as _};
#[test]
fn click_and_keyboard_select_and_the_row_is_a_list_item() {
let mut h = Harness::builder().with_size(vec2(300.0, 200.0)).build_ui_state(
|ui, sel: &mut Option<usize>| {
for (i, name) in ["Alpha", "Beta"].iter().enumerate() {
let id = if i == 0 { "rows.alpha" } else { "rows.beta" };
if ui.add(Row::new(i, name).selected(*sel == Some(i)).automation_id(id)).clicked() {
*sel = Some(i);
}
}
},
None,
);
h.run();
let beta = h.get_by_label("Beta");
assert_eq!(beta.accesskit_node().role(), Role::ListItem);
beta.click();
h.run();
assert_eq!(*h.state(), Some(1));
assert_eq!(automation::find(&h.ctx, "rows.beta").and_then(|e| e.selected), Some(true));
assert_eq!(h.get_by_label("Beta").accesskit_node().is_selected(), Some(true));
h.key_press(Key::Tab);
h.run();
h.key_press(Key::Enter);
h.run();
assert_eq!(*h.state(), Some(0));
}
#[test]
fn indent_style_and_weak_rows_still_click_and_bad_indents_are_safe() {
let mut h = Harness::builder().with_size(vec2(200.0, 200.0)).build_ui_state(
|ui, sel: &mut Option<usize>| {
for (i, (name, indent)) in [("Deep", 40.0), ("Huge", 1e9), ("Nan", f32::NAN), ("Neg", -5.0)].iter().enumerate() {
let row = Row::new(i, name).selected(*sel == Some(i)).indent(*indent).text_style(TextStyle::Monospace).weak(i % 2 == 0);
if ui.add(row).clicked() {
*sel = Some(i);
}
}
},
None,
);
h.run();
h.get_by_label("Deep").click();
h.run();
assert_eq!(*h.state(), Some(0));
h.get_by_label("Nan").click();
h.run();
assert_eq!(*h.state(), Some(2));
}
#[test]
fn odd_sizes_do_not_panic_and_long_text_is_truncated() {
let long = "x".repeat(10_000);
let mut h = Harness::builder().with_size(vec2(120.0, 100.0)).build_ui(|ui| {
let _ = ui.add(Row::new("nan", "nan").height(f32::NAN));
let _ = ui.add(Row::new("neg", "neg").height(-4.0));
let _ = ui.add(Row::new("empty", ""));
let r = ui.add(Row::new("long", &long));
assert!(r.rect.width() <= 120.0);
});
h.run();
}
#[test]
fn checked_counts_interactive_widgets_only() {
let ctx = egui::Context::default();
let mut counted = 0;
let mut out = ctx.run_ui(Default::default(), |ui| {
let ((), n) = count_interactive(ui, |ui| {
for i in 0..30 {
let _ = ui.add(Row::new(i, "row"));
}
let _ = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
});
counted = n;
});
out.textures_delta.clear();
assert_eq!(counted, 30);
let site = Location::caller();
assert!(first_warning(site));
assert!(!first_warning(site), "one warning per call site");
}
}