From 7ccff3b96ea6a9d20995114d87764d2de9dd6ed9 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 16:14:39 +0200 Subject: [PATCH 1/2] fix(toast): draw the toast as widgets so the message can be selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toast was painted straight onto the layer with painter.galley(), and painted text is never hit-tested: nothing in it could be selected or copied. A toast often carries the path or the command the user needs elsewhere ("Exported frame to /path"), and egui's selectable labels — on by default — only work on real widgets. Draw it as an Area with a Frame and a wrapping Label instead, keeping the same centring, 6 px radius, 20/11 padding and the 0.25 s fade-out. The new tests assert the toast has an area of its own, that the pointer reaches the text (egui offers the text cursor over it), and that a long message still wraps within the canvas. All three fail when the toast is painted, which is what they are for. --- crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/panels/mod.rs | 28 ++++++++++----- crates/ui-egui/src/tests_toast.rs | 58 +++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/tests_toast.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77..b2b60f18 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -114,6 +114,8 @@ mod tests_sync; #[cfg(test)] mod tests_titlebar; #[cfg(test)] +mod tests_toast; +#[cfg(test)] mod tests_unsaved; #[cfg(test)] mod tests_wb_selector; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a8d99aed..7a90d5a1 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -69,6 +69,9 @@ pub fn resizable_side( } /// The HUD toast at the bottom centre of the canvas. +/// +/// Drawn as widgets rather than a painted galley so the message can be selected and copied: an +/// error here often carries a path or a command the user wants to paste somewhere else. pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { let now = ctx.input(|i| i.time); let Some((text, until, label)) = app.ui.toast.clone() else { return }; @@ -79,16 +82,25 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(canvas) = app.canvas_rect else { return }; let t = Tokens::get(ctx); let fade = ((until - now) / 0.25).clamp(0.0, 1.0) as f32; - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("toast"))); + let (background, foreground) = label.map(crate::theme::label_toast_colors).unwrap_or((egui::Color32::from_black_alpha(200), t.text)); + let background = background.gamma_multiply(fade); + let foreground = foreground.gamma_multiply(fade); // long messages (a save warning, the web build's notices) wrap within the canvas let wrap = (canvas.width() - 80.0).clamp(200.0, 720.0); - let (background, foreground) = label.map(crate::theme::label_toast_colors).unwrap_or((egui::Color32::from_black_alpha(200), t.text)); - let foreground = foreground.gamma_multiply(fade); - let galley = painter.layout(text, t.font(14.0), foreground, wrap); - let size = vec2(galley.size().x + 40.0, (galley.size().y + 22.0).max(40.0)); - let r = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 60.0), size); - painter.rect_filled(r, 6.0, background.gamma_multiply(fade)); - painter.galley(r.center() - galley.size() / 2.0, galley, foreground); + egui::Area::new(egui::Id::new("toast")) + .order(egui::Order::Foreground) + .movable(false) + .pivot(Align2::CENTER_CENTER) + .fixed_pos(pos2(canvas.center().x, canvas.bottom() - 60.0)) + .show(ctx, |ui| { + ui.set_max_width(wrap); + egui::Frame::NONE.fill(background).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::symmetric(20, 11)).show( + ui, + |ui| { + ui.add(egui::Label::new(egui::RichText::new(text).font(t.font(14.0)).color(foreground)).wrap()); + }, + ); + }); ctx.request_repaint_after(std::time::Duration::from_millis(30)); } diff --git a/crates/ui-egui/src/tests_toast.rs b/crates/ui-egui/src/tests_toast.rs new file mode 100644 index 00000000..cf671d24 --- /dev/null +++ b/crates/ui-egui/src/tests_toast.rs @@ -0,0 +1,58 @@ +//! The toast is drawn as widgets, not as paint. That is what makes its message selectable: a toast +//! often carries a path or a command the user wants somewhere else, and painted text cannot be +//! picked up by the pointer at all. + +use std::time::Duration; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn demo() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + // let the first renders finish: a render still running when the test binary exits can crash in the + // GPU driver's teardown (the preview pool doesn't join its workers) + h.settle(T); + h +} + +/// One event per frame: [`LightcraftApp::raw_input_hook`] takes pointer events singly. +fn send(h: &mut Headless, e: egui::Event) { + h.app.synthetic.push(e); + h.step(); +} + +fn show_toast(h: &mut Headless, msg: &str) -> egui::Rect { + h.app.ui.toast = Some((msg.to_owned(), f64::MAX, None)); + h.step(); + h.step(); + h.view.ctx.memory(|m| m.area_rect(egui::Id::new("toast"))).expect("the toast is laid out as a widget, not paint") +} + +#[test] +fn the_toast_is_a_widget_the_pointer_reaches() { + let mut h = demo(); + let msg = "Exported frame to /tmp/scene-0001.png"; + let area = show_toast(&mut h, msg); + + assert!(area.is_positive(), "the toast has a rect of its own: {area:?}"); + assert!(h.painted_text().iter().any(|t| t.contains("Exported frame")), "the message is still painted"); + + // The pointer reaches the message: egui offers the text cursor over it. Painted text is never + // hit-tested, so this is the difference that makes selection possible at all. + send(&mut h, egui::Event::PointerMoved(area.center())); + assert_eq!(h.last_cursor, egui::CursorIcon::Text, "the toast text is a text target"); +} + +#[test] +fn the_toast_still_wraps_a_long_message_inside_the_canvas() { + let mut h = demo(); + let msg = "The SAM 3 download failed: the file is 4.1 GB and the connection dropped at 98 %, so nothing was installed and the masks are still unavailable"; + let area = show_toast(&mut h, msg); + + let canvas = h.app.canvas_rect.expect("the demo has a canvas"); + assert!(area.width() <= canvas.width() - 80.0, "the toast wraps within the canvas: {area:?}"); + assert!(h.painted_text().iter().any(|t| t.contains("SAM 3 download failed")), "the whole message is painted"); +} From 031537ec0291721403b47fd4b3321ef297f2f1bc Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 16:29:35 +0200 Subject: [PATCH 2/2] contributors: add @switch87's names, as in the sibling craft apps --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 79f20c72..e153870f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -27,3 +27,8 @@ sync_github_name = true [people.sanyamsingh001] display_name = "sanyamsingh001" + +[people.switch87] +real_name = "Gert Pellin" +display_name = "Gert Pellin" +sync_github_name = false