Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions app/src/themes/theme_chooser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ use crate::{
themes::theme::SelectedSystemThemes,
user_config::{load_theme_configs, themes_dir, WarpConfig, WarpConfigUpdateEvent},
util::traffic_lights::{TrafficLightData, TrafficLightSide},
window_settings::WindowSettings,
};
use crate::{appearance::AppearanceManager, send_telemetry_from_ctx};
use crate::{editor::EditorView, resource_center::TipsCompleted};
Expand Down Expand Up @@ -590,7 +589,7 @@ impl ThemeChooser {
) -> Box<dyn Element> {
let mut margin_left = 16.;

let zoom_factor = WindowSettings::as_ref(app).zoom_level.as_zoom_factor();
let zoom_factor = app.window_zoom_factor(self.window_id).as_f32();
// Since this panel is always on the left, only account for left-side traffic lights.
if let Some(width) = traffic_light_data
.filter(|data| data.side == TrafficLightSide::Left)
Expand Down
147 changes: 119 additions & 28 deletions app/src/workspace/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ use crate::view_components::callout_bubble::{
use crate::view_components::{
AgentToast, AgentToastStack, DismissibleToast, DismissibleToastStack, ToastLink,
};
use crate::window_settings::{WindowSettings, WindowSettingsChangedEvent, ZoomLevel};
use crate::window_settings::{WindowSettings, WindowSettingsChangedEvent};
use crate::workflows::{
manager::WorkflowOpenSource, AIWorkflowOrigin, CloudWorkflow, WorkflowSelectionSource,
WorkflowSource, WorkflowType, WorkflowViewMode,
Expand Down Expand Up @@ -1055,6 +1055,94 @@ pub struct Workspace {
remove_tab_config_confirmation_dialog: ViewHandle<RemoveTabConfigConfirmationDialog>,
}

/// Returns the next zoom percentage from `ZoomLevel::VALUES` relative to
/// `current_percent`, snapping to the nearest VALUES entry in the chosen
/// direction even when `current_percent` itself is not a VALUES entry.
/// Stays at the boundary if there is no further step in that direction.
fn next_zoom_step(current_percent: u16, increase: bool) -> u16 {
use crate::window_settings::ZoomLevel;
if increase {
ZoomLevel::VALUES
.iter()
.find(|&&v| v > current_percent)
.copied()
.unwrap_or_else(|| {
*ZoomLevel::VALUES
.last()
.expect("ZoomLevel::VALUES is non-empty")
})
} else {
ZoomLevel::VALUES
.iter()
.rev()
.find(|&&v| v < current_percent)
.copied()
.unwrap_or_else(|| {
*ZoomLevel::VALUES
.first()
.expect("ZoomLevel::VALUES is non-empty")
})
}
}

#[cfg(test)]
mod next_zoom_step_tests {
use super::next_zoom_step;
use crate::window_settings::ZoomLevel;

#[test]
fn step_up_from_in_values() {
assert_eq!(next_zoom_step(100, true), 110);
assert_eq!(next_zoom_step(110, true), 125);
}

#[test]
fn step_down_from_in_values() {
assert_eq!(next_zoom_step(100, false), 90);
assert_eq!(next_zoom_step(110, false), 100);
}

#[test]
fn step_up_from_non_values_snaps_to_next_higher() {
// 105 is between 100 and 110 in VALUES; up → 110.
assert_eq!(next_zoom_step(105, true), 110);
// 230 is between 225 and 250; up → 250.
assert_eq!(next_zoom_step(230, true), 250);
}

#[test]
fn step_down_from_non_values_snaps_to_next_lower() {
// 105 → down → 100.
assert_eq!(next_zoom_step(105, false), 100);
// 230 → down → 225.
assert_eq!(next_zoom_step(230, false), 225);
}

#[test]
fn step_up_at_max_stays_at_max() {
let max = *ZoomLevel::VALUES.last().unwrap();
assert_eq!(next_zoom_step(max, true), max);
}

#[test]
fn step_down_at_min_stays_at_min() {
let min = *ZoomLevel::VALUES.first().unwrap();
assert_eq!(next_zoom_step(min, false), min);
}

#[test]
fn step_up_above_max_stays_at_max() {
// current_percent above all VALUES: no entry > current → stays at max.
assert_eq!(next_zoom_step(500, true), *ZoomLevel::VALUES.last().unwrap());
}

#[test]
fn step_down_below_min_stays_at_min() {
// current_percent below all VALUES: no entry < current → stays at min.
assert_eq!(next_zoom_step(10, false), *ZoomLevel::VALUES.first().unwrap());
}
}

impl Workspace {
pub fn is_tab_drag_preview(&self) -> bool {
self.is_tab_drag_preview
Expand Down Expand Up @@ -11987,7 +12075,7 @@ impl Workspace {

/// Updates the titlebar height to match the scaled tab bar height.
pub fn update_titlebar_height(&self, ctx: &mut ViewContext<Self>) {
let zoom_factor = WindowSettings::as_ref(ctx).zoom_level.as_zoom_factor();
let zoom_factor = ctx.window_zoom_factor(ctx.window_id()).as_f32();
let scaled_tab_bar_height = (TOTAL_TAB_BAR_HEIGHT * zoom_factor) as f64;

if let Some(platform_window) = ctx.windows().platform_window(ctx.window_id()) {
Expand Down Expand Up @@ -15550,33 +15638,36 @@ impl Workspace {
}

fn reset_zoom(&mut self, ctx: &mut ViewContext<Self>) {
WindowSettings::handle(ctx).update(ctx, |window_settings, ctx| {
report_if_error!(window_settings
.zoom_level
.set_value(ZoomLevel::default_value(), ctx));
});
// Cmd+0: clear the per-window zoom override so this window follows
// the app-wide default again. The native macOS titlebar height also
// needs to be recomputed for this window because we no longer route
// through `WindowSettingsChangedEvent::ZoomLevel`.
let window_id = ctx.window_id();
ctx.reset_window_zoom_factor(window_id);
self.update_titlebar_height(ctx);
}

fn adjust_zoom(&mut self, increase: bool, ctx: &mut ViewContext<Self>) {
let current_zoom = *WindowSettings::as_ref(ctx).zoom_level.value();
let Some(current_index) = crate::window_settings::ZoomLevel::VALUES
.iter()
.position(|zoom| *zoom == current_zoom)
else {
return;
};

let next_index = if increase {
(current_index + 1).min(crate::window_settings::ZoomLevel::VALUES.len() - 1)
} else {
current_index.saturating_sub(1)
};

WindowSettings::handle(ctx).update(ctx, |window_settings, ctx| {
report_if_error!(window_settings
.zoom_level
.set_value(crate::window_settings::ZoomLevel::VALUES[next_index], ctx));
});
// Cmd++ / Cmd+-: step the focused window's zoom up or down within
// the discrete percentages defined by `ZoomLevel::VALUES`. Writes a
// per-window override; other windows are unaffected. The native macOS
// titlebar height is recomputed explicitly because Cmd++/Cmd+- no
// longer mutates `WindowSettings::zoom_level` (and therefore does not
// fire `WindowSettingsChangedEvent::ZoomLevel`).
//
// The current effective zoom is not guaranteed to be one of the
// discrete `ZoomLevel::VALUES` entries — e.g. a caller could set the
// app-wide default to a value that does not appear in VALUES — so we
// snap to the nearest VALUES entry in the chosen direction rather
// than indexing into VALUES directly. This keeps Cmd++/Cmd+- usable
// from any in-range starting factor.
let window_id = ctx.window_id();
let current_factor = ctx.window_zoom_factor(window_id);
let current_percent = (current_factor.as_f32() * 100.0).round() as u16;
let next_percent = next_zoom_step(current_percent, increase);
let next_factor = next_percent as f32 / 100.0;
ctx.set_window_zoom_factor(window_id, next_factor);
self.update_titlebar_height(ctx);
}

fn adjust_terminal_font_size(&mut self, font_size_delta: f32, ctx: &mut ViewContext<Self>) {
Expand Down Expand Up @@ -17489,7 +17580,7 @@ impl Workspace {
}
}

let zoom_factor = WindowSettings::as_ref(ctx).zoom_level.as_zoom_factor();
let zoom_factor = ctx.window_zoom_factor(self.window_id).as_f32();
let traffic_light_data = traffic_light_data(ctx, self.window_id);
if let Some(traffic_light_data) = traffic_light_data.as_ref() {
let vertical_tabs_active = FeatureFlag::VerticalTabs.is_enabled()
Expand All @@ -17511,7 +17602,7 @@ impl Workspace {
}

fn compute_tab_bar_left_padding(&self, ctx: &AppContext) -> f32 {
let zoom_factor = WindowSettings::as_ref(ctx).zoom_level.as_zoom_factor();
let zoom_factor = ctx.window_zoom_factor(self.window_id).as_f32();
let traffic_light_data = traffic_light_data(ctx, self.window_id);
let is_window_fullscreen = ctx
.windows()
Expand Down
51 changes: 47 additions & 4 deletions crates/warpui_core/src/core/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1178,19 +1178,62 @@ impl AppContext {
self.event_munger = Box::new(handler)
}

/// Sets the zoom factor for the application. Changing the zoom factor adjusts
/// the magnification of every element rendered within the application.
/// Sets the app-wide default zoom factor. Per-window overrides set via
/// [`AppContext::set_window_zoom_factor`] are not affected; windows that
/// have no override will follow this new default.
///
/// All views in every window are invalidated when this is invoked.
///
/// ## Validation
/// The zoom factor is clamped to the range [0.5, 4.0].
/// The zoom factor is clamped to the range [0.5, 3.5], matching
/// `WindowSettings::ZoomLevel::VALUES` (50%–350%). This range is shared
/// with [`AppContext::set_window_zoom_factor`] so that
/// `Workspace::adjust_zoom`'s discrete-step lookup can always find the
/// effective zoom in the VALUES table; values outside the range would
/// dead-end the keyboard shortcut until `reset_window_zoom_factor` is
/// called.
pub fn set_zoom_factor(&mut self, zoom_factor: f32) {
let zoom_factor = ZoomFactor::new(zoom_factor.clamp(0.5, 4.0));
let zoom_factor = ZoomFactor::new(zoom_factor.clamp(0.5, 3.5));
self.zoom_factor = zoom_factor;
self.invalidate_all_views();
}

/// Returns the effective zoom factor for `window_id`: the per-window
/// override if set, otherwise the app-wide default. Falls back to the
/// default if `window_id` is not found.
pub fn window_zoom_factor(&self, window_id: WindowId) -> ZoomFactor {
self.windows
.get(&window_id)
.and_then(|w| w.zoom_factor_override)
.unwrap_or(self.zoom_factor)
}

/// Sets a per-window zoom factor override. Only invalidates the views of
/// `window_id`; other windows are unaffected.
///
/// ## Validation
/// The zoom factor is clamped to the range [0.5, 3.5], aligning with the
/// discrete percentages exposed by `WindowSettings::ZoomLevel::VALUES`
/// (50%–350%). Values outside this range are not reachable through the
/// keyboard shortcut, and would leave `adjust_zoom`'s position lookup
/// unable to step until `reset_window_zoom_factor` clears the override.
pub fn set_window_zoom_factor(&mut self, window_id: WindowId, zoom_factor: f32) {
let zoom_factor = ZoomFactor::new(zoom_factor.clamp(0.5, 3.5));
if let Some(window) = self.windows.get_mut(&window_id) {
window.zoom_factor_override = Some(zoom_factor);
self.invalidate_all_views_for_window(window_id);
}
}

/// Clears the per-window zoom factor override. The window will follow the
/// app-wide default returned by [`AppContext::zoom_factor`].
pub fn reset_window_zoom_factor(&mut self, window_id: WindowId) {
if let Some(window) = self.windows.get_mut(&window_id) {
window.zoom_factor_override = None;
self.invalidate_all_views_for_window(window_id);
}
}

/// Sets the callback invoked before opening a URL.
pub fn set_before_open_url<F>(&mut self, handler: F)
where
Expand Down
4 changes: 4 additions & 0 deletions crates/warpui_core/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -664,3 +664,7 @@ impl<T> RequestState<T> {
#[cfg(test)]
#[path = "mod_test.rs"]
mod tests;

#[cfg(test)]
#[path = "zoom_test.rs"]
mod zoom_tests;
6 changes: 5 additions & 1 deletion crates/warpui_core/src/core/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::{

use serde::{Deserialize, Serialize};

use crate::{core::view::AnyViewHandle, AnyView, EntityId};
use crate::{core::view::AnyViewHandle, AnyView, EntityId, ZoomFactor};

/// A unique identifier for a window.
///
Expand Down Expand Up @@ -47,4 +47,8 @@ pub(super) struct Window {

/// The ID of the currently focused view, if any.
pub focused_view: Option<EntityId>,

/// Per-window zoom factor override. `None` means the window follows the
/// app-wide default returned by [`AppContext::zoom_factor`].
pub zoom_factor_override: Option<ZoomFactor>,
}
Loading