From e4d281eb20f710af33ba5a34effa41d7948a17f6 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Tue, 13 Oct 2026 10:11:31 -0600 Subject: [PATCH] Brushes: preset card size numbers stay clear of the tips (#4454) (#4485) In the Brush Preset picker's cards the tip thumbnail grew to 44.8 pt in a 42 pt cell while the size number was anchored to the cell's bottom, so the number of any medium or large brush (Hard Round's 30, Soft Round's 45) was drawn under the tip and couldn't be read. card_tip_rect now keeps a band for the number at the bottom of the cell and fits the tip above it, still scaled by the preset's thumb_scale so big brushes read bigger than small ones. Compact cards (no number) are unchanged. Test: card_tips_never_cover_their_size_number checks every footer scale that shows numbers, in all three card kinds. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/brushes_tab.rs | 33 ++++++++++----- crates/ui-egui/src/brushes_tab_tests.rs | 53 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/brushes_tab.rs b/crates/ui-egui/src/brushes_tab.rs index 7a8d56b3d0..703b6e0290 100644 --- a/crates/ui-egui/src/brushes_tab.rs +++ b/crates/ui-egui/src/brushes_tab.rs @@ -475,6 +475,22 @@ fn darker(c: Color32, f: f32) -> Color32 { Color32::from_rgba_unmultiplied((r as f32 * f).round() as u8, (g as f32 * f).round() as u8, (b as f32 * f).round() as u8, a) } +/// Where a card's tip thumbnail goes in its `cell`. `number_h` is the height of the size number +/// under it, `None` on compact cards (no number: the tip takes the cell whole). Otherwise the +/// number keeps a band at the bottom of the cell and the tip is centred in what's left above it, +/// at [`CARD_TIP_BASE`] × `scale` or that room's side, whichever is smaller, times the preset's +/// [`crate::brush_sections::thumb_scale`]: big brushes still read bigger than small ones, and +/// none of them reaches down over the number. +fn card_tip_rect(cell: egui::Rect, number_h: Option, size: f32, scale: f32) -> egui::Rect { + let Some(number_h) = number_h else { + return egui::Rect::from_center_size(cell.center(), egui::Vec2::splat((cell.width().min(cell.height()) - 2.0).max(1.0))); + }; + // The number sits 1 pt off the cell's bottom, with a 1 pt gap above it. + let room = egui::Rect::from_min_max(cell.min, pos2(cell.right(), (cell.bottom() - number_h - 2.0).max(cell.top()))); + let side = (CARD_TIP_BASE * scale).min(room.width().min(room.height())).max(0.0) * crate::brush_sections::thumb_scale(size); + egui::Rect::from_center_size(room.center(), egui::Vec2::splat(if side.is_finite() { side.max(1.0) } else { 1.0 })) +} + /// One preset in the Brush Preset picker: a 2 × 2 grid whose bottom row (the name) spans both /// columns — tip at the top left, stroke preview at the top right. A part that's off loses its /// cell (the other takes the room): with only the tip and the name on, the name moves into the @@ -545,18 +561,13 @@ fn preset_card( let tip_w = if tip_only { top.width() } else { (CARD_TIP_W * scale).min(top.width() * 0.5) }; let cell = egui::Rect::from_min_size(top.left_top(), vec2(tip_w, top_h)); let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text); - let side = if compact { (cell.width().min(cell.height()) - 2.0).max(1.0) } else { CARD_TIP_BASE * scale * crate::brush_sections::thumb_scale(pb.size) }; - let center = if compact { cell.center() } else { pos2(cell.center().x, cell.center().y - 2.0 * scale_h) }; - let tr = egui::Rect::from_center_size(center, vec2(side, side)); + let number = + (!compact).then(|| ui.painter().layout_no_wrap(format!("{}", pb.size.round() as i64), egui::FontId::proportional(CARD_NUM_FONT), t.text_faint)); + let tr = card_tip_rect(cell, number.as_ref().map(|g| g.size().y), pb.size, scale); ui.painter().image(tip.id(), tr, full_uv(), Color32::WHITE); - if !compact { - ui.painter().text( - pos2(cell.center().x, cell.bottom() - 1.0), - egui::Align2::CENTER_BOTTOM, - format!("{}", pb.size.round() as i64), - egui::FontId::proportional(CARD_NUM_FONT), - t.text_faint, - ); + if let Some(g) = number { + let at = pos2(cell.center().x - g.size().x / 2.0, cell.bottom() - 1.0 - g.size().y); + ui.painter().galley(at, g, t.text_faint); } x = cell.right(); } diff --git a/crates/ui-egui/src/brushes_tab_tests.rs b/crates/ui-egui/src/brushes_tab_tests.rs index 3a225aa6c6..4c61ecc54d 100644 --- a/crates/ui-egui/src/brushes_tab_tests.rs +++ b/crates/ui-egui/src/brushes_tab_tests.rs @@ -265,3 +265,56 @@ fn the_brushes_tab_grid_is_centred_past_its_indent() { h.run_steps(6); rows_are_centred(&h, PANEL_LIST.indent, 2); } + +/// #4454: a card's size number sat under the tip thumbnail of any medium or large brush (the +/// tip took up to 44.8 pt of a 42 pt cell, the number anchored to the cell's bottom), so the +/// "30" of Hard Round and the "45" of Soft Round couldn't be read. At every footer scale that +/// shows numbers, in each card kind, the tip now stays above the number, and bigger brushes still +/// get bigger tips. +#[test] +fn card_tips_never_cover_their_size_number() { + let number_h = std::rc::Rc::new(std::cell::Cell::new(0.0_f32)); + let seen = number_h.clone(); + let mut h = Harness::new_ui(move |ui| { + let g = ui.painter().layout_no_wrap("45".into(), egui::FontId::proportional(CARD_NUM_FONT), Color32::WHITE); + seen.set(g.size().y); + }); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run(); + let number_h = number_h.get(); + assert!(number_h > 8.0, "the number has a real height: {number_h}"); + let mut scale = CARD_COMPACT_SCALE + 0.01; + while scale <= *crate::brush_picker::SCALE_RANGE.end() + 1e-3 { + let scale_h = scale.max(CARD_MIN_H_SCALE); + // The tip cell of a full card (name row under it), a tip-and-name card and a tip-only one, + // as [`preset_card`] sizes them in a wide column. + let cells = [ + vec2(CARD_TIP_W * scale, (CARD_MAX_H - CARD_PAD * 2.0 - CARD_NAME_H) * scale_h), + vec2(CARD_TIP_W * scale, (CARD_TIP_NAME_H - CARD_PAD * 2.0) * scale_h), + vec2((CARD_TIP_ONLY_W - 16.0) * scale, (CARD_TIP_ONLY_H - CARD_PAD * 2.0) * scale_h), + ]; + for size in cells { + let cell = Rect::from_min_size(egui::pos2(10.0, 20.0), size); + let number_top = cell.bottom() - 1.0 - number_h; + let mut last = 0.0; + for brush in [1.0, 5.0, 13.0, 30.0, 45.0, 100.0, 2500.0] { + let tip = card_tip_rect(cell, Some(number_h), brush, scale); + assert!(tip.bottom() <= number_top + 1e-3, "scale {scale}, cell {size:?}, {brush} px: tip {tip:?} reaches the number at {number_top}"); + assert!(cell.expand(1e-3).contains_rect(tip), "scale {scale}, {brush} px: tip {tip:?} leaves its cell {cell:?}"); + assert!(tip.width() >= last, "scale {scale}, {brush} px: a bigger brush gets a smaller tip"); + last = tip.width(); + } + } + scale += 0.05; + } + // The tip-only grid's cells are tall enough for the number: a big brush's tip still spans + // the cell's width. + let cell = Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(CARD_TIP_ONLY_W - 16.0, CARD_TIP_ONLY_H - CARD_PAD * 2.0)); + assert!((card_tip_rect(cell, Some(number_h), 45.0, 1.0).width() - cell.width()).abs() < 1e-3); + // Compact cards: no number, the tip fills the cell; nonsense never makes a non-finite rect. + assert_eq!(card_tip_rect(cell, None, 45.0, 0.5).width(), cell.width() - 2.0); + for (n, size, scale) in [(f32::NAN, 30.0, 1.0), (500.0, f32::NAN, 1.0), (12.0, 30.0, f32::INFINITY), (12.0, -5.0, 0.0)] { + let r = card_tip_rect(cell, Some(n), size, scale); + assert!(r.min.is_finite() && r.max.is_finite(), "{n} {size} {scale}: {r:?}"); + } +}