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:
1 parent
1ce494631c
commit
fa53d72f73
26 files changed
+1640
-320
No files matched your search
@@ -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})");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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} 时轨比胶囊还窄"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user