From a5ac92d056ee939133ed4715d594ec481c55b94f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 07:30:35 -0800 Subject: [PATCH] Defer floating font wheel input until layer order settles --- crates/ui-egui/src/font_menu.rs | 71 +++++++++- .../ui-egui/src/panel_docking/native_tests.rs | 126 ++++++++++++++++++ 2 files changed, 195 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 5fbca5a2e9..ab4afc35c4 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -13,7 +13,7 @@ //! selected text, one undo step per notch. The menu's order at the first turn stays fixed until the //! pointer leaves the field or the text selection changes; steps stop at its first and last family. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use egui::{Color32, Sense, Ui, vec2}; @@ -436,6 +436,15 @@ struct WheelField { layer: egui::LayerId, } +#[derive(Clone)] +struct QueuedWheel { + frame: u64, + field: egui::Id, + position: egui::Pos2, + event: egui::Event, + turns: i32, +} + #[derive(Clone, Default)] struct WheelFields { pass: u64, @@ -445,6 +454,8 @@ struct WheelFields { pointer: Option, fields: Vec, pending: HashMap>, + queued: Vec, + replay: Vec, } #[cfg(not(target_arch = "wasm32"))] @@ -461,6 +472,49 @@ impl egui::Plugin for WheelLayerSnapshot { "font wheel viewport layers" } + fn on_begin_pass(&mut self, ui: &mut Ui) { + let ctx = ui.ctx(); + let viewport = ctx.viewport_id(); + if viewport == egui::ViewportId::ROOT { + return; + } + let key = wheel_fields_id(viewport); + let Some(mut fields) = ctx.data(|d| d.get_temp::(key)) else { return }; + let frame = ctx.cumulative_frame_nr_for(viewport); + let mut ready = Vec::new(); + fields.queued.retain(|event| { + if event.frame < frame { + ready.push(event.clone()); + false + } else { + true + } + }); + let invalid = ready + .iter() + .filter(|event| { + fields.fields.iter().find(|field| field.id == event.field).is_none_or(|field| ctx.layer_id_at(event.position) != Some(field.layer)) + }) + .map(|event| event.field) + .collect::>(); + let replayed = fields.replay.len(); + for event in ready { + if invalid.contains(&event.field) { + fields.replay.push(egui::Event::PointerMoved(event.position)); + fields.replay.push(event.event); + } else if event.turns != 0 { + fields.pending.entry(event.field).or_default().push(WheelInput::Notches(event.turns)); + } + } + for id in invalid { + ctx.data_mut(|d| d.remove::(id.with("wheel"))); + } + if fields.replay.len() > replayed { + ctx.request_repaint(); + } + ctx.data_mut(|d| d.insert_temp(key, fields)); + } + fn on_end_pass(&mut self, ui: &mut Ui) { let ctx = ui.ctx(); let viewport = ctx.viewport_id(); @@ -502,6 +556,12 @@ fn wheel_fields_id(viewport: egui::ViewportId) -> egui::Id { egui::Id::unique("font-wheel-fields").with(viewport) } +#[cfg(test)] +pub(crate) fn wheel_field_layer_for_test(ctx: &egui::Context, viewport: egui::ViewportId, point: egui::Pos2) -> Option { + ctx.data(|data| data.get_temp::(wheel_fields_id(viewport))) + .and_then(|fields| fields.fields.iter().find(|field| field.rect.contains(point)).map(|field| field.layer)) +} + #[derive(Clone, PartialEq)] struct WheelTarget { input: crate::state::InputGeneration, @@ -678,7 +738,11 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { }; let whole = notches.trunc(); ctx.data_mut(|d| d.insert_temp(id, notches - whole)); - if whole != 0.0 { + if cfg!(not(target_arch = "wasm32")) && ctx.viewport_id() != raw.viewport_id { + if let Some(position) = pointer { + fields.queued.push(QueuedWheel { frame, field: field.id, position, event: event.clone(), turns: whole as i32 }); + } + } else if whole != 0.0 { fields.pending.entry(field.id).or_default().push(WheelInput::Notches(whole as i32)); } captured = true; @@ -689,6 +753,9 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { } true }); + if !fields.replay.is_empty() { + raw.events.splice(0..0, std::mem::take(&mut fields.replay)); + } fields.pointer = pointer; ctx.data_mut(|d| d.insert_temp(key, fields)); if captured { diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index 77e3d08bc6..c750b6e397 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -34,6 +34,9 @@ struct Host { child_transition_overlay: Option<(ViewportId, Rect, bool)>, child_empty_overlay: Option<(ViewportId, Rect)>, child_pass_through_overlay: Option<(ViewportId, Rect)>, + child_raise_overlay: Option<(ViewportId, Rect, bool)>, + child_raise_layer: Option<(ViewportId, egui::LayerId)>, + child_layer_order: HashMap>, /// The window with the keyboard. focus: ViewportId, time: f64, @@ -98,8 +101,23 @@ impl Host { 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 child_raise_overlay = host.borrow().child_raise_overlay.filter(|(viewport, _, _)| *viewport == id).map(|(_, rect, raise)| (rect, raise)); + let child_raise_layer = host.borrow().child_raise_layer.filter(|(viewport, _)| *viewport == id).map(|(_, layer)| layer); + let mut layer_order = Vec::new(); let mut full = ctx.run_ui(input, |ui| { + layer_order = ui.ctx().memory(|memory| memory.layer_ids().collect()); + if let Some((rect, _)) = child_raise_overlay { + egui::Area::new(egui::Id::unique("Child raised overlay")).order(egui::Order::Background).fixed_pos(rect.min).show(ui.ctx(), |ui| { + ui.allocate_space(rect.size()); + }); + } (viewport.viewport_ui_cb)(ui); + if let Some(layer) = child_raise_layer { + ui.ctx().move_to_top(layer); + } + if child_raise_overlay.is_some_and(|(_, raise)| raise) { + ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Background, egui::Id::unique("Child raised overlay"))); + } 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"); @@ -151,6 +169,7 @@ impl Host { let mut shown = Vec::new(); full.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); let mut host = host.borrow_mut(); + host.child_layer_order.insert(id, layer_order); if let Some(window) = host.windows.get_mut(&id) { window.texts = shown; } @@ -174,6 +193,9 @@ impl Harness { child_transition_overlay: None, child_empty_overlay: None, child_pass_through_overlay: None, + child_raise_overlay: None, + child_raise_layer: None, + child_layer_order: HashMap::new(), focus: ViewportId::ROOT, time: 0.0, commands: Vec::new(), @@ -528,6 +550,7 @@ fn wheel_over_a_floating_character_font_field_changes_the_font() { }, ]); h.frame(vec![]); + 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))); @@ -560,6 +583,7 @@ fn wheel_over_a_floating_character_font_field_changes_the_font() { }, ]); h.frame(vec![]); + h.frame(vec![]); assert_ne!(h.app.run("text.fonts", json!({})).unwrap(), covered); } @@ -658,6 +682,107 @@ fn wheel_stays_under_an_empty_interactive_overlay() { assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), covered); } +#[test] +fn a_raised_overlay_blocks_the_next_font_wheel_event() { + 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_raise_overlay = Some((viewport, field.expand(10.0), false)); + h.frames(2); + let field_layer = crate::font_menu::wheel_field_layer_for_test(&h.ctx, viewport, pointer).expect("the font field layer"); + h.host.borrow_mut().child_raise_layer = Some((viewport, field_layer)); + h.frame(vec![]); + h.host.borrow_mut().child_raise_layer = None; + let overlay_layer = egui::LayerId::new(egui::Order::Background, egui::Id::unique("Child raised overlay")); + let before = h.app.run("text.fonts", json!({})).unwrap(); + h.host.borrow_mut().child_raise_overlay = Some((viewport, field.expand(10.0), true)); + h.frame(vec![]); + let order = h.host.borrow().child_layer_order.get(&viewport).cloned().expect("child layer order"); + let field_index = order.iter().position(|layer| *layer == field_layer).expect("font field in layer order"); + let overlay_index = order.iter().position(|layer| *layer == overlay_layer).unwrap_or_else(|| panic!("overlay absent from {order:?}")); + assert!(field_index > overlay_index, "the font field must start above the overlay: {order:?}"); + h.host.borrow_mut().child_raise_overlay = Some((viewport, field.expand(10.0), false)); + 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![]); + let order = h.host.borrow().child_layer_order.get(&viewport).cloned().expect("child layer order"); + let field_index = order.iter().position(|layer| *layer == field_layer).expect("font field in layer order"); + let overlay_index = order.iter().position(|layer| *layer == overlay_layer).unwrap_or_else(|| panic!("overlay absent from {order:?}")); + assert!(overlay_index > field_index, "the overlay must finish above the font field: {order:?}"); + assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), before); + h.frame(vec![]); + h.frame(vec![]); + assert!( + h.ctx.input_for(viewport, |input| input.raw.events.iter().any(|event| matches!(event, Event::MouseWheel { .. }))), + "the raised overlay must receive the wheel event" + ); + assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), before); +} + +#[test] +fn an_overlay_raised_with_the_wheel_event_does_not_change_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.host.borrow_mut().child_raise_overlay = Some((viewport, field.expand(10.0), false)); + h.frames(2); + let field_layer = crate::font_menu::wheel_field_layer_for_test(&h.ctx, viewport, pointer).expect("the font field layer"); + h.host.borrow_mut().child_raise_layer = Some((viewport, field_layer)); + h.frame(vec![]); + h.host.borrow_mut().child_raise_layer = None; + h.frame(vec![]); + let overlay_layer = egui::LayerId::new(egui::Order::Background, egui::Id::unique("Child raised overlay")); + let order = h.host.borrow().child_layer_order.get(&viewport).cloned().expect("child layer order"); + let field_index = order.iter().position(|layer| *layer == field_layer).expect("font field in layer order"); + let overlay_index = order.iter().position(|layer| *layer == overlay_layer).expect("overlay in layer order"); + assert!(field_index > overlay_index, "the font field must be above the overlay before the wheel event: {order:?}"); + let before = h.app.run("text.fonts", json!({})).unwrap(); + h.host.borrow_mut().child_raise_overlay = Some((viewport, field.expand(10.0), 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![]); + assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), before); + h.frame(vec![]); + h.frame(vec![]); + assert!( + h.ctx.input_for(viewport, |input| input.raw.events.iter().any(|event| matches!(event, Event::MouseWheel { .. }))), + "the raised overlay must receive the wheel event" + ); + assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), before); +} + #[test] fn wheel_without_a_move_uses_the_floating_viewports_pointer() { let mut h = Harness::new(); @@ -684,5 +809,6 @@ fn wheel_without_a_move_uses_the_floating_viewports_pointer() { source: egui::MouseWheelSource::Unknown, }); h.frame(vec![]); + h.frame(vec![]); assert_ne!(h.app.run("text.fonts", json!({})).unwrap(), before); }