Merge pull request #6375 from dotCooCoo/fix/font-wheel-panel-scroll

Font menu: a panel scrolled past a closed font field keeps scrolling
This commit is contained in:
BFlat
2026-11-10 09:21:14 +07:00
committed by GitHub
7 changed files with 581 additions and 14 deletions
+97 -4
View File
@@ -12,6 +12,8 @@
//! the mouse wheel turned over the closed field applies the next family (or previous one) to the
//! 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.
//! The field takes the wheel only when the pointer was moved onto it after the field last moved and
//! the wheel stream began there.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -441,6 +443,54 @@ struct WheelField {
layer: egui::LayerId,
}
/// The seconds without wheel input that end a wheel stream. This is VectorCraft's own value.
pub(crate) const WHEEL_STREAM_GAP: f64 = 0.5;
/// The field the pointer was last moved onto, while that field stays where it was.
#[derive(Clone, Copy)]
struct ArmedWheelField {
id: egui::Id,
rect: egui::Rect,
pass: u64,
}
#[derive(Clone, Copy, PartialEq)]
enum WheelStreamOwner {
Field(egui::Id),
Elsewhere,
}
/// One run of wheel input with no gap of [`WHEEL_STREAM_GAP`] in it, trackpad momentum included.
#[derive(Clone, Copy, Default)]
struct WheelStream {
last: Option<f64>,
owner: Option<WheelStreamOwner>,
}
impl WheelStream {
/// Note wheel input at `now`; a gap since the last input starts a new stream with no owner.
fn note_input(&mut self, now: f64) {
if !self.last.is_some_and(|last| (0.0..WHEEL_STREAM_GAP).contains(&(now - last))) {
self.owner = None;
}
self.last = Some(now);
}
/// Leave the rest of the stream to the panel when `field` holds it.
fn release(&mut self, field: egui::Id) {
if self.owner == Some(WheelStreamOwner::Field(field)) {
self.owner = Some(WheelStreamOwner::Elsewhere);
}
}
/// Leave the rest of the stream to the panel when any field holds it.
fn release_any(&mut self) {
if let Some(WheelStreamOwner::Field(field)) = self.owner {
self.release(field);
}
}
}
#[derive(Clone)]
struct QueuedWheel {
#[cfg(not(target_arch = "wasm32"))]
@@ -478,6 +528,8 @@ struct WheelFields {
pending: HashMap<egui::Id, Vec<WheelInput>>,
queued: Vec<QueuedWheel>,
replay: Vec<ReplayWheel>,
armed: Option<ArmedWheelField>,
stream: WheelStream,
}
#[cfg(not(target_arch = "wasm32"))]
@@ -524,6 +576,7 @@ impl egui::Plugin for WheelLayerSnapshot {
let id = event.field.with("wheel");
if !active {
ctx.data_mut(|d| d.remove::<f32>(id));
fields.stream.release(event.field);
if let Some(target) = ctx.layer_id_at(event.position) {
fields.replay.push(ReplayWheel { target, position: event.position, event: event.event });
}
@@ -636,7 +689,8 @@ pub(crate) fn sync_wheel_target(app: &VectorcraftApp, ctx: &egui::Context, viewp
let key = wheel_fields_id(viewport);
let (previous, mut fields) = ctx.data(|d| (d.get_temp::<WheelTarget>(key.with("target")), d.get_temp::<WheelFields>(key)));
if previous.is_some_and(|old| old != target) {
fields = None;
// A new target keeps the armed field and the running wheel stream.
fields = fields.map(|old| WheelFields { pass: old.pass, armed: old.armed, stream: old.stream, ..Default::default() });
}
let mut hidden = Vec::new();
if let Some(fields) = &mut fields {
@@ -685,6 +739,16 @@ fn remember_owned_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool, men
fields.fields.clear();
}
fields.fields.retain(|f| f.id != resp.id);
if let Some(armed) = &mut fields.armed
&& armed.id == resp.id
{
if armed.rect == resp.interact_rect && pass.saturating_sub(armed.pass) <= 1 {
armed.pass = pass;
} else {
fields.armed = None;
fields.stream.release(resp.id);
}
}
if enabled {
fields.fields.push(WheelField { id: resp.id, menu, owner, rect: resp.interact_rect, layer: resp.layer_id });
}
@@ -695,7 +759,10 @@ fn remember_owned_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool, men
}
/// 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.
/// events stay in the input, so scrolling elsewhere keeps its own delta and event order. A field
/// takes a wheel stream only when the stream begins with the pointer over it and the pointer was
/// moved onto it after the field last moved. A stream that began anywhere else stays there until
/// it ends, and a stream that leaves its field does not return to it.
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 };
@@ -712,7 +779,10 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) {
if pass < fields.pass || pass.saturating_sub(fields.pass) > 1 {
fields.fields.clear();
fields.pointer = None;
fields.armed = None;
fields.stream.release_any();
}
let now = raw.time.unwrap_or_else(|| ctx.input_for(raw.viewport_id, |i| i.time) + f64::from(raw.predicted_dt));
if fields.pending_frame != frame {
fields.pending_frame = frame;
fields.pending.clear();
@@ -766,8 +836,20 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) {
{
fields.activating.push(id);
}
if matches!(event, egui::Event::MouseWheel { .. }) {
fields.stream.note_input(now);
}
match event {
egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } => {
if pointer != Some(*p) {
let pass = fields.pass;
fields.armed = fields.fields.iter().rev().find(|f| over(f, Some(*p))).map(|f| ArmedWheelField { id: f.id, rect: f.rect, pass });
if let Some(WheelStreamOwner::Field(id)) = fields.stream.owner
&& !fields.fields.iter().any(|f| f.id == id && over(f, Some(*p)))
{
fields.stream.release(id);
}
}
mark_queued_wheel_leaves(&mut fields, Some(*p));
for field in &fields.fields {
if pointer.is_some_and(|point| field.rect.contains(point))
@@ -782,6 +864,8 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) {
}
egui::Event::PointerGone => {
pointer = None;
fields.armed = None;
fields.stream.release_any();
mark_queued_wheel_leaves(&mut fields, None);
for field in &fields.fields {
if !fields.queued.iter().any(|event| event.field == field.id) {
@@ -790,8 +874,13 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) {
}
}
}
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() {
egui::Event::MouseWheel { unit, delta, modifiers, source, .. } if modifiers.is_none() && delta.y != 0.0 => {
let under = fields.fields.iter().rev().find(|f| !fields.activating.contains(&f.id) && over(f, pointer)).copied();
let starts_on_field = under
.filter(|f| fields.armed.is_some_and(|armed| armed.id == f.id) && !matches!(source, egui::MouseWheelSource::Momentum))
.map(|f| f.id);
let owner = *fields.stream.owner.get_or_insert(starts_on_field.map_or(WheelStreamOwner::Elsewhere, WheelStreamOwner::Field));
if let Some(field) = under.filter(|f| owner == WheelStreamOwner::Field(f.id)) {
let amount = -match unit {
egui::MouseWheelUnit::Point => delta.y / line,
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => delta.y,
@@ -823,6 +912,10 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) {
captured = true;
return false;
}
fields.stream.owner = Some(WheelStreamOwner::Elsewhere);
}
egui::Event::MouseWheel { delta, .. } if *delta != egui::Vec2::ZERO => {
fields.stream.owner.get_or_insert(WheelStreamOwner::Elsewhere);
}
_ => {}
}
+253
View File
@@ -355,6 +355,259 @@ fn activating_the_family_popup_leaves_its_wheel_input_available() {
}
}
/// A family field at `top`, the input's clock at `time`. Returns the wheel events left in the
/// input, the notches the field took and the point in the middle of the field.
fn stream_frame(ctx: &egui::Context, top: Option<f32>, time: f64, events: Vec<egui::Event>) -> (usize, i32, egui::Pos2) {
let mut raw = egui::RawInput { time: Some(time), events, ..Default::default() };
super::take_raw_wheel(ctx, &mut raw);
let left = raw.events.iter().filter(|e| matches!(e, egui::Event::MouseWheel { .. })).count();
let mut notches = 0;
let mut point = egui::Pos2::ZERO;
let mut out = ctx.run_ui(raw, |ui| {
let Some(top) = top else { return };
let rect = egui::Rect::from_min_size(egui::pos2(10.0, top), egui::vec2(120.0, 24.0));
let resp = ui.put(rect, egui::Button::new("Family"));
point = resp.rect.center();
for turn in super::remember_wheel_field(ui, &resp, true) {
if let super::WheelInput::Notches(n) = turn {
notches += n;
}
}
});
out.textures_delta.clear();
(left, notches, point)
}
fn stream_turn(phase: egui::TouchPhase, source: egui::MouseWheelSource, dy: f32) -> egui::Event {
egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, dy), phase, modifiers: Default::default(), source }
}
fn plain_turn() -> egui::Event {
stream_turn(egui::TouchPhase::Move, egui::MouseWheelSource::Unknown, -1.0)
}
/// A context with a family field drawn at `top` for three frames, from clock time 100.
fn stream_context(top: f32) -> (egui::Context, egui::Pos2) {
let ctx = egui::Context::default();
let mut point = egui::Pos2::ZERO;
for i in 0..3 {
point = stream_frame(&ctx, Some(top), 100.0 + f64::from(i), vec![]).2;
}
(ctx, point)
}
/// A field that moved under a pointer that stayed put leaves the wheel, including a reported
/// pointer position that did not change, until the pointer really moves.
#[test]
fn a_field_that_slides_under_a_still_pointer_leaves_the_wheel() {
let (ctx, point) = stream_context(40.0);
let below = egui::pos2(point.x, point.y + 200.0);
stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(below)]);
stream_frame(&ctx, Some(40.0 + 200.0), 111.0, vec![]);
let (left, notches, moved) = stream_frame(&ctx, Some(240.0), 120.0, vec![plain_turn()]);
assert_eq!(moved, below);
assert_eq!((left, notches), (1, 0), "the pointer did not move");
let (left, notches, _) = stream_frame(&ctx, Some(240.0), 130.0, vec![egui::Event::PointerMoved(below), plain_turn()]);
assert_eq!((left, notches), (1, 0), "a report of the same position is not a move");
let nudged = below + egui::vec2(2.0, 0.0);
let (left, notches, _) = stream_frame(&ctx, Some(240.0), 140.0, vec![egui::Event::PointerMoved(nudged), plain_turn()]);
assert_eq!((left, notches), (0, 1), "the pointer moved onto the field");
}
/// A field the pointer was moved onto loses that when the field moves, even back to the same
/// place, or when it is missing from a pass.
#[test]
fn a_field_that_moves_away_and_back_needs_the_pointer_moved_again() {
let (ctx, point) = stream_context(40.0);
stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point)]);
stream_frame(&ctx, Some(44.0), 111.0, vec![]);
stream_frame(&ctx, Some(40.0), 112.0, vec![]);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 120.0, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "the field moved after the pointer did");
let (ctx, point) = stream_context(40.0);
stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point)]);
stream_frame(&ctx, None, 111.0, vec![]);
stream_frame(&ctx, Some(40.0), 112.0, vec![]);
stream_frame(&ctx, Some(40.0), 113.0, vec![]);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 120.0, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "the field was gone for a pass");
let (ctx, point) = stream_context(40.0);
stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), egui::Event::PointerGone]);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 120.0, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "the pointer left the window");
}
/// A field missing from a pass loses the pointer's placement while another font field keeps
/// drawing in that pass, as when a panel collapses and the Control bar's field stays.
#[test]
fn a_field_gone_for_a_pass_beside_another_field_needs_the_pointer_moved_again() {
let ctx = egui::Context::default();
let field_at = |ui: &mut egui::Ui, top: f32| {
let rect = egui::Rect::from_min_size(egui::pos2(10.0, top), egui::vec2(120.0, 24.0));
let resp = ui.put(rect, egui::Button::new("Family"));
super::remember_wheel_field(ui, &resp, true);
};
let frame = |time: f64, family: bool, events: Vec<egui::Event>| {
let mut raw = egui::RawInput { time: Some(time), events, ..Default::default() };
super::take_raw_wheel(&ctx, &mut raw);
let left = raw.events.iter().filter(|e| matches!(e, egui::Event::MouseWheel { .. })).count();
let mut out = ctx.run_ui(raw, |ui| {
field_at(ui, 200.0);
if family {
field_at(ui, 40.0);
}
});
out.textures_delta.clear();
left
};
for i in 0..3 {
frame(100.0 + f64::from(i), true, vec![]);
}
let point = egui::pos2(70.0, 52.0);
assert_eq!(frame(110.0, true, vec![egui::Event::PointerMoved(point), plain_turn()]), 0, "the pointer moved onto the field");
frame(120.0, false, vec![]);
frame(121.0, true, vec![]);
frame(122.0, true, vec![]);
assert_eq!(frame(130.0, true, vec![plain_turn()]), 1, "the field was gone for a pass");
}
/// A pointer at rest on a field that stayed put keeps stepping it, stream after stream.
#[test]
fn a_pointer_resting_on_its_field_keeps_the_wheel_across_streams() {
let (ctx, point) = stream_context(40.0);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (0, 1));
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + super::WHEEL_STREAM_GAP / 2.0, vec![plain_turn()]);
assert_eq!((left, notches), (0, 1), "the same stream");
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 200.0, vec![plain_turn()]);
assert_eq!((left, notches), (0, 1), "a new stream, with the pointer still on the field");
}
/// A stream that began elsewhere stays there when the pointer moves onto a field, until a gap
/// with no wheel input ends it.
#[test]
fn a_wheel_stream_that_began_elsewhere_stays_there_until_a_gap() {
let (ctx, point) = stream_context(40.0);
let away = point + egui::vec2(0.0, 200.0);
let step = super::WHEEL_STREAM_GAP * 0.8;
let (left, _, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(away), plain_turn()]);
assert_eq!(left, 1, "the stream began off the field");
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + step, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (1, 0), "a nudge onto the field does not take a running stream");
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "each event renews the stream");
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step + super::WHEEL_STREAM_GAP, vec![plain_turn()]);
assert_eq!((left, notches), (0, 1), "the stream ended, and the pointer was moved onto the field");
}
/// A stream that leaves its field goes to what is under the pointer and does not come back.
#[test]
fn a_wheel_stream_that_leaves_its_field_does_not_return() {
let (ctx, point) = stream_context(40.0);
let away = point + egui::vec2(0.0, 200.0);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (0, 1));
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.1, vec![egui::Event::PointerMoved(away), plain_turn()]);
assert_eq!((left, notches), (1, 0), "the pointer left the field");
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.2, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (1, 0), "the stream stays off the field");
}
/// A stream loses its field when the pointer leaves without a wheel turn or when the field moves.
#[test]
fn a_wheel_stream_loses_its_field_when_the_pointer_leaves_or_the_field_moves() {
let (ctx, point) = stream_context(40.0);
let away = point + egui::vec2(0.0, 200.0);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (0, 1));
stream_frame(&ctx, Some(40.0), 110.1, vec![egui::Event::PointerMoved(away)]);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.2, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (1, 0), "the pointer left the field during the stream");
let (ctx, point) = stream_context(40.0);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (0, 1));
stream_frame(&ctx, Some(44.0), 110.1, vec![]);
let (left, notches, _) = stream_frame(&ctx, Some(44.0), 110.2, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "the field moved during the stream");
let (ctx, point) = stream_context(40.0);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (0, 1));
stream_frame(&ctx, Some(40.0), 110.1, vec![egui::Event::PointerGone]);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.2, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (1, 0), "the pointer left the window during the stream");
}
/// Trackpad momentum and zero-delta phase events keep their stream going, and momentum alone does
/// not start a stream on a field.
#[test]
fn trackpad_momentum_stays_with_its_stream() {
use egui::{MouseWheelSource::Momentum, MouseWheelSource::Trackpad, TouchPhase};
let (ctx, point) = stream_context(40.0);
let away = point + egui::vec2(0.0, 200.0);
let step = super::WHEEL_STREAM_GAP * 0.8;
stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(away), stream_turn(TouchPhase::Move, Trackpad, -1.0)]);
// The fingers lift with no delta, the pointer moves onto the field, and momentum follows.
stream_frame(&ctx, Some(40.0), 110.0 + step, vec![stream_turn(TouchPhase::End, Trackpad, 0.0)]);
let (left, notches, _) =
stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step, vec![egui::Event::PointerMoved(point), stream_turn(TouchPhase::Start, Momentum, -1.0)]);
assert_eq!((left, notches), (1, 0), "momentum continues the stream that began off the field");
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 200.0, vec![stream_turn(TouchPhase::Move, Momentum, -1.0)]);
assert_eq!((left, notches), (1, 0), "momentum does not start a stream on the field");
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 300.0, vec![stream_turn(TouchPhase::Move, Trackpad, -1.0)]);
assert_eq!((left, notches), (0, 1), "fingers on the trackpad do");
}
/// A stream that began as a sideways scroll stays off the field when it turns vertical.
#[test]
fn a_sideways_turn_that_starts_a_stream_keeps_it_off_the_field() {
let (ctx, point) = stream_context(40.0);
let sideways = egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(-1.0, 0.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
};
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), sideways]);
assert_eq!((left, notches), (1, 0));
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.1, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "the stream began as a sideways scroll");
}
/// A stream loses its field when a layout pass is skipped, even though the field comes back in the
/// same place before the stream ends.
#[test]
fn a_wheel_stream_loses_its_field_when_a_pass_is_skipped() {
let (ctx, point) = stream_context(40.0);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (0, 1));
stream_frame(&ctx, None, 110.1, vec![]);
stream_frame(&ctx, Some(40.0), 110.2, vec![]);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.3, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "the field missed a pass during the stream");
}
/// A modified turn is left in the input and keeps the stream away from the field.
#[test]
fn a_modified_turn_that_starts_a_stream_keeps_it_off_the_field() {
let (ctx, point) = stream_context(40.0);
let zoom = egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, -1.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::COMMAND,
source: egui::MouseWheelSource::Unknown,
};
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), zoom]);
assert_eq!((left, notches), (1, 0));
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.1, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "the stream began as a modified turn");
}
/// 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() {
@@ -1054,8 +1054,9 @@ fn wheel_over_a_floating_character_font_field_changes_the_font() {
h.host.borrow_mut().child_overlay = None;
h.frame(vec![]);
h.host.borrow_mut().time += crate::font_menu::WHEEL_STREAM_GAP;
h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([
Event::PointerMoved(pointer),
Event::PointerMoved(pointer + vec2(1.0, 0.0)),
Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, -1.0),
@@ -1087,8 +1088,9 @@ fn wheel_over_a_floating_character_font_field_changes_the_font() {
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().time += crate::font_menu::WHEEL_STREAM_GAP;
h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([
Event::PointerMoved(pointer),
Event::PointerMoved(pointer + vec2(2.0, 0.0)),
Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, -1.0),
@@ -1453,6 +1455,75 @@ fn wheel_events_on_both_sides_of_an_overlay_are_checked_separately() {
assert_eq!(h.ctx.input_for(viewport, |input| input.pointer.latest_pos()), Some(clear));
}
#[test]
fn a_wheel_stream_that_began_outside_a_floating_font_field_stays_outside() {
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 on = pos2(field.min.x + 4.0, field.center().y);
let off = pos2(field.min.x + 4.0, field.max.y + 60.0);
let turn = Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, -1.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
};
let before = h.app.run("text.fonts", json!({})).unwrap();
h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([Event::PointerMoved(off), turn.clone(), Event::PointerMoved(on), turn]);
h.frames(3);
assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), before);
}
#[test]
fn a_wheel_stream_leaves_a_floating_font_field_that_an_overlay_covered_before_its_queued_turn() {
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 turn = Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, -1.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
};
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");
let raise_field = |h: &mut Harness| {
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![]);
};
raise_field(&mut h);
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), turn.clone()]);
h.frames(2);
assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), before, "the overlay covered the field before its queued turn");
h.host.borrow_mut().child_raise_overlay = Some((viewport, field.expand(10.0), false));
raise_field(&mut h);
h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.push(turn);
h.frames(2);
assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), before, "the stream left the field when the overlay covered it");
}
#[test]
fn leaving_a_floating_font_field_resets_queued_fractional_wheel_input() {
let mut h = Harness::new();
+128 -6
View File
@@ -238,8 +238,24 @@ fn panel_frame(
family: &str,
draw: &dyn Fn(&mut VectorcraftApp, &mut egui::Ui),
) -> (Option<egui::Pos2>, bool) {
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() };
panel_frame_at(ctx, app, None, events, family, draw)
}
/// [`panel_frame`] with the input's clock set to `time` seconds.
fn panel_frame_at(
ctx: &egui::Context,
app: &mut VectorcraftApp,
time: Option<f64>,
events: Vec<egui::Event>,
family: &str,
draw: &dyn Fn(&mut VectorcraftApp, &mut egui::Ui),
) -> (Option<egui::Pos2>, bool) {
let mut input = egui::RawInput {
time,
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());
@@ -394,13 +410,16 @@ fn wheel_events_use_the_pointer_position_when_they_occurred() {
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);
let after_gap = |ctx: &egui::Context| Some(ctx.input(|i| i.time) + crate::font_menu::WHEEL_STREAM_GAP);
let later = after_gap(&ctx);
panel_frame_at(&ctx, &mut app, later, 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);
let later = after_gap(&ctx);
panel_frame_at(&ctx, &mut app, later, 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();
@@ -412,20 +431,32 @@ fn wheel_events_use_the_pointer_position_when_they_occurred() {
source: egui::MouseWheelSource::Unknown,
};
panel_frame(&ctx, &mut app, vec![end], "Inter", &draw);
panel_frame(
let later = after_gap(&ctx);
panel_frame_at(
&ctx,
&mut app,
later,
vec![
wheel(egui::MouseWheelUnit::Point, -2.0, Default::default()),
egui::Event::PointerMoved(field),
wheel(line, -1.0, Default::default()),
egui::Event::PointerMoved(outside),
wheel(egui::MouseWheelUnit::Point, -2.0, Default::default()),
],
"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 fractional turn made before the pointer reached the field does not count toward its notches.
let speed = ctx.options(|o| o.input_options.line_scroll_speed);
let later = after_gap(&ctx);
let early = wheel(egui::MouseWheelUnit::Point, -0.75 * speed, Default::default());
panel_frame_at(&ctx, &mut app, later, vec![early, egui::Event::PointerMoved(field)], "Inter", &draw);
let later = after_gap(&ctx);
panel_frame_at(&ctx, &mut app, later, vec![wheel(line, -0.5, Default::default())], "Inter", &draw);
assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1, "half a notch on the field");
panel_frame(&ctx, &mut app, vec![wheel(line, -0.5, Default::default())], "Inter", &draw);
assert_eq!(app.session.active().unwrap().history.undo.len(), before + 2, "a whole notch on the field");
}
/// A turn queued for one document cannot restyle another document opened before its UI pass.
@@ -514,6 +545,97 @@ fn the_wheel_steps_the_families_in_properties_and_the_control_bar() {
}
}
/// The Character panel in a scroll area, its family field near the bottom of the view.
fn scrolling_character_panel(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
ui.add_space(250.0);
crate::panels::character::show(app, ui);
ui.add_space(600.0);
});
}
/// Scrolling a panel with a still pointer keeps going when the closed family field slides under
/// the pointer, and the font stays as it was.
#[test]
fn scrolling_a_panel_past_the_family_field_under_a_still_pointer_keeps_the_font() {
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 = scrolling_character_panel;
let mut field = None;
for _ in 0..3 {
field = panel_frame(&ctx, &mut app, vec![], "Inter", &draw).0;
}
let field = field.expect("the panel shows the family field");
let still = egui::pos2(field.x, field.y - 120.0);
panel_frame(&ctx, &mut app, vec![egui::Event::PointerMoved(still)], "Inter", &draw);
let before = app.session.active().unwrap().history.undo.len();
let mut passed = false;
for _ in 0..240 {
let at = panel_frame(&ctx, &mut app, vec![wheel(egui::MouseWheelUnit::Point, -6.0, Default::default())], "Inter", &draw).0;
if at.is_some_and(|point| point.y < still.y - 30.0) {
passed = true;
break;
}
}
assert!(passed, "the panel scrolled the field past the pointer");
assert_eq!(first_style(&app, id).font_family, "Inter");
assert_eq!(app.session.active().unwrap().history.undo.len(), before);
}
/// A family field that a panel scrolled under a still pointer leaves later wheel input to the
/// panel, including after the stream ends, until the pointer really moves.
#[test]
fn a_family_field_scrolled_under_a_still_pointer_takes_the_wheel_after_the_pointer_moves() {
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 = scrolling_character_panel;
let mut time = 100.0;
let mut field = None;
for _ in 0..3 {
time += 0.02;
field = panel_frame_at(&ctx, &mut app, Some(time), vec![], "Inter", &draw).0;
}
let field = field.expect("the panel shows the family field");
let still = egui::pos2(field.x, field.y - 40.0);
time += 0.02;
panel_frame_at(&ctx, &mut app, Some(time), vec![egui::Event::PointerMoved(still)], "Inter", &draw);
let before = app.session.active().unwrap().history.undo.len();
let mut under = false;
for _ in 0..80 {
time += 0.02;
panel_frame_at(&ctx, &mut app, Some(time), vec![wheel(egui::MouseWheelUnit::Point, -3.0, Default::default())], "Inter", &draw);
let mut at = None;
for _ in 0..12 {
time += 0.02;
at = panel_frame_at(&ctx, &mut app, Some(time), vec![], "Inter", &draw).0;
}
if at.is_some_and(|point| (point.y - still.y).abs() < 4.0) {
under = true;
break;
}
}
assert!(under, "the panel scrolled the field under the pointer");
assert_eq!(app.session.active().unwrap().history.undo.len(), before);
time += 5.0;
let notch = || wheel(egui::MouseWheelUnit::Line, -1.0, Default::default());
panel_frame_at(&ctx, &mut app, Some(time), vec![notch()], "Inter", &draw);
assert_eq!(first_style(&app, id).font_family, "Inter", "the still pointer did not arm the field");
let mut at = None;
for _ in 0..12 {
time += 0.02;
at = panel_frame_at(&ctx, &mut app, Some(time), vec![], "Inter", &draw).0;
}
let at = at.expect("the field is still in view");
time += 5.0;
panel_frame_at(&ctx, &mut app, Some(time), vec![egui::Event::PointerMoved(at), notch()], "Inter", &draw);
assert_ne!(first_style(&app, id).font_family, "Inter", "the pointer moved onto the field");
assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1);
}
/// #5253: a family the wheel steps to keeps the type's style when it has that style, else takes the
/// closest it has, as a family picked from the menu does. The menus that only pick a font ignore
/// the wheel.
+28
View File
@@ -60,6 +60,34 @@ fn assert_queued_wheel_discarded_after_target_change(range: bool) {
assert_eq!(app.session.active().unwrap().history.undo.len(), before, "the captured turn cannot restyle the new target");
}
#[test]
fn a_font_field_that_appears_under_a_still_pointer_leaves_the_wheel_until_the_pointer_moves() {
let (ctx, mut app) = selected_type_with_control();
let mut texts = Vec::new();
for _ in 0..8 {
texts = frame(&mut app, &ctx, vec![]);
}
let point = texts.iter().find(|(t, p)| t == "Inter" && p.y < 150.0).unwrap().1;
let selected = app.session.active().unwrap().selection.objects.first().unwrap().0;
app.run("select.set", json!({"ids": []})).unwrap();
frame(&mut app, &ctx, vec![Event::PointerMoved(point)]);
frame(&mut app, &ctx, vec![]);
app.run("select.set", json!({"ids": [selected]})).unwrap();
for _ in 0..8 {
frame(&mut app, &ctx, vec![]);
}
let mut still = wheel_input(point);
still.events.remove(0);
app.raw_input_hook(&ctx, &mut still);
assert!(still.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })), "the pointer did not move after the field appeared");
let mut out = ctx.run_ui(still, |ui| app.ui(ui));
out.textures_delta.clear();
let mut moved = wheel_input(point + vec2(1.0, 0.0));
moved.time = Some(ctx.input(|i| i.time) + 5.0);
app.raw_input_hook(&ctx, &mut moved);
assert!(!moved.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })), "the pointer moved onto the field");
}
#[test]
fn artboard_controls_leave_replaced_font_fields_wheel_available() {
let (ctx, mut app) = selected_type_with_control();
+1 -1
View File
@@ -18,7 +18,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Feature | Status | Notes |
|---|---|---|
| Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. Order, bounds and Undo grouping are VectorCraft's choices; scores unchanged. |
| Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. The field takes the wheel only when the pointer was moved onto it after the field last moved and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input. Order, bounds, Undo grouping, this rule and its 0.5 s are VectorCraft's choices; scores unchanged. |
| Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; in justified text a line a soft return ends is justified as a wrapped line is, only the paragraph's last line keeping its alignment, as Illustrator's own layout in a user's `.ai` sets it (evidence: `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`; first done the other way in #2754) |
| Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. |
| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches back to the tool whose double-click on type chose the Type tool (kept until another tool is chosen or another document or window becomes active, so later edits with the Type tool return there too), or to the Selection tool when there is no such tool, as when the Type tool was chosen from the toolbar or after such a switch (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; Type › Type Orientation sets every frame of a thread, and a thread's frames share one writing direction; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) |
+1 -1
View File
@@ -264,7 +264,7 @@ tools, never checked side by side) isn't captured row by row.
| Behaviour | Status | Notes |
|---|---|---|
| Wheel over a closed font-family field in Character, Properties or Control | done | #5253: applies the next or previous family to selected text, one undo step per notch; uses saved font filters, ignores search text and stops at the list's ends. Escape and pointer departure keep the change. Other font-picking menus and modifier-wheel input keep their existing behavior. |
| Wheel over a closed font-family field in Character, Properties or Control | done | #5253: applies the next or previous family to selected text, one undo step per notch; uses saved font filters, ignores search text and stops at the list's ends. Escape and pointer departure keep the change. The field takes the wheel only when the pointer was moved onto it after the field last moved and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input. This rule and its 0.5 s are VectorCraft's choices. Other font-picking menus and modifier-wheel input keep their existing behavior. |
| Arrow keys nudge by Keyboard Increment (0.001–1296 pt); Shift ×10; Alt copies | done | |
| Fields commit what was typed on a click away or a move to another field, as Enter does; Escape discards it | done | #1787 |
| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370 |