fix: a11y: magnifier: correct Centered/OnEdge focal-point math and refresh focal point at zoom-in

- I used AI to assist in research of the focal point computation formulae for OnEdge and Centered modes.
- I used the formulas found in [KDE's zoom magnifier](https://invent.kde.org/plasma/kwin/-/blob/master/src/plugins/zoom/zoom.cpp) and converted them to Rust and the COSMIC focal point system.

- Remove unnecessary focal-point precomputation in `OutputZoomState::new`, because it is now updated on first zoom in anyway.

- Do focal point computation in Local coordinates in update_focal_point to eliminate local-global conversion confusion/ issues.

- ZoomMovement::OnEdge - single code path rewrite - old two-branch logic caused jumping and unpredictability at high zoom. Fixes [#2683](https://github.com/pop-os/cosmic-epoch/issues/2683), partially addresses [#2688](https://github.com/pop-os/cosmic-epoch/issues/2688).

- ZoomMovement::Centered - translation/clamp/invert - prevents "blow up" of calculation at zoom level > 2.0 that caused cursor to disappear off bottom right of screen. Fixes [#1503](https://github.com/pop-os/cosmic-comp/issues/1503).

- `is_level_change` - sets `previous_point` so that `animating_focal_point` interpolates instead of jumping. Still room for improvement (needs focal point recomputation per-frame during animation to prevent "boomerang" feel), but this is still an improvement over jumping.

- In `trigger_zoom`, call `update_focal_point` when `level > 1.0` - overlaps proposal in [#1859](https://github.com/pop-os/cosmic-comp/pull/1859) + the level gate to prevent stale focal point on zoom in. Fixes [#2511](https://github.com/pop-os/cosmic-comp/issues/2511) and [#1883](https://github.com/pop-os/cosmic-epoch/issues/1883). Partially addresses [#1420](https://github.com/pop-os/cosmic-comp/issues/1420) (item 1 only).
This commit is contained in:
Tom Grushka 2026-07-07 19:45:41 -06:00 committed by Victoria Brekenfeld
parent 693d1ad775
commit 01c0cb020e
2 changed files with 83 additions and 90 deletions

View file

@ -2490,6 +2490,14 @@ impl Shell {
increment: zoom_config.increment, increment: zoom_config.increment,
movement: zoom_config.view_moves, movement: zoom_config.view_moves,
}); });
if level > 1.0 {
self.update_focal_point(
seat,
seat.get_pointer().unwrap().current_location().as_global(),
zoom_config.view_moves,
);
}
} }
pub fn update_focal_point( pub fn update_focal_point(

View file

@ -27,7 +27,7 @@ use smithay::{
}, },
}, },
output::Output, output::Output,
utils::{IsAlive, Point, Rectangle, Serial, Size}, utils::{FrameExtents, IsAlive, Point, Rectangle, Serial, Size},
}; };
use tracing::error; use tracing::error;
@ -76,36 +76,8 @@ impl OutputZoomState {
) -> OutputZoomState { ) -> OutputZoomState {
theme.transparent = theme.cosmic().frosted_system_interface; theme.transparent = theme.cosmic().frosted_system_interface;
let cursor_position = seat.get_pointer().unwrap().current_location().as_global(); let cursor_position = seat.get_pointer().unwrap().current_location().as_global();
let focal_point = cursor_position.to_local(output);
let output_geometry = output.geometry().to_f64(); let output_geometry = output.geometry().to_f64();
let focal_point = if output_geometry.contains(cursor_position) {
match movement {
ZoomMovement::Continuously | ZoomMovement::OnEdge => {
cursor_position.to_local(output)
}
ZoomMovement::Centered => {
let mut zoomed_output_geometry = output.geometry().to_f64().downscale(level);
zoomed_output_geometry.loc =
cursor_position - zoomed_output_geometry.size.downscale(2.).to_point();
let mut focal_point = zoomed_output_geometry
.loc
.to_local(output)
.upscale(level)
.to_global(output);
focal_point.x = focal_point.x.clamp(
output_geometry.loc.x,
(output_geometry.loc.x + output_geometry.size.w).next_down(),
);
focal_point.y = focal_point.y.clamp(
output_geometry.loc.y,
(output_geometry.loc.y + output_geometry.size.h).next_down(),
);
focal_point.to_local(output)
}
}
} else {
(output_geometry.size.w / 2., output_geometry.size.h / 2.).into()
};
let program = ZoomProgram::new(level, movement, increment); let program = ZoomProgram::new(level, movement, increment);
let element = IcedElement::new(program, Size::default(), loop_handle, theme); let element = IcedElement::new(program, Size::default(), loop_handle, theme);
@ -269,86 +241,99 @@ impl ZoomState {
&mut self, &mut self,
output: &Output, output: &Output,
cursor_position: Point<f64, Global>, cursor_position: Point<f64, Global>,
original_position: Point<f64, Global>, _original_position: Point<f64, Global>,
movement: ZoomMovement, movement: ZoomMovement,
) { ) {
let output_geometry = output.geometry().to_f64(); let output_geometry = output.geometry().to_f64().to_local(output);
let mut zoomed_output_geometry = output.zoomed_geometry().unwrap().to_f64(); let zoomed_output_geometry = output.zoomed_geometry().unwrap().to_f64().to_local(output);
let output_state = output.user_data().get::<Mutex<OutputZoomState>>().unwrap(); let output_state = output.user_data().get::<Mutex<OutputZoomState>>().unwrap();
let mut output_state_ref = output_state.lock().unwrap(); let mut output_state_ref = output_state.lock().unwrap();
// animate movement type changes let level = output_state_ref.current_level();
if self.movement != movement {
let is_level_change = output_state_ref
.previous_level
.is_some_and(|prev| prev.0 != level);
// animate level and movement type changes
if is_level_change || self.movement != movement {
output_state_ref.previous_point = Some((output_state_ref.focal_point, Instant::now())); output_state_ref.previous_point = Some((output_state_ref.focal_point, Instant::now()));
self.movement = movement; self.movement = movement;
} }
let cursor_position = cursor_position.to_local(output); let cursor_position = cursor_position.to_local(output);
match movement { match movement {
ZoomMovement::Continuously => output_state_ref.focal_point = cursor_position, ZoomMovement::Continuously => {
// Focal point is the cursor position
output_state_ref.focal_point = cursor_position;
}
ZoomMovement::OnEdge => { ZoomMovement::OnEdge => {
if !zoomed_output_geometry if is_level_change {
.overlaps_or_touches(Rectangle::new(original_position, Size::from((16., 16.)))) output_state_ref.focal_point = cursor_position;
{ return;
zoomed_output_geometry.loc = cursor_position.to_global(output) }
- zoomed_output_geometry.size.downscale(2.).to_point();
let mut focal_point = zoomed_output_geometry // Compute small margin relative to zoomed output to keep cursor within
.loc let margin_size = zoomed_output_geometry.size.h * 0.02;
.to_local(output) let margins = FrameExtents::new(margin_size, margin_size, margin_size, margin_size);
.upscale(output_state_ref.level) let inner_rect = zoomed_output_geometry - margins;
.to_global(output);
if inner_rect.contains(cursor_position) {
// Do not move if cursor within margins
return;
}
// Compute dx and dy to move the zoomed output based on cursor distance outside margin(s)
let dx = if cursor_position.x < inner_rect.loc.x {
cursor_position.x - inner_rect.loc.x
} else if cursor_position.x > inner_rect.loc.x + inner_rect.size.w {
cursor_position.x - (inner_rect.loc.x + inner_rect.size.w)
} else {
0.0
};
let dy = if cursor_position.y < inner_rect.loc.y {
cursor_position.y - inner_rect.loc.y
} else if cursor_position.y > inner_rect.loc.y + inner_rect.size.h {
cursor_position.y - (inner_rect.loc.y + inner_rect.size.h)
} else {
0.0
};
let mut focal_point = output_state_ref.focal_point + Point::new(dx, dy);
// Clamp the final focal point to output geometry
focal_point.x = focal_point.x.clamp( focal_point.x = focal_point.x.clamp(
output_geometry.loc.x, output_geometry.loc.x,
output_geometry.loc.x + output_geometry.size.w - 1., output_geometry.loc.x + output_geometry.size.w - 1.0,
); );
focal_point.y = focal_point.y.clamp( focal_point.y = focal_point.y.clamp(
output_geometry.loc.y, output_geometry.loc.y,
output_geometry.loc.y + output_geometry.size.h - 1., output_geometry.loc.y + output_geometry.size.h - 1.0,
); );
output_state_ref.previous_point =
Some((output_state_ref.focal_point, Instant::now()));
output_state_ref.focal_point = focal_point.to_local(output);
} else if !zoomed_output_geometry.contains(cursor_position.to_global(output)) {
let mut diff = output_state_ref.focal_point.to_global(output)
+ (cursor_position.to_global(output) - original_position)
.upscale(output_state_ref.level);
diff.x = diff.x.clamp(
output_geometry.loc.x,
(output_geometry.loc.x + output_geometry.size.w).next_down(),
);
diff.y = diff.y.clamp(
output_geometry.loc.y,
(output_geometry.loc.y + output_geometry.size.h).next_down(),
);
diff -= output_state_ref.focal_point.to_global(output);
output_state_ref.focal_point += diff.as_logical().as_local(); output_state_ref.focal_point = focal_point;
}
} }
ZoomMovement::Centered => { ZoomMovement::Centered => {
zoomed_output_geometry.loc = cursor_position.to_global(output) let center = (output_geometry.size / 2.).to_point();
- zoomed_output_geometry.size.downscale(2.).to_point();
let mut focal_point = zoomed_output_geometry if level <= 1.0 + f64::EPSILON {
.loc // Without this break, focal point will jump to (0, 0) on zoom out
.to_local(output) output_state_ref.focal_point = center;
.upscale( return;
(output_geometry.size.w }
/ (output_geometry.size.w - zoomed_output_geometry.size.w)
.max(f64::EPSILON)) // Compute translation to keep cursor at center of screen
.max(1.), let mut tx = center.x - cursor_position.x * level;
) let mut ty = center.y - cursor_position.y * level;
.to_global(output);
focal_point.x = focal_point.x.clamp( // Clamp translation to keep viewport within screen bounds
output_geometry.loc.x, tx = tx.clamp(output_geometry.size.w * (1.0 - level), 0.0);
output_geometry.loc.x + output_geometry.size.w - 1., ty = ty.clamp(output_geometry.size.h * (1.0 - level), 0.0);
);
focal_point.y = focal_point.y.clamp( // Convert translation back to focal point: T = F * (1 - level)
output_geometry.loc.y, output_state_ref.focal_point =
output_geometry.loc.y + output_geometry.size.h - 1., Point::from((tx / (1.0 - level), ty / (1.0 - level)));
);
output_state_ref.focal_point = focal_point.to_local(output);
} }
} }
} }