Handle font wheel input in native viewports

This commit is contained in:
dotCooCoo
2026-11-04 19:41:47 +07:00
committed by bflatastic
parent 27fc855246
commit d9499bc0a3
4 changed files with 384 additions and 12 deletions
+8 -4
View File
@@ -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<NativeInputHook>, windows: HashSet<egui::ViewportId>) {
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;
+77 -3
View File
@@ -447,6 +447,57 @@ struct WheelFields {
pending: HashMap<egui::Id, Vec<WheelInput>>,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone)]
struct WheelLayers(Arc<dyn Fn(egui::Pos2) -> Option<egui::LayerId> + 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::<WheelFields>(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::<Vec<_>>()
});
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::<Vec<_>>()
});
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::<Vec<_>>();
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::<WheelFields>(key)) else { return };
if !ctx.input(|i| i.raw.viewports.contains_key(&raw.viewport_id)) {
ctx.data_mut(|d| d.remove::<WheelFields>(key));
#[cfg(not(target_arch = "wasm32"))]
ctx.data_mut(|d| {
d.remove::<WheelLayers>(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<egui::Pos2>| 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::<WheelLayers>(key.with("layers")));
let over = |field: &WheelField, point: Option<egui::Pos2>| {
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;
+9 -2
View File
@@ -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.
@@ -29,6 +29,11 @@ struct Window {
struct Host {
windows: HashMap<ViewportId, Window>,
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<RefCell<Host>>,
root_overlay: Option<Rect>,
}
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);
}