Merge upstream main into fix/issue-283-color-scheme-echo

This commit is contained in:
GeekMr committed 2026-09-26 18:34:42 +08:00
commit eaf472375f
24 files changed
+2018 -80

No files matched your search

@@ -0,0 +1,103 @@
# Windows Acrylic controller ownership and compatibility
## Status
Implemented on the GPUI Windows path; local native acceptance on Windows 11
build 26200.9457, x64 Windows App Runtime 1.8 version 8000.946.1701.0.
## Context
AccentPolicy Acrylic leaves a measurable pattern near screen edges. Dark edge
overlays hide that pattern by changing the visual design. DesktopAcrylicController
reduces it, but its default target policy falls back to opaque gray on deactivation.
Pebrel must retain its existing pixel-alpha opacity model and fast opacity slider.
## Evidence
- A local 2560x1600 / 150% DPI comparison used colored bands and an 8 px checker
behind floating, screen-edge-aligned and maximized GPUI windows. Configuration
applied before SetTarget alone still went gray. Applying it after SetTarget,
followed by zero tint/luminosity opacity, preserved the blurred background.
- The production build's clean background ROI matched that successful prototype
pixel-for-pixel, including active/inactive/reactivated states. It is slightly
brighter than Accent, not a promise of identical pixels across implementations.
- The original 16 valid alternating prototype measurements isolated the target
PID/window-thread CPU and PID GPU counters. Window-thread cycles medians rose
about 5–10%, working set about 3 MiB; CPU time was noisy. Shared DWM cost cannot
be attributed reliably to this HWND. These are local prototype observations,
not a product performance guarantee.
- GPUI's pinned Windows platform runs its quit callback after GetMessage exits;
App shutdown borrows App and its async wait does not pump Windows messages.
- [SetTarget requirements](https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/microsoft.ui.composition.systembackdrops.desktopacryliccontroller.settarget?view=windows-app-sdk-1.8)
require a DispatcherQueue and host backdrop opt-in. The
[native dynamic dependency API](https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/framework-packages/use-the-dynamic-dependency-api)
supports an installed Windows App SDK framework on Windows 11 22H2+.
## Decision
- [acrylic.rs](../../../../nebula_app/src/platform/acrylic.rs) owns controller/target
pairs by GPUI WindowId on the UI thread. One optional compositor/queue is shared
across material changes; there is no worker, frame callback or focus polling.
- Clear Accent, blur-behind and system backdrop before attaching the lower
DesktopWindowTarget. GPUI keeps its upper target. Configuration sets input
active after attachment; tint and luminosity opacity are zero. Window pixel
alpha continues to supply the user's opacity and theme color.
- Close per-window resources before changing material or destroying the HWND.
The synchronous app-quit callback closes remaining targets. A guard around
Application::run drains the owned queue only after run returns, outside the
App borrow. Its own RoInitialize reference survives GPUI's OleUninitialize.
An existing external queue is borrowed and never shut down here.
- Keep runtime activation valid until owned resources are released. Queue drain
failure retains the context until process exit instead of invalidating pending
callbacks. The drain has a two-second deadline; it is not on the render path.
- Resolve native package-graph APIs from the already loaded OS module. Require
build 22621+, the 1.8 framework family and minimum tested 8000.946.1701.0. Select
a matching installed architecture/version; never install or search for DLLs.
Missing runtime, unsupported API or failed attachment uses existing Accent.
- Use a private, narrow WinAppSDK 1.8 ABI plus existing Windows crate projections.
No bootstrap DLL, new NuGet build step or bundled runtime enters the installer.
SDK activation factories are local rather than process-cached interface pointers.
- Keep the existing per-mode/per-window application gate. Failed GPUI updates
are not cached as successful; opacity changes do not recreate native resources.
## Rejected alternatives
- Edge masks, larger opaque borders and CPU/capture blur change the visual design
or add recurring work.
- DWMSBT_TRANSIENTWINDOW/default controller policy does not preserve unfocused
transparency. Reapplying focus policy before SetTarget does not fix that order.
- Bundling the complete runtime exceeds the existing installer budget. Bootstrap
alone still requires runtime deployment. An opportunistic installed-runtime
path preserves startup compatibility without introducing an installer action.
- A new composition thread complicates HWND teardown and adds a thread lifetime.
- Pumping a shutdown queue inside on_app_quit can reenter an already borrowed App.
## Consequences
Machines without the supported runtime retain the old edge behavior. Windows 10,
other GPUs, mixed-DPI monitors, accessibility and system transparency-policy
changes need their own native acceptance; this local run does not certify them.
The selected minimum may be widened only with evidence for that runtime version.
## Validation
- `cargo build -p nebula --locked`
- `cargo test -p nebula --bin pebrel platform::acrylic:: -- --include-ignored --test-threads=1`
exercises missing-package failure, invalid HWND rollback, independent targets,
repeated close/reattach, full-size roots and actual DispatcherQueue shutdown.
- `cargo test -p nebula --bin pebrel native_acrylic_gpui_material_switches_and_window_lifecycle -- --ignored --test-threads=1`
opens real GPUI windows, switches all five material modes, creates a second
window under an unchanged mode, verifies opacity refreshes do not reattach,
closes one window and quits with the other still alive.
- Production captures cover floating/left/right/maximized and focus transitions;
isolated processes exited normally and removed their runtime endpoints.
- Architecture gate uses the upstream-synchronized base commit.
## Supersedes
None. Accent remains the compatibility implementation.
## Revisit when
GPUI owns a system-backdrop lifecycle, the supported runtime changes, or portable
runtime distribution can meet the existing size and platform contracts.
@@ -0,0 +1,110 @@
# Acrylic during native window transitions
## Status
Implemented as a compatibility fallback on the GPUI Windows controller path.
Local native acceptance uses Windows 11 build 26200.9457 and Windows App Runtime
1.8 version 8000.946.1701.0; other compositor/driver combinations need acceptance.
## Context
Desktop Acrylic fixes the Accent edge artifact and preserves unfocused blur, but
its old blurred background can stretch or crop during maximize and restore to a
floating window. Ordinary window movement does not reproduce this incident.
The previous Accent path becomes temporarily clear during those animations.
Taskbar minimize/restore keeps native behavior: clearing before shrink introduces
a more noticeable flash than its existing, brief blurred snapshot.
## Evidence
- Colored horizontal bands and repeated text behind the same GPUI window expose
the stale blur geometry. A flat background does not establish correctness.
- An Accent/controller comparison keeps the GPUI renderer, theme and opacity
constant. The controller's old sample remains visible during the transition;
Accent exposes the clear background and resumes blur afterward. No application
code explicitly pauses Accent during these transitions.
- Native message traces put WINDOWPOSCHANGED and SIZE only a few milliseconds
after the command, while the visible transition continues. Neither message is
a completion notification for the compositor animation.
- The root already fills the desktop target. The public controller APIs provide
no force-recompute or per-HWND animation-completion operation. This does not
establish a documented internal DWM defect.
- [DWMWINDOWATTRIBUTE](https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute)
documents TRANSITIONS_FORCEDISABLED for setting only. A local native probe
confirms readback returns E_INVALIDARG, so it cannot identify animation state.
## Decision
- [transitions.rs](../../../../nebula_app/src/platform/acrylic/transitions.rs)
belongs to the per-window Backdrop and installs a subclass on its owning UI
thread. Pause only the system-backdrop brush before a meaningful system command
or a maximize state change. The latter also covers ShowWindow calls. Minimize
and return from the taskbar are tracked but never initiate a material pause.
Hidden-window startup, ordinary movement and interactive geometry changes
retain their material.
- Resume after a nonblocking 300 ms settling interval, measured to cover the local
native animation. This is a bounded fallback, not an exact completion signal.
Repeated transitions extend the deadline; a queued old timer cannot shorten it.
If a window is minimized during a pending maximum-size transition, the timer
still restores its saved brush without delaying or replaying the minimize.
- Preserve a newer SDK brush instead of overwriting a policy/accessibility update
with the saved brush. Respect the system's SPI_GETANIMATION setting. Do not
change animation settings, focus policy, GPUI content, opacity or startup flow.
- Remove the timer/subclass before closing the controller/target. The subclass
holds its own Rc until removal or HWND destruction; callbacks also hold a local
Rc for nested teardown. No RefCell borrow spans a COM call or DefSubclassProc.
- Timer allocation failure restores usable material and logs the failure; it
must not leave a visible window permanently clear. Failed subclass removal
retains an inert callback allocation until HWND destruction.
- There is no new worker or per-frame update. Additional native work occurs on
state changes and their settling timer, not continuously during rendering.
## Rejected alternatives
- SYSTEMBACKDROP AUTO and a transparent Accent alongside the controller did not
remove the stretched sample in the native comparison.
- Disabling GPUI DirectComposition still reproduced the sample artifact and
removed visible GPUI content in the probe; it is not a production solution.
- Disabling Windows animations or adding edge masks changes the requested visual
behavior. A rendering-backend replacement exceeds this focused repair.
- Restoring at WINDOWPOSCHANGED exposes stale material while DWM is still animating.
- Polling a write-only DWM attribute cannot detect disabled transitions.
- For taskbar minimization, RequestCommitAsync, DwmFlush and earlier CBT notification
still retained the old snapshot in local probes. A 32 ms asynchronous preparation
did clear it, but added a visible clear frame before shrinking. That tradeoff is
rejected: there is no CBT hook, minimize veto/replay or synchronous DWM wait in
production. Preserve the less distracting native minimize animation.
## Consequences
The fallback deliberately exposes a clear background during maximize/windowed
restore transitions, then
restores normal blur. It does not make Acrylic resample throughout the animation.
An unusually long system animation may outlast the measured settling interval.
The system animation setting is supported; per-HWND external suppression has no
supported readback and is not inferred. Runtime changes to accessibility policy
still require their own native visual acceptance.
If the composition target itself fails during restoration, the error is logged;
the guard does not retry COM calls indefinitely or overwrite an unknown SDK state.
## Validation
- Native tests exercise ordinary geometry, system commands, ShowWindowAsync,
queued stale timers, successive transitions, unchanged taskbar material, a newer SDK
brush, owner teardown and HWND destruction before the Rust owner.
- Colored-background recordings compare all four transition directions with the
old Accent and unguarded controller. Static material/focus acceptance remains
documented in the original controller note.
- Test processes have isolated configuration and are closed by their own HWND;
existing user sessions are not used as test targets.
## Supersedes
None. Extends [controller ownership](2026-09-22-acrylic-controller.md) with the
animation compatibility boundary.
## Revisit when
The Windows controller exposes supported transition synchronization, GPUI shares
ownership of the backdrop target, or a tested direct solution preserves material
through the animation without changing the existing rendering behavior.
+2
View File
@@ -8,3 +8,5 @@
- 测试真实控件和布局路径;编译或状态单测不能代替 hover、命中区域、键盘路径和视觉验收。
- 只有跨层所有权、持久化、协议、线程/生命周期或重要性能取舍才写 note,路径镜像到 `architecture/notes/nebula_app/<capability>/`。
- Agent 集成、改键输入与 SSH 认证的因果记录分别位于 [`ai_hook`](../architecture/notes/nebula_app/ai_hook/)、[`input`](../architecture/notes/nebula_app/input/) 和 [`ssh_session`](../architecture/notes/nebula_app/ssh_session/)。
- Windows Acrylic 的运行库回退与窗口生命周期见 [`platform`](../architecture/notes/nebula_app/platform/2026-09-22-acrylic-controller.md)。
- Windows Acrylic 的动画期间透明回退与计时边界见 [`窗口状态切换`](../architecture/notes/nebula_app/platform/2026-09-22-acrylic-window-transitions.md)。
+1 -1
View File
@@ -193,7 +193,7 @@ objc2-app-kit = { version = "0.3.1", default-features = false, features = [
[target.'cfg(windows)'.dependencies]
windows-core = "=0.61.2"
windows = { version = "=0.61.3", features = ["Data_Xml_Dom", "UI_Notifications", "Data_Pdf", "Storage", "Storage_Streams", "Foundation", "Win32_System_WinRT", "Win32_Graphics_Gdi", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_DataExchange", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_SystemServices", "Win32_UI_Shell", "Win32_UI_Shell_PropertiesSystem", "Win32_System_Variant", "Win32_Storage_FileSystem", "Win32_UI_Input_KeyboardAndMouse"] }
windows = { version = "=0.61.3", features = ["Data_Xml_Dom", "UI_Notifications", "Data_Pdf", "Storage", "Storage_Streams", "Foundation", "System", "UI_Composition", "UI_Composition_Desktop", "Win32_System_WinRT_Composition", "Win32_System_WinRT", "Win32_Graphics_Gdi", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_DataExchange", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_SystemServices", "Win32_UI_Shell", "Win32_UI_Shell_PropertiesSystem", "Win32_System_Variant", "Win32_Storage_FileSystem", "Win32_UI_Input_KeyboardAndMouse"] }
dirs = "6.0.0"
dwrote = "0.11.5"
# Registry reads for installed-shell detection (WSL distros under Lxss, Git /
+2
View File
@@ -164,6 +164,7 @@ pub fn run_shell(
}
}
let _runtime_server = runtime_server;
let _acrylic = crate::platform::acrylic::RunGuard::default();
// GPUI 默认只有 macOS 在最后一扇窗关掉后仍驻留(Dock 里留个没有窗口的
// 进程)。Nebula 没有 Dock 重开入口,也没法在 Mac 上验证那个无窗状态,
// 首版三端统一:最后一扇窗关闭即退出(驻留另有 keep_session 能力位管)。
@@ -189,6 +190,7 @@ pub fn run_shell(
/// 组件库/主题/快捷键/用户配置的一次性初始化。
fn init(cx: &mut App) {
crate::platform::acrylic::init(cx);
// 三端都注册内嵌 Maple:Linux/macOS 的系统等宽字体没有 NF 图标码点,
// 侧栏与提示符会出方框;字形同源也是跨平台截图能互相比对的前提。
register_bundled_fonts(cx);
@@ -14,6 +14,46 @@ fn hook(session: &str, name: &str, sequence: u64) -> AiHookEvent {
crate::ai_hook::parse_remote_envelope(wire.as_bytes(), Some(42)).unwrap()
}
#[gpui::test]
fn pi_redraw_anchor_follows_accepted_hook_and_command_lifecycle(cx: &mut TestAppContext) {
use nebula_terminal::term::test::TermSize;
use nebula_terminal::vte::ansi::Processor;
let (view, window, _) = open(cx);
view.update(window, |view, cx| {
let event = |kind: &str, seq: u64| {
crate::ai_hook::parse_remote_envelope(
format!("nebula-hook/1 source=pi pane=42\n{{\"kind\":\"{kind}\",\"session_id\":\"redraw-policy\",\"bridge_sequence\":{seq}}}").as_bytes(),
Some(42),
).unwrap()
};
let check = |view: &mut TerminalView, expected: usize| {
let mut term = view.session.as_ref().unwrap().term.lock();
term.resize(TermSize::new(60, 5));
let mut stream = Processor::<nebula_terminal::vte::ansi::StdSyncHandler>::default();
let text = (0..12).map(|n| format!("ROW_{n:03}")).collect::<Vec<_>>().join("\r\n");
let frame = format!("\x1b[?2026h\x1b[2J\x1b[H\x1b[3J{text}\x1b[?2026l");
term.scroll_display(Scroll::Bottom);
stream.advance(&mut *term, frame.as_bytes());
term.scroll_display(Scroll::Top);
term.scroll_display(Scroll::Delta(-2));
stream.advance(&mut *term, frame.as_bytes());
assert_eq!(term.grid().display_offset(), expected);
};
check(view, 0);
assert!(view.handle_ai_hook(&event("session-start", 1), cx));
check(view, 5);
assert!(view.handle_ai_hook(&event("done", 2), cx));
check(view, 5); // 回合结束不是 Pi 进程退出。
assert!(view.handle_ai_hook(&event("session-end", 3), cx));
check(view, 0);
assert!(view.handle_ai_hook(&event("session-start", 4), cx));
check(view, 5);
view.finish_foreground_command(Some(0), cx);
check(view, 0);
});
}
fn screen(view: &mut TerminalView, text: &str) {
feed(view, format!("\x1b[2J\x1b[H{}", text.replace('\n', "\r\n")).as_bytes());
}
@@ -67,6 +67,11 @@ impl TerminalView {
| crate::ai_hook::AiTurnOutcome::Incomplete
);
}
if let Some(session) = &self.session {
session.term.lock().set_redraw_anchor_enabled(
event.source == "pi" && event.kind != AiHookKind::SessionEnd,
);
}
if let Some(target) = target {
let previous = self.recovery.target.clone();
self.recovery.confirm(target);
@@ -853,6 +853,9 @@ impl TerminalView {
_window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(session) = &self.session {
session.term.lock().cancel_redraw_anchor();
}
let delta_y = event.delta.pixel_delta(self.line_height).y.as_f32();
// 旧壳 `mouse_wheel_input`:Ctrl+滚轮先于一切滚动消费者,一步 1
// 逻辑像素,钳在 4–64。设置页步进会写盘;这里同样写 `font_size=`,
@@ -127,6 +127,9 @@ impl TerminalView {
/// the primary edge; the cached-prompt path calls the same reset so the two
/// lifecycle routes cannot drift apart.
pub(super) fn clear_foreground_agent_state(&mut self, cx: &mut Context<Self>) -> bool {
if let Some(session) = &self.session {
session.term.lock().set_redraw_anchor_enabled(false);
}
self.confirmation.observe_waiting(false);
self.recovery.command_ended();
self.answers.close();
+47 -7
View File
@@ -400,17 +400,22 @@ fn apply_window_effects(cx: &mut App) {
.into_iter()
.filter(|handle| !already_applied.contains(&handle.window_id()))
.collect::<Vec<_>>();
cx.set_global(AppliedBlur { blur, windows: window_ids });
let mut applied =
already_applied.intersection(&window_ids).copied().collect::<HashSet<_>>();
for handle in pending {
if let Err(err) = handle.update(cx, |_, window, _| {
crate::platform::acrylic::remove(window.window_handle().window_id());
window.set_background_appearance(appearance);
#[cfg(windows)]
apply_windows_accent_policy(window, blur, appearance);
window.refresh();
}) {
log::warn!("failed to apply window visual effects: {err}");
} else {
applied.insert(handle.window_id());
}
}
cx.set_global(AppliedBlur { blur, windows: applied });
});
}
@@ -424,11 +429,14 @@ fn apply_window_effects(cx: &mut App) {
/// | `Aero` | state 3 + 玻璃色调 | `DWMSBT_NONE` | 整窗实时玻璃模糊 |
/// | `Mica` | 全零 | `DWMSBT_MAINWINDOW` | 系统壁纸 backdrop |
/// | `Mica Alt` | 全零 | `DWMSBT_TABBEDWINDOW` | 强色调系统壁纸 backdrop |
/// | `Acrylic` | state 4 + 非零 alpha | `DWMSBT_NONE` | 实时模糊 + tint/噪点/饱和 |
/// | `Acrylic` | controller 成功时清零,否则 state 4 | `DWMSBT_NONE` | 实时 Acrylic |
///
/// `Mica` / `Mica Alt` 使用公开 DWM 属性。Nebula 不读取
/// `SPI_GETDESKWALLPAPER` 或 `TranscodedWallpaper`;显示器选择、壁纸排布、模糊和
/// 色调全部交给系统合成器。Windows 不支持该属性时退回 Acrylic,避免透明空洞。
/// Acrylic 在 Windows 11 22H2+ 优先使用可用的 DesktopAcrylicController;缺少
/// Windows App Runtime 或绑定失败时保留 AccentPolicy 回退。控制器只随窗口和
/// 材质档位变化创建/释放,不跟随透明度滑块或焦点重建。
///
/// 两条通道**必须互斥**:同时开 Acrylic 与 system backdrop 时 DWM 的行为未
/// 定义(实测表现为 backdrop 赢,Acrylic 被吞)。所以每档都要把另一条显式
@@ -494,7 +502,7 @@ fn apply_windows_accent_policy(
}
let apply_accent = |mut accent: AccentPolicy, phase: &str| {
let Some(set_attribute) = set_attribute else { return };
let Some(set_attribute) = set_attribute else { return false };
let mut data = WindowCompositionAttributeData {
attribute: 19, // WCA_ACCENT_POLICY
data: &mut accent as *mut _ as *mut core::ffi::c_void,
@@ -503,7 +511,9 @@ fn apply_windows_accent_policy(
// SAFETY: hwnd 来自当前存活的 GPUI 窗口,数据在调用期间保持有效。
if unsafe { set_attribute(hwnd, &mut data) } == 0 {
log::warn!("SetWindowCompositionAttribute({phase}) failed");
return false;
}
true
};
let disabled_accent = AccentPolicy {
@@ -520,9 +530,11 @@ fn apply_windows_accent_policy(
// 必须先移除旧 WCA 层。反过来先写 DWMSBT 时,Aero/Acrylic 的
// AccentPolicy 会阻止 DWM 接纳新材质,事后再清也不会自动重算 frame。
if system_material_requested {
apply_accent(disabled_accent, "clear-before-system-backdrop");
}
let accent_cleared = if system_material_requested || blur == BlurModeName::Acrylic {
apply_accent(disabled_accent, "clear-before-system-backdrop")
} else {
false
};
let blur_behind = DWM_BLURBEHIND {
dwFlags: DWM_BB_ENABLE,
@@ -550,6 +562,17 @@ fn apply_windows_accent_policy(
let system_material_available =
system_material_requested && backdrop_result.is_some_and(|result| result >= 0);
let acrylic_requested = blur == BlurModeName::Acrylic
|| (matches!(blur, BlurModeName::Mica | BlurModeName::MicaAlt)
&& !system_material_available
&& appearance != WindowBackgroundAppearance::Transparent);
let controller_available = acrylic_requested
&& windows_build_number() >= 22_621
&& accent_cleared
&& backdrop_result.is_some_and(|result| result >= 0)
&& blur_behind_result >= 0
&& crate::platform::acrylic::apply(window.window_handle().window_id(), hwnd as isize);
let accent = match blur {
BlurModeName::Acrylic => AccentPolicy {
state: 4, // ACCENT_ENABLE_ACRYLICBLURBEHIND
@@ -576,7 +599,7 @@ fn apply_windows_accent_policy(
BlurModeName::Mica | BlurModeName::MicaAlt | BlurModeName::None => disabled_accent,
};
if !(system_material_requested && system_material_available) {
if !controller_available && !(system_material_requested && system_material_available) {
apply_accent(accent, "final");
}
@@ -813,3 +836,20 @@ fn image_corners(bounds: Bounds<Pixels>, image: Bounds<Pixels>, radius: Pixels)
bottom_right: if right && bottom { radius } else { px(0.0) },
}
}
/// Initialize only the visual state needed by native material acceptance tests.
#[cfg(test)]
pub(crate) fn test_install_visual_effects(cx: &mut App, opacity: f32, blur: BlurModeName) {
cx.set_global(VisualEffects {
opacity,
blur,
wallpaper: None,
generation: Arc::new(AtomicU64::new(0)),
loading: false,
});
}
#[cfg(test)]
pub(crate) fn test_apply_window_effects(cx: &mut App) {
apply_window_effects(cx);
}
+32
View File
@@ -0,0 +1,32 @@
//! Optional native Acrylic lifecycle behind a platform-neutral shell interface.
//! Windows owns the material; other platforms retain their existing GPUI backdrop.
#[cfg(windows)]
mod bindings;
#[cfg(windows)]
mod composition;
#[cfg(windows)]
mod controller;
#[cfg(all(test, windows))]
mod gpui_tests;
#[cfg(windows)]
mod runtime;
#[cfg(all(test, windows))]
mod tests;
#[cfg(windows)]
mod transitions;
#[cfg(all(test, windows))]
pub(crate) use controller::test_snapshot;
#[cfg(windows)]
pub(crate) use controller::{RunGuard, apply, init, remove};
#[cfg(not(windows))]
#[derive(Default)]
pub(crate) struct RunGuard;
#[cfg(not(windows))]
pub(crate) fn init(_: &mut gpui::App) {}
#[cfg(not(windows))]
pub(crate) fn remove(_: gpui::WindowId) {}
+154
View File
@@ -0,0 +1,154 @@
//! Narrow Windows App SDK 1.8 ABI, absent from the OS-only `windows` crate.
//! Interface identities and slot order come from Microsoft.UI.winmd 1.8.
//! Use local activation factories: no SDK COM pointer may outlive our runtime lease.
use std::ffi::c_void;
use windows::UI::Color;
use windows::UI::Composition::CompositionTarget;
use windows::Win32::System::WinRT::{RoActivateInstance, RoGetActivationFactory};
use windows_core::{HRESULT, HSTRING, IInspectable_Vtbl, Interface, Result};
windows_core::imp::define_interface!(Acrylic, AcrylicVtbl, 0x7c20a6af_8eb3_5f08_bdfc_6d35e35dfe45);
#[repr(C)]
pub struct AcrylicVtbl {
base: IInspectable_Vtbl,
get_fallback: unsafe extern "system" fn(*mut c_void, *mut Color) -> HRESULT,
set_fallback: unsafe extern "system" fn(*mut c_void, Color) -> HRESULT,
get_luminosity: unsafe extern "system" fn(*mut c_void, *mut f32) -> HRESULT,
set_luminosity: unsafe extern "system" fn(*mut c_void, f32) -> HRESULT,
get_tint_color: unsafe extern "system" fn(*mut c_void, *mut Color) -> HRESULT,
set_tint_color: unsafe extern "system" fn(*mut c_void, Color) -> HRESULT,
get_tint: unsafe extern "system" fn(*mut c_void, *mut f32) -> HRESULT,
set_tint: unsafe extern "system" fn(*mut c_void, f32) -> HRESULT,
}
windows_core::imp::define_interface!(Statics, StaticsVtbl, 0xa9e8f790_79ef_5416_9b67_6bcfe867c8b7);
#[repr(C)]
pub struct StaticsVtbl {
base: IInspectable_Vtbl,
is_supported: unsafe extern "system" fn(*mut c_void, *mut bool) -> HRESULT,
}
windows_core::imp::define_interface!(
Configuration,
ConfigurationVtbl,
0xebcce1b9_0e0c_5431_ab0e_00f3f0669962
);
#[repr(C)]
pub struct ConfigurationVtbl {
base: IInspectable_Vtbl,
get_high_contrast_color: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> HRESULT,
set_high_contrast_color: unsafe extern "system" fn(*mut c_void, *mut c_void) -> HRESULT,
get_high_contrast: unsafe extern "system" fn(*mut c_void, *mut bool) -> HRESULT,
set_high_contrast: unsafe extern "system" fn(*mut c_void, bool) -> HRESULT,
get_input_active: unsafe extern "system" fn(*mut c_void, *mut bool) -> HRESULT,
set_input_active: unsafe extern "system" fn(*mut c_void, bool) -> HRESULT,
get_theme: unsafe extern "system" fn(*mut c_void, *mut i32) -> HRESULT,
set_theme: unsafe extern "system" fn(*mut c_void, i32) -> HRESULT,
}
windows_core::imp::define_interface!(
Controller,
ControllerVtbl,
0x5632d76c_0b74_5b52_aa33_80262068aeb2
);
#[repr(C)]
pub struct ControllerVtbl {
base: IInspectable_Vtbl,
set_window_target:
unsafe extern "system" fn(*mut c_void, WindowId, *mut c_void, *mut bool) -> HRESULT,
set_core_target:
unsafe extern "system" fn(*mut c_void, *mut c_void, *mut c_void, *mut bool) -> HRESULT,
}
#[repr(C)]
struct WindowId {
value: u64,
}
windows_core::imp::define_interface!(
WithTargets,
WithTargetsVtbl,
0x9c56fe7c_98eb_5f89_ad97_dad57fc30c8c
);
#[repr(C)]
pub struct WithTargetsVtbl {
base: IInspectable_Vtbl,
get_state: unsafe extern "system" fn(*mut c_void, *mut i32) -> HRESULT,
add_target: unsafe extern "system" fn(*mut c_void, *mut c_void, *mut bool) -> HRESULT,
remove_all_targets: unsafe extern "system" fn(*mut c_void) -> HRESULT,
remove_target: unsafe extern "system" fn(*mut c_void, *mut c_void, *mut bool) -> HRESULT,
set_configuration: unsafe extern "system" fn(*mut c_void, *mut c_void) -> HRESULT,
add_state_changed: unsafe extern "system" fn(*mut c_void, *mut c_void, *mut i64) -> HRESULT,
remove_state_changed: unsafe extern "system" fn(*mut c_void, i64) -> HRESULT,
}
const CLASS: &str = "Microsoft.UI.Composition.SystemBackdrops.DesktopAcrylicController";
impl Acrylic {
pub(super) fn new() -> Result<Self> {
unsafe {
let factory: Statics = RoGetActivationFactory(&HSTRING::from(CLASS))?;
let mut supported = false;
(factory.vtable().is_supported)(factory.as_raw(), &mut supported).ok()?;
if !supported {
return Err(windows_core::Error::new(
HRESULT(0x80004001_u32 as i32),
"Acrylic unsupported",
));
}
RoActivateInstance(&HSTRING::from(CLASS))?.cast()
}
}
pub(super) fn attach(&self, hwnd: isize, target: &CompositionTarget) -> Result<()> {
let controller: Controller = self.cast()?;
let mut attached = false;
unsafe {
(controller.vtable().set_window_target)(
controller.as_raw(),
WindowId { value: hwnd as u64 },
target.as_raw(),
&mut attached,
)
.ok()?;
}
if !attached {
return Err(windows_core::Error::new(
HRESULT(0x80004005_u32 as i32),
"Acrylic target rejected",
));
}
Ok(())
}
pub(super) fn configure(&self) -> Result<Configuration> {
unsafe {
let config: Configuration = RoActivateInstance(&HSTRING::from(
"Microsoft.UI.Composition.SystemBackdrops.SystemBackdropConfiguration",
))?
.cast()?;
(config.vtable().set_input_active)(config.as_raw(), true).ok()?;
let targets: WithTargets = self.cast()?;
// SetTarget installs its own focus policy. Override AFTER attaching,
// otherwise deactivation reinstates opaque gray. Keep OS contrast and
// transparency policies; only the focus-derived fallback is disabled.
(targets.vtable().set_configuration)(targets.as_raw(), config.as_raw()).ok()?;
(self.vtable().set_tint_color)(self.as_raw(), Color { A: 255, R: 46, G: 52, B: 64 })
.ok()?;
(self.vtable().set_tint)(self.as_raw(), 0.0).ok()?;
(self.vtable().set_luminosity)(self.as_raw(), 0.0).ok()?;
Ok(config)
}
}
pub(super) fn close(&self) {
match self.cast::<windows::Foundation::IClosable>().and_then(|object| object.Close()) {
Ok(()) => {},
Err(error) => {
log::warn!(target: "nebula", "closing Acrylic controller failed: {error}")
},
}
}
}
@@ -0,0 +1,206 @@
//! STA-affine composition resources. No background worker or per-frame updates.
use std::marker::PhantomData;
use std::rc::Rc;
use std::time::{Duration, Instant};
use windows::System::{DispatcherQueue, DispatcherQueueController};
use windows::UI::Composition::Compositor;
use windows::UI::Composition::Desktop::DesktopWindowTarget;
use windows::Win32::Foundation::HWND;
use windows::Win32::System::WinRT::Composition::ICompositorDesktopInterop;
use windows::Win32::System::WinRT::{
CreateDispatcherQueueController, DQTAT_COM_NONE, DQTYPE_THREAD_CURRENT, DispatcherQueueOptions,
RO_INIT_SINGLETHREADED, RoInitialize, RoUninitialize,
};
use windows_core::{HRESULT, Interface, Result};
use windows_sys::Win32::Graphics::Dwm::{DWMWA_USE_HOSTBACKDROPBRUSH, DwmSetWindowAttribute};
use windows_sys::Win32::UI::WindowsAndMessaging::{
DispatchMessageW, IsWindow, MSG, PM_REMOVE, PeekMessageW, TranslateMessage, WM_QUIT,
};
use super::bindings::{Acrylic, Configuration};
use super::runtime::Runtime;
use super::transitions::Transitions;
struct Apartment;
impl Drop for Apartment {
fn drop(&mut self) {
unsafe { RoUninitialize() };
}
}
pub(super) struct Context {
compositor: Option<Compositor>,
queue: Option<DispatcherQueueController>,
// Drop order: SDK objects/queue, runtime package lease, then our own STA ref.
_runtime: Runtime,
_apartment: Apartment,
_thread: PhantomData<Rc<()>>,
}
impl Context {
pub(super) fn new() -> Result<Self> {
unsafe { RoInitialize(RO_INIT_SINGLETHREADED)? };
let apartment = Apartment;
let runtime = Runtime::load()?;
let queue = if DispatcherQueue::GetForCurrentThread().is_ok() {
None
} else {
Some(unsafe {
CreateDispatcherQueueController(DispatcherQueueOptions {
dwSize: std::mem::size_of::<DispatcherQueueOptions>() as u32,
threadType: DQTYPE_THREAD_CURRENT,
apartmentType: DQTAT_COM_NONE,
})?
})
};
// Delay fallible compositor activation until State owns this context;
// even a failed attach must drain an owned queue at application shutdown.
Ok(Self {
compositor: None,
queue,
_runtime: runtime,
_apartment: apartment,
_thread: PhantomData,
})
}
pub(super) fn attach(&mut self, hwnd: isize) -> Result<Backdrop> {
if self.compositor.is_none() {
self.compositor = Some(Compositor::new()?);
}
let compositor = self.compositor.as_ref().unwrap();
let host = HostBackdrop::new(hwnd)?;
let interop: ICompositorDesktopInterop = compositor.cast()?;
// GPUI owns the topmost target; the material occupies the lower target.
let target = unsafe { interop.CreateDesktopWindowTarget(HWND(hwnd as _), false)? };
let mut backdrop =
Backdrop { transitions: None, controller: None, config: None, target, _host: host };
let root = compositor.CreateContainerVisual()?;
let mut relative_size = root.RelativeSizeAdjustment()?;
relative_size.X = 1.0;
relative_size.Y = 1.0;
root.SetRelativeSizeAdjustment(relative_size)?;
backdrop.target.SetRoot(&root)?;
backdrop.controller = Some(Acrylic::new()?);
let controller = backdrop.controller.as_ref().unwrap();
controller.attach(hwnd, &backdrop.target.cast()?)?;
backdrop.config = Some(controller.configure()?);
backdrop.transitions = Some(Transitions::new(hwnd, backdrop.target.cast()?)?);
Ok(backdrop)
}
/// Called outside GPUI's App borrow, AFTER Application::run returns. Never
/// pump messages in a window-close callback or on_app_quit (re-entrancy).
pub(super) fn shutdown(mut self) -> bool {
if let Some(compositor) = self.compositor.take() {
if let Err(error) = compositor.Close() {
log::warn!(target: "nebula", "closing Acrylic compositor failed: {error}");
}
}
let Some(queue) = self.queue.as_ref() else { return true };
let shutdown = match queue.ShutdownQueueAsync() {
Ok(shutdown) => shutdown,
Err(error) => {
log::warn!(target: "nebula", "Acrylic queue shutdown failed: {error}");
// Keep package resolution and our STA alive for outstanding
// callbacks until process exit. This is not a live-window leak.
std::mem::forget(self);
return false;
},
};
let deadline = Instant::now() + Duration::from_secs(2);
while shutdown.Status().is_ok_and(|status| status.0 == 0) && Instant::now() < deadline {
let mut message: MSG = unsafe { std::mem::zeroed() };
// The outer application loop has ended; WM_QUIT needs no repost.
unsafe {
while Instant::now() < deadline
&& PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_REMOVE) != 0
{
if message.message != WM_QUIT {
TranslateMessage(&message);
DispatchMessageW(&message);
}
}
}
std::thread::sleep(Duration::from_millis(1));
}
if shutdown.Status().is_ok_and(|status| status.0 == 1) {
if let Err(error) = shutdown.GetResults() {
log::warn!(target: "nebula", "Acrylic queue shutdown result: {error}");
std::mem::forget(shutdown);
std::mem::forget(self);
return false;
}
true
} else {
log::warn!(target: "nebula", "Acrylic queue did not drain; retaining runtime until process exit");
std::mem::forget(shutdown);
std::mem::forget(self);
false
}
}
}
pub(super) struct Backdrop {
transitions: Option<Transitions>,
controller: Option<Acrylic>,
config: Option<Configuration>,
target: DesktopWindowTarget,
_host: HostBackdrop,
}
#[cfg(test)]
impl Backdrop {
pub(super) fn has_live_full_size_root(&self) -> bool {
self.target
.Root()
.and_then(|root| root.RelativeSizeAdjustment())
.is_ok_and(|size| size.X == 1.0 && size.Y == 1.0)
}
}
impl Drop for Backdrop {
fn drop(&mut self) {
// The HWND callback must stop using its target before the SDK closes it.
self.transitions.take();
if let Some(controller) = self.controller.take() {
controller.close();
}
self.config.take();
if let Err(error) = self.target.Close() {
log::warn!(target: "nebula", "closing Acrylic desktop target failed: {error}");
}
}
}
struct HostBackdrop(isize);
impl HostBackdrop {
fn new(hwnd: isize) -> Result<Self> {
Self::set(hwnd, true)?;
Ok(Self(hwnd))
}
fn set(hwnd: isize, enabled: bool) -> Result<()> {
let value = i32::from(enabled);
unsafe {
HRESULT(DwmSetWindowAttribute(
hwnd as _,
DWMWA_USE_HOSTBACKDROPBRUSH as u32,
&value as *const _ as _,
std::mem::size_of_val(&value) as u32,
))
.ok()
}
}
}
impl Drop for HostBackdrop {
fn drop(&mut self) {
if unsafe { IsWindow(self.0 as _) } != 0 {
if let Err(error) = Self::set(self.0, false) {
log::warn!(target: "nebula", "clearing Acrylic host backdrop failed: {error}");
}
}
}
}
@@ -0,0 +1,94 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::marker::PhantomData;
use std::rc::Rc;
use super::composition::{Backdrop, Context};
#[derive(Default)]
struct State {
windows: HashMap<gpui::WindowId, Backdrop>,
context: Option<Context>,
unavailable: bool,
#[cfg(test)]
attachments: usize,
}
thread_local! {
static STATE: RefCell<State> = RefCell::new(State::default());
}
/// Owns final queue drain outside the App's shutdown borrow. Must surround run.
#[derive(Default)]
pub(crate) struct RunGuard(PhantomData<Rc<()>>);
impl Drop for RunGuard {
fn drop(&mut self) {
let mut state = STATE.with(|state| std::mem::take(&mut *state.borrow_mut()));
state.windows.clear();
if let Some(context) = state.context.take() {
context.shutdown();
}
}
}
pub(crate) fn init(cx: &mut gpui::App) {
cx.on_window_closed(|_, id| remove(id)).detach();
cx.on_app_quit(|_| {
STATE.with(|state| state.borrow_mut().windows.clear());
async {}
})
.detach();
}
pub(crate) fn remove(id: gpui::WindowId) {
STATE.with(|state| {
state.borrow_mut().windows.remove(&id);
});
}
/// Called only for a new HWND or material change, never an opacity/frame update.
pub(crate) fn apply(id: gpui::WindowId, hwnd: isize) -> bool {
STATE.with(|state| {
let mut state = state.borrow_mut();
if state.windows.contains_key(&id) {
return true;
}
if state.unavailable {
return false;
}
if state.context.is_none() {
match Context::new() {
Ok(context) => state.context = Some(context),
Err(error) => {
state.unavailable = true;
log::info!(target: "nebula", "Desktop Acrylic unavailable; using Accent fallback: {error}");
return false;
},
}
}
match state.context.as_mut().unwrap().attach(hwnd) {
Ok(backdrop) => {
state.windows.insert(id, backdrop);
#[cfg(test)]
{
state.attachments += 1;
}
log::debug!(target: "nebula", "Desktop Acrylic attached to HWND {hwnd:#x}");
true
},
Err(error) => {
log::warn!(target: "nebula", "Desktop Acrylic attach failed; using Accent fallback: {error}");
false
},
}
})
}
#[cfg(test)]
pub(crate) fn test_snapshot() -> (usize, usize) {
STATE.with(|state| {
let state = state.borrow();
(state.windows.len(), state.attachments)
})
}
@@ -0,0 +1,114 @@
//! Opt-in real GPUI/WinRT lifecycle acceptance; no terminal, settings or user data.
use crate::gpui_shell::wallpaper::{
VisualEffects, test_apply_window_effects as apply_window_effects, test_install_visual_effects,
};
use crate::platform::acrylic;
use gpui::{App, AppContext, AsyncApp, Context, Render, WindowHandle, WindowOptions};
use gpui::{IntoElement, Styled, Window, WindowBackgroundAppearance, div};
use nebula_settings::BlurModeName;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::time::Duration;
struct EmptyWindow;
impl Render for EmptyWindow {
fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement {
div().size_full()
}
}
fn open(cx: &mut App) -> WindowHandle<EmptyWindow> {
cx.open_window(
WindowOptions {
window_background: WindowBackgroundAppearance::Transparent,
..Default::default()
},
|_, cx| cx.new(|_| EmptyWindow),
)
.unwrap()
}
async fn settle(cx: &AsyncApp, expected: usize) -> Result<(), String> {
cx.background_executor().timer(Duration::from_millis(180)).await;
let actual = acrylic::test_snapshot().0;
if actual != expected {
return Err(format!("expected {expected} native backdrops, observed {actual}"));
}
Ok(())
}
async fn exercise(first: WindowHandle<EmptyWindow>, cx: &AsyncApp) -> Result<(), String> {
settle(cx, 1).await?;
let second = cx.update(|cx| {
let window = open(cx);
apply_window_effects(cx);
window
});
settle(cx, 2).await?;
for mode in [
BlurModeName::Aero,
BlurModeName::Acrylic,
BlurModeName::Mica,
BlurModeName::Acrylic,
BlurModeName::MicaAlt,
BlurModeName::Acrylic,
BlurModeName::None,
BlurModeName::Acrylic,
] {
cx.update(|cx| {
cx.global_mut::<VisualEffects>().blur = mode;
apply_window_effects(cx);
});
settle(cx, if mode == BlurModeName::Acrylic { 2 } else { 0 }).await?;
}
let before = acrylic::test_snapshot();
for value in 0..=20 {
cx.update(|cx| {
cx.global_mut::<VisualEffects>().opacity = value as f32 / 20.0;
apply_window_effects(cx);
});
}
settle(cx, 2).await?;
if acrylic::test_snapshot() != before {
return Err("opacity refresh recreated native resources".into());
}
cx.update(|cx| first.update(cx, |_, window, _| window.remove_window()))
.map_err(|error| error.to_string())?;
settle(cx, 1).await?;
cx.update(|cx| second.update(cx, |_, window, _| window.refresh()))
.map_err(|error| error.to_string())?;
Ok(())
}
#[test]
#[ignore = "opens real GPUI windows; requires Windows 11 and Windows App Runtime 1.8"]
fn native_acrylic_gpui_material_switches_and_window_lifecycle() {
let result = Rc::new(RefCell::new(None));
let shared_result = result.clone();
let timed_out = Rc::new(Cell::new(false));
let watchdog = timed_out.clone();
let guard = acrylic::RunGuard::default();
gpui_platform::application().run(move |cx| {
acrylic::init(cx);
test_install_visual_effects(cx, 0.4, BlurModeName::Acrylic);
let first = open(cx);
apply_window_effects(cx);
cx.spawn(async move |cx| {
*shared_result.borrow_mut() = Some(exercise(first, cx).await);
cx.update(|cx| cx.quit());
})
.detach();
cx.spawn(async move |cx| {
cx.background_executor().timer(Duration::from_secs(15)).await;
watchdog.set(true);
cx.update(|cx| cx.quit());
})
.detach();
});
drop(guard);
assert!(!timed_out.get(), "normal GPUI quit must finish before the watchdog");
assert_eq!(result.borrow_mut().take(), Some(Ok(())));
assert_eq!(acrylic::test_snapshot().0, 0);
assert!(windows::System::DispatcherQueue::GetForCurrentThread().is_err());
}
+105
View File
@@ -0,0 +1,105 @@
//! Optional, process-scoped Windows App Runtime package graph lease.
//!
//! Resolve the Windows 11 API dynamically so older Windows can still load Pebrel.
//! No bootstrap DLL, installation, filesystem probing or global package mutation.
use std::ffi::c_void;
use std::ptr::{null, null_mut};
use windows_core::{Error, HRESULT, Result};
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress};
use windows_sys::Win32::System::Memory::{GetProcessHeap, HeapFree};
type Create = unsafe extern "system" fn(
*const c_void,
*const u16,
u64,
i32,
i32,
*const u16,
u32,
*mut *mut u16,
) -> i32;
type Add = unsafe extern "system" fn(*const u16, i32, u32, *mut *mut c_void, *mut *mut u16) -> i32;
type Remove = unsafe extern "system" fn(*mut c_void) -> i32;
type Delete = unsafe extern "system" fn(*const u16) -> i32;
pub(super) struct Runtime {
context: *mut c_void,
id: *mut u16,
remove: Remove,
delete: Delete,
}
impl Runtime {
pub(super) fn load() -> Result<Self> {
Self::load_family(windows_core::w!("Microsoft.WindowsAppRuntime.1.8_8wekyb3d8bbwe"))
}
fn load_family(family: windows_core::PCWSTR) -> Result<Self> {
// SAFETY: kernelbase is an OS module already loaded for this process. Each
// export has the signature in the Windows SDK's appmodel.h (22621+).
unsafe {
let module = GetModuleHandleW(windows_core::w!("kernelbase.dll").as_ptr());
let proc = |name: &'static std::ffi::CStr| {
GetProcAddress(module, name.as_ptr().cast()).ok_or_else(Error::from_win32)
};
if module.is_null() {
return Err(Error::from_win32());
}
let create: Create = std::mem::transmute(proc(c"TryCreatePackageDependency")?);
let add: Add = std::mem::transmute(proc(c"AddPackageDependency")?);
let remove: Remove = std::mem::transmute(proc(c"RemovePackageDependency")?);
let delete: Delete = std::mem::transmute(proc(c"DeletePackageDependency")?);
let mut lease = Self { context: null_mut(), id: null_mut(), remove, delete };
// Pin the minimum runtime actually validated, allowing newer patches
// in the same 1.8 family. Process lifetime; default architecture.
HRESULT(create(
null(),
family.as_ptr(),
(8000_u64 << 48) | (946_u64 << 32) | (1701_u64 << 16),
0,
0,
null(),
0,
&mut lease.id,
))
.ok()?;
let mut full_name = null_mut();
let result = HRESULT(add(lease.id, 0, 0, &mut lease.context, &mut full_name));
if !full_name.is_null() {
if let Ok(name) = windows_core::PCWSTR(full_name).to_string() {
log::debug!(target: "nebula", "Acrylic runtime: {name}");
}
HeapFree(GetProcessHeap(), 0, full_name.cast());
}
result.ok()?;
Ok(lease)
}
}
}
#[cfg(test)]
#[test]
fn absent_runtime_is_a_recoverable_error() {
assert!(
Runtime::load_family(windows_core::w!("Pebrel.MissingAcrylicRuntime_8wekyb3d8bbwe"))
.is_err()
);
}
impl Drop for Runtime {
fn drop(&mut self) {
// Release owned SDK objects before removing their activation context.
// Removing the graph entry does not unload already loaded runtime DLLs.
unsafe {
if !self.context.is_null() {
let _ = (self.remove)(self.context);
}
if !self.id.is_null() {
let _ = (self.delete)(self.id);
HeapFree(GetProcessHeap(), 0, self.id.cast());
}
}
}
}
+225
View File
@@ -0,0 +1,225 @@
use super::composition::Context;
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
use windows_sys::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DestroyWindow, DispatchMessageW, PM_REMOVE, PeekMessageW, TranslateMessage,
WS_EX_NOREDIRECTIONBITMAP, WS_OVERLAPPEDWINDOW,
};
struct TestWindow(isize);
impl TestWindow {
fn new() -> Self {
let hwnd = unsafe {
CreateWindowExW(
WS_EX_NOREDIRECTIONBITMAP,
windows_core::w!("STATIC").as_ptr(),
windows_core::w!("Pebrel Acrylic lifecycle test").as_ptr(),
WS_OVERLAPPEDWINDOW,
0,
0,
320,
240,
std::ptr::null_mut(),
std::ptr::null_mut(),
GetModuleHandleW(std::ptr::null()),
std::ptr::null(),
)
};
assert!(!hwnd.is_null());
Self(hwnd as isize)
}
}
impl Drop for TestWindow {
fn drop(&mut self) {
assert_ne!(unsafe { DestroyWindow(self.0 as _) }, 0);
}
}
#[test]
#[ignore = "requires interactive Windows 11 and Windows App Runtime 1.8 >= 8000.946.1701.0"]
fn native_targets_rollback_switch_independently_and_drain_before_sta_exit() {
// Real COM/WinRT calls protect ABI, failure rollback, two HWND ownership and
// shutdown. This does not substitute for GPUI focus/edge screenshot checks.
let mut context = Context::new().expect("required native runtime");
let first = TestWindow::new();
let second = TestWindow::new();
assert!(context.attach(0).is_err());
for _ in 0..8 {
let a = context.attach(first.0).expect("first native target");
let b = context.attach(second.0).expect("independent second target");
assert!(a.has_live_full_size_root() && b.has_live_full_size_root());
drop(a);
assert!(b.has_live_full_size_root());
// Reattach the same HWND while the other remains alive (material toggle).
let a = context.attach(first.0).expect("reattach after Close");
let mut message = unsafe { std::mem::zeroed() };
unsafe {
while PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_REMOVE) != 0 {
TranslateMessage(&message);
DispatchMessageW(&message);
}
}
drop(b);
assert!(a.has_live_full_size_root());
drop(a);
}
drop(first);
drop(second);
assert!(context.shutdown(), "owned queue must actually complete shutdown");
assert!(windows::System::DispatcherQueue::GetForCurrentThread().is_err());
}
#[test]
#[ignore = "opens a native window; requires interactive Windows 11 and Windows App Runtime 1.8"]
fn native_transition_guard_preserves_moves_restarts_deadlines_and_releases_its_hook() {
use std::time::{Duration, Instant};
use windows::UI::Color;
use windows::UI::Composition::{Compositor, ICompositionSupportsSystemBackdrop};
use windows::Win32::Foundation::HWND;
use windows::Win32::System::WinRT::Composition::ICompositorDesktopInterop;
use windows_core::Interface;
use windows_sys::Win32::UI::WindowsAndMessaging::{
PM_NOREMOVE, SC_MAXIMIZE, SC_MINIMIZE, SC_RESTORE, SW_MAXIMIZE, SW_MINIMIZE, SW_RESTORE,
SW_SHOWNOACTIVATE, SWP_NOACTIVATE, SWP_NOZORDER, SendMessageW, SetWindowPos, ShowWindow,
ShowWindowAsync, WM_SYSCOMMAND, WM_TIMER,
};
let pump = |millis| {
let until = Instant::now() + Duration::from_millis(millis);
while Instant::now() < until {
let mut message = unsafe { std::mem::zeroed() };
unsafe {
while PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_REMOVE) != 0 {
TranslateMessage(&message);
DispatchMessageW(&message);
}
}
std::thread::sleep(Duration::from_millis(2));
}
};
let context = Context::new().unwrap();
let window = TestWindow::new();
let compositor = Compositor::new().unwrap();
let interop: ICompositorDesktopInterop = compositor.cast().unwrap();
let target = unsafe { interop.CreateDesktopWindowTarget(HWND(window.0 as _), false).unwrap() };
let root = compositor.CreateContainerVisual().unwrap();
target.SetRoot(&root).unwrap();
let support: ICompositionSupportsSystemBackdrop = target.cast().unwrap();
let original =
compositor.CreateColorBrushWithColor(Color { A: 255, R: 40, G: 70, B: 90 }).unwrap();
support.SetSystemBackdrop(&original).unwrap();
let guard = super::transitions::Transitions::new(window.0, support.clone()).unwrap();
unsafe {
ShowWindow(window.0 as _, SW_SHOWNOACTIVATE);
SetWindowPos(
window.0 as _,
std::ptr::null_mut(),
100,
100,
360,
260,
SWP_NOACTIVATE | SWP_NOZORDER,
);
}
assert_eq!(
support.SystemBackdrop().unwrap(),
original.cast().unwrap(),
"ordinary geometry keeps the material"
);
unsafe { SendMessageW(window.0 as _, WM_SYSCOMMAND, SC_MAXIMIZE as usize, 0) };
assert!(support.SystemBackdrop().is_err(), "maximization removes the stale sample");
// Make a WM_TIMER from the old transition pending without dispatching it.
std::thread::sleep(Duration::from_millis(340));
let mut message = unsafe { std::mem::zeroed() };
assert_ne!(
unsafe { PeekMessageW(&mut message, window.0 as _, WM_TIMER, WM_TIMER, PM_NOREMOVE) },
0
);
unsafe { SendMessageW(window.0 as _, WM_SYSCOMMAND, SC_RESTORE as usize, 0) };
pump(70);
assert!(
support.SystemBackdrop().is_err(),
"an old queued timer must not end the new transition"
);
pump(320);
assert_eq!(support.SystemBackdrop().unwrap(), original.cast().unwrap());
// GPUI's titlebar takes the ShowWindow path, without WM_SYSCOMMAND.
for action in [SW_MAXIMIZE, SW_RESTORE] {
unsafe {
ShowWindowAsync(window.0 as _, action);
}
pump(20);
assert!(support.SystemBackdrop().is_err(), "programmatic transitions also pause");
pump(340);
assert_eq!(support.SystemBackdrop().unwrap(), original.cast().unwrap());
}
unsafe { SendMessageW(window.0 as _, WM_SYSCOMMAND, SC_MINIMIZE as usize, 0) };
assert_eq!(
support.SystemBackdrop().unwrap(),
original.cast().unwrap(),
"minimize keeps native material"
);
pump(340);
assert_eq!(support.SystemBackdrop().unwrap(), original.cast().unwrap());
unsafe { SendMessageW(window.0 as _, WM_SYSCOMMAND, SC_RESTORE as usize, 0) };
pump(340);
assert_eq!(
support.SystemBackdrop().unwrap(),
original.cast().unwrap(),
"restore from taskbar keeps native material"
);
unsafe { SendMessageW(window.0 as _, WM_SYSCOMMAND, SC_MAXIMIZE as usize, 0) };
assert!(support.SystemBackdrop().is_err());
let newer = compositor.CreateColorBrushWithColor(Color { A: 255, R: 0, G: 0, B: 0 }).unwrap();
support.SetSystemBackdrop(&newer).unwrap();
pump(340);
assert_eq!(
support.SystemBackdrop().unwrap(),
newer.cast().unwrap(),
"a newer SDK policy brush wins"
);
for action in [SW_MINIMIZE, SW_RESTORE] {
unsafe {
ShowWindowAsync(window.0 as _, action);
}
pump(340);
assert_eq!(
support.SystemBackdrop().unwrap(),
newer.cast().unwrap(),
"maximized taskbar transitions keep native material"
);
}
unsafe { SendMessageW(window.0 as _, WM_SYSCOMMAND, SC_RESTORE as usize, 0) };
assert!(support.SystemBackdrop().is_err());
drop(guard);
support.SetSystemBackdrop(&newer).unwrap();
pump(340);
assert_eq!(
support.SystemBackdrop().unwrap(),
newer.cast().unwrap(),
"dropping the owner cancels pending restoration"
);
// Native HWND destruction can precede the Rust owner during reentrant close.
let guard = super::transitions::Transitions::new(window.0, support.clone()).unwrap();
unsafe { SendMessageW(window.0 as _, WM_SYSCOMMAND, SC_MAXIMIZE as usize, 0) };
assert!(support.SystemBackdrop().is_err());
drop(window);
pump(340);
drop(guard);
drop(support);
target.Close().unwrap();
drop(target);
drop(interop);
drop(root);
drop(original);
drop(newer);
compositor.Close().unwrap();
drop(compositor);
assert!(context.shutdown());
}
@@ -0,0 +1,270 @@
//! Keep a stale Acrylic sample out of maximize/restore-to-window animations.
//!
//! Unlike Accent, the controller's composition target remains visible while DWM
//! scales the old window representation. Temporarily clear only that backdrop;
//! the GPUI scene, pixel opacity, focus policy and Windows animation stay intact.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::time::{Duration, Instant};
use windows::UI::Composition::{CompositionBrush, ICompositionSupportsSystemBackdrop};
use windows_core::{Error, Result};
use windows_sys::Win32::Foundation::HWND;
use windows_sys::Win32::UI::Shell::{
DefSubclassProc, GetWindowSubclass, RemoveWindowSubclass, SetWindowSubclass,
};
use windows_sys::Win32::UI::WindowsAndMessaging::{
ANIMATIONINFO, IsIconic, IsWindowVisible, IsZoomed, KillTimer, SC_MAXIMIZE, SC_RESTORE,
SPI_GETANIMATION, SetTimer, SystemParametersInfoW, WM_NCDESTROY, WM_SETTINGCHANGE,
WM_SYSCOMMAND, WM_TIMER, WM_WINDOWPOSCHANGING,
};
// WM_WINDOWPOSCHANGED precedes the compositor animation's completion. Windows
// exposes no per-HWND completion event here. This bounded, nonblocking settling
// interval covers the native transition measured in our acceptance probes; it is
// not a claim to observe DWM completion. Repeated transitions extend the deadline.
const SETTLE: Duration = Duration::from_millis(300);
#[derive(Clone, Copy, PartialEq, Eq)]
struct ShowState {
maximized: bool,
minimized: bool,
}
impl ShowState {
fn read(hwnd: HWND) -> Self {
Self {
maximized: unsafe { IsZoomed(hwnd) != 0 },
minimized: unsafe { IsIconic(hwnd) != 0 },
}
}
fn changes_for(self, command: usize) -> bool {
match command as u32 & 0xfff0 {
SC_MAXIMIZE => !self.maximized && !self.minimized,
SC_RESTORE => self.maximized && !self.minimized,
_ => false,
}
}
}
struct State {
target: ICompositionSupportsSystemBackdrop,
saved: RefCell<Option<CompositionBrush>>,
show: Cell<ShowState>,
deadline: Cell<Option<Instant>>,
attached: Cell<bool>,
native_ref: Cell<bool>,
}
/// Owned by one Backdrop, on its HWND's thread; no global registry or worker.
pub(super) struct Transitions {
hwnd: isize,
state: Rc<State>,
}
impl Transitions {
pub(super) fn new(hwnd: isize, target: ICompositionSupportsSystemBackdrop) -> Result<Self> {
let state = Rc::new(State {
target,
saved: RefCell::new(None),
show: Cell::new(ShowState::read(hwnd as _)),
deadline: Cell::new(None),
attached: Cell::new(false),
native_ref: Cell::new(false),
});
let id = Rc::as_ptr(&state) as usize;
let native = Rc::into_raw(state.clone());
// SAFETY: the subclass owns a strong reference until successful removal
// or WM_NCDESTROY. Each invocation also takes an Rc for nested teardown.
if unsafe { SetWindowSubclass(hwnd as _, Some(window_proc), id, id) } == 0 {
let error = Error::from_win32();
unsafe {
drop(Rc::from_raw(native));
}
return Err(error);
}
state.native_ref.set(true);
state.attached.set(true);
Ok(Self { hwnd, state })
}
}
impl Drop for Transitions {
fn drop(&mut self) {
self.state.detach(self.hwnd as _, Rc::as_ptr(&self.state) as usize, false);
}
}
impl State {
fn detach(&self, hwnd: HWND, id: usize, destroying: bool) {
self.attached.set(false);
if self.native_ref.get() {
let mut data = 0;
let removed = unsafe {
KillTimer(hwnd, id);
RemoveWindowSubclass(hwnd, Some(window_proc), id) != 0
|| destroying
|| GetWindowSubclass(hwnd, Some(window_proc), id, &mut data) == 0
};
if removed && self.native_ref.replace(false) {
// The caller still holds the owner or callback Rc here.
unsafe {
Rc::decrement_strong_count(self as *const State);
}
} else {
// Keep an inert failed-to-remove callback alive until destruction.
log::warn!(target: "nebula", "Acrylic transition hook removal failed; retaining it until HWND destruction");
}
}
self.deadline.set(None);
let saved = self.saved.borrow_mut().take();
drop(saved);
}
fn cancel(&self, hwnd: HWND, id: usize) {
unsafe {
KillTimer(hwnd, id);
}
self.deadline.set(None);
self.resume();
}
fn pause(&self, hwnd: HWND, id: usize) {
if !self.attached.get() || unsafe { IsWindowVisible(hwnd) } == 0 {
return;
}
if !animations_enabled() {
self.cancel(hwnd, id);
return;
}
// Arm before clearing; a failed timer must not leave a live window clear.
if unsafe { SetTimer(hwnd, id, SETTLE.as_millis() as u32, None) } == 0 {
log::warn!(target: "nebula", "Acrylic transition timer failed; retaining the normal material");
self.cancel(hwnd, id);
return;
}
self.deadline.set(Some(Instant::now() + SETTLE));
let needs_brush = self.saved.borrow().is_none();
if needs_brush {
let Ok(brush) = self.target.SystemBackdrop() else { return };
if !self.attached.get() {
return;
}
match self.target.SetSystemBackdrop(None::<&CompositionBrush>) {
Ok(()) if self.attached.get() => {
let previous = self.saved.replace(Some(brush));
drop(previous);
},
Ok(()) => {},
Err(error) => {
log::warn!(target: "nebula", "pausing Acrylic transition failed: {error}")
},
}
}
}
fn resume(&self) {
let saved = self.saved.borrow_mut().take();
if let Some(brush) = saved {
if !self.attached.get() {
return;
}
// Null WinRT interfaces map to Error::empty (code 0). A real COM
// error does not prove that the SDK's current material is empty.
match self.target.SystemBackdrop() {
Err(error) if error.code().0 == 0 && self.attached.get() => {
if let Err(error) = self.target.SetSystemBackdrop(&brush) {
log::warn!(target: "nebula", "resuming Acrylic transition failed: {error}");
}
},
Err(error) => {
log::warn!(target: "nebula", "reading Acrylic transition material failed: {error}")
},
// A newer SDK brush, including an accessibility fallback, wins.
Ok(_) => {},
}
}
}
fn on_timer(&self, hwnd: HWND, id: usize) {
if !self.attached.get() {
return;
}
unsafe { KillTimer(hwnd, id) };
let Some(deadline) = self.deadline.get() else { return };
if let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
// KillTimer does not remove queued WM_TIMER messages. A stale message
// from an earlier transition must not reveal the backdrop too soon.
let millis = remaining.as_millis().saturating_add(1).min(u32::MAX as u128) as u32;
if unsafe { SetTimer(hwnd, id, millis, None) } != 0 {
return;
}
// On resource failure, a usable material is preferable to leaving
// the window permanently clear. The original artifact can recur.
log::warn!(target: "nebula", "Acrylic transition timer rearm failed; restoring material");
}
self.deadline.set(None);
self.resume();
}
}
fn animations_enabled() -> bool {
let mut animation =
ANIMATIONINFO { cbSize: std::mem::size_of::<ANIMATIONINFO>() as u32, iMinAnimate: 1 };
unsafe {
if SystemParametersInfoW(
SPI_GETANIMATION,
animation.cbSize,
&mut animation as *mut _ as _,
0,
) != 0
&& animation.iMinAnimate == 0
{
return false;
}
}
true
}
unsafe extern "system" fn window_proc(
hwnd: HWND,
message: u32,
wparam: usize,
lparam: isize,
id: usize,
data: usize,
) -> isize {
if !matches!(
message,
WM_SYSCOMMAND | WM_WINDOWPOSCHANGING | WM_TIMER | WM_NCDESTROY | WM_SETTINGCHANGE
) {
return unsafe { DefSubclassProc(hwnd, message, wparam, lparam) };
}
// SAFETY: the installed subclass retains this raw Rc allocation.
// No mutable borrow or borrowed state crosses DefSubclassProc/COM callbacks.
let state = unsafe {
Rc::increment_strong_count(data as *const State);
Rc::from_raw(data as *const State)
};
if message == WM_NCDESTROY {
state.detach(hwnd, id, true);
} else if message == WM_TIMER && wparam == id {
state.on_timer(hwnd, id);
return 0;
} else if message == WM_SETTINGCHANGE && state.attached.get() && !animations_enabled() {
state.cancel(hwnd, id);
} else if message == WM_WINDOWPOSCHANGING {
let show = ShowState::read(hwnd);
let previous = state.show.replace(show);
// Taskbar minimize/restore retains the native material and animation.
// Pausing before minimize would add a visible flash before shrinking.
if !previous.minimized && !show.minimized && previous.maximized != show.maximized {
state.pause(hwnd, id);
}
} else if message == WM_SYSCOMMAND && ShowState::read(hwnd).changes_for(wparam) {
state.pause(hwnd, id);
}
unsafe { DefSubclassProc(hwnd, message, wparam, lparam) }
}
+2
View File
@@ -9,6 +9,8 @@
//! 切模块 + 模块内 `#[cfg]` 分派」,只保留真正值钱的两点:单一入口,以及
//! 能力探测(让 UI 隐藏入口,而不是让功能在别的平台报错)。
#[cfg(feature = "gpui-shell")]
pub(crate) mod acrylic;
pub(crate) mod ai_session_identity;
pub mod capabilities;
pub mod credentials;
+15 -3
View File
@@ -185,9 +185,21 @@ impl StreamProcessor {
}
pub fn stop_sync<U: EventListener>(&mut self, terminal: &mut Term<U>) {
terminal.cancel_redraw_anchor();
self.parser.stop_sync(terminal);
}
fn advance<U: EventListener>(&mut self, terminal: &mut Term<U>, bytes: &[u8]) {
// VTE 0.15 在约 2 MiB 时强制提交同步缓冲;提前取消兼容锚点,
// 但不强制结束/截断输出。分块保证单次大输入也不会绕过检查。
for chunk in bytes.chunks(4096) {
if self.parser.sync_bytes_count() >= 1024 * 1024 {
terminal.cancel_redraw_anchor();
}
self.parser.advance(terminal, chunk);
}
}
pub fn feed<U: EventListener>(
&mut self,
terminal: &mut Term<U>,
@@ -200,7 +212,7 @@ impl StreamProcessor {
// Titles and shell identity/cwd reports must retain wire order:
// coalescing a remote cwd past a parent prompt would attribute that
// directory to the parent shell's completion history.
self.parser.advance(terminal, &bytes[advanced..offset]);
self.advance(terminal, &bytes[advanced..offset]);
advanced = offset;
match event {
OscEvent::Cwd(cwd) => event_proxy.send_event(Event::CwdReport(cwd)),
@@ -239,7 +251,7 @@ impl StreamProcessor {
let rows = (disp_h / cell_h).ceil().max(1.0) as usize;
let abs_line = terminal.nebula_cursor_abs_line();
for _ in 0..=rows {
self.parser.advance(terminal, b"\r\n");
self.advance(terminal, b"\r\n");
}
event_proxy.send_event(Event::InlineImage {
data: std::sync::Arc::new(data),
@@ -250,7 +262,7 @@ impl StreamProcessor {
},
}
}
self.parser.advance(terminal, &bytes[advanced..]);
self.advance(terminal, &bytes[advanced..]);
}
}
+57
View File
@@ -0,0 +1,57 @@
//! 清屏与滚动历史清除的网格、选区及阅读位置合同。
use super::{Boundary, Column, Dimensions, EventListener, Line, Term, TermMode, ansi, cmp};
impl<T: EventListener> Term<T> {
pub(super) fn clear_screen_contents(&mut self, mode: ansi::ClearMode) {
self.observe_redraw_clear(&mode);
log::trace!("Clearing screen: {mode:?}");
let bg = self.grid.cursor.template.bg;
let screen_lines = self.screen_lines();
match mode {
ansi::ClearMode::Above => {
let cursor = self.grid.cursor.point;
if cursor.line > 1 {
self.grid.reset_region(..cursor.line);
}
let end = cmp::min(cursor.column + 1, Column(self.columns()));
for cell in &mut self.grid[cursor.line][..end] {
*cell = bg.into();
}
let range = Line(0)..=cursor.line;
self.selection = self.selection.take().filter(|s| !s.intersects_range(range));
},
ansi::ClearMode::Below => {
let cursor = self.grid.cursor.point;
for cell in &mut self.grid[cursor.line][cursor.column..] {
*cell = bg.into();
}
if (cursor.line.0 as usize) < screen_lines - 1 {
self.grid.reset_region((cursor.line + 1)..);
}
let range = cursor.line..Line(screen_lines as i32);
self.selection = self.selection.take().filter(|s| !s.intersects_range(range));
},
ansi::ClearMode::All => {
if self.mode.contains(TermMode::ALT_SCREEN) {
self.grid.reset_region(..);
} else {
let old_offset = self.grid.display_offset();
self.grid.clear_viewport();
let lines = self.grid.display_offset().saturating_sub(old_offset);
self.vi_mode_cursor.point.line =
(self.vi_mode_cursor.point.line - lines).grid_clamp(self, Boundary::Grid);
}
self.selection = None;
},
ansi::ClearMode::Saved if self.history_size() > 0 => {
self.grid.clear_history();
self.vi_mode_cursor.point.line =
self.vi_mode_cursor.point.line.grid_clamp(self, Boundary::Cursor);
self.selection = self.selection.take().filter(|s| !s.intersects_range(..Line(0)));
},
ansi::ClearMode::Saved => (),
}
self.mark_fully_damaged();
}
}
+11 -69
View File
@@ -28,12 +28,14 @@ use crate::vte::ansi::{
};
pub mod cell;
mod clear;
pub mod color;
mod damage;
mod keyboard;
#[cfg(test)]
mod keyboard_contract_tests;
mod prompt;
mod redraw_anchor;
mod renderable;
pub mod search;
@@ -158,6 +160,7 @@ pub fn viewport_to_point_from(origin: Line, point: Point<usize>) -> Point {
}
pub struct Term<T> {
redraw_anchor: redraw_anchor::RedrawAnchor,
/// Terminal focus controlling the cursor shape.
pub is_focused: bool,
@@ -342,6 +345,7 @@ impl<T> Term<T> {
where
T: EventListener,
{
self.cancel_redraw_anchor();
let old_display_offset = self.grid.display_offset();
self.grid.scroll_display(scroll);
self.event_proxy.send_event(Event::MouseCursorDirty);
@@ -382,6 +386,7 @@ impl<T> Term<T> {
let damage = TermDamageState::new(num_cols, num_lines);
Term {
redraw_anchor: Default::default(),
inactive_grid,
scroll_region,
event_proxy,
@@ -727,6 +732,7 @@ impl<T> Term<T> {
/// Resize terminal to new dimensions.
pub fn resize<S: Dimensions>(&mut self, size: S) {
self.cancel_redraw_anchor();
let old_cols = self.columns();
let old_lines = self.screen_lines();
@@ -804,6 +810,7 @@ impl<T> Term<T> {
/// Swap primary and alternate screen buffer.
pub fn swap_alt(&mut self) {
self.cancel_redraw_anchor();
if !self.mode.contains(TermMode::ALT_SCREEN) {
// Set alt screen cursor to the current primary screen cursor.
self.inactive_grid.cursor = self.grid.cursor.clone();
@@ -1902,73 +1909,7 @@ impl<T: EventListener> Handler for Term<T> {
#[inline]
fn clear_screen(&mut self, mode: ansi::ClearMode) {
trace!("Clearing screen: {mode:?}");
let bg = self.grid.cursor.template.bg;
let screen_lines = self.screen_lines();
match mode {
ansi::ClearMode::Above => {
let cursor = self.grid.cursor.point;
// If clearing more than one line.
if cursor.line > 1 {
// Fully clear all lines before the current line.
self.grid.reset_region(..cursor.line);
}
// Clear up to the current column in the current line.
let end = cmp::min(cursor.column + 1, Column(self.columns()));
for cell in &mut self.grid[cursor.line][..end] {
*cell = bg.into();
}
let range = Line(0)..=cursor.line;
self.selection = self.selection.take().filter(|s| !s.intersects_range(range));
},
ansi::ClearMode::Below => {
let cursor = self.grid.cursor.point;
for cell in &mut self.grid[cursor.line][cursor.column..] {
*cell = bg.into();
}
if (cursor.line.0 as usize) < screen_lines - 1 {
self.grid.reset_region((cursor.line + 1)..);
}
let range = cursor.line..Line(screen_lines as i32);
self.selection = self.selection.take().filter(|s| !s.intersects_range(range));
},
ansi::ClearMode::All => {
if self.mode.contains(TermMode::ALT_SCREEN) {
self.grid.reset_region(..);
} else {
let old_offset = self.grid.display_offset();
self.grid.clear_viewport();
// Compute number of lines scrolled by clearing the viewport.
let lines = self.grid.display_offset().saturating_sub(old_offset);
self.vi_mode_cursor.point.line =
(self.vi_mode_cursor.point.line - lines).grid_clamp(self, Boundary::Grid);
}
self.selection = None;
},
ansi::ClearMode::Saved if self.history_size() > 0 => {
self.grid.clear_history();
self.vi_mode_cursor.point.line =
self.vi_mode_cursor.point.line.grid_clamp(self, Boundary::Cursor);
self.selection = self.selection.take().filter(|s| !s.intersects_range(..Line(0)));
},
// We have no history to clear.
ansi::ClearMode::Saved => (),
}
self.mark_fully_damaged();
self.clear_screen_contents(mode);
}
#[inline]
@@ -1987,6 +1928,7 @@ impl<T: EventListener> Handler for Term<T> {
/// Reset all important fields in the term struct.
#[inline]
fn reset_state(&mut self) {
self.cancel_redraw_anchor();
if self.mode.contains(TermMode::ALT_SCREEN) {
mem::swap(&mut self.grid, &mut self.inactive_grid);
}
@@ -2156,7 +2098,7 @@ impl<T: EventListener> Handler for Term<T> {
self.cursor_blinking_override = Some(true);
self.event_proxy.send_event(Event::CursorBlinkingChange);
},
NamedPrivateMode::SyncUpdate => (),
NamedPrivateMode::SyncUpdate => self.begin_redraw_anchor(),
}
}
@@ -2212,7 +2154,7 @@ impl<T: EventListener> Handler for Term<T> {
self.cursor_blinking_override = Some(false);
self.event_proxy.send_event(Event::CursorBlinkingChange);
},
NamedPrivateMode::SyncUpdate => (),
NamedPrivateMode::SyncUpdate => self.finish_redraw_anchor(),
}
}
+144
View File
@@ -0,0 +1,144 @@
//! 同步重绘后的保守阅读定位;只查找新网格,不恢复被应用清掉的内容。
use super::{Cell, EventListener, Flags, Term, TermMode};
use crate::grid::{Dimensions, Grid, Scroll};
use crate::index::{Column, Line};
const ANCHOR_ROWS: usize = 3;
const MAX_COLUMNS: usize = 1024;
const MAX_SCAN_CELLS: usize = 4 * 1024 * 1024;
#[derive(Default)]
pub(super) struct RedrawAnchor {
enabled: bool,
active: bool,
rows: Option<Vec<String>>,
cleared_viewport: bool,
cleared_history: bool,
}
impl<T> Term<T> {
/// 由宿主根据已确认的应用生命周期选择;默认不改变终端滚动语义。
pub fn set_redraw_anchor_enabled(&mut self, enabled: bool) {
if self.redraw_anchor.enabled != enabled {
self.cancel_redraw_anchor();
self.redraw_anchor.enabled = enabled;
}
}
/// 用户操作或不完整同步更新优先,迟到的输出不得恢复旧阅读位置。
pub fn cancel_redraw_anchor(&mut self) {
self.redraw_anchor.rows = None;
}
pub(super) fn begin_redraw_anchor(&mut self) {
let previous_active = self.redraw_anchor.active;
self.redraw_anchor = RedrawAnchor {
enabled: self.redraw_anchor.enabled,
active: true,
..Default::default()
};
if previous_active
|| !self.redraw_anchor.enabled
|| self.grid.display_offset() == 0
|| self.mode.intersects(TermMode::ALT_SCREEN | TermMode::VI)
|| self.selection.is_some()
|| self.columns() > MAX_COLUMNS
|| self.screen_lines() < ANCHOR_ROWS
{
return;
}
let start = -(self.grid.display_offset() as i32);
let rows: Option<Vec<_>> = (0..ANCHOR_ROWS)
.map(|offset| row_key(&self.grid, Line(start + offset as i32)))
.collect();
self.redraw_anchor.rows = rows.filter(|rows| rows.iter().any(|row| !row.is_empty()));
}
pub(super) fn observe_redraw_clear(&mut self, mode: &super::ansi::ClearMode) {
if !self.redraw_anchor.active {
return;
}
match mode {
super::ansi::ClearMode::All => self.redraw_anchor.cleared_viewport = true,
super::ansi::ClearMode::Saved => self.redraw_anchor.cleared_history = true,
_ => (),
}
}
}
impl<T: EventListener> Term<T> {
pub(super) fn finish_redraw_anchor(&mut self) {
let enabled = self.redraw_anchor.enabled;
let state = std::mem::replace(
&mut self.redraw_anchor,
RedrawAnchor { enabled, ..Default::default() },
);
let Some(rows) = state.rows else { return };
if !state.cleared_viewport
|| !state.cleared_history
|| self.mode.intersects(TermMode::ALT_SCREEN | TermMode::VI)
|| self.selection.is_some()
|| self.grid.total_lines().saturating_mul(self.columns()) > MAX_SCAN_CELLS
{
return;
}
let start = -(self.history_size() as i32);
let end = self.screen_lines() as i32 - ANCHOR_ROWS as i32;
let mut matched = None;
for line in start..=end {
if rows.iter().enumerate().all(|(offset, expected)| {
row_key(&self.grid, Line(line + offset as i32)).as_ref() == Some(expected)
}) {
if matched.is_some() {
return;
}
matched = Some(line);
}
}
if let Some(line) = matched.filter(|line| *line <= 0) {
self.scroll_display(Scroll::Bottom);
self.scroll_display(Scroll::Delta(-line));
}
}
}
fn row_key(grid: &Grid<Cell>, line: Line) -> Option<String> {
let mut text = String::new();
let mut space = false;
for column in 0..grid.columns() {
let cell = &grid[line][Column(column)];
// 换行重排没有可靠的行身份,不以不完整片段猜测原位置。
if cell.flags.contains(Flags::WRAPLINE) {
return None;
}
if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) {
continue;
}
if cell.c == ' ' && cell.zerowidth().is_none() {
space = !text.is_empty();
continue;
}
if space {
text.push(' ');
space = false;
}
text.push(cell.c);
if let Some(chars) = cell.zerowidth() {
text.extend(chars);
}
}
// Pi 表格的纯边框随列宽伸长;只归一纯边框,不折叠正文中的重复字符。
if text.contains('─')
&& text
.chars()
.all(|c| matches!(c, '─' | '┌' | '┬' | '┐' | '├' | '┼' | '┤' | '└' | '┴' | '┘'))
{
let mut previous = None;
text.retain(|c| {
let keep = c != '─' || previous != Some(c);
previous = Some(c);
keep
});
}
Some(text)
}
+273
View File
@@ -0,0 +1,273 @@
use nebula_terminal::Term;
use nebula_terminal::event::VoidListener;
use nebula_terminal::event_loop::StreamProcessor;
use nebula_terminal::grid::{Dimensions, Scroll};
use nebula_terminal::index::{Column, Line};
use nebula_terminal::term::{Config, test::TermSize};
type Terminal = Term<VoidListener>;
fn feed(stream: &mut StreamProcessor, term: &mut Terminal, bytes: &[u8]) {
stream.feed(term, &VoidListener, bytes);
}
fn numbered(count: usize) -> String {
(0..count).map(|n| format!("ROW_{n:03}")).collect::<Vec<_>>().join("\r\n")
}
fn frame(text: &str) -> String {
format!("\x1b[?2026h\x1b[2J\x1b[H\x1b[3J{text}\x1b[?2026l")
}
fn row(term: &Terminal, line: i32) -> String {
(0..term.columns())
.map(|col| term.grid()[Line(line)][Column(col)].c)
.collect::<String>()
.trim_end()
.to_owned()
}
fn top(term: &Terminal) -> String {
row(term, -(term.grid().display_offset() as i32))
}
fn initial(enabled: bool) -> (Terminal, StreamProcessor) {
let mut term = Term::new(Config::default(), &TermSize::new(60, 5), VoidListener);
let mut stream = StreamProcessor::default();
term.set_redraw_anchor_enabled(enabled);
feed(&mut stream, &mut term, format!("SECRET_OLD\r\n{}", numbered(10)).as_bytes());
term.scroll_display(Scroll::Top);
term.scroll_display(Scroll::Delta(-3));
assert_eq!(top(&term), "ROW_002");
(term, stream)
}
#[test]
fn complete_sync_redraw_reanchors_new_content_without_resurrecting_history() {
let (mut term, mut stream) = initial(true);
feed(&mut stream, &mut term, frame(&numbered(12)).as_bytes());
assert_eq!(top(&term), "ROW_002");
assert_eq!(term.history_size(), 7);
let rows: Vec<_> = (-7..5).map(|line| row(&term, line)).collect();
assert_eq!(rows, numbered(12).split("\r\n").collect::<Vec<_>>());
}
#[test]
fn disabled_policy_preserves_standard_clear_behavior() {
let (mut term, mut stream) = initial(false);
feed(&mut stream, &mut term, frame(&numbered(12)).as_bytes());
assert_eq!(term.grid().display_offset(), 0);
assert_eq!(top(&term), "ROW_007");
}
#[test]
fn byte_boundaries_do_not_change_reanchoring() {
let bytes = frame(&numbered(12)).into_bytes();
for split in 0..=bytes.len() {
let (mut term, mut stream) = initial(true);
feed(&mut stream, &mut term, &bytes[..split]);
feed(&mut stream, &mut term, &bytes[split..]);
assert_eq!(top(&term), "ROW_002", "split={split}");
}
}
#[test]
fn plain_clear_and_missing_or_ambiguous_anchor_do_not_reanchor() {
for bytes in [
"\x1b[3J".to_owned(),
frame("new\r\ncontent"),
frame(&format!("{}\r\n{}", numbered(12), numbered(12))),
format!("\x1b[2J\x1b[H\x1b[3J{}", numbered(12)),
] {
let (mut term, mut stream) = initial(true);
feed(&mut stream, &mut term, bytes.as_bytes());
assert_eq!(term.grid().display_offset(), 0);
}
}
#[test]
fn user_scroll_or_disable_during_sync_cancels_pending_anchor() {
for disable in [false, true] {
let (mut term, mut stream) = initial(true);
feed(&mut stream, &mut term, b"\x1b[?2026h");
if disable {
term.set_redraw_anchor_enabled(false);
} else {
term.scroll_display(Scroll::Bottom);
}
feed(
&mut stream,
&mut term,
format!("\x1b[2J\x1b[H\x1b[3J{}\x1b[?2026l", numbered(12)).as_bytes(),
);
assert_eq!(term.grid().display_offset(), 0);
}
}
#[test]
fn forced_sync_stop_does_not_restore_a_partial_redraw() {
let (mut term, mut stream) = initial(true);
feed(
&mut stream,
&mut term,
format!("\x1b[?2026h\x1b[2J\x1b[H\x1b[3J{}", numbered(12)).as_bytes(),
);
stream.stop_sync(&mut term);
assert_eq!(term.grid().display_offset(), 0);
feed(&mut stream, &mut term, b"\x1b[?2026l");
assert_eq!(term.grid().display_offset(), 0);
}
#[test]
fn cancellation_resize_reset_and_alt_screen_do_not_restore_old_anchor() {
for operation in 0..4 {
let (mut term, mut stream) = initial(true);
feed(&mut stream, &mut term, b"\x1b[?2026h");
match operation {
0 => term.cancel_redraw_anchor(),
1 => term.resize(TermSize::new(61, 5)),
2 => term.swap_alt(),
_ => (),
}
let reset = if operation == 3 { "\x1bc" } else { "" };
feed(
&mut stream,
&mut term,
format!("{reset}\x1b[2J\x1b[H\x1b[3J{}\x1b[?2026l", numbered(12)).as_bytes(),
);
assert_eq!(term.grid().display_offset(), 0, "operation={operation}");
}
}
#[test]
fn oversized_sync_update_cancels_even_for_single_large_input() {
let (mut term, mut stream) = initial(true);
let bytes = format!(
"\x1b[?2026h{}\x1b[2J\x1b[H\x1b[3J{}\x1b[?2026l",
"\x00".repeat(2 * 1024 * 1024),
numbered(12)
);
feed(&mut stream, &mut term, bytes.as_bytes());
assert_eq!(term.grid().display_offset(), 0);
}
#[test]
fn bottom_follow_and_append_remain_unchanged() {
let (mut term, mut stream) = initial(true);
feed(&mut stream, &mut term, b"\r\nAPPEND");
assert_eq!(top(&term), "ROW_002");
term.scroll_display(Scroll::Bottom);
feed(&mut stream, &mut term, frame(&numbered(12)).as_bytes());
assert_eq!(term.grid().display_offset(), 0);
}
#[test]
fn paragraph_spacing_preserves_anchor_including_blank_top_row() {
for blank_top in [false, true] {
let (mut term, mut stream) = initial(true);
let paragraphs =
(0..12).map(|n| format!("PARAGRAPH_{n:03}")).collect::<Vec<_>>().join("\r\n\r\n");
feed(&mut stream, &mut term, frame(&paragraphs).as_bytes());
term.scroll_display(Scroll::Top);
term.scroll_display(Scroll::Delta(if blank_top { -5 } else { -4 }));
let before: Vec<_> =
(0..3).map(|n| row(&term, -(term.grid().display_offset() as i32) + n)).collect();
feed(
&mut stream,
&mut term,
frame(&format!("{paragraphs}\r\n\r\nNEW_PARAGRAPH")).as_bytes(),
);
let after: Vec<_> =
(0..3).map(|n| row(&term, -(term.grid().display_offset() as i32) + n)).collect();
assert_eq!(after, before, "blank_top={blank_top}");
}
}
#[test]
fn table_rules_widen_without_losing_the_reading_row() {
let table = |padding: usize| {
(0..12)
.map(|n| {
format!(
"│ ROW_{n:03}{}│ value {n} │\r\n├{}┼──────────┤",
" ".repeat(padding),
"─".repeat(9 + padding)
)
})
.collect::<Vec<_>>()
.join("\r\n")
};
let (mut term, mut stream) = initial(true);
feed(&mut stream, &mut term, frame(&table(1)).as_bytes());
term.scroll_display(Scroll::Top);
term.scroll_display(Scroll::Delta(-4));
assert_eq!(top(&term), "│ ROW_002 │ value 2 │");
feed(&mut stream, &mut term, frame(&table(6)).as_bytes());
assert_eq!(top(&term), "│ ROW_002 │ value 2 │");
}
#[test]
fn partial_clear_does_not_trigger_compatibility_relocation() {
for clear in ["\x1b[2J\x1b[H", "\x1b[3J"] {
let (mut enabled, mut stream) = initial(true);
let (mut standard, mut control) = initial(false);
let bytes = format!("\x1b[?2026h{clear}{}\x1b[?2026l", numbered(12));
feed(&mut stream, &mut enabled, bytes.as_bytes());
feed(&mut control, &mut standard, bytes.as_bytes());
assert_eq!(top(&enabled), top(&standard));
assert_eq!(enabled.grid().display_offset(), standard.grid().display_offset());
}
}
#[test]
fn repeated_sync_frames_recapture_the_current_reading_position() {
let (mut term, mut stream) = initial(true);
for count in 12..20 {
feed(&mut stream, &mut term, frame(&numbered(count)).as_bytes());
assert_eq!(top(&term), "ROW_002");
}
}
#[test]
fn wide_and_combining_characters_survive_reanchoring() {
let content = |count| {
(0..count).map(|n| format!("中文_{n:03} e\u{301}")).collect::<Vec<_>>().join("\r\n")
};
let (mut term, mut stream) = initial(true);
feed(&mut stream, &mut term, frame(&content(12)).as_bytes());
term.scroll_display(Scroll::Top);
term.scroll_display(Scroll::Delta(-2));
let before = top(&term);
feed(&mut stream, &mut term, frame(&content(15)).as_bytes());
assert_eq!(top(&term), before);
assert_eq!(term.grid().display_offset(), 8);
}
#[test]
fn ambiguous_normalized_padding_does_not_choose_a_matching_copy() {
let (mut term, mut stream) = initial(true);
let first = (0..12).map(|n| format!("ROW_{n:03} value")).collect::<Vec<_>>().join("\r\n");
feed(&mut stream, &mut term, frame(&first).as_bytes());
term.scroll_display(Scroll::Top);
term.scroll_display(Scroll::Delta(-2));
let second = first.replace(" value", " value");
feed(&mut stream, &mut term, frame(&format!("{first}\r\n{second}")).as_bytes());
assert_eq!(term.grid().display_offset(), 0);
}
#[test]
fn table_padding_changes_preserve_three_row_anchor() {
let (mut term, mut stream) = initial(true);
let table = |padding: usize| {
(0..12)
.map(|n| format!("│ ROW_{n:03}{}│ value {n} │", " ".repeat(padding)))
.collect::<Vec<_>>()
.join("\r\n")
};
feed(&mut stream, &mut term, frame(&table(1)).as_bytes());
term.scroll_display(Scroll::Top);
term.scroll_display(Scroll::Delta(-2));
assert_eq!(top(&term), "│ ROW_002 │ value 2 │");
feed(&mut stream, &mut term, frame(&table(6)).as_bytes());
assert_eq!(top(&term), "│ ROW_002 │ value 2 │");
}