//! 实例页:**list-detail** 的唯一用例。 //! //! ## 版式(由 [`super::layout`] 决议,页面不自己算宽度) //! //! | 版面 | 结构 | 退化行为 | //! |---|---|---| //! | `ListDetail`(窗口 ≥840) | 左列 336 固定、**独立滚动**;右列详情,正文列 ≤424 | — | //! | `SingleColumn`(<840) | 列表与详情**整页切换**:本页只渲染详情列(≤424,居中) | 顶部给「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) .direction(scrollable::Direction::Vertical( scrollable::Scrollbar::new() .width(frame::SCROLLBAR_WIDTH) .scroller_width(frame::SCROLLBAR_WIDTH) .margin(frame::SCROLLBAR_MARGIN), )) .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 与 900 都是 2 列**。 /// /// 3 列需要 `3 × META_CARD_MIN_WIDTH + 2 × META_GAP` = 504px,而行宽上限是 424,**数学上不可行**; /// 硬塞回 3 列会让 `NeoForge 20.4.237`(118px)这类值按词折行、同排卡底不齐。 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(), ) }