From 2c442585b10a008bceeb468570f5af8a5ead67fb Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 1 Nov 2026 23:27:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(app-iced):=20M84=20=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E9=AA=A8=E6=9E=B6=E4=B8=8E=E5=B8=83=E5=B1=80=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=EF=BC=88=E6=96=AD=E7=82=B9=20/=20=E8=A1=8C=E5=AE=BD=E4=B8=8A?= =?UTF-8?q?=E9=99=90=20/=20=E4=B8=89=E7=A7=8D=E7=89=88=E9=9D=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 视觉复审在 M68 的判词是不能把「全宽单卡」当页面模板(1180 宽下正文单行 712px ≈ 118 字符)。 本 mission 把它落成一套**布局系统**,四个页面只是它的用例: - `pages/layout.rs`:官方 600 / 840 两个档位 → 三种版面(单列 / supporting-pane / list-detail), 阈值由列宽和算出(设置 240+24+320 → 720;实例 336+24+320 → 816 ≤ 840),行宽硬上限 14 × 0.5em × 72 = **504px**,全部是纯函数,`pages/layout/tests.rs` 用绝对值和边界表钉死。 - `pages/frame.rs`:页头(56 / 两行 84)+ 内容列(≤504)+ 两列骨架 + 空态位; `pages/frame/pieces.rs` 是占位控件(卡片 / 分隔线 / 文本按钮 / 图标),令牌驱动、无完整状态机, 待 M75 的 components 家族合入后替换。 - 四页:实例(list-detail,列表列 336 独立滚动,窄窗退化成整页切换)、 设置(supporting-pane,240 分组导航 + ≤504 表单列)、下载 / 日志(单列,两行页头)。 - `pages/harness.rs` + `examples/pages_demo.rs`:自带 `--screen/--width/--screenshot-theme`, 不碰 `cli.rs` / `app.rs`(M75 在改)。 偏离与说明: - 布局用「页边距 16/24」而非 16/24/32,官方 expanded 也是 24(少一条偏离登记); - 实例空详情态一屏会有 5 档字号(22/16/24/12 + 14 的操作按钮),未截图的状态,已知并登记; - `lib.rs` 只加一行 `pub mod pages;`。 --- crates/app-iced/examples/pages_demo.rs | 37 ++ crates/app-iced/src/lib.rs | 1 + crates/app-iced/src/pages/download.rs | 106 +++++ crates/app-iced/src/pages/frame.rs | 189 +++++++++ crates/app-iced/src/pages/frame/pieces.rs | 292 ++++++++++++++ crates/app-iced/src/pages/frame/tests.rs | 101 +++++ crates/app-iced/src/pages/harness.rs | 399 +++++++++++++++++++ crates/app-iced/src/pages/instances.rs | 342 ++++++++++++++++ crates/app-iced/src/pages/instances/data.rs | 136 +++++++ crates/app-iced/src/pages/instances/tests.rs | 49 +++ crates/app-iced/src/pages/layout.rs | 310 ++++++++++++++ crates/app-iced/src/pages/layout/tests.rs | 280 +++++++++++++ crates/app-iced/src/pages/logs.rs | 145 +++++++ crates/app-iced/src/pages/mod.rs | 281 +++++++++++++ crates/app-iced/src/pages/settings.rs | 311 +++++++++++++++ 15 files changed, 2979 insertions(+) create mode 100644 crates/app-iced/examples/pages_demo.rs create mode 100644 crates/app-iced/src/pages/download.rs create mode 100644 crates/app-iced/src/pages/frame.rs create mode 100644 crates/app-iced/src/pages/frame/pieces.rs create mode 100644 crates/app-iced/src/pages/frame/tests.rs create mode 100644 crates/app-iced/src/pages/harness.rs create mode 100644 crates/app-iced/src/pages/instances.rs create mode 100644 crates/app-iced/src/pages/instances/data.rs create mode 100644 crates/app-iced/src/pages/instances/tests.rs create mode 100644 crates/app-iced/src/pages/layout.rs create mode 100644 crates/app-iced/src/pages/layout/tests.rs create mode 100644 crates/app-iced/src/pages/logs.rs create mode 100644 crates/app-iced/src/pages/mod.rs create mode 100644 crates/app-iced/src/pages/settings.rs diff --git a/crates/app-iced/examples/pages_demo.rs b/crates/app-iced/examples/pages_demo.rs new file mode 100644 index 0000000..34122bf --- /dev/null +++ b/crates/app-iced/examples/pages_demo.rs @@ -0,0 +1,37 @@ +//! 页面骨架截图入口(M84 `PAGEFRAME` 的验收通道)。 +//! +//! ```text +//! cargo run -p unlucky-app-iced --example pages_demo -- \ +//! --screen=instances --width=1180 --height=760 \ +//! --screenshot=out.png --screenshot-theme=light --software-renderer +//! ``` +//! +//! 参数解析与 iced 接线都在 `unlucky_app_iced::pages::harness` 里:**本 example 只是入口**, +//! 它也刻意不接 `cli.rs`(那边归 M75)。详细口径见 `pages::harness` 的模块文档。 + +use std::process::ExitCode; + +use unlucky_app_iced::pages::harness::{self, Request}; + +fn main() -> ExitCode { + let request = match Request::parse(std::env::args().skip(1)) { + Ok(request) => request, + Err(message) => { + eprintln!("[cli] {message}"); + eprintln!( + "用法:pages_demo [--screen=instances|download|settings|logs] [--width ] \ + [--height ] [--theme=light|dark] [--screenshot-theme=light|dark] \ + [--screenshot ] [--renderer=auto|gpu|cpu] [--software-renderer]" + ); + return ExitCode::from(2); + } + }; + + match harness::run(request) { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("[run] {message}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/app-iced/src/lib.rs b/crates/app-iced/src/lib.rs index 4fe1497..56e26b2 100644 --- a/crates/app-iced/src/lib.rs +++ b/crates/app-iced/src/lib.rs @@ -24,6 +24,7 @@ //! 等价验收后翻牌(bin 改名 + 旧 crate 降级),步骤写在 //! `DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md`。 +pub mod pages; pub mod shot; pub mod theme; diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs new file mode 100644 index 0000000..fd4d0f6 --- /dev/null +++ b/crates/app-iced/src/pages/download.rs @@ -0,0 +1,106 @@ +//! 下载页:**单列**版面的用例(列宽 ≤504,水平居中)。 +//! +//! 这一页永远单列,理由不是「简单」,而是官方对「一次只做一件事」的入口(下载队列)的口径: +//! 内容是一串线性进度项,分栏没有第二个阅读对象。窗口变宽时**正文列不变宽**(撞行宽上限), +//! 多出来的宽度留白——这就是 M68「全宽单卡」的反面。 +//! +//! 页头 1 个操作 + 空态 3 个快捷入口(12b §15.5:≤3)。 + +use iced::widget::{column, container, row}; +use iced::{Alignment, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use crate::theme::Ui; + +use super::frame::{self, Header}; +use super::layout::FrameMetrics; +use super::{Action, Message}; + +/// 整页。 +pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> { + let header = Header::new("Download") + .subtitle("Versions, mod loaders and modpacks") + .action(frame::text_action( + ui, + "Refresh sources", + Action::RefreshSources, + )); + + let empty = frame::empty_state( + ui, + IconKind::Download, + "No downloads in progress", + "Pick a version or drop a modpack file here. Sources are probed per region and can be pinned \ + in Settings → Downloads.", + vec![ + frame::text_action(ui, "Browse versions", Action::BrowseVersions), + frame::text_action(ui, "Import modpack", Action::ImportModpack), + frame::text_action(ui, "Check sources", Action::CheckSources), + ], + ); + + let body = frame::single_column( + metrics, + column![source_status(ui), empty] + .spacing(ui.space(Space::X4)) + .width(Length::Fill) + .height(Length::Fill), + ); + + frame::page(ui, metrics, header, body) +} + +/// 来源状态行(12pt,`OnSurfaceVariant`):说明这一页的数据从哪来,不是业务数据。 +fn source_status<'a>(ui: &'a Ui) -> Element<'a, Message> { + container( + row![ + frame::glyph( + IconKind::Check, + ui.color(Role::Secondary), + frame::ICON_SMALL_PX + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + "3 sources healthy · BMCLAPI pinned by region · 2 min ago", + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + ) + .width(Length::Fill) + .into() +} + +#[cfg(test)] +mod tests { + use super::super::layout::{content_column_width, frame_metrics, measure_cap_px, PaneKit}; + + /// 单列页:任何窗口宽度都不许出现第二列,正文列不许超过行宽上限。 + #[test] + fn download_is_single_column_at_every_width() { + for width in [600.0, 840.0, 900.0, 1180.0, 2400.0] { + let metrics = frame_metrics(PaneKit::Single, width); + assert!(metrics.secondary.is_none(), "{width} 宽下载页出现了第二列"); + assert!(metrics.column <= measure_cap_px() + f32::EPSILON); + assert_eq!(metrics.column, content_column_width(width)); + } + } + + /// 宽窗下多出来的宽度必须留白(不是把卡片拉宽)——1180 的正文列 < 可用宽度。 + #[test] + fn extra_width_goes_to_margin_not_to_the_column() { + let metrics = frame_metrics(PaneKit::Single, 1180.0); + assert_eq!(metrics.column, measure_cap_px()); + assert!( + metrics.available - metrics.column >= 400.0, + "1180 宽下应留出大片留白(M68 的全宽卡就是没有这段留白)" + ); + } +} diff --git a/crates/app-iced/src/pages/frame.rs b/crates/app-iced/src/pages/frame.rs new file mode 100644 index 0000000..a5ec078 --- /dev/null +++ b/crates/app-iced/src/pages/frame.rs @@ -0,0 +1,189 @@ +//! 页面骨架:**页头**(标题 + 副标题 + 可选操作区)+ **内容区**(列宽由 [`super::layout`] 决定) +//! + **空态位**。 +//! +//! ## 边界 +//! +//! 这里只放「页面级」的东西:页头、页边距、内容列、两列骨架、空态位的摆放。 +//! 具体控件(卡片 / 分隔线 / 文本按钮 / 图标)在 [`pieces`],它们是**占位实现** +//! (同一份令牌,没有完整状态机),等 M75 的 `components/` 家族合入后由另一条 mission 换掉。 +//! +//! ## 判据落点(`research/12b`) +//! +//! | 部件 | 参考 | 落点 | +//! |---|---|---| +//! | 页头 | 16.1 AppBar 高 56(登记偏离官方 64)/ 两行 84 | `LayoutMetrics::header_height` | +//! | 页头标题 | 16.4 `TitleLarge 22/28` | `Component::TopAppBar.spec().text` | +//! | 页头副标题 | 16.4 `LabelMedium 12/16` `OnSurfaceVariant` | 同上 `.support` | +//! | 空态 | 15.1 图标块 96×96 / `Large(16)`;15.2 标题 `HeadlineSmall 24`;15.3 副文案 `BodySmall 12` | `Component::EmptyState` | + +mod pieces; + +#[cfg(test)] +mod tests; + +pub use pieces::{ + card, divider, empty_state, filled, gap, glyph, glyph_box, scroll_style, text_action, + text_role, text_role_centered, ICON_PX, ICON_SMALL_PX, +}; + +use iced::widget::{column, container, row}; +use iced::{Alignment, Element, Length, Padding}; + +use unlucky_design::color::Role; +use unlucky_design::component_map::Component; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use crate::theme::Ui; + +use super::layout::{self, FrameMetrics}; +use super::Message; + +/// 页头操作区之间的间隔(`Space::X2`)。 +const HEADER_ACTION_GAP: f32 = Space::X2.dp(); + +/// 页头内容。 +pub struct Header<'a> { + /// 标题(`TitleLarge`)。 + pub title: &'a str, + /// 副标题(`LabelMedium`,给了页头就是两行高)。 + pub subtitle: Option<&'a str>, + /// 右侧操作区(text button 占位,数量 ≤3)。 + pub actions: Vec>, +} + +impl<'a> Header<'a> { + /// 单行页头。 + pub fn new(title: &'a str) -> Self { + Self { + title, + subtitle: None, + actions: Vec::new(), + } + } + + /// 加副标题(页头自动变两行高)。 + #[must_use] + pub fn subtitle(mut self, subtitle: &'a str) -> Self { + self.subtitle = Some(subtitle); + self + } + + /// 追加一个右侧操作。 + #[must_use] + pub fn action(mut self, action: Element<'a, Message>) -> Self { + self.actions.push(action); + self + } +} + +/// 一整页:页头 + 内容区(页边距已由布局系统决定)。 +pub fn page<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + header: Header<'a>, + body: impl Into>, +) -> Element<'a, Message> { + let bar = header_bar(ui, metrics, header); + let content = container(body) + .width(Length::Fill) + .height(Length::Fill) + .padding(metrics.gutter); + + column![bar, content] + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn header_bar<'a>(ui: &'a Ui, metrics: FrameMetrics, header: Header<'a>) -> Element<'a, Message> { + let spec = Component::TopAppBar.spec(); + // 16.4:标题 TitleLarge(emphasized w500),副标题 LabelMedium + OnSurfaceVariant + let mut titles = column![text_role( + ui, + spec.text, + true, + header.title, + ui.color(Role::OnSurface) + )] + .spacing(ui.space(Space::X1)); + if let Some(subtitle) = header.subtitle { + titles = titles.push(text_role( + ui, + spec.support.unwrap_or(TypeRole::LabelMedium), + false, + subtitle, + ui.color(Role::OnSurfaceVariant), + )); + } + + let actions = row(header.actions) + .spacing(HEADER_ACTION_GAP) + .align_y(Alignment::Center); + + container( + row![titles.width(Length::Fill), actions] + .width(Length::Fill) + .align_y(Alignment::Center), + ) + .width(Length::Fill) + .height(ui.metrics().header_height(header.subtitle.is_some())) + .align_y(Alignment::Center) + .padding(Padding::from([0.0, metrics.gutter])) + .into() +} + +/// 单列版面的正文列:宽 = 行宽上限内的实际列宽,**水平居中**。 +pub fn single_column<'a>( + metrics: FrameMetrics, + content: impl Into>, +) -> Element<'a, Message> { + centered(metrics.column, content) +} + +/// 两列版面:左列 `secondary`(独立滚动区由调用方自己加 `scrollable`)+ 右列 `main`。 +/// +/// 两列都**显式**取布局系统给的宽度,不吃 Fill:列表列必须固定 336, +/// 详情列必须窄于可用宽度,否则又回到「全宽单卡」。 +pub fn panes<'a>( + metrics: FrameMetrics, + secondary: impl Into>, + main: impl Into>, +) -> Element<'a, Message> { + let width = metrics.secondary.unwrap_or(0.0); + row![ + container(secondary) + .width(Length::Fixed(width)) + .height(Length::Fill), + container(main) + .width(Length::Fixed(metrics.main_pane)) + .height(Length::Fill), + ] + .spacing(layout::PANES_GAP) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +/// 把内容塞进固定宽度并水平居中(`align_x` 不会覆盖子元素的宽度,见 Q8)。 +pub fn centered<'a>(width: f32, content: impl Into>) -> Element<'a, Message> { + container(container(content).width(Length::Fixed(width))) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Start) + .into() +} + +/// 与 [`centered`] 同宽,但**贴左**:列表右侧的详情列要贴着自己的 pane 左边,不再二次居中。 +pub fn left_aligned<'a>( + width: f32, + content: impl Into>, +) -> Element<'a, Message> { + container(container(content).width(Length::Fixed(width))) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Start) + .align_y(Alignment::Start) + .into() +} diff --git a/crates/app-iced/src/pages/frame/pieces.rs b/crates/app-iced/src/pages/frame/pieces.rs new file mode 100644 index 0000000..997acdc --- /dev/null +++ b/crates/app-iced/src/pages/frame/pieces.rs @@ -0,0 +1,292 @@ +//! 页面用的**占位控件**:卡片 / 分隔线 / 空态 / 文本按钮 / 图标 / 容器样式。 +//! +//! 全部走令牌(颜色、圆角、排版、间距、一像素线),但**没有完整状态机**—— +//! 这些是给页面骨架站稳版式用的临时件,M74/M75 的 `components/` 家族合入后由另一条 mission 替换。 +//! 每个函数都在文档里点明它对应哪个组件的档位(12b 的参考值)。 + +use iced::widget::text::LineHeight; +use iced::widget::{button, column, container, row, rule, scrollable, space, svg}; +use iced::Theme as IcedTheme; +use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow}; + +use unlucky_design::color::{Role, StateLayer}; +use unlucky_design::component_map::Component; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::icons::{IconKind, IconPaint}; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::{TypeRole, TypeStyle}; + +use crate::icons; +use crate::pages::{Action, Message}; +use crate::theme::Ui; + +/// 空态图标块边长(12b 15.1:96×96,圆角 `Large`)。 +pub(super) const EMPTY_ICON_BLOCK: f32 = 96.0; +/// 空态字形渲染尺寸(12b 15.1:图标 32~48 `[估]`,取 40)。 +pub(super) const EMPTY_ICON_GLYPH: f32 = 40.0; +/// 滚动条宽度(iced 默认 10px 的令牌化名字,用于把滑块做成药丸)。 +const SCROLLBAR_TRACK: f32 = 10.0; + +/// 描边卡片(占位:M75 的 `components` 会提供真正的 `OutlinedCard`)。 +pub fn card<'a>(ui: &'a Ui, content: impl Into>) -> Element<'a, Message> { + let spec = Component::OutlinedCard.spec(); + container(content) + .width(Length::Fill) + .padding(ui.metrics().card_padding) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Card))), + border: ui.outlined(spec.shape), + ..container::Style::default() + }) + .into() +} + +/// 一像素分隔线(`OutlineVariant`,厚度取令牌)。 +pub fn divider<'a>(ui: &'a Ui) -> Element<'a, Message> { + rule::horizontal::(ui.metrics().divider_thickness) + .style(move |_theme| rule::Style { + color: ui.color(Role::OutlineVariant), + radius: ui.radius_dp(ShapeScale::None), + fill_mode: rule::FillMode::Full, + snap: true, + }) + .into() +} + +/// 空态(12b §15):图标块 96 + 24 标题 + 12 副文案 + ≤3 个快捷入口,**整块垂直居中**。 +pub fn empty_state<'a>( + ui: &'a Ui, + icon: IconKind, + title: &'a str, + body: &'a str, + actions: Vec>, +) -> Element<'a, Message> { + let spec = Component::EmptyState.spec(); + + let block = container(glyph( + icon, + ui.color(Role::OnSurfaceVariant), + EMPTY_ICON_GLYPH, + )) + .width(EMPTY_ICON_BLOCK) + .height(EMPTY_ICON_BLOCK) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.color(Role::SurfaceContainerHigh))), + border: Border { + radius: ui.radius_dp(spec.shape), + ..Border::default() + }, + ..container::Style::default() + }); + + let mut stack = column![ + block, + text_role_centered(ui, spec.text, false, title, ui.color(Role::OnSurface)), + text_role_centered( + ui, + spec.support.unwrap_or(TypeRole::BodySmall), + false, + body, + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X3)) + .align_x(Alignment::Center); + + if !actions.is_empty() { + stack = stack.push( + row(actions) + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + ); + } + + container(stack) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .into() +} + +/// 文本按钮占位(TextButton 档:透明底、`OnSurfaceVariant`、40 高、full 圆角)。 +/// +/// 只画静息、hover、pressed 三态(focus/disabled 归 M75 的 `interaction.rs`); +/// 消息是页面自己的 [`Message::Action`],与 app 的 `Message` 完全解耦。 +pub fn text_action<'a>(ui: &'a Ui, label: &'a str, action: Action) -> Element<'a, Message> { + let spec = Component::TextButton.spec(); + let height = ui.metrics().control_height; + + let content = text_role( + ui, + spec.text, + false, + label, + ui.color(Role::OnSurfaceVariant), + ); + + button(container(content).align_y(Alignment::Center)) + .height(height) + .padding(Padding::from([0.0, ui.space(Space::X4)])) + .style(move |_theme: &IcedTheme, status| { + let background = match status { + button::Status::Hovered => Some(Background::Color(ui.state_over( + StateLayer::Hover, + Role::OnSurface, + Role::Surface, + ))), + button::Status::Pressed => Some(Background::Color(ui.state_over( + StateLayer::Pressed, + Role::OnSurface, + Role::Surface, + ))), + button::Status::Active | button::Status::Disabled => None, + }; + + button::Style { + background, + text_color: ui.color(Role::OnSurfaceVariant), + border: Border { + radius: ui.radius(ShapeScale::Full, height), + ..Border::default() + }, + shadow: Shadow::default(), + snap: true, + } + }) + .on_press(Message::Action(action)) + .into() +} + +/// 令牌排版档 → 文字元素(字号 / 行高 / 字重全来自令牌,不写字面量)。 +pub fn text_role<'a>( + ui: &'a Ui, + role: TypeRole, + emphasized: bool, + content: &'a str, + color: Color, +) -> Element<'a, Message> { + text_widget(ui, role, emphasized, content, color).into() +} + +/// 同 [`text_role`],但**文字自身居中**(空态的标题与副文案:图标块是居中锚点)。 +pub fn text_role_centered<'a>( + ui: &'a Ui, + role: TypeRole, + emphasized: bool, + content: &'a str, + color: Color, +) -> Element<'a, Message> { + text_widget(ui, role, emphasized, content, color) + .align_x(iced::widget::text::Alignment::Center) + .into() +} + +fn text_widget<'a>( + ui: &'a Ui, + role: TypeRole, + emphasized: bool, + content: &'a str, + color: Color, +) -> iced::widget::Text<'a> { + let style: TypeStyle = if emphasized { + ui.emphasized(role) + } else { + ui.style(role) + }; + + iced::widget::text(content) + .size(style.size_px) + .line_height(LineHeight::Absolute(Pixels(style.line_height_px))) + .font(ui.font_of(style)) + .color(color) +} + +/// 任意尺寸的令牌图标(`icons::RENDER_PX` 只有 24 一档;空态图标要 40)。 +/// +/// 描边宽度仍按令牌的 `Icon::stroke_width(render_px)` 折算,所以视觉线宽在任何尺寸下都是 1.5。 +pub fn glyph(kind: IconKind, color: Color, render_px: f32) -> Element<'static, Message> { + let icon = kind.icon(); + let paint = match icon.paint { + IconPaint::Stroke => format!( + "fill=\"none\" stroke=\"currentColor\" stroke-width=\"{}\" \ + stroke-linecap=\"round\" stroke-linejoin=\"round\"", + icon.stroke_width(render_px) + ), + IconPaint::Fill => "fill=\"currentColor\" stroke=\"none\"".to_owned(), + }; + + let source = format!( + "", + render_px, icon.path + ); + + svg(svg::Handle::from_memory(source.into_bytes())) + .width(render_px) + .height(render_px) + .style(move |_theme, _status| svg::Style { color: Some(color) }) + .into() +} + +/// 固定宽 × 固定高的图标容器(Q9:按钮给的 Fill 约束会撑大字形,必须中间夹一层)。 +pub fn glyph_box(kind: IconKind, color: Color, px: f32) -> Element<'static, Message> { + container(glyph(kind, color, px)) + .width(px) + .height(px) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .into() +} + +/// 竖直留白(布局拼装用;值来自令牌栅格)。 +pub fn gap<'a>(px: f32) -> Element<'a, Message> { + space::vertical().height(px).into() +} + +/// 纯色容器样式(页面底色 / 留白带)。 +pub fn filled(color: Color) -> container::Style { + container::Style { + background: Some(Background::Color(color)), + ..container::Style::default() + } +} + +/// 滚动条配色(令牌角色):轨道透明,滑块 `Outline`,尺寸取 iced 默认。 +/// +/// 只在**独立滚动区**上挂(实例页列表列),不许挂到整页上——整页滚动会把页头一起滚走。 +pub fn scroll_style(ui: &Ui) -> impl Fn(&IcedTheme, scrollable::Status) -> scrollable::Style + '_ { + let rail = scrollable::Rail { + background: None, + border: Border::default(), + scroller: scrollable::Scroller { + background: Background::Color(ui.color(Role::Outline)), + border: Border { + radius: ui.radius(ShapeScale::Full, SCROLLBAR_TRACK), + ..Border::default() + }, + }, + }; + + move |_theme, _status| scrollable::Style { + container: container::Style::default(), + vertical_rail: rail, + horizontal_rail: rail, + gap: None, + auto_scroll: scrollable::AutoScroll { + background: Background::Color(ui.color(Role::SurfaceContainerHigh)), + border: Border::default(), + shadow: Shadow::default(), + icon: ui.color(Role::OnSurfaceVariant), + }, + } +} + +/// `crate::icons::RENDER_PX` 的再导出(页面里 24px 图标走这个常量,不写字面量)。 +pub const ICON_PX: f32 = icons::RENDER_PX; + +/// 小号图标(行尾 chevron 之类的辅助指示,16px)。 +pub const ICON_SMALL_PX: f32 = 16.0; diff --git a/crates/app-iced/src/pages/frame/tests.rs b/crates/app-iced/src/pages/frame/tests.rs new file mode 100644 index 0000000..4f9689e --- /dev/null +++ b/crates/app-iced/src/pages/frame/tests.rs @@ -0,0 +1,101 @@ +//! `frame` 的断言:空态 / 卡片 / 页头规格与一屏字号档。 +use super::pieces::{EMPTY_ICON_BLOCK, EMPTY_ICON_GLYPH}; +use super::*; + +use unlucky_design::color::SchemeFlavor; +use unlucky_design::shape::ShapeScale; +use unlucky_design::typography::Platform; + +use crate::pages::layout::{frame_metrics, PaneKit}; +use crate::theme::Families; + +fn ui() -> Ui { + Ui::with_platform( + SchemeFlavor::Light, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ) +} + +#[test] +fn empty_state_follows_the_12b_reference() { + // 15.1 图标块 96×96、圆角 Large(16);15.2 标题 HeadlineSmall;15.3 副文案 BodySmall + let spec = Component::EmptyState.spec(); + assert_eq!(EMPTY_ICON_BLOCK, 96.0); + assert_eq!(spec.shape, ShapeScale::Large); + assert_eq!(spec.text, TypeRole::HeadlineSmall); + assert_eq!(ui().style(spec.text).size_px, 24.0); + assert_eq!(spec.support, Some(TypeRole::BodySmall)); + assert_eq!(ui().style(TypeRole::BodySmall).size_px, 12.0); + // 字形 32~48([估]) + assert!((32.0..=48.0).contains(&EMPTY_ICON_GLYPH)); + const { assert!(EMPTY_ICON_GLYPH < EMPTY_ICON_BLOCK) }; +} + +#[test] +fn card_uses_the_outlined_card_spec() { + let spec = Component::OutlinedCard.spec(); + assert_eq!(spec.shape, ShapeScale::Medium); + assert_eq!(ui().radius_dp(spec.shape).top_left, 12.0); + assert_eq!(spec.text, TypeRole::TitleMedium); + assert_eq!(spec.support, Some(TypeRole::BodySmall)); +} + +#[test] +fn page_header_uses_the_two_documented_heights() { + let ui = ui(); + assert_eq!(ui.metrics().header_height(false), 56.0); + assert_eq!(ui.metrics().header_height(true), 84.0); +} + +/// 一屏字号 ≤4 档(rubric D4)。规格表来源:12b §16.4 / §15 / 3.x OutlinedCard。 +#[test] +fn every_screen_keeps_at_most_four_text_sizes() { + let ui = ui(); + + let header = ui.emphasized(Component::TopAppBar.spec().text).size_px; + let row_title = ui.style(Component::ListItem.spec().text).size_px; + let row_support = ui + .style(Component::ListItem.spec().support.unwrap()) + .size_px; + let action = ui.style(Component::TextButton.spec().text).size_px; + let body = ui.style(TypeRole::BodyMedium).size_px; + let empty_title = ui.style(Component::EmptyState.spec().text).size_px; + + let screens: [(&str, &[f32]); 3] = [ + // 实例页(list-detail 与窄窗单列):页头 + 行标题 + 行副文案 + 操作/正文 + ("instances", &[header, row_title, row_support, action, body]), + // 设置页:页头 + 分组项/操作 + 行标签 + 行值 + 空态标题 + ( + "settings", + &[header, action, row_support, body, empty_title], + ), + // 下载 / 日志:页头 + 空态标题 + 空态副文案 + 操作 + ("empty-pages", &[header, empty_title, row_support, action]), + ]; + + for (name, sizes) in screens { + let mut unique = sizes.to_vec(); + unique.sort_by(f32::total_cmp); + unique.dedup(); + assert!( + unique.len() <= 4, + "{name} 一屏用了 {} 档字号 {unique:?},超过 rubric D4 的 4 档", + unique.len() + ); + assert!(unique[0] >= 12.0, "{name} 出现小于 12px 的字号"); + assert!(unique[unique.len() - 1] <= 24.0, "{name} 字号过大"); + } +} + +/// 版面几何是页面唯一宽度来源:两列版面的正文列必须窄于可用宽度。 +#[test] +fn two_pane_body_is_narrower_than_the_available_width() { + let wide = frame_metrics(PaneKit::ListDetail, 1180.0); + assert!(wide.column < wide.available); + assert!(wide.secondary.is_some()); +} diff --git a/crates/app-iced/src/pages/harness.rs b/crates/app-iced/src/pages/harness.rs new file mode 100644 index 0000000..868478a --- /dev/null +++ b/crates/app-iced/src/pages/harness.rs @@ -0,0 +1,399 @@ +//! 截图 harness:把**一页**按给定窗口宽度 / 主题单独渲染并出图。 +//! +//! 它是 `examples/pages_demo.rs` 的入口(该 example 只有三行)。**刻意不接 `cli.rs`**: +//! `cli.rs` 归 M75 改(它正在加 `--screen`),本 mission 不碰;等 `cli.rs` 具备 `--screen` / +//! `--window-width` 之后,这个 harness 应当删掉,改由正式启动参数出图。 +//! +//! ## 用法 +//! +//! ```text +//! cargo run -p unlucky-app-iced --example pages_demo -- \ +//! --screen=instances --width=1180 --height=760 \ +//! --screenshot=out.png --screenshot-theme=light --software-renderer +//! ``` +//! +//! 参数:`--screen=instances|download|settings|logs`、`--width`、`--height`、 +//! `--theme` / `--screenshot-theme`(后者优先)、`--screenshot`、`--renderer`、 +//! `--software-renderer`、`--scale-factor`。没给 `--screenshot` 就只是开窗看。 +//! +//! ## 为什么还要等 3 帧 +//! +//! 与 `app.rs` 同一口径(M60 坑 #5):第一帧可能是启动时的旧尺寸,且字体首次上屏会抖动。 + +use std::path::PathBuf; +use std::time::Instant; + +use iced::{window, Element, Subscription, Task}; + +use unlucky_design::color::SchemeFlavor; + +use crate::cli::Backend; +use crate::fonts; +use crate::probe::{self, Probe}; +use crate::shot; +use crate::theme::Ui; + +use super::{demo, Screen}; + +/// 截图落盘的帧号(见模块文档)。 +const CAPTURE_AT_FRAME: u32 = 3; +/// 验收窗口尺寸(D30:1180×760、100% DPI)。 +const DEFAULT_SIZE: (f32, f32) = (1180.0, 760.0); + +/// 一次渲染请求。 +#[derive(Debug, Clone, PartialEq)] +pub struct Request { + /// 渲染哪一页。 + pub screen: Screen, + /// 窗口宽度(**含外壳**:布局档位就是按它判的)。 + pub width: f32, + /// 窗口高度。 + pub height: f32, + /// 主题(`--screenshot-theme` > `--theme` > 浅色)。 + pub flavor: SchemeFlavor, + /// 出图路径(`None` = 只开窗)。 + pub screenshot: Option, + /// 渲染后端请求。 + pub backend: Backend, + /// 强制缩放因子(截图要 100% DPI)。 + pub scale_factor: Option, +} + +impl Default for Request { + fn default() -> Self { + Self { + screen: Screen::Instances, + width: DEFAULT_SIZE.0, + height: DEFAULT_SIZE.1, + flavor: SchemeFlavor::Light, + screenshot: None, + backend: Backend::Auto, + scale_factor: None, + } + } +} + +impl Request { + /// 从进程参数解析(`argv[1..]`)。 + /// + /// # Errors + /// 未知参数、缺值、数字非法时返回人话。 + pub fn parse(args: impl IntoIterator) -> Result { + let mut request = Self::default(); + let mut theme: Option = None; + let mut shot_theme: Option = None; + let mut args = args.into_iter(); + + while let Some(arg) = args.next() { + let (key, inline) = match arg.split_once('=') { + Some((key, value)) => (key.to_owned(), Some(value.to_owned())), + None => (arg.clone(), None), + }; + + match key.as_str() { + "--screen" => { + let raw = take(&mut args, inline, "--screen")?; + request.screen = Screen::from_slug(&raw).ok_or_else(|| { + format!("--screen 只认 instances|download|settings|logs,收到 {raw:?}") + })?; + } + "--width" => { + request.width = number(&take(&mut args, inline, "--width")?, "--width")? + } + "--height" => { + request.height = number(&take(&mut args, inline, "--height")?, "--height")?; + } + "--theme" => theme = Some(flavor(&take(&mut args, inline, "--theme")?)?), + "--screenshot-theme" => { + shot_theme = Some(flavor(&take(&mut args, inline, "--screenshot-theme")?)?); + } + "--screenshot" => { + request.screenshot = + Some(PathBuf::from(take(&mut args, inline, "--screenshot")?)); + } + "--software-renderer" => request.backend = Backend::Cpu, + "--renderer" => { + request.backend = match take(&mut args, inline, "--renderer")?.as_str() { + "auto" => Backend::Auto, + "gpu" => Backend::Gpu, + "cpu" => Backend::Cpu, + other => { + return Err(format!("--renderer 只认 auto|gpu|cpu,收到 {other:?}")) + } + }; + } + "--scale-factor" => { + let raw = take(&mut args, inline, "--scale-factor")?; + let value = number(&raw, "--scale-factor")?; + if value <= 0.0 { + return Err(format!("--scale-factor 必须 > 0,收到 {raw:?}")); + } + request.scale_factor = Some(value); + } + other => return Err(format!("未知参数 {other:?}")), + } + } + + request.flavor = shot_theme.or(theme).unwrap_or(SchemeFlavor::Light); + + if request.width < 320.0 || request.height < 320.0 { + return Err(format!( + "窗口太小:{}×{}(至少 320×320)", + request.width, request.height + )); + } + + Ok(request) + } + + /// 出图文件名片段(`--`,归档用)。 + pub fn slug(&self) -> String { + let class = super::layout::WidthClass::of(self.width); + let flavor = if self.flavor.is_dark() { + "dark" + } else { + "light" + }; + format!( + "{}-{}-{flavor}-w{}", + self.screen.slug(), + class.name(), + self.width as u32 + ) + } +} + +fn take( + args: &mut impl Iterator, + inline: Option, + flag: &str, +) -> Result { + inline + .or_else(|| args.next()) + .ok_or_else(|| format!("{flag} 需要一个值")) +} + +fn number(raw: &str, flag: &str) -> Result { + raw.parse::() + .map_err(|_| format!("{flag} 需要数字,收到 {raw:?}")) + .and_then(|value| { + if value.is_finite() && value > 0.0 { + Ok(value) + } else { + Err(format!("{flag} 必须是正数,收到 {raw:?}")) + } + }) +} + +fn flavor(raw: &str) -> Result { + match raw { + "light" => Ok(SchemeFlavor::Light), + "dark" => Ok(SchemeFlavor::Dark), + other => Err(format!("主题只认 light|dark,收到 {other:?}")), + } +} + +/// 启动(阻塞到出图或关窗)。 +/// +/// # Errors +/// 后端探测/字体求值失败、截图落盘失败时返回人话。 +pub fn run(request: Request) -> Result<(), String> { + let started = Instant::now(); + + let needs_probe = request.backend == Backend::Auto; + let probe = if needs_probe { + Probe::run() + } else { + Probe::default() + }; + probe::apply(request.backend, &probe); + + let resolved = fonts::resolve(); + eprintln!("{}", resolved.describe()); + let families = resolved.families(); + + let boot = request.clone(); + let application = iced::application( + move || { + ( + Harness { + ui: Ui::new(boot.flavor, families), + request: boot.clone(), + window: None, + frames: 0, + }, + Task::done(Message::Boot), + ) + }, + Harness::update, + Harness::view, + ) + .theme(Harness::theme) + .subscription(Harness::subscription) + .default_font(iced::Font { + family: iced::font::Family::Name(families.text), + ..iced::Font::DEFAULT + }) + .window(window::Settings { + size: iced::Size::new(request.width, request.height), + decorations: false, + resizable: true, + ..window::Settings::default() + }); + + eprintln!( + "[harness] screen={} width={} height={} slug={}(布局档位 {})", + request.screen.slug(), + request.width, + request.height, + request.slug(), + super::layout::WidthClass::of(request.width).name() + ); + + match request.scale_factor { + Some(scale) => application.scale_factor(move |_| scale).run(), + None => application.run(), + } + .map_err(|error| format!("iced 启动失败:{error}"))?; + + eprintln!( + "[harness] 用时 {:.0} ms", + started.elapsed().as_secs_f64() * 1000.0 + ); + Ok(()) +} + +struct Harness { + request: Request, + ui: Ui, + window: Option, + frames: u32, +} + +#[derive(Debug, Clone)] +enum Message { + Boot, + WindowReady(Option), + Frame, + Captured(window::Screenshot), + /// 页面里的动作(骨架期只打印,接线后由 app 层处理)。 + Page(super::Message), +} + +impl Harness { + fn update(&mut self, message: Message) -> Task { + match message { + Message::Boot => window::latest().map(Message::WindowReady), + Message::WindowReady(id) => { + self.window = id; + Task::none() + } + Message::Frame => { + self.frames += 1; + if self.frames < CAPTURE_AT_FRAME || self.request.screenshot.is_none() { + return Task::none(); + } + match self.window { + Some(id) => window::screenshot(id).map(Message::Captured), + None => Task::none(), + } + } + Message::Captured(screenshot) => { + let Some(path) = self.request.screenshot.clone() else { + return Task::none(); + }; + match shot::save(&screenshot, &path) { + Ok(summary) => { + eprintln!("{}", summary.describe()); + eprintln!("[harness] slug={}", self.request.slug()); + } + Err(error) => eprintln!("[harness] 截图失败:{error}"), + } + iced::exit() + } + Message::Page(message) => { + eprintln!("[harness] 页面动作:{message:?}(骨架期不做业务)"); + Task::none() + } + } + } + + fn subscription(&self) -> Subscription { + if self.request.screenshot.is_some() { + window::frames().map(|_| Message::Frame) + } else { + Subscription::none() + } + } + + fn theme(&self) -> iced::Theme { + self.ui.theme() + } + + fn view(&self) -> Element<'_, Message> { + demo(&self.ui, self.request.screen, self.request.width).map(Message::Page) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Result { + Request::parse(args.iter().map(|arg| (*arg).to_owned())) + } + + #[test] + fn defaults_are_the_acceptance_frame() { + let request = parse(&[]).expect("空参数要能解析"); + assert_eq!(request.screen, Screen::Instances); + assert_eq!((request.width, request.height), (1180.0, 760.0)); + assert_eq!(request.flavor, SchemeFlavor::Light); + assert!(request.screenshot.is_none()); + } + + #[test] + fn screen_and_width_parse_in_both_argv_forms() { + let spaced = parse(&["--screen", "settings", "--width", "900"]).unwrap(); + let inline = parse(&["--screen=settings", "--width=900"]).unwrap(); + assert_eq!(spaced, inline); + assert_eq!(spaced.screen, Screen::Settings); + assert_eq!(spaced.width, 900.0); + assert!(parse(&["--screen=gallery"]).is_err()); + assert!(parse(&["--width=abc"]).is_err()); + } + + #[test] + fn screenshot_theme_wins_over_theme() { + let request = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap(); + assert_eq!(request.flavor, SchemeFlavor::Light); + assert_eq!(parse(&["--theme=dark"]).unwrap().flavor, SchemeFlavor::Dark); + } + + #[test] + fn software_renderer_is_an_alias_of_cpu() { + assert_eq!( + parse(&["--software-renderer"]).unwrap().backend, + Backend::Cpu + ); + assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu); + assert_eq!(parse(&["--renderer=gpu"]).unwrap().backend, Backend::Gpu); + } + + #[test] + fn slug_carries_screen_class_and_flavor() { + let request = parse(&[ + "--screen=instances", + "--width=700", + "--screenshot-theme=dark", + ]) + .unwrap(); + assert_eq!(request.slug(), "instances-medium-dark-w700"); + } + + #[test] + fn tiny_windows_are_rejected() { + assert!(parse(&["--width=200"]).is_err()); + assert!(parse(&["--height=100"]).is_err()); + } +} diff --git a/crates/app-iced/src/pages/instances.rs b/crates/app-iced/src/pages/instances.rs new file mode 100644 index 0000000..20070b7 --- /dev/null +++ b/crates/app-iced/src/pages/instances.rs @@ -0,0 +1,342 @@ +//! 实例页:**list-detail** 的唯一用例。 +//! +//! ## 版式(由 [`super::layout`] 决议,页面不自己算宽度) +//! +//! | 版面 | 结构 | 退化行为 | +//! |---|---|---| +//! | `ListDetail`(窗口 ≥840) | 左列 336 固定、**独立滚动**;右列详情,正文列 ≤504 | — | +//! | `SingleColumn`(<840) | 列表与详情**整页切换**:本页只渲染详情列(≤504,居中) | 顶部给「Back to instances」,列表退成上一屏 | +//! +//! 为什么窄窗不是「上下堆」:官方 compact 下 list-detail 的口径是一次只显示一个 pane(列表 → 详情, +//! 整页切换)。上下堆会把详情挤到折叠线以下,等于详情不存在。 +//! +//! ## 判据 +//! +//! 列表行 `Component::ListItem`(BodyLarge / BodySmall,圆角 `Small`),行高 = `row_height + Space::X3` +//! = 56(两行行文;`row_height` 44 是单行列表的节奏)。选中行 `SecondaryContainer` + full 圆角(M3 列表)。 + +mod data; + +#[cfg(test)] +mod tests; + +pub(super) use data::SELECTED; +use data::{Entry, DEMO, NOTE}; + +use iced::widget::{column, container, row, scrollable}; +use iced::{Alignment, Background, Border, Element, Length, Padding}; + +use unlucky_design::color::Role; +use unlucky_design::component_map::Component; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use crate::theme::Ui; + +use super::frame::{self, Header}; +use super::layout::{self, FrameMetrics, PageLayout}; +use super::{Action, Message}; + +/// 整页(`selected = None` 时详情区显示空态)。 +pub fn view<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + selected: Option, +) -> Element<'a, Message> { + let header = Header::new("Instances") + .subtitle("Local versions and their mod loaders") + .action(frame::text_action(ui, "New instance", Action::NewInstance)) + .action(frame::text_action(ui, "Import", Action::Import)); + + let body = match metrics.layout { + PageLayout::ListDetail => { + let detail = match selected { + Some(_) => detail(ui, metrics), + None => detail_empty(ui), + }; + frame::panes(metrics, list_column(ui, selected), detail) + } + _ => match selected { + Some(_) => narrow_detail(ui, metrics), + None => frame::single_column(metrics, detail_empty(ui)), + }, + }; + + frame::page(ui, metrics, header, body) +} + +// --------------------------------------------------------------- 列表列 + +fn list_column<'a>(ui: &'a Ui, selected: Option) -> Element<'a, Message> { + let head = container( + row![ + container(frame::text_role( + ui, + TypeRole::LabelMedium, + false, + "ALL INSTANCES", + ui.color(Role::OnSurfaceVariant), + )) + .width(Length::Fill) + .align_y(Alignment::Center), + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + "12", + ui.color(Role::OnSurfaceVariant), + ), + ] + .width(Length::Fill) + .align_y(Alignment::Center), + ) + .width(Length::Fill) + .padding(Padding { + top: ui.space(Space::X3), + right: ui.space(Space::X3), + bottom: ui.space(Space::X1), + left: ui.space(Space::X3), + }); + + let rows = column( + DEMO.iter() + .enumerate() + .map(|(index, entry)| list_row(ui, entry, selected == Some(index))) + .collect::>(), + ) + .spacing(ui.space(Space::X1)) + .padding(Padding::from([0.0, ui.space(Space::X2)])) + .width(Length::Fill); + + // **独立滚动**:只有列表列这一条 scrollable,详情列不跟着滚。 + let scroller = scrollable(rows) + .height(Length::Fill) + .style(frame::scroll_style(ui)); + + container(column![head, scroller].height(Length::Fill)) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Card))), + border: Border { + radius: ui.radius_dp(ShapeScale::Large), + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + +fn list_row<'a>(ui: &'a Ui, entry: &'a Entry, selected: bool) -> Element<'a, Message> { + let spec = Component::ListItem.spec(); + let height = ui.metrics().row_height + Space::X3.dp(); + + let name_color = if selected { + ui.color(Role::OnSecondaryContainer) + } else { + ui.color(Role::OnSurface) + }; + let support_color = if selected { + ui.color(Role::OnSecondaryContainer) + } else { + ui.color(Role::OnSurfaceVariant) + }; + + let texts = column![ + frame::text_role(ui, spec.text, false, entry.name, name_color), + frame::text_role( + ui, + spec.support.unwrap_or(TypeRole::BodySmall), + false, + entry.loader, + support_color, + ), + ] + .spacing(0.0) + .width(Length::Fill); + + let inner = row![ + frame::glyph_box(IconKind::Instances, support_color, frame::ICON_PX), + texts, + frame::glyph(IconKind::ChevronRight, support_color, frame::ICON_SMALL_PX), + ] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .align_y(Alignment::Center); + + container(inner) + .width(Length::Fill) + .height(height) + .padding(Padding::from([0.0, ui.space(Space::X2)])) + .align_y(Alignment::Center) + .style(move |_theme| container::Style { + background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))), + border: Border { + radius: ui.radius(ShapeScale::Full, height), + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + +// --------------------------------------------------------------- 详情列 + +fn detail<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> { + let entry = &DEMO[SELECTED]; + + frame::left_aligned( + metrics.column, + column![ + detail_head(ui, entry), + meta_grid(ui, metrics, entry), + frame::divider(ui), + body_paragraph(ui), + frame::divider(ui), + action_row(ui), + ] + .spacing(ui.space(Space::X4)) + .width(Length::Fill), + ) +} + +fn narrow_detail<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> { + // 窄窗:列表退成上一屏,本屏只放详情(官方 compact 的 list-detail 口径)。 + let entry = &DEMO[SELECTED]; + + frame::single_column( + metrics, + column![ + row![frame::text_action(ui, "Back to instances", Action::Back)] + .width(Length::Fill) + .align_y(Alignment::Center), + detail_head(ui, entry), + meta_grid(ui, metrics, entry), + frame::divider(ui), + body_paragraph(ui), + frame::divider(ui), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + "Below 840 px the list and the detail share one column, so opening an instance replaces \ + this screen (full-page switch). Widen the window to see both panes again.", + ui.color(Role::OnSurfaceVariant), + ), + action_row(ui), + ] + .spacing(ui.space(Space::X4)) + .width(Length::Fill), + ) +} + +fn detail_head<'a>(ui: &'a Ui, entry: &'a Entry) -> Element<'a, Message> { + let titles = column![ + frame::text_role( + ui, + TypeRole::TitleLarge, + true, + entry.name, + ui.color(Role::OnSurface) + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + entry.version, + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + row![ + titles, + frame::text_action(ui, "Play", Action::Play), + frame::text_action(ui, "More", Action::More), + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .align_y(Alignment::Center) + .into() +} + +/// 元信息网格:列数由 [`layout::meta_columns`] 决定(1180 → 3 列,900 → 2 列)。 +fn meta_grid<'a>(ui: &'a Ui, metrics: FrameMetrics, entry: &'a Entry) -> Element<'a, Message> { + let cells = [ + ("VERSION", entry.version), + ("LOADER", entry.loader), + ("MEMORY", entry.memory), + ("JAVA", entry.java), + ("MODS", entry.mods), + ("LAST PLAYED", entry.last_played), + ]; + + let mut grid = column![].spacing(layout::META_GAP).width(Length::Fill); + let columns = metrics.meta_columns.clamp(1, cells.len()); + + for chunk in cells.chunks(columns) { + let mut cells_row = row![].spacing(layout::META_GAP).width(Length::Fill); + for (label, value) in chunk { + let cell = frame::card( + ui, + column![ + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + label, + ui.color(Role::OnSurfaceVariant), + ), + frame::text_role( + ui, + TypeRole::BodyMedium, + false, + value, + ui.color(Role::OnSurface) + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ); + cells_row = cells_row.push(container(cell).width(Length::Fill)); + } + grid = grid.push(cells_row); + } + + grid.into() +} + +fn body_paragraph<'a>(ui: &'a Ui) -> Element<'a, Message> { + frame::text_role( + ui, + TypeRole::BodyMedium, + false, + NOTE, + ui.color(Role::OnSurfaceVariant), + ) +} + +fn action_row<'a>(ui: &'a Ui) -> Element<'a, Message> { + row![ + frame::text_action(ui, "Duplicate", Action::Duplicate), + frame::text_action(ui, "Open folder", Action::OpenFolder), + frame::text_action(ui, "Delete", Action::Delete), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center) + .into() +} + +fn detail_empty<'a>(ui: &'a Ui) -> Element<'a, Message> { + frame::empty_state( + ui, + IconKind::Instances, + "No instance selected", + "Pick an instance on the left to see its version, loader and launch options.", + Vec::new(), + ) +} diff --git a/crates/app-iced/src/pages/instances/data.rs b/crates/app-iced/src/pages/instances/data.rs new file mode 100644 index 0000000..d0c8c55 --- /dev/null +++ b/crates/app-iced/src/pages/instances/data.rs @@ -0,0 +1,136 @@ +//! 实例页的**演示数据**(骨架期没有业务状态机,也没有业务数据)。 +//! +//! 12 条,**故意比可视区高**:列表列的独立滚动条要能在截图里看见(单测钉住这条)。 +//! 文案是英文基线:i18n 是后续 mission,干净 CI 上没有中文字体,写中文会变豆腐块。 + +/// 演示条目(骨架期没有业务数据;文案是英文基线,i18n 是后续 mission)。 +pub(crate) struct Entry { + pub(crate) name: &'static str, + pub(crate) version: &'static str, + pub(crate) loader: &'static str, + pub(crate) memory: &'static str, + pub(crate) java: &'static str, + pub(crate) mods: &'static str, + pub(crate) last_played: &'static str, +} + +/// 12 条演示数据:**比可视区高**,这样列表列的独立滚动条在截图里能看见。 +pub(crate) const DEMO: [Entry; 12] = [ + Entry { + name: "Fabulous World", + version: "1.20.4", + loader: "Fabric 0.15.7", + memory: "4 GiB", + java: "Temurin 21", + mods: "148 mods", + last_played: "2 hours ago", + }, + Entry { + name: "Skyblock Origins", + version: "1.21.1", + loader: "NeoForge 21.1.72", + memory: "6 GiB", + java: "Temurin 21", + mods: "86 mods", + last_played: "yesterday", + }, + Entry { + name: "Vanilla Survival", + version: "1.21.4", + loader: "Vanilla", + memory: "2 GiB", + java: "Temurin 21", + mods: "0 mods", + last_played: "3 days ago", + }, + Entry { + name: "Create: Above and Beyond", + version: "1.18.2", + loader: "Forge 40.2.0", + memory: "8 GiB", + java: "Temurin 17", + mods: "312 mods", + last_played: "last week", + }, + Entry { + name: "All the Mods 9", + version: "1.20.1", + loader: "NeoForge 20.4.237", + memory: "8 GiB", + java: "Temurin 17", + mods: "412 mods", + last_played: "2 weeks ago", + }, + Entry { + name: "Better MC", + version: "1.20.1", + loader: "Fabric 0.14.24", + memory: "6 GiB", + java: "Temurin 17", + mods: "264 mods", + last_played: "last month", + }, + Entry { + name: "RLCraft", + version: "1.12.2", + loader: "Forge 14.23.5", + memory: "4 GiB", + java: "Zulu 8", + mods: "184 mods", + last_played: "2 months ago", + }, + Entry { + name: "Origins SMP", + version: "1.20.4", + loader: "Quilt 0.24.0", + memory: "4 GiB", + java: "Temurin 21", + mods: "57 mods", + last_played: "3 months ago", + }, + Entry { + name: "Redstone Lab", + version: "1.19.4", + loader: "Fabric 0.15.2", + memory: "3 GiB", + java: "Temurin 17", + mods: "34 mods", + last_played: "April 12", + }, + Entry { + name: "Pixelmon Reforged", + version: "1.16.5", + loader: "Forge 36.2.34", + memory: "6 GiB", + java: "Temurin 8", + mods: "121 mods", + last_played: "March 3", + }, + Entry { + name: "Techopolis 2", + version: "1.20.1", + loader: "Forge 47.2.20", + memory: "8 GiB", + java: "Temurin 17", + mods: "198 mods", + last_played: "January 28", + }, + Entry { + name: "Cobblemon", + version: "1.21.1", + loader: "Fabric 0.16.9", + memory: "5 GiB", + java: "Temurin 21", + mods: "74 mods", + last_played: "January 9", + }, +]; + +/// 演示选中的条目(第一条)。 +pub(crate) const SELECTED: usize = 0; + +/// 详情正文(**故意写长**:这是行宽上限的视觉证据——1180 宽窗口下也只断在 ~72 字符)。 +pub(crate) const NOTE: &str = "This instance keeps its own mods, configs and saves. The launcher resolves the version chain \ + (instance → mod loader → vanilla) at launch time, verifies every jar it downloaded against the mirror \ + manifest, and streams progress back as structured events so the UI never has to guess. Nothing on this \ + page is wired to real data yet: the layout is, the numbers are."; diff --git a/crates/app-iced/src/pages/instances/tests.rs b/crates/app-iced/src/pages/instances/tests.rs new file mode 100644 index 0000000..76c6015 --- /dev/null +++ b/crates/app-iced/src/pages/instances/tests.rs @@ -0,0 +1,49 @@ +//! `instances` 的断言:演示数据与窄窗退化。 +use super::*; + +use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass}; + +#[test] +fn demo_rows_overflow_the_visible_list() { + // 列表列要能滚动:条目数必须让内容高过可视区(760 窗高 − 标题栏 48 − 页头 84 − 页边距) + let visible = 760.0 - 48.0 - 84.0 - 2.0 * WidthClass::Expanded.gutter(); + let row_height = 44.0 + Space::X3.dp(); + let content = DEMO.len() as f32 * row_height; + assert!( + content > visible, + "列表内容 {content} 不比可视区 {visible} 高,截图里看不到独立滚动条" + ); +} + +#[test] +fn every_demo_entry_has_name_version_and_loader() { + for entry in &DEMO { + assert!(!entry.name.is_empty()); + assert!(!entry.version.is_empty()); + assert!(!entry.loader.is_empty()); + } + assert!(SELECTED < DEMO.len()); +} + +#[test] +fn detail_paragraph_is_long_enough_to_wrap() { + // 行宽上限 ≈ 72 字符/行;写够几行才看得出断行位置 + assert!(NOTE.len() > 200, "正文档太短,看不出行宽上限"); +} + +#[test] +fn narrow_widths_switch_the_page_to_one_column() { + // 1180 / 900 → 两列;700 → 单列(整页切换) + assert_eq!( + frame_metrics(PaneKit::ListDetail, 1180.0).layout, + PageLayout::ListDetail + ); + assert_eq!( + frame_metrics(PaneKit::ListDetail, 700.0).layout, + PageLayout::SingleColumn + ); + // 窄窗下详情不吃满窗口:正文列仍 ≤ 行宽上限 + let narrow = frame_metrics(PaneKit::ListDetail, 700.0); + assert!(narrow.column <= measure_cap_px() + f32::EPSILON); + assert!(narrow.secondary.is_none()); +} diff --git a/crates/app-iced/src/pages/layout.rs b/crates/app-iced/src/pages/layout.rs new file mode 100644 index 0000000..3a65e0d --- /dev/null +++ b/crates/app-iced/src/pages/layout.rs @@ -0,0 +1,310 @@ +//! 页面布局系统:**窗口宽度档位 → 版面 → 列宽**。纯函数,不碰 iced 控件树。 +//! +//! 这是本 mission 的核心交付物,四个页面只是它的用例。`layout.rs` 是页面级几何的 +//! **唯一定义点**:页面文件里不许再出现列宽字面量。 +//! +//! ## 1. 宽度档位(官方响应式断点 600 / 840) +//! +//! 档位定义照官方 M3「Window size classes」(A 级:m3.material.io 的 app-quality/window-size-classes, +//! Compose `WindowSizeClass` 的同一组硬值):**600dp 与 840dp** 是两个分界。 +//! +//! | 档位 | 窗口宽度(逻辑 px @100% DPI) | 页边距(gutter) | +//! |---|---|---| +//! | `Compact` | `< 600` | 16(`Space::X4`) | +//! | `Medium` | `600 ≤ w < 840` | 24(`Space::X6`) | +//! | `Expanded` | `≥ 840` | 24(`Space::X6`) | +//! +//! 页边距取 16/24 而不是 16/24/32:官方 expanded 页边距也是 24,写 32 会白白多一条偏离登记。 +//! +//! > 本 crate 的真实窗口下限是 880(`app.rs` 的 `MIN_SIZE`),所以日常只有 `Expanded`; +//! > 600 以下的档位是给「将来允许更窄窗口 / 内嵌面板」准备的,也让布局规则**可测**。 +//! +//! ## 2. 三种版面与「什么宽度用哪种」 +//! +//! | 版面 | 含义 | 页面上用它 | 成立条件 | 退化到 | +//! |---|---|---|---|---| +//! | `SingleColumn` | 单列,列宽 ≤ 行宽上限 | 下载 / 日志(全部档位);实例 / 设置(窄窗) | 总是成立 | — | +//! | `SupportingPane` | 主列 + 窄侧列(侧列是辅助,主列是阅读对象) | 设置(分组导航 240 + 表单列) | `w ≥ 600` 且 `w ≥ 720` | `SingleColumn` | +//! | `ListDetail` | 固定列表列(320~360,**独立滚动**)+ 详情列 | 实例 | `w ≥ 840` 且 `w ≥ 816` | `SingleColumn` | +//! +//! 两个门槛都是**算出来的**,不是拍的(见 [`panes_min_window`]): +//! +//! - 设置:`240(drawer)+ 24(间隙)+ 320(表单列下限)+ 2×24(gutter)+ 88(rail)= 720`; +//! - 实例:`336(列表列)+ 24(间隙)+ 320(详情列下限)+ 2×24(gutter)+ 88(rail)= 816`。 +//! +//! 实例的几何门槛 816 低于官方 expanded 断点 840 —— 所以**档位是更严的那一条**, +//! 单测 `expanded_is_wide_enough_for_list_detail` 把这条「余量 24px」钉死。 +//! +//! ## 3. 行宽约束(历史事故:M68 的全宽单卡) +//! +//! M68 骨架的内容区是**一张全宽卡**:1180 窗宽 − 88 rail − 2×24 留白 = **712px**。 +//! 按当前字体栈(`BodyMedium` 14px,西文平均推进 0.5em ≈ 7px/字符)算是 **≈118 字符/行**, +//! 远超舒适行宽。视觉复审的判词因此是「**不能把全宽单卡当页面模板**,官方桌面口径是 +//! supporting-pane / list-detail」。 +//! +//! 所以正文列宽有**硬上限** [`measure_cap_px`] = 14 × 0.5 × 72 = **504px ≈ 72 字符**, +//! 由 [`content_column_width`] 与 [`frame_metrics`] 无条件施加:任何窗口宽度、任何版面, +//! 正文列都不会超过它。1180 宽窗口下本 mission 的正文列是 504(不是 712)。 + +use unlucky_design::spacing::{LayoutMetrics, Space}; +use unlucky_design::typography::TypeRole; + +/// 官方 M3 窗口档的 medium 断点(600dp)。 +pub const BREAKPOINT_MEDIUM: f32 = 600.0; +/// 官方 M3 窗口档的 expanded 断点(840dp)。 +pub const BREAKPOINT_EXPANDED: f32 = 840.0; + +/// 列表列宽(list-detail 左列)。任务区间 320~360,取 4px 栅格内的 **336**: +/// 落在区间中段,既放得下「名字 + 版本 + 尾随图标」,又给详情列多留 16px。 +pub const LIST_COLUMN_WIDTH: f32 = 336.0; + +/// 详情列下限:再窄就不再分栏(约 45 字符 × 7px ≈ 315,取 4px 栅格的 320)。 +pub const DETAIL_MIN_WIDTH: f32 = 320.0; + +/// 侧列宽(设置页分组导航)= D33 的 drawer 宽 240。 +/// +/// 复用 rail 的**尺寸语义**(88 ⇄ 240 同一容器的两种形态),但**不复制 rail 的实现**: +/// 分组导航是页内元素,没有 hover 药丸、没有二级标签。 +pub const SIDE_NAV_WIDTH: f32 = LayoutMetrics::DESKTOP.nav_drawer_width; + +/// 表单列下限(supporting-pane 主列):与详情列下限同源(320 ≈ 45 字符)。 +pub const FORM_MIN_WIDTH: f32 = DETAIL_MIN_WIDTH; + +/// 两列之间的间隙(`Space::X6` = 24)。 +pub const PANES_GAP: f32 = Space::X6.dp(); + +/// 详情里元信息网格的**单卡最小宽度**(三列时 3×160 + 2×12 = 504,正好等于行宽上限)。 +pub const META_CARD_MIN_WIDTH: f32 = 160.0; +/// 元信息网格卡片间距(`Space::X3` = 12)。 +pub const META_GAP: f32 = Space::X3.dp(); + +/// 西文每字符的平均推进(em)。`[估]`:Segoe UI / Inter 混排在 0.45~0.55em 之间,取中值 0.5。 +/// +/// 这是本文件唯一一个「按字体栈换算」的经验系数;换字体栈要重新量(不是令牌)。 +pub const CHAR_ADVANCE_EM: f32 = 0.5; +/// 舒适行宽的字符数上限(任务建议 60~72,取上界 72)。 +pub const MAX_MEASURE_CHARS: f32 = 72.0; +/// 行宽下限的字符数(再窄正文就碎成短行,45 是最低可读长度)。 +pub const MIN_MEASURE_CHARS: f32 = 45.0; + +/// 导航轨宽度(与 `shell::nav` 同一令牌值;布局系统只读它,不重定义)。 +pub const fn rail_width() -> f32 { + LayoutMetrics::DESKTOP.nav_rail_width +} + +/// 正文列宽上限(px):`BodyMedium` 14px × 0.5em × 72 字符 = **504**。 +/// +/// 硬上限,任何版面 / 任何窗口宽度都不许超;`1180` 窗宽下正文列就是它(而非 M68 的 712)。 +pub fn measure_cap_px() -> f32 { + TypeRole::BodyMedium.style().size_px * CHAR_ADVANCE_EM * MAX_MEASURE_CHARS +} + +/// 正文列宽下限(px):14 × 0.5 × 45 = 315。 +pub fn measure_floor_px() -> f32 { + TypeRole::BodyMedium.style().size_px * CHAR_ADVANCE_EM * MIN_MEASURE_CHARS +} + +/// 窗口宽度档位(官方 600 / 840 两个断点)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WidthClass { + /// `< 600` + Compact, + /// `600 ≤ w < 840` + Medium, + /// `≥ 840` + Expanded, +} + +impl WidthClass { + /// 按**窗口宽度**(含导航轨,逻辑 px)判档。 + pub fn of(window_width: f32) -> Self { + if window_width < BREAKPOINT_MEDIUM { + Self::Compact + } else if window_width < BREAKPOINT_EXPANDED { + Self::Medium + } else { + Self::Expanded + } + } + + /// 稳定名字(日志/测试/文件名用)。 + pub const fn name(self) -> &'static str { + match self { + Self::Compact => "compact", + Self::Medium => "medium", + Self::Expanded => "expanded", + } + } + + /// 页边距:compact 16 / medium 24 / expanded 24(官方 expanded 也是 24)。 + pub const fn gutter(self) -> f32 { + match self { + Self::Compact => Space::X4.dp(), + Self::Medium | Self::Expanded => Space::X6.dp(), + } + } +} + +/// 三种版面。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PageLayout { + /// 单列(列宽 ≤ 行宽上限,水平居中)。 + SingleColumn, + /// 主列 + 辅助侧列。 + SupportingPane, + /// 固定列表列 + 详情列。 + ListDetail, +} + +impl PageLayout { + /// 稳定名字。 + pub const fn name(self) -> &'static str { + match self { + Self::SingleColumn => "single-column", + Self::SupportingPane => "supporting-pane", + Self::ListDetail => "list-detail", + } + } + + /// 是否有两个列(侧列 + 主列)。 + pub const fn is_two_pane(self) -> bool { + !matches!(self, Self::SingleColumn) + } +} + +/// 页面声明的「最宽版面」——布局系统按窗口宽度降级。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaneKit { + /// 单列页(下载 / 日志)。 + Single, + /// 主列 + 侧列(设置)。 + Supporting, + /// 列表 + 详情(实例)。 + ListDetail, +} + +/// 两列版面成立所需的最小窗口宽度(**公式**,不是拍值): +/// `Σ列宽 + 间隙 + 2×gutter + rail`。 +pub fn panes_min_window(first: f32, second: f32) -> f32 { + first + second + PANES_GAP + 2.0 * WidthClass::Expanded.gutter() + rail_width() +} + +/// `ListDetail` 的几何门槛:336 + 320 + 24 + 48 + 88 = **816**。 +pub fn list_detail_min_window() -> f32 { + panes_min_window(LIST_COLUMN_WIDTH, DETAIL_MIN_WIDTH) +} + +/// `SupportingPane` 的几何门槛:240 + 320 + 24 + 48 + 88 = **720**。 +pub fn supporting_pane_min_window() -> f32 { + panes_min_window(SIDE_NAV_WIDTH, FORM_MIN_WIDTH) +} + +/// 版面决议:档位与几何门槛**同时**满足才升到两列,否则退化单列。 +pub fn resolve(kit: PaneKit, window_width: f32) -> PageLayout { + let class = WidthClass::of(window_width); + let wide_enough = match kit { + PaneKit::Single => false, + PaneKit::Supporting => { + class != WidthClass::Compact && window_width >= supporting_pane_min_window() + } + PaneKit::ListDetail => { + class == WidthClass::Expanded && window_width >= list_detail_min_window() + } + }; + + match (kit, wide_enough) { + (PaneKit::Single, _) | (_, false) => PageLayout::SingleColumn, + (PaneKit::Supporting, true) => PageLayout::SupportingPane, + (PaneKit::ListDetail, true) => PageLayout::ListDetail, + } +} + +/// 页面可用宽度:窗口 − 导航轨 − 两侧页边距(下限 0)。 +pub fn available_width(window_width: f32) -> f32 { + let class = WidthClass::of(window_width); + (window_width - rail_width() - 2.0 * class.gutter()).max(0.0) +} + +/// **给定窗口宽度 → 内容(正文)列宽**。本 mission 的「行宽约束」入口。 +/// +/// - 单列页:就是正文列宽(水平居中摆放); +/// - 两列页:这是**主列**的正文列宽;实际值还要看 [`frame_metrics`](详情列会被列表列挤窄)。 +/// +/// 返回值永远 ≤ [`measure_cap_px`]:1180 窗宽下是 504(≈72 字符),不是 712(≈118 字符)。 +pub fn content_column_width(window_width: f32) -> f32 { + available_width(window_width).min(measure_cap_px()) +} + +/// 元信息网格的列数:3 列需要 504(= 行宽上限),2 列需要 332,否则 1 列。 +pub fn meta_columns(width: f32) -> usize { + for columns in [3usize, 2] { + let needed = columns as f32 * META_CARD_MIN_WIDTH + (columns - 1) as f32 * META_GAP; + if width >= needed { + return columns; + } + } + 1 +} + +/// 一屏的版面几何(页面直接消费,不再自己算列宽)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FrameMetrics { + /// 窗口宽度(输入)。 + pub window_width: f32, + /// 宽度档位。 + pub class: WidthClass, + /// 决议出来的版面。 + pub layout: PageLayout, + /// 页边距。 + pub gutter: f32, + /// 页面可用宽度(已扣导航轨与页边距)。 + pub available: f32, + /// 正文列宽(≤ 行宽上限)。两列版面时是**主列**的正文列宽。 + pub column: f32, + /// 第二列(list-detail 的列表列 / supporting-pane 的侧列)。 + pub secondary: Option, + /// 主列所在 pane 的宽度(单列 = 正文列宽)。 + pub main_pane: f32, + /// 详情元信息网格列数。 + pub meta_columns: usize, +} + +/// 一次算齐版面几何。 +pub fn frame_metrics(kit: PaneKit, window_width: f32) -> FrameMetrics { + let class = WidthClass::of(window_width); + let gutter = class.gutter(); + let available = available_width(window_width); + let layout = resolve(kit, window_width); + + let (column, secondary, main_pane) = match layout { + PageLayout::SingleColumn => { + let column = available.min(measure_cap_px()); + (column, None, column) + } + PageLayout::ListDetail => { + let list = LIST_COLUMN_WIDTH.min(available); + let pane = (available - list - PANES_GAP).max(0.0); + (pane.min(measure_cap_px()), Some(list), pane) + } + PageLayout::SupportingPane => { + let side = SIDE_NAV_WIDTH.min(available); + let pane = (available - side - PANES_GAP).max(0.0); + (pane.min(measure_cap_px()), Some(side), pane) + } + }; + + FrameMetrics { + window_width, + class, + layout, + gutter, + available, + column, + secondary, + main_pane, + meta_columns: meta_columns(column), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/pages/layout/tests.rs b/crates/app-iced/src/pages/layout/tests.rs new file mode 100644 index 0000000..51b1a5c --- /dev/null +++ b/crates/app-iced/src/pages/layout/tests.rs @@ -0,0 +1,280 @@ +//! `layout` 的断言:断点表、版面决议、行宽上限、列宽几何。 +//! +//! 这些是**门禁级**断言:阈值改错必须立刻红(tower 会故意改错来验)。 +use super::*; + +/// 断点表:官方 600 / 840 两个硬值,边界闭开区间逐点钉死。 +#[test] +fn breakpoints_are_the_official_600_and_840() { + assert_eq!(BREAKPOINT_MEDIUM, 600.0, "官方 medium 断点"); + assert_eq!(BREAKPOINT_EXPANDED, 840.0, "官方 expanded 断点"); + + for (width, expected) in [ + (320.0, WidthClass::Compact), + (599.0, WidthClass::Compact), + (599.9, WidthClass::Compact), + (600.0, WidthClass::Medium), + (720.0, WidthClass::Medium), + (839.0, WidthClass::Medium), + (839.9, WidthClass::Medium), + (840.0, WidthClass::Expanded), + (900.0, WidthClass::Expanded), + (1180.0, WidthClass::Expanded), + (2560.0, WidthClass::Expanded), + ] { + assert_eq!( + WidthClass::of(width), + expected, + "{width} 宽应该判为 {expected:?}" + ); + } +} + +/// 版面决议表(tower 会照这张表对文档):一列写死「宽度 → 版面」。 +#[test] +fn layout_table_matches_the_documented_thresholds() { + // (窗口宽度, 实例页, 设置页, 下载/日志页) + let table = [ + ( + 320.0, + PageLayout::SingleColumn, + PageLayout::SingleColumn, + PageLayout::SingleColumn, + ), + ( + 599.0, + PageLayout::SingleColumn, + PageLayout::SingleColumn, + PageLayout::SingleColumn, + ), + ( + 700.0, + PageLayout::SingleColumn, + PageLayout::SingleColumn, + PageLayout::SingleColumn, + ), + ( + 720.0, + PageLayout::SingleColumn, + PageLayout::SupportingPane, + PageLayout::SingleColumn, + ), + ( + 800.0, + PageLayout::SingleColumn, + PageLayout::SupportingPane, + PageLayout::SingleColumn, + ), + ( + 839.0, + PageLayout::SingleColumn, + PageLayout::SupportingPane, + PageLayout::SingleColumn, + ), + ( + 840.0, + PageLayout::ListDetail, + PageLayout::SupportingPane, + PageLayout::SingleColumn, + ), + ( + 900.0, + PageLayout::ListDetail, + PageLayout::SupportingPane, + PageLayout::SingleColumn, + ), + ( + 1180.0, + PageLayout::ListDetail, + PageLayout::SupportingPane, + PageLayout::SingleColumn, + ), + ( + 1920.0, + PageLayout::ListDetail, + PageLayout::SupportingPane, + PageLayout::SingleColumn, + ), + ]; + + for (width, instances, settings, single) in table { + assert_eq!( + resolve(PaneKit::ListDetail, width), + instances, + "{width} 宽实例页版面不对" + ); + assert_eq!( + resolve(PaneKit::Supporting, width), + settings, + "{width} 宽设置页版面不对" + ); + assert_eq!( + resolve(PaneKit::Single, width), + single, + "{width} 宽单列页版面对不" + ); + } +} + +#[test] +fn pane_thresholds_are_the_sum_of_their_columns() { + // 336 + 320 + 24 + 2×24 + 88 = 816 + assert_eq!(list_detail_min_window(), 816.0); + // 240 + 320 + 24 + 2×24 + 88 = 720 + assert_eq!(supporting_pane_min_window(), 720.0); + // 官方 expanded 断点必须**宽于** list-detail 的几何门槛,否则「840 起用 list-detail」是空话 + assert!( + BREAKPOINT_EXPANDED >= list_detail_min_window(), + "840 档必须放得下 336 + 24 + 320 的三列和" + ); + // 设置页在 medium 档里就有能用的窗口宽度(720 < 840) + assert!(supporting_pane_min_window() < BREAKPOINT_EXPANDED); + assert!(WidthClass::of(supporting_pane_min_window()) == WidthClass::Medium); +} + +/// 行宽上限:M68 的事故(1180 宽正文 712px ≈ 118 字符)不许再出现。 +#[test] +fn line_length_is_capped_at_72_characters() { + let char_px = TypeRole::BodyMedium.style().size_px * CHAR_ADVANCE_EM; + assert_eq!(measure_cap_px(), 504.0, "14 × 0.5 × 72"); + assert_eq!(measure_floor_px(), 315.0, "14 × 0.5 × 45"); + + for width in [600.0, 840.0, 900.0, 1180.0, 1440.0, 2560.0] { + let column = content_column_width(width); + let chars = column / char_px; + assert!( + chars <= MAX_MEASURE_CHARS + 0.001, + "{width} 宽窗口的正文行宽 {chars:.1} 字符,超过 {MAX_MEASURE_CHARS} 上限" + ); + assert!(column <= measure_cap_px() + f32::EPSILON); + } + + // 1180 —— M68 就是在这条上翻的车:当年 712px(118 字符),现在必须是 504(72 字符) + let wide = content_column_width(1180.0); + assert!( + wide <= 72.0 * char_px, + "1180 宽下正文列 {wide}px 超过 72 字符换算值" + ); + assert!(wide < 712.0, "不许回到 M68 的全宽单卡(712px)"); +} + +#[test] +fn line_length_tracks_width_until_it_hits_the_cap() { + // 窄窗:正文列跟着可用宽度走,但永远不低于「下限以下就不该再压缩」 + let narrow = content_column_width(600.0); + assert!(narrow < measure_cap_px(), "600 宽时正文列应窄于上限"); + assert_eq!(narrow, available_width(600.0)); + + // 单调不减:窗口变宽,正文列不变窄 + let mut previous = 0.0; + for width in [320.0, 480.0, 600.0, 720.0, 840.0, 1024.0, 1180.0, 1600.0] { + let column = content_column_width(width); + assert!(column >= previous, "{width} 宽的正文列比更窄时还小"); + previous = column; + } + + // 一旦撞上上限就锁死(宽窗不会继续变宽) + assert_eq!(content_column_width(1180.0), measure_cap_px()); + assert_eq!(content_column_width(1920.0), measure_cap_px()); +} + +#[test] +fn list_detail_geometry_keeps_the_list_column_in_range() { + let metrics = frame_metrics(PaneKit::ListDetail, 1180.0); + assert_eq!(metrics.layout, PageLayout::ListDetail); + + let list = metrics.secondary.expect("list-detail 必须有列表列"); + assert!( + (320.0..=360.0).contains(&list), + "列表列 {list} 不在 320~360 参考区间" + ); + assert_eq!(list, LIST_COLUMN_WIDTH); + + // 详情正文列:≤ 行宽上限,且明显窄于「窗口全宽」 + assert!(metrics.column <= measure_cap_px() + f32::EPSILON); + assert!(metrics.column < metrics.available, "详情列必须窄于可用宽度"); + + // 列表列 + 间隙 + 详情 pane = 可用宽度(不留缝) + assert_eq!( + list + PANES_GAP + metrics.main_pane, + metrics.available, + "两列几何必须铺满可用宽度" + ); +} + +#[test] +fn list_detail_degrades_to_one_column_below_the_threshold() { + for width in [320.0, 599.0, 700.0, 816.0, 839.0] { + assert_eq!( + frame_metrics(PaneKit::ListDetail, width).layout, + PageLayout::SingleColumn, + "{width} 宽应该退化成单列" + ); + assert!(frame_metrics(PaneKit::ListDetail, width) + .secondary + .is_none()); + } + + // 840 = 官方 expanded 断点,list-detail 从这里成立 + assert_eq!( + frame_metrics(PaneKit::ListDetail, 840.0).layout, + PageLayout::ListDetail + ); + // 840 宽下详情正文列仍要够宽(≥ 下限),否则等于假分栏 + let at_breakpoint = frame_metrics(PaneKit::ListDetail, 840.0); + assert!( + at_breakpoint.column >= measure_floor_px(), + "840 宽下详情列 {:?} 低于行宽下限", + at_breakpoint.column + ); +} + +#[test] +fn settings_pane_keeps_the_drawer_width_and_a_readable_form_column() { + let metrics = frame_metrics(PaneKit::Supporting, 1180.0); + assert_eq!(metrics.layout, PageLayout::SupportingPane); + assert_eq!(metrics.secondary, Some(SIDE_NAV_WIDTH)); + assert_eq!(SIDE_NAV_WIDTH, 240.0, "D33:展开态是 240 的 drawer"); + assert_eq!(metrics.column, measure_cap_px(), "表单列吃满行宽上限"); + + let narrow = frame_metrics(PaneKit::Supporting, 700.0); + assert_eq!(narrow.layout, PageLayout::SingleColumn); + assert!(narrow.secondary.is_none()); +} + +#[test] +fn meta_grid_drops_columns_as_the_detail_narrows() { + assert_eq!(meta_columns(measure_cap_px()), 3, "504 = 3×160 + 2×12"); + assert_eq!(meta_columns(340.0), 2); + assert_eq!(meta_columns(300.0), 1); + + // 与三张对照图的详情列宽对应:1180 → 3 列,900 → 2 列 + let wide = frame_metrics(PaneKit::ListDetail, 1180.0); + let mid = frame_metrics(PaneKit::ListDetail, 900.0); + assert_eq!(wide.meta_columns, 3); + assert_eq!(mid.meta_columns, 2); + assert!(mid.column < wide.column, "900 宽下详情列必须真的变窄"); +} + +#[test] +fn gutters_follow_the_class_and_are_on_the_grid() { + assert_eq!(WidthClass::Compact.gutter(), 16.0); + assert_eq!(WidthClass::Medium.gutter(), 24.0); + assert_eq!(WidthClass::Expanded.gutter(), 24.0); + for class in [ + WidthClass::Compact, + WidthClass::Medium, + WidthClass::Expanded, + ] { + assert_eq!(class.gutter() % 4.0, 0.0, "{class:?} 的页边距不在 4px 栅格"); + } +} + +#[test] +fn page_layout_names_are_stable() { + assert_eq!(PageLayout::SingleColumn.name(), "single-column"); + assert_eq!(PageLayout::SupportingPane.name(), "supporting-pane"); + assert_eq!(PageLayout::ListDetail.name(), "list-detail"); + assert!(!PageLayout::SingleColumn.is_two_pane()); + assert!(PageLayout::ListDetail.is_two_pane()); +} diff --git a/crates/app-iced/src/pages/logs.rs b/crates/app-iced/src/pages/logs.rs new file mode 100644 index 0000000..6acd66e --- /dev/null +++ b/crates/app-iced/src/pages/logs.rs @@ -0,0 +1,145 @@ +//! 日志页:**单列**版面的第二个用例(两行页头 84 + 正文列 ≤504)。 +//! +//! 与下载页的区别在页头:日志页的页头是**两行**(标题 + 一行运行状态), +//! 高度走 `LayoutMetrics::page_header_tall_height`(84),这是 12b §16.1 那栏在骨架期的落地。 +//! +//! 内容区给「日志源」一张卡(文件行)+ 空态位(当前没有输出)。真正的等宽日志视图属于 +//! 后续「日志页接事件流」的 mission,本页只负责版式。 + +use iced::widget::{column, row}; +use iced::{Alignment, Element, Length, Padding}; + +use unlucky_design::color::Role; +use unlucky_design::component_map::Component; +use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use crate::theme::Ui; + +use super::frame::{self, Header}; +use super::layout::FrameMetrics; +use super::{Action, Message}; + +/// 演示日志源(文件名 + 大小/时间)。 +const SOURCES: [(&str, &str); 3] = [ + ("launcher.log", "128 KiB · live"), + ("launch-2026-11-01T09-14-02.log", "412 KiB · 09:14"), + ("diagnose-2026-10-31.yaml", "18 KiB · yesterday"), +]; + +/// 整页。 +pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> { + let header = Header::new("Logs") + .subtitle("Launcher and game output · nothing is running right now") + .action(frame::text_action(ui, "Open folder", Action::OpenLogs)) + .action(frame::text_action( + ui, + "Copy diagnostics", + Action::CopyDiagnostics, + )) + .action(frame::text_action(ui, "Clear", Action::ClearLogs)); + + let body = frame::single_column( + metrics, + column![ + source_card(ui), + frame::empty_state( + ui, + IconKind::Logs, + "No output yet", + "Logs stream here while the launcher or the game is running. Start an instance to see \ + the first lines appear.", + vec![frame::text_action(ui, "Start an instance", Action::Play)], + ), + ] + .spacing(ui.space(Space::X4)) + .width(Length::Fill) + .height(Length::Fill), + ); + + frame::page(ui, metrics, header, body) +} + +/// 日志源列表(12b 的 `ListItem` 档:行文 `BodyLarge`、副文案 `BodySmall`)。 +fn source_card<'a>(ui: &'a Ui) -> Element<'a, Message> { + let spec = Component::ListItem.spec(); + let mut rows = column![].spacing(0.0).width(Length::Fill); + + for (index, (name, meta)) in SOURCES.iter().enumerate() { + if index > 0 { + rows = rows.push(frame::divider(ui)); + } + + rows = rows.push( + row![ + frame::glyph_box( + IconKind::Logs, + ui.color(Role::OnSurfaceVariant), + frame::ICON_PX + ), + column![ + frame::text_role(ui, spec.text, false, name, ui.color(Role::OnSurface)), + frame::text_role( + ui, + spec.support.unwrap_or(TypeRole::BodySmall), + false, + meta, + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(0.0) + .width(Length::Fill), + ] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .align_y(Alignment::Center) + .padding(Padding::from([ui.space(Space::X2), ui.space(Space::X2)])), + ); + } + + frame::card(ui, rows) +} + +#[cfg(test)] +mod tests { + use super::super::layout::{frame_metrics, measure_cap_px, PaneKit}; + use super::*; + use unlucky_design::color::SchemeFlavor; + use unlucky_design::typography::Platform; + + use crate::theme::Families; + + #[test] + fn logs_page_is_single_column_and_capped() { + for width in [700.0, 1180.0, 1600.0] { + let metrics = frame_metrics(PaneKit::Single, width); + assert!(metrics.secondary.is_none()); + assert!(metrics.column <= measure_cap_px() + f32::EPSILON); + } + } + + #[test] + fn header_is_two_lines_because_of_the_status_subtitle() { + let ui = Ui::with_platform( + SchemeFlavor::Dark, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ); + assert_eq!(ui.metrics().header_height(true), 84.0); + assert!(ui.metrics().header_height(true) > ui.metrics().header_height(false)); + } + + #[test] + fn every_source_row_has_a_name_and_meta() { + assert_eq!(SOURCES.len(), 3); + for (name, meta) in SOURCES { + assert!(name.contains("log") || name.contains("yaml")); + assert!(!meta.is_empty()); + } + } +} diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs new file mode 100644 index 0000000..bef6208 --- /dev/null +++ b/crates/app-iced/src/pages/mod.rs @@ -0,0 +1,281 @@ +//! # 页面骨架 + 布局系统(P4 / M84 `PAGEFRAME`) +//! +//! 本模块**不依赖组件库**(`components/` 由 M74/M75 并行建):它交付的是**布局系统**, +//! 四个页面只是它的用例。控件全是同一份令牌下的占位实现,等组件家族合入后由另一条 mission 替换。 +//! +//! ## 为什么要做这一层(历史判词) +//! +//! M68 的骨架内容区是**一张全宽卡**。视觉复审的结论(用户认可): +//! +//! > **不能把「全宽单卡」当页面模板** —— 正文单行 712px ≈ **118 字符**,远超舒适行宽; +//! > 官方桌面口径是 **supporting-pane / list-detail**。 +//! +//! 所以这里先立三条规矩,页面照着用: +//! +//! 1. **宽度档位**:官方响应式断点 **600 / 840**([`layout::WidthClass`]); +//! 2. **版面**:单列 / supporting-pane / list-detail,各自的成立条件与退化阈值写在 [`layout`]; +//! 3. **行宽上限**:正文列硬上限 **504px ≈ 72 字符**([`layout::measure_cap_px`]), +//! 由 [`layout::content_column_width`] / [`layout::frame_metrics`] **无条件施加**, +//! 页面拿不到「更宽」的选项。 +//! +//! ## 版面阈值表(`layout` 模块文档是全文,这里给速查) +//! +//! | 版面 | 用它的页面 | 成立条件 | 退化 | +//! |---|---|---|---| +//! | `SingleColumn` | 下载 / 日志(永远);实例 / 设置(窄窗) | 总是 | — | +//! | `SupportingPane` | 设置 | `w ≥ 600` 且 `w ≥ 720`(240 + 24 + 320 + 2×24 + 88) | 单列(分组按组堆叠) | +//! | `ListDetail` | 实例 | `w ≥ 840`(官方 expanded)且 `w ≥ 816` | 单列(列表 ↔ 详情整页切换) | +//! +//! ## 模块 +//! +//! | 文件 | 职责 | +//! |---|---| +//! | [`layout`] | **布局系统**:档位 / 版面 / 列宽 / 阈值,纯函数 + 单测(无 iced 控件树) | +//! | [`frame`] | 页头 / 内容列 / 两列骨架 / 空态 / 卡片 / 分隔线(占位控件) | +//! | `instances` | 实例页(list-detail) | +//! | `settings` | 设置页(supporting-pane) | +//! | `download` / `logs` | 下载 / 日志页(单列,两行页头的差异) | +//! | [`harness`] | 截图 harness(`examples/pages_demo.rs` 的入口,**不是** `cli.rs` 的一部分) | +//! +//! ## 截图通道 +//! +//! ```text +//! cargo run -p unlucky-app-iced --example pages_demo -- \ +//! --screen=instances --width=1180 --height=760 \ +//! --screenshot=out.png --screenshot-theme=light --software-renderer +//! ``` +//! +//! `cli.rs` 还没有 `--screen` / `--width`(M75 在改它),所以参数解析放在 [`harness`] 里, +//! **本模块一行没碰 `cli.rs` / `app.rs`**。 + +pub mod frame; +pub mod harness; +pub mod layout; + +mod download; +mod instances; +mod logs; +mod settings; + +pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass}; + +use iced::widget::{container, row, space}; +use iced::{Element, Length}; + +use unlucky_design::elevation::MaterialLayer; + +use crate::theme::Ui; + +use frame::filled; + +/// 页面消息。 +/// +/// **故意不用 `crate::app::Message`**(组件库契约 §2 的同一条理由):页面不绑 app 的状态层, +/// 接线时由 app 层把 [`Message::Action`] 映射成自己的消息。骨架期的动作只有「点了哪个按钮」, +/// 没有业务语义。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Message { + /// 点了页面里的某个动作。 + Action(Action), +} + +/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + /// 新建实例。 + NewInstance, + /// 导入。 + Import, + /// 返回实例列表(窄窗整页切换)。 + Back, + /// 启动。 + Play, + /// 更多。 + More, + /// 复制实例。 + Duplicate, + /// 打开实例目录。 + OpenFolder, + /// 删除实例。 + Delete, + /// 重新探测下载源。 + RefreshSources, + /// 浏览版本列表。 + BrowseVersions, + /// 导入整合包。 + ImportModpack, + /// 检查下载源。 + CheckSources, + /// 打开日志目录。 + OpenLogs, + /// 复制诊断信息。 + CopyDiagnostics, + /// 清空日志。 + ClearLogs, + /// 重置当前设置分组。 + ResetSection, + /// 保存设置。 + SaveSettings, + /// 新增逐实例覆盖。 + AddOverride, +} + +/// 四个页面。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Screen { + /// 实例(list-detail)。 + Instances, + /// 下载(单列)。 + Download, + /// 设置(supporting-pane)。 + Settings, + /// 日志(单列,两行页头)。 + Logs, +} + +impl Screen { + /// 全部页面(截图矩阵按这个顺序铺)。 + pub const ALL: [Screen; 4] = [ + Screen::Instances, + Screen::Download, + Screen::Settings, + Screen::Logs, + ]; + + /// 稳定 slug(命令行参数与文件名用)。 + pub const fn slug(self) -> &'static str { + match self { + Screen::Instances => "instances", + Screen::Download => "download", + Screen::Settings => "settings", + Screen::Logs => "logs", + } + } + + /// 反向解析(`--screen=instances`)。 + pub fn from_slug(slug: &str) -> Option { + Self::ALL.into_iter().find(|screen| screen.slug() == slug) + } + + /// 页面声明的**最宽**版面;实际用哪个由 [`layout::resolve`] 按窗口宽度决定。 + pub const fn kit(self) -> PaneKit { + match self { + Screen::Instances => PaneKit::ListDetail, + Screen::Settings => PaneKit::Supporting, + Screen::Download | Screen::Logs => PaneKit::Single, + } + } +} + +/// 单独渲染一页(截图 / 手看用,**不依赖业务状态机**)。 +/// +/// 窗口宽度是**含外壳的窗口宽度**(档位判据就是它):返回的树里,左侧 88px 与顶部 48px 是 +/// **留白带**,代表真实外壳的导航轨与标题栏——`shell::nav` 是私有模块,本 mission 不复制它的实现 +/// (真正的 `NavigationDrawer` 由 M78 提供,接线时把这两条带换成外壳即可)。 +pub fn demo<'a>(ui: &'a Ui, screen: Screen, window_width: f32) -> Element<'a, Message> { + let metrics = layout::frame_metrics(screen.kit(), window_width); + + let page = match screen { + Screen::Instances => instances::view(ui, metrics, Some(instances::SELECTED)), + Screen::Download => download::view(ui, metrics), + Screen::Settings => settings::view(ui, metrics), + Screen::Logs => logs::view(ui, metrics), + }; + + let shell = row![rail_placeholder(ui), page] + .width(Length::Fill) + .height(Length::Fill); + + let titled = iced::widget::column![titlebar_placeholder(ui), shell] + .width(Length::Fill) + .height(Length::Fill); + + container(titled) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme| filled(ui.layer(MaterialLayer::Canvas))) + .into() +} + +/// 标题栏留白带(高 = `LayoutMetrics::titlebar_height`)。 +fn titlebar_placeholder<'a>(ui: &'a Ui) -> Element<'a, Message> { + container(space::horizontal()) + .width(Length::Fill) + .height(ui.metrics().titlebar_height) + .style(move |_theme| filled(ui.layer(MaterialLayer::Canvas))) + .into() +} + +/// 导航轨留白带(宽 = `LayoutMetrics::nav_rail_width`)。 +fn rail_placeholder<'a>(ui: &'a Ui) -> Element<'a, Message> { + container(space::horizontal()) + .width(Length::Fixed(layout::rail_width())) + .height(Length::Fill) + .style(move |_theme| filled(ui.layer(MaterialLayer::Canvas))) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + use unlucky_design::color::SchemeFlavor; + use unlucky_design::typography::Platform; + + use crate::theme::Families; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + fn ui() -> Ui { + Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES) + } + + #[test] + fn four_screens_answer_the_mission_list() { + assert_eq!(Screen::ALL.len(), 4); + let slugs: Vec<&str> = Screen::ALL.iter().map(|screen| screen.slug()).collect(); + assert_eq!(slugs, vec!["instances", "download", "settings", "logs"]); + for screen in Screen::ALL { + assert_eq!(Screen::from_slug(screen.slug()), Some(screen)); + } + assert_eq!(Screen::from_slug("gallery"), None); + } + + #[test] + fn screens_declare_the_layout_their_page_needs() { + assert_eq!(Screen::Instances.kit(), PaneKit::ListDetail); + assert_eq!(Screen::Settings.kit(), PaneKit::Supporting); + assert_eq!(Screen::Download.kit(), PaneKit::Single); + assert_eq!(Screen::Logs.kit(), PaneKit::Single); + } + + /// 四个页面 × 三个对照宽度都能构图(不 panic、不依赖运行时)。 + #[test] + fn every_screen_builds_at_the_three_comparison_widths() { + let ui = ui(); + for width in [1180.0, 900.0, 700.0] { + for screen in Screen::ALL { + let _element: Element<'_, Message> = demo(&ui, screen, width); + } + } + } + + /// 版面对照:同一页在 1180 / 900 / 700 三个宽度上必须**真的换版面**。 + #[test] + fn the_width_comparison_really_changes_the_layout() { + let wide = layout::frame_metrics(Screen::Instances.kit(), 1180.0); + let mid = layout::frame_metrics(Screen::Instances.kit(), 900.0); + let narrow = layout::frame_metrics(Screen::Instances.kit(), 700.0); + + assert_eq!(wide.layout, PageLayout::ListDetail); + assert_eq!(mid.layout, PageLayout::ListDetail); + assert_eq!(narrow.layout, PageLayout::SingleColumn); + + // 900 与 1180 同为 list-detail,但详情列必须真的变窄(不是硬编码 1180 的宽度) + assert!(mid.column < wide.column, "900 的详情列应窄于 1180"); + assert!(narrow.secondary.is_none(), "700 不许再有列表列"); + } +} diff --git a/crates/app-iced/src/pages/settings.rs b/crates/app-iced/src/pages/settings.rs new file mode 100644 index 0000000..a05689d --- /dev/null +++ b/crates/app-iced/src/pages/settings.rs @@ -0,0 +1,311 @@ +//! 设置页:**supporting-pane** 的用例(左侧分组导航 240 + 右侧表单列 ≤504)。 +//! +//! ## 为什么是 supporting-pane 而不是 list-detail +//! +//! 分组导航是**辅助**(它不承载数据,只切换右侧表单的内容),右侧表单才是阅读对象; +//! 官方把这种「主列 + 窄侧列」叫 supporting pane。list-detail 的前提是左列本身是一份可浏览的数据集 +//! (实例列表就是),设置页不满足。 +//! +//! ## 侧列与 rail 的关系(D33) +//! +//! 宽度 240 直接取 `LayoutMetrics::nav_drawer_width`(D33:同一容器 rail 88 ⇄ drawer 240 的展开态), +//! 但**不复制 rail 的实现**:这里没有 hover 药丸、没有二级标签、没有 `Message::Select`。 +//! 将来真正的 `NavigationDrawer` 由 M78 提供,本页换掉 `group_nav()` 即可。 +//! +//! ## 退化(窗口 <720 或 compact 档) +//! +//! 侧列消失,分组导航退化成「按组堆叠」:同一列里每组一张卡 + 一段 12pt 组名。整页仍居中、正文列仍 ≤504。 + +use iced::widget::{column, container, row}; +use iced::{Alignment, Background, Border, Element, Length, Padding}; + +use unlucky_design::color::Role; +use unlucky_design::component_map::Component; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use crate::theme::Ui; + +use super::frame::{self, Header}; +use super::layout::{FrameMetrics, PageLayout}; +use super::{Action, Message}; + +/// 一个设置分组:名字、图标、以及若干「标签 = 值」行。 +struct Group { + name: &'static str, + icon: IconKind, + rows: &'static [(&'static str, &'static str)], +} + +/// 演示分组(骨架期:值只是文本,真控件等 M76 的输入家族)。 +const GROUPS: [Group; 4] = [ + Group { + name: "General", + icon: IconKind::Settings, + rows: &[ + ("Language", "English (System)"), + ("Theme", "Follow system"), + ("Startup page", "Instances"), + ("Close button", "Exit to tray"), + ], + }, + Group { + name: "Appearance", + icon: IconKind::Sidebar, + rows: &[ + ("Density", "Comfortable"), + ("Corner style", "Rounded"), + ("Motion", "Full"), + ("Text scale", "100%"), + ], + }, + Group { + name: "Java", + icon: IconKind::Play, + rows: &[ + ("Runtime", "Temurin 21.0.4"), + ("Default memory", "4 GiB"), + ("JVM arguments", "Automatic"), + ], + }, + Group { + name: "Downloads", + icon: IconKind::Download, + rows: &[ + ("Preferred source", "Automatic (region)"), + ("Concurrency", "8 threads"), + ("Verify checksums", "On"), + ], + }, +]; + +/// 演示选中的分组。 +const SELECTED: usize = 0; + +/// 空态位高度:给图标块 96 + 标题 + 副文案 + 按钮留够空间(`[估]`,按 12b §15 的元素最小高度累加)。 +const EMPTY_SLOT_HEIGHT: f32 = 320.0; + +/// 整页。 +pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> { + let header = Header::new("Settings") + .subtitle("Launcher behaviour, Java runtimes and download sources") + .action(frame::text_action( + ui, + "Reset section", + Action::ResetSection, + )) + .action(frame::text_action(ui, "Save", Action::SaveSettings)); + + let body = match metrics.layout { + PageLayout::SupportingPane => frame::panes( + metrics, + group_nav(ui), + frame::left_aligned(metrics.column, form(ui, Some(SELECTED))), + ), + _ => frame::single_column(metrics, form(ui, None)), + }; + + frame::page(ui, metrics, header, body) +} + +// --------------------------------------------------------------- 侧列 + +fn group_nav<'a>(ui: &'a Ui) -> Element<'a, Message> { + let items = column( + GROUPS + .iter() + .enumerate() + .map(|(index, group)| group_item(ui, group, index == SELECTED)) + .collect::>(), + ) + .spacing(ui.space(Space::X1)) + .padding(Padding::from([ui.space(Space::X2), ui.space(Space::X2)])) + .width(Length::Fill); + + container(items) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Card))), + border: Border { + radius: ui.radius_dp(ShapeScale::Large), + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + +fn group_item<'a>(ui: &'a Ui, group: &'a Group, selected: bool) -> Element<'a, Message> { + let spec = Component::NavigationItem.spec(); + let height = ui.metrics().row_height; + + let content_color = if selected { + ui.color(Role::OnSecondaryContainer) + } else { + ui.color(Role::OnSurfaceVariant) + }; + + let inner = row![ + frame::glyph_box(group.icon, content_color, frame::ICON_PX), + frame::text_role(ui, spec.text, false, group.name, content_color), + ] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .align_y(Alignment::Center); + + container(inner) + .width(Length::Fill) + .height(height) + .padding(Padding::from([0.0, ui.space(Space::X3)])) + .align_y(Alignment::Center) + .style(move |_theme| container::Style { + background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))), + border: Border { + radius: ui.radius(ShapeScale::Full, height), + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + +// --------------------------------------------------------------- 表单列 + +/// 表单列(正文列宽 ≤504)。`only = Some(i)` 只渲染一组(supporting-pane), +/// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。 +fn form<'a>(ui: &'a Ui, only: Option) -> Element<'a, Message> { + let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill); + + for (index, group) in GROUPS.iter().enumerate() { + if only.is_some_and(|selected| selected != index) { + continue; + } + + stack = stack.push( + column![ + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + group.name, + ui.color(Role::OnSurfaceVariant), + ), + group_card(ui, group), + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill), + ); + } + + stack = stack.push(overrides_empty(ui)); + stack.into() +} + +/// 一组设置 = 一张卡,行间 1px 分隔线(M3 设置列表的常规做法)。 +fn group_card<'a>(ui: &'a Ui, group: &'a Group) -> Element<'a, Message> { + let mut rows = column![].spacing(0.0).width(Length::Fill); + + for (index, (label, value)) in group.rows.iter().enumerate() { + if index > 0 { + rows = rows.push(frame::divider(ui)); + } + rows = rows.push(setting_row(ui, label, value)); + } + + frame::card(ui, rows) +} + +fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Message> { + let height = ui.metrics().control_height + Space::X2.dp(); + + row![ + container(frame::text_role( + ui, + TypeRole::BodyMedium, + false, + label, + ui.color(Role::OnSurface), + )) + .width(Length::Fill) + .align_y(Alignment::Center), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + value, + ui.color(Role::OnSurfaceVariant), + ), + ] + .height(height) + .width(Length::Fill) + .align_y(Alignment::Center) + .into() +} + +/// 空态位:没有逐实例覆盖时的样子(12b §15 的口径)。 +fn overrides_empty<'a>(ui: &'a Ui) -> Element<'a, Message> { + container(frame::empty_state( + ui, + IconKind::Instances, + "No per-instance overrides", + "Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest \ + of the launcher keeps the defaults above.", + vec![frame::text_action(ui, "Add override", Action::AddOverride)], + )) + .width(Length::Fill) + .height(Length::Fixed(EMPTY_SLOT_HEIGHT)) + .into() +} + +#[cfg(test)] +mod tests { + use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH}; + use super::*; + + #[test] + fn side_nav_reuses_the_drawer_width() { + // D33:分组导航走 drawer 240(同一容器 rail 88 ⇄ drawer 240),不是 rail 的 88 + assert_eq!(SIDE_NAV_WIDTH, 240.0); + let metrics = frame_metrics(PaneKit::Supporting, 1180.0); + assert_eq!(metrics.secondary, Some(240.0)); + assert_ne!(metrics.secondary, Some(layout::rail_width())); + } + + #[test] + fn form_column_never_exceeds_the_measure_cap() { + for width in [720.0, 840.0, 1180.0, 1920.0] { + let metrics = frame_metrics(PaneKit::Supporting, width); + assert!( + metrics.column <= measure_cap_px() + f32::EPSILON, + "{width} 宽下表单列 {:?} 超过行宽上限", + metrics.column + ); + assert!(metrics.column >= layout::measure_floor_px()); + } + } + + #[test] + fn every_group_has_a_name_icon_and_rows() { + assert!(GROUPS.len() >= 3); + assert!(SELECTED < GROUPS.len()); + for group in &GROUPS { + assert!(!group.name.is_empty()); + assert!(!group.icon.icon().path.is_empty(), "{} 缺图标", group.name); + assert!(!group.rows.is_empty(), "{} 没有设置行", group.name); + for (label, value) in group.rows { + assert!(!label.is_empty() && !value.is_empty()); + } + } + } + + #[test] + fn narrow_window_stacks_the_groups_instead_of_the_side_nav() { + let narrow = frame_metrics(PaneKit::Supporting, 700.0); + assert_eq!(narrow.layout, PageLayout::SingleColumn); + assert!(narrow.secondary.is_none(), "窄窗不许留侧列"); + } +}