fix: route recovery notices to toast and keep message close visible
This commit is contained in:
1 parent
f52b9a428c
commit
b42e81c7cb
9 files changed
+533
-45
No files matched your search
+6
-2
@@ -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 主机编辑器的大字与图标不再发糊** — 身份条的名字、头像形状与图标列表此前是把按终端字号栅格化的位图硬放大,对着原型一眼可见的糊;现在按真实字号重新栅格化。放大后名字框的光标换算按真实字形步进,点第十个字不再偏一格。
|
||||
- **图标选择器不再透出表单文字** — 浮层的底与表单同批提交时全部沉在文字之下;现在浮层(图标列表、测试状态提示)在表单文字之后单独提交,什么都透不上来。
|
||||
- **「测试连接」失败显示完整原因** — 此前被压成单行截断,全文藏在悬浮层里;现在失败原因直接折行铺开(最多四行),只有超长的尾巴才收进悬浮层。
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user