fix: route recovery notices to toast and keep message close visible

This commit is contained in:
kuddev committed 2026-08-03 19:36:40 +08:00
1 parent f52b9a428c
commit b42e81c7cb
9 files changed
+533 -45

No files matched your search

+6 -2
View File
@@ -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 主机编辑器的大字与图标不再发糊** — 身份条的名字、头像形状与图标列表此前是把按终端字号栅格化的位图硬放大,对着原型一眼可见的糊;现在按真实字号重新栅格化。放大后名字框的光标换算按真实字形步进,点第十个字不再偏一格。
- **图标选择器不再透出表单文字** — 浮层的底与表单同批提交时全部沉在文字之下;现在浮层(图标列表、测试状态提示)在表单文字之后单独提交,什么都透不上来。
- **「测试连接」失败显示完整原因** — 此前被压成单行截断,全文藏在悬浮层里;现在失败原因直接折行铺开(最多四行),只有超长的尾巴才收进悬浮层。
+52
View File
@@ -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<toast::Toast>,
/// 消息栏关闭按钮:绘制矩形 + 墨色,由终端 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<SshHostEditor>,
pub nebula_ssh_editor_rects: Option<SshEditorRects>,
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<String>,
@@ -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();
+176
View File
@@ -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<String>, 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");
}
}
+2
View File
@@ -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;
+191
View File
@@ -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<UiQuad>,
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);
}
}
+37
View File
@@ -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<UiQuad>,
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:
+14 -6
View File
@@ -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));
}
+14
View File
@@ -1267,8 +1267,22 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
.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,
+41 -37
View File
@@ -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<MessageBarRect> {
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<String> {
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]