From 12743199458c2782e2a677d780ac5fe1c55194ba Mon Sep 17 00:00:00 2001 From: JoyLin <72300219+JoyElliot@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:24:30 +0800 Subject: [PATCH] fix(startup): prepare icons asynchronously and stabilize first frame (#251) Prepare DPI-specific icons off the UI thread and present the initial Windows frame before showing the window, preserving startup geometry and cancellation ownership. --- .../2026-09-22-first-frame-startup.md | 99 +++++++++ .../2026-09-22-nonblocking-first-frame.md | 101 +++++++++ nebula_app/AGENTS.md | 1 + nebula_app/src/display/program_identity.rs | 21 +- nebula_app/src/gpui_shell/terminal/view.rs | 7 + .../gpui_shell/terminal/view/typography.rs | 32 ++- nebula_app/src/gpui_shell/workspace.rs | 103 +-------- nebula_app/src/gpui_shell/workspace/logos.rs | 197 ++++++++++++++++++ .../src/gpui_shell/workspace/pane_header.rs | 13 +- .../workspace/settings_navigation.rs | 1 + .../src/gpui_shell/workspace/sidebar.rs | 6 +- .../gpui_shell/workspace/tab_presentation.rs | 10 +- .../src/gpui_shell/workspace/top_tabs.rs | 3 +- .../src/gpui_shell/workspace/windowing.rs | 21 +- .../workspace/windowing/startup_geometry.rs | 159 ++++++++++++++ nebula_app/src/platform/startup.rs | 29 +++ .../src/platform/startup/first_frame.rs | 64 ++++++ 17 files changed, 742 insertions(+), 125 deletions(-) create mode 100644 architecture/notes/nebula_app/gpui_shell/workspace/2026-09-22-first-frame-startup.md create mode 100644 architecture/notes/nebula_app/gpui_shell/workspace/2026-09-22-nonblocking-first-frame.md create mode 100644 nebula_app/src/gpui_shell/workspace/logos.rs create mode 100644 nebula_app/src/gpui_shell/workspace/windowing/startup_geometry.rs create mode 100644 nebula_app/src/platform/startup/first_frame.rs diff --git a/architecture/notes/nebula_app/gpui_shell/workspace/2026-09-22-first-frame-startup.md b/architecture/notes/nebula_app/gpui_shell/workspace/2026-09-22-first-frame-startup.md new file mode 100644 index 0000000..13f7e0a --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/workspace/2026-09-22-first-frame-startup.md @@ -0,0 +1,99 @@ +# First-frame geometry and repeated logo preparation + +Superseded in its synchronous-preparation choice by +[nonblocking first frame](2026-09-22-nonblocking-first-frame.md); geometry remains. +The later upstream #241 integration also replaced the settings-logo synchronization +described below with static SVGs, as recorded in that successor note. + +## Status + +Proposed; Windows native startup measurements and focused regressions verified. + +## Context + +A regular window was created at 1080x720 logical pixels. Workspace construction +then measured the configured base font and asynchronously resized the window to +the 116x30 default grid plus chrome. GPUI exposes the native window before that +construction finishes, so synchronous logo preparation made the blank interval +and the later size jump especially visible in Debug builds. + +## Evidence + +On one Windows machine at 150% DPI, three fresh-config Debug launches spent +3.29-3.35 seconds between visible HWND and first painted controls. The HWND's UI +thread used 3.16-3.20 seconds in that interval; logo preparation used about 3.15 +seconds. Disabling blur retained the delay. Desktop Acrylic initialization and +attachment took 8-16 milliseconds in the instrumented material branch. + +Five of the ten embedded AI logos have identical source bytes and no tint for +either theme. Their full PNG decode, Lanczos3 resize and alpha-centering work was +performed twice per workspace/DPI. Sharing only those results reduced preparation +from 20 textures to 15 without changing the pixel pipeline. + +With the startup change and Desktop Acrylic combined, three Debug launches used +2.16-2.19 seconds of UI-thread CPU during a 2.19-2.27 second visible blank interval. +Every launch had one native bounds value from first visibility through settled +content. These are local measurements, not a Release or cross-machine guarantee. +The observer tracked only its launched process and HWND thread; unrelated system +CPU use was not counted. Pixel polling adds tens of milliseconds of uncertainty. + +## Decision + +- Keep PNG preparation, theme tint, Lanczos3 filtering, alpha centering and BGRA + conversion unchanged in `workspace/logos.rs`. Share immutable `Arc` + only where the identity catalog confirms an unchanged source and no theme tint. +- Keep textures owned by the workspace and rebuild them at its actual DPI. The + existing settings-logo synchronization and DPI render block remain authoritative. +- Put the common grid/chrome sizing rule in `windowing/startup_geometry.rs`. + On Windows, use the platform's primary-monitor DPI query and the existing font + shaping/rounding code before creating the native window. Clamp to the visible + work area and retain the 760x540 resize floor unless the display is smaller. +- Measure again with the actual window. Skip an asynchronous resize only when + sizes differ by less than one device pixel. Failed DPI preflight and unsupported + platforms retain the prior post-creation sizing policy; a changed DPI can still + require a corrective resize. +- Use base font metrics for window size and current zoom metrics for PTY grid + dimensions. Keep Quick Terminal's actual geometry and its animation ownership. + Do not alter show/focus/silent-start decisions or the PTY startup resize grace. + +## Rejected alternatives + +- Hiding the window until preparation finishes only conceals the CPU work and + changes activation/silent-start timing. +- Deferring logo work to workers introduces cancellation, DPI generations and + placeholder behavior. Avoid that lifecycle change for this bounded improvement. +- Smaller source assets or a different resize filter risk different icon edges. +- A process-global texture cache would need DPI/theme lifetime and eviction rules. +- Guessing display scale as 1.0 on unsupported platforms can create the wrong grid. +- Enabling optimization in the development profile changes build policy and hides + the cost without removing duplicate preparation. Release already optimizes it. + +## Consequences + +Debug startup still does synchronous image work and is not instant. The first +regular window is centered using its final size, instead of preserving the corner +of the old temporary rectangle. Small configured fonts retain a usable window +floor, and large work-area insets cannot make preflight sizing overflow that area. +The material implementation is independent: the change builds from upstream main +with Accent and was also exercised with the separate Acrylic branch. + +## Validation + +- Logo tests compare the omitted theme preparation pixel-for-pixel at 15 and 23 + physical pixels; they also check distinct theme outputs and texture ownership. +- Geometry tests cover the default grid, sidebar width, display caps, small fonts, + the minimum size, and subpixel rounding at 100/125/150/200% scale. +- Real Windows launches cover ordinary and blur-disabled startup, combined Acrylic + startup, a 4pt base font and persisted 28px zoom. Native bounds are sampled from + first visibility and diagnostic logs identify any fallback resize. +- Native macOS/Linux appearance, display changes during creation, and live Quick + Terminal/hide-to-tray interaction are not claimed by these measurements. + +## Supersedes + +None. + +## Revisit when + +GPUI exposes a portable pre-window display scale API; measured Release startup +still warrants asynchronous preparation; or logo source/tint behavior changes. diff --git a/architecture/notes/nebula_app/gpui_shell/workspace/2026-09-22-nonblocking-first-frame.md b/architecture/notes/nebula_app/gpui_shell/workspace/2026-09-22-nonblocking-first-frame.md new file mode 100644 index 0000000..e6aeae6 --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/workspace/2026-09-22-nonblocking-first-frame.md @@ -0,0 +1,101 @@ +# Nonblocking logo preparation and first presentation + +## Status + +Proposed; Windows native launch probes and targeted regression tests passed. + +## Context + +The initial first-frame change computed final geometry before creating the HWND +and removed duplicate logo work, but still prepared the remaining 15 textures on +the UI thread. Debug startup consequently retained about two seconds of blank +window time. The user requires usable content without waiting for those textures. + +The Acrylic-only test package did not contain that separate startup branch. +Combined test packages must identify both changes; material and startup ownership +remain separate from the packaging integration branch. + +## Evidence + +- Pinned GPUI `App::open_window` builds/draws the initial scene after the Windows + platform constructor has already shown its HWND. That draw does not present. +- `on_next_frame` callbacks and effects deferred from them run before present; + they are not completion notifications. Windows has no frame waker, and ordinary + invalidation of a hidden HWND does not guarantee delivery of its first paint. +- GPUI Windows handles `WM_PAINT` synchronously through its frame callback, which + presents an initial pending scene even while the window is hidden. +- In isolated Debug launches with the combined material/startup code, first paint + completed before visibility. The first full screenshots contained the controls; + sampled native bounds stayed constant through settled content. UI-thread CPU + from process start to visibility was about 0.52-0.59 seconds. Logo CPU work + continued on the worker after the UI became usable. Windows' opening animation + still affects early screen pixels; an HWND visibility event is not a screenshot. + +## Decision + +- A workspace-owned `LogoLoad` prepares the existing pixel pipeline on the + background executor. Its UI task holds a weak entity and returns pixels into a + ready slot. Render consumes ready pixels for tabs and pane headers without + waiting for decoding. Upstream #241 moved settings Agent icons to static SVGs; + the former settings-logo synchronization is no longer needed. +- Replacing or dropping a request cancels its sole foreground publishing task + and signals cooperative cancellation between images. Reset and publication + run on the same foreground thread; a cancelled publisher cannot apply an old + worker result. An image already decoding can finish without updating the UI. +- Initial missing brand images use the existing glyph fallback in exactly the + eventual image width. A DPI transition keeps its prior image while replacement + is pending, avoiding a flash back to a different glyph. This can briefly resample + the old DPI texture; cancelled requests cannot replace it afterward. +- Only visible, focused Windows windows defer their initial show. The caller + leaves silent-start, background-created windows and Quick Terminal unchanged. +- After window creation and deferred material setup, schedule a foreground task. + Resolve the live HWND in a short GPUI update, leave that App/Window borrow, then + synchronously send `WM_PAINT`. After its handler returns, activate through GPUI + to consume pending placement and honor the original focus request. + +## Rejected alternatives + +- A fixed sleep or next-frame/defer callback cannot prove a frame was presented. +- Raw `ShowWindow` would leave GPUI's pending initial placement unconsumed and + could reset geometry at later activation. +- Making every window activate would break background and silent-start behavior. +- A synchronous decode before showing merely relocates the startup stall. +- Generation checks duplicate the owned foreground task's cancellation guarantee: + background workers only produce pixels and never publish into the workspace. +- New image filters, rescaled source assets or persistent caches would change + visual output or introduce unrelated cache/version/persistence policy. + +## Consequences + +No new thread pool, timer, per-frame native operation or global logo cache is +introduced. Each window owns at most one publishing request. Cancelled synchronous +image work is bounded by the current image; it does not mutate UI state. + +The presentation adapter depends on the pinned GPUI Windows paint path. Its native +message must run outside any App/Window borrow, on the foreground thread. A queued +task resolves a still-live GPUI handle and cannot revive a destroyed workspace. +Windows' normal opening animation remains enabled. + +## Validation + +- Pixel comparisons at 15px/23px preserve the previous PNG/tint/filter output. +- Replaced/DPI-returning requests cancel their old publishers; dropping a request + signals cancellation, and a pre-cancelled worker does not decode images. +- Option tests cover visible/hidden and focused/background combinations. +- Geometry and the first-PTY-size/maximize regression remain passing. +- Native launch screenshots/logs distinguish completed first paint, HWND visibility + and compositor animation; blur-disabled launches use the same presentation path. +- macOS/Linux retain their original show path. Their native appearance is not + claimed by Windows probes; Quick Terminal interaction requires separate native + coverage when that path is changed. + +## Supersedes + +The synchronous-preparation choice in +[the initial first-frame note](2026-09-22-first-frame-startup.md). +Its base-font geometry, zoom/grid ownership and per-window texture sharing remain. + +## Revisit when + +GPUI changes `WM_PAINT`, frame throttling or presentation ownership, adds a public +post-present show API, or the product needs a shared cross-window texture cache. diff --git a/nebula_app/AGENTS.md b/nebula_app/AGENTS.md index 0ced932..26a0269 100644 --- a/nebula_app/AGENTS.md +++ b/nebula_app/AGENTS.md @@ -2,6 +2,7 @@ - 本目录是应用编排和适配层;依赖方向保持为 UI/组合 → 应用能力 → 共享领域规则,不在视图里复制设置、持久化、终端状态或协议状态机。 - `src/gpui_shell` 负责 GPUI 呈现、命令和订阅。交互改动必须覆盖默认、hover、pressed、键盘焦点、disabled/loading 以及成功/失败中实际需要的状态。 +- 首帧窗口尺寸、非阻塞 logo 准备与原生呈现顺序的因果记录见 [`workspace`](../architecture/notes/nebula_app/gpui_shell/workspace/)。 - 操作形成“触发 → 执行 → 可感知结果”的闭环。成功只能在操作真实完成或底层接口明确接受后显示,不能把回调触发当成平台级成功。 - 异步工作必须有所有者、取消/过期策略和视图销毁清理;旧结果不得覆盖新状态。渲染回调不得同步等待磁盘、网络、子进程或长锁。 - 新 UI 文案使用现有 typed message ID、命名占位符和回退合同,遵循 [`../docs/internationalization.md`](../docs/internationalization.md)。 diff --git a/nebula_app/src/display/program_identity.rs b/nebula_app/src/display/program_identity.rs index 418a207..ec84984 100644 --- a/nebula_app/src/display/program_identity.rs +++ b/nebula_app/src/display/program_identity.rs @@ -47,21 +47,30 @@ impl AiLogo { } } - pub(crate) fn tint_pixels(self, pixels: &mut [u8], ink: [u8; 3]) { - let preserve_luma = match self { - Self::OpenAi | Self::Pi => false, + fn tint_preserves_luma(self) -> Option { + match self { + Self::OpenAi | Self::Pi => Some(false), // OpenCode stores a luma map: the frame is white, the inner block gray. // Kimi's letterform is solid white with a small blue accent: embedded // unchanged it vanishes on light themes, so it rides the luma map too — // the K tracks the theme ink at full strength, the accent dims with it. - Self::OpenCode | Self::Kimi => true, + Self::OpenCode | Self::Kimi => Some(true), Self::Claude | Self::Grok | Self::Antigravity | Self::Trae | Self::OhMyPi - | Self::CodeBuddy => return, - }; + | Self::CodeBuddy => None, + } + } + + /// Reuse a prepared texture only when neither its source nor tint depends on theme. + pub(crate) fn shares_theme_texture(self) -> bool { + self.tint_preserves_luma().is_none() && std::ptr::eq(self.png(false), self.png(true)) + } + + pub(crate) fn tint_pixels(self, pixels: &mut [u8], ink: [u8; 3]) { + let Some(preserve_luma) = self.tint_preserves_luma() else { return }; for pixel in pixels.chunks_exact_mut(4) { let luma = if preserve_luma { u16::from(pixel[0]) } else { 255 }; for channel in 0..3 { diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index a3a488d..eddb5c6 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -495,6 +495,13 @@ impl TerminalView { typography::startup_cell_metrics(window, cx) } + pub(in crate::gpui_shell) fn startup_cell_metrics_at_scale( + scale: f32, + cx: &App, + ) -> (Pixels, Pixels) { + typography::startup_cell_metrics_at_scale(scale, cx) + } + pub(in crate::gpui_shell) fn set_output_visible( &mut self, visible: bool, diff --git a/nebula_app/src/gpui_shell/terminal/view/typography.rs b/nebula_app/src/gpui_shell/terminal/view/typography.rs index d6c173e..7b4fe92 100644 --- a/nebula_app/src/gpui_shell/terminal/view/typography.rs +++ b/nebula_app/src/gpui_shell/terminal/view/typography.rs @@ -5,7 +5,8 @@ //! the startup grid from drifting away from the first prepainted frame. use gpui::{ - App, Font, FontFeatures, FontStyle, FontWeight, Hsla, Pixels, SharedString, TextRun, Window, px, + App, Font, FontFeatures, FontStyle, FontWeight, Hsla, Pixels, SharedString, TextRun, Window, + WindowTextSystem, px, }; use nebula_settings::CellWidthModeName; @@ -87,7 +88,8 @@ pub(super) fn line_height_for_view( } fn measure_cell_metrics( - window: &Window, + text_system: &WindowTextSystem, + scale: f32, family: &str, font_size: Pixels, mode: CellWidthModeName, @@ -96,7 +98,7 @@ fn measure_cell_metrics( line_height_multiplier: Option, ) -> (Pixels, Pixels) { let font = mono_font(family, FontWeight::NORMAL, FontStyle::Normal, false); - let sample = window.text_system().shape_line( + let sample = text_system.shape_line( SharedString::new_static("M"), font_size, &[TextRun { @@ -110,13 +112,13 @@ fn measure_cell_metrics( None, ); ( - effective_cell_width(sample.width.as_f32(), mode, window.scale_factor(), offset_x), + effective_cell_width(sample.width.as_f32(), mode, scale, offset_x), effective_line_height_with_theme( sample.ascent.as_f32() + sample.descent.as_f32(), font_size.as_f32(), line_height_multiplier, offset_y, - window.scale_factor(), + scale, ), ) } @@ -135,7 +137,8 @@ pub(super) fn cell_metrics(window: &Window, cx: &App) -> (Pixels, Pixels) { None => (REQUIRED_FONT_FAMILY, 15.0, CellWidthModeName::Compact, 0.0, 0.0, None), }; measure_cell_metrics( - window, + window.text_system(), + window.scale_factor(), family, px(font_size), mode, @@ -148,6 +151,20 @@ pub(super) fn cell_metrics(window: &Window, cx: &App) -> (Pixels, Pixels) { /// Startup sizing uses the configured base font, while a persisted terminal /// zoom only affects the live grid after the window has been created. pub(super) fn startup_cell_metrics(window: &Window, cx: &App) -> (Pixels, Pixels) { + measure_startup_cell_metrics(window.text_system(), window.scale_factor(), cx) +} + +/// The same font system and rounding rules can size a window before its HWND exists. +pub(super) fn startup_cell_metrics_at_scale(scale: f32, cx: &App) -> (Pixels, Pixels) { + let text_system = WindowTextSystem::new(cx.text_system().clone()); + measure_startup_cell_metrics(&text_system, scale, cx) +} + +fn measure_startup_cell_metrics( + text_system: &WindowTextSystem, + scale: f32, + cx: &App, +) -> (Pixels, Pixels) { let (family, font_size, mode, offset_x, offset_y, line_height_multiplier) = match cx.try_global::() { Some(settings) => ( @@ -161,7 +178,8 @@ pub(super) fn startup_cell_metrics(window: &Window, cx: &App) -> (Pixels, Pixels None => (REQUIRED_FONT_FAMILY, 15.0, CellWidthModeName::Compact, 0.0, 0.0, None), }; measure_cell_metrics( - window, + text_system, + scale, family, px(font_size), mode, diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 71aec1f..680c59b 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -32,7 +32,6 @@ use gpui::{ SharedString, StatefulInteractiveElement as _, Styled as _, StyledImage as _, Subscription, Window, canvas, div, ease_out_quint, fill, img, px, relative, size, }; -use image::Frame; use crate::display::color::Rgb; use crate::gpui_shell::code_tab::CodeTabViewEvent; @@ -58,6 +57,7 @@ mod documents; mod file_tree; mod key_actions; mod launcher_menu; +mod logos; mod notifications; mod palette; mod pane_header; @@ -264,46 +264,6 @@ type SplitBoundsStore = Rc), Bounds>>> /// 键:pane id(方向导航要拿所有叶子的屏幕矩形算最近邻)。 type PaneBoundsStore = Rc>>>; -fn decode_sidebar_logo( - logo: crate::display::AiLogo, - dark: bool, - target_size: u32, -) -> Option> { - let mut rgba = image::load_from_memory(logo.png(dark)).ok()?.into_rgba8(); - logo.tint_pixels(&mut rgba, if dark { [236, 239, 245] } else { [35, 40, 50] }); - // 直接复用旧壳的 Lanczos3 物理像素预缩放与 alpha 质量中心校正。 - // 先 tint 再缩放,避免 1024px 原图在 GPUI paint 阶段临时压到十几个 - // 逻辑像素时产生灰边、锯齿与非整数 DPI 采样。 - let (prepared, width, height) = crate::display::prepare_ai_logo_texture( - rgba.as_raw(), - rgba.width(), - rgba.height(), - target_size, - ); - let mut rgba = image::RgbaImage::from_raw(width, height, prepared)?; - // GPUI 的原始帧使用 BGRA;与壁纸解码走同一通道转换。 - for pixel in rgba.chunks_exact_mut(4) { - pixel.swap(0, 2); - } - Some(Arc::new(RenderImage::new([Frame::new(rgba)]))) -} - -fn sidebar_logo_images( - target_size: u32, -) -> HashMap<(crate::display::AiLogo, bool), Arc> { - use crate::display::AiLogo; - - let mut images = HashMap::new(); - for logo in AiLogo::ALL { - for dark in [false, true] { - if let Some(image) = decode_sidebar_logo(logo, dark, target_size) { - images.insert((logo, dark), image); - } - } - } - images -} - /// GPUI `Bounds` → `nebula_split::Rect`(同为窗口逻辑像素坐标系)。 fn to_split_rect(bounds: &Bounds) -> nebula_split::Rect { nebula_split::Rect::new( @@ -822,6 +782,7 @@ pub struct NebulaWorkspace { sidebar_logo_images: HashMap<(crate::display::AiLogo, bool), Arc>, /// 品牌图缓存对应的整数物理像素边长;窗口跨 DPI 显示器时据此重建。 sidebar_logo_target_px: u32, + sidebar_logo_load: logos::LogoLoad, /// 跟随系统深浅:OS 外观切换的监听(旧壳 ThemeChanged 的对应物)。 _appearance_sub: Subscription, /// spinner 在窗口失焦时冻结为静态状态;重新聚焦后由一次 render 恢复按需帧循环。 @@ -919,7 +880,7 @@ impl NebulaWorkspace { }) .detach(); } - let initial_grid = Self::prepare_initial_grid( + let initial_grid = windowing::prepare_initial_grid( window, cx, sidebar_width, @@ -980,8 +941,6 @@ impl NebulaWorkspace { cx.subscribe_in(&command_manager_input, window, Self::on_command_manager_input_event); let file_tree_search_subscription = cx.subscribe_in(&file_tree_search_input, window, Self::on_file_tree_search_event); - let sidebar_logo_target_px = - (TAB_LABEL_ICON_SIZE * window.scale_factor()).round().max(1.0) as u32; let mut this = Self { tabs: Vec::new(), tab_meta: Vec::new(), @@ -1060,8 +1019,9 @@ impl NebulaWorkspace { remote_files_scroll: gpui::UniformListScrollHandle::new(), tab_menu: None, selection_context_menu: None, - sidebar_logo_images: sidebar_logo_images(sidebar_logo_target_px), - sidebar_logo_target_px, + sidebar_logo_images: HashMap::new(), + sidebar_logo_target_px: 0, + sidebar_logo_load: logos::LogoLoad::default(), _appearance_sub: appearance_sub, spinner_window_active, _spinner_activation_sub: spinner_activation_sub, @@ -1133,53 +1093,6 @@ impl NebulaWorkspace { this } - /// 默认窗口尺寸 = 旧壳默认画布 116×30 的反推(`display` 的 - /// `Dimensions` 默认值)。画布按配置基准字号定形;持久化缩放只参与 - /// 随后的实际行列反推,不能把缩放后的 116 列全加到启动窗宽上。 - /// 布局链横向:网格 + 侧栏 + 卡缝 p_2×2(16) + - /// 终端水平内边距 24;纵向:网格 + 标题栏 34(gpui-component - /// TITLE_BAR_HEIGHT)+ 卡缝 16 + 终端垂直内边距 16。各加 2px 余量让 - /// 浮点 floor 不缩行列;放不下的屏幕按 95% 工作区收拢(网格随之变小, - /// 与旧壳"开不下就小"同义)。 - fn prepare_initial_grid( - window: &mut Window, - cx: &mut App, - sidebar_width: f32, - fit_window_to_default_grid: bool, - ) -> (u16, u16) { - let (cell_w, line_h) = TerminalView::cell_metrics(window, cx); - let (startup_cell_w, startup_line_h) = TerminalView::startup_cell_metrics(window, cx); - // 标签栏位置只改变 chrome 内部布局,不能改变产品的默认外窗几何。 - // 顶栏模式仍保留与侧栏模式相同的横向预算,让两种模式启动时宽高一致。 - let chrome_w = sidebar_width + 16.0 + 24.0 + 2.0; - let chrome_h = 34.0 + 16.0 + 16.0 + 2.0; - let (w, h) = if fit_window_to_default_grid { - let mut w = f32::from(TerminalView::DEFAULT_GRID_COLUMNS) * f32::from(startup_cell_w) - + chrome_w; - let mut h = - f32::from(TerminalView::DEFAULT_GRID_LINES) * f32::from(startup_line_h) + chrome_h; - if let Some(display) = cx.primary_display() { - let bounds = display.bounds().size; - w = w.min(f32::from(bounds.width) * 0.95); - h = h.min(f32::from(bounds.height) * 0.95); - } - window.resize(size(px(w), px(h))); - (w, h) - } else { - // 快速终端的 WindowOptions 已经给出目标显示器全宽和 40% 高度。 - // 再排队一次普通网格 resize 会与原生滑入竞争,首帧 DComp 表面只 - // 覆盖旧宽度,右侧因此变黑。 - let bounds = window.bounds().size; - (f32::from(bounds.width), f32::from(bounds.height)) - }; - // 反推收拢后的目标网格:终端 spawn 直接用它,出生即最终几何, - // 启动路径零 ConPTY resize(resize 竞态会打乱 shell 首屏输出的 - // 坐标缓存,参见 set_layout 的启动稳定闸)。 - let cols = ((w - chrome_w) / f32::from(cell_w) + 0.001).floor().max(2.0) as u16; - let rows = ((h - chrome_h) / f32::from(line_h) + 0.001).floor().max(2.0) as u16; - (cols, rows) - } - /// `LaunchSession::Default` 的口语短标。 /// /// 这里不能再读运行时的“当前默认 Shell”:`Default` 已经是这个 Tab 创建 @@ -3047,10 +2960,10 @@ impl Render for NebulaWorkspace { let draw_file_divider = self.side_panel.open; let sidebar_logo_target_px = (TAB_LABEL_ICON_SIZE * window.scale_factor()).round().max(1.0) as u32; - if sidebar_logo_target_px != self.sidebar_logo_target_px { + if let Some(images) = logos::poll_sidebar_logo_images(self, sidebar_logo_target_px, cx) { // GPUI 窗口可跨不同 DPI 的显示器;原纹理只在整数物理像素尺寸 // 变化时重建,普通 render 不重复解码 PNG。 - self.sidebar_logo_images = sidebar_logo_images(sidebar_logo_target_px); + self.sidebar_logo_images = images; self.sidebar_logo_target_px = sidebar_logo_target_px; } // Some tab-open/restore paths assign `active` directly. Clear a focus diff --git a/nebula_app/src/gpui_shell/workspace/logos.rs b/nebula_app/src/gpui_shell/workspace/logos.rs new file mode 100644 index 0000000..918597c --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/logos.rs @@ -0,0 +1,197 @@ +//! DPI-specific sidebar textures, shared by tabs and pane headers. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use gpui::{Context, RenderImage, Task}; +use image::Frame; + +use super::NebulaWorkspace; + +type LogoImages = HashMap<(crate::display::AiLogo, bool), Arc>; + +#[derive(Default)] +pub(super) struct LogoLoad { + target: u32, + pending: Option, + ready: Option, +} + +struct PendingLoad { + cancelled: Arc, + _task: Task<()>, +} + +impl Drop for PendingLoad { + fn drop(&mut self) { + // A synchronous decode already running on a worker cannot be preempted; + // stop between images, and drop the foreground task that would publish it. + self.cancelled.store(true, Ordering::Relaxed); + } +} + +impl LogoLoad { + fn reset(&mut self, target: u32) { + // Reset and publication run on the foreground thread. Dropping the sole + // publishing task prevents an old worker result from reaching this load. + self.pending = None; + self.ready = None; + self.target = target; + } +} + +/// Render consumes already prepared pixels; it never decodes or waits for a worker. +pub(super) fn poll_sidebar_logo_images( + workspace: &mut NebulaWorkspace, + target: u32, + cx: &mut Context, +) -> Option { + let load = &mut workspace.sidebar_logo_load; + if target == workspace.sidebar_logo_target_px { + if load.target != target { + load.reset(target); + } + return None; + } + if load.target != target { + load.reset(target); + let cancelled = Arc::new(AtomicBool::new(false)); + let worker_cancelled = cancelled.clone(); + let worker = cx + .background_executor() + .spawn(async move { sidebar_logo_images(target, &worker_cancelled) }); + let task = cx.spawn(async move |workspace, cx| { + let Some(images) = worker.await else { return }; + let _ = workspace.update(cx, |workspace, cx| { + workspace.sidebar_logo_load.ready = Some(images); + cx.notify(); + }); + }); + load.pending = Some(PendingLoad { cancelled, _task: task }); + } + load.ready.take() +} + +fn decode_sidebar_logo( + logo: crate::display::AiLogo, + dark: bool, + target_size: u32, +) -> Option> { + let mut rgba = image::load_from_memory(logo.png(dark)).ok()?.into_rgba8(); + logo.tint_pixels(&mut rgba, if dark { [236, 239, 245] } else { [35, 40, 50] }); + // 直接复用旧壳的 Lanczos3 物理像素预缩放与 alpha 质量中心校正。 + // 先 tint 再缩放,避免 1024px 原图在 GPUI paint 阶段临时压到十几个 + // 逻辑像素时产生灰边、锯齿与非整数 DPI 采样。 + let (prepared, width, height) = crate::display::prepare_ai_logo_texture( + rgba.as_raw(), + rgba.width(), + rgba.height(), + target_size, + ); + let mut rgba = image::RgbaImage::from_raw(width, height, prepared)?; + // GPUI 的原始帧使用 BGRA;与壁纸解码走同一通道转换。 + for pixel in rgba.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + Some(Arc::new(RenderImage::new([Frame::new(rgba)]))) +} + +fn sidebar_logo_images(target_size: u32, cancelled: &AtomicBool) -> Option { + use crate::display::AiLogo; + + let mut images = HashMap::new(); + for logo in AiLogo::ALL { + if cancelled.load(Ordering::Relaxed) { + return None; + } + if logo.shares_theme_texture() { + if let Some(image) = decode_sidebar_logo(logo, false, target_size) { + images.insert((logo, false), image.clone()); + images.insert((logo, true), image); + } + continue; + } + for dark in [false, true] { + if cancelled.load(Ordering::Relaxed) { + return None; + } + if let Some(image) = decode_sidebar_logo(logo, dark, target_size) { + images.insert((logo, dark), image); + } + } + } + Some(images) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::display::AiLogo; + + #[test] + fn sidebar_logos_share_only_identical_theme_pixels_at_each_dpi() { + // 100% and 150% DPI keep separate physical textures. There is no global cache. + for target in [15, 23] { + let images = sidebar_logo_images(target, &AtomicBool::new(false)).unwrap(); + assert_eq!(images.len(), AiLogo::ALL.len() * 2); + let unique = images.values().map(Arc::as_ptr).collect::>(); + assert_eq!(unique.len(), 15, "five color assets must not be prepared twice"); + for logo in AiLogo::ALL { + let light = &images[&(logo, false)]; + let dark = &images[&(logo, true)]; + assert_eq!(u32::from(light.size(0).width), target); + assert_eq!(u32::from(dark.size(0).height), target); + if Arc::ptr_eq(light, dark) { + // Compare the omitted dark preparation against the actual output, + // so a later asset/tint change cannot silently change its pixels. + let reference = decode_sidebar_logo(logo, true, target).unwrap(); + assert_eq!(dark.as_bytes(0), reference.as_bytes(0), "{logo:?}"); + } else { + assert_ne!(light.as_bytes(0), dark.as_bytes(0), "{logo:?}"); + } + } + } + } + + #[cfg(feature = "gpui-test-support")] + #[gpui::test] + fn replacing_load_cancels_already_queued_publication(cx: &mut gpui::TestAppContext) { + use std::cell::Cell; + use std::rc::Rc; + + for replace in [true, false] { + let published = Rc::new(Cell::new(false)); + let result = published.clone(); + let (reply, received) = futures::channel::oneshot::channel::<()>(); + let worker = cx.executor().spawn(async move { received.await.unwrap() }); + let task = cx.foreground_executor().spawn(async move { + worker.await; + result.set(true); + }); + let mut load = LogoLoad::default(); + load.reset(15); + load.pending = + Some(PendingLoad { cancelled: Arc::new(AtomicBool::new(false)), _task: task }); + cx.run_until_parked(); + reply.send(()).unwrap(); + // Finish the worker, leaving its foreground publisher queued. + assert!(cx.dispatcher.tick(true)); + if replace { + load.reset(23); + load.reset(15); // Returning to the old DPI must not revive its task. + } + cx.run_until_parked(); + assert_eq!(published.get(), !replace, "only the retained request may publish"); + } + } + + #[test] + fn releasing_or_replacing_a_load_cancels_further_image_work() { + let cancelled = Arc::new(AtomicBool::new(false)); + let pending = PendingLoad { cancelled: cancelled.clone(), _task: Task::ready(()) }; + drop(pending); + assert!(cancelled.load(Ordering::Relaxed)); + assert!(sidebar_logo_images(23, &cancelled).is_none()); + } +} diff --git a/nebula_app/src/gpui_shell/workspace/pane_header.rs b/nebula_app/src/gpui_shell/workspace/pane_header.rs index 407df7e..4fe2065 100644 --- a/nebula_app/src/gpui_shell/workspace/pane_header.rs +++ b/nebula_app/src/gpui_shell/workspace/pane_header.rs @@ -203,6 +203,7 @@ fn broadcast_mark(side: f32, color: Hsla) -> impl IntoElement { /// 一个 pane 的标题信息:图标(AI 品牌图 / Nerd Font 字位)+ 一行标题。 struct PaneTitle { logo: Option>, + logo_pending: bool, glyph: Option<&'static str>, text: SharedString, } @@ -216,10 +217,9 @@ impl NebulaWorkspace { .running_program .clone() .or_else(|| view.ai_session.as_ref().map(|identity| identity.source.clone())); - let logo = program - .as_deref() - .and_then(crate::display::ai_logo_for_program) - .and_then(|logo| self.sidebar_logo_images.get(&(logo, dark)).cloned()); + let brand_logo = program.as_deref().and_then(crate::display::ai_logo_for_program); + let logo = brand_logo.and_then(|logo| self.sidebar_logo_images.get(&(logo, dark)).cloned()); + let logo_pending = brand_logo.is_some() && logo.is_none(); let glyph = program .as_deref() .filter(|_| logo.is_none()) @@ -230,7 +230,7 @@ impl NebulaWorkspace { (None, Some(destination)) => SharedString::from(destination.clone()), (None, None) => SharedString::from(view.tab_label()), }; - PaneTitle { logo, glyph, text } + PaneTitle { logo, logo_pending, glyph, text } } /// 一个 pane 的标题条。左区整条是切焦点的命中区,右区三枚按钮各自 @@ -264,7 +264,7 @@ impl NebulaWorkspace { let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0); let title_px = label_px * 0.78; - let PaneTitle { logo, glyph, text } = self.pane_title(view, cx, dark); + let PaneTitle { logo, logo_pending, glyph, text } = self.pane_title(view, cx, dark); let group: SharedString = format!("pane-header-{pane_id}").into(); let icon_ink = if focused { ink } else { muted }; @@ -332,6 +332,7 @@ impl NebulaWorkspace { .when_some(glyph, |grip, glyph| { grip.child( div() + .when(logo_pending, |slot| slot.w(px(title_px))) .flex_shrink_0() .font_family(symbol_family) .text_size(px(title_px)) diff --git a/nebula_app/src/gpui_shell/workspace/settings_navigation.rs b/nebula_app/src/gpui_shell/workspace/settings_navigation.rs index 6a56b6e..e0461ff 100644 --- a/nebula_app/src/gpui_shell/workspace/settings_navigation.rs +++ b/nebula_app/src/gpui_shell/workspace/settings_navigation.rs @@ -129,6 +129,7 @@ impl NebulaWorkspace { is_settings: true, activity: SidebarActivity::Idle, logo_image: None, + logo_pending: false, program_glyph: None, shell_tag: None, color: None, diff --git a/nebula_app/src/gpui_shell/workspace/sidebar.rs b/nebula_app/src/gpui_shell/workspace/sidebar.rs index 72a58a1..8d6074a 100644 --- a/nebula_app/src/gpui_shell/workspace/sidebar.rs +++ b/nebula_app/src/gpui_shell/workspace/sidebar.rs @@ -232,6 +232,7 @@ impl NebulaWorkspace { is_settings, activity, logo_image, + logo_pending, program_glyph, shell_tag, color: tab_color, @@ -445,7 +446,7 @@ impl NebulaWorkspace { .when_some(program_glyph, |row, glyph| { row.child( div() - .w(px(TAB_LABEL_ICON_W)) + .w(px(if logo_pending { TAB_LABEL_ICON_SIZE } else { TAB_LABEL_ICON_W })) .flex_shrink_0() .font_family(symbol_family.clone()) .text_size(px(label_px)) @@ -966,7 +967,7 @@ impl NebulaWorkspace { let chrome_family = theme.mono_font_family.clone(); let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0); - let TabPresentation { title, logo_image, program_glyph, pane_count, .. } = + let TabPresentation { title, logo_image, logo_pending, program_glyph, pane_count, .. } = self.tab_presentation(self.active, cx, dark); slot.child( h_flex() @@ -988,6 +989,7 @@ impl NebulaWorkspace { .when_some(program_glyph, |row, glyph| { row.child( div() + .when(logo_pending, |slot| slot.w(px(TAB_LABEL_ICON_SIZE))) .flex_shrink_0() .font_family(symbol_family) .text_size(px(label_px)) diff --git a/nebula_app/src/gpui_shell/workspace/tab_presentation.rs b/nebula_app/src/gpui_shell/workspace/tab_presentation.rs index c23ea64..27d3382 100644 --- a/nebula_app/src/gpui_shell/workspace/tab_presentation.rs +++ b/nebula_app/src/gpui_shell/workspace/tab_presentation.rs @@ -10,6 +10,7 @@ pub(super) struct TabPresentation { pub(super) is_settings: bool, pub(super) activity: SidebarActivity, pub(super) logo_image: Option>, + pub(super) logo_pending: bool, pub(super) program_glyph: Option<&'static str>, pub(super) shell_tag: Option, pub(super) color: Option, @@ -71,10 +72,10 @@ impl NebulaWorkspace { // 事件 vs 状态的唯一裁定处(侧栏与顶栏共用这份 presentation),规则与 // 理由见 [`sidebar::resting_activity`]。 let activity = sidebar::resting_activity(activity, active, self.meta(ix).has_bell); - let logo_image = program - .as_deref() - .and_then(crate::display::ai_logo_for_program) - .and_then(|logo| self.sidebar_logo_images.get(&(logo, dark)).cloned()); + let brand_logo = program.as_deref().and_then(crate::display::ai_logo_for_program); + let logo_image = + brand_logo.and_then(|logo| self.sidebar_logo_images.get(&(logo, dark)).cloned()); + let logo_pending = brand_logo.is_some() && logo_image.is_none(); let program_glyph = program .as_deref() .filter(|_| logo_image.is_none()) @@ -111,6 +112,7 @@ impl NebulaWorkspace { is_settings, activity, logo_image, + logo_pending, program_glyph, shell_tag, color: meta.color, diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs index cdbd122..7345384 100644 --- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs +++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs @@ -180,6 +180,7 @@ impl NebulaWorkspace { is_settings, activity, logo_image, + logo_pending, program_glyph, shell_tag, color, @@ -383,7 +384,7 @@ impl NebulaWorkspace { .when_some(program_glyph, |row, glyph| { row.child( div() - .w(px(TAB_LABEL_ICON_W)) + .w(px(if logo_pending { TAB_LABEL_ICON_SIZE } else { TAB_LABEL_ICON_W })) .flex_shrink_0() .font_family(symbol_family.clone()) .text_size(px(label_px)) diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs index 8bf78b2..4aa0234 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -6,6 +6,8 @@ mod shutdown; pub(crate) use shutdown::{quit_all, quit_for_update}; +mod startup_geometry; +pub(super) use startup_geometry::prepare_initial_grid; #[cfg(windows)] mod quick_window; @@ -416,10 +418,16 @@ pub(super) fn open_recipe_window(session: crate::session::Session, cx: &mut App) }); } -fn workspace_window_options(cx: &mut App, focus: bool, role: WindowRole) -> WindowOptions { +fn workspace_window_options( + cx: &mut App, + focus: bool, + role: WindowRole, + sidebar_width: f32, +) -> WindowOptions { match role { WindowRole::Regular => { - let preferred = size(px(1080.0), px(720.0)); + let preferred = startup_geometry::preferred_size(cx, sidebar_width) + .unwrap_or_else(|| size(px(1080.0), px(720.0))); let bounds = cx.primary_display().map_or_else( || Bounds::centered(None, preferred, cx), |display| { @@ -499,14 +507,16 @@ fn open_workspace_window( role: WindowRole, ) -> gpui::Result<(u64, Entity)> { let (runtime_window_id, runtime_hub) = allocate_window(cx); + let runtime = nebula_settings::RuntimeSettings::load(); let start_hidden = shell_events.is_some() && matches!(startup, WorkspaceStartup::RestoreOrDefault) - && crate::platform::startup::start_hidden(&nebula_settings::RuntimeSettings::load()); - let mut options = workspace_window_options(cx, focus, role); + && crate::platform::startup::start_hidden(&runtime); + let mut options = workspace_window_options(cx, focus, role, runtime.sidebar_width); if start_hidden { options.show = false; options.focus = false; } + let deferred_show = crate::platform::startup::first_frame::defer_show(&mut options); let workspace_slot = Rc::new(RefCell::new(None)); let hwnd_slot = Rc::new(RefCell::new(0isize)); let workspace_out = workspace_slot.clone(); @@ -557,6 +567,9 @@ fn open_workspace_window( role, }); crate::gpui_shell::wallpaper::refresh(cx); + if deferred_show { + crate::platform::startup::first_frame::present_then_show(handle, cx); + } Ok((runtime_window_id, workspace)) } diff --git a/nebula_app/src/gpui_shell/workspace/windowing/startup_geometry.rs b/nebula_app/src/gpui_shell/workspace/windowing/startup_geometry.rs new file mode 100644 index 0000000..ec225ae --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/windowing/startup_geometry.rs @@ -0,0 +1,159 @@ +//! One sizing rule for the initial native window and its first terminal grid. +//! +//! Measure the configured base font, not persisted terminal zoom. Where the +//! platform exposes display DPI, supply the final size before the window is +//! shown; otherwise keep the existing post-creation sizing fallback. + +use gpui::{App, Pixels, Size, Window, px, size}; + +use crate::gpui_shell::terminal::view::TerminalView; + +fn chrome_size(sidebar_width: f32) -> Size { + // Sidebar, gutters, terminal padding and the existing 2px rounding allowance. + size(px(sidebar_width + 16.0 + 24.0 + 2.0), px(34.0 + 16.0 + 16.0 + 2.0)) +} + +fn default_size( + metrics: (Pixels, Pixels), + sidebar_width: f32, + display_limit: Option>, +) -> Size { + let chrome = chrome_size(sidebar_width); + let preferred = size( + metrics.0 * f32::from(TerminalView::DEFAULT_GRID_COLUMNS) + chrome.width, + metrics.1 * f32::from(TerminalView::DEFAULT_GRID_LINES) + chrome.height, + ); + display_limit.map_or(preferred, |limit| preferred.min(&limit)) +} + +fn display_limit(cx: &App) -> Option> { + cx.primary_display().map(|display| { + let bounds = display.bounds().size; + size(bounds.width * 0.95, bounds.height * 0.95) + }) +} + +fn fit_native_size(preferred: Size, visible: Option>) -> Size { + // Keep the existing resize floor even for a very small configured font. + // A display smaller than that floor still owns the upper bound. + let preferred = preferred.max(&size(px(760.0), px(540.0))); + visible.map_or(preferred, |visible| preferred.min(&visible)) +} + +fn fit_preflight_size(preferred: Size, cx: &App) -> Size { + fit_native_size(preferred, cx.primary_display().map(|display| display.visible_bounds().size)) +} + +pub(super) fn preferred_size(cx: &App, sidebar_width: f32) -> Option> { + let scale = crate::platform::startup::primary_display_scale()?; + Some(fit_preflight_size( + default_size( + TerminalView::startup_cell_metrics_at_scale(scale, cx), + sidebar_width, + display_limit(cx), + ), + cx, + )) +} + +fn same_device_size(actual: Size, requested: Size, scale: f32) -> bool { + // Window placement quantizes logical sizes to device pixels. A subpixel + // round-trip difference must not schedule a second asynchronous SetWindowPos. + (f32::from(actual.width - requested.width) * scale).abs() < 1.0 + && (f32::from(actual.height - requested.height) * scale).abs() < 1.0 +} + +pub(in crate::gpui_shell::workspace) fn prepare_initial_grid( + window: &mut Window, + cx: &mut App, + sidebar_width: f32, + fit_window_to_default_grid: bool, +) -> (u16, u16) { + let (cell_w, line_h) = TerminalView::cell_metrics(window, cx); + let actual = window.bounds().size; + let target = if fit_window_to_default_grid { + let requested = default_size( + TerminalView::startup_cell_metrics(window, cx), + sidebar_width, + display_limit(cx), + ); + let requested = if crate::platform::startup::primary_display_scale().is_some() { + fit_preflight_size(requested, cx) + } else { + // Keep the original sizing policy on platforms without preflight DPI. + requested + }; + if same_device_size(actual, requested, window.scale_factor()) { + actual + } else { + // DPI changes during creation, failed preflight and unsupported + // platforms retain the measured-window fallback instead of guessing. + window.resize(requested); + requested + } + } else { + // Quick Terminal owns its monitor geometry and slide-in animation. + actual + }; + let chrome = chrome_size(sidebar_width); + let cols = ((f32::from(target.width - chrome.width) / f32::from(cell_w)) + 0.001) + .floor() + .max(2.0) as u16; + let rows = ((f32::from(target.height - chrome.height) / f32::from(line_h)) + 0.001) + .floor() + .max(2.0) as u16; + (cols, rows) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initial_window_keeps_the_base_font_grid_and_fits_the_display() { + let metrics = (px(8.0), px(20.0)); + assert_eq!(default_size(metrics, 230.0, None), size(px(1200.0), px(668.0))); + assert_eq!( + default_size(metrics, 230.0, Some(size(px(1000.0), px(600.0)))), + size(px(1000.0), px(600.0)), + ); + assert_eq!(default_size(metrics, 300.0, None), size(px(1270.0), px(668.0)),); + } + + #[test] + fn startup_size_skips_only_device_pixel_rounding_differences() { + let requested = size(px(1200.0), px(668.0)); + for scale in [1.0, 1.25, 1.5, 2.0] { + assert!(same_device_size(requested, requested, scale)); + assert!(same_device_size( + size(requested.width + px(0.5 / scale), requested.height), + requested, + scale, + )); + assert!(!same_device_size(size(px(1080.0), px(720.0)), requested, scale)); + assert!(!same_device_size( + size(requested.width + px(2.0 / scale), requested.height), + requested, + scale, + )); + } + } + + #[test] + fn small_fonts_keep_the_resize_floor_unless_the_display_is_smaller() { + let small_font = size(px(504.0), px(248.0)); + assert_eq!(fit_native_size(small_font, None), size(px(760.0), px(540.0))); + assert_eq!( + fit_native_size(small_font, Some(size(px(1920.0), px(1040.0)))), + size(px(760.0), px(540.0)), + ); + assert_eq!( + fit_native_size(small_font, Some(size(px(640.0), px(480.0)))), + size(px(640.0), px(480.0)), + ); + assert_eq!( + fit_native_size(size(px(1200.0), px(668.0)), Some(size(px(1000.0), px(600.0)))), + size(px(1000.0), px(600.0)), + ); + } +} diff --git a/nebula_app/src/platform/startup.rs b/nebula_app/src/platform/startup.rs index 0d5f4d9..6ed1924 100644 --- a/nebula_app/src/platform/startup.rs +++ b/nebula_app/src/platform/startup.rs @@ -8,6 +8,35 @@ pub(crate) fn report_error(error: &dyn std::fmt::Display, gui_launch: bool) { #[cfg(windows)] mod console; +#[cfg(feature = "gpui-shell")] +pub(crate) mod first_frame; + +/// Match GPUI's primary-monitor DPI query before a native window is created. +/// Other platforms retain post-creation sizing until their display API exposes scale. +pub(crate) fn primary_display_scale() -> Option { + #[cfg(windows)] + { + use windows_sys::Win32::Foundation::POINT; + use windows_sys::Win32::Graphics::Gdi::{MONITOR_DEFAULTTOPRIMARY, MonitorFromPoint}; + use windows_sys::Win32::UI::HiDpi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI}; + + // SAFETY: the monitor is borrowed and both DPI outputs are valid local pointers. + unsafe { + let monitor = MonitorFromPoint(POINT { x: 0, y: 0 }, MONITOR_DEFAULTTOPRIMARY); + let (mut x, mut y) = (0, 0); + if monitor.is_null() + || GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, &mut x, &mut y) < 0 + || x == 0 + || x != y + { + return None; + } + Some(x as f32 / 96.0) + } + } + #[cfg(not(windows))] + None +} /// Prepare process-wide GUI state before worker threads or terminal children exist. pub(crate) fn prepare_gui_process() -> std::io::Result<()> { diff --git a/nebula_app/src/platform/startup/first_frame.rs b/nebula_app/src/platform/startup/first_frame.rs new file mode 100644 index 0000000..2e44f2e --- /dev/null +++ b/nebula_app/src/platform/startup/first_frame.rs @@ -0,0 +1,64 @@ +//! Present a regular Windows startup window before making it visible. +//! +//! GPUI builds the scene in App::open_window, but its Windows backend normally +//! shows the HWND before that scene exists. Its next-frame callbacks run BEFORE +//! presentation, so neither those callbacks nor App::defer are a paint barrier. + +use gpui::{AnyWindowHandle, App, WindowOptions}; + +pub(crate) fn defer_show(options: &mut WindowOptions) -> bool { + if !cfg!(windows) || !options.show || !options.focus { + return false; + } + // Preserve the requested activation; GPUI retains the original placement + // until activate_window. Silent/background/Quick Terminal paths never enter. + options.show = false; + true +} + +pub(crate) fn present_then_show(handle: AnyWindowHandle, cx: &mut App) { + #[cfg(windows)] + cx.spawn(async move |cx| { + use windows_sys::Win32::UI::WindowsAndMessaging::{SendMessageW, WM_PAINT}; + use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + let hwnd = handle.update(cx, |_, window, _| { + let native = HasWindowHandle::window_handle(window).ok()?; + let RawWindowHandle::Win32(native) = native.as_raw() else { return None }; + Some(native.hwnd.get()) + }); + let Ok(Some(hwnd)) = hwnd else { return }; + + // SAFETY: this one-shot task runs on GPUI's foreground thread. The handle + // was resolved from a live GPUI window above, and no App/Window borrow is + // held across SendMessage: WM_PAINT re-enters GPUI's frame callback. + // Pinned GPUI Windows handles WM_PAINT by synchronously drawing/presenting + // even when hidden. Explicit delivery avoids relying on hidden-window + // WM_PAINT generation by Windows. There are no timers or polling loops. + unsafe { SendMessageW(hwnd as _, WM_PAINT, 0, 0) }; + + // Only after the paint handler returns, use GPUI activation to consume + // its pending placement. Raw ShowWindow would leave that placement stale. + let _ = handle.update(cx, |_, window, _| window.activate_window()); + }) + .detach(); + + #[cfg(not(windows))] + let _ = (handle, cx); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_visible_focused_windows_defer_their_initial_show() { + for (show, focus) in [(true, true), (true, false), (false, true), (false, false)] { + let mut options = WindowOptions { show, focus, ..Default::default() }; + let deferred = defer_show(&mut options); + assert_eq!(deferred, cfg!(windows) && show && focus); + assert_eq!(options.show, show && !deferred); + assert_eq!(options.focus, focus); + } + } +}