From b42e81c7cb73050845bed62fed2a5ff843e517ab Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 3 Aug 2026 19:36:40 +0800 Subject: [PATCH] fix: route recovery notices to toast and keep message close visible --- CHANGELOG.md | 8 +- nebula_app/src/display/mod.rs | 52 ++++++++ nebula_app/src/display/toast.rs | 176 ++++++++++++++++++++++++ nebula_app/src/display/ui/mod.rs | 2 + nebula_app/src/display/ui/toast.rs | 191 +++++++++++++++++++++++++++ nebula_app/src/display/ui/widgets.rs | 37 ++++++ nebula_app/src/event.rs | 20 ++- nebula_app/src/input/mouse.rs | 14 ++ nebula_app/src/message_bar.rs | 78 +++++------ 9 files changed, 533 insertions(+), 45 deletions(-) create mode 100644 nebula_app/src/display/toast.rs create mode 100644 nebula_app/src/display/ui/toast.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 46b6329..7c3c611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,9 @@ Every release entry is provided in English and Simplified Chinese. #### Fixed -- **A crash is now told apart from a clean exit** — the session snapshot records whether the process reached its teardown. After an unclean exit (crash, force-kill, power loss) the restored window says so in the message bar instead of restoring silently; and when the crash-loop breaker trips after three failed launches, the offending session is moved to `session.crashed.json` instead of being overwritten by the next autosave a second later — previously the only evidence of a restore-crash loop destroyed itself. +- **A crash is now told apart from a clean exit** — the session snapshot records whether the process reached its teardown. After an unclean exit (crash, force-kill, power loss) the restored window says so in a transient success toast instead of restoring silently; and when the crash-loop breaker trips after three failed launches, the offending session is moved to `session.crashed.json` instead of being overwritten by the next autosave a second later — previously the only evidence of a restore-crash loop destroyed itself. +- **Recovery notices use the right layer** — a completed recovery is a short success toast, while a crash-loop breaker notice remains in the message bar with the quarantined session path available for follow-up. +- **The message-bar close button stays visible with CJK text** — wide characters are measured in terminal columns and the close control is drawn as a reusable UI widget, so long multilingual notices no longer push the button off-screen; its plate and hover state make the control visibly clickable. - **Sharp text and icons in the SSH host editor** — the identity strip's name, the avatar shape and the icon list previously scaled up bitmaps rasterized at the terminal font size, visibly blurry next to the design; they now re-rasterize at their true size. Caret placement in the enlarged name field follows the true glyph advance, so clicking the tenth character no longer lands the caret a column off. - **The icon picker no longer shows form text through its panel** — overlay backgrounds were submitted in the same batch as the form's fills, which all paint beneath text; overlays (the icon list, the test-status tooltip) now paint after the form's text, so nothing bleeds through. - **Test-connection failures show the whole reason** — the status line used to flatten the error into one truncated line with the full text hidden behind a hover tooltip. Failures now wrap up to four lines right in the dialog; the tooltip remains only for over-long tails. @@ -51,7 +53,9 @@ Every release entry is provided in English and Simplified Chinese. #### 修复 -- **崩溃与正常退出现在分得清了** — 会话快照记录进程有没有走完收尾。上次是异常退出(崩溃、强杀、断电)时,恢复后会在消息栏明说,而不是悄悄恢复;连续三次启动失败触发断路器时,那份会话被挪到 `session.crashed.json` 而不是被一秒后的自动保存盖掉——此前「一恢复就崩」的唯一现场会自己销毁。 +- **崩溃与正常退出现在分得清了** — 会话快照记录进程有没有走完收尾。上次是异常退出(崩溃、强杀、断电)时,恢复后会用自动消失的 success toast 明说,而不是悄悄恢复;连续三次启动失败触发断路器时,那份会话被挪到 `session.crashed.json` 而不是被一秒后的自动保存盖掉——此前「一恢复就崩」的唯一现场会自己销毁。 +- **恢复提示分到正确的层** — 已完成的恢复成功提示走短暂的 success toast;断路器提示仍留在消息栏,并保留隔离会话路径供后续处理。 +- **中文消息栏的关闭按钮始终可见** — 按终端显示列宽处理宽字符,关闭控件改为可复用 UI 组件自绘,中文长消息不会再把按钮挤出屏幕;常态底板与悬停反馈让它明显可点击。 - **SSH 主机编辑器的大字与图标不再发糊** — 身份条的名字、头像形状与图标列表此前是把按终端字号栅格化的位图硬放大,对着原型一眼可见的糊;现在按真实字号重新栅格化。放大后名字框的光标换算按真实字形步进,点第十个字不再偏一格。 - **图标选择器不再透出表单文字** — 浮层的底与表单同批提交时全部沉在文字之下;现在浮层(图标列表、测试状态提示)在表单文字之后单独提交,什么都透不上来。 - **「测试连接」失败显示完整原因** — 此前被压成单行截断,全文藏在悬浮层里;现在失败原因直接折行铺开(最多四行),只有超长的尾巴才收进悬浮层。 diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 06aadc1..2dfa736 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -85,6 +85,7 @@ mod state; mod surface_opacity; mod terminal_color; mod terminal_math; +mod toast; /// Processor uses the same persisted value before the first window exists so /// the global quick-terminal shortcut is active from application startup. @@ -105,6 +106,7 @@ pub use state::{ AcceptKey, NebulaConfirm, NebulaInlineImage, NebulaPaneState, NebulaShell, SplitDirection, SplitNav, }; +pub use toast::ToastKind; mod file_dialog; pub(crate) mod keymap; @@ -1241,6 +1243,13 @@ pub struct Display { /// Undo button geometry published by the draw pass for exact hit-testing. nebula_ssh_delete_undo_rect: Option<(f32, f32, f32, f32)>, nebula_ssh_delete_undo_hover: bool, + /// 在场的轻提示(右下角,自动消失)。见 [`toast`]。 + nebula_toasts: Vec, + /// 消息栏关闭按钮:绘制矩形 + 墨色,由终端 pass 发布给 chrome pass。 + /// 几何来自 `message_bar::message_close_button_rect`,与输入层的命中共用 + /// 同一个 helper,所以画出来的和点得到的永远是同一块。 + nebula_message_close: Option<((f32, f32, f32, f32), Rgb)>, + nebula_message_close_hover: bool, pub nebula_ssh_editor: Option, pub nebula_ssh_editor_rects: Option, nebula_ssh_editor_open: bool, @@ -1921,6 +1930,9 @@ impl Display { nebula_ai_fix_bar: None, nebula_ssh_delete_undo_rect: None, nebula_ssh_delete_undo_hover: false, + nebula_toasts: Vec::new(), + nebula_message_close: None, + nebula_message_close_hover: false, nebula_ssh_editor: None, nebula_ssh_editor_rects: None, nebula_ssh_editor_open: false, @@ -2237,6 +2249,33 @@ impl Display { } } + pub fn set_message_close_hover(&mut self, hovered: bool) { + if self.nebula_message_close_hover != hovered { + self.nebula_message_close_hover = hovered; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + + /// 消息栏的关闭按钮。画在 chrome pass 里而不是随消息文本走:横幅是终端 + /// 文字管线画的,那条路只能放字符,而拼进文本的 `[X]` 会被 CJK 消息挤出 + /// 屏幕——按钮点得到却看不见,用户因此报告「无法关闭」。 + /// + /// 配色走终端色系(横幅是 yellow/red 底 + 背景色的字),所以墨色由终端 + /// pass 一并发布,不取 Skin。 + fn draw_message_close(&mut self) { + let Some((rect, ink)) = self.nebula_message_close else { return }; + let size = self.size_info; + let scale = self.window.scale_factor as f32; + let ink = Rgba::new(ink.r, ink.g, ink.b, 255); + // 常态就有一层淡底,按钮才读得出"可点";hover 加深作为反馈。 + let fill = Rgba::new(ink.r, ink.g, ink.b, if self.nebula_message_close_hover { 64 } else { 28 }); + + let mut quads = Vec::new(); + ui::widgets::push_close_button(&mut quads, rect, scale, ink, fill); + self.renderer.draw_ui(&size, &quads); + } + pub fn set_chrome_tabs( &mut self, labels: Vec, @@ -5819,7 +5858,16 @@ impl Display { glyph_cache, ); } + + // 关闭按钮交给 chrome pass 自绘:这里是终端文字管线,画不了圆角 + // 底和图标墨迹。发布几何 + 墨色,`draw_message_close` 随后照着画, + // 与 `message_close_button_rect` 共用同一份矩形。 + self.nebula_message_close = + message_bar::message_close_button_rect(&size_info, search_offset != 0) + .map(|rect| ((rect.x, rect.y, rect.width, rect.height), fg)); } else { + self.nebula_message_close = None; + self.nebula_message_close_hover = false; // Draw rectangles. self.renderer.draw_rects(&size_info, &metrics, rects); } @@ -6924,6 +6972,10 @@ impl Display { self.draw_resize_hud(); context_menu::draw(self); self.draw_ssh_delete_undo(); + // 消息栏的关闭按钮:横幅由终端 pass 画,按钮必须在它之上。 + self.draw_message_close(); + // 轻提示:右下角,在浮条之上、模态之下(模态要求决策,不该被提示压住)。 + toast::draw(self); self.draw_ai_fix_bar(); self.draw_ssh_editor_modal(); self.draw_confirm_modal(); diff --git a/nebula_app/src/display/toast.rs b/nebula_app/src/display/toast.rs new file mode 100644 index 0000000..6b66cca --- /dev/null +++ b/nebula_app/src/display/toast.rs @@ -0,0 +1,176 @@ +//! Toast 的生命周期:谁在场、活多久、什么时候淡出。 +//! +//! 视觉配方全在 [`ui::toast`](crate::display::ui::toast),这里只推进状态。 +//! +//! # 为什么这条提示不该用消息栏 +//! +//! 消息栏那条横幅会一直占着窗口底部整整一行,直到用户找到并点掉它。这个代价 +//! 只在「用户必须做点什么」时才划算:配置写错了、断路器拦下了会话恢复。而 +//! 「已恢复 3 个标签」这类**已经结束的事实**没有任何待办动作,却同样要求用户 +//! 去点一个他根本没注意到的关闭按钮,于是横幅就一直挂在那儿。这类信息归 +//! toast:说完就走,不占布局,不接鼠标。 +//! +//! # 允许它自动消失的前提 +//! +//! 每条 toast 同时写一行 `log::info!`。toast 不承载唯一副本——错过那几秒的 +//! 用户仍然能在日志里查到,否则「自动消失」就是在丢信息。 + +use std::time::{Duration, Instant}; + +use unicode_width::UnicodeWidthChar; + +use crate::display::Display; +use crate::display::ui::toast as recipe; +use crate::motion::{Easing, MotionPolicy, Tween}; + +pub use recipe::ToastKind; + +/// 完全可见的停留时长(不含出入场)。4.2s:够从容读完一句中文长句,又短到 +/// 用户不会开始琢磨"它怎么还不走"。 +const HOLD: Duration = Duration::from_millis(4200); +const FADE_IN: Duration = Duration::from_millis(140); +const FADE_OUT: Duration = Duration::from_millis(240); +/// 同屏最多叠三条,再多就挤掉最旧的。toast 是提示不是日志。 +const MAX_VISIBLE: usize = 3; + +#[derive(Debug)] +pub(super) struct Toast { + text: String, + kind: ToastKind, + born: Instant, + progress: Tween, + /// 已进入淡出段。用标志位而不是每帧比时间,是为了让 `animate_to` 只被调用 + /// 一次——重复下发会把补间起点一直重置在当前值上,结果永远淡不完。 + leaving: bool, +} + +impl Toast { + fn new(text: String, kind: ToastKind) -> Self { + let mut progress = Tween::new(0.0); + progress.animate_to(1.0, FADE_IN, Easing::SwiftOut, MotionPolicy::Full); + Self { text, kind, born: Instant::now(), progress, leaving: false } + } +} + +impl Display { + /// 弹一条自动消失的提示。 + /// + /// 同时落一行日志:这是 toast 被允许自动消失的前提,见模块文档。 + /// + /// 立即请求一次重绘——调用点通常在启动或某个操作的收尾处,那之后窗口可能 + /// 长时间没有任何重绘理由,提示就不会有第一帧。 + pub fn push_toast(&mut self, text: impl Into, kind: ToastKind) { + let text = text.into(); + if text.trim().is_empty() { + return; + } + log::info!("toast [{kind:?}]: {text}"); + self.nebula_toasts.push(Toast::new(text, kind)); + while self.nebula_toasts.len() > MAX_VISIBLE { + self.nebula_toasts.remove(0); + } + self.window.request_redraw(); + } +} + +/// 推进并绘制整叠提示。最新的一条在最下,旧的往上排。 +pub(super) fn draw(d: &mut Display) { + if d.nebula_toasts.is_empty() { + return; + } + + let frame = d.nebula_ui_anims.frame(); + for toast in &mut d.nebula_toasts { + toast.progress.step(frame); + if !toast.leaving && toast.born.elapsed() >= HOLD { + toast.progress.animate_to(0.0, FADE_OUT, Easing::EaseInQuad, MotionPolicy::Full); + toast.leaving = true; + } + } + d.nebula_toasts.retain(|toast| !toast.leaving || toast.progress.is_active()); + // 这一帧本身就是「已经没有提示」的那一帧(present 每帧全量重画),清空后 + // 直接收工,不必再约下一帧。 + if d.nebula_toasts.is_empty() { + return; + } + + let size = d.ui_size_info(); + let scale = d.window.scale_factor as f32; + let cell_w = size.cell_width(); + let cell_h = size.cell_height(); + let viewport = (size.width(), size.height()); + let sk = d.nebula_theme.skin(); + let capacity = recipe::text_capacity(viewport.0, cell_w, scale); + + // 先把这一帧要画的摘出来:绘制要同时可变借用 renderer 与 glyph_cache, + // 不能再攥着 toast 列表。最多三条短字符串,无所谓。 + let items: Vec<(String, ToastKind, f32)> = d + .nebula_toasts + .iter() + .rev() + .map(|toast| { + (fit_cols(&toast.text, capacity), toast.kind, toast.progress.value().clamp(0.0, 1.0)) + }) + .collect(); + + for (index, (text, kind, progress)) in items.iter().enumerate() { + if *progress <= 0.004 { + continue; + } + let rect = + recipe::toast_rect(viewport, index, text_cols(text), cell_w, cell_h, scale, *progress); + + let mut quads = Vec::new(); + recipe::push_toast(&mut quads, rect, viewport, *kind, scale, &sk, *progress); + d.renderer.draw_ui(&size, &quads); + + let (text_x, text_y) = recipe::text_origin(rect, cell_h, scale); + let ink = recipe::ink(&sk, *progress); + d.renderer.draw_chrome_text(&size, text_x, text_y, ink, text, &mut d.glyph_cache); + } + + // 保活:提示全靠自己的时钟退场,中间没有任何输入事件来推动帧循环。 + d.window.request_redraw(); +} + +fn text_cols(text: &str) -> usize { + text.chars().map(|ch| ch.width().unwrap_or(1).max(1)).sum() +} + +/// 按列宽裁剪并补省略号。按显示宽度累加而不是数字符:一个汉字占两列,数字符 +/// 会让中文提示超出卡片。 +fn fit_cols(text: &str, cols: usize) -> String { + if text_cols(text) <= cols { + return text.to_owned(); + } + let budget = cols.saturating_sub(1); + let mut out = String::new(); + let mut used = 0; + for ch in text.chars() { + let width = ch.width().unwrap_or(1).max(1); + if used + width > budget { + break; + } + out.push(ch); + used += width; + } + out.push('…'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fit_cols_counts_cjk_as_two_columns() { + assert_eq!(fit_cols("已恢复", 6), "已恢复"); + // 6 列预算减去省略号那一列 = 5 列,只放得下两个汉字。 + assert_eq!(fit_cols("已恢复三个标签", 6), "已恢…"); + } + + #[test] + fn fit_cols_keeps_short_text_untouched() { + assert_eq!(fit_cols("ok", 10), "ok"); + } +} diff --git a/nebula_app/src/display/ui/mod.rs b/nebula_app/src/display/ui/mod.rs index 7cd81ee..6e20d31 100644 --- a/nebula_app/src/display/ui/mod.rs +++ b/nebula_app/src/display/ui/mod.rs @@ -30,6 +30,7 @@ //! - [`surface`] —— 面板/卡片/描边/遮罩的配方(本层的核心) //! - [`text_field`] —— 输入框的光标、选区、命中测试(唯一有可变状态的组件) //! - [`widgets`] —— slider / toggle / combobox / spinner +//! - [`toast`] —— 自动消失的轻提示浮层 //! - [`keycap`] —— 键帽 chip //! - [`icons`] —— 图标墨迹 //! - [`guardrails`] —— 把上面这些契约变成会失败的测试 @@ -56,5 +57,6 @@ pub mod os_icons; pub mod surface; pub mod text_field; pub mod theme; +pub mod toast; pub mod tokens; pub mod widgets; diff --git a/nebula_app/src/display/ui/toast.rs b/nebula_app/src/display/ui/toast.rs new file mode 100644 index 0000000..faa038c --- /dev/null +++ b/nebula_app/src/display/ui/toast.rs @@ -0,0 +1,191 @@ +//! Toast 浮层的视觉配方:一次性、自动消失的轻提示。 +//! +//! 本模块只管**长什么样**和**在哪儿**——尺寸、语义色、入场位移、文字原点。 +//! 生命周期(停留多久、什么时候淡出、怎么堆叠)属于状态,归 +//! [`crate::display::toast`]。分家的理由和本层其余组件一致:几何有唯一来源, +//! 绘制与命中就不可能漂移;将来别处要弹提示,拿到的是同一份配方而不是又一 +//! 套手写矩形。 +//! +//! 位置定在右下角而不是底部居中:底部中线已经被 SSH 撤销条和 AI 建议条占着, +//! 那两条带按钮和键位、必须停在视觉中线上。toast 不接鼠标,让开就好——这样 +//! 也省掉了一整套互相避让的逻辑。 + +use super::surface; +use super::theme::Skin; +use super::tokens::{radius, space}; +use crate::display::color::Rgb; +use crate::renderer::ui::{Rgba, UiQuad}; + +pub(crate) type Rect = (f32, f32, f32, f32); + +/// 单条高度的下限(逻辑像素)。实际高度还要保证容得下一行文字。 +const MIN_H: f32 = 48.0; +/// 距窗口右下角的留白。 +const MARGIN: f32 = 20.0; +/// 单条最大宽度。再宽就不该用 toast 承载了。 +const MAX_W: f32 = 560.0; +/// 左侧语义色细条的宽度。 +const RAIL_W: f32 = 3.0; +/// 入场时先压低这么多再浮上来;淡出时反向沉下去。 +const RISE: f32 = 10.0; + +/// 提示的语气。语义色只花在左边那根细条上——强调色与渐变的预算不动。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastKind { + /// 中性信息。 + Info, + /// 已经办成的事:恢复成功、保存成功。 + Success, + /// 值得知道,但不需要动手。真正要用户处理的仍然走消息栏。 + Warning, +} + +impl ToastKind { + fn rail(self, sk: &Skin) -> Rgba { + match self { + Self::Info => Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255), + Self::Success => sk.ok, + Self::Warning => sk.warn, + } + } +} + +/// 单条的高度。 +#[inline] +pub(crate) fn bar_height(cell_h: f32, scale: f32) -> f32 { + (MIN_H * scale).max(cell_h + space::S * scale) +} + +/// 文字能占的最大列数。布局与截断都用它,卡片因此永远不会被文字撑破。 +pub(crate) fn text_capacity(viewport_w: f32, cell_w: f32, scale: f32) -> usize { + let s = |value: f32| value * scale; + let inner = max_width(viewport_w, scale) - s(space::M) * 2.0 - s(RAIL_W) - s(space::S); + ((inner / cell_w.max(1.0)).floor() as usize).max(6) +} + +fn max_width(viewport_w: f32, scale: f32) -> f32 { + let s = |value: f32| value * scale; + s(MAX_W).min(viewport_w - s(MARGIN) * 2.0).max(s(MIN_H) * 2.0) +} + +/// 第 `index` 条的矩形(0 = 最下面那条,也就是最新的一条)。 +/// +/// `progress` 是 0..1 的出入场进度:不到 1 时整条压低 [`RISE`],读作浮上来 / +/// 沉下去。坐标吸附到整像素——半像素的浮层边会被 GPU 采样糊成两条灰边。 +pub(crate) fn toast_rect( + viewport: (f32, f32), + index: usize, + text_cols: usize, + cell_w: f32, + cell_h: f32, + scale: f32, + progress: f32, +) -> Rect { + let s = |value: f32| value * scale; + let (view_w, view_h) = viewport; + let bar_h = bar_height(cell_h, scale); + let content = s(space::M) * 2.0 + s(RAIL_W) + s(space::S) + text_cols as f32 * cell_w; + let bar_w = content.min(max_width(view_w, scale)); + let x = (view_w - s(MARGIN) - bar_w).max(0.0); + let stack = index as f32 * (bar_h + s(space::XS)); + let y = view_h - s(MARGIN) - bar_h - stack + s(RISE) * (1.0 - progress.clamp(0.0, 1.0)); + (x.round(), y.round(), bar_w, bar_h) +} + +/// 文字的左上角原点。与 [`toast_rect`] 同源,文字不会和卡片错位。 +pub(crate) fn text_origin(rect: Rect, cell_h: f32, scale: f32) -> (f32, f32) { + let s = |value: f32| value * scale; + let (x, y, _, h) = rect; + ((x + s(space::M) + s(RAIL_W) + s(space::S)).round(), (y + (h - cell_h) * 0.5).round()) +} + +/// 卡片本体:Menu 层的浮层配方 + 左侧语义色细条。 +/// +/// `progress` 同时驱动整条的透明度,所以出入场不需要调用方再去调色。 +pub(crate) fn push_toast( + quads: &mut Vec, + rect: Rect, + viewport: (f32, f32), + kind: ToastKind, + scale: f32, + sk: &Skin, + progress: f32, +) { + let s = |value: f32| value * scale; + let (x, y, _, h) = rect; + surface::push_surface(quads, rect, viewport, scale, sk, surface::Elevation::Menu, progress); + + let rail_w = s(RAIL_W); + let rail_h = (h - s(space::M)).max(s(space::S)); + quads.push(UiQuad::solid( + x + s(space::M), + (y + (h - rail_h) * 0.5).round(), + rail_w, + rail_h, + rail_w * 0.5, + surface::fade(kind.rail(sk), progress), + )); +} + +/// 文字的墨色。 +/// +/// chrome 的文字管线没有 alpha 通道,淡入淡出只能把墨混向卡片自己的底色—— +/// 这也是卡片底必须不透明([`surface::Elevation::Menu`])的原因之一。 +pub(crate) fn ink(sk: &Skin, progress: f32) -> Rgb { + let progress = progress.clamp(0.0, 1.0); + if progress >= 1.0 { + return sk.ink; + } + let mix = |top: u8, bottom: u8| { + (top as f32 * progress + bottom as f32 * (1.0 - progress)).round().clamp(0.0, 255.0) as u8 + }; + Rgb::new( + mix(sk.ink.r, sk.panel.r), + mix(sk.ink.g, sk.panel.g), + mix(sk.ink.b, sk.panel.b), + ) +} + +/// 圆角查询:调用方需要在卡片上再叠东西时用,避免又写死一个数。 +#[inline] +pub(crate) fn corner(scale: f32) -> f32 { + radius::OVERLAY * scale +} + +#[cfg(test)] +mod tests { + use super::*; + + const VIEWPORT: (f32, f32) = (1000.0, 800.0); + + #[test] + fn stack_grows_upward_from_the_bottom_right() { + let first = toast_rect(VIEWPORT, 0, 20, 8.0, 18.0, 1.0, 1.0); + let second = toast_rect(VIEWPORT, 1, 20, 8.0, 18.0, 1.0, 1.0); + assert!(second.1 < first.1, "新的一条在下,旧的往上排"); + assert_eq!(first.0 + first.2, VIEWPORT.0 - MARGIN, "右边缘留白固定"); + } + + #[test] + fn card_never_exceeds_the_viewport_budget() { + let wide = toast_rect(VIEWPORT, 0, 400, 8.0, 18.0, 1.0, 1.0); + assert!(wide.2 <= MAX_W, "超长文字不能把卡片撑破"); + assert!(wide.0 >= 0.0); + } + + #[test] + fn entry_progress_only_pushes_the_card_down() { + let settled = toast_rect(VIEWPORT, 0, 20, 8.0, 18.0, 1.0, 1.0); + let entering = toast_rect(VIEWPORT, 0, 20, 8.0, 18.0, 1.0, 0.0); + assert_eq!(entering.1 - settled.1, RISE); + assert_eq!(entering.0, settled.0, "入场只在纵向位移,横向不动"); + } + + #[test] + fn text_origin_stays_inside_the_card() { + let rect = toast_rect(VIEWPORT, 0, 20, 8.0, 18.0, 1.0, 1.0); + let (tx, ty) = text_origin(rect, 18.0, 1.0); + assert!(tx > rect.0 && tx < rect.0 + rect.2); + assert!(ty > rect.1 && ty + 18.0 <= rect.1 + rect.3); + } +} diff --git a/nebula_app/src/display/ui/widgets.rs b/nebula_app/src/display/ui/widgets.rs index ecef090..7e05a1f 100644 --- a/nebula_app/src/display/ui/widgets.rs +++ b/nebula_app/src/display/ui/widgets.rs @@ -236,6 +236,43 @@ pub(crate) fn push_combobox_popup( } } +// ---- close button ---- + +/// 按钮的可视方块:在命中区里居中的正方形。绘制与 hover 反馈都用它,命中区 +/// 本身可以比它宽(消息栏那种按网格列切出来的 3 列区域),指针容差因此不会 +/// 被图标的视觉尺寸绑死。 +pub(crate) fn close_button_plate_rect(hit: Rect) -> Rect { + let (hx, hy, hw, hh) = hit; + let side = hw.min(hh); + ((hx + (hw - side) * 0.5).round(), (hy + (hh - side) * 0.5).round(), side, side) +} + +/// 关闭按钮:一块底 + 一个 ✕ 墨迹。 +/// +/// `fill` 由调用方按状态给(常态一层淡底、hover 加深)——本层不做 hover 决策。 +/// 常态就画底是刻意的:裸 ✕ 在横幅上读不出"这是个可点的东西",用户反馈过 +/// 「看不到关闭按钮 = 无法关闭」。 +/// +/// `ink` 同样由调用方给:消息栏是终端色系(黄/红底 + 背景色的字),不走 Skin。 +pub(crate) fn push_close_button( + quads: &mut Vec, + hit: Rect, + scale: f32, + ink: Rgba, + fill: Rgba, +) { + let plate = close_button_plate_rect(hit); + let (px, py, side, _) = plate; + quads.push(UiQuad::solid(px, py, side, side, radius::CHIP * scale, fill)); + + let cx = px + side * 0.5; + let cy = py + side * 0.5; + let half = (side * 0.22).max(3.0 * scale); + let stroke = (1.4 * scale).max(1.0); + icons::push_segment(quads, (cx - half, cy - half), (cx + half, cy + half), stroke, ink); + icons::push_segment(quads, (cx + half, cy - half), (cx - half, cy + half), stroke, ink); +} + // ---- spinner ---- /// Numeric stepper docked at the right edge of its row: diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index 05b0eb8..96fbc0a 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -53,7 +53,7 @@ use crate::display::NebulaPaneState; use crate::display::color::Rgb; use crate::display::hint::HintMatch; use crate::display::window::{ImeInhibitor, Window}; -use crate::display::{Display, Preedit, SizeInfo}; +use crate::display::{Display, Preedit, SizeInfo, ToastKind}; use crate::input::{self, ActionContext as _}; use crate::logging::{LOG_TARGET_CONFIG, LOG_TARGET_WINIT}; use crate::message_bar::{Message, MessageBuffer, MessageType}; @@ -235,12 +235,17 @@ impl Processor { && window_options.terminal_options.command().is_none(); // 恢复行为归设置·高级→会话管(默认开)。关掉只是不回放——快照照写, // 工作区导出与崩溃现场诊断都还在。 - let mut notice = None; + // + // 两类提示分流:恢复成功是**已经结束、没有待办**的事实,走自动消失的 + // toast;断路器那条带着隔离文件路径,用户可能要去把它捞出来,必须留在 + // 消息栏等他自己关掉。 + let mut restored_notice = None; + let mut blocked_notice = None; let restore = if plain_launch && crate::display::restore_session_enabled() { match crate::session::load() { Some(mut session) if crate::session::should_restore(&session) => { if crate::session::was_crash(&session) { - notice = Some(format!( + restored_notice = Some(format!( "已从上次异常退出恢复 {} 个标签(进程未正常收尾)。", session.tabs.len() )); @@ -254,7 +259,7 @@ impl Processor { // 会话隔离出去再干净启动,否则一秒后的自动保存就会盖掉这份 // 「一恢复就崩」的唯一现场。 Some(session) if !session.tabs.is_empty() => { - notice = Some(match crate::session::quarantine() { + blocked_notice = Some(match crate::session::quarantine() { Some(path) => format!( "连续三次启动失败,已跳过会话恢复;上次的会话保存在 {}。", path.display() @@ -278,8 +283,11 @@ impl Processor { boot, )?; - // 恢复提示走消息栏:不打断输入,但下一次按键前一定看得见。 - if let Some(text) = notice { + // 恢复成功:说完就走。断路器:留在消息栏,路径要能被读到。 + if let Some(text) = restored_notice { + window_context.display.push_toast(text, ToastKind::Success); + } + if let Some(text) = blocked_notice { window_context.message_buffer.push(Message::new(text, MessageType::Warning)); } diff --git a/nebula_app/src/input/mouse.rs b/nebula_app/src/input/mouse.rs index 67ad371..ba44c33 100644 --- a/nebula_app/src/input/mouse.rs +++ b/nebula_app/src/input/mouse.rs @@ -1267,8 +1267,22 @@ impl> Processor { .then_some(CursorIcon::Default) } + /// 把关闭按钮的悬停态同步给绘制层。指针形状那条路是只读的,而按钮要给 + /// 视觉反馈,所以单独走一次——命中仍旧来自同一个 rect helper。 + fn update_message_close_hover(&mut self) { + let size = self.ctx.size_info(); + let mouse = self.ctx.mouse(); + let (x, y) = (mouse.x as f32, mouse.y as f32); + let search_active = self.ctx.search_active(); + let hovered = self.ctx.message().is_some() + && message_bar::message_close_button_rect(&size, search_active) + .is_some_and(|rect| rect.contains(x, y)); + self.ctx.display().set_message_close_hover(hovered); + } + /// Icon state of the cursor. fn cursor_state(&mut self) -> CursorIcon { + self.update_message_close_hover(); let display_offset = self.ctx.terminal().grid().display_offset(); let mut point = self.ctx.mouse().point(&self.ctx.size_info(), display_offset); // `point` is clamped to `size_info`, but we're about to index the grid, diff --git a/nebula_app/src/message_bar.rs b/nebula_app/src/message_bar.rs index a22dc5b..4a6a0b5 100644 --- a/nebula_app/src/message_bar.rs +++ b/nebula_app/src/message_bar.rs @@ -6,7 +6,14 @@ use nebula_terminal::grid::Dimensions; use crate::display::SizeInfo; -pub const CLOSE_BUTTON_TEXT: &str = "[X]"; +/// 关闭按钮占的列数。按钮本身是自绘图标(见 `ui::widgets::push_close_button`), +/// 这里只负责在首行右端**留出**这块地,让文字不会跑到它底下。 +/// +/// 早先的做法是把 `[X]` 三个字符拼进首行文本里。那条路在中文消息上必然失败: +/// 渲染器 `draw_string` 按显示宽度排版且不消费 spacer,而下面的换行逻辑又给 +/// 每个宽字符补了一个空格,于是一个汉字吃掉三列,首行溢出,行尾的 `[X]` 被 +/// 静默裁掉——按钮点得到却看不见,用户当然会报告"无法关闭"。 +pub const CLOSE_BUTTON_COLUMNS: usize = 3; const CLOSE_BUTTON_PADDING: usize = 1; const MIN_FREE_LINES: usize = 3; const TRUNCATED_MESSAGE: &str = "[MESSAGE TRUNCATED]"; @@ -49,13 +56,15 @@ pub fn message_bar_rect(size_info: &SizeInfo, search_active: bool) -> MessageBar MessageBarRect::new(x, y, right - x, size_info.height() - y) } -/// Pixel hit target occupied by the visible `[X]` on the first message row. +/// Pixel hit target occupied by the visible close button on the first message +/// row. Drawing and hit-testing both come from here, so the icon can never +/// drift from what is clickable. #[inline] pub fn message_close_button_rect( size_info: &SizeInfo, search_active: bool, ) -> Option { - let button_columns = CLOSE_BUTTON_TEXT.chars().count(); + let button_columns = CLOSE_BUTTON_COLUMNS; if size_info.columns() < button_columns { return None; } @@ -100,12 +109,15 @@ impl Message { } /// Formatted message text lines. + /// + /// 一切按**显示宽度**记账(一个汉字两列),因为渲染器就是这么排的。首行 + /// 右端留出 [`CLOSE_BUTTON_COLUMNS`] 列给自绘的关闭按钮。 pub fn text(&self, size_info: &SizeInfo) -> Vec { let num_cols = size_info.columns(); let total_lines = (size_info.height() - size_info.padding_y() - size_info.padding_bottom()) / size_info.cell_height(); let max_lines = (total_lines as usize).saturating_sub(MIN_FREE_LINES); - let button_len = CLOSE_BUTTON_TEXT.chars().count(); + let button_len = CLOSE_BUTTON_COLUMNS; // Split line to fit the screen. let mut lines = Vec::new(); @@ -133,7 +145,7 @@ impl Message { lines.push(Self::pad_text(line, num_cols)); line = new_line; - line_len = line.chars().count(); + line_len = Self::width_of(&line); // Do not append whitespace at EOL. if is_whitespace { @@ -142,12 +154,6 @@ impl Message { } line.push(c); - - // Reserve extra column for fullwidth characters. - if width == 2 { - line.push(' '); - } - line_len += width } lines.push(Self::pad_text(line, num_cols)); @@ -162,15 +168,6 @@ impl Message { } } - // Append close button to first line. - if button_len <= num_cols { - if let Some(line) = lines.get_mut(0) { - // `num_cols` 是字符/单元格数量,不能直接作为 UTF-8 字节下标。 - Self::truncate_chars(line, num_cols - button_len); - line.push_str(CLOSE_BUTTON_TEXT); - } - } - lines } @@ -192,18 +189,18 @@ impl Message { self.target = Some(target); } - /// Right-pad text to fit a specific number of columns. + /// Right-pad text to fill a specific number of **columns**. #[inline] fn pad_text(mut text: String, num_cols: usize) -> String { - let padding_len = num_cols.saturating_sub(text.chars().count()); + let padding_len = num_cols.saturating_sub(Self::width_of(&text)); text.extend(vec![' '; padding_len]); text } - fn truncate_chars(text: &mut String, max_chars: usize) { - if let Some((byte_index, _)) = text.char_indices().nth(max_chars) { - text.truncate(byte_index); - } + /// Display width in terminal columns. + #[inline] + fn width_of(text: &str) -> usize { + text.chars().map(|c| c.width().unwrap_or(0)).sum() } } @@ -301,7 +298,7 @@ mod tests { } #[test] - fn appends_close_button() { + fn reserves_columns_for_the_close_button() { let input = "a"; let mut message_buffer = MessageBuffer::default(); message_buffer.push(Message::new(input.into(), MessageType::Error)); @@ -309,11 +306,12 @@ mod tests { let lines = message_buffer.message().unwrap().text(&size); - assert_eq!(lines, vec![String::from("a [X]")]); + // 按钮是自绘图标,不再占用文本流——首行只是把右端那几列空出来。 + assert_eq!(lines, vec![String::from("a ")]); } #[test] - fn multiline_close_button_first_line() { + fn multiline_reserves_on_first_line_only() { let input = "fo\nbar"; let mut message_buffer = MessageBuffer::default(); message_buffer.push(Message::new(input.into(), MessageType::Error)); @@ -321,7 +319,7 @@ mod tests { let lines = message_buffer.message().unwrap().text(&size); - assert_eq!(lines, vec![String::from("fo [X]"), String::from("bar ")]); + assert_eq!(lines, vec![String::from("fo "), String::from("bar ")]); } #[test] @@ -371,7 +369,10 @@ mod tests { assert_eq!( lines, - vec![String::from("hahahahahahahahaha [X]"), String::from("[MESSAGE TRUNCATED] ")] + vec![ + String::from("hahahahahahahahaha "), + String::from("[MESSAGE TRUNCATED] ") + ] ); } @@ -408,7 +409,7 @@ mod tests { let lines = message_buffer.message().unwrap().text(&size); - assert_eq!(lines, vec![String::from("t [X]"), String::from("est ")]); + assert_eq!(lines, vec![String::from("t "), String::from("est ")]); } #[test] @@ -460,7 +461,7 @@ mod tests { assert_eq!( lines, - vec![String::from("a [X]"), String::from("bc "), String::from("defg ")] + vec![String::from("a "), String::from("bc "), String::from("defg ")] ); } @@ -475,21 +476,24 @@ mod tests { assert_eq!( lines, - vec![String::from("ab [X]"), String::from("c 👩 d "), String::from("fgh ")] + vec![String::from("ab "), String::from("c 👩d "), String::from("fgh ")] ); } #[test] - fn wraps_cjk_before_appending_close_button() { + fn cjk_wraps_by_display_width() { let mut message_buffer = MessageBuffer::default(); message_buffer.push(Message::new("配置加载失败".into(), MessageType::Error)); let size = SizeInfo::new(7., 10., 1., 1., 0., 0., false); let lines = message_buffer.message().unwrap().text(&size); + // 一个汉字两列。此前每个宽字符还会被补一个空格,而渲染器又按宽度 + // 排版,于是一个字吃掉三列、整行溢出——行尾的关闭按钮就是这样被 + // 裁掉的。 assert_eq!( lines, - vec![String::from("配 [X]"), String::from("置 加 载 "), String::from("失 败 ")] + vec![String::from("配 "), String::from("置加载 "), String::from("失败 ")] ); } @@ -502,7 +506,7 @@ mod tests { let lines = message_buffer.message().unwrap().text(&size); - assert_eq!(lines, vec![String::from("[X]"), String::from("0 1"), String::from("2 3"),]); + assert_eq!(lines, vec![String::from(" "), String::from("0 1"), String::from("2 3"),]); } #[test]