fix: preserve scrolling zoom direction
This commit is contained in:
1 parent
578b536b26
commit
c601dcbac5
3 files changed
+236
-5
No files matched your search
+91
-4
@@ -124,6 +124,8 @@ pub struct Ferese {
|
|||||||
closing_windows: HashMap<WindowId, ClosingAnimation>,
|
closing_windows: HashMap<WindowId, ClosingAnimation>,
|
||||||
viewport_animations: HashMap<WorkspaceId, AnimatedValue>,
|
viewport_animations: HashMap<WorkspaceId, AnimatedValue>,
|
||||||
scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
|
scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
|
||||||
|
viewport_coupled_widths: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
|
||||||
|
pending_column_width_cycles: HashSet<WindowId>,
|
||||||
pub focused_window: Option<WindowId>,
|
pub focused_window: Option<WindowId>,
|
||||||
column_width_presets: Vec<ColumnWidth>,
|
column_width_presets: Vec<ColumnWidth>,
|
||||||
gap_config: GapConfig,
|
gap_config: GapConfig,
|
||||||
@@ -430,6 +432,8 @@ impl Ferese {
|
|||||||
closing_windows: HashMap::new(),
|
closing_windows: HashMap::new(),
|
||||||
viewport_animations: HashMap::new(),
|
viewport_animations: HashMap::new(),
|
||||||
scrolling_world_x: HashMap::new(),
|
scrolling_world_x: HashMap::new(),
|
||||||
|
viewport_coupled_widths: HashMap::new(),
|
||||||
|
pending_column_width_cycles: HashSet::new(),
|
||||||
focused_window: None,
|
focused_window: None,
|
||||||
column_width_presets: config.column_width_presets,
|
column_width_presets: config.column_width_presets,
|
||||||
gap_config: config.gap_config,
|
gap_config: config.gap_config,
|
||||||
@@ -817,6 +821,8 @@ impl Ferese {
|
|||||||
self.closing_windows.remove(&id);
|
self.closing_windows.remove(&id);
|
||||||
self.window_rules_applied.remove(&id);
|
self.window_rules_applied.remove(&id);
|
||||||
self.scrolling_world_x.remove(&id);
|
self.scrolling_world_x.remove(&id);
|
||||||
|
self.viewport_coupled_widths.remove(&id);
|
||||||
|
self.pending_column_width_cycles.remove(&id);
|
||||||
if let Err(error) = self.workspaces.remove_window(id) {
|
if let Err(error) = self.workspaces.remove_window(id) {
|
||||||
tracing::error!(%error, ?id, "failed to remove window from layout");
|
tracing::error!(%error, ?id, "failed to remove window from layout");
|
||||||
}
|
}
|
||||||
@@ -830,6 +836,7 @@ impl Ferese {
|
|||||||
pub fn relayout(&mut self) {
|
pub fn relayout(&mut self) {
|
||||||
self.arrange_layers();
|
self.arrange_layers();
|
||||||
let mut previous_scrolling_world_x = std::mem::take(&mut self.scrolling_world_x);
|
let mut previous_scrolling_world_x = std::mem::take(&mut self.scrolling_world_x);
|
||||||
|
let pending_column_width_cycles = std::mem::take(&mut self.pending_column_width_cycles);
|
||||||
let outputs = self.space.outputs().cloned().collect::<Vec<_>>();
|
let outputs = self.space.outputs().cloned().collect::<Vec<_>>();
|
||||||
let constraints = self.window_constraints();
|
let constraints = self.window_constraints();
|
||||||
let mut visible = HashSet::new();
|
let mut visible = HashSet::new();
|
||||||
@@ -873,17 +880,20 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let viewport_target = workspace.layout.viewport_x();
|
let viewport_target = workspace.layout.viewport_x();
|
||||||
let viewport_current = viewport_target.map(|target| {
|
let viewport_motion = viewport_target.map(|target| {
|
||||||
let viewport = self
|
let viewport = self
|
||||||
.viewport_animations
|
.viewport_animations
|
||||||
.entry(workspace_id)
|
.entry(workspace_id)
|
||||||
.or_insert_with(|| AnimatedValue::new(target));
|
.or_insert_with(|| AnimatedValue::new(target));
|
||||||
|
let target_changed = (viewport.target - target).abs() > 0.001;
|
||||||
viewport.retarget_preserving_motion(target);
|
viewport.retarget_preserving_motion(target);
|
||||||
if !self.animations_enabled {
|
if !self.animations_enabled {
|
||||||
viewport.snap();
|
viewport.snap();
|
||||||
}
|
}
|
||||||
viewport.current
|
(viewport.current, target_changed)
|
||||||
});
|
});
|
||||||
|
let viewport_current = viewport_motion.map(|(current, _)| current);
|
||||||
|
let viewport_target_changed = viewport_motion.is_some_and(|(_, changed)| changed);
|
||||||
|
|
||||||
for warning in layout.warnings {
|
for warning in layout.warnings {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -938,6 +948,7 @@ impl Ferese {
|
|||||||
is_fullscreen,
|
is_fullscreen,
|
||||||
is_floating,
|
is_floating,
|
||||||
scrolling,
|
scrolling,
|
||||||
|
pending_column_width_cycles.contains(id) && viewport_target_changed,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -957,7 +968,7 @@ impl Ferese {
|
|||||||
|
|
||||||
let mut scrolling_world_x = HashMap::new();
|
let mut scrolling_world_x = HashMap::new();
|
||||||
|
|
||||||
for (window, id, rect, is_fullscreen, is_floating, scrolling) in placements {
|
for (window, id, rect, is_fullscreen, is_floating, scrolling, couple_width) in placements {
|
||||||
let committed_size = client_size(&window);
|
let committed_size = client_size(&window);
|
||||||
let geometry = self
|
let geometry = self
|
||||||
.window_geometry
|
.window_geometry
|
||||||
@@ -980,6 +991,28 @@ impl Ferese {
|
|||||||
geometry.visual.current.x = animated_world_x.current - viewport_x;
|
geometry.visual.current.x = animated_world_x.current - viewport_x;
|
||||||
geometry.visual.velocity.x = 0.0;
|
geometry.visual.velocity.x = 0.0;
|
||||||
scrolling_world_x.insert(id, (workspace, animated_world_x));
|
scrolling_world_x.insert(id, (workspace, animated_world_x));
|
||||||
|
|
||||||
|
let coupled = couple_width
|
||||||
|
|| self
|
||||||
|
.viewport_coupled_widths
|
||||||
|
.get(&id)
|
||||||
|
.is_some_and(|(previous_workspace, _)| *previous_workspace == workspace);
|
||||||
|
if coupled {
|
||||||
|
let width = self.viewport_coupled_widths.entry(id).or_insert_with(|| {
|
||||||
|
(workspace, AnimatedValue::new(geometry.visual.current.width))
|
||||||
|
});
|
||||||
|
if width.0 != workspace {
|
||||||
|
*width = (workspace, AnimatedValue::new(geometry.visual.current.width));
|
||||||
|
}
|
||||||
|
width.1.retarget_preserving_motion(rect.width);
|
||||||
|
if !self.animations_enabled {
|
||||||
|
width.1.snap();
|
||||||
|
}
|
||||||
|
geometry.visual.current.width = width.1.current;
|
||||||
|
geometry.visual.velocity.width = width.1.velocity;
|
||||||
|
} else if pending_column_width_cycles.contains(&id) {
|
||||||
|
self.viewport_coupled_widths.remove(&id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let visual = geometry.visual.current;
|
let visual = geometry.visual.current;
|
||||||
let location = (visual.x.round() as i32, visual.y.round() as i32);
|
let location = (visual.x.round() as i32, visual.y.round() as i32);
|
||||||
@@ -1096,13 +1129,25 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut settled_coupled_widths = Vec::new();
|
||||||
for (window, id) in windows {
|
for (window, id) in windows {
|
||||||
let Some(geometry) = self.window_geometry.get_mut(&id) else {
|
let Some(geometry) = self.window_geometry.get_mut(&id) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let coupled_target = self
|
||||||
|
.viewport_coupled_widths
|
||||||
|
.get(&id)
|
||||||
|
.map(|(_, width)| width.target);
|
||||||
|
if coupled_target.is_some() {
|
||||||
|
geometry.visual.target.width = geometry.visual.current.width;
|
||||||
|
geometry.visual.velocity.width = 0.0;
|
||||||
|
}
|
||||||
active_animation |=
|
active_animation |=
|
||||||
geometry.advance(delta, self.spring_config, self.animations_enabled);
|
geometry.advance(delta, self.spring_config, self.animations_enabled);
|
||||||
|
if let Some(target) = coupled_target {
|
||||||
|
geometry.visual.target.width = target;
|
||||||
|
}
|
||||||
if let Some((workspace, world_x)) = self.scrolling_world_x.get_mut(&id)
|
if let Some((workspace, world_x)) = self.scrolling_world_x.get_mut(&id)
|
||||||
&& let Some(viewport) = self.viewport_animations.get(workspace)
|
&& let Some(viewport) = self.viewport_animations.get(workspace)
|
||||||
{
|
{
|
||||||
@@ -1114,6 +1159,20 @@ impl Ferese {
|
|||||||
geometry.visual.current.x = world_x.current - viewport.current;
|
geometry.visual.current.x = world_x.current - viewport.current;
|
||||||
geometry.visual.velocity.x = 0.0;
|
geometry.visual.velocity.x = 0.0;
|
||||||
}
|
}
|
||||||
|
if let Some((_, width)) = self.viewport_coupled_widths.get_mut(&id) {
|
||||||
|
let width_active = if self.animations_enabled {
|
||||||
|
width.advance(delta, self.viewport_spring_config)
|
||||||
|
} else {
|
||||||
|
width.snap();
|
||||||
|
false
|
||||||
|
};
|
||||||
|
active_animation |= width_active;
|
||||||
|
geometry.visual.current.width = width.current;
|
||||||
|
geometry.visual.velocity.width = width.velocity;
|
||||||
|
if !width_active {
|
||||||
|
settled_coupled_widths.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(size) = geometry.client.expire_wait(self.start_time.elapsed()) {
|
if let Some(size) = geometry.client.expire_wait(self.start_time.elapsed()) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
?id,
|
?id,
|
||||||
@@ -1130,6 +1189,9 @@ impl Ferese {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for id in settled_coupled_widths {
|
||||||
|
self.viewport_coupled_widths.remove(&id);
|
||||||
|
}
|
||||||
|
|
||||||
active_animation
|
active_animation
|
||||||
}
|
}
|
||||||
@@ -1320,7 +1382,10 @@ impl Ferese {
|
|||||||
.workspaces
|
.workspaces
|
||||||
.cycle_column_width(window, &self.column_width_presets)
|
.cycle_column_width(window, &self.column_width_presets)
|
||||||
{
|
{
|
||||||
Ok(true) => self.relayout(),
|
Ok(true) => {
|
||||||
|
self.pending_column_width_cycles.insert(window);
|
||||||
|
self.relayout();
|
||||||
|
}
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Err(error) => tracing::error!(%error, ?window, "failed to cycle column width"),
|
Err(error) => tracing::error!(%error, ?window, "failed to cycle column width"),
|
||||||
}
|
}
|
||||||
@@ -1715,4 +1780,26 @@ mod tests {
|
|||||||
assert!(animation.close_sent);
|
assert!(animation.close_sent);
|
||||||
assert!(!animation.advance(Duration::from_secs(1)));
|
assert!(!animation.advance(Duration::from_secs(1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coupled_right_column_width_keeps_its_right_edge_stable() {
|
||||||
|
let spring = SpringConfig {
|
||||||
|
mass: 1.0,
|
||||||
|
stiffness: 320.0,
|
||||||
|
damping: 2.0 * 320.0_f64.sqrt(),
|
||||||
|
position_tolerance: 0.1,
|
||||||
|
velocity_tolerance: 0.1,
|
||||||
|
};
|
||||||
|
let mut width = AnimatedValue::new(980.0);
|
||||||
|
let mut viewport = AnimatedValue::new(495.0);
|
||||||
|
width.retarget_preserving_motion(485.0);
|
||||||
|
viewport.retarget_preserving_motion(0.0);
|
||||||
|
|
||||||
|
for _ in 0..120 {
|
||||||
|
width.advance(Duration::from_secs_f64(1.0 / 120.0), spring);
|
||||||
|
viewport.advance(Duration::from_secs_f64(1.0 / 120.0), spring);
|
||||||
|
|
||||||
|
assert!((width.current - viewport.current - 485.0).abs() < 0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -78,6 +78,7 @@ pub struct ScrollingLayout {
|
|||||||
default_width: ColumnWidth,
|
default_width: ColumnWidth,
|
||||||
focus_strategy: ViewportFocusStrategy,
|
focus_strategy: ViewportFocusStrategy,
|
||||||
reveal_pending: bool,
|
reveal_pending: bool,
|
||||||
|
width_cycle_pending: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ScrollingLayout {
|
impl Default for ScrollingLayout {
|
||||||
@@ -90,6 +91,7 @@ impl Default for ScrollingLayout {
|
|||||||
default_width: ColumnWidth::default(),
|
default_width: ColumnWidth::default(),
|
||||||
focus_strategy: ViewportFocusStrategy::Minimal,
|
focus_strategy: ViewportFocusStrategy::Minimal,
|
||||||
reveal_pending: false,
|
reveal_pending: false,
|
||||||
|
width_cycle_pending: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,6 +283,7 @@ impl ScrollingLayout {
|
|||||||
.map(|index| presets[(index + 1) % presets.len()])
|
.map(|index| presets[(index + 1) % presets.len()])
|
||||||
.unwrap_or(presets[0]);
|
.unwrap_or(presets[0]);
|
||||||
self.columns[column].width = next;
|
self.columns[column].width = next;
|
||||||
|
self.width_cycle_pending = next != current;
|
||||||
Ok(next != current)
|
Ok(next != current)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,7 +464,11 @@ impl ScrollingLayout {
|
|||||||
column_positions.push((next_column_x, width));
|
column_positions.push((next_column_x, width));
|
||||||
next_column_x += width + inner;
|
next_column_x += width + inner;
|
||||||
}
|
}
|
||||||
if self.reveal_pending {
|
if self.width_cycle_pending {
|
||||||
|
self.retarget_after_width_cycle(&column_positions, viewport_width);
|
||||||
|
self.width_cycle_pending = false;
|
||||||
|
self.reveal_pending = false;
|
||||||
|
} else if self.reveal_pending {
|
||||||
self.reveal_active_column(&column_positions, viewport_width);
|
self.reveal_active_column(&column_positions, viewport_width);
|
||||||
self.reveal_pending = false;
|
self.reveal_pending = false;
|
||||||
}
|
}
|
||||||
@@ -529,6 +536,27 @@ impl ScrollingLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn retarget_after_width_cycle(&mut self, positions: &[(f64, f64)], viewport_width: f64) {
|
||||||
|
let Some(active) = self.active_column else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(&(start, width)) = positions.get(active) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(&(last_start, last_width)) = positions.last() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let strip_width = last_start + last_width;
|
||||||
|
|
||||||
|
if strip_width <= viewport_width {
|
||||||
|
self.viewport_x = 0.0;
|
||||||
|
} else if width >= viewport_width {
|
||||||
|
self.viewport_x = start;
|
||||||
|
} else {
|
||||||
|
self.viewport_x = (start + width - viewport_width).max(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn move_horizontally(
|
fn move_horizontally(
|
||||||
&mut self,
|
&mut self,
|
||||||
window: WindowId,
|
window: WindowId,
|
||||||
@@ -986,6 +1014,79 @@ mod tests {
|
|||||||
assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.5));
|
assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.5));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn left_column_zoom_pushes_and_restores_the_right_column() {
|
||||||
|
let (mut layout, bounds, gaps) = two_half_width_layout();
|
||||||
|
let presets = [ColumnWidth::Proportion(0.5), ColumnWidth::Full];
|
||||||
|
|
||||||
|
layout.focus(window(1)).unwrap();
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(1)))
|
||||||
|
.unwrap();
|
||||||
|
layout.cycle_column_width(window(1), &presets).unwrap();
|
||||||
|
let zoomed = layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(1)))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(layout.viewport_x(), 0.0);
|
||||||
|
assert_eq!(zoomed.geometry[&window(1)].x, 10.0);
|
||||||
|
assert_eq!(zoomed.geometry[&window(1)].width, 980.0);
|
||||||
|
assert_eq!(zoomed.geometry[&window(2)].x, 1_000.0);
|
||||||
|
|
||||||
|
layout.cycle_column_width(window(1), &presets).unwrap();
|
||||||
|
let restored = layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(1)))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(layout.viewport_x(), 0.0);
|
||||||
|
assert_eq!(restored.geometry[&window(1)].x, 10.0);
|
||||||
|
assert_eq!(restored.geometry[&window(2)].x, 505.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn right_column_zoom_moves_left_neighbor_out_and_back() {
|
||||||
|
let (mut layout, bounds, gaps) = two_half_width_layout();
|
||||||
|
let presets = [ColumnWidth::Proportion(0.5), ColumnWidth::Full];
|
||||||
|
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2)))
|
||||||
|
.unwrap();
|
||||||
|
layout.cycle_column_width(window(2), &presets).unwrap();
|
||||||
|
let zoomed = layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2)))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(layout.viewport_x(), 495.0);
|
||||||
|
assert_eq!(zoomed.geometry[&window(1)].x, -485.0);
|
||||||
|
assert_eq!(zoomed.geometry[&window(2)].x, 10.0);
|
||||||
|
assert_eq!(zoomed.geometry[&window(2)].width, 980.0);
|
||||||
|
|
||||||
|
layout.cycle_column_width(window(2), &presets).unwrap();
|
||||||
|
let restored = layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2)))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(layout.viewport_x(), 0.0);
|
||||||
|
assert_eq!(restored.geometry[&window(1)].x, 10.0);
|
||||||
|
assert_eq!(restored.geometry[&window(2)].x, 505.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn two_half_width_layout() -> (ScrollingLayout, Rect, GapConfig) {
|
||||||
|
let mut layout = ScrollingLayout::default();
|
||||||
|
layout.insert(window(1), None).unwrap();
|
||||||
|
layout.insert(window(2), Some(window(1))).unwrap();
|
||||||
|
|
||||||
|
(
|
||||||
|
layout,
|
||||||
|
Rect::new(0.0, 0.0, 1_000.0, 800.0),
|
||||||
|
GapConfig {
|
||||||
|
inner: 10.0,
|
||||||
|
outer: 10.0,
|
||||||
|
smart: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_center_places_a_middle_column_in_the_viewport_center() {
|
fn explicit_center_places_a_middle_column_in_the_viewport_center() {
|
||||||
let mut layout = ScrollingLayout::default();
|
let mut layout = ScrollingLayout::default();
|
||||||
|
|||||||
@@ -548,6 +548,49 @@ a new column, cycle standard widths, set a precise width, or redistribute
|
|||||||
heights within a column. Full-width columns occupy the viewport but do not alter
|
heights within a column. Full-width columns occupy the viewport but do not alter
|
||||||
neighboring column widths.
|
neighboring column widths.
|
||||||
|
|
||||||
|
### 5.1 Scrolling column zoom motion
|
||||||
|
|
||||||
|
Cycling a scrolling column between a proportional width and full width is a
|
||||||
|
layout morph, not a camera-scale effect. The focused column's visual allocation
|
||||||
|
animates with the normal geometry spring while the scrolling viewport uses its
|
||||||
|
dedicated critically damped spring. Ferese sends the final client size once and
|
||||||
|
may scale and clip the latest valid buffer inside the animated allocation until
|
||||||
|
the matching commit arrives.
|
||||||
|
|
||||||
|
For two half-width columns, zooming the left column expands it in place and
|
||||||
|
pushes the right column out through the right edge. Zooming the right column
|
||||||
|
retargets the viewport so it expands into the viewport while the left column
|
||||||
|
leaves through the left edge. Zoom-out continuously reverses these
|
||||||
|
relationships: a right neighbor returns from the right and a left neighbor
|
||||||
|
returns from the left. A neighbor remains a live window and MUST NOT fade,
|
||||||
|
teleport, use a screenshot substitute, or receive an unrelated translation.
|
||||||
|
|
||||||
|
The presented horizontal position remains the composition:
|
||||||
|
|
||||||
|
```text
|
||||||
|
screen_x = animated_world_x - animated_viewport_x
|
||||||
|
```
|
||||||
|
|
||||||
|
Width-cycle viewport targets expose the final scrolling layout. If the complete
|
||||||
|
column strip fits, the viewport returns to the strip origin. A full-width
|
||||||
|
focused column aligns to the usable viewport; a smaller focused column in a
|
||||||
|
larger strip keeps as much real left-side context as fits. This retargeting is
|
||||||
|
specific to column zoom. Pointer-driven and directional manual resize continue
|
||||||
|
toward the existing viewport target and do not trigger comfort-region
|
||||||
|
recentering.
|
||||||
|
|
||||||
|
Zoom is interruptible. A reversed width cycle or focus change retargets active
|
||||||
|
geometry and viewport springs from their current positions and velocities; it
|
||||||
|
MUST NOT finish or snap through the previous target first. Hit testing follows
|
||||||
|
presented geometry. Reduced motion shortens the settle while preserving spatial
|
||||||
|
direction, and disabling animations snaps directly to the final layout.
|
||||||
|
|
||||||
|
Tests MUST cover half-plus-half zoom and zoom-out for either focused column,
|
||||||
|
neighbor exit and return direction, bounded focused-anchor drift, interrupted
|
||||||
|
and reversed transitions, focus changes during motion, geometry/viewport
|
||||||
|
composition, one final configure rather than per-frame configure traffic,
|
||||||
|
animated hit testing, reduced motion, and animation-disabled behavior.
|
||||||
|
|
||||||
Mode conversion is deterministic. Scrolling-to-tree conversion builds
|
Mode conversion is deterministic. Scrolling-to-tree conversion builds
|
||||||
horizontal structure from columns and vertical structure from windows within a
|
horizontal structure from columns and vertical structure from windows within a
|
||||||
column, preserving reading order and size proportions. Tree-to-scrolling
|
column, preserving reading order and size proportions. Tree-to-scrolling
|
||||||
|
|||||||
Reference in new issue
Block a user