Files
pebrel/nebula_app/src/gpui_shell/mod.rs
T
JoyLin 3ad289e3e2 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.
2026-09-26 18:20:16 +08:00

409 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! GPUI UI 层(产品代码,feature = "gpui-shell")。
//!
//! 这里是 nebula.exe 体内的新 UI:终端视图/渲染元素/工作区与配置桥全部
//! 住在本模块,`nebula_gpui` crate 只是组件验收的实验场,不承载产品代码
//! (ROADMAP 终局形态第 1 条)。
//!
//! `nebula --gpui` 从主线程直接进入 GPUI 消息循环;旧壳不会同时启动。
// 父模块宏会覆盖所有外置子模块里的同名预导入宏,让普通 `cargo check`
// 就能拦住会在 Windows GUI 无效标准句柄上 panic 的裸 print 调用。
#[allow(unused_macros)]
macro_rules! println {
($($args:tt)*) => {
compile_error!("GPUI code must not use println!; use a fallible diagnostic sink")
};
}
#[allow(unused_macros)]
macro_rules! eprintln {
($($args:tt)*) => {
compile_error!("GPUI code must not use eprintln!; use try_write_stderr")
};
}
mod assets;
pub mod code_tab;
pub mod config;
pub(crate) mod copy_feedback;
pub mod doc_tabs;
pub mod file_drop;
mod file_editor;
mod file_preview;
pub mod http;
pub mod math_view;
mod molecule_view;
pub mod network_settings;
pub mod prelude;
mod scientific_render;
pub mod session_restore;
pub mod settings_pane;
pub mod ssh_hosts;
pub mod ssh_settings;
pub mod terminal;
pub mod theme;
pub mod toast;
pub mod wallpaper;
pub mod widgets;
pub mod workspace;
use assets::NebulaAssets;
use gpui::{App, AppContext as _};
use std::borrow::Cow;
/// GPUI 主窗没有可靠的标准错误句柄;保留父进程重定向诊断,但写失败不能
/// 再把非致命错误升级成应用 panic。
pub(crate) fn try_write_stderr(args: std::fmt::Arguments<'_>) {
use std::io::Write as _;
let _ = writeln!(std::io::stderr(), "{args}");
}
/// GPUI 壳的跨线程唤醒:托盘、mux ATTACH、runtime 控制都汇到工作区 pump。
pub(crate) enum GpuiShellEvent {
TrayFocus(Option<u64>),
NotificationFocus(Option<u64>),
NotificationChoice { pane_id: u64, request_id: u64, choice: usize },
TrayQuit,
MuxAttach,
RuntimeControl(std::sync::Arc<crate::runtime_api::RuntimeDispatch>),
UpdateAvailable(crate::update_check::UpdateCheckResult),
SshPrompt(std::sync::Arc<crate::ssh_prompt::Prompt>),
}
/// 在当前线程启动 GPUI 运行时并打开主窗口,阻塞直至 UI 退出。
///
/// GPUI 拥有自己的消息循环:主窗形态从主线程调用(winit 不启动);
/// spike 形态从专用线程调用。一个进程内只允许调用一次。
pub fn run_shell(
initial_cwd: Option<std::path::PathBuf>,
initial_command: Option<crate::config::ui_config::Program>,
shell_id: Option<String>,
) {
if crate::platform::CAPABILITIES.self_update_install {
match crate::update_download::handoff::installation_in_progress() {
Ok(true) => {
log::info!("Installation is being updated; resident startup deferred");
return;
},
Err(error) => {
log::warn!("Could not inspect installation ownership: {error}");
},
Ok(false) => {},
}
}
if crate::platform::CAPABILITIES.self_update_install
&& crate::update_download::handoff::apply_scheduled()
{
return;
}
let (shell_tx, shell_rx) = std::sync::mpsc::channel();
crate::notify::init_gpui_activation(shell_tx.clone());
crate::ssh_prompt::install({
let sender = shell_tx.clone();
move |request| sender.send(GpuiShellEvent::SshPrompt(request)).is_ok()
});
crate::update_check::spawn_gpui_once(shell_tx.clone());
let runtime_hub = crate::runtime_api::RuntimeHub::new();
crate::tray::init_gpui({
let tx = shell_tx.clone();
move |command| {
let event = match command {
crate::tray::GpuiTrayCommand::Focus(pane) => GpuiShellEvent::TrayFocus(pane),
crate::tray::GpuiTrayCommand::Quit => GpuiShellEvent::TrayQuit,
};
let _ = tx.send(event);
}
});
// 驻留进程写 runtime.port:第二份 `nebula --gpui` 经 ATTACH + tab.new
// 并入当前窗口,而不是再拉一套进程。Drop 会删 port 文件。
let runtime_server = crate::runtime_api::RuntimeServer::spawn_callback(
{
let tx = shell_tx;
move |callback| {
let event = match callback {
crate::runtime_api::RuntimeCallback::Attach => GpuiShellEvent::MuxAttach,
crate::runtime_api::RuntimeCallback::Control(dispatch) => {
GpuiShellEvent::RuntimeControl(dispatch)
},
};
let _ = tx.send(event);
}
},
runtime_hub.clone(),
);
if runtime_server.is_none()
&& initial_command.is_none()
&& !crate::platform::elevation::requires_isolation()
{
// 与另一份进程同时启动时,对方可能已经拿到 owner lock、但尚未来得及
// 发布 endpoint。短暂等待并交接,避免继续打开一个无控制面的空窗口。
let handover_cwd = initial_cwd.clone().or_else(|| std::env::current_dir().ok());
for _ in 0..40 {
let behavior = nebula_settings::RuntimeSettings::load().windowing_behavior;
let handed_over = match behavior {
nebula_settings::WindowingBehaviorName::UseNew => {
crate::runtime_api::try_open_window_existing(
handover_cwd.as_deref(),
shell_id.as_deref(),
)
},
_ => match handover_cwd.as_deref() {
Some(dir) => {
crate::runtime_api::try_open_directory_existing(dir, shell_id.as_deref())
},
None => crate::runtime_api::try_open_default_tab_existing(shell_id.as_deref()),
},
};
if handed_over {
crate::tray::shutdown();
return;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
}
let _runtime_server = runtime_server;
let _acrylic = crate::platform::acrylic::RunGuard::default();
// GPUI 默认只有 macOS 在最后一扇窗关掉后仍驻留(Dock 里留个没有窗口的
// 进程)。Nebula 没有 Dock 重开入口,也没法在 Mac 上验证那个无窗状态,
// 首版三端统一:最后一扇窗关闭即退出(驻留另有 keep_session 能力位管)。
gpui_platform::application()
.with_assets(NebulaAssets)
.with_quit_mode(gpui::QuitMode::LastWindowClosed)
.run(move |cx| {
// GPUI is the only event loop in `--gpui` mode, so it owns the same
// per-process hook pipe before the first TerminalView spawns.
let ai_events = crate::ai_hook::spawn_gpui_server();
#[cfg(windows)]
crate::ai_hook::spawn_config_guard();
init(cx);
if initial_cwd.is_some()
|| !crate::platform::startup::start_hidden(&nebula_settings::RuntimeSettings::load())
{
cx.activate(true);
}
open_main_window(cx, ai_events, shell_rx, runtime_hub, initial_cwd, initial_command, shell_id);
});
crate::tray::shutdown();
}
/// 组件库/主题/快捷键/用户配置的一次性初始化。
fn init(cx: &mut App) {
crate::platform::acrylic::init(cx);
// 三端都注册内嵌 Maple:Linux/macOS 的系统等宽字体没有 NF 图标码点,
// 侧栏与提示符会出方框;字形同源也是跨平台截图能互相比对的前提。
register_bundled_fonts(cx);
// 组件库必须只初始化一次,否则全局 action、菜单和主题状态会重复注册。
gpui_component::init(cx);
file_editor::init(cx);
// TextView 的公式渲染钩子:旧壳数学管线(compile → 栅格化)接入组件库
// 的 markdown 渲染;不注册时公式回退为源码文本。
math_view::register(cx);
// 网络图片加载:gpui 默认 NullHttpClient,markdown 文档里的 http(s)
// 图源全部失败;换成 ureq 实现(跑在后台 executor)。
http::register(cx);
// Read one runtime snapshot before installing any renderer. The resolved
// custom theme, terminal palette and chrome therefore start from the same
// immutable value and cannot diverge during bootstrap.
let runtime = nebula_settings::RuntimeSettings::load();
let theme = theme::resolve_theme_name(
runtime.theme,
runtime.follow_system_theme,
theme::system_is_light(cx),
);
let settings = config::Settings::load_with_runtime(theme, runtime);
gpui_component::set_locale(settings.ui_language.gpui_component_locale());
cx.set_global(settings);
theme::apply_chrome_theme(cx);
terminal::init(cx);
workspace::init(cx);
toast::init(cx);
}
fn register_bundled_fonts(cx: &App) {
// GPUI resolves a family through the system collection first and silently
// falls back when it is absent. Add Maple before any component/window can
// resolve a font so the default remains the same private face as winit.
if let Err(error) = cx.text_system().add_fonts(vec![
Cow::Borrowed(crate::font_install::REQUIRED_FONT_BYTES),
Cow::Borrowed(include_bytes!("../../../assets/fonts/MapleMono-NF-CN-Regular.ttf")),
]) {
try_write_stderr(format_args!(
"[nebula:gpui] failed to register bundled Maple font: {error}"
));
}
// 用户导入的私有字体(设置页字体选择器写入的目录):与旧壳
// `refresh_private_fonts` 同源同径——启动一次性注册,选择器和终端
// 都能立刻解析这些族。
let imported = crate::font_install::imported_font_files();
for path in &imported {
match std::fs::read(path) {
Ok(bytes) => {
if let Err(error) = cx.text_system().add_fonts(vec![Cow::Owned(bytes)]) {
try_write_stderr(format_args!(
"[nebula:gpui] failed to register imported font {}: {error}",
path.display()
));
}
},
Err(error) => {
try_write_stderr(format_args!(
"[nebula:gpui] failed to read imported font {}: {error}",
path.display()
));
},
}
}
}
fn open_main_window(
cx: &mut App,
ai_events: std::sync::mpsc::Receiver<crate::ai_hook::AiHookEvent>,
shell_events: std::sync::mpsc::Receiver<GpuiShellEvent>,
runtime_hub: crate::runtime_api::RuntimeHub,
initial_cwd: Option<std::path::PathBuf>,
initial_command: Option<crate::config::ui_config::Program>,
shell_id: Option<String>,
) {
workspace::windowing::initialize(cx, runtime_hub);
workspace::windowing::open_initial_window(
cx,
ai_events,
shell_events,
initial_cwd,
initial_command,
shell_id,
);
}
/// 按 `tray` 设置挂上或摘掉系统托盘图标(旧壳 `tray::set_enabled`)。
/// 启动、设置热应用、系统外观变化都经 [`theme::apply_chrome_theme`] 落到这里。
pub(crate) fn apply_tray_setting() {
crate::tray::set_enabled(nebula_settings::RuntimeSettings::load().tray);
}
/// 关窗保留会话:藏起 HWND,PTY 与工作区继续活着。
///
/// 旧壳 detach 是销毁窗口、进程无窗驻留。GPUI 用 `SW_HIDE` 达到同一
/// 用户可见效果。禁止 `minimize_window`:托盘关着时任务栏不能留一个
/// 点不掉的最小化窗口。
pub(crate) fn hide_native_window(window: &gpui::Window) {
#[cfg(windows)]
{
native_show(window, false);
}
#[cfg(not(windows))]
{
let _ = window;
}
}
/// 只恢复被 `SW_HIDE` 的 HWND,不改变当前前台窗口。
///
/// Windows 的 `SW_SHOW` 自带激活语义,不能用来处理后台 runtime 请求;
/// 显式 focus 由调用方在恢复可见性后另行调用 `activate_window`。
pub(crate) fn reveal_native_window(window: &gpui::Window) {
#[cfg(windows)]
{
native_show(window, true);
}
#[cfg(not(windows))]
{
let _ = window;
}
}
/// 把藏起来的窗口全部显示出来。
///
/// 必须 `defer`:本函数会从 `apply_runtime_settings` →
/// `reveal_if_tray_disabled` 被调到,而那条链路正处在某个窗口自己的 update
/// 回调里(设置页事件)。此刻该窗口已被 `App::update_window` 从 slot 里 take
/// 走,对同一 handle 再 update 只会拿到 `Err("window not found")`。
///
/// 2026-08-21:这里原先是 `let _ = handle.update(..)`,吞掉那个错误的后果很重
/// ——「窗口已最小化到托盘 + 在设置页关掉托盘」时,托盘图标按设置消失了,窗口
/// 却没被显示出来,而 `reveal_session` 已经把 `window_hidden` 置回 false,
/// 守卫 `if self.window_hidden && ..` 从此永假、不再重试:应用变成既无窗口也无
/// 托盘图标、只能从任务管理器结束的僵尸。与 `wallpaper::apply_window_effects`
/// 同一个根因、同一个修法。
pub(crate) fn reveal_all_windows(cx: &mut App) {
cx.defer(|cx| {
for handle in cx.windows() {
// 快速终端由自己的显隐状态机管理;托盘设置变化只能恢复普通窗口,
// 否则一个已向上收起的 Quake 窗口会在后台被无动画地重新显示。
if workspace::windowing::is_quick_terminal_window(handle, cx) {
continue;
}
if let Err(err) = handle.update(cx, |_, window, _| {
reveal_native_window(window);
}) {
log::warn!("failed to reveal window: {err}");
}
}
});
}
/// 给 GPUI 主窗补上任务栏悬停预览 / Alt+Tab 用的窗口图标。
///
/// exe 资源里有图标(`windows/nebula.rc` 的 `IDI_ICON`),任务栏按钮因此
/// 是对的;但 GPUI 注册窗口类时不带 `hIcon`,也从不发 `WM_SETICON`,于是
/// 悬停预览角标和 Alt+Tab 走系统默认白框占位图(用户 #51)。开窗后把资源
/// 图标按窗口 DPI 各挂一份,资源与句柄缓存由 `app_icon` 管理。
#[cfg(windows)]
pub(crate) fn set_native_window_icon(window: &gpui::Window) {
use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
let Ok(handle) = HasWindowHandle::window_handle(window) else {
return;
};
let RawWindowHandle::Win32(handle) = handle.as_raw() else {
return;
};
let hwnd = handle.hwnd.get() as *mut core::ffi::c_void;
crate::app_icon::windows::set_window(hwnd, crate::app_icon::selected());
}
pub(crate) fn apply_app_icon(variant: nebula_settings::AppIconName, cx: &mut App) {
if !crate::app_icon::set_selected(variant) {
return;
}
crate::tray::refresh_app_icon();
#[cfg(windows)]
crate::app_icon::windows::refresh_pinned(variant);
#[cfg(windows)]
cx.defer(|cx| {
for handle in cx.windows() {
if let Err(error) = handle.update(cx, |_, window, _| set_native_window_icon(window)) {
log::warn!("failed to update window icon: {error}");
}
}
});
cx.refresh_windows();
}
#[cfg(windows)]
fn native_show(window: &gpui::Window, show: bool) -> bool {
use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
let Ok(handle) = HasWindowHandle::window_handle(window) else {
return false;
};
let RawWindowHandle::Win32(handle) = handle.as_raw() else {
return false;
};
let hwnd = handle.hwnd.get() as *mut core::ffi::c_void;
let cmd = if show {
windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNOACTIVATE
} else {
windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE
};
// SAFETY: hwnd 来自当前 GPUI 窗口;ShowWindow 对无效句柄安全失败。
unsafe {
windows_sys::Win32::UI::WindowsAndMessaging::ShowWindow(hwnd, cmd);
}
true
}