fix(gpui): align settings and modal interactions

This commit is contained in:
kuddev committed 2026-08-25 11:41:18 +08:00
1 parent 1960e4ae40
commit 4794c162aa
9 files changed
+277 -129

No files matched your search

+96 -7
View File
@@ -9,10 +9,12 @@ pub use gpui_component::{
ActiveTheme as _, Colorize as _, Disableable as _, Icon, IconName, InteractiveElementExt as _,
Selectable as _, Sizable as _, StyledExt as _, TitleBar,
alert::{Alert, AlertVariant},
button::{Button, ButtonVariants as _},
button::{Button, ButtonVariant, ButtonVariants as _},
checkbox::Checkbox,
color_picker::{ColorPicker, ColorPickerEvent, ColorPickerState},
dialog::{Dialog, DialogAction, DialogButtonProps, DialogClose, DialogFooter},
dialog::{
Dialog, DialogAction, DialogButtonProps, DialogClose, DialogDescription, DialogFooter,
},
dock::{
DockArea, DockAreaState, DockItem, Panel, PanelControl, PanelEvent, PanelState, TabPanel,
},
@@ -39,10 +41,97 @@ pub use gpui_component::WindowExt as _;
// 现有的 glob 用法不受影响。
pub use gpui_component::{ActiveTheme, Colorize};
use gpui::{Window, px};
use gpui::{ParentElement as _, SharedString, Styled as _, Window, div, px, relative};
/// 保留旧壳确认框的 480px 内容宽度;位置和交互采用新版 Dialog。
pub fn center_confirm_dialog(dialog: Dialog, _window: &Window) -> Dialog {
const DIALOG_W: f32 = 480.0;
dialog.width(px(DIALOG_W))
const CONFIRM_DIALOG_WIDTH: f32 = 480.0;
const CONFIRM_DIALOG_HORIZONTAL_PADDING: f32 = 26.0;
const CONFIRM_DIALOG_TOP_PADDING: f32 = 26.0;
const CONFIRM_DIALOG_BOTTOM_PADDING: f32 = 20.0;
const CONFIRM_DIALOG_BASE_HEIGHT: f32 = 130.0;
const CONFIRM_DIALOG_BODY_LINE_HEIGHT: f32 = 24.0;
/// gpui-component 0.5.2 默认把 Dialog 放在视口高度的 1/10;旧 GPUI 壳则
/// 使用 480px 内容宽度并垂直居中。这里集中恢复旧壳几何,同时保留窄窗口限幅。
pub fn center_modal_dialog(dialog: Dialog, window: &Window, estimated_height: f32) -> Dialog {
let viewport = window.viewport_size();
let width = CONFIRM_DIALOG_WIDTH.min((f32::from(viewport.width) - 32.0).max(240.0));
let margin_top = ((f32::from(viewport.height) - estimated_height) * 0.5).max(16.0);
dialog
.width(px(width))
.margin_top(px(margin_top))
.pt(px(CONFIRM_DIALOG_TOP_PADDING))
.pb(px(CONFIRM_DIALOG_BOTTOM_PADDING))
.px(px(CONFIRM_DIALOG_HORIZONTAL_PADDING))
}
/// 构造与旧 GPUI 壳一致的确认框。显式 footer 是必要的:固定依赖版本的普通
/// Dialog 不会仅凭 button_props 生成按钮;DialogAction/DialogClose 负责把鼠标
/// 点击重新汇入 Enter/Esc 共用的确认与取消回调。
pub fn confirm_dialog(
dialog: Dialog,
window: &mut Window,
title: impl Into<SharedString>,
description: impl Into<SharedString>,
ok_text: impl Into<SharedString>,
cancel_text: impl Into<SharedString>,
ok_variant: ButtonVariant,
) -> Dialog {
let title = title.into();
let description = description.into();
let ok_text = ok_text.into();
let cancel_text = cancel_text.into();
let text_style = window.text_style();
let body_width = if description.is_empty() {
0.0
} else {
f32::from(
window
.text_system()
.shape_line(
description.clone(),
px(16.0),
&[text_style.to_run(description.len())],
None,
)
.width,
)
};
let available_body_width =
(CONFIRM_DIALOG_WIDTH - CONFIRM_DIALOG_HORIZONTAL_PADDING * 2.0).max(1.0);
let body_lines = (body_width / available_body_width).ceil().max(1.0);
let estimated_height =
CONFIRM_DIALOG_BASE_HEIGHT + body_lines * CONFIRM_DIALOG_BODY_LINE_HEIGHT;
let content_description = description.clone();
center_modal_dialog(dialog, window, estimated_height)
.close_button(false)
.overlay_closable(false)
.title(div().text_lg().font_semibold().line_height(relative(1.0)).child(title))
.content(move |content, _, _| {
content.child(
DialogDescription::new()
.text_base()
.line_height(relative(1.5))
.child(content_description.clone()),
)
})
.footer(
DialogFooter::new()
.child(
DialogClose::new()
.child(Button::new("confirm-cancel").label(cancel_text.clone())),
)
.child(DialogAction::new().child(
Button::new("confirm-ok").label(ok_text.clone()).with_variant(ok_variant),
)),
)
.button_props(
DialogButtonProps::default()
.ok_text(ok_text)
.ok_variant(ok_variant)
.cancel_text(cancel_text)
.show_cancel(true),
)
}
+27 -12
View File
@@ -1046,12 +1046,17 @@ impl SettingsPane {
let pane = cx.entity().downgrade();
window.open_dialog(cx, move |dialog, window, _cx| {
let pane = pane.clone();
center_confirm_dialog(dialog, window)
.title("让背景图覆盖窗口控件区域?")
.button_props(DialogButtonProps::default().show_cancel(true).ok_text("开启").cancel_text("取消"))
.child(SharedString::from(
confirm_dialog(
dialog,
window,
"让背景图覆盖窗口控件区域?",
SharedString::from(
"背景图会延伸到标题栏、窗口按钮、Tab 与 SSH 侧栏下方,低对比度图片可能影响操作可见性;界面仍会保留最低不透明度保护。",
))
),
"开启",
"取消",
ButtonVariant::Primary,
)
.on_ok(move |_, _, cx| {
let _ = pane.update(cx, |this, cx| {
this.persist(&[("background_image_cover_chrome", "1".to_owned())], cx);
@@ -1145,7 +1150,15 @@ impl SettingsPane {
// 成一次重绘,所以每帧调是安全的。
cx.refresh_windows();
cx.notify();
self.schedule_slider_persist("opacity", format!("{opacity:.2}"), cx);
// 用户拖动后 opacity 已是显式选择;同时把旧 blur=1 规范成枚举值,
// 否则共享迁移层会再次把 blur=1 + opacity=1.00 解读成默认 0.82。
self.schedule_slider_persist(
vec![
("opacity", format!("{opacity:.2}")),
("blur", self.runtime.blur.settings_value().to_owned()),
],
cx,
);
}
/// 背景图透明度是**烘焙进纹理**的(decode + 长边压到 2560 + 逐像素乘
@@ -1158,26 +1171,28 @@ impl SettingsPane {
}
self.runtime.background_image_opacity = opacity;
cx.notify();
self.schedule_slider_persist("background_image_opacity", format!("{opacity:.2}"), cx);
self.schedule_slider_persist(
vec![("background_image_opacity", format!("{opacity:.2}"))],
cx,
);
}
/// 滑块停手后才落盘。滑块只有 `Change` 事件(组件库没有"松手"信号),
/// 所以用 debounce:每个新事件覆盖上一个待落盘任务,旧 `Task` drop 即取消。
/// `Change` 同时覆盖鼠标拖动和键盘调整,因此统一用 debounce 延后落盘:
/// 每个新事件覆盖上一个待落盘任务,旧 `Task` drop 即取消。
///
/// 落盘那一次会走完整的 [`Self::persist`](写盘 + 设置重载 + 每终端
/// `apply_settings` + 主题重建 + 壁纸重烘焙),跑一次没问题;每帧跑就是
/// 2026-08-21 报的"拖拽卡顿的要死"。
fn schedule_slider_persist(
&mut self,
key: &'static str,
value: String,
updates: Vec<(&'static str, String)>,
cx: &mut Context<Self>,
) {
let executor = cx.background_executor().clone();
self.slider_persist = Some(cx.spawn(async move |this, cx| {
executor.timer(Duration::from_millis(180)).await;
let _ = this.update(cx, |this, cx| {
this.persist(&[(key, value)], cx);
this.persist(&updates, cx);
});
}));
}
@@ -209,8 +209,16 @@ impl SettingsPane {
.child(
gpui::canvas(
move |bounds, _, cx| {
let _ = picker.update(cx, |picker, _| {
let _ = picker.update(cx, |picker, cx| {
if picker.font_picker_trigger_bounds == Some(bounds) {
return;
}
picker.font_picker_trigger_bounds = Some(bounds);
// anchored() 消费的是上一轮 render 拿到的窗口坐标;
// 聚焦自动滚动后补一帧,弹层才会继续贴住输入框。
if picker.font_picker_open {
cx.notify();
}
});
},
|_, _, _, _| {},
@@ -292,6 +300,8 @@ impl SettingsPane {
.enumerate()
.map(|(index, family)| {
let family_name: SharedString = family.clone().into();
let display_name: SharedString = font_display_name(family).into();
let tooltip_name = display_name.clone();
h_flex()
.id(SharedString::from(format!("font-chain-row-{index}")))
.h(px(38.0))
@@ -312,12 +322,17 @@ impl SettingsPane {
)
.child(
div()
.id(("font-chain-name", index))
.flex_1()
.min_w_0()
.truncate()
.text_sm()
.font_family(family_name)
.child(font_display_name(family)),
.tooltip(move |window, cx| {
gpui_component::tooltip::Tooltip::new(tooltip_name.clone())
.build(window, cx)
})
.child(display_name),
)
.when(index == 0, |row| row.child(badge("主", muted)))
.child(Self::chain_action(
@@ -358,6 +373,8 @@ impl SettingsPane {
let select_name = entry.name.clone();
let append_name = entry.name.clone();
let family: SharedString = entry.name.clone().into();
let display_name: SharedString = font_display_name(&entry.name).into();
let tooltip_name = display_name.clone();
let bundled = entry.source == FontSource::Bundled;
let already_selected =
families.iter().any(|family| family.eq_ignore_ascii_case(&entry.name));
@@ -374,12 +391,17 @@ impl SettingsPane {
.hover(|row| row.bg(hover_bg))
.child(
div()
.id(("font-available-name", index))
.flex_1()
.min_w_0()
.truncate()
.text_sm()
.font_family(family)
.child(font_display_name(&entry.name)),
.tooltip(move |window, cx| {
gpui_component::tooltip::Tooltip::new(tooltip_name.clone())
.build(window, cx)
})
.child(display_name),
)
.when(bundled, |row| row.child(badge("内置", muted)))
.when(entry.source == FontSource::Imported, |row| {
@@ -371,7 +371,6 @@ impl SettingsPane {
// 占位「搜索动作或按键…」,下面再空 12px 才到冲突条 / 分组。
v_flex()
.w_full()
.gap(px(GROUP_GAP))
// 捕获态的「点击任何位置先撤销」(旧壳 input/chrome.rs 的统一撤
// 销合同):行的 mouse_down 会 stop_propagation 自行处理转移/
// 取消,搜索框这里显式撤销(旧壳点搜索框 = blur 捕获),其余
+14 -16
View File
@@ -33,9 +33,7 @@ use super::session::{self, TerminalSession};
use super::suggest;
use super::{KEY_CONTEXT, TerminalBackTab, TerminalTab};
use crate::gpui_shell::config::Settings;
use crate::gpui_shell::prelude::{
ActiveTheme as _, Colorize as _, DialogButtonProps, center_confirm_dialog,
};
use crate::gpui_shell::prelude::{ActiveTheme as _, ButtonVariant, Colorize as _, confirm_dialog};
use crate::{config::UiConfig, font_install::REQUIRED_FONT_FAMILY};
use futures::StreamExt as _;
@@ -1398,19 +1396,19 @@ impl TerminalView {
window.open_dialog(cx, move |dialog, window, _cx| {
let text = text.clone();
let view = view.clone();
center_confirm_dialog(dialog, window)
.title(title.clone())
.button_props(
DialogButtonProps::default()
.ok_text(ok_text.clone())
.cancel_text(cancel_text.clone())
.show_cancel(true),
)
.child(body.clone())
.on_ok(move |_, _window, cx| {
let _ = view.update(cx, |this, cx| this.paste_now(&text, cx));
true
})
confirm_dialog(
dialog,
window,
title.clone(),
body.clone(),
ok_text.clone(),
cancel_text.clone(),
ButtonVariant::Primary,
)
.on_ok(move |_, _window, cx| {
let _ = view.update(cx, |this, cx| this.paste_now(&text, cx));
true
})
});
}
+70 -64
View File
@@ -106,6 +106,10 @@ gpui::actions!(
/// 不能挂全局:否则 CC/Codex 的终止对话键到不了 PTY。
const PALETTE_KEY_CONTEXT: &str = "NebulaCommandPalette";
/// 侧栏槽位右缘到正文卡可见左缘的 8px 卡缝;拖拽热区必须对准后者。
const SIDEBAR_RESIZE_VISUAL_OFFSET: f32 = 8.0;
const SIDEBAR_RESIZE_HANDLE_WIDTH: f32 = 6.0;
/// 工作区静态默认绑定的 combo 集([`init`] 的镜像)。撤销已失效的自定义
/// 注入时要排除:gpui 的 NoAction 打在静态默认键上会误杀基础功能。
const STATIC_DEFAULT_COMBOS: &[&str] = &[
@@ -1571,32 +1575,31 @@ impl NebulaWorkspace {
window.open_dialog(cx, move |dialog, window, _cx| {
let confirm_workspace = confirm_workspace.clone();
let close_workspace = close_workspace.clone();
center_confirm_dialog(dialog, window)
.title("关闭窗口?")
.button_props(
DialogButtonProps::default()
.ok_text("关闭")
.ok_variant(gpui_component::button::ButtonVariant::Danger)
.cancel_text("取消")
.show_cancel(true),
)
.child(body.clone())
.on_ok(move |_, window, cx| {
let _ = confirm_workspace.update(cx, |workspace, cx| {
workspace.save_clean_window_session(cx);
workspace.window_close_confirm_open = false;
// `remove_window` 是确认后的最终动作,不会重新触发
// should-close,从而避免再次弹出同一确认框。
window.remove_window();
});
true
})
.on_close(move |_, _, cx| {
let _ = close_workspace.update(cx, |workspace, cx| {
workspace.window_close_confirm_open = false;
cx.notify();
});
})
confirm_dialog(
dialog,
window,
"关闭窗口?",
body.clone(),
"关闭",
"取消",
ButtonVariant::Danger,
)
.on_ok(move |_, window, cx| {
let _ = confirm_workspace.update(cx, |workspace, cx| {
workspace.save_clean_window_session(cx);
workspace.window_close_confirm_open = false;
// `remove_window` 是确认后的最终动作,不会重新触发
// should-close,从而避免再次弹出同一确认框。
window.remove_window();
});
true
})
.on_close(move |_, _, cx| {
let _ = close_workspace.update(cx, |workspace, cx| {
workspace.window_close_confirm_open = false;
cx.notify();
});
})
});
false
}
@@ -1616,22 +1619,21 @@ impl NebulaWorkspace {
let workspace = cx.entity().downgrade();
window.open_dialog(cx, move |dialog, window, _cx| {
let workspace = workspace.clone();
center_confirm_dialog(dialog, window)
.title("关闭此分栏?")
.button_props(
DialogButtonProps::default()
.ok_text("关闭")
.ok_variant(gpui_component::button::ButtonVariant::Danger)
.cancel_text("取消")
.show_cancel(true),
)
.child(body.clone())
.on_ok(move |_, window, cx| {
let _ = workspace.update(cx, |workspace, cx| {
workspace.close_pane(tab_ix, pane_id, window, cx);
});
true
})
confirm_dialog(
dialog,
window,
"关闭此分栏?",
body.clone(),
"关闭",
"取消",
ButtonVariant::Danger,
)
.on_ok(move |_, window, cx| {
let _ = workspace.update(cx, |workspace, cx| {
workspace.close_pane(tab_ix, pane_id, window, cx);
});
true
})
});
}
@@ -1644,22 +1646,21 @@ impl NebulaWorkspace {
let workspace = cx.entity().downgrade();
window.open_dialog(cx, move |dialog, window, _cx| {
let workspace = workspace.clone();
center_confirm_dialog(dialog, window)
.title("关闭此标签页?")
.button_props(
DialogButtonProps::default()
.ok_text("关闭")
.ok_variant(gpui_component::button::ButtonVariant::Danger)
.cancel_text("取消")
.show_cancel(true),
)
.child(body.clone())
.on_ok(move |_, window, cx| {
let _ = workspace.update(cx, |workspace, cx| {
workspace.close_tab(tab_ix, window, cx);
});
true
})
confirm_dialog(
dialog,
window,
"关闭此标签页?",
body.clone(),
"关闭",
"取消",
ButtonVariant::Danger,
)
.on_ok(move |_, window, cx| {
let _ = workspace.update(cx, |workspace, cx| {
workspace.close_tab(tab_ix, window, cx);
});
true
})
});
}
@@ -4508,8 +4509,11 @@ impl Render for NebulaWorkspace {
.absolute()
.top_0()
.bottom_0()
.left(px(-3.0))
.w(px(6.0))
.left(px(
SIDEBAR_RESIZE_VISUAL_OFFSET
- SIDEBAR_RESIZE_HANDLE_WIDTH * 0.5,
))
.w(px(SIDEBAR_RESIZE_HANDLE_WIDTH))
.cursor_col_resize()
.on_mouse_down(
MouseButton::Left,
@@ -4621,10 +4625,12 @@ impl Render for NebulaWorkspace {
.occlude()
.cursor_col_resize()
.on_mouse_move(cx.listener(|this, event: &gpui::MouseMoveEvent, _, cx| {
let width = f32::from(event.position.x).clamp(
nebula_settings::MIN_SIDEBAR_WIDTH,
nebula_settings::MAX_SIDEBAR_WIDTH,
);
let width = (f32::from(event.position.x)
- SIDEBAR_RESIZE_VISUAL_OFFSET)
.clamp(
nebula_settings::MIN_SIDEBAR_WIDTH,
nebula_settings::MAX_SIDEBAR_WIDTH,
);
if (width - this.sidebar_width).abs() >= 0.5 {
this.sidebar_width = width;
cx.notify();
@@ -417,29 +417,28 @@ impl NebulaWorkspace {
window.open_dialog(cx, move |dialog, window, _cx| {
let workspace = workspace.clone();
let path = path.clone();
center_confirm_dialog(dialog, window)
.title(title.clone())
.button_props(
DialogButtonProps::default()
.ok_text("删除")
.ok_variant(gpui_component::button::ButtonVariant::Danger)
.cancel_text("取消")
.show_cancel(true),
)
.child(body.clone())
.on_ok(move |_, _, cx| {
let _ = workspace.update(cx, |this, cx| {
match crate::display::send_to_recycle_bin(&path) {
Ok(()) => {
this.side_panel.request_refresh();
this.sync_side_panel_to_active(false, cx);
},
Err(error) => this.side_panel.set_notice(format!("删除失败:{error}")),
}
cx.notify();
});
true
})
confirm_dialog(
dialog,
window,
title.clone(),
body.clone(),
"删除",
"取消",
ButtonVariant::Danger,
)
.on_ok(move |_, _, cx| {
let _ = workspace.update(cx, |this, cx| {
match crate::display::send_to_recycle_bin(&path) {
Ok(()) => {
this.side_panel.request_refresh();
this.sync_side_panel_to_active(false, cx);
},
Err(error) => this.side_panel.set_notice(format!("删除失败:{error}")),
}
cx.notify();
});
true
})
});
}
}
@@ -32,6 +32,9 @@ pub(crate) fn show_update_notification(
let action_result = result.clone();
let notification = Notification::warning(message)
.title(title)
// 更新是需要用户明确处理的待办;保持到查看或手动关闭,避免短暂出现后
// 被误判为“没有右下角提示”。
.autohide(false)
.w_auto()
.min_w(px(300.0))
.max_w(px(440.0))
@@ -129,10 +132,12 @@ pub(crate) fn open_update_dialog(
),
)
.child(
div().text_sm().line_height(relative(1.55)).text_color(muted).child(hint.clone()),
div().text_base().line_height(relative(1.5)).text_color(muted).child(hint.clone()),
);
center_confirm_dialog(dialog, window)
.title(title.clone())
center_modal_dialog(dialog, window, 250.0)
.close_button(false)
.overlay_closable(false)
.title(div().text_lg().font_semibold().line_height(relative(1.0)).child(title.clone()))
.footer(
DialogFooter::new()
.child(
+15
View File
@@ -118,6 +118,21 @@ pub fn spawn_gpui_once(sender: std::sync::mpsc::Sender<crate::gpui_shell::GpuiSh
return;
}
let spawned = std::thread::Builder::new().name("update-check-gpui".into()).spawn(move || {
if cfg!(debug_assertions) {
// 调试包启动后强制走一次完整的“事件 -> 右下角通知 -> 详情弹窗”链路。
// STARTED 仍保证每进程仅一次;正式包的该条件恒为 false,不会触发。
std::thread::sleep(Duration::from_secs(2));
let current = env!("CARGO_PKG_VERSION").to_owned();
let result = UpdateCheckResult {
latest: format!("{current}-test"),
current,
update_available: true,
};
log::info!("update-check: forcing one GPUI update reminder in debug build");
let _ = sender.send(crate::gpui_shell::GpuiShellEvent::UpdateAvailable(result));
return;
}
// 对齐旧壳:首屏和首个终端会话稳定后再联网。
std::thread::sleep(Duration::from_secs(12));
let result = match check_now() {