diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 139256f..f1b39fc 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -38,8 +38,10 @@ pub fn toggle_button(ui: &mut Ui, icon: Icon, on: bool, tip: &str) -> Response { pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::ui_font(11.5); - let lines: Vec<&str> = label.split('\n').collect(); - let text_w = lines.iter().map(|l| ui.painter().layout_no_wrap(l.to_string(), font.clone(), t.text).size().x).fold(0.0, f32::max); + let lines: Vec<_> = label.split('\n').map(|line| ui.painter().layout_no_wrap(line.to_string(), font.clone(), t.text)).collect(); + let arrow_size = 10.0; + let arrow_space = if dropdown { 4.0 + arrow_size } else { 0.0 }; + let text_w = lines.iter().enumerate().map(|(i, line)| line.size().x + if i + 1 == lines.len() { arrow_space } else { 0.0 }).fold(0.0, f32::max); let w = (text_w + 12.0).max(44.0); let (rect, resp) = ui.allocate_exact_size(vec2(w, 72.0), Sense::click()); if resp.is_pointer_button_down_on() { @@ -48,12 +50,20 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo ui.painter().rect_filled(rect, 5.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text); - for (i, l) in lines.iter().enumerate() { - let mut s = l.to_string(); - if dropdown && i + 1 == lines.len() { - s.push_str(" ▾"); + for (i, line) in lines.iter().enumerate() { + let has_arrow = dropdown && i + 1 == lines.len(); + let line_w = line.size().x + if has_arrow { arrow_space } else { 0.0 }; + let left = rect.center().x - line_w / 2.0; + let y = rect.top() + 46.0 + i as f32 * 13.0; + ui.painter().galley(pos2(left, y - line.size().y / 2.0), line.clone(), t.text); + if has_arrow { + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(left + line_w - arrow_size / 2.0, y + 1.0), vec2(arrow_size, arrow_size)), + Icon::Chevron, + t.text_dim, + ); } - ui.painter().text(pos2(rect.center().x, rect.top() + 46.0 + i as f32 * 13.0), Align2::CENTER_CENTER, s, font.clone(), t.text); } resp.on_hover_text(tip) } diff --git a/crates/ui-egui/tests/widgets.rs b/crates/ui-egui/tests/widgets.rs new file mode 100644 index 0000000..54c8f6e --- /dev/null +++ b/crates/ui-egui/tests/widgets.rs @@ -0,0 +1,62 @@ +//! Ribbon widget paint tests, using egui's bundled fonts on every platform. + +use egui::{FontDefinitions, FontFamily, Rect, Shape, pos2, vec2}; +use gridcraft_ui_egui::{icons::Icon, theme, widgets}; + +fn render_big_button(label: &str, dropdown: bool) -> (Rect, Vec) { + let ctx = egui::Context::default(); + let mut fonts = FontDefinitions::default(); + let proportional = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); + fonts.families.insert(FontFamily::Name(theme::UI.into()), proportional); + ctx.set_fonts(fonts); + let mut rect = Rect::NOTHING; + let mut output = ctx + .run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0))), ..Default::default() }, |ui| { + rect = widgets::big_button(ui, Icon::More, label, "test button", dropdown).rect + }); + output.textures_delta.clear(); + (rect, output.shapes.into_iter().map(|shape| shape.shape).collect()) +} + +#[test] +fn big_dropdown_paints_chevron_beside_final_label_line() { + for label in ["Paste", "Conditional\nFormatting", "Longer first line\nGo"] { + let (rect, shapes) = render_big_button(label, true); + let texts: Vec<_> = shapes.iter().filter_map(|shape| if let Shape::Text(text) = shape { Some(text) } else { None }).collect(); + assert_eq!(texts.iter().map(|text| text.galley.job.text.as_str()).collect::>(), label.split('\n').collect::>()); + let paths: Vec<_> = shapes.iter().filter_map(|shape| if let Shape::Path(path) = shape { Some(path) } else { None }).collect(); + assert_eq!(paths.len(), 1, "dropdown indicator must be painted independently of font glyphs"); + let chevron = paths[0]; + assert_eq!(chevron.points.len(), 3); + assert!(chevron.points[0].x < chevron.points[1].x && chevron.points[1].x < chevron.points[2].x); + assert!(chevron.points[1].y > chevron.points[0].y && chevron.points[1].y > chevron.points[2].y); + let last = texts.last().unwrap(); + let last_rect = last.galley.rect.translate(last.pos.to_vec2()); + assert!(chevron.points[0].x > last_rect.right(), "arrow must not overlap the label"); + assert!((chevron.points[1].y - last_rect.center().y).abs() < 3.0, "arrow must align with the final line"); + assert!(rect.right() - chevron.points[2].x >= 6.0, "button must reserve space for the arrow and padding"); + assert!(last_rect.left() - rect.left() >= 6.0); + assert!(((last_rect.left() + chevron.points[2].x) / 2.0 - rect.center().x).abs() < 2.0, "label and arrow must be centered together"); + for text in texts.iter().take(texts.len() - 1) { + let text_rect = text.galley.rect.translate(text.pos.to_vec2()); + assert!((text_rect.center().x - rect.center().x).abs() < 0.01, "earlier lines remain centered"); + } + } +} + +#[test] +fn big_button_without_dropdown_keeps_centered_labels_and_width() { + for label in ["Paste", "Conditional\nFormatting"] { + let (rect, shapes) = render_big_button(label, false); + assert!(!shapes.iter().any(|shape| matches!(shape, Shape::Path(_))), "plain button must not paint an arrow"); + let mut widest: f32 = 0.0; + for shape in shapes { + if let Shape::Text(text) = shape { + let text_rect = text.galley.rect.translate(text.pos.to_vec2()); + assert!((text_rect.center().x - rect.center().x).abs() < 0.01); + widest = widest.max(text_rect.width()); + } + } + assert!((rect.width() - (widest + 12.0).max(44.0)).abs() < 0.01); + } +}