Files
Unlucky-Launcher/crates/app-iced/src/components/navigation/tabs.rs
T
AskaEth 35dbefb08d feat(app-iced): 禁用态合成底改成容器给的 Base(M234)
`interaction::disabled` 的四个入口一直收一个「底」,但 8 个调用点全传死
`Role::Surface`:卡片(SurfaceContainer)/ 面板(SurfaceContainerLow)里的禁用控件
把 10%(按钮族)/ 12%(面状控件族)叠在画布色上 —— M223「CR 1.05、形丢失」的根因。

- 新增 `interaction::Base`(画布 / 容器底的一等参数,`From<Role>` 兼容历史调用点);
  四个入口改成 `(ui, base, …)`,内部不再出现任何写死的 `Role::Surface`。
- 容器导出自己的 `BASE`:`surfaces::elevated::BASE`(卡)、`inputs::menu::PANEL_BASE`
  (菜单面板,项的 38% 与状态层都改叠它)、`navigation::drawer::BASE`。
- 控件收 `.base(..)`:Button / icon_on / Field / SearchBar / Slider / Checkbox / Radio /
  Switch / Chip / ListItem / Tabs;Fab 与 icon() 是画布默认值。
- `feedback/action.rs` 的本地 α 合成删掉,改走 `disabled_content_over_role` 并给真实容器底
  (Snackbar `InverseSurface` / Dialog `SurfaceContainerHigh`)。
- 判据:`interaction::disabled::tests`(卡片/面板/画布三档逐值)、
  `buttons::tests::base`(Button::base → StyleSpec → style_closure 接线)、
  `gallery` 新增 `disabledbase` 段 + `gallery::probe::bases` 精确像素断言
  (±10 容差区分不了 1/255 的差,所以自己数逐位相等的像素)。
- 变异自证:assemble 的调用点改回画布底后,上面两条都红(见 artifacts/m234/README.md)。

已知未尽:`crates/app-iced/src/pages/**` 不在 scope 内,页面侧「把控件放进卡片/面板」的
调用点尚未接 `.base(..)`(行为与本 mission 之前一致);`pages/…/java/tests/marker.rs`
有一行被迫更新(入口多了一个实参,语义不变 = 画布底)。
2026-11-11 06:34:23 +08:00

374 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Tabs(`research/12b` §6):**Primary**(下方 3px 指示条)与 **Secondary**(1px 分隔线)。
//!
//! ## 参考值(`[源]` = A1,本轮 `javap` 实读本机 AAR)
//!
//! ```text
//! PrimaryNavigationTabTokens: ContainerHeight 48、IconAndLabelTextContainerHeight 64、
//! ActiveIndicatorHeight 3、ActiveIndicatorShape RoundedCornerShape(3dp)、
//! IconSize 24、ContainerShape CornerNone、LabelTextFont TitleSmall
//! ActiveIndicatorColor=Primary;Active*/Inactive* 各态内容色(见下表)
//! SecondaryNavigationTabTokens: ContainerHeight 48、DividerHeight、DividerColor=SurfaceVariant、
//! ActiveLabelTextColor=OnSurface、InactiveLabelTextColor=OnSurfaceVariant
//! ```
//!
//! | 项 | Primary | Secondary |
//! |---|---|---|
//! | 选中内容色 | **`Primary`** | **`OnSurface`**(不是 Primary——6.5 明写) |
//! | 未选内容色 | `OnSurfaceVariant` | `OnSurfaceVariant` |
//! | hover / focus / pressed | 未选项提到 **`OnSurface`**,选中项保持不变 | 同左 |
//! | 底部条 | 仅选中项:**3px `Primary`**,宽度**跟随该 tab** | 所有项:**1px `SurfaceVariant`**(连成一条线) |
//!
//! 尺寸/圆角/线厚全部来自 [`TabsGeometry`](**G22/G23**:`LayoutMetrics` 没有 Tabs 档)。
//!
//! ## 指示条怎么做到「宽度跟随选中项」
//!
//! iced 没有「取到渲染后文本宽度」的公开 API,所以这里用 [`stack`] 的**分层语义**:
//!
//! 1. `Stack` 的**底层**是 tab 自己的内容容器(`Length::Shrink` → 宽度 = 文本实测宽 + 2×16);
//! 2. `Stack` 的**上层**是 `Length::Fill` 的指示条。`Stack::layout` 先按**底层**定尺寸,再用
//! 「已知尺寸」的 `Limits` 布局其余层(`iced_widget-0.14.2/src/stack.rs` 的
//! `size = limits.resolve(self.width, self.height, base.size())`)——于是 Fill 的指示条
//! 拿到的正好是「这个 tab 的宽」,**不是**整行宽。
//!
//! 这也顺带避开 Q8 的另一面:把条写成 `center_x(Fill)` 会把它撑成整行。
//!
//! ## 横向滚动
//!
//! 整行外面套 `scrollable`(`Direction::Horizontal`,滚动条 `Scrollbar::hidden()`):
//! tab 宽度因此是**内容宽**而不是「行宽 ÷ n」——官方 `ScrollableTabRow` 的口径。
//! `TabMinWidth = 90` **本轮不施加**(iced 无 min-width,登记在 G23)。
use iced::widget::text::{LineHeight, Wrapping};
use iced::widget::{button, container, scrollable, space, text, Row, Stack};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Base, Interaction};
use super::super::layout;
use super::geometry::{TabsGeometry, TABS_ICON_HEIGHT};
/// 两种 Tabs(`research/12b` §6;M3 的 `PrimaryTabRow` / `SecondaryTabRow`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TabsVariant {
/// 主标签页:选中项下方 3px `Primary` 指示条。
Primary,
/// 次标签页:1px `SurfaceVariant` 分隔线 + 选中项 `OnSurface` 文字。
Secondary,
}
impl TabsVariant {
/// 两种变体(gallery 状态矩阵按这个次序出图)。
pub const ALL: [TabsVariant; 2] = [TabsVariant::Primary, TabsVariant::Secondary];
/// 稳定名字(截图文件名 / 图例用)。
pub const fn name(self) -> &'static str {
match self {
TabsVariant::Primary => "primary",
TabsVariant::Secondary => "secondary",
}
}
/// 是否是 Primary(指示条 3px)。
pub const fn is_primary(self) -> bool {
matches!(self, TabsVariant::Primary)
}
}
/// 一个标签页。
#[derive(Debug, Clone)]
pub struct Tab<'a, M> {
label: &'a str,
icon: Option<IconKind>,
selected: bool,
on_press: Option<M>,
state: Option<Interaction>,
}
impl<'a, M> Tab<'a, M> {
/// 一个未选中的 tab。
pub fn new(label: &'a str, on_press: M) -> Self {
Self {
label,
icon: None,
selected: false,
on_press: Some(on_press),
state: None,
}
}
/// 选中(内容色换成激活档 + 画指示条)。
pub fn selected(mut self) -> Self {
self.selected = true;
self
}
/// 前置图标(24,`IconSize::Toolbar`;带图标的容器高官方是 64,本轮只做文字档)。
pub fn icon(mut self, kind: IconKind) -> Self {
self.icon = Some(kind);
self
}
/// 强制视觉态(gallery 取证;不动状态机)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
/// 转禁用:**只清消息**(修饰器,不是构造函数)。
pub fn disabled(mut self) -> Self {
self.on_press = None;
self
}
}
/// 一行标签页。
#[derive(Debug, Clone)]
pub struct Tabs<'a, M> {
variant: TabsVariant,
tabs: Vec<Tab<'a, M>>,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
}
impl<'a, M> Tabs<'a, M> {
/// Primary tabs(3px 指示条)。
pub fn primary() -> Self {
Self {
variant: TabsVariant::Primary,
tabs: Vec::new(),
base: Base::CANVAS,
}
}
/// Secondary tabs(1px 分隔线)。
pub fn secondary() -> Self {
Self {
variant: TabsVariant::Secondary,
tabs: Vec::new(),
base: Base::CANVAS,
}
}
/// 追加一个 tab。
pub fn tab(mut self, tab: Tab<'a, M>) -> Self {
self.tabs.push(tab);
self
}
/// **告诉 tab 条它踩在什么底上**(M234):禁用态的 38% 标签/指示条叠在这个底上。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
}
/// 一个 tab 的内容色(A1 的 `Active*` / `Inactive*` / 各态表,见模块文档)。
fn content_color(ui: &Ui, base: Base, primary: bool, state: Interaction, selected: bool) -> Color {
if state.is_disabled() {
// A1 的 Tabs 没有 Disabled* token(实读:Primary/Secondary 两套都只有 Active/Inactive × 四态),
// 按 ListItem 的 38% 口径处理 —— 登记 `[估]`。
return interaction::disabled_content_over_role(ui, base, Role::OnSurface, false);
}
if selected {
// 6.5:Primary 选中 `Primary`;Secondary 选中 `OnSurface`
return if primary {
ui.color(Role::Primary)
} else {
ui.color(Role::OnSurface)
};
}
match state {
// 未选中静息 `OnSurfaceVariant`;hover/focus/pressed 提到 `OnSurface`(A1 的 Inactive* 四态)
Interaction::Enabled => ui.color(Role::OnSurfaceVariant),
_ => ui.color(Role::OnSurface),
}
}
/// Primary 指示条的颜色(只有选中项有;禁用项降档,否则「点不动却亮着」)。
fn bar_color(ui: &Ui, base: Base, state: Interaction, selected: bool) -> Option<Color> {
if !selected {
return None;
}
if state.is_disabled() {
return Some(interaction::disabled_content_over_role(
ui,
base,
Role::OnSurface,
false,
));
}
Some(ui.color(Role::Primary))
}
/// 6.6:Secondary 的分隔线色 = **`SurfaceVariant`**(不是 `OutlineVariant`——那是 §22 的分隔线)。
///
/// 色值只在这里出现一次:整行那条线与逐 tab 的实现都从这里取。
fn secondary_line_color(ui: &Ui) -> Color {
ui.color(Role::SurfaceVariant)
}
/// 一行标签页(自带横向滚动)。
pub fn tabs<'a, M: Clone + 'a>(ui: &'a Ui, tabs: Tabs<'a, M>) -> Element<'a, M> {
let geometry = TabsGeometry::of(ui);
let primary = tabs.variant.is_primary();
let base = tabs.base;
// 6.1:带图标时容器高 **64**(`IconAndLabelTextContainerHeight`),纯文字 48
let height = if tabs.tabs.iter().any(|tab| tab.icon.is_some()) {
TABS_ICON_HEIGHT
} else {
geometry.height
};
let mut row = Row::new()
.height(Length::Fixed(height))
.align_y(Alignment::Center);
for tab in tabs.tabs {
row = row.push(tab_cell(ui, base, tab, geometry, primary, height));
}
let body: Element<'a, M> = if primary {
row.into()
} else {
// A1 的 Secondary 分隔线是**整行一条直线**,不是每个 tab 一段:
// 逐段画会在相邻段之间留缝(视觉 r1 P2-2)。这里用 `Stack` 把一条 Fill 线压在整行底下,
// 底座是 tab 行本身(Shrink → 内容宽),所以线长 = tab 行总宽,横向滚动时跟着走。
let line = container(space::vertical())
.width(Length::Fill)
.height(Length::Fixed(geometry.secondary_bar))
.style(move |_theme| container::Style {
background: Some(Background::Color(secondary_line_color(ui))),
..container::Style::default()
});
Stack::new()
.push(container(row))
.push(
container(line)
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::End),
)
.into()
};
scrollable(body)
.direction(scrollable::Direction::Horizontal(
scrollable::Scrollbar::hidden(),
))
.height(Length::Fixed(height))
.into()
}
/// 一个 tab 单元(内容 + 底部条)。
#[allow(clippy::too_many_arguments)]
fn tab_cell<'a, M: Clone + 'a>(
ui: &'a Ui,
base: Base,
tab: Tab<'a, M>,
geometry: TabsGeometry,
primary: bool,
height: f32,
) -> Element<'a, M> {
let forced = tab.state.or(if tab.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
});
let state = forced.unwrap_or(Interaction::Enabled);
let selected = tab.selected;
let color = content_color(ui, base, primary, state, selected);
let style = ui.style(TypeRole::TitleSmall);
let label = text(tab.label)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
// 单行(容器高固定 48/64):长标签让 tab 变宽 + 横向滚动,不折行(偏离 D-M79-5)
.wrapping(Wrapping::None)
.font(ui.font_of(style))
.color(color);
let mut content = Row::new().align_y(Alignment::Center).spacing(geometry.pad);
if let Some(kind) = tab.icon {
content = content.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, color),
));
}
content = content.push(label);
let bar_height = geometry.bar(primary);
// 底层:内容自己决定宽度(Shrink),底部留出条的高度 —— 未选中的 Primary tab 也占同样的高
let cell = container(content)
.height(Length::Fixed(height))
// Q21:要给居中就两轴都显式给(`Container` 默认 Left/Top)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.padding(Padding {
top: 0.0,
right: geometry.pad,
bottom: bar_height,
left: geometry.pad,
});
// Primary:逐 tab 画条(要跟随该 tab 的宽度);Secondary:整行一条,在 `tabs()` 里画
let bar = if primary {
bar_color(ui, base, state, selected)
} else {
None
};
let radius = geometry.indicator_radius();
let mut stack = Stack::new().push(cell);
if let Some(color) = bar {
// 上层:Fill —— `Stack` 先按底层定尺寸,所以这里的 Fill = 这个 tab 的宽(不是整行)
stack = stack.push(
container(
container(space::vertical())
.width(Length::Fill)
.height(Length::Fixed(bar_height))
.style(move |_theme| container::Style {
background: Some(Background::Color(color)),
border: Border {
radius,
..Border::default()
},
..container::Style::default()
}),
)
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::End),
);
}
let fill = interaction::overlay_color(ui, state, Role::OnSurface, base.role());
let button = button(stack)
.height(Length::Fixed(height))
.padding(0)
.style(move |_theme, _status| button::Style {
background: fill.map(Background::Color),
text_color: color,
// ContainerShape = CornerNone(tab 本身是直角;圆角只在指示条上)
border: Border {
radius: ui.radius_dp(ShapeScale::None),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
});
match tab.on_press {
Some(message) => button.on_press(message).into(),
None => button.into(),
}
}
#[cfg(test)]
mod tests;