Files
Unlucky-Launcher/crates/app-iced/src/pages/instances/detail.rs
T
AskaEth 89d98184c1 fix(app-iced): M102 页面几何 —— 正文块贴左吃满页宽、列表末行不再被裁半行、日志按词断行
① 正文列与页头不对齐(下载 / 日志):根因是 `frame::single_column` 把**行宽上限 424**
当成**整页块宽度**并水平居中(1180 下 1044 里只摆 424,左缘 422 对页头标题 112)。
现在两个量分开:`layout` 的 `column` 只管成段散文(≤424),页面块宽走新的
`frame::body_width`(= available / main_pane)。新增 `frame::body`(贴左吃满)与
`frame::prose`(散文锁 424)。1180×760 下下载 / 日志的块左缘 = 112 = 页头标题左缘。

② 实例列表末行被裁半行:页级行距 `Space::X1`(4) 让行节奏变成 64、与可视高度除不尽,
且滚动区直贴卡片 1px 圆角描边。行距归零(下载页同改)+ 滚动区底部内缩 `Space::X2`(8)
⇒ 可视区 580−32−8 = 540 = 9×60,Redstone Lab 及其副标题完整可见,第 10 行整体在裁切线外。

③ 日志断行把单词劈开(`graphics.gl_o` / `peration_failure`):新增
`layout::wrap::breakable`,在 `.` `_` `:` `/` `\` 后插零宽空格(U+200B 软断点),
日志行正文与一行摘要都过它;悬挂缩进不变(前缀仍是独立文本控件),
`Line::text` 与 `Logs::summary()`(剪贴板)仍是原文。工具函数放 `pages/layout/`:
断点是「列宽之后」的另一半排版判据,纯函数、可单测、不碰控件树。

护栏:`body_block_starts_at_the_header_title_and_fills_the_page`、
`list_viewport_fits_whole_rows_and_still_overflow`(把 580/32/8/540/60 五个数钉住)、
`wrap::tests`(断点位置 / 不重复插 / 可还原 / 幂等)、`log_text_carries_soft_breaks_but_keeps_the_original`。
`download/tests.rs` 的「宽窗留白」口径按新分工改成「宽度归块、不归散文列」。
2026-11-04 19:53:38 +08:00

254 lines
8.0 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.
//! 实例页的**详情列**(M90 r2 从 `instances.rs` 拆出来:那边超过 400 行会被 guards 的 file-size 判红)。
//!
//! 结构:标题行(名字 + 版本 + Play/Stop)→ 元信息网格(列数由 `layout::meta_columns` 决定)
//! → 一条分隔线 → 正文段 / 运行状态行 → 动作行(Duplicate / Open folder / Delete)。
//! 窄窗(<840)时列表退成上一屏,本文件只画详情 + 一个「返回」按钮。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_presenter::router::Route;
use crate::app::components::buttons::{self, Button};
use crate::app::components::divider;
use crate::app::components::interaction::Interaction;
use crate::theme::Ui;
use unlucky_design::icons::IconKind;
use super::super::frame;
use super::super::layout::{self, FrameMetrics};
use super::super::model::Detail;
use super::super::{Action, Message};
use super::forced_state;
// --------------------------------------------------------------- 详情列
pub(super) fn detail_column<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
detail: &'a Detail,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let mut body = column![
detail_head(ui, detail, forced),
meta_grid(ui, metrics, detail),
divider::full(ui),
]
.spacing(ui.space(Space::X4))
.width(Length::Fill);
body = push_text(ui, metrics, body, detail);
// 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留)
frame::left_aligned(metrics.column, body.push(action_row(ui, forced)))
}
pub(super) fn narrow_detail<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
detail: &'a Detail,
forced: Option<Interaction>,
) -> Element<'a, Message> {
// 窄窗:列表退成上一屏,本屏只放详情(官方 compact 的 list-detail 口径)。
let mut body = column![
row![buttons::text(
ui,
forced_state(
Button::new("Back to instances", Message::Navigate(Route::Instances)),
forced,
),
)]
.width(Length::Fill)
.align_y(Alignment::Center),
detail_head(ui, detail, forced),
meta_grid(ui, metrics, detail),
divider::full(ui),
]
.spacing(ui.space(Space::X4))
.width(Length::Fill);
body = push_text(ui, metrics, body, detail);
body = body.push(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),
));
frame::body(metrics, body.push(action_row(ui, forced)))
}
/// 正文段(演示数据才有)与运行状态行(真数据才有)——两者都是可选的,顺序固定。
///
/// 散文段套 [`frame::prose`]:M102 起页面块吃满页宽,但「一行 ≤72 字符」的承诺仍然只由
/// 散文承担(`frame::body_width` 管块、`metrics.column` 管一句话)。
fn push_text<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
mut body: iced::widget::Column<'a, Message>,
detail: &'a Detail,
) -> iced::widget::Column<'a, Message> {
if let Some(note) = &detail.note {
body = body.push(frame::prose(
metrics,
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
note.as_str(),
ui.color(Role::OnSurfaceVariant),
),
));
}
if let Some(status) = &detail.status {
body = body.push(frame::text_role(
ui,
TypeRole::BodyMedium,
false,
status.as_str(),
ui.color(Role::OnSurface),
));
}
body
}
fn detail_head<'a>(
ui: &'a Ui,
detail: &'a Detail,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let titles = column![
frame::text_role(
ui,
TypeRole::TitleLarge,
true,
detail.name.as_str(),
ui.color(Role::OnSurface)
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
detail.subtitle.as_str(),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
// 主操作:**全屏唯一**实心 accent(rubric K2)。不可用时退化成禁用态(只清消息,不改身份)。
let launch = if detail.can_launch {
buttons::filled(
ui,
forced_state(
Button::new("Play", Message::Launch(detail.id.clone())),
forced,
),
)
} else {
buttons::filled(ui, Button::disabled("Play"))
};
let stop = if detail.can_stop {
buttons::text(
ui,
forced_state(
Button::new("Stop", Message::Stop(detail.id.clone())),
forced,
),
)
} else {
buttons::text(ui, Button::disabled("Stop"))
};
row![titles, launch, stop]
.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, detail: &'a Detail) -> Element<'a, Message> {
let mut grid = column![].spacing(layout::META_GAP).width(Length::Fill);
let columns = metrics.meta_columns.clamp(1, detail.meta.len().max(1));
for chunk in detail.meta.chunks(columns) {
let mut cells_row = row![].spacing(layout::META_GAP).width(Length::Fill);
for cell in chunk {
let card = frame::card(
ui,
column![
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
cell.label.as_str(),
ui.color(Role::OnSurfaceVariant),
),
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
cell.value.as_str(),
ui.color(Role::OnSurface)
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
);
cells_row = cells_row.push(container(card).width(Length::Fill));
}
grid = grid.push(cells_row);
}
grid.into()
}
fn action_row<'a>(ui: &'a Ui, forced: Option<Interaction>) -> Element<'a, Message> {
row![
buttons::text(
ui,
forced_state(
Button::new("Duplicate", Message::Action(Action::Duplicate)),
forced,
)
),
buttons::text(
ui,
forced_state(
Button::new("Open folder", Message::Action(Action::OpenFolder)),
forced,
)
),
buttons::text(
ui,
forced_state(
Button::new("Delete", Message::Action(Action::Delete)),
forced
),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.into()
}
pub(super) 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(),
)
}