Merge upstream main into codex/issue-317-color-emoji
This commit is contained in:
commit
93946622d4
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.
|
||||
+101
@@ -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.
|
||||
@@ -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)。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user