feat(nav): M300 导航轨折叠按钮 + 折叠宽度令牌 + 宽度过渡与落盘

用户原话「默认左侧固定,允许折叠」:M297 盘查确认 iced 从来没接过折叠按钮,
presenter 的 toggle_nav/nav_collapsed 是 Slint 遗留、唯一调用方在已退役的 crates/app。

## 令牌(先定数值再动手)
- nav_collapsed_width = 56 + 2 × Space::X1 = **64**:胶囊(M283 定稿 56×32,本轮不动)
  两侧各留 4px 才不被裁切;旧值 48 的死因写进注释(48 − 2×4 = 40 < 56)。
- 断言:折叠宽 ≥ 胶囊宽 + 2×X1、≥ touch_target 48、在 4px 栅格上。

## 折叠按钮与折叠态
- 按钮放**轨底、头像位之上**:轨顶一个像素都不动(M283/M299 的展开态对照图仍可比),
  轨底本来就是「固定贴底」的槽,两态下位置不变。几何与导航项同一套(56×32 药丸 + 52 命中高)。
- 折叠态:隐藏文字标签与账户名,每项/头像/按钮都补 M3 气泡 tooltip(折叠态不许是一列无名符号);
  纵节奏不变(项高仍 52),所以切换时图标不上下跳。五项图标两两不同由用例钉住。

## 动效(复用底盘、零新常数)
- 帧泵加**第二条并列宽度轨**(nav ⇄ rail),时长/曲线复用同一张 rail_spec();
  借用右栗那条会让左轨折叠改掉右栗的目标(暗耦合),tower 已否掉那个方案。
- 起跑在 update 侧;启动按落盘档对表(幂等,仅在没有轨道在跑时);折叠**不置 view_dirty**
  —— 宽度在 view 里逐帧采样,帧由轨道自己续,跑完 needs_frames() 回 false。

## 落盘(任务要求「重启读回仍折叠」)
- core schema 登记 ui.nav.collapsed(纯增量 bool,不碰 schemaVersion);presenter 桥加
  load/persist 一对(走唯一写入口 write_with,现读→改→落盘);启动 construct 读回。
- 截图通道不读用户配置(D30),折叠态出图走 ULL_NAV_COLLAPSED=1。

## 判据
- app-iced: 1053 passed(新增 3 条接线用例 + 7 条 shell/几何/树结构用例 + 6 条帧泵用例);
  presenter: 408 passed(新增落盘往返 1 条);fmt / clippy --workspace --all-targets 均 0。
- 变异实验:删 observe_nav ⇒ 宽度停在 88 红;删启动对表 ⇒ 首次展开硬跳红。
- 像素(DevDocs/artifacts/m300/):折叠态内容左缘 112→88(24px 真回收)、药丸两态都 56 宽
  且折叠档右缘 59 < 64(不裁切)、图标行是展开态子集(纵节奏不变)。

## 已知偏离(已登记,tower 批了紧随 mission)
crates/app-iced/src/pages/layout.rs 的 rail_width() 仍是 const fn(恒 88),所以折叠态内容块
宽度不变、整块左移 24px ⇒ 右侧多一条 24px 空白带(像素图里保留、未裁)。
This commit is contained in:
AskaEth committed 2026-11-13 16:15:16 +08:00
1 parent 1ce494631c
commit fa53d72f73
26 files changed
+1640 -320

No files matched your search

