diff --git a/specs/GH9576/product.md b/specs/GH9576/product.md new file mode 100644 index 000000000..27d2e0f89 --- /dev/null +++ b/specs/GH9576/product.md @@ -0,0 +1,103 @@ +# PRODUCT.md — Show current zoom level when zooming + +Issue: https://github.com/warpdotdev/warp/issues/9576 + +## Summary + +When a user changes Warp's UI zoom level from a keyboard shortcut, menu action, command-palette action, or supported Ctrl+scroll gesture, Warp should briefly show the resulting zoom level as a percentage in the workspace. The indicator should make the new state obvious without requiring the user to open Settings → Appearance → Window → Zoom. + +Figma: none provided. A prior issue comment requested designs as part of the spec; this spec defines a lightweight HUD direction that can be validated with a live prototype or screenshot before implementation review. + +## Problem + +Warp already supports discrete UI zoom levels, but the workspace gives no direct feedback after zooming. Users can see the interface rescale, but they cannot tell whether they landed at `90%`, `100%`, `110%`, or another supported step unless they open settings. After several zoom-in or zoom-out actions, the only quick way to recover a known value is reset-to-default. + +## Goals + +- Show the current UI zoom level immediately after user-initiated zoom changes. +- Use the same percentage values and formatting shown by the Appearance settings dropdown. +- Keep the feedback transient, low-distraction, and scoped to the active workspace window. +- Support every existing user-facing zoom path that dispatches `IncreaseZoom`, `DecreaseZoom`, or `ResetZoom`. +- Avoid adding another persistent setting, toolbar item, menu item, or panel. +- Preserve existing zoom behavior, step values, limits, and reset semantics. + +## Non-goals + +- Changing the supported zoom values or introducing continuous zoom. +- Showing a permanent zoom badge in the tab bar, status area, settings button, or terminal. +- Showing the indicator for unrelated settings changes unless they are caused by a user-facing workspace zoom action. +- Adding telemetry as part of the spec-only change. If product later wants measurement, that should be specified separately. +- Removing or stabilizing the existing `UIZoom` feature flag. + +## Design direction + +The intended surface is a compact, non-interactive HUD rather than a general-purpose toast. It should visually resemble common zoom feedback in apps such as VS Code, Chrome, Slack, and macOS apps: + +- Content: the zoom percentage only, for example `110%`. +- Placement: centered horizontally near the top of the active workspace content, below the tab/title bar area and above terminal or panel content. +- Shape: small rounded rectangle or pill with theme-aware background, border or shadow if needed for contrast, and prominent percentage text. +- Lifetime: visible for about one second after the most recent zoom action, then disappears automatically. +- Repeated zooming: update the same HUD in place and restart the timeout instead of stacking multiple indicators. +- Interaction: no close button, no link, no click action, and no keyboard focus. +- Accessibility: the indicator is visual feedback for a user-triggered scaling action and should not steal focus. If the UI framework supports non-disruptive announcements without excessive chatter, it may expose the updated percentage, but this should not block the initial implementation. + +## User experience + +1. When the `UIZoom` feature is enabled, invoking "Zoom In" increases the zoom level to the next supported value and shows a HUD containing the resulting percentage, such as `110%`. + +2. Invoking "Zoom Out" decreases the zoom level to the previous supported value and shows the resulting percentage, such as `90%`. + +3. Invoking "Reset Zoom" resets the zoom level to the default value and shows `100%`. + +4. The indicator uses exactly the supported zoom values from Warp's zoom setting: `50%`, `60%`, `70%`, `80%`, `90%`, `100%`, `110%`, `125%`, `150%`, `175%`, `200%`, `225%`, `250%`, `300%`, and `350%`. + +5. The indicator text is formatted as `{value}%` with no extra label by default. For example, it says `125%`, not `Zoom: 125%`. + +6. The behavior applies to every user-facing workspace zoom trigger that dispatches the existing zoom actions: + - "Zoom In", "Zoom Out", and "Reset Zoom" keybindings. + - The corresponding command-palette or keybinding-dispatched actions. + - The corresponding View menu items. + - Ctrl+scroll zooming on Windows and Linux when that path dispatches zoom actions. + +7. The indicator is scoped to the window where the zoom action was invoked. It does not appear in other open Warp windows. + +8. If a user invokes zoom in at the maximum supported value (`350%`) or zoom out at the minimum supported value (`50%`), the zoom value remains clamped and the HUD still shows the current value. This confirms that the request was handled and explains why the UI did not scale further. + +9. If the current zoom setting is somehow not one of the supported stepped values and a zoom-in or zoom-out action cannot determine the next value, the action should not show a misleading HUD. Existing fallback behavior for invalid values is preserved. + +10. Consecutive zoom actions within the HUD lifetime update the visible percentage and extend the display duration from the latest action. The user should never see a stack of old zoom percentages. + +11. Opening Settings → Appearance → Window → Zoom and changing the dropdown continues to update the UI zoom and selected value as it does today. Showing the HUD for direct settings-dropdown changes is optional, but the implementation must not regress settings behavior. + +12. The HUD must render correctly over the main workspace in common layouts: single terminal pane, split panes, settings open, command palette closed, AI/resource panels open, horizontal tabs, vertical tabs, and full-screen or hover-hidden tab-bar modes. + +13. The HUD should respect the active theme and remain readable at all supported zoom levels. It must not obscure modal dialogs, native confirmation dialogs, or blocking overlays more aggressively than existing workspace overlays. + +14. The feature remains gated by the same `UIZoom` availability as the underlying zoom actions. When UI zoom is disabled and the same shortcuts adjust terminal font size instead, no UI zoom percentage HUD is shown. + +## Success criteria + +- A user can press the zoom-in shortcut once from `100%` and see `110%` without opening settings. +- A user can press the zoom-out shortcut once from `100%` and see `90%`. +- A user can reset zoom and see `100%`. +- Repeated keypresses update one transient indicator rather than creating multiple stacked toasts. +- At `50%` and `350%`, additional decrease/increase requests show the clamped current percentage and do not suggest a value outside the supported list. +- The settings dropdown continues to show and persist the same zoom value used by the HUD. +- The indicator appears in the active window only and disappears automatically after roughly one second. +- Existing zoom shortcuts, menu actions, command-palette actions, and Linux/Windows Ctrl+scroll zoom behavior continue to work. +- The implementation can be reviewed with at least one screenshot or short video showing the HUD in the workspace. + +## Validation + +- Unit test the zoom action helpers so increase, decrease, reset, min clamp, max clamp, and invalid-current-value cases map to the expected HUD behavior. +- Add or update workspace view tests to verify that dispatching zoom actions creates or updates one active zoom indicator and that the indicator can be dismissed by its timeout path. +- Manually verify on macOS that `Cmd =`, `Cmd -`, and reset-to-default zoom show the expected percentage. +- Manually verify on Windows or Linux that Ctrl+scroll zoom shows the same percentage feedback when `UIZoom` is enabled. +- Manually verify Appearance settings still displays and updates the same current zoom level. +- Capture a screenshot or short video artifact of the HUD to satisfy the design-review request in the issue comments if no Figma mock is available. + +## Open questions + +- Should direct changes from the Appearance zoom dropdown show the HUD, or should feedback be limited to quick workspace zoom actions? +- Should the HUD include accessible live-region announcement text, or is visual feedback sufficient for this first iteration? +- Should final visual placement be top-center of workspace content or centered over the whole window? This spec prefers top-center of workspace content to avoid modal-like interruption. diff --git a/specs/GH9576/tech.md b/specs/GH9576/tech.md new file mode 100644 index 000000000..e17e82960 --- /dev/null +++ b/specs/GH9576/tech.md @@ -0,0 +1,162 @@ +# TECH.md — Show current zoom level when zooming + +Issue: https://github.com/warpdotdev/warp/issues/9576 +Product spec: `specs/GH9576/product.md` + +## Problem + +Warp already has stepped UI zoom values, zoom actions, keybindings, menu entries, settings UI, and the runtime bridge that applies the zoom factor. The missing piece is a transient workspace-level surface that is shown after user-initiated zoom actions and displays the resulting `ZoomLevel` percentage without changing the zoom semantics. + +## Relevant code + +- `app/src/window_settings.rs:73` — `zoom_level` setting definition. +- `app/src/window_settings.rs (84-98)` — `ZoomLevel::VALUES` and `ZoomLevel::as_zoom_factor()`. +- `app/src/workspace/action.rs (179-188)` — `WorkspaceAction::IncreaseZoom`, `DecreaseZoom`, and `ResetZoom`. +- `app/src/workspace/mod.rs (304-329)` — fixed zoom bindings registered when `FeatureFlag::UIZoom` is enabled. +- `app/src/workspace/mod.rs (382-410)` — editable zoom keybindings and adjacent font-size fallback bindings. +- `app/src/workspace/view.rs (15075-15101)` — `WindowSettingsChangedEvent::ZoomLevel` currently updates titlebar height. +- `app/src/workspace/view.rs (15660-15694)` — `increase_zoom`, `decrease_zoom`, `reset_zoom`, and `adjust_zoom`. +- `app/src/workspace/view.rs (20065-20067)` — action dispatch routes zoom actions to the helpers above. +- `app/src/workspace/view.rs (22995-23005)` — Linux/Windows Ctrl+scroll dispatches zoom actions when `UIZoom` is enabled. +- `app/src/settings_view/appearance_page.rs (914-926)` — settings-page observer applies `ctx.set_zoom_factor()` when `ZoomLevel` changes. +- `app/src/settings_view/appearance_page.rs (2444-2458)` — Appearance dropdown formats zoom items as `{value}%`. +- `app/src/settings_view/appearance_page.rs (5110-5146)` — `ZoomLevelWidget` renders the Appearance → Window → Zoom setting. +- `app/src/workspace/view.rs (2882-2890)` — workspace creates `DismissibleToastStack` instances with four-second timeout. +- `app/src/workspace/view.rs (19052-19062)` — global workspace toast positioning is already centered near the top of tab content. +- `app/src/workspace/view.rs (22891-22902)` — workspace overlays render the global toast stack using that positioning. +- `app/src/view_components/dismissible_toast.rs (58-120)` — existing ephemeral toast timeout and update behavior. + +## Current state + +`ZoomLevel` is a local window setting with stepped percentage values. When `FeatureFlag::UIZoom` is enabled, workspace keybindings and menu actions dispatch `IncreaseZoom`, `DecreaseZoom`, or `ResetZoom`. `increase_zoom` and `decrease_zoom` call `adjust_zoom`, which looks up the current value in `ZoomLevel::VALUES`, clamps to the min or max step, and writes the resulting value to `WindowSettings`. `reset_zoom` writes `ZoomLevel::default_value()`. + +The visual zoom factor is applied by the Appearance settings page's subscription to `WindowSettingsChangedEvent::ZoomLevel`, which calls `ctx.set_zoom_factor(WindowSettings::as_ref(ctx).zoom_level.as_zoom_factor())`. Workspace also listens to the same event to update titlebar height. + +Workspace already has general toast infrastructure, but it is optimized for message toasts with close buttons, optional icons, links, object IDs, and a four-second timeout. A zoom percentage is better represented as a short-lived HUD: one centered percentage, no close affordance, no stacking, and a shorter lifetime. + +## Proposed changes + +### 1. Add a dedicated zoom HUD view + +Introduce a small view component under the workspace or shared view-components area, for example `app/src/workspace/zoom_level_hud.rs` or `app/src/view_components/zoom_level_hud.rs`. Keep it focused on this feature rather than extending `DismissibleToastStack`. + +Suggested shape: + +- `ZoomLevelHud` view with state: + - `visible_zoom_level: Option` + - `dismiss_handle: Option` +- `show_zoom_level(&mut self, zoom_level: u16, ctx: &mut ViewContext)` + - sets `visible_zoom_level` to the new value + - aborts any previous dismiss timer + - spawns a new `Timer::after(Duration::from_secs(1))` or `Duration::from_millis(1000)` + - clears `visible_zoom_level` when the timer fires + - calls `ctx.notify()` +- `render` + - renders nothing when `visible_zoom_level` is `None` + - otherwise renders a compact, non-interactive rounded container containing `format!("{zoom_level}%")` + +Use theme-aware colors from `Appearance` rather than hard-coded colors. The UI should be high contrast enough across themes and should not use button themes or a close button. The existing UI guideline to reuse shared abstractions applies where relevant, but this HUD is not a button. + +### 2. Store and render the HUD from `Workspace` + +Add a field on `Workspace`: + +- `zoom_level_hud: ViewHandle` + +Create it in `Workspace::new` near the existing toast-stack creation. Render it as a positioned overlay in `Workspace::render`, close to where `toast_stack` is currently rendered. + +Positioning should follow the product direction: + +- Prefer a helper such as `zoom_level_hud_positioning(&self) -> OffsetPositioning`. +- Anchor to `TAB_CONTENT_POSITION_ID` with `PositionedElementAnchor::TopMiddle` and `ChildAnchor::TopMiddle`. +- Use a small positive y offset below the tab/title bar, similar to or slightly larger than `global_toast_positioning`. +- Use `PositionedElementOffsetBounds::WindowByPosition` so it behaves consistently with existing workspace overlays. + +Rendering this separately from `toast_stack` avoids stacking behavior and allows a one-second lifetime without altering general toasts. + +### 3. Show the HUD from zoom actions after the value is known + +Refactor the zoom helpers so they return or compute the final zoom value that should be shown. + +Recommended approach: + +- Add a helper on `Workspace`, for example `fn show_zoom_level_hud(&mut self, zoom_level: u16, ctx: &mut ViewContext)`. +- In `reset_zoom`, set the value to `ZoomLevel::default_value()` and call `show_zoom_level_hud(ZoomLevel::default_value(), ctx)` after attempting the setting update. +- In `adjust_zoom`, keep the existing `current_index` lookup. If the current value is not found, return without showing the HUD. Otherwise compute `next_index` as today, set that value, and call `show_zoom_level_hud(next_zoom, ctx)`. +- At min and max, `next_index` may equal `current_index`; still call `show_zoom_level_hud(current_zoom, ctx)` so the user sees the clamped value. + +This keeps the HUD coupled to user-initiated zoom actions instead of every `WindowSettingsChangedEvent::ZoomLevel`. It also avoids showing the HUD for startup restoration, sync, or direct settings changes unless product later chooses to expand the behavior. + +If implementation wants the HUD to appear for direct Appearance dropdown changes, add an explicit `WorkspaceAction::SetZoomLevelFromSettings`-style path rather than showing on every settings event. The product spec leaves that optional, but the safer first implementation is action-only. + +### 4. Consider moving zoom-factor application out of the settings page + +The current runtime bridge to `ctx.set_zoom_factor()` lives in `AppearanceSettingsPageView`'s settings observer. That means the code path is tied to a settings page view subscription even though zoom is a workspace-level behavior. The spec can be implemented without moving it, but implementation should evaluate whether this is already reliable when the Appearance page has never been opened. + +If zoom factor changes do not apply unless the settings page exists, move or duplicate the zoom-factor bridge to an app- or workspace-owned observer that always exists for the window. A low-risk option is to update the existing workspace `WindowSettingsChangedEvent::ZoomLevel` branch so it calls both: + +- `ctx.set_zoom_factor(WindowSettings::as_ref(ctx).zoom_level.as_zoom_factor())` +- `self.update_titlebar_height(ctx)` + +Then remove the duplicate `ctx.set_zoom_factor()` call from the Appearance page observer only if tests confirm there is no regression in dropdown behavior. If the existing bridge is already globally reliable because the settings page view always exists, keep it unchanged and avoid unrelated churn. + +### 5. Keep existing zoom settings and feature gating + +Do not change: + +- `ZoomLevel::VALUES` +- `ZoomLevel::default_value()` +- keybinding names +- `CustomAction` variants +- menu item registration +- `FeatureFlag::UIZoom` gating +- the font-size fallback path used when `UIZoom` is disabled + +The HUD should only be reachable from zoom actions that are registered when `FeatureFlag::UIZoom` is enabled. + +## End-to-end flow + +1. User invokes a zoom shortcut, menu item, command-palette action, or Ctrl+scroll path. +2. The input path dispatches `WorkspaceAction::IncreaseZoom`, `DecreaseZoom`, or `ResetZoom`. +3. `Workspace::handle_action` routes the action to `increase_zoom`, `decrease_zoom`, or `reset_zoom`. +4. The helper writes the new `WindowSettings::zoom_level` value, preserving existing min/max clamping. +5. The helper calls `zoom_level_hud.show_zoom_level(new_value, ctx)`. +6. The HUD renders as a top-centered overlay in the active workspace, showing `{new_value}%`. +7. Any previous HUD dismissal timer is cancelled and replaced. +8. After about one second without another zoom action, the HUD clears itself and disappears. +9. Existing settings observers apply the zoom factor and update titlebar height as they do today, or the workspace observer takes ownership if the implementation moves the runtime bridge. + +## Risks and mitigations + +- Risk: using `DismissibleToastStack` creates stacked percentages, close buttons, icons, or a four-second lifetime that does not match the desired UX. Mitigation: implement a dedicated single-state HUD view. +- Risk: showing the HUD on every `ZoomLevel` settings event could flash on startup, sync, or settings initialization. Mitigation: trigger it from user zoom actions only for the first implementation. +- Risk: repeated keypresses leak timers or clear the new value when an old timer fires. Mitigation: store and abort the previous `SpawnedFutureHandle` before starting a new timer. +- Risk: the zoom factor bridge is currently owned by the Appearance settings page. Mitigation: verify the bridge exists even when settings has never been opened; if not, move it into the workspace zoom-settings observer. +- Risk: the HUD is positioned under modal dialogs or important overlays. Mitigation: render it with the workspace overlay stack near global toasts, and manually verify with settings, panels, full-screen, and dialog states. +- Risk: theme contrast is insufficient. Mitigation: use existing theme colors from `Appearance` and capture a design-review screenshot or video. + +## Testing and validation + +### Unit and view tests + +- Add focused tests for zoom step calculation if the implementation extracts a helper. Cover increase, decrease, reset, min clamp, max clamp, and invalid current value. +- Add `ZoomLevelHud` tests if the view test framework supports checking render state: + - `show_zoom_level(110)` makes the HUD visible with `110%`. + - `show_zoom_level(125)` while visible replaces `110%` with `125%`. + - the dismissal path clears the visible value. +- Add or extend `workspace/view_test.rs` coverage so dispatching `WorkspaceAction::IncreaseZoom`, `DecreaseZoom`, and `ResetZoom` updates the `WindowSettings::zoom_level` and the HUD state in the active workspace. + +### Manual validation + +- macOS: from `100%`, press `Cmd =` and confirm the HUD reads `110%`; press `Cmd -` and confirm it reads `100%`; reset and confirm it reads `100%`. +- Windows/Linux: with `UIZoom` enabled, hold Ctrl and scroll up/down over the workspace and confirm the HUD tracks the stepped values. +- Bounds: at `50%`, invoke zoom out and confirm the HUD still reads `50%`; at `350%`, invoke zoom in and confirm it still reads `350%`. +- Settings: open Appearance → Window → Zoom and confirm the dropdown still tracks the current value and changing it does not regress the applied UI scale. +- Layouts: verify the HUD over a single pane, split panes, vertical tabs, settings open, an AI/resource panel open, and fullscreen or hover-hidden tab-bar mode. +- Design artifact: attach a screenshot or short video of the HUD in the workspace because the issue comment requested designs and no Figma mock is available. + +## Follow-ups + +- Decide whether direct changes from the Appearance zoom dropdown should also show the HUD. +- Decide whether to add an accessibility announcement for the changed zoom percentage. +- If this design proves generally useful, consider extracting a reusable single-value HUD component for other transient workspace state changes rather than overloading general toasts.