diff --git a/Cargo.lock b/Cargo.lock index ebe7663a14..9e22df11e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1561,7 +1561,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "gpui_util", "indexmap", @@ -2208,7 +2208,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "proc-macro2", "quote", @@ -3391,7 +3391,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "accesskit", "anyhow", @@ -3507,7 +3507,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "accesskit", "accesskit_unix", @@ -3559,7 +3559,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "accesskit", "accesskit_macos", @@ -3608,7 +3608,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3619,7 +3619,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "console_error_panic_hook", "gpui", @@ -3632,7 +3632,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "schemars", "serde", @@ -3642,7 +3642,7 @@ dependencies = [ [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "anyhow", "gpui", @@ -3653,7 +3653,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "anyhow", "log", @@ -3663,7 +3663,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3687,7 +3687,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "anyhow", "bytemuck", @@ -3716,7 +3716,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "accesskit", "accesskit_windows", @@ -4048,7 +4048,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "anyhow", "async-compression", @@ -5221,7 +5221,7 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "anyhow", "bindgen", @@ -6364,7 +6364,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "collections", "serde", @@ -7443,7 +7443,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "derive_refineable", ] @@ -8129,7 +8129,7 @@ checksum = "c62751faa8bc286982334a082fe125184a29fc89d17775766e4f891b7d726980" [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "async-task", "backtrace", @@ -8914,7 +8914,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "heapless 0.9.3", "log", @@ -10331,7 +10331,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/katulevskiy/zui?rev=fd28a46632f8dc1a6c430f694ce970cd3f0a3407#fd28a46632f8dc1a6c430f694ce970cd3f0a3407" dependencies = [ "perf", "quote", diff --git a/Cargo.toml b/Cargo.toml index 9f66383825..4ceb948410 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -172,3 +172,12 @@ inherits = "release" lto = "thin" codegen-units = 1 strip = "debuginfo" + +# Whole-window scaling patch; also applies to gpui-base's GPUI dependency so +# the application and component layer share one GPUI type universe. +# Remove after the patch is merged and the upstream revision is updated. +[patch."https://github.com/zeronsh/zui"] +gpui = { git = "https://github.com/katulevskiy/zui", rev = "fd28a46632f8dc1a6c430f694ce970cd3f0a3407" } +gpui_platform = { git = "https://github.com/katulevskiy/zui", rev = "fd28a46632f8dc1a6c430f694ce970cd3f0a3407" } +gpui_tokio = { git = "https://github.com/katulevskiy/zui", rev = "fd28a46632f8dc1a6c430f694ce970cd3f0a3407" } +gpui_macros = { git = "https://github.com/katulevskiy/zui", rev = "fd28a46632f8dc1a6c430f694ce970cd3f0a3407" } diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 60bd67d758..14e6b9901e 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -12,6 +12,7 @@ browser-fixture = [] windows-render-fixture = [] appshots-fixture = ["gpui_platform/test-support"] resource-profile = ["gpui/bench", "gpui_platform/test-support"] +ui-scale-fixture = ["gpui/bench", "gpui_platform/test-support"] [[example]] name = "macos-resource-profile" @@ -33,6 +34,10 @@ required-features = ["windows-render-fixture"] name = "appshots-fixture" required-features = ["appshots-fixture"] +[[example]] +name = "ui-scale-fixture" +required-features = ["ui-scale-fixture"] + [[example]] name = "new-project-fixture" required-features = ["project-palette-fixture"] diff --git a/crates/ui/examples/ui-scale-fixture.rs b/crates/ui/examples/ui-scale-fixture.rs new file mode 100644 index 0000000000..6939310c51 --- /dev/null +++ b/crates/ui/examples/ui-scale-fixture.rs @@ -0,0 +1,199 @@ +//! Native UI-scale evidence from an isolated profile; no engine or model calls. +//! Run with ZERON_OPEN_ROUTE=settings/appearance. +use gpui::{AppContext, AsyncApp, Bounds, WindowBounds, WindowOptions, px, size}; +use std::{path::PathBuf, time::Duration}; +use zeron_ui::*; + +async fn pause(cx: &mut AsyncApp) { + cx.background_executor() + .timer(Duration::from_millis(300)) + .await; +} + +fn press(handle: gpui::AnyWindowHandle, cx: &mut AsyncApp, combo: &str) -> anyhow::Result<()> { + handle.update(cx, |_, window, cx| { + window.draw(cx).clear(); + window.present_if_needed(); + let keystroke = gpui::Keystroke::parse(combo).unwrap(); + window.dispatch_event( + gpui::PlatformInput::KeyDown(gpui::KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + prefer_character_input: false, + }), + cx, + ); + window.dispatch_event( + gpui::PlatformInput::KeyUp(gpui::KeyUpEvent { keystroke }), + cx, + ); + })?; + Ok(()) +} + +fn capture( + handle: gpui::AnyWindowHandle, + cx: &mut AsyncApp, + output: &std::path::Path, + name: &str, + expected: u16, +) -> anyhow::Result<()> { + handle.update(cx, |_, window, cx| { + assert_eq!(settings::current(cx).ui_scale_percent, expected); + assert_eq!(window.ui_scale(), ui_scale::factor(expected)); + window.draw(cx).clear(); + window.present_if_needed(); + let path = output.join(format!("{name}.png")); + #[cfg(target_os = "linux")] + { + anyhow::ensure!( + std::env::var_os("WAYLAND_DISPLAY").is_none(), + "Linux capture needs an isolated X11 display with WAYLAND_DISPLAY unset" + ); + let status = std::process::Command::new("import") + .args(["-silent", "-window", "UI scale fixture"]) + .arg(path) + .status()?; + anyhow::ensure!(status.success(), "native X11 window capture failed"); + } + #[cfg(not(target_os = "linux"))] + window.render_to_image()?.save(path)?; + Ok(()) + })? +} + +fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt().with_env_filter("warn").init(); + let output = PathBuf::from(std::env::args().nth(1).expect("output directory")); + std::fs::create_dir_all(&output)?; + let temp = tempfile::tempdir()?; + let data = temp.path().to_path_buf(); + let runtime = tokio::runtime::Runtime::new()?; + let _guard = runtime.enter(); + gpui_platform::application() + .with_assets(icons::Assets) + .run(move |cx| { + gpui_tokio::init(cx); + gpui_base::init(cx); + let mut saved = settings::UiSettings::default(); + saved.reduce_motion = motion::ReduceMotion::On; + saved.appearance = appearance::AppearanceMode::Dark; + settings::init(saved.clone(), data.clone(), cx); + let fonts = typography::register_fonts(cx); + typography::init( + saved.ui_font_family.clone(), + saved.ui_font_size, + saved.terminal_font_family.clone(), + saved.terminal_font_size, + saved.code_font_family.clone(), + saved.code_font_size, + fonts, + cx, + ); + theme_library::init(data.clone(), cx); + appearance::init( + saved.appearance, + saved.theme_selection, + saved.accent, + saved.surface, + cx, + ); + history::init( + saved.git_history_columns, + saved.git_history_column_widths, + saved.git_history_column_order, + saved.git_history_author_display, + cx, + ); + motion::init( + saved.reduce_motion, + saved.pause_animations_in_background, + cx, + ); + composer::init(cx, saved.composer_send_behavior); + terminal::panel::init(cx); + app_menus::init(cx); + let state = cx.new(|_| { + let mut state = state::AppState::new(); + state.connection = zeron_proto::view::ConnectionStatus::Ready; + state.workspace_scope = Some(zeron_proto::WorkspaceScope::Local); + state.local_device_id = Some("fixture".into()); + state.auto_selected = true; + state.chats_synced = true; + state.spaces_synced = true; + state + }); + let boot = EngineBootConfig { + data_dir: data.clone(), + ipc_port: 0, + edge_url: String::new(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: HarnessId::Mock, + }; + let handle = cx + .open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::new( + gpui::point(px(20.), px(20.)), + size(px(1440.), px(1080.)), + ))), + window_min_size: Some(size(px(900.), px(600.))), + window_decorations: cfg!(target_os = "linux") + .then_some(gpui::WindowDecorations::Client), + app_owns_titlebar_drag: true, + window_background: theme::Theme::of(cx).window_background_appearance(), + app_id: Some("zeron-ui-scale-fixture".into()), + titlebar: Some(gpui::TitlebarOptions { + title: Some("UI scale fixture".into()), + appears_transparent: true, + ..Default::default() + }), + ..Default::default() + }, + |window, cx| { + window.set_rem_size(px(typography::font_size(cx).pixels())); + cx.new(|cx| shell::Shell::new(state, boot, cx)) + }, + ) + .unwrap(); + cx.activate(true); + cx.spawn(async move |cx| { + let result: anyhow::Result<()> = async { + pause(cx).await; + capture(handle.into(), cx, &output, "appearance-100", 100)?; + for _ in 0..5 { + press(handle.into(), cx, "ctrl-shift-+")?; + } + pause(cx).await; + capture(handle.into(), cx, &output, "appearance-150", 150)?; + assert_eq!(settings::UiSettings::load(&data).ui_scale_percent, 150); + // Native bounds updates must preserve the selected zoom. + handle.update(cx, |_, window, cx| window.bounds_changed(cx))?; + capture( + handle.into(), + cx, + &output, + "appearance-150-after-resize", + 150, + )?; + press(handle.into(), cx, "ctrl-shift-0")?; + pause(cx).await; + capture(handle.into(), cx, &output, "appearance-reset-100", 100)?; + handle.update(cx, |_, window, cx| { + appearance::set_mode(appearance::AppearanceMode::Light, cx); + ui_scale::set(120, window, cx); + })?; + pause(cx).await; + capture(handle.into(), cx, &output, "appearance-light-120", 120)?; + Ok(()) + } + .await; + result.expect("UI scale fixture failed"); + cx.update(|cx| cx.quit()); + }) + .detach(); + }); + Ok(()) +} diff --git a/crates/ui/src/browser/mod.rs b/crates/ui/src/browser/mod.rs index 6b4cb526a8..92ac1d882e 100644 --- a/crates/ui/src/browser/mod.rs +++ b/crates/ui/src/browser/mod.rs @@ -26,6 +26,7 @@ pub(crate) fn bind_keys(cx: &mut App, keymap: &crate::settings::KeymapConfig) { // already assign mod-shift-r to the right pane. let available = |combo: &str, own: Option| { !combo.is_empty() + && !crate::ui_scale::is_reserved_combo(combo) && !ShortcutId::ALL.iter().any(|id| { Some(*id) != own && gpui::Keystroke::parse(&platform_combo(keymap.get(*id))).ok() @@ -200,6 +201,11 @@ impl BrowserSurface { .iter() .filter(|id| **id != crate::settings::ShortcutId::SaveFile) .map(|id| crate::settings::platform_combo(keymap.get(*id))) + .chain( + crate::ui_scale::combos() + .into_iter() + .map(|(combo, _)| combo.to_owned()), + ) .collect(), ); } diff --git a/crates/ui/src/browser/view.rs b/crates/ui/src/browser/view.rs index 47d268754b..842a47c830 100644 --- a/crates/ui/src/browser/view.rs +++ b/crates/ui/src/browser/view.rs @@ -534,6 +534,9 @@ impl Render for BrowserSurface { let right = (window.viewport_size().width - right_occlusion).max(mask.left()); mask.size.width = mask.size.width.min(right - mask.left()); + let bounds = window.ui_to_platform_bounds(bounds); + let mask = window.ui_to_platform_bounds(mask); + let resize_inset = resize_inset * window.ui_scale(); let dragging = cx.has_active_drag(); window.on_present(move || { if let Some(native) = native.upgrade() { diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 79a33d9886..d9e2eedaa7 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -61,6 +61,7 @@ pub mod theme; pub mod theme_library; pub mod transcript; pub mod typography; +pub mod ui_scale; mod workspace_links; use std::path::PathBuf; @@ -420,6 +421,7 @@ fn open_main_window( ..Default::default() }, move |window, cx| { + ui_scale::apply_to_window(settings::current(cx).ui_scale_percent, window); window.set_rem_size(px(typography::font_size(cx).pixels())); // React to the user flipping macOS between light and dark. Detached: // the subscription lives as long as the window does, and the window diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index e142454e63..7a97c77ee3 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -896,6 +896,8 @@ pub struct UiSettings { pub ui_font_family: crate::typography::UiFontFamily, /// Base size for interface and conversational prose. pub ui_font_size: crate::typography::UiFontSize, + /// Whole-interface zoom, independent of fonts and system display density. + pub ui_scale_percent: u16, /// Terminal family and absolute pixel size. Only fixed-width families /// qualify, including compatible Nerd Fonts. pub terminal_font_family: crate::typography::UiFontFamily, @@ -1008,6 +1010,7 @@ impl Default for UiSettings { git_history_author_display: GitHistoryAuthorDisplay::default(), ui_font_family: crate::typography::UiFontFamily::default(), ui_font_size: crate::typography::UiFontSize::default(), + ui_scale_percent: crate::ui_scale::DEFAULT_PERCENT, terminal_font_family: crate::typography::UiFontFamily::GeistMono, terminal_font_size: crate::typography::TERMINAL_FONT_SIZE_DEFAULT, code_font_family: crate::typography::UiFontFamily::GeistMono, @@ -1615,11 +1618,17 @@ impl UiSettings { self.git_history_column_widths = self.git_history_column_widths.clamped(); self.git_history_column_order = self.git_history_column_order.normalized(); self.ui_font_size = self.ui_font_size.normalized(); + self.ui_scale_percent = crate::ui_scale::clamp(self.ui_scale_percent); if let Some(background) = self.new_thread_composer_background.as_mut() { background.adjustment = background.adjustment.normalized(); } self.keymap.heal_jump_slots(); self.keymap.heal_reserved_composer_shortcuts(); + for id in ShortcutId::ALL { + if crate::ui_scale::is_reserved_combo(self.keymap.get(id)) { + self.keymap.reset(id); + } + } self } @@ -2563,6 +2572,7 @@ mod tests { git_history_author_display: GitHistoryAuthorDisplay::Name, ui_font_family: crate::typography::UiFontFamily::Installed("Arial".into()), ui_font_size: crate::typography::UiFontSize::ALL[5], + ui_scale_percent: 150, theme_selection: zeron_theme::ThemeSelection { light: "catppuccin-latte".into(), dark: "catppuccin-mocha".into(), diff --git a/crates/ui/src/settings/appearance.rs b/crates/ui/src/settings/appearance.rs index 3fc10efcce..08eb95fea7 100644 --- a/crates/ui/src/settings/appearance.rs +++ b/crates/ui/src/settings/appearance.rs @@ -3912,6 +3912,91 @@ impl Render for AppearancePage { let terminal_size = self.render_size_picker(FontKind::Terminal, &theme, fixed.clone(), cx); let code_size = self.render_size_picker(FontKind::Code, &theme, fixed.clone(), cx); + let scale = crate::settings::current(cx).ui_scale_percent; + let mut scale_controls = div().flex().items_center().gap(px(6.0)); + for (id, label, target, enabled) in [ + ( + "ui-scale-decrease", + "−", + crate::ui_scale::stepped(scale, -1), + scale > crate::ui_scale::MIN_PERCENT, + ), + ( + "ui-scale-increase", + "+", + crate::ui_scale::stepped(scale, 1), + scale < crate::ui_scale::MAX_PERCENT, + ), + ( + "ui-scale-reset", + "Reset", + crate::ui_scale::DEFAULT_PERCENT, + scale != crate::ui_scale::DEFAULT_PERCENT, + ), + ] { + if id == "ui-scale-increase" { + scale_controls = scale_controls.child( + div() + .id("ui-scale-value") + .min_w(px(48.0)) + .text_center() + .text_size(crate::typography::ui_rems(13.0)) + .child(format!("{scale}%")), + ); + } + scale_controls = scale_controls.child( + widgets::text_action(&theme, widgets::ActionTone::Outlined, label) + .id(id) + .debug_selector(move || id.into()) + .aria_label(match id { + "ui-scale-decrease" => "Decrease UI scale", + "ui-scale-increase" => "Increase UI scale", + _ => "Reset UI scale to 100%", + }) + .min_w(px(32.0)) + .justify_center() + .when(!enabled, |button| button.opacity(0.4).cursor_default()) + .when(enabled, |button| { + button + .tab_index(0) + .role(gpui::Role::Button) + .focus_visible(|s| s.border_2().border_color(theme.accent)) + .on_click(cx.listener(move |_, _, window, cx| { + crate::ui_scale::set(target, window, cx); + })) + }), + ); + } + let scale_card = widgets::section_card(&theme) + .mt_0() + .font_family(fixed.clone()) + .child( + widgets::card_row(&theme, true) + .justify_between() + .child( + div() + .flex_1() + .child(widgets::row_title(&theme, "UI scale")) + .child(widgets::meta_line( + &theme, + vec![ + div() + .child("Resize the entire interface · 50–200%") + .into_any_element(), + ], + )) + .child(widgets::meta_line( + &theme, + vec![ + div() + .child("Ctrl+Shift+Plus / Minus · Ctrl+Shift+0 to reset") + .into_any_element(), + ], + )), + ) + .child(scale_controls), + ); + let scale_section = widgets::section(&theme, "Interface scale", scale_card); let mut font_rows = widgets::section_card(&theme) .mt_0() .font_family(fixed.clone()); @@ -4036,6 +4121,7 @@ impl Render for AppearancePage { .mt(px(24.0)) .gap(px(12.0)), ) + .child(scale_section) .child( widgets::section_card(&theme) .children(color_rows) diff --git a/crates/ui/src/settings/shortcuts.rs b/crates/ui/src/settings/shortcuts.rs index e64a47a486..6cfa2c8059 100644 --- a/crates/ui/src/settings/shortcuts.rs +++ b/crates/ui/src/settings/shortcuts.rs @@ -249,6 +249,15 @@ impl ShortcutsPage { cx.stop_propagation(); return; } + if crate::ui_scale::is_reserved_combo(&combo) { + self.conflict_notice = Some( + format!("{} is reserved for UI scaling.", display_combo(&combo)).into(), + ); + self.stop_recording(); + cx.notify(); + cx.stop_propagation(); + return; + } if send_combo_is_reserved(self.composer_send_behavior, &combo) { self.conflict_notice = Some( format!("{} is reserved for the composer.", display_combo(&combo)).into(), diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 46b26e3024..867536c9b3 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -404,10 +404,10 @@ pub fn apply_keymap( ) { fn valid_or_default(combo: &str, fallback: &str) -> String { let candidate = platform_combo(combo); - if Keystroke::parse(&candidate).is_ok() { + if Keystroke::parse(&candidate).is_ok() && !crate::ui_scale::is_reserved_combo(combo) { candidate } else { - tracing::warn!(%combo, "unparseable shortcut combo; using default"); + tracing::warn!(%combo, "invalid or reserved shortcut combo; using default"); platform_combo(fallback) } } @@ -422,6 +422,7 @@ pub fn apply_keymap( // close, ⌘M minimize, ⌘H hide on macOS) — these back the native menu // key equivalents and must survive keymap re-application. crate::app_menus::bind_keys(cx); + crate::ui_scale::bind_keys(cx); cx.bind_keys([ KeyBinding::new( &valid_or_default(&keymap.random_wallpaper, "mod-u"), @@ -4478,6 +4479,7 @@ impl Shell { self.settings.open_web_links_in_zeron = current.open_web_links_in_zeron; self.settings.ui_font_family = current.ui_font_family; self.settings.ui_font_size = current.ui_font_size; + self.settings.ui_scale_percent = current.ui_scale_percent; self.settings.terminal_font_family = current.terminal_font_family; self.settings.terminal_font_size = current.terminal_font_size; self.settings.code_font_family = current.code_font_family; @@ -6111,6 +6113,11 @@ impl Shell { // The tween runs in cluster-start coordinates; the spacer is that // minus the container's own padding. let start = self.eval_tween(self.titlebar_tween, titlebar_cluster_start(fullscreen)); + let start = if fullscreen { + start + } else { + start / crate::ui_scale::factor(self.settings.ui_scale_percent) + }; let width = (start - container_pad).max(0.0); Some(div().flex_none().h_full().w(px(width)).into_any_element()) } @@ -6131,6 +6138,11 @@ impl Shell { self.titlebar_tween, cluster_buttons_start(is_macos, fullscreen, self.linux_left_caption_count()), ); + let cluster = if is_macos && !fullscreen { + cluster / crate::ui_scale::factor(self.settings.ui_scale_percent) + } else { + cluster + }; cluster + CLUSTER_BUTTONS_WIDTH + TITLEBAR_IDENTITY_GAP } @@ -12436,6 +12448,15 @@ impl Render for Shell { // no-ops (zeron __root.tsx gates the hotkey on `!isSettings`, and // the terminal panel is only mounted on session routes). The // sidebar toggle stays live everywhere, as in the original. + .on_action(cx.listener(|_, _: &crate::ui_scale::Increase, window, cx| { + crate::ui_scale::step(1, window, cx); + })) + .on_action(cx.listener(|_, _: &crate::ui_scale::Decrease, window, cx| { + crate::ui_scale::step(-1, window, cx); + })) + .on_action(cx.listener(|_, _: &crate::ui_scale::Reset, window, cx| { + crate::ui_scale::set(crate::ui_scale::DEFAULT_PERCENT, window, cx); + })) .on_action(cx.listener(|this, _: &ToggleTerminal, window, cx| { if matches!(this.route, Route::Chat) { this.toggle_terminal(window, cx) @@ -16412,6 +16433,112 @@ mod settings_modal_regressions { assert_eq!(press(&keymap.new_session), (Route::Chat, None)); } + #[gpui::test] + fn ui_scale_appearance_buttons_use_scaled_hit_testing(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + init_settings_test(settings::UiSettings::default(), dir.path(), cx); + let (shell, cx) = cx.add_window_view(|_, cx| { + let mut shell = test_shell(dir.path(), cx); + shell.debug_gate = Some(GatePhase::Ready); + shell.splash = SplashPhase::Gone; + shell + }); + shell.update(cx, |shell, cx| { + shell.open_settings(SettingsSection::Appearance, cx) + }); + for (selector, expected) in [ + ("ui-scale-increase", 110), + ("ui-scale-increase", 120), + ("ui-scale-decrease", 110), + ("ui-scale-reset", 100), + ] { + cx.update(|window, cx| window.draw(cx).clear()); + let center = cx + .debug_bounds(selector) + .expect("scale control visible") + .center(); + let position = cx.update(|window, _| center * window.ui_scale()); + cx.simulate_click(position, gpui::Modifiers::default()); + cx.update(|window, cx| { + assert_eq!( + settings::current(cx).ui_scale_percent, + expected, + "{selector}" + ); + assert_eq!(window.ui_scale(), crate::ui_scale::factor(expected)); + }); + } + } + + #[gpui::test] + fn ui_scale_shortcuts_work_from_settings_and_preserve_independent_settings( + cx: &mut TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + init_settings_test(settings::UiSettings::default(), dir.path(), cx); + let window = cx.add_window(|_, cx| { + let mut shell = test_shell(dir.path(), cx); + shell.debug_gate = Some(GatePhase::Ready); + shell.splash = SplashPhase::Gone; + shell + }); + window + .update(cx, |shell, _, cx| { + shell.open_settings(SettingsSection::Appearance, cx) + }) + .unwrap(); + cx.run_until_parked(); + for (combo, expected) in [ + ("ctrl-shift-+", 110), + ("ctrl-shift-=", 120), + ("ctrl-shift--", 110), + ("ctrl-shift-_", 100), + ("ctrl-shift-+", 110), + ] { + cx.simulate_keystrokes(window.into(), combo); + window + .update(cx, |shell, window, cx| { + assert_eq!(settings::current(cx).ui_scale_percent, expected, "{combo}"); + assert_eq!(window.ui_scale(), crate::ui_scale::factor(expected)); + assert_eq!(shell.route, Route::Settings(SettingsSection::Appearance)); + // A later pane save must not restore the shell's stale scale. + shell.settings.sidebar_width = 300.0; + shell.schedule_save(cx); + assert_eq!(settings::current(cx).ui_scale_percent, expected); + }) + .unwrap(); + } + cx.update(|cx| { + apply_keymap( + cx, + &KeymapConfig::default(), + ComposerSendBehavior::default(), + ) + }); + cx.simulate_keystrokes(window.into(), "ctrl-shift-0"); + window + .update(cx, |_, window, cx| { + assert_eq!(window.ui_scale(), 1.0); + assert_eq!(settings::current(cx).ui_scale_percent, 100); + settings::flush(cx); + }) + .unwrap(); + window + .update(cx, |shell, _, cx| shell.close_settings(cx)) + .unwrap(); + cx.run_until_parked(); + window + .update(cx, |shell, window, cx| { + window.focus(&shell.composer.focus_handle(cx), cx); + }) + .unwrap(); + cx.simulate_keystrokes(window.into(), "ctrl-shift-+"); + assert_eq!(cx.read(|cx| settings::current(cx).ui_scale_percent), 110); + cx.simulate_keystrokes(window.into(), "ctrl-shift-0"); + assert_eq!(UiSettings::load(dir.path()).ui_scale_percent, 100); + assert_eq!(UiSettings::load(dir.path()).sidebar_width, 300.0); + } + #[gpui::test] fn settings_reopen_where_they_were_left(cx: &mut TestAppContext) { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ui/src/ui_scale.rs b/crates/ui/src/ui_scale.rs new file mode 100644 index 0000000000..8bf629b11e --- /dev/null +++ b/crates/ui/src/ui_scale.rs @@ -0,0 +1,211 @@ +//! Device-local, whole-window zoom. Font preferences remain independent. +use gpui::{App, KeyBinding, Keystroke, Window, actions}; + +use crate::settings::{self, SavePolicy}; + +pub const MIN_PERCENT: u16 = 50; +pub const MAX_PERCENT: u16 = 200; +pub const DEFAULT_PERCENT: u16 = 100; +pub const STEP_PERCENT: u16 = 10; + +actions!(ui_scale, [Increase, Decrease, Reset]); + +pub fn clamp(percent: u16) -> u16 { + percent.clamp(MIN_PERCENT, MAX_PERCENT) +} + +pub fn factor(percent: u16) -> f32 { + f32::from(clamp(percent)) / 100.0 +} + +pub fn stepped(percent: u16, direction: i16) -> u16 { + (i32::from(clamp(percent)) + i32::from(direction) * i32::from(STEP_PERCENT)) + .clamp(i32::from(MIN_PERCENT), i32::from(MAX_PERCENT)) as u16 +} + +pub fn apply_to_window(percent: u16, window: &mut Window) { + let scale = factor(percent); + window.set_ui_scale(scale); + // AppKit keeps the traffic lights at native size. Align their centers + // with the scaled titlebar controls, including when the UI shrinks. + #[cfg(target_os = "macos")] + window.set_traffic_light_position(gpui::point(gpui::px(14.0), gpui::px(21.0 * scale - 7.0))); +} + +pub fn set(percent: u16, window: &mut Window, cx: &mut App) { + let percent = clamp(percent); + settings::update(SavePolicy::Immediate, cx, |settings| { + settings.ui_scale_percent = percent; + }); + apply_to_window(percent, window); + // The active window is already borrowed. Update other open windows without + // trying to reenter it, so they share the same device-local preference. + for handle in cx.windows() { + if handle != window.window_handle() { + let _ = handle.update(cx, |_, window, _| apply_to_window(percent, window)); + } + } + cx.refresh_windows(); +} + +pub fn step(direction: i16, window: &mut Window, cx: &mut App) { + set( + stepped(settings::current(cx).ui_scale_percent, direction), + window, + cx, + ); +} + +// Backends can report either the physical punctuation key or its shifted +// character. Accept both, and preserve the requested Ctrl chords on macOS +// alongside the usual Command equivalents. +pub(crate) fn combos() -> Vec<(&'static str, u8)> { + let mut combos = vec![ + ("ctrl-shift-=", 0), + ("ctrl-shift-+", 0), + ("ctrl-shift--", 1), + ("ctrl-shift-_", 1), + ("ctrl-shift-0", 2), + ("ctrl-shift-)", 2), + ]; + if cfg!(target_os = "macos") { + combos.extend([ + ("cmd-shift-=", 0), + ("cmd-shift-+", 0), + ("cmd-shift--", 1), + ("cmd-shift-_", 1), + ("cmd-shift-0", 2), + ("cmd-shift-)", 2), + ]); + } + combos +} + +pub fn bind_keys(cx: &mut App) { + cx.bind_keys(combos().into_iter().map(|(combo, action)| match action { + 0 => KeyBinding::new(combo, Increase, None), + 1 => KeyBinding::new(combo, Decrease, None), + _ => KeyBinding::new(combo, Reset, None), + })); +} + +pub fn is_reserved_combo(combo: &str) -> bool { + let Ok(candidate) = Keystroke::parse(&settings::platform_combo(combo)) else { + return false; + }; + combos() + .into_iter() + .any(|(combo, _)| Keystroke::parse(combo).is_ok_and(|key| key == candidate)) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct ScaleProbe { + clicks: usize, + scroll: gpui::Point, + } + + impl gpui::Render for ScaleProbe { + fn render( + &mut self, + _: &mut Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + use gpui::prelude::*; + gpui::div().size_full().child( + gpui::div() + .absolute() + .left(gpui::px(20.)) + .top(gpui::px(20.)) + .w(gpui::px(100.)) + .h(gpui::px(80.)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, _| this.clicks += 1), + ) + .on_scroll_wheel(cx.listener(|this, event: &gpui::ScrollWheelEvent, _, _| { + this.scroll = event.delta.pixel_delta(gpui::px(16.)); + })), + ) + } + } + + #[gpui::test] + fn ui_scale_native_hit_testing_and_scroll_stay_aligned(cx: &mut gpui::TestAppContext) { + let (view, cx) = cx.add_window_view(|window, _| { + window.set_ui_scale(1.5); + ScaleProbe { + clicks: 0, + scroll: Default::default(), + } + }); + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: gpui::point(gpui::px(150.), gpui::px(90.)), + ..Default::default() + }); + assert_eq!(view.read_with(cx, |view, _| view.clicks), 1); + cx.simulate_event(gpui::ScrollWheelEvent { + position: gpui::point(gpui::px(150.), gpui::px(90.)), + delta: gpui::ScrollDelta::Pixels(gpui::point(gpui::px(30.), gpui::px(60.))), + ..Default::default() + }); + assert_eq!( + view.read_with(cx, |view, _| view.scroll), + gpui::point(gpui::px(20.), gpui::px(40.)) + ); + } + + #[test] + fn scale_steps_clamp_without_overflow_and_reset_to_default() { + assert_eq!(stepped(100, 1), 110); + assert_eq!(stepped(100, -1), 90); + assert_eq!(stepped(200, 1), 200); + assert_eq!(stepped(50, -1), 50); + assert_eq!(stepped(u16::MAX, i16::MAX), 200); + assert_eq!(stepped(0, i16::MIN), 50); + assert_eq!(factor(DEFAULT_PERCENT), 1.0); + } + + #[test] + fn scaling_shortcuts_parse_and_are_reserved() { + for (combo, _) in combos() { + Keystroke::parse(combo).unwrap(); + assert!(is_reserved_combo(combo)); + } + assert!(!is_reserved_combo("mod-s")); + assert!(!is_reserved_combo("ctrl-+")); + } + + #[test] + fn ui_scale_reserved_shortcuts_heal_only_colliding_preferences() { + let mut saved = settings::UiSettings::default(); + saved.keymap.random_wallpaper = "ctrl-shift-+".into(); + saved.keymap.save_file = "mod-alt-s".into(); + let healed = saved.clamped(); + assert_eq!( + healed.keymap.random_wallpaper, + settings::ShortcutId::RandomWallpaper.default_combo() + ); + assert_eq!(healed.keymap.save_file, "mod-alt-s"); + } + + #[test] + fn scale_defaults_migrates_clamps_and_round_trips() { + let legacy: settings::UiSettings = serde_json::from_str("{}").unwrap(); + assert_eq!(legacy.ui_scale_percent, 100); + let dir = tempfile::tempdir().unwrap(); + let mut settings = settings::UiSettings::default(); + settings.ui_scale_percent = 150; + settings.save(dir.path()).unwrap(); + let restored = settings::UiSettings::load(dir.path()); + assert_eq!(restored.ui_scale_percent, 150); + assert_eq!(restored.ui_font_size, settings.ui_font_size); + settings.ui_scale_percent = 0; + assert_eq!(settings.clone().clamped().ui_scale_percent, 50); + settings.ui_scale_percent = u16::MAX; + assert_eq!(settings.clamped().ui_scale_percent, 200); + } +} diff --git a/docs/screenshots/ui-scale/appearance-100.png b/docs/screenshots/ui-scale/appearance-100.png new file mode 100644 index 0000000000..ad6824d739 Binary files /dev/null and b/docs/screenshots/ui-scale/appearance-100.png differ diff --git a/docs/screenshots/ui-scale/appearance-150.png b/docs/screenshots/ui-scale/appearance-150.png new file mode 100644 index 0000000000..9616b20737 Binary files /dev/null and b/docs/screenshots/ui-scale/appearance-150.png differ diff --git a/docs/screenshots/ui-scale/appearance-light-120.png b/docs/screenshots/ui-scale/appearance-light-120.png new file mode 100644 index 0000000000..f517f362a9 Binary files /dev/null and b/docs/screenshots/ui-scale/appearance-light-120.png differ diff --git a/docs/ui-scale.md b/docs/ui-scale.md new file mode 100644 index 0000000000..375b97b037 --- /dev/null +++ b/docs/ui-scale.md @@ -0,0 +1,54 @@ +# Desktop UI scale + +Settings → Appearance → Interface scale resizes text, icons, spacing, and panes together. Use the minus and plus buttons to change the scale in 10 percentage point steps between 50% and 200%, or Reset to return to 100%. + +Keyboard shortcuts work from conversations and settings: + +- `Ctrl+Shift+Plus` increases the scale. +- `Ctrl+Shift+Minus` decreases the scale. +- `Ctrl+Shift+0` resets to 100%. + +macOS also accepts the corresponding Command shortcuts. Both shifted punctuation and physical key spellings are bound. These shortcuts are reserved; existing conflicting shortcut preferences revert to their defaults, while unrelated customizations remain intact. + +The scale is device-local and saved immediately in `ui-settings.json` as `uiScalePercent`. Missing preferences default to 100%. Font preferences and native window geometry remain independent. Startup and reopened windows restore the saved scale; native resize and display changes preserve it. + +The [supporting ZUI PR](https://github.com/zeronsh/zui/pull/14) adds `Window::set_ui_scale`. It multiplies the display density for layout/painting, divides the logical viewport and native input coordinates, and converts IME and native child-view bounds back to platform coordinates. A Cargo patch makes the app and `gpui-base` use the same GPUI implementation. Return that dependency to upstream after the supporting ZUI change is merged. + +## Native screenshots + +The images come from the production shell with an isolated temporary profile and no engine or model calls. The native window remains 1440 × 1080 logical pixels in each capture. + +| Dark, 100% | Dark, 150% | +| --- | --- | +| ![Appearance at 100%](screenshots/ui-scale/appearance-100.png) | ![Appearance at 150%](screenshots/ui-scale/appearance-150.png) | + +![Light appearance at 120%](screenshots/ui-scale/appearance-light-120.png) + +Reproduce the captures: + +```sh +ZERON_OPEN_ROUTE=settings/appearance cargo run -p zeron-ui \ + --example ui-scale-fixture --features ui-scale-fixture -- /tmp/zeron-ui-scale-shots +``` + +On Linux, run in an isolated X11 display (Xvfb and ImageMagick are required): + +```sh +Xvfb :97 -screen 0 1600x1200x24 -nolisten tcp & +env -u WAYLAND_DISPLAY DISPLAY=:97 LP_NUM_THREADS=4 \ + ZERON_OPEN_ROUTE=settings/appearance cargo run -p zeron-ui \ + --example ui-scale-fixture --features ui-scale-fixture -- /tmp/zeron-ui-scale-shots +``` + +The fixture asserts shortcut changes, immediate persistence, scale retention after native bounds updates, and reset behavior before saving its images. Linux captures the actual X11 app window; macOS uses the native rendered frame. + +## Checks + +```sh +cargo test --locked -p zeron-ui --lib -- --test-threads=1 +cargo check --locked -p zeron +``` + +The supporting ZUI checkout runs `cargo test -p gpui --lib ui_scale -- --test-threads=1` separately. + +Regression coverage includes old settings, persistence/clamping, shifted-key aliases, reserved shortcuts, settings-button hit testing after zoom, pixel-scroll conversion, and retaining zoom through later pane saves. Native screenshots are exercised on Linux; macOS and Windows still need live platform verification.