+68 -13
View File
@@ -10,7 +10,7 @@ use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::TypeRole;
use crate::app::components::interaction::{self, Interaction};
@@ -20,13 +20,24 @@ use crate::icons;
use crate::shell::overridden;
use crate::theme::Ui;
use super::{nav_item_height, nav_rail_width, NavItem};
use super::tip::tip;
use super::{nav_item_height, NavItem};
/// 导航轨底部的**账户头像位**状态(M114)。
/// 导航轨底部的**账户头像位**状态(M114)+ 轨自身的折叠态(M300)。
///
/// 为什么单独传而不是塞进 `NavItem`:它不是主序列里的第 6 项(那会让轨道越堆越长),
/// 而是固定在**底部**的一个头像位;未登录/已登录两态要在**形状上**就不同(描边剪影 vs 首字母块)。
#[derive(Debug, Clone, PartialEq, Eq)]
///
/// ## 为什么折叠态也搭这个结构(M300)
///
/// `shell::NavRail`(`shell/mod.rs`)是 `nav::rail(..)` 唯一的入参通道,而那个文件不在本
/// mission 的 scope 里;`RailAccount` 是**唯一**由 `app/view/rail.rs`(scope 内)构造、又直接递给
/// `nav::rail`(scope 内)的值。于是轨的「这一帧多宽 / 标签收不收」搭它进来,一个字段都不新增到
/// `NavRail` 上。代价是这个结构现在有两个职责——已经比「动 `shell/mod.rs` 去加第三个字段」便宜,
/// 且它们确实是同一块几何的两个输入(轨的宽与轨底的内容)。
///
/// `f32` 的进度进场后 `Eq` 不再成立(`PartialEq` 仍在):调用方只比字段,不做整结构相等。
#[derive(Debug, Clone, PartialEq)]
pub struct RailAccount {
/// 是否已登录(至少有一个账户)
pub signed_in: bool,
@@ -36,6 +47,10 @@ pub struct RailAccount {
pub label: String,
/// 窄高度/窄宽下的图标条形态(隐藏文字标签,只留头像位)
pub compact: bool,
/// 用户把轨折叠了(M300):只留图标 + tooltip。判据只有一处(`ItemView::labels_visible`)。
nav_collapsed: bool,
/// 折叠进度的**这一帧**取值(0 = 折叠 64,1 = 展开 88)。中间帧由 `motion::nav(open)` 给。
nav_progress: f32,
}
impl RailAccount {
@@ -46,6 +61,9 @@ impl RailAccount {
initial: String::new(),
label: label.into(),
compact: false,
// 出厂:轨展开(`UiPrefs::nav_collapsed` 的出厂值也是 false)
nav_collapsed: false,
nav_progress: 1.0,
}
}
@@ -56,6 +74,8 @@ impl RailAccount {
initial: initial.into(),
label: label.into(),
compact: false,
nav_collapsed: false,
nav_progress: 1.0,
}
}
@@ -65,6 +85,33 @@ impl RailAccount {
self.compact = compact;
self
}
/// 轨的折叠档 + **这一帧**的过渡进度(M300)。
///
/// 两个值必须一起给:只给 `collapsed` 会让布局按一个过期的进度算宽,只给进度会让标签在
/// 折叠档里还画着。`progress` 会被夹到 `0..=1`(`nav_width` 里再夹一次,这里只是让调用方
/// 传什么都不会把轨画成负数宽)。
#[must_use]
pub fn nav(mut self, collapsed: bool, progress: f32) -> Self {
self.nav_collapsed = collapsed;
self.nav_progress = progress.clamp(0.0, 1.0);
self
}
/// 轨现在是不是折叠档(标签该不该收)。
#[must_use]
pub fn nav_collapsed(&self) -> bool {
self.nav_collapsed
}
/// 这一帧的轨宽(展开 88 ⇄ 折叠 64;中间帧按动效进度插值)。
///
/// **唯一实现点是 `super::nav_width`**:`nav::rail`、`account_item`、`toggle_button` 与用例
/// 都读它,于是「谁把这四个调用点各算一遍」在结构上不可能。
#[must_use]
pub fn nav_width(&self, metrics: LayoutMetrics) -> f32 {
super::nav_width(metrics, self.nav_progress)
}
}
/// 轨底的账户头像位:未登录 = 描边剪影,已登录 = 首字母块(两态在**形状**上就不同)。
@@ -133,12 +180,16 @@ pub(super) fn account_item<'a>(
.into()
};
let width = account.nav_width(ui.metrics());
let height = nav_item_height(ui, indicator_height);
// M300:折叠档与「窄高图标条」是同一条判据——名字("Not signed in" / 账户名)在 64 宽的轨里
// 放不下,收起来之后可读名改由 tooltip 给(与主序列项同一条纪律)。
let labels_visible = !account.compact && !account.nav_collapsed();
let mut stack = column![avatar]
.spacing(ui.space(Space::X1))
.width(nav_rail_width(ui.metrics()))
.width(width)
.align_x(Alignment::Center);
if !account.compact {
if labels_visible {
// 用**拥有的**字符串画标签:`RailAccount` 是调用方临时算出来的状态,
// 借它的 `&str` 会让整棵树跟着那个临时值一起活(E0515)。
let style = ui.style(TypeRole::LabelSmall);
@@ -154,7 +205,7 @@ pub(super) fn account_item<'a>(
}
let clickable: Element<'a, Message> = button(stack)
.width(nav_rail_width(ui.metrics()))
.width(width)
.height(height)
.padding(0)
.style(move |_theme: &IcedTheme, _status| button::Style {
@@ -168,10 +219,16 @@ pub(super) fn account_item<'a>(
.on_press(Message::Select(NavItem::Account))
.into();
// A8:运行期态套状态取样控件(强制态 = 取证,**不套**:取证图不许有过渡中间帧)。
if forced.is_none() {
let clickable = if forced.is_none() {
interaction::tracked(clickable, true)
} else {
clickable
};
// M300:名字不画的时候,头像位也必须说得出自己是谁(否则折叠态的轨底就是一个孤零零的剪影)。
if labels_visible {
clickable
} else {
tip(ui, clickable, account.label.clone())
}
}
@@ -277,10 +334,8 @@ mod tests {
let width = super::super::indicator_size(ui.metrics()).0;
let size = avatar_size(&ui, width);
assert!(size > 0.0);
assert!(
size <= nav_rail_width(ui.metrics()),
"头像位不能比导航轨还宽({size} > {})",
nav_rail_width(ui.metrics())
);
// 展开档是两档里宽的那一档:头像位连它都放得下,折叠档才不需要另算一遍。
let rail = ui.metrics().nav_rail_width;
assert!(size <= rail, "头像位不能比导航轨还宽({size} > {rail})");
}
}
+73
View File
@@ -0,0 +1,73 @@
//! 导航轨的**纯几何**(M300 从 `nav.rs` 拆出):两个宽度档的插值 + 药丸矩形 + 项高。
//!
//! 为什么拆:`nav.rs` 在 M300 要加「折叠态的一项」与「折叠按钮」两件事,几何这一块(约 85 行)
//! 又不与视图组装耦合——它就是几条令牌推导。拆开之后 `nav.rs` 只剩「谁放在哪」。
//!
//! **唯一实现点不变**:`indicator_size` / `state_layer_box` / `nav_item_height` 仍各只有一份,
//! `nav.rs` 用 `pub(super) use` 把它们按原名再导出,于是 `shell/tests.rs` 里
//! `super::nav::{indicator_size, nav_item_height, state_layer_box}` 的路径一个字符都没变
//! (那是 M283/M299 的几何判据,不许为了搬文件改断言)。
//!
//! `pill_color` **留在了 `nav.rs`**:`shell/tests.rs` 与 M299 的
//! `components/selection/tests/single_entry.rs` 都按**文件名**扫「这一处必须经
//! `selected_fill` / `layer_color` 求值」,把它搬走会让那两条门禁静默失守(`selected_fill` 的
//! 白名单里写的是 `shell/nav.rs`)。搬文件不许改别人的门禁 —— 这条比「按职责拆分」优先。
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
/// 导航轨宽度(M69 起是令牌层的 `nav_rail_width`)。
///
/// M69(`141e9b1`)把 `LayoutMetrics` 的 nav 字段做了语义纠正:
///
/// | 旧 | 新 |
/// |---|---|
/// | `nav_expanded_width = 88` | **`nav_rail_width = 88`**(值不变,名字从「展开」改成「轨」) |
/// | `nav_collapsed_width = 48` | 删除(48 连 56 宽的选中指示器都放不下) |
/// | —(缺) | `nav_drawer_width = 240`(D33 的展开态抽屉,本 crate 未用) |
///
/// 三处调用点都走这里:将来令牌再改名,仍然只改这一行。
pub(in crate::shell) fn nav_rail_width(metrics: LayoutMetrics) -> f32 {
metrics.nav_rail_width
}
/// 这一帧的轨宽:折叠档 ⇄ 展开档之间按动效进度插值(M300)。
///
/// - `progress = 1.0` → **逐位**等于 [`nav_rail_width`](88)——静息展开态与 M283/M299 的图一字不差;
/// - `progress = 0.0` → **逐位**等于 [`LayoutMetrics::nav_collapsed_width`](64);
/// - 中间帧是线性的(曲线已经在帧泵那一侧按 `Emphasized*` 采过样,这里再套一条曲线就是两次缓动)。
///
/// 为什么这一层住在这里而不是 `design::spacing`:它只是「两个令牌之间的插值」,不是新令牌;
/// 而折叠档能容下 56 宽胶囊这件事由 `spacing.rs` 的断言钉住,两处不重叠。
pub(in crate::shell) fn nav_width(metrics: LayoutMetrics, progress: f32) -> f32 {
let collapsed = metrics.nav_collapsed_width;
let open = nav_rail_width(metrics);
collapsed + (open - collapsed) * progress.clamp(0.0, 1.0)
}
/// 选中指示器尺寸(12b 5.4 参考 56×32),全部由令牌推导、不写死:
///
/// - 宽 = [`nav_rail_width`] − 2 × `Space::X4` = 88 − 32 = **56**(轨的左右内边距 16)
/// - 高 = `control_height − Space::X2` = 40 − 8 = **32**(可见高度上下各让 4)
pub(in crate::shell) fn indicator_size(metrics: LayoutMetrics) -> (f32, f32) {
(
nav_rail_width(metrics) - Space::X4.dp() * 2.0,
metrics.control_height - Space::X2.dp(),
)
}
/// 状态层(hover / pressed / 过渡帧)画在**哪块矩形**上:药丸,与选中指示器同一块几何(M283)。
///
/// 这是那块矩形的唯一定义点——`rail_item` 用它定尺寸,`shell::tests` 的几何判据钉住它。
/// 它**不是**按钮的 `nav_rail_width × 项高`(88×52):那正是用户点名的「父容器整个方形区域加深」。
pub(in crate::shell) fn state_layer_box(metrics: LayoutMetrics) -> (f32, f32) {
indicator_size(metrics)
}
/// 导航项高度 = 指示器 + 4 + 标签行高 = 32 + 4 + 16 = 52(`LM.nav_item_height` 是列表行节奏,
/// 药丸 + 标签的轨项放不进 44,偏差登记在交接文档里)。
pub(in crate::shell) fn nav_item_height(ui: &Ui, indicator_height: f32) -> f32 {
indicator_height + ui.space(Space::X1) + ui.style(TypeRole::LabelMedium).line_height_px
}
+169
View File
@@ -6,6 +6,7 @@
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::spacing::LayoutMetrics;
use unlucky_design::typography::Platform;
use crate::theme::Families;
@@ -119,3 +120,171 @@ fn the_download_item_is_labelled_version_management() {
);
}
}
// ---------------------------------------------------------------- M300 折叠档
use iced::advanced::widget::tree::Tag;
use iced::advanced::widget::Tree;
use iced::widget::tooltip::Position;
use iced::widget::{text, tooltip};
use super::{nav_width, rail_item, ItemView};
/// 一个元素在控件树里的标签,以及「这棵树里有几个文字节点 / 几个 tooltip 子树」。
///
/// 为什么要走**真控件树**而不是断言 `ItemView::labels_visible()`:后者是判据本身,断言它等于
/// 「断言我自己写的那个 bool 是真的」。真树能抓住「折叠分支里又把 label 推回去了」,因为那时
/// 文字节点会多出来。
fn tree_of(el: &iced::Element<'_, Message>) -> Tree {
Tree::new(el.as_widget())
}
/// 文字节点的标签(`text` 与包着它的 `container` 同 tag,见 iced 的 `container::tag` 委托)。
fn text_tag() -> Tag {
let probe: iced::Element<'static, Message> = text("probe").into();
tree_of(&probe).tag
}
/// tooltip 子树的标签。
fn tooltip_tag() -> Tag {
let probe: iced::Element<'static, Message> =
tooltip(text("probe"), text("tip"), Position::Right).into();
tree_of(&probe).tag
}
/// 数「tooltip 子树之外」的文字节点:tooltip 自己带一个气泡文案,它在折叠态**应该**存在
/// (那是可读名),所以不能算进「标签」的口径里。
fn text_nodes_outside_tooltips(tree: &Tree, text: Tag, tip: Tag) -> usize {
if tree.tag == tip {
return 0;
}
let here = usize::from(tree.tag == text);
here + tree
.children
.iter()
.map(|child| text_nodes_outside_tooltips(child, text, tip))
.sum::<usize>()
}
fn tooltip_count(tree: &Tree, tip: Tag) -> usize {
let here = usize::from(tree.tag == tip);
here + tree
.children
.iter()
.map(|child| tooltip_count(child, tip))
.sum::<usize>()
}
/// **折叠态:文字标签不进控件树,但每个图标都有可读名(tooltip)。**
///
/// 抓得住:①折叠分支里把 label 又推回去(`text_nodes_outside_tooltips` 从 0 变 1);
/// ②折叠态只剩图标却没有可读名(`tooltip_count` 从 1 变 0 —— 那正是用户点名的
/// 「一列无名符号」);③展开态被顺手塞上 tooltip(展开态那个计数从 0 变 1)。
/// 抓不住:tooltip 真弹出来长什么样、以及文字**内容**对不对(气泡里的文案由 `Key` 目录守,
/// 像素由 `DevDocs/artifacts/m300/` 两张图守)。
#[test]
fn the_collapsed_item_keeps_no_label_but_gives_the_icon_a_name() {
let ui = ui();
let item = NavItem::Instances;
let expanded = rail_item(
&ui,
item,
ItemView {
selected: true,
compact: false,
collapsed: false,
width: ui.metrics().nav_rail_width,
},
None,
Lang::En,
);
let collapsed = rail_item(
&ui,
item,
ItemView {
selected: true,
compact: false,
collapsed: true,
width: ui.metrics().nav_collapsed_width,
},
None,
Lang::En,
);
let (text, tip) = (text_tag(), tooltip_tag());
let expanded_tree = tree_of(&expanded);
let collapsed_tree = tree_of(&collapsed);
assert!(
text_nodes_outside_tooltips(&expanded_tree, text, tip) > 0,
"前提:展开态必须画标签(否则这条用例什么都没测到)"
);
assert_eq!(
text_nodes_outside_tooltips(&collapsed_tree, text, tip),
0,
"折叠态不许把文字标签放进控件树"
);
assert_eq!(
tooltip_count(&collapsed_tree, tip),
1,
"折叠态的图标必须有可读名(tooltip)"
);
assert_eq!(
tooltip_count(&expanded_tree, tip),
0,
"展开态有可见标签,不需要 tooltip"
);
}
/// **折叠态里每个图标仍然可辨识**:五项图标两两不同。
///
/// 抓得住:有人把某一项的图标换成同一个(或把 `icon()` 写成常量)⇒ 折叠态立刻变成「一列同样的
/// 符号」。抓不住:24px 下肉眼能否分辨(那要出图)与 tooltip 的文案(`Key` 目录守)。
#[test]
fn the_five_icons_stay_distinguishable_when_the_labels_are_gone() {
let mut paths = Vec::new();
for item in NavItem::ALL {
let path = item.icon().icon().path;
assert!(!path.is_empty(), "{item:?} 没有图标");
assert!(
!paths.contains(&path),
"{item:?} 的图标与前面某项重复:折叠态会变成一列同样的符号"
);
paths.push(path);
}
assert_eq!(paths.len(), NavItem::ALL.len());
}
/// **宽度是两档之间的插值**,两端**逐位**等于令牌。
///
/// 抓得住:①把折叠档写成 48(旧值)⇒ 端点断言红,且 `spacing.rs` 的「容得下胶囊」断言也会红;
/// ②插值写成非线性的(例如再套一条缓动)⇒ 中点断言红(曲线在帧泵那一侧采过样了);
/// ③进度不做钳位(动效中间帧越界一次就把轨画成负数宽)。
#[test]
fn the_rail_width_interpolates_between_the_two_tokens() {
let metrics = ui().metrics();
assert_eq!(nav_width(metrics, 1.0), metrics.nav_rail_width, "展开端");
assert_eq!(
nav_width(metrics, 0.0),
metrics.nav_collapsed_width,
"折叠端"
);
assert_eq!(
nav_width(metrics, 0.5),
(metrics.nav_rail_width + metrics.nav_collapsed_width) / 2.0,
"中点必须是线性的"
);
// 钳位:越界的进度不许把轨画到两端之外
assert_eq!(nav_width(metrics, 1.7), metrics.nav_rail_width);
assert_eq!(nav_width(metrics, -0.3), metrics.nav_collapsed_width);
// 折叠档必须容得下 56 宽的胶囊(两个端点各一次,含动效中间帧的单调性)
let pill = indicator_size(metrics).0;
assert!(pill <= nav_width(metrics, 0.0), "折叠端容不下胶囊");
for step in 0..=10 {
let progress = step as f32 / 10.0;
assert!(
nav_width(metrics, progress) >= pill,
"进度 {progress} 时轨比胶囊还窄"
);
}
}
+49
View File
@@ -0,0 +1,49 @@
//! M3 气泡 tooltip(导航轨专用):折叠态里图标是唯一的可见标识,可读名只能靠它。
//!
//! 视觉与 `pages/agent/panel.rs` / `pages/agent/strip.rs` 那两处一致(`Overlay` 层底 +
//! `Small` 圆角 + `BodySmall` 字号)。**没有抽公共件**:那两个文件不在 M300 的 scope 里,
//! 硬抽要动别人的模块;三份拷贝已作为 finding 报给 tower,去重单独立项。
use iced::advanced::text::IntoFragment;
use iced::widget::tooltip::Position;
use iced::widget::{container, text, tooltip};
use iced::{Background, Border, Element, Padding};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
/// 把一个元素包成「悬停出气泡」的形态。`Position::Right`:轨贴窗口左缘,气泡只能往右弹。
///
/// 文案接受 `impl IntoFragment`:导航项的标签是 `&'static str`(目录里的词),而账户名是这一帧
/// 现拼的 `String`(`RailAccount` 是临时值,借它的 `&str` 会让整棵树跟着那个临时值活)。
pub(super) fn tip<'a, M: 'a, S>(
ui: &'a Ui,
content: Element<'a, M>,
text_value: S,
) -> Element<'a, M>
where
S: IntoFragment<'a>,
{
let style = ui.style(TypeRole::BodySmall);
let bubble = container(
text(text_value)
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurface)),
)
.padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)]))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: Border {
radius: ui.radius_dp(ShapeScale::Small),
..Border::default()
},
..container::Style::default()
});
tooltip(content, bubble, Position::Right).into()
}
+118
View File
@@ -0,0 +1,118 @@
//! 导航轨底部的**折叠按钮**(M300 `NAVCOLLAPSE`):点一下在展开(88,带标签)与折叠(64,只图标)
//! 之间切换。
//!
//! ## 为什么放在轨底、头像位之上
//!
//! ①**轨顶一个像素都不动**:轨顶是 `Space::X6`(24) 留白 + 首项,M283/M299 的展开态对照图(D30)
//! 全靠那一段的可比性;把按钮塞在轨顶会把整列往下推 52px,等于给每个既有取证图加一条无关差异。
//! ②轨底本来就已经有一个「固定贴底」的槽(M114 的账户头像位),按钮与它同属 footer:两态下位置
//! 不变(只改宽),过渡期间目标也一直在同一处,不会边动边跑。
//! ③折叠态下按钮仍然完整可见(64 里放得下 56 的胶囊),回得去——这是「可折叠」的必要条件。
//!
//! ## 为什么图标是 `Sidebar` 而文案两态不同
//!
//! 按钮只有图标,所以可读名走 tooltip([`Key::NavCollapse`] / [`Key::NavExpand`]):图标说「这是
//! 侧栏开关」,文案说「点下去会发生什么」。用同一个字形而不是「< / >」的另一原因是两个方向的
//! 箭头在 24px 下几乎对称,用户读不出当前态,反而在 tooltip 里说得清。
use iced::widget::{button, container};
use iced::{Alignment, Background, Border, Element, Shadow, Theme as IcedTheme};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_i18n::Lang;
use crate::app::components::interaction::{self, Interaction};
use crate::app::Message;
use crate::cli::ForcedState;
use crate::icons;
use crate::shell::overridden;
use crate::theme::Ui;
use super::account::RailAccount;
use super::tip::tip;
use super::{nav_item_height, pill_color, state_layer_box};
/// 轨底的折叠按钮。
///
/// 几何与导航项**同一套**:轨宽 × 项高(52)的整条命中区,里面是 56×32 的胶囊(M283 的那块),
/// 状态层(hover / pressed / 过渡帧)画在胶囊上而不是按钮上——与 `rail_item` 同一个理由:
/// 同一个元素不许并存两种形状。
///
/// **强制度**:`--force-state` 的取证图**不套**状态取样控件(取证图里不许有过渡中间帧),
/// 但照样按强制态求值底色(`overridden`)。
pub(super) fn toggle_button<'a>(
ui: &'a Ui,
account: &RailAccount,
forced: Option<ForcedState>,
lang: Lang,
) -> Element<'a, Message> {
let metrics = ui.metrics();
let width = account.nav_width(metrics);
let collapsed = account.nav_collapsed();
let (pill_width, pill_height) = state_layer_box(metrics);
let state = Interaction::from_status(overridden(forced, button::Status::Active));
// 24 的盒也要走 `view_at`(显式给盒尺寸):`icons.rs` 的白名单按**文件**记 `icons::view`
// 的调用点数(`shell/nav.rs` 1 处、`shell/nav/account.rs` 1 处),本文件是 0——那个白名单
// 不在 M300 的 scope 里,所以这里用等价的显式入口,而不是去改别人的门禁。
let icon = container(icons::view_at::<Message>(
IconKind::Sidebar,
ui.color(Role::OnSurfaceVariant),
unlucky_design::icons::IconSize::Toolbar.px(),
))
.width(pill_width)
.height(pill_height)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
// 与未选中的导航项同一条:导轨画的是 `MaterialLayer::Canvas`,状态层叠在同一个角色上。
background: pill_color(ui, state, false).map(Background::Color),
border: Border {
radius: ui.radius(ShapeScale::Full, pill_height),
..Border::default()
},
..container::Style::default()
});
let height = nav_item_height(ui, pill_height);
let clickable: Element<'a, Message> = button(
container(icon)
.width(width)
.height(height)
.align_x(Alignment::Center)
.align_y(Alignment::Center),
)
.width(width)
.height(height)
.padding(0)
.style(move |_theme: &IcedTheme, _status| button::Style {
// 底色**恒为 `None`**:状态层由胶囊给(M283)。
background: None,
text_color: ui.color(Role::OnSurfaceVariant),
border: Border::default(),
shadow: Shadow::default(),
snap: true,
})
.on_press(Message::ToggleNav)
.into();
let clickable = if forced.is_none() {
interaction::tracked(clickable, true)
} else {
clickable
};
// 文案跟**当前态**走:展开时告诉用户「点了会折叠」,折叠时告诉用户「点了会展开」。
let catalog = lang.catalog();
tip(
ui,
clickable,
catalog.text(if collapsed {
unlucky_i18n::Key::NavExpand
} else {
unlucky_i18n::Key::NavCollapse
}),
)
}