From bdee8d22c55038d8de072e90c46aacc558de9b5b Mon Sep 17 00:00:00 2001 From: galact-byte <94279960+galact-byte@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:55:14 +0800 Subject: [PATCH 1/2] fix(terminal): preserve Pi reading position across synchronized redraws (#234) Reanchor only uniquely matched content after complete synchronized Pi redraws. Preserve standard clearing and cancel stale anchors on user actions or interrupted updates. Related to #159. --- .../terminal/view/activity_tests.rs | 40 +++ .../terminal/view/agent_activity.rs | 5 + .../src/gpui_shell/terminal/view/pointer.rs | 3 + .../src/gpui_shell/terminal/view/runtime.rs | 3 + nebula_terminal/src/event_loop.rs | 18 +- nebula_terminal/src/term/clear.rs | 57 ++++ nebula_terminal/src/term/mod.rs | 80 +---- nebula_terminal/src/term/redraw_anchor.rs | 144 +++++++++ nebula_terminal/tests/pi_redraw_anchor.rs | 273 ++++++++++++++++++ 9 files changed, 551 insertions(+), 72 deletions(-) create mode 100644 nebula_terminal/src/term/clear.rs create mode 100644 nebula_terminal/src/term/redraw_anchor.rs create mode 100644 nebula_terminal/tests/pi_redraw_anchor.rs diff --git a/nebula_app/src/gpui_shell/terminal/view/activity_tests.rs b/nebula_app/src/gpui_shell/terminal/view/activity_tests.rs index c1552ed..dec49d6 100644 --- a/nebula_app/src/gpui_shell/terminal/view/activity_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/activity_tests.rs @@ -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::::default(); + let text = (0..12).map(|n| format!("ROW_{n:03}")).collect::>().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()); } diff --git a/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs b/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs index 625e23d..14b7617 100644 --- a/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs +++ b/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs @@ -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); diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer.rs b/nebula_app/src/gpui_shell/terminal/view/pointer.rs index 0ae3802..a5f6221 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer.rs @@ -853,6 +853,9 @@ impl TerminalView { _window: &mut Window, cx: &mut Context, ) { + 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=`, diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index bf8b158..1940225 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -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) -> 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(); diff --git a/nebula_terminal/src/event_loop.rs b/nebula_terminal/src/event_loop.rs index 120a20e..3f455f3 100644 --- a/nebula_terminal/src/event_loop.rs +++ b/nebula_terminal/src/event_loop.rs @@ -185,9 +185,21 @@ impl StreamProcessor { } pub fn stop_sync(&mut self, terminal: &mut Term) { + terminal.cancel_redraw_anchor(); self.parser.stop_sync(terminal); } + fn advance(&mut self, terminal: &mut Term, 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( &mut self, terminal: &mut Term, @@ -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..]); } } diff --git a/nebula_terminal/src/term/clear.rs b/nebula_terminal/src/term/clear.rs new file mode 100644 index 0000000..ef6805b --- /dev/null +++ b/nebula_terminal/src/term/clear.rs @@ -0,0 +1,57 @@ +//! 清屏与滚动历史清除的网格、选区及阅读位置合同。 +use super::{Boundary, Column, Dimensions, EventListener, Line, Term, TermMode, ansi, cmp}; + +impl Term { + 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(); + } +} diff --git a/nebula_terminal/src/term/mod.rs b/nebula_terminal/src/term/mod.rs index c9fa81c..cffb9dc 100644 --- a/nebula_terminal/src/term/mod.rs +++ b/nebula_terminal/src/term/mod.rs @@ -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) -> Point { } pub struct Term { + redraw_anchor: redraw_anchor::RedrawAnchor, /// Terminal focus controlling the cursor shape. pub is_focused: bool, @@ -342,6 +345,7 @@ impl Term { 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 Term { let damage = TermDamageState::new(num_cols, num_lines); Term { + redraw_anchor: Default::default(), inactive_grid, scroll_region, event_proxy, @@ -727,6 +732,7 @@ impl Term { /// Resize terminal to new dimensions. pub fn resize(&mut self, size: S) { + self.cancel_redraw_anchor(); let old_cols = self.columns(); let old_lines = self.screen_lines(); @@ -804,6 +810,7 @@ impl Term { /// 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 Handler for Term { #[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 Handler for Term { /// 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 Handler for Term { 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 Handler for Term { self.cursor_blinking_override = Some(false); self.event_proxy.send_event(Event::CursorBlinkingChange); }, - NamedPrivateMode::SyncUpdate => (), + NamedPrivateMode::SyncUpdate => self.finish_redraw_anchor(), } } diff --git a/nebula_terminal/src/term/redraw_anchor.rs b/nebula_terminal/src/term/redraw_anchor.rs new file mode 100644 index 0000000..11546cb --- /dev/null +++ b/nebula_terminal/src/term/redraw_anchor.rs @@ -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>, + cleared_viewport: bool, + cleared_history: bool, +} + +impl Term { + /// 由宿主根据已确认的应用生命周期选择;默认不改变终端滚动语义。 + 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> = (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 Term { + 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, line: Line) -> Option { + 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) +} diff --git a/nebula_terminal/tests/pi_redraw_anchor.rs b/nebula_terminal/tests/pi_redraw_anchor.rs new file mode 100644 index 0000000..2744698 --- /dev/null +++ b/nebula_terminal/tests/pi_redraw_anchor.rs @@ -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; + +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::>().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::() + .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::>()); +} + +#[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::>().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::>() + .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::>().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::>().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::>() + .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 │"); +} From 3ad289e3e2a793d57cc3fc7614c7a127e1e92e97 Mon Sep 17 00:00:00 2001 From: JoyLin <72300219+JoyElliot@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:20:16 +0800 Subject: [PATCH 2/2] fix(windows): preserve Acrylic blur at edges and on deactivation (#250) Use a supported installed Acrylic controller with owned window and runtime lifetimes. Retain the existing Accent fallback and pixel-opacity behavior. Addresses the Acrylic portion of #236; the Aero issue remains open. --- .../platform/2026-09-22-acrylic-controller.md | 103 +++++++ .../2026-09-22-acrylic-window-transitions.md | 110 +++++++ nebula_app/AGENTS.md | 2 + nebula_app/Cargo.toml | 2 +- nebula_app/src/gpui_shell/mod.rs | 2 + nebula_app/src/gpui_shell/wallpaper.rs | 54 +++- nebula_app/src/platform/acrylic.rs | 32 +++ nebula_app/src/platform/acrylic/bindings.rs | 154 ++++++++++ .../src/platform/acrylic/composition.rs | 206 +++++++++++++ nebula_app/src/platform/acrylic/controller.rs | 94 ++++++ nebula_app/src/platform/acrylic/gpui_tests.rs | 114 ++++++++ nebula_app/src/platform/acrylic/runtime.rs | 105 +++++++ nebula_app/src/platform/acrylic/tests.rs | 225 +++++++++++++++ .../src/platform/acrylic/transitions.rs | 270 ++++++++++++++++++ nebula_app/src/platform/mod.rs | 2 + 15 files changed, 1467 insertions(+), 8 deletions(-) create mode 100644 architecture/notes/nebula_app/platform/2026-09-22-acrylic-controller.md create mode 100644 architecture/notes/nebula_app/platform/2026-09-22-acrylic-window-transitions.md create mode 100644 nebula_app/src/platform/acrylic.rs create mode 100644 nebula_app/src/platform/acrylic/bindings.rs create mode 100644 nebula_app/src/platform/acrylic/composition.rs create mode 100644 nebula_app/src/platform/acrylic/controller.rs create mode 100644 nebula_app/src/platform/acrylic/gpui_tests.rs create mode 100644 nebula_app/src/platform/acrylic/runtime.rs create mode 100644 nebula_app/src/platform/acrylic/tests.rs create mode 100644 nebula_app/src/platform/acrylic/transitions.rs diff --git a/architecture/notes/nebula_app/platform/2026-09-22-acrylic-controller.md b/architecture/notes/nebula_app/platform/2026-09-22-acrylic-controller.md new file mode 100644 index 0000000..94a7125 --- /dev/null +++ b/architecture/notes/nebula_app/platform/2026-09-22-acrylic-controller.md @@ -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. diff --git a/architecture/notes/nebula_app/platform/2026-09-22-acrylic-window-transitions.md b/architecture/notes/nebula_app/platform/2026-09-22-acrylic-window-transitions.md new file mode 100644 index 0000000..718f87f --- /dev/null +++ b/architecture/notes/nebula_app/platform/2026-09-22-acrylic-window-transitions.md @@ -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. diff --git a/nebula_app/AGENTS.md b/nebula_app/AGENTS.md index 96d053e..0ced932 100644 --- a/nebula_app/AGENTS.md +++ b/nebula_app/AGENTS.md @@ -8,3 +8,5 @@ - 测试真实控件和布局路径;编译或状态单测不能代替 hover、命中区域、键盘路径和视觉验收。 - 只有跨层所有权、持久化、协议、线程/生命周期或重要性能取舍才写 note,路径镜像到 `architecture/notes/nebula_app//`。 - 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)。 diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index 9ef3160..c1a84a9 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -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 / diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs index b2d8a95..4b0bdba 100644 --- a/nebula_app/src/gpui_shell/mod.rs +++ b/nebula_app/src/gpui_shell/mod.rs @@ -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); diff --git a/nebula_app/src/gpui_shell/wallpaper.rs b/nebula_app/src/gpui_shell/wallpaper.rs index 981f449..5336a64 100644 --- a/nebula_app/src/gpui_shell/wallpaper.rs +++ b/nebula_app/src/gpui_shell/wallpaper.rs @@ -400,17 +400,22 @@ fn apply_window_effects(cx: &mut App) { .into_iter() .filter(|handle| !already_applied.contains(&handle.window_id())) .collect::>(); - cx.set_global(AppliedBlur { blur, windows: window_ids }); + let mut applied = + already_applied.intersection(&window_ids).copied().collect::>(); 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, image: Bounds, 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); +} diff --git a/nebula_app/src/platform/acrylic.rs b/nebula_app/src/platform/acrylic.rs new file mode 100644 index 0000000..c8be510 --- /dev/null +++ b/nebula_app/src/platform/acrylic.rs @@ -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) {} diff --git a/nebula_app/src/platform/acrylic/bindings.rs b/nebula_app/src/platform/acrylic/bindings.rs new file mode 100644 index 0000000..d25abee --- /dev/null +++ b/nebula_app/src/platform/acrylic/bindings.rs @@ -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 { + 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 { + 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::().and_then(|object| object.Close()) { + Ok(()) => {}, + Err(error) => { + log::warn!(target: "nebula", "closing Acrylic controller failed: {error}") + }, + } + } +} diff --git a/nebula_app/src/platform/acrylic/composition.rs b/nebula_app/src/platform/acrylic/composition.rs new file mode 100644 index 0000000..ecf1173 --- /dev/null +++ b/nebula_app/src/platform/acrylic/composition.rs @@ -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, + queue: Option, + // Drop order: SDK objects/queue, runtime package lease, then our own STA ref. + _runtime: Runtime, + _apartment: Apartment, + _thread: PhantomData>, +} + +impl Context { + pub(super) fn new() -> Result { + 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::() 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 { + 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, + controller: Option, + config: Option, + 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::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}"); + } + } + } +} diff --git a/nebula_app/src/platform/acrylic/controller.rs b/nebula_app/src/platform/acrylic/controller.rs new file mode 100644 index 0000000..ba247e3 --- /dev/null +++ b/nebula_app/src/platform/acrylic/controller.rs @@ -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, + context: Option, + unavailable: bool, + #[cfg(test)] + attachments: usize, +} + +thread_local! { + static STATE: RefCell = RefCell::new(State::default()); +} + +/// Owns final queue drain outside the App's shutdown borrow. Must surround run. +#[derive(Default)] +pub(crate) struct RunGuard(PhantomData>); + +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) + }) +} diff --git a/nebula_app/src/platform/acrylic/gpui_tests.rs b/nebula_app/src/platform/acrylic/gpui_tests.rs new file mode 100644 index 0000000..726ff01 --- /dev/null +++ b/nebula_app/src/platform/acrylic/gpui_tests.rs @@ -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 { + 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, 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::().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::().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()); +} diff --git a/nebula_app/src/platform/acrylic/runtime.rs b/nebula_app/src/platform/acrylic/runtime.rs new file mode 100644 index 0000000..ea900e4 --- /dev/null +++ b/nebula_app/src/platform/acrylic/runtime.rs @@ -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::load_family(windows_core::w!("Microsoft.WindowsAppRuntime.1.8_8wekyb3d8bbwe")) + } + + fn load_family(family: windows_core::PCWSTR) -> Result { + // 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()); + } + } + } +} diff --git a/nebula_app/src/platform/acrylic/tests.rs b/nebula_app/src/platform/acrylic/tests.rs new file mode 100644 index 0000000..4b2ca69 --- /dev/null +++ b/nebula_app/src/platform/acrylic/tests.rs @@ -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()); +} diff --git a/nebula_app/src/platform/acrylic/transitions.rs b/nebula_app/src/platform/acrylic/transitions.rs new file mode 100644 index 0000000..a5897c8 --- /dev/null +++ b/nebula_app/src/platform/acrylic/transitions.rs @@ -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>, + show: Cell, + deadline: Cell>, + attached: Cell, + native_ref: Cell, +} + +/// Owned by one Backdrop, on its HWND's thread; no global registry or worker. +pub(super) struct Transitions { + hwnd: isize, + state: Rc, +} + +impl Transitions { + pub(super) fn new(hwnd: isize, target: ICompositionSupportsSystemBackdrop) -> Result { + 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::() 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) } +} diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs index a82c44a..4b832d6 100644 --- a/nebula_app/src/platform/mod.rs +++ b/nebula_app/src/platform/mod.rs @@ -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;