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.
409 lines
16 KiB
Rust
409 lines
16 KiB
Rust
//! 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
|
||
}
|