Type: route font wheel input before scroll smoothing

This commit is contained in:
dotCooCoo
2026-11-03 16:03:40 +07:00
committed by bflatastic
parent 2a95625b99
commit 13ede29a5e
6 changed files with 404 additions and 47 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ impl VectorcraftApp {
/// Identify the document, modal dialog and focused widget that the current accepted input batch
/// belongs to.
pub(super) fn input_generation(&self) -> InputGeneration {
pub(crate) fn input_generation(&self) -> InputGeneration {
InputGeneration {
document: self.session.active().map_or(0, |document| document.uid),
dialog: self.ui.dialog.as_ref().map(|dialog| egui::Id::unique(&dialog.kind)),
+6
View File
@@ -175,6 +175,12 @@ impl VectorcraftApp {
/// injected until the batch was actually applied, so a burst of hidden ticks cannot queue
/// several steps of a drag or replay them together later.
pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.fill_raw_input(ctx, raw);
crate::font_menu::sync_wheel_target(self, ctx, raw.viewport_id);
crate::font_menu::take_raw_wheel(ctx, raw);
}
fn fill_raw_input(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// Both hosts call this hook once per host iteration, before deciding whether a UI pass
// follows; it is the iteration boundary, not a UI-phase marker.
self.begin_host_iteration();
+188 -45
View File
@@ -249,6 +249,7 @@ struct MenuState {
/// kept while the pointer stays on it: each step makes its family a recent one, which would
/// otherwise move it in the order.
wheel_order: Option<Arc<[String]>>,
wheel_target: Option<crate::state::InputGeneration>,
}
/// The font family menu showing `current`, `width` points wide, rows sampling `sample` (the
@@ -263,6 +264,12 @@ pub fn font_menu(
) -> Option<FontPick> {
let state_id = ui.scope_id().with(&id).with("font-menu");
let mut st: MenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default();
let target = ui.data(|d| d.get_temp(wheel_fields_id(ui.ctx().viewport_id()).with("target")));
let target_changed = st.wheel_target != target;
if target_changed {
st.wheel_order = None;
st.wheel_target = target;
}
let height = st.header + LIST_HEIGHT;
let state = &mut st;
// The search field and filters stay in place over the rows, which scroll. The rows are all
@@ -277,35 +284,53 @@ pub fn font_menu(
)
});
let drawn = shown.is_some();
if target_changed {
ui.data_mut(|d| d.remove::<f32>(field.id.with("wheel")));
}
let mut out = shown.flatten();
if !drawn && out.is_none() && st.previewed.take().is_some() {
// Closed (Escape, a click outside) with a preview showing.
out = Some(FontPick::EndPreview);
}
if !look.wheel || drawn || !field.contains_pointer() {
let turns = remember_wheel_field(ui, &field, look.wheel && !drawn && field.enabled());
if !look.wheel || !field.enabled() {
st.wheel_order = None;
ui.data_mut(|d| d.remove::<f32>(field.id.with("wheel")));
} else if out.is_none() {
let notches = wheel_notches(ui, &field);
if !notches.is_empty() {
} else if out.is_none() || drawn {
if !turns.is_empty() {
let mut own = vectorcraft_text::FontDb::global().canonical(current, "").0;
if st.wheel_order.is_none() {
st.wheel_order = Some(wheel_order(&st, look).into());
}
let mut steps = Vec::new();
if let Some(order) = st.wheel_order.as_deref() {
for notch in notches {
let next = wheel_steps(order, &own, notch);
if let Some(last) = next.last() {
own.clone_from(last);
for turn in turns {
match turn {
WheelInput::Leave => st.wheel_order = None,
WheelInput::Notches(notches) => {
if st.wheel_order.is_none() {
st.wheel_order = Some(wheel_order(&st, look).into());
}
if let Some(order) = st.wheel_order.as_deref() {
let next = wheel_steps(order, &own, notches);
if let Some(last) = next.last() {
own.clone_from(last);
}
steps.extend(next);
}
}
steps.extend(next);
}
}
if !steps.is_empty() {
if drawn {
// These turns preceded the opener. The popup cancels back to the newly
// applied family, and any preview drawn this pass has not been applied.
st.current.clone_from(&own);
st.previewed = None;
}
out = Some(FontPick::Wheel(steps));
}
}
if drawn || !field.contains_pointer() {
st.wheel_order = None;
ui.data_mut(|d| d.remove::<f32>(field.id.with("wheel")));
}
}
if matches!(out, Some(FontPick::Chosen(..))) {
st.previewed = None;
@@ -355,40 +380,158 @@ fn wheel_steps(order: &[String], current: &str, notches: i32) -> Vec<String> {
if to >= at { order.iter().skip(at + 1).take(to - at).cloned().collect() } else { order.iter().take(at).skip(to).rev().cloned().collect() }
}
/// Whole notches of the mouse wheel turned over closed field `resp` this frame, positive when it
/// turns down (towards the user). A wheel notch is a line, or `line_scroll_speed` points of a
/// trackpad or fine wheel; what is left of a notch carries over to the next turn. The wheel over
/// the field is the field's: the panel under it does not scroll. A turn with a modifier key is
/// left to the panel and the canvas.
fn wheel_notches(ui: &Ui, resp: &egui::Response) -> Vec<i32> {
let (line, id) = (ui.ctx().options(|o| o.input_options.line_scroll_speed), resp.id.with("wheel"));
let mut notches: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(0.0);
let mut completed = Vec::new();
let mut turned = false;
ui.input_mut(|i| {
i.events.retain(|e| match e {
egui::Event::MouseWheel { unit, delta, modifiers, .. } if modifiers.is_none() => {
notches -= match unit {
egui::MouseWheelUnit::Point => delta.y / line.max(1.0),
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => delta.y,
};
let whole = notches.trunc();
if whole != 0.0 {
completed.push(whole as i32);
notches -= whole;
}
turned = true;
false
}
_ => true,
});
if turned || i.modifiers.is_none() {
// The rest of a turn egui spreads over the next frames scrolls nothing either.
i.smooth_scroll_delta = egui::Vec2::ZERO;
#[derive(Clone)]
enum WheelInput {
Leave,
Notches(i32),
}
#[derive(Clone, Copy)]
struct WheelField {
id: egui::Id,
rect: egui::Rect,
layer: egui::LayerId,
}
#[derive(Clone, Default)]
struct WheelFields {
pass: u64,
pending_frame: u64,
activation_pass: u64,
activating: Vec<egui::Id>,
pointer: Option<egui::Pos2>,
fields: Vec<WheelField>,
pending: HashMap<egui::Id, Vec<WheelInput>>,
}
fn wheel_fields_id(viewport: egui::ViewportId) -> egui::Id {
egui::Id::unique("font-wheel-fields").with(viewport)
}
/// Pending turns belong to the document, dialog and focus that received their input batch.
pub(crate) fn sync_wheel_target(app: &VectorcraftApp, ctx: &egui::Context, viewport: egui::ViewportId) {
let target = app.input_generation();
let key = wheel_fields_id(viewport);
ctx.data_mut(|d| {
if d.get_temp::<crate::state::InputGeneration>(key.with("target")).is_some_and(|old| old != target) {
d.remove::<WheelFields>(key);
}
d.insert_temp(key.with("target"), target);
});
ui.data_mut(|d| d.insert_temp(id, notches));
completed
}
/// Remember the visible closed fields for the next input batch and take this field's turns.
fn remember_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool) -> Vec<WheelInput> {
let key = wheel_fields_id(ui.ctx().viewport_id());
let pass = ui.ctx().cumulative_pass_nr();
ui.data_mut(|d| {
let mut fields: WheelFields = d.get_temp(key).unwrap_or_default();
if fields.pass != pass {
fields.pass = pass;
fields.fields.clear();
}
fields.fields.retain(|f| f.id != resp.id);
if enabled {
fields.fields.push(WheelField { id: resp.id, rect: resp.interact_rect, layer: resp.layer_id });
}
let turns = fields.pending.remove(&resp.id).unwrap_or_default();
d.insert_temp(key, fields);
turns
})
}
/// Take the closed font fields' wheel events before egui smooths and combines them. Other
/// events stay in the input, so scrolling elsewhere keeps its own delta and event order.
pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) {
let key = wheel_fields_id(raw.viewport_id);
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));
return;
}
let frame = ctx.cumulative_frame_nr_for(raw.viewport_id);
let pass = ctx.cumulative_pass_nr_for(raw.viewport_id);
if pass < fields.pass || pass.saturating_sub(fields.pass) > 1 {
fields.fields.clear();
fields.pointer = None;
}
if fields.pending_frame != frame {
fields.pending_frame = frame;
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));
// An activation batch belongs to the popup, including a release after a prior press.
if fields.activation_pass != pass {
fields.activation_pass = pass;
fields.activating.clear();
let pressed = ctx.input(|i| if i.pointer.button_down(egui::PointerButton::Primary) { i.pointer.press_origin() } else { None });
if let Some(field) = fields.fields.iter().find(|f| over(f, pressed)) {
fields.activating.push(field.id);
}
}
let focused = ctx.memory(|m| m.focused());
let mut captured = false;
raw.events.retain(|event| {
let opening = match event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, .. } => {
fields.fields.iter().rev().find(|f| over(f, Some(*pos))).map(|f| f.id)
}
egui::Event::Key { key: egui::Key::Enter | egui::Key::Space, pressed: true, .. } if raw.focused => {
focused.filter(|id| fields.fields.iter().any(|f| f.id == *id))
}
_ => None,
};
if let Some(id) = opening
&& !fields.activating.contains(&id)
{
fields.activating.push(id);
}
match event {
egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } => {
for field in &fields.fields {
if over(field, pointer) && !over(field, Some(*p)) {
ctx.data_mut(|d| d.remove::<f32>(field.id.with("wheel")));
fields.pending.entry(field.id).or_default().push(WheelInput::Leave);
}
}
pointer = Some(*p);
}
egui::Event::PointerGone => {
pointer = None;
for field in &fields.fields {
ctx.data_mut(|d| d.remove::<f32>(field.id.with("wheel")));
fields.pending.entry(field.id).or_default().push(WheelInput::Leave);
}
}
egui::Event::MouseWheel { unit, delta, modifiers, .. } if modifiers.is_none() && delta.y != 0.0 => {
if let Some(field) = fields.fields.iter().rev().find(|f| !fields.activating.contains(&f.id) && over(f, pointer)).copied() {
let id = field.id.with("wheel");
let mut notches: f32 = ctx.data(|d| d.get_temp(id)).unwrap_or(0.0);
notches -= match unit {
egui::MouseWheelUnit::Point => delta.y / line,
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => delta.y,
};
let whole = notches.trunc();
ctx.data_mut(|d| d.insert_temp(id, notches - whole));
if whole != 0.0 {
fields.pending.entry(field.id).or_default().push(WheelInput::Notches(whole as i32));
}
captured = true;
return false;
}
}
_ => {}
}
true
});
fields.pointer = pointer;
ctx.data_mut(|d| d.insert_temp(key, fields));
if captured {
ctx.request_repaint();
}
}
/// What the menu's header hands its rows.
+93
View File
@@ -262,6 +262,99 @@ fn the_wheel_follows_the_menus_order_and_filters() {
assert_eq!(order, favorites, "the favourites filter");
}
/// A field removed by a later layout pass must not intercept the next wheel event.
#[test]
fn a_family_field_omitted_from_the_last_pass_leaves_the_wheel_to_its_owner() {
let ctx = egui::Context::default();
let mut point = egui::Pos2::ZERO;
for _ in 0..3 {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let resp = ui.button("Family");
point = resp.rect.center();
super::remember_wheel_field(ui, &resp, true);
});
out.textures_delta.clear();
}
let mut pass = 0;
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
if pass == 0 {
let resp = ui.button("Family");
point = resp.rect.center();
super::remember_wheel_field(ui, &resp, true);
ui.ctx().request_discard("the next pass removes the field");
}
pass += 1;
});
out.textures_delta.clear();
assert!(pass > 1);
let mut raw = egui::RawInput {
events: vec![
egui::Event::PointerMoved(point),
egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, -1.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
},
],
..Default::default()
};
super::take_raw_wheel(&ctx, &mut raw);
assert!(raw.events.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. })));
}
/// Activation leaves later wheel input to the popup; an earlier turn still belongs to the field.
#[test]
fn activating_the_family_popup_leaves_its_wheel_input_available() {
let activations = [None, Some((egui::Key::Enter, egui::Modifiers::NONE)), Some((egui::Key::Space, egui::Modifiers::SHIFT))];
for activation in activations {
let ctx = egui::Context::default();
let frame = |events| {
let mut raw = egui::RawInput { events, ..Default::default() };
super::take_raw_wheel(&ctx, &mut raw);
let mut point = egui::Pos2::ZERO;
let mut out = ctx.run_ui(raw, |ui| {
let resp = ui.button("Family");
point = resp.rect.center();
if activation.is_some() {
resp.request_focus();
}
super::remember_wheel_field(ui, &resp, true);
});
out.textures_delta.clear();
point
};
let mut point = egui::Pos2::ZERO;
for _ in 0..3 {
point = frame(vec![]);
}
let button =
|pressed| egui::Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
let opener = if let Some((key, modifiers)) = activation {
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
} else {
frame(vec![egui::Event::PointerMoved(point), button(true)]);
button(false)
};
let turn = || egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, -1.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
};
let mut events = vec![egui::Event::PointerMoved(point)];
if activation.is_some() {
events.push(turn());
}
events.extend([opener, turn()]);
let mut raw = egui::RawInput { events, ..Default::default() };
super::take_raw_wheel(&ctx, &mut raw);
assert_eq!(raw.events.iter().filter(|e| matches!(e, egui::Event::MouseWheel { .. })).count(), 1);
}
}
/// The Font Technology filter's Variable is the font adjective, not the Variables panel's noun.
#[test]
fn the_variable_filter_is_named_as_fonts_are() {
+1
View File
@@ -804,6 +804,7 @@ fn font_height_reference(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) {
/// Control bar), with their `ids` and `widths`. The mouse wheel over the closed family field steps
/// the text through the families.
pub(crate) fn font_pickers(app: &mut VectorcraftApp, ui: &mut Ui, s: &CharStyle, ids: (&str, &str), widths: (f32, f32)) {
crate::font_menu::sync_wheel_target(app, ui.ctx(), ui.ctx().viewport_id());
let sample = crate::font_menu::sample_text(app);
let look = crate::font_menu::MenuLook { wheel: true, ..crate::font_menu::MenuLook::of(app) };
if let Some(pick) = crate::font_menu::font_menu(ui, ids.0, &s.font_family, widths.0, sample.as_deref(), look) {
+115 -1
View File
@@ -232,8 +232,9 @@ fn panel_frame(
family: &str,
draw: &dyn Fn(&mut VectorcraftApp, &mut egui::Ui),
) -> (Option<egui::Pos2>, bool) {
let input =
let mut input =
egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), ..Default::default() };
app.raw_input_hook(ctx, &mut input);
let mut out = ctx.run_ui(input, |ui| {
crate::font_menu::end_stale_preview(app, ui.ctx());
draw(app, ui);
@@ -336,6 +337,119 @@ fn opposite_wheel_notches_in_one_frame_keep_their_undo_steps() {
assert_eq!(first_style(&app, id).font_family, "Inter");
}
/// A turn before opening applies once; the popup keeps the later wheel and cancels to that font.
#[test]
fn a_wheel_before_opening_the_family_popup_keeps_its_committed_change() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let id = selected_inter(&mut app);
let draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| crate::panels::character::show(app, ui);
let mut field = None;
for _ in 0..3 {
field = panel_frame(&ctx, &mut app, vec![], "Inter", &draw).0;
}
let field = field.unwrap();
let before = app.session.active().unwrap().history.undo.len();
let button = |pressed| egui::Event::PointerButton { pos: field, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
let turn = || wheel(egui::MouseWheelUnit::Line, -1.0, Default::default());
panel_frame(&ctx, &mut app, vec![egui::Event::PointerMoved(field), turn(), button(true), button(false), turn()], "Inter", &draw);
let applied = first_style(&app, id).font_family;
assert_ne!(applied, "Inter");
assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1);
for _ in 0..3 {
panel_frame(&ctx, &mut app, vec![], &applied, &draw);
}
panel_frame(&ctx, &mut app, key(egui::Key::Escape), &applied, &draw);
assert_eq!(first_style(&app, id).font_family, applied);
assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(first_style(&app, id).font_family, "Inter");
}
/// A later pointer move must not change which field owns an earlier wheel event.
#[test]
fn wheel_events_use_the_pointer_position_when_they_occurred() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let id = selected_inter(&mut app);
let draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| crate::panels::character::show(app, ui);
let mut field = None;
for _ in 0..3 {
field = panel_frame(&ctx, &mut app, vec![], "Inter", &draw).0;
}
let field = field.expect("the Character panel shows the family field");
let outside = egui::pos2(580.0, 780.0);
panel_frame(&ctx, &mut app, vec![egui::Event::PointerMoved(outside)], "Inter", &draw);
let before = app.session.active().unwrap().history.undo.len();
let line = egui::MouseWheelUnit::Line;
panel_frame(&ctx, &mut app, vec![wheel(line, -1.0, Default::default()), egui::Event::PointerMoved(field)], "Inter", &draw);
assert_eq!(first_style(&app, id).font_family, "Inter", "the wheel was outside the field");
assert_eq!(app.session.active().unwrap().history.undo.len(), before);
assert!(ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. }))), "outside wheel input is left for its owner");
panel_frame(&ctx, &mut app, vec![wheel(line, -1.0, Default::default()), egui::Event::PointerMoved(outside)], "Inter", &draw);
assert_ne!(first_style(&app, id).font_family, "Inter", "the wheel was over the field before the pointer left");
assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(first_style(&app, id).font_family, "Inter");
let button = |pos| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
panel_frame(&ctx, &mut app, vec![button(field), wheel(line, -1.0, Default::default()), button(outside)], "Inter", &draw);
assert_ne!(first_style(&app, id).font_family, "Inter", "button events also carry the pointer's position");
assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1);
app.run("edit.undo", json!({})).unwrap();
let end = egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::Vec2::ZERO,
modifiers: Default::default(),
phase: egui::TouchPhase::End,
source: egui::MouseWheelSource::Unknown,
};
panel_frame(&ctx, &mut app, vec![end], "Inter", &draw);
panel_frame(
&ctx,
&mut app,
vec![
wheel(egui::MouseWheelUnit::Point, -2.0, Default::default()),
egui::Event::PointerMoved(field),
wheel(line, -1.0, Default::default()),
egui::Event::PointerMoved(outside),
],
"Inter",
&draw,
);
assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1);
assert_eq!(ctx.input(|i| i.smooth_scroll_delta.y), -2.0, "only the outside wheel contributes to panel scrolling");
}
/// A turn queued for one document cannot restyle another document opened before its UI pass.
#[test]
fn a_wheel_queued_for_another_document_is_discarded() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut app = VectorcraftApp::new(Session::new(), Default::default());
selected_inter(&mut app);
let draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| crate::panels::character::show(app, ui);
let mut field = None;
for _ in 0..3 {
field = panel_frame(&ctx, &mut app, vec![], "Inter", &draw).0;
}
let field = field.expect("the Character panel shows the family field");
let mut raw = egui::RawInput {
events: vec![egui::Event::PointerMoved(field), wheel(egui::MouseWheelUnit::Line, -1.0, Default::default())],
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))),
..Default::default()
};
app.raw_input_hook(&ctx, &mut raw);
assert!(!raw.events.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. })));
let id = selected_inter(&mut app);
let before = app.session.active().unwrap().history.undo.len();
let mut out = ctx.run_ui(raw, |ui| draw(&mut app, ui));
out.textures_delta.clear();
assert_eq!(first_style(&app, id).font_family, "Inter");
assert_eq!(app.session.active().unwrap().history.undo.len(), before);
}
/// #5253: the Properties panel and the Control bar have the same family field, and the wheel steps
/// the families over it too.
#[test]