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.
This commit is contained in:
JoyLin authored and GitHub committed 2026-09-27 18:24:30 +08:00
1 parent 7b0cedb5f9
commit 1274319945
17 files changed
+742 -125

No files matched your search

@@ -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<RenderImage>`
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.
@@ -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.
+1
View File
@@ -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)。
+15 -6
View File
@@ -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<bool> {
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 {
@@ -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,
@@ -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<f32>,
) -> (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::<Settings>() {
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,
+8 -95
View File
@@ -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<RefCell<HashMap<(usize, Vec<bool>), Bounds<Pixels>>>>
/// 键:pane id(方向导航要拿所有叶子的屏幕矩形算最近邻)。
type PaneBoundsStore = Rc<RefCell<HashMap<u64, Bounds<Pixels>>>>;
fn decode_sidebar_logo(
logo: crate::display::AiLogo,
dark: bool,
target_size: u32,
) -> Option<Arc<RenderImage>> {
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<RenderImage>> {
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<Pixels>) -> nebula_split::Rect {
nebula_split::Rect::new(
@@ -822,6 +782,7 @@ pub struct NebulaWorkspace {
sidebar_logo_images: HashMap<(crate::display::AiLogo, bool), Arc<RenderImage>>,
/// 品牌图缓存对应的整数物理像素边长;窗口跨 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
@@ -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<RenderImage>>;
#[derive(Default)]
pub(super) struct LogoLoad {
target: u32,
pending: Option<PendingLoad>,
ready: Option<LogoImages>,
}
struct PendingLoad {
cancelled: Arc<AtomicBool>,
_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<NebulaWorkspace>,
) -> Option<LogoImages> {
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<Arc<RenderImage>> {
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<LogoImages> {
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::<std::collections::HashSet<_>>();
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());
}
}
@@ -203,6 +203,7 @@ fn broadcast_mark(side: f32, color: Hsla) -> impl IntoElement {
/// 一个 pane 的标题信息:图标(AI 品牌图 / Nerd Font 字位)+ 一行标题。
struct PaneTitle {
logo: Option<std::sync::Arc<gpui::RenderImage>>,
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))
@@ -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,
@@ -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))
@@ -10,6 +10,7 @@ pub(super) struct TabPresentation {
pub(super) is_settings: bool,
pub(super) activity: SidebarActivity,
pub(super) logo_image: Option<Arc<RenderImage>>,
pub(super) logo_pending: bool,
pub(super) program_glyph: Option<&'static str>,
pub(super) shell_tag: Option<SharedString>,
pub(super) color: Option<Rgb>,
@@ -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,
@@ -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))
@@ -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<NebulaWorkspace>)> {
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))
}
@@ -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<Pixels> {
// 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<Pixels>>,
) -> Size<Pixels> {
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<Size<Pixels>> {
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<Pixels>, visible: Option<Size<Pixels>>) -> Size<Pixels> {
// 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<Pixels>, cx: &App) -> Size<Pixels> {
fit_native_size(preferred, cx.primary_display().map(|display| display.visible_bounds().size))
}
pub(super) fn preferred_size(cx: &App, sidebar_width: f32) -> Option<Size<Pixels>> {
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<Pixels>, requested: Size<Pixels>, 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)),
);
}
}
+29
View File
@@ -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<f32> {
#[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<()> {
@@ -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);
}
}
}