Files
Unlucky-Launcher/crates/app-iced/src/pages/instances.rs
T
AskaEth 87d5dd8f67 docs(app-iced): 订正 meta_grid 注释(1180 与 900 都是 2 列)
r3 复审:`meta_grid` 的文档还写着「1180 → 3 列,900 → 2 列」,但本轮起两者都是 2 列。

证据:`grep -n "3 列" crates/app-iced/src/pages/instances.rs`
- 旧:`/// 元信息网格:列数由 [...] 决定(1180 → 3 列,900 → 2 列)。`
- 新:写明「1180 与 900 都是 2 列;3 列需要 3×160 + 2×12 = 504px > 行宽上限 424,数学上不可行;
  硬塞回 3 列会让 NeoForge 20.4.237(118px)折行、同排卡底不齐」。
- 现在文件里唯一出现「3 列」的地方就是这条「不可行」的说明(第 275/276 行)。
2026-11-02 03:52:24 +08:00

352 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 实例页:**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<usize>,
) -> 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<usize>) -> 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::<Vec<_>>(),
)
.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(),
)
}