Skip to content

feat: activate edge snapping for auto-zoom viewport#1667

Open
namearth5005 wants to merge 10 commits intoCapSoftware:mainfrom
namearth5005:feat/auto-zoom-edge-snap
Open

feat: activate edge snapping for auto-zoom viewport#1667
namearth5005 wants to merge 10 commits intoCapSoftware:mainfrom
namearth5005:feat/auto-zoom-edge-snap

Conversation

@namearth5005
Copy link

@namearth5005 namearth5005 commented Mar 18, 2026

Summary

Activates the existing dead-code apply_edge_snap_to_focus function to prevent the zoomed viewport from panning into awkward corner positions (refs #1646, builds on #1663, #1664, #1665, #1666):

  • Activated apply_edge_snap_to_focus — removed #[allow(dead_code)], wired into per-frame rendering
  • Per-frame application — edge snapping runs after spring physics smoothing but before viewport bounds, so camera motion stays smooth while avoiding corners
  • Only for Auto mode — Manual zoom mode is unaffected (user explicitly chose the focus point)
  • Configurableedge_snap_enabled toggle in settings (default true); when disabled, segments get edge_snap_ratio: 0.0

How it works

When the cursor moves near screen edges during a zoomed Auto segment:

Before: Camera tries to center on cursor → viewport extends past screen edge → awkward cut-off view

After: apply_edge_snap_to_focus snaps the focus to keep the viewport cleanly aligned with the screen edge → professional-looking edge behavior (like Screen Studio)

The function uses edge_snap_ratio (0.25 = 25% of viewport) as a threshold. Within this zone, the focus snaps to show the edge cleanly rather than following the cursor into the corner.

Files changed

File Change
crates/rendering/src/zoom_focus_interpolation.rs Removed #[allow(dead_code)]
crates/rendering/src/lib.rs Applied edge snap per-frame for current + prev frames
crates/project/src/configuration.rs Added edge_snap_enabled to AutoZoomConfig
apps/desktop/src-tauri/src/recording.rs Config-driven edge_snap_ratio on generated segments
apps/desktop/.../experimental.tsx Added Edge Snapping toggle

Test plan

  • Record with cursor near screen edges → viewport stays cleanly within screen bounds
  • Toggle "Edge Snapping" off → viewport follows cursor freely to corners
  • Manual zoom mode unaffected by edge snapping
  • Existing recordings render identically (serde defaults)

Greptile Summary

This PR activates the previously dead-code apply_edge_snap_to_focus function and bundles it with a large AutoZoomConfig refactor — converting all zoom-generation magic constants into configurable fields (dead-zone, double-click dedup, right-click filtering, spatial-intensity-based zoom) and exposing a subset of them through a new settings panel.

Key changes:

  • apply_edge_snap_to_focus is now called per-frame in ProjectUniforms::new, guarded behind ZoomMode::Auto — clean and consistent with existing rendering patterns.
  • All zoom-generation constants in generate_zoom_segments_from_clicks_impl are replaced with AutoZoomConfig fields, and the config is threaded from GeneralSettingsStore through to segment generation.
  • A new compute_zoom_amount function varies zoom level by spatial spread of click positions; however movement-triggered and single-click segments always receive max_zoom_amount (2.5× default) because positions.len() < 2, compared to the previous flat AUTO_ZOOM_AMOUNT = 1.5×. This is an unintentional behavioural regression.
  • The experimental settings panel adds an Edge Snapping toggle and Min/Max Zoom sliders, but no cross-slider validation prevents users from setting Min Zoom above Max Zoom, which inverts zoom intensity behaviour in the Rust layer.
  • GeneralSettingsStore::get errors are silently swallowed with unwrap_or(None).unwrap_or_default(), which falls back to edge snap enabled regardless of user preference.

Confidence Score: 2/5

  • Not safe to merge without addressing the zoom amount regression for movement segments and the missing Min/Max Zoom validation.
  • The core edge-snap activation is correct and well-scoped, but the same commit bundles a large AutoZoomConfig refactor that introduces a silent behavioural regression: movement-triggered segments and single-click segments now zoom at max_zoom_amount (2.5× by default) instead of the previous 1.5×, because compute_zoom_amount unconditionally returns max_zoom_amount when positions.len() < 2. Additionally, the new UI sliders allow Min Zoom > Max Zoom, inverting the intensity curve logic in Rust. These two issues affect every user who records with cursor movement, making the PR risky to ship as-is.
  • apps/desktop/src-tauri/src/recording.rs (compute_zoom_amount fallback) and apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx (slider cross-validation)

Important Files Changed

Filename Overview
apps/desktop/src-tauri/src/recording.rs Large refactor converting all zoom segment generation constants to AutoZoomConfig fields; introduces compute_zoom_amount for spatial-intensity-based zoom, dead-zone grouping, double-click dedup, and right-click filtering. Critical bug: movement segments (empty positions) and single-click segments always receive max_zoom_amount instead of the previous AUTO_ZOOM_AMOUNT = 1.5×, causing a silent behavioural regression.
crates/rendering/src/lib.rs Activates apply_edge_snap_to_focus per-frame for both current and previous frame focus positions; correctly guards behind ZoomMode::Auto check and mirrors the pattern already used by SegmentsCursor. Segment lookup uses frame_time (output time), consistent with existing zoom rendering code.
crates/rendering/src/zoom_focus_interpolation.rs Removes #[allow(dead_code)] to make apply_edge_snap_to_focus public; no logic changes. The snap function correctly handles viewport bounds using viewport_half and snap_threshold derived from zoom amount and edge_snap_ratio.
crates/project/src/configuration.rs Introduces AutoZoomConfig struct with camelCase serde, struct-level #[serde(default)] backed by a full Default impl; field-level #[serde(default = "...")] annotations on the four newer fields are redundant but harmless. Backward-compatible with existing stored recordings via serde defaults.
apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx Adds Edge Snapping toggle and four sliders (Min Zoom, Max Zoom, Sensitivity, Smoothing) shown when Auto Zoom is enabled. Missing cross-slider validation: Min Zoom can be set higher than Max Zoom, producing inverted zoom intensity behaviour in the Rust layer.
apps/desktop/src-tauri/src/lib.rs Threads AppHandle into generate_zoom_segments_from_clicks to pass AutoZoomConfig from GeneralSettingsStore. Error handling uses unwrap_or(None).unwrap_or_default() which silently swallows settings load errors, defaulting to edge snap enabled.
apps/desktop/src-tauri/src/general_settings.rs Adds auto_zoom_config: cap_project::AutoZoomConfig field to GeneralSettingsStore with #[serde(default)] and proper Default initialisation; straightforward, no issues.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Per-frame render call] --> B[zoom_focus_interpolator.interpolate\ncurrent_recording_time]
    B --> C{Active segment at\nframe_time?}
    C -- No segment --> E[Use focus as-is]
    C -- Manual mode --> E
    C -- Auto mode --> D[apply_edge_snap_to_focus\nfocus + segment.edge_snap_ratio]
    D --> F[zoom_focus passed to\nInterpolatedZoom]
    E --> F

    G[generate_zoom_segments_from_clicks_impl] --> H{config.ignore_right_clicks?}
    H -- yes --> I[filter cursor_num != 0]
    H -- no --> J[Sort by time_ms]
    I --> J
    J --> K{double_click_threshold_ms > 0?}
    K -- yes --> L[deduplicate double-clicks]
    K -- no --> M[Group clicks\ntime + spatial + dead_zone]
    L --> M
    M --> N[Build intervals with positions]
    N --> O[Add movement intervals\npositions = empty]
    O --> P[Merge overlapping intervals]
    P --> Q[compute_zoom_amount\npositions]
    Q --> R{positions.len < 2?}
    R -- yes\nmovements & single clicks --> S[return max_zoom_amount ⚠️]
    R -- no --> T[spatial spread → lerp\nmin..max_zoom_amount]
    S --> U[ZoomSegment\nedge_snap_ratio = 0.25 if enabled]
    T --> U
Loading

Comments Outside Diff (2)

  1. apps/desktop/src-tauri/src/recording.rs, line 261-281 (link)

    P1 Movement segments always receive max_zoom_amount

    compute_zoom_amount returns config.max_zoom_amount whenever positions.len() < 2. Movement-triggered intervals are pushed with vec![] (empty positions), so they will always hit this early return and zoom at max_zoom_amount (2.5× by default). The previous behaviour was AUTO_ZOOM_AMOUNT = 1.5× for all segments.

    This is a silent behavioural regression: any recording that had movement-triggered zoom segments will now render with a noticeably harder zoom (2.5× vs 1.5×). Single-click segments (one position) are also affected the same way.

    Consider falling back to config.zoom_amount (the general-purpose baseline) instead of max_zoom_amount when the spatial spread cannot be computed:

        if positions.len() < 2 {
            return config.zoom_amount;  // use the base zoom, not the maximum
        }
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src-tauri/src/recording.rs
    Line: 261-281
    
    Comment:
    **Movement segments always receive `max_zoom_amount`**
    
    `compute_zoom_amount` returns `config.max_zoom_amount` whenever `positions.len() < 2`. Movement-triggered intervals are pushed with `vec![]` (empty positions), so they will always hit this early return and zoom at `max_zoom_amount` (2.5× by default). The previous behaviour was `AUTO_ZOOM_AMOUNT = 1.5×` for all segments.
    
    This is a silent behavioural regression: any recording that had movement-triggered zoom segments will now render with a noticeably harder zoom (2.5× vs 1.5×). Single-click segments (one position) are also affected the same way.
    
    Consider falling back to `config.zoom_amount` (the general-purpose baseline) instead of `max_zoom_amount` when the spatial spread cannot be computed:
    
    ```rust
        if positions.len() < 2 {
            return config.zoom_amount;  // use the base zoom, not the maximum
        }
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. apps/desktop/src-tauri/src/lib.rs, line 36-38 (link)

    P2 Silent error suppression hides settings load failures

    unwrap_or(None).unwrap_or_default() discards any Err from GeneralSettingsStore::get. If the settings file is corrupted or unreadable, the function silently falls back to AutoZoomConfig::default(), which has edge_snap_enabled: true. A user who has disabled edge snapping would unknowingly have it re-enabled on every editor reload until the settings file recovers.

    Consider propagating the error (or at least logging it) so the failure is visible:

    let settings = GeneralSettingsStore::get(&app)
        .map_err(|e| {
            tracing::warn!("Failed to load general settings, using defaults: {e}");
        })
        .ok()
        .flatten()
        .unwrap_or_default();
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src-tauri/src/lib.rs
    Line: 36-38
    
    Comment:
    **Silent error suppression hides settings load failures**
    
    `unwrap_or(None).unwrap_or_default()` discards any `Err` from `GeneralSettingsStore::get`. If the settings file is corrupted or unreadable, the function silently falls back to `AutoZoomConfig::default()`, which has `edge_snap_enabled: true`. A user who has disabled edge snapping would unknowingly have it re-enabled on every editor reload until the settings file recovers.
    
    Consider propagating the error (or at least logging it) so the failure is visible:
    
    ```rust
    let settings = GeneralSettingsStore::get(&app)
        .map_err(|e| {
            tracing::warn!("Failed to load general settings, using defaults: {e}");
        })
        .ok()
        .flatten()
        .unwrap_or_default();
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/recording.rs
Line: 261-281

Comment:
**Movement segments always receive `max_zoom_amount`**

`compute_zoom_amount` returns `config.max_zoom_amount` whenever `positions.len() < 2`. Movement-triggered intervals are pushed with `vec![]` (empty positions), so they will always hit this early return and zoom at `max_zoom_amount` (2.5× by default). The previous behaviour was `AUTO_ZOOM_AMOUNT = 1.5×` for all segments.

This is a silent behavioural regression: any recording that had movement-triggered zoom segments will now render with a noticeably harder zoom (2.5× vs 1.5×). Single-click segments (one position) are also affected the same way.

Consider falling back to `config.zoom_amount` (the general-purpose baseline) instead of `max_zoom_amount` when the spatial spread cannot be computed:

```rust
    if positions.len() < 2 {
        return config.zoom_amount;  // use the base zoom, not the maximum
    }
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx
Line: 176-193

Comment:
**No validation that Max Zoom ≥ Min Zoom**

The two sliders are fully independent: Min Zoom goes up to 3.0× and Max Zoom starts at 1.5×, so a user can set Min Zoom = 3.0 and Max Zoom = 1.5.

When `min_zoom_amount > max_zoom_amount`, `compute_zoom_amount` produces *inverted* results:
- A tight cluster of clicks (small `max_dist`) returns `max_zoom_amount` (1.5×) — i.e. minimal zoom.
- Spread activity (large `max_dist`) returns `min_zoom_amount` (3.0×) — i.e. maximum zoom.

The effect is completely backwards from the labelling. Consider clamping or cross-validating on change:

```tsx
onChange={(v) => {
    const clamped = Math.min(v, settings.autoZoomConfig?.maxZoomAmount ?? 4.0);
    handleConfigChange("minZoomAmount", clamped);
}}
```

and symmetrically for the Max Zoom slider:

```tsx
onChange={(v) => {
    const clamped = Math.max(v, settings.autoZoomConfig?.minZoomAmount ?? 1.0);
    handleConfigChange("maxZoomAmount", clamped);
}}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/lib.rs
Line: 36-38

Comment:
**Silent error suppression hides settings load failures**

`unwrap_or(None).unwrap_or_default()` discards any `Err` from `GeneralSettingsStore::get`. If the settings file is corrupted or unreadable, the function silently falls back to `AutoZoomConfig::default()`, which has `edge_snap_enabled: true`. A user who has disabled edge snapping would unknowingly have it re-enabled on every editor reload until the settings file recovers.

Consider propagating the error (or at least logging it) so the failure is visible:

```rust
let settings = GeneralSettingsStore::get(&app)
    .map_err(|e| {
        tracing::warn!("Failed to load general settings, using defaults: {e}");
    })
    .ok()
    .flatten()
    .unwrap_or_default();
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "feat(rendering): act..."

Greptile also left 1 inline comment on this PR.

Comment on lines +176 to +193
<SettingSlider
label="Min Zoom"
value={settings.autoZoomConfig?.minZoomAmount ?? 1.2}
onChange={(v) => handleConfigChange("minZoomAmount", v)}
min={1.0}
max={3.0}
step={0.1}
format={(v) => `${v.toFixed(1)}x`}
/>
<SettingSlider
label="Max Zoom"
value={settings.autoZoomConfig?.maxZoomAmount ?? 2.5}
onChange={(v) => handleConfigChange("maxZoomAmount", v)}
min={1.5}
max={4.0}
step={0.1}
format={(v) => `${v.toFixed(1)}x`}
/>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 No validation that Max Zoom ≥ Min Zoom

The two sliders are fully independent: Min Zoom goes up to 3.0× and Max Zoom starts at 1.5×, so a user can set Min Zoom = 3.0 and Max Zoom = 1.5.

When min_zoom_amount > max_zoom_amount, compute_zoom_amount produces inverted results:

  • A tight cluster of clicks (small max_dist) returns max_zoom_amount (1.5×) — i.e. minimal zoom.
  • Spread activity (large max_dist) returns min_zoom_amount (3.0×) — i.e. maximum zoom.

The effect is completely backwards from the labelling. Consider clamping or cross-validating on change:

onChange={(v) => {
    const clamped = Math.min(v, settings.autoZoomConfig?.maxZoomAmount ?? 4.0);
    handleConfigChange("minZoomAmount", clamped);
}}

and symmetrically for the Max Zoom slider:

onChange={(v) => {
    const clamped = Math.max(v, settings.autoZoomConfig?.minZoomAmount ?? 1.0);
    handleConfigChange("maxZoomAmount", clamped);
}}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx
Line: 176-193

Comment:
**No validation that Max Zoom ≥ Min Zoom**

The two sliders are fully independent: Min Zoom goes up to 3.0× and Max Zoom starts at 1.5×, so a user can set Min Zoom = 3.0 and Max Zoom = 1.5.

When `min_zoom_amount > max_zoom_amount`, `compute_zoom_amount` produces *inverted* results:
- A tight cluster of clicks (small `max_dist`) returns `max_zoom_amount` (1.5×) — i.e. minimal zoom.
- Spread activity (large `max_dist`) returns `min_zoom_amount` (3.0×) — i.e. maximum zoom.

The effect is completely backwards from the labelling. Consider clamping or cross-validating on change:

```tsx
onChange={(v) => {
    const clamped = Math.min(v, settings.autoZoomConfig?.maxZoomAmount ?? 4.0);
    handleConfigChange("minZoomAmount", clamped);
}}
```

and symmetrically for the Max Zoom slider:

```tsx
onChange={(v) => {
    const clamped = Math.max(v, settings.autoZoomConfig?.minZoomAmount ?? 1.0);
    handleConfigChange("maxZoomAmount", clamped);
}}
```

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant