Merge upstream main into fix/issue-283-color-scheme-echo
This commit is contained in:
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.
|
||||
@@ -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)。
|
||||
@@ -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 /
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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..]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(¶graphs).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 │");
|
||||
}
|
||||
Reference in new issue
Block a user