From d9499bc0a38ef819335eece34153a85bf2ba506e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 16:56:24 -0800 Subject: [PATCH] Handle font wheel input in native viewports --- crates/ui-egui/src/doc_windows.rs | 12 +- crates/ui-egui/src/font_menu.rs | 80 ++++- crates/ui-egui/src/panel_docking.rs | 11 +- .../ui-egui/src/panel_docking/native_tests.rs | 293 +++++++++++++++++- 4 files changed, 384 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/doc_windows.rs b/crates/ui-egui/src/doc_windows.rs index d1984536f..2f620a7df 100644 --- a/crates/ui-egui/src/doc_windows.rs +++ b/crates/ui-egui/src/doc_windows.rs @@ -677,18 +677,22 @@ mod input { "vectorcraft document windows input" } - fn input_hook(&mut self, _: &egui::Context, input: &mut egui::RawInput) { - if let Some(hook) = self.hook.as_ref().filter(|_| self.windows.contains(&input.viewport_id)) { - hook(input); + fn input_hook(&mut self, ctx: &egui::Context, input: &mut egui::RawInput) { + if self.windows.contains(&input.viewport_id) { + if let Some(hook) = &self.hook { + hook(input); + } + crate::font_menu::take_raw_wheel(ctx, input); } } } /// Run `hook` on the input of `windows` from their next frame on. pub(super) fn register(ctx: &egui::Context, hook: Option, windows: HashSet) { - if hook.is_none() && ctx.with_plugin(|_: &mut DocumentWindowInput| ()).is_none() { + if windows.is_empty() && ctx.with_plugin(|_: &mut DocumentWindowInput| ()).is_none() { return; } + crate::font_menu::ensure_native_wheel_layers(ctx); ctx.add_plugin(DocumentWindowInput::default()); ctx.with_plugin(|p: &mut DocumentWindowInput| { p.hook = hook; diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 7366cc1a3..5fbca5a2e 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -447,6 +447,57 @@ struct WheelFields { pending: HashMap>, } +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone)] +struct WheelLayers(Arc Option + Send + Sync>); + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +struct WheelLayerSnapshot; + +#[cfg(not(target_arch = "wasm32"))] +impl egui::Plugin for WheelLayerSnapshot { + fn debug_name(&self) -> &'static str { + "font wheel viewport layers" + } + + fn on_end_pass(&mut self, ui: &mut Ui) { + let ctx = ui.ctx(); + let viewport = ctx.viewport_id(); + if viewport == egui::ViewportId::ROOT || ctx.data(|d| d.get_temp::(wheel_fields_id(viewport))).is_none() { + return; + } + let current_layers = ctx.viewport(|viewport| { + viewport.this_pass.widgets.layers().filter_map(|(layer, widgets)| (!widgets.is_empty()).then_some(*layer)).collect::>() + }); + let layers = ctx.memory(|m| { + m.layer_ids() + .filter(|layer| current_layers.contains(layer)) + .filter(|layer| m.areas().is_visible(layer)) + .filter_map(|layer| m.area_rect(layer.id).map(|rect| (layer, rect))) + .collect::>() + }); + let layers = layers + .into_iter() + .filter(|(layer, _)| egui::AreaState::load(ctx, layer.id).is_some_and(|state| state.interactable)) + .map(|(layer, rect)| (layer, ctx.layer_transform_to_global(layer).map_or(rect, |transform| transform * rect))) + .collect::>(); + ctx.data_mut(|d| { + d.insert_temp( + wheel_fields_id(viewport).with("layers"), + WheelLayers(Arc::new(move |p| layers.iter().rev().find(|(_, rect)| rect.contains(p)).map(|(layer, _)| *layer))), + ) + }); + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn ensure_native_wheel_layers(ctx: &egui::Context) { + if ctx.with_plugin(|_: &mut WheelLayerSnapshot| ()).is_none() { + ctx.add_plugin(WheelLayerSnapshot); + } +} + fn wheel_fields_id(viewport: egui::ViewportId) -> egui::Id { egui::Id::unique("font-wheel-fields").with(viewport) } @@ -535,6 +586,10 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { let Some(mut fields) = ctx.data(|d| d.get_temp::(key)) else { return }; if !ctx.input(|i| i.raw.viewports.contains_key(&raw.viewport_id)) { ctx.data_mut(|d| d.remove::(key)); + #[cfg(not(target_arch = "wasm32"))] + ctx.data_mut(|d| { + d.remove::(key.with("layers")); + }); return; } let frame = ctx.cumulative_frame_nr_for(raw.viewport_id); @@ -548,9 +603,28 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { fields.pending.clear(); } let line = ctx.options(|o| o.input_options.line_scroll_speed).max(1.0); - let mut pointer = fields.pointer.or_else(|| ctx.input(|i| i.pointer.latest_pos())); - let over = - |field: &WheelField, point: Option| point.is_some_and(|p| field.rect.contains(p) && ctx.layer_id_at(p) == Some(field.layer)); + let mut pointer = fields.pointer.or_else(|| ctx.input_for(raw.viewport_id, |i| i.pointer.latest_pos())); + #[cfg(not(target_arch = "wasm32"))] + let native_layers = ctx.data(|d| d.get_temp::(key.with("layers"))); + let over = |field: &WheelField, point: Option| { + point.is_some_and(|p| { + if !field.rect.contains(p) { + return false; + } + if ctx.viewport_id() == raw.viewport_id { + ctx.layer_id_at(p) == Some(field.layer) + } else { + #[cfg(not(target_arch = "wasm32"))] + { + native_layers.as_ref().is_some_and(|layers| (layers.0)(p) == Some(field.layer)) + } + #[cfg(target_arch = "wasm32")] + { + false + } + } + }) + }; // An activation batch belongs to the popup, including a release after a prior press. if fields.activation_pass != pass { fields.activation_pass = pass; diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 0deb2c3c0..cc177a4de 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1123,8 +1123,15 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { .input_blocked(modal) .auto_collapse_iconic(app.session.prefs.auto_collapse_icon_panels); #[cfg(not(target_arch = "wasm32"))] - if let Some(hook) = app.services.native_input_hook.clone() { - dock = dock.native_input_hook(move |_, raw| hook(raw)); + { + crate::font_menu::ensure_native_wheel_layers(ui.ctx()); + let hook = app.services.native_input_hook.clone(); + dock = dock.native_input_hook(move |ctx, raw| { + if let Some(hook) = &hook { + hook(raw); + } + crate::font_menu::take_raw_wheel(ctx, raw); + }); } // Tab and Shift+Tab (`window.dock`, `window.panels`) and the full screen modes hide the panels; // the canvas stays. diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index 61eb0e2f2..77e3d08bc 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -29,6 +29,11 @@ struct Window { struct Host { windows: HashMap, + child_overlay: Option<(ViewportId, Rect)>, + child_transparent_overlay: Option<(ViewportId, Rect)>, + child_transition_overlay: Option<(ViewportId, Rect, bool)>, + child_empty_overlay: Option<(ViewportId, Rect)>, + child_pass_through_overlay: Option<(ViewportId, Rect)>, /// The window with the keyboard. focus: ViewportId, time: f64, @@ -87,7 +92,54 @@ impl Host { ..Default::default() } }; - let mut full = ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)); + let child_overlay = host.borrow().child_overlay.filter(|(viewport, _)| *viewport == id).map(|(_, rect)| rect); + let child_transparent_overlay = host.borrow().child_transparent_overlay.filter(|(viewport, _)| *viewport == id).map(|(_, rect)| rect); + let child_transition_overlay = + host.borrow().child_transition_overlay.filter(|(viewport, _, _)| *viewport == id).map(|(_, rect, painted)| (rect, painted)); + let child_empty_overlay = host.borrow().child_empty_overlay.filter(|(viewport, _)| *viewport == id).map(|(_, rect)| rect); + let child_pass_through_overlay = host.borrow().child_pass_through_overlay.filter(|(viewport, _)| *viewport == id).map(|(_, rect)| rect); + let mut full = ctx.run_ui(input, |ui| { + (viewport.viewport_ui_cb)(ui); + if let Some(rect) = child_overlay { + egui::Window::new("Child overlay").fixed_pos(rect.min).fixed_size(rect.size()).show(ui.ctx(), |ui| { + ui.label("Child overlay"); + }); + } + if let Some(rect) = child_transparent_overlay { + egui::Area::new(egui::Id::unique("Child transparent overlay")).order(egui::Order::Foreground).fixed_pos(rect.min).show( + ui.ctx(), + |ui| { + ui.allocate_space(rect.size()); + }, + ); + } + if let Some((rect, painted)) = child_transition_overlay { + egui::Area::new(egui::Id::unique("Child transition overlay")).order(egui::Order::Foreground).fixed_pos(rect.min).show( + ui.ctx(), + |ui| { + ui.allocate_space(rect.size()); + if painted { + ui.label("Child transition overlay"); + } + }, + ); + } + if let Some(rect) = child_empty_overlay { + egui::Area::new(egui::Id::unique("Child empty overlay")).order(egui::Order::Foreground).fixed_pos(rect.min).show(ui.ctx(), |ui| { + ui.set_min_size(rect.size()); + }); + } + if let Some(rect) = child_pass_through_overlay { + egui::Area::new(egui::Id::unique("Child pass-through overlay")) + .order(egui::Order::Foreground) + .fixed_pos(rect.min) + .interactable(false) + .show(ui.ctx(), |ui| { + let (paint, _) = ui.allocate_exact_size(rect.size(), egui::Sense::hover()); + ui.painter().rect_filled(paint, 0.0, egui::Color32::TRANSPARENT); + }); + } + }); full.textures_delta.clear(); fn texts(s: &egui::Shape, out: &mut Vec<(String, Rect)>) { match s { @@ -110,18 +162,29 @@ struct Harness { app: VectorcraftApp, ctx: egui::Context, host: Rc>, + root_overlay: Option, } impl Harness { fn new() -> Self { - let host = Rc::new(RefCell::new(Host { windows: HashMap::new(), focus: ViewportId::ROOT, time: 0.0, commands: Vec::new() })); + let host = Rc::new(RefCell::new(Host { + windows: HashMap::new(), + child_overlay: None, + child_transparent_overlay: None, + child_transition_overlay: None, + child_empty_overlay: None, + child_pass_through_overlay: None, + focus: ViewportId::ROOT, + time: 0.0, + commands: Vec::new(), + })); let shared = host.clone(); egui::Context::set_immediate_viewport_renderer(move |ctx, viewport| Host::render(&shared, ctx, viewport)); let ctx = egui::Context::default(); ctx.set_embed_viewports(false); let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - let mut h = Self { app, ctx, host }; + let mut h = Self { app, ctx, host, root_overlay: None }; h.frames(3); h } @@ -135,9 +198,15 @@ impl Harness { let mut input = egui::RawInput { time: Some(time), events, screen_rect: Some(SCREEN), viewports, focused, ..Default::default() }; self.app.raw_input_hook(&self.ctx, &mut input); let app = &mut self.app; + let root_overlay = self.root_overlay; let mut full = self.ctx.run_ui(input, |ui| { app.logic(ui.ctx()); app.ui(ui); + if let Some(rect) = root_overlay { + egui::Window::new("Root overlay").fixed_pos(rect.min).fixed_size(rect.size()).show(ui.ctx(), |ui| { + ui.label("Root overlay"); + }); + } }); full.textures_delta.clear(); self.host.borrow_mut().apply(&full); @@ -399,3 +468,221 @@ fn ui_docking_leaves_out_the_controls_of_os_windows() { assert!(!hits.iter().any(swatches), "none of the OS window's: {hits:?}"); assert!(report["layout"]["floating"][0]["native"].is_array(), "its place is the window's native rect"); } + +#[test] +fn wheel_over_a_floating_character_font_field_changes_the_font() { + let mut h = Harness::new(); + h.app.run("text.create", json!({"x": 30, "y": 50, "text": "Hi", "font": "Source Sans 3"})).unwrap(); + let viewport = h.float("character"); + let field = h + .host + .borrow() + .windows + .get(&viewport) + .and_then(|w| w.texts.iter().find(|(text, _)| text == "Source Sans 3").map(|(_, rect)| *rect)) + .expect("the Character panel's font family field"); + let pointer = pos2(field.min.x + 4.0, field.center().y); + h.root_overlay = Some(field.expand(10.0)); + h.frames(2); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ + Event::PointerMoved(pointer), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }, + ]); + h.frame(vec![]); + h.frames(2); + let fonts = h.app.run("text.fonts", json!({})).unwrap(); + assert_ne!(fonts[0]["family"], "Source Sans 3", "{fonts}"); + + h.host.borrow_mut().child_overlay = Some((viewport, field.expand(10.0))); + h.frames(2); + let covered = h.app.run("text.fonts", json!({})).unwrap(); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ + Event::PointerMoved(pointer), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }, + ]); + h.frame(vec![]); + assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), covered); + + h.host.borrow_mut().child_overlay = None; + h.frame(vec![]); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ + Event::PointerMoved(pointer), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }, + ]); + h.frame(vec![]); + assert_ne!(h.app.run("text.fonts", json!({})).unwrap(), covered); + + h.host.borrow_mut().child_transparent_overlay = Some((viewport, field.expand(10.0))); + h.frames(2); + let covered = h.app.run("text.fonts", json!({})).unwrap(); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ + Event::PointerMoved(pointer), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }, + ]); + h.frame(vec![]); + assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), covered); + + h.host.borrow_mut().child_transparent_overlay = None; + h.host.borrow_mut().child_pass_through_overlay = Some((viewport, field.expand(10.0))); + h.frames(2); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ + Event::PointerMoved(pointer), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }, + ]); + h.frame(vec![]); + assert_ne!(h.app.run("text.fonts", json!({})).unwrap(), covered); +} + +#[test] +fn a_modal_blocks_font_wheel_input_in_a_floating_panel() { + let mut h = Harness::new(); + h.app.run("text.create", json!({"x": 30, "y": 50, "text": "Hi", "font": "Source Sans 3"})).unwrap(); + let viewport = h.float("character"); + let field = h + .host + .borrow() + .windows + .get(&viewport) + .and_then(|w| w.texts.iter().find(|(text, _)| text == "Source Sans 3").map(|(_, rect)| *rect)) + .expect("the Character panel's font family field"); + let pointer = pos2(field.min.x + 4.0, field.center().y); + let before = h.app.run("text.fonts", json!({})).unwrap(); + h.app.ui.about = true; + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ + Event::PointerMoved(pointer), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }, + ]); + h.frame(vec![]); + h.frames(2); + assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), before); + h.app.ui.about = false; + h.frames(2); + assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), before); +} + +#[test] +fn wheel_stays_under_an_overlay_that_stops_painting() { + let mut h = Harness::new(); + h.app.run("text.create", json!({"x": 30, "y": 50, "text": "Hi", "font": "Source Sans 3"})).unwrap(); + let viewport = h.float("character"); + let field = h + .host + .borrow() + .windows + .get(&viewport) + .and_then(|w| w.texts.iter().find(|(text, _)| text == "Source Sans 3").map(|(_, rect)| *rect)) + .expect("the Character panel's font family field"); + let pointer = pos2(field.min.x + 4.0, field.center().y); + h.host.borrow_mut().child_transition_overlay = Some((viewport, field.expand(10.0), true)); + h.frames(2); + let covered = h.app.run("text.fonts", json!({})).unwrap(); + h.host.borrow_mut().child_transition_overlay = Some((viewport, field.expand(10.0), false)); + h.frame(vec![]); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ + Event::PointerMoved(pointer), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }, + ]); + h.frame(vec![]); + assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), covered); +} + +#[test] +fn wheel_stays_under_an_empty_interactive_overlay() { + let mut h = Harness::new(); + h.app.run("text.create", json!({"x": 30, "y": 50, "text": "Hi", "font": "Source Sans 3"})).unwrap(); + let viewport = h.float("character"); + let field = h + .host + .borrow() + .windows + .get(&viewport) + .and_then(|w| w.texts.iter().find(|(text, _)| text == "Source Sans 3").map(|(_, rect)| *rect)) + .expect("the Character panel's font family field"); + let pointer = pos2(field.min.x + 4.0, field.center().y); + h.host.borrow_mut().child_empty_overlay = Some((viewport, field.expand(10.0))); + h.frames(2); + let covered = h.app.run("text.fonts", json!({})).unwrap(); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ + Event::PointerMoved(pointer), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }, + ]); + h.frame(vec![]); + assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), covered); +} + +#[test] +fn wheel_without_a_move_uses_the_floating_viewports_pointer() { + let mut h = Harness::new(); + h.app.run("text.create", json!({"x": 30, "y": 50, "text": "Hi", "font": "Source Sans 3"})).unwrap(); + let viewport = h.float("character"); + let field = h + .host + .borrow() + .windows + .get(&viewport) + .and_then(|w| w.texts.iter().find(|(text, _)| text == "Source Sans 3").map(|(_, rect)| *rect)) + .expect("the Character panel's font family field"); + let pointer = pos2(field.min.x + 4.0, field.center().y); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.push(Event::PointerMoved(pointer)); + h.frame(vec![Event::PointerMoved(pos2(1200.0, 800.0))]); + h.app.run("text.create", json!({"x": 30, "y": 90, "text": "New", "font": "Source Sans 3"})).unwrap(); + h.frames(2); + let before = h.app.run("text.fonts", json!({})).unwrap(); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }); + h.frame(vec![]); + assert_ne!(h.app.run("text.fonts", json!({})).unwrap(), before); +}