复审 reviewer-modpackpick-r1(p2-1items / fix-then-merge)的返工。 P2-1 预检失败被吞掉(错误呈现回归) - host::import::spawn_scan 在 `scan.error.is_some()` 时**再发一条 PackEvent::Report** (`convert::failed_report_of` 把 core 的 ImportError 含 hint 逐字段搬过去),于是页面回到 既有的「致命 —— 已回滚」结果块,不再落回入口态。 - convert 里把失败报告的组装收成一处(`failed_report` / `failed_report_of` → `failed_report_with`)。 - 证据:`a_failed_scan_still_reaches_the_screen_as_a_report` 断言**事件序列** `["scan-with-error", "report-failed"]` + 报告里 code/message 与预检同源(MultiMC `formatVersion: 2`,detect 认得、read 才失败);`a_clean_scan_does_not_emit_a_report` 是对照。 变异验证:删掉那条 Report 发送 ⇒ 该用例红。 P3-1 写侧补条目数闸 - `WriteLimits` 增加 `entries`(生产口径 = `PACK_MAX_ENTRIES`);`preflight` 在**读盘之前** 按「清单 1 条 + 覆盖文件 N 条」判 `count > limit`。 - 证据:`the_write_side_refuses_a_pack_over_the_unified_entry_count`(20001 条声明只放 1 个 真文件 ⇒ 报 `pack_limits` 而不是 `pack_io`,即闸在读盘前)、 `the_entry_count_gate_is_the_same_number_as_the_read_side`(两侧同一个数)。 变异验证:注释掉 `guard_entries` 调用 ⇒ 前一条红。 P3-2 「内容相同」判据归一 - 删掉 `scan::judge` 里与 `merge::decide` 重复的 `disk.sha1 == pack_sha1` 早退,只留 `decide().reason?`;两处文档互相指路(`merge.rs` 注明这是唯一判据)。 - 变异验证:把 `decide` 的相同分支改成"冲突" ⇒ **8 条**红(含 4 条 scan 用例与 merge 单测)。 P3-3/P3-4 口径与注释 - `TogglePick` 注释改成与代码一致(保留项留在表里,靠 `decisions()` 过滤)。 - `unknown_remote` 的计数与文案统一:判不了的两种来路(补全前没路径 / 有路径但缺 sha1)都算, 注记载明两者;新增 `a_remote_entry_with_a_path_but_no_sha1_is_counted_as_unjudgeable`。 视觉(tower 看图给的三条) - 冲突行两种取值都改成**同几何的 pill**(保留=描边、覆盖=tonal),各自右对齐进同宽度的槽 ⇒ 右边缘同一条线;路径列吃 `Fill` 把槽推到行右缘。 - 列表与动作行之间加 8dp 呼吸;分页行的 Previous/Next 用 `Space::Fill` 顶到右端, 与内容列对齐(不再是右侧一大片留白)。 - 重新出图:C:/Temp/m160-shots/pick-light.png / pick-dark.png。 顺手 - `format/write.rs` 的单测拆到 `format/write/tests.rs`(纯搬移,守 400 行),并把里面两个 自己拼 `%TEMP%` 路径的用例改用 `TempRoot`(断言红了也不留垃圾)。 - `import_decisions.rs` 的夹具拆到 `tests/decisions_support.rs`(纯搬移,守 400 行)。 证据:fmt 干净、clippy -D warnings 干净、`cargo test --workspace --all-targets -j 4` = 2512 passed / 0 failed / **4 ignored**(51 个 test binary);跑前/跑后 `%TEMP%` 的 `ull-pack-*` 计数 1922→1922、`ull-pack-*-zip64-*` 0→0、`ull-import-*` 1360→1360(增量 0), df 106G→106G 余量不变。
312 lines
11 KiB
Rust
312 lines
11 KiB
Rust
//! 冲突清单那一屏的画法(M160):**分页** + 三个入口 + 计数摘要 + 确认/取消。
|
|
//!
|
|
//! ## 版面
|
|
//!
|
|
//! ```text
|
|
//! ┌ 导入前:有 312 个文件已存在且内容不同 ───────────────┐
|
|
//! │ 保留 309 · 覆盖 3 · 覆盖将丢弃 2.4 MB │
|
|
//! │ 没有改动的一律按「保留」:… │
|
|
//! │ [全部保留我的] [全部覆盖] │
|
|
//! ├ 第 1-40 项,共 312 项 [上一页] [下一页] │
|
|
//! │ config/sodium-options.json 包内 overrides/ · 本来就在 │
|
|
//! │ 你的:4.1 KB · 包内:3.8 KB [覆盖]│
|
|
//! │ …(**只画当前这一页**) │
|
|
//! ├ [按这些选择导入] [取消导入] │
|
|
//! └─────────────────────────────────────────────────────┘
|
|
//! ```
|
|
//!
|
|
//! ## 三处「不误导」
|
|
//!
|
|
//! 1. **缺省说明写在按钮下面**:用户不必猜"没点过的那 309 条会怎样";
|
|
//! 2. **计数摘要里的丢弃量只算真选了覆盖的**:用最坏数字吓人不算如实;
|
|
//! 3. **按钮写的是"点了会变成什么"**(当前保留 ⇒ 按钮写「覆盖」),所以点一下就知道后果。
|
|
//!
|
|
//! ## 为什么分页而不是滚动
|
|
//!
|
|
//! 几千条冲突一次画完 = 几千个 widget:布局与滚动都卡。分页把 widget 数量钉在
|
|
//! [`super::super::model::PAGE_SIZE`] 条以内,翻页只是换数据。
|
|
|
|
use iced::widget::{column, container, row};
|
|
use iced::{Alignment, Background, Border, Element, Length, Padding};
|
|
|
|
use unlucky_design::color::Role;
|
|
use unlucky_design::icons::IconKind;
|
|
use unlucky_design::shape::ShapeScale;
|
|
use unlucky_design::spacing::Space;
|
|
use unlucky_design::typography::TypeRole;
|
|
|
|
use unlucky_i18n::Catalog;
|
|
|
|
use crate::app::components::buttons::{self, Button};
|
|
use crate::app::components::selection::{chip, Chip};
|
|
use crate::pages::frame;
|
|
use crate::pages::Message as PageMessage;
|
|
use crate::theme::Ui;
|
|
|
|
use super::super::model::{Pick, PickRow};
|
|
use super::super::PackMessage;
|
|
use super::intent;
|
|
|
|
/// 处置按钮槽的宽度(px):取最宽状态「⟳ Overwrite」的实际占用再留余量,
|
|
/// 保证「Keep」与「Overwrite」两种状态的右边缘落在同一条线上。
|
|
const TOGGLE_SLOT: f32 = 132.0;
|
|
|
|
/// 整块。
|
|
pub(super) fn pick_block<'a>(
|
|
ui: &'a Ui,
|
|
catalog: Catalog,
|
|
pick: &'a Pick,
|
|
) -> Element<'a, PageMessage> {
|
|
let mut block = column![
|
|
frame::text_role(
|
|
ui,
|
|
TypeRole::TitleSmall,
|
|
true,
|
|
pick.title.as_str(),
|
|
ui.color(Role::OnSurface),
|
|
),
|
|
// 计数摘要:这是"我要丢掉多少"的唯一一处数字,用强调色
|
|
frame::text_role(
|
|
ui,
|
|
TypeRole::BodyMedium,
|
|
true,
|
|
pick.summary.as_str(),
|
|
ui.color(Role::Primary),
|
|
),
|
|
frame::text_role(
|
|
ui,
|
|
TypeRole::BodySmall,
|
|
false,
|
|
pick.default_note,
|
|
ui.color(Role::OnSurfaceVariant),
|
|
),
|
|
]
|
|
.spacing(ui.space(Space::X1))
|
|
.width(Length::Fill);
|
|
|
|
if let Some(note) = &pick.unknown_note {
|
|
block = block.push(
|
|
row![
|
|
frame::glyph(IconKind::Warn, ui.color(Role::Tertiary), frame::ICON_PX),
|
|
frame::text_role(
|
|
ui,
|
|
TypeRole::BodySmall,
|
|
false,
|
|
note.as_str(),
|
|
ui.color(Role::Tertiary),
|
|
),
|
|
]
|
|
.spacing(ui.space(Space::X1))
|
|
.align_y(Alignment::Center),
|
|
);
|
|
}
|
|
|
|
// 三个入口:整批保留 / 整批覆盖(逐项就是下面每行那个按钮)
|
|
block = block.push(
|
|
row![
|
|
chip(
|
|
ui,
|
|
Chip::filter(
|
|
pick.keep_all,
|
|
pick.keep_all_active,
|
|
intent(PackMessage::PickKeepAll),
|
|
),
|
|
),
|
|
chip(
|
|
ui,
|
|
Chip::filter(
|
|
pick.overwrite_all,
|
|
pick.overwrite_all_active,
|
|
intent(PackMessage::PickOverwriteAll),
|
|
),
|
|
),
|
|
]
|
|
.spacing(ui.space(Space::X2)),
|
|
);
|
|
|
|
// 分页头(当前页之外的行一律不画)
|
|
block = block.push(bounded(
|
|
column(
|
|
std::iter::once(pager(ui, catalog, pick))
|
|
.chain(pick.rows.iter().map(|item| pick_row(ui, item)))
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
.spacing(ui.space(Space::X1))
|
|
.width(Length::Fill),
|
|
375.0,
|
|
));
|
|
|
|
// 列表与动作行之间留出与其它区块一致的呼吸(复审 r1 视觉 2):滚到底时最后一行会紧贴
|
|
// 动作行,靠这层 padding 把两者分开(直接调列 spacing 会把行间距也一起拉开)。
|
|
block = block.push(
|
|
container(
|
|
row![
|
|
buttons::filled(
|
|
ui,
|
|
Button::new(pick.confirm.as_str(), intent(PackMessage::ConfirmImport))
|
|
.leading(IconKind::Check),
|
|
),
|
|
buttons::text(
|
|
ui,
|
|
Button::new(pick.cancel, intent(PackMessage::CancelImport)),
|
|
),
|
|
]
|
|
.spacing(ui.space(Space::X2))
|
|
.align_y(Alignment::Center),
|
|
)
|
|
.padding(Padding {
|
|
top: ui.space(Space::X2),
|
|
..Padding::ZERO
|
|
}),
|
|
);
|
|
|
|
container(block)
|
|
.width(Length::Fill)
|
|
.padding(ui.space(Space::X3))
|
|
.style(move |_theme| container::Style {
|
|
border: Border {
|
|
color: ui.color(Role::Outline),
|
|
width: 1.0,
|
|
radius: ui.radius_dp(ShapeScale::Large),
|
|
},
|
|
background: Some(Background::Color(ui.color(Role::Surface))),
|
|
..container::Style::default()
|
|
})
|
|
.into()
|
|
}
|
|
|
|
/// 分页头:区间 + 上一页 / 下一页。
|
|
fn pager<'a>(ui: &'a Ui, _catalog: Catalog, pick: &'a Pick) -> Element<'a, PageMessage> {
|
|
let previous = if pick.can_prev {
|
|
buttons::text(
|
|
ui,
|
|
Button::new(
|
|
pick.prev,
|
|
intent(PackMessage::PickPage(pick.page.saturating_sub(1))),
|
|
)
|
|
.leading(IconKind::ChevronDown),
|
|
)
|
|
} else {
|
|
// 到边了就把按钮指回当前页(点了不动的死按钮比"没有按钮"更让人困惑)
|
|
buttons::text(
|
|
ui,
|
|
Button::new(pick.prev, intent(PackMessage::PickPage(pick.page))),
|
|
)
|
|
};
|
|
let next = if pick.can_next {
|
|
buttons::text(
|
|
ui,
|
|
Button::new(pick.next, intent(PackMessage::PickPage(pick.page + 1)))
|
|
.leading(IconKind::ChevronDown),
|
|
)
|
|
} else {
|
|
buttons::text(
|
|
ui,
|
|
Button::new(pick.next, intent(PackMessage::PickPage(pick.page))),
|
|
)
|
|
};
|
|
// 分页行与行内控件共用同一条右锚点(复审 r1 视觉 3):区间文案靠左,两个按钮用
|
|
// `horizontal_space` 顶到右边,于是「Previous · Next」的右边缘与下面每一行的处置槽对齐。
|
|
// 两个按钮进一个**右对齐的槽**,槽的右边缘就是行的右边缘 —— 于是它们的右端与下面每行的
|
|
// 处置槽落在同一条线上(按钮自身的内边距也一起对齐,不会差那十几像素)。
|
|
let buttons_slot = container(
|
|
row![previous, next]
|
|
.spacing(ui.space(Space::X2))
|
|
.align_y(Alignment::Center),
|
|
)
|
|
.width(Length::Fixed(TOGGLE_SLOT * 2.0))
|
|
.align_x(Alignment::End);
|
|
row![
|
|
frame::text_role(
|
|
ui,
|
|
TypeRole::LabelMedium,
|
|
false,
|
|
pick.range.as_str(),
|
|
ui.color(Role::OnSurfaceVariant),
|
|
),
|
|
iced::widget::Space::new().width(Length::Fill),
|
|
buttons_slot,
|
|
]
|
|
.spacing(ui.space(Space::X2))
|
|
.align_y(Alignment::Center)
|
|
.width(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
/// 一条冲突:**第一行**路径 + 处置按钮,**第二行**来源 · 成因 · 两侧体积。
|
|
///
|
|
/// 为什么两行:单行里塞四个字段(路径 / 来源 / 体积 / 按钮)会把路径压成一条窄柱,
|
|
/// 出图实测每行折成四五段——扫读成本比两行高得多。
|
|
fn pick_row<'a>(ui: &'a Ui, item: &'a PickRow) -> Element<'a, PageMessage> {
|
|
// 两种取值都是**同一种几何**(等高的 pill),只是填充不同:
|
|
// · 当前=覆盖 ⇒ **tonal 填充**(这一行会被盖掉,是"待反悔"的那一档)
|
|
// · 当前=保留 ⇒ **描边**(缺省档;点了才会覆盖)
|
|
// 两种 pill 再各自右对齐进同宽度的槽,于是**右边缘落在同一条线**(复审 r1 视觉 1 的必修项)——
|
|
// 之前一边是描边 pill、一边是无容器的文字按钮,宽窄与右端都不一致。
|
|
let toggle = if item.overwrites() {
|
|
buttons::tonal(
|
|
ui,
|
|
Button::new(
|
|
item.toggle,
|
|
intent(PackMessage::TogglePick(item.path.clone())),
|
|
)
|
|
.leading(IconKind::Refresh),
|
|
)
|
|
} else {
|
|
buttons::outlined(
|
|
ui,
|
|
Button::new(
|
|
item.toggle,
|
|
intent(PackMessage::TogglePick(item.path.clone())),
|
|
)
|
|
.leading(IconKind::Refresh),
|
|
)
|
|
};
|
|
// 路径那半列必须自己吃掉剩余宽度(`Length::Fill`),否则处置槽会紧跟文字、离右边缘一大截,
|
|
// 与分页行的右锚点对不上(出图实测:行内按钮停在 x≈680、分页按钮在 x≈780)。
|
|
let path_slot = container(frame::text_role(
|
|
ui,
|
|
TypeRole::BodySmall,
|
|
false,
|
|
item.path.as_str(),
|
|
ui.color(Role::OnSurface),
|
|
))
|
|
.width(Length::Fill);
|
|
// 处置按钮自己那一格:定宽 + 右对齐 ⇒ 两种取值的 pill 右边缘落在同一条线上。
|
|
// 右侧再留 8dp:滚动条就贴在内容右边缘(出图实测 x≈791),pill 紧贴着它显得挤。
|
|
let toggle_slot = container(toggle)
|
|
.width(Length::Fixed(TOGGLE_SLOT))
|
|
.align_x(Alignment::End)
|
|
.padding(Padding {
|
|
right: ui.space(Space::X2),
|
|
..Padding::ZERO
|
|
});
|
|
let head = row![path_slot, toggle_slot]
|
|
.spacing(ui.space(Space::X2))
|
|
.align_y(Alignment::Center)
|
|
.width(Length::Fill);
|
|
|
|
let detail = frame::text_role_owned(
|
|
ui,
|
|
TypeRole::BodySmall,
|
|
false,
|
|
format!("{} · {} · {}", item.source, item.reason, item.sizes),
|
|
ui.color(Role::OnSurfaceVariant),
|
|
);
|
|
|
|
column![head, detail]
|
|
.spacing(ui.space(Space::X1))
|
|
.width(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
/// 有界容器:内容多时自己滚(与结果块的 `bounded` 同一条口径,但这里限高不同)。
|
|
fn bounded<'a>(
|
|
content: impl Into<Element<'a, PageMessage>>,
|
|
height: f32,
|
|
) -> Element<'a, PageMessage> {
|
|
iced::widget::scrollable(container(content).width(Length::Fill))
|
|
.height(Length::Fixed(height))
|
|
.into()
|
|
}
|