feat(m160): 整合包逐文件覆盖/保留决策 + 导入前冲突清单 + 限额口径统一
主任务(逐文件覆盖/保留)
- core: 新增 import/scan.rs —— 只读预检产出「冲突清单」(目标已存在且 SHA-1 不同才进),
逐条给路径/来源/两侧体积与哈希;用户删过的、内容相同的、被叠层遮蔽的一律不打扰。
- core: 新增 import/decision.rs —— ConflictDecisions(整批档 + 逐项表,逐项优先),
缺省档恒为 KeepUser;ImportRequest.conflict_policy 改由它承载,CLI 调用点不变。
- core: phase.rs 的 settle 按 path 取决策;非冲突路径不受决策影响。
- presenter: ImportScanView / ImportConflictView / ConflictDecisionsView(全部保留 /
全部覆盖 / 逐项{default,items})+ PackCommand::Scan / PackEvent::Scan;host 新增只读
spawn_scan,且新实例路径不再提前建目录(target_of 只算路径,prepare_target 才建)。
- app-iced: 导入前冲突清单页(分页 40 条/页、三入口、计数摘要、确认/取消、两行式行布局);
预检干净时直接往下导,不打扰用户。出图见 C:/Temp/m160-shots/{pick-light,pick-dark}.png。
- i18n: 新增 18 条 en/zh 文案 + 更新「两者都留」那条口径说明。
限额口径统一
- pack/archive/limits.rs 成为唯一来源:PACK_MAX_ENTRIES/UNCOMPRESSED_BYTES/ENTRY_BYTES
(20k / 512 MiB / 64 MiB);读侧 PackLimits::default 与写侧 WriteLimits::production 同源。
- 写侧不再直接用 zip::ZIP64_BYTES_THR;新增单条目闸,于是「能导出的包一定能导入」。
不等式在 pack/tests/limits.rs 里用 const 断言钉死。
测试临时物泄漏(塔台追加项)
- addon/temp.rs:TempRoot —— Drop 即删(panic 展开同样删),Deref/AsRef 到落点路径,
under()/at() 支持"子树落点、删父根"。形状照 core::update::tests::support::TempDir。
- addon::pack 与 addon::tests 两族全部改用 TempRoot(含 fixtures::Fixture 持守卫、
multimc/fabric_instance 等返回守卫);4 GiB 稀疏文件的语义一字未改,只改"谁负责删"。
- 自证:pack/tests/mod.rs 三条用例(drop 删 / panic 也删 / 子树删父根)。
测试
- import_decisions.rs(11 例):预检清单、不打扰、逐项决策后的最终文件集、基线记包内哈希。
- roundtrip_three.rs(2 例):叠层 + 冲突夹具 import→export→import 三轮文件集逐字节一致,
以及「选保留就按设计分叉」的反向证据。
- pack/tests/limits.rs(5 例):单条/总量边界、读侧用同一批常量、写出即可读回。
证据:cargo fmt --check 干净;cargo clippy --workspace --all-targets -D warnings 干净;
cargo test --workspace --all-targets -j 4 → 2507 passed / 0 failed(51 个 test binary)。
This commit is contained in:
1 parent
a4f096eea3
commit
4defc41e8d
64 files changed
+3819
-442
No files matched your search
@@ -8,7 +8,7 @@
|
||||
//!
|
||||
//! | 变量 | 取值 | 作用 |
|
||||
//! |---|---|---|
|
||||
//! | `ULL_CONTENT_PACK` | `importing` / `partial` / `conflict` / `failed` / `export` | 选导入导出区的哪一态(不设 = 只有入口) |
|
||||
//! | `ULL_CONTENT_PACK` | `importing` / `pick` / `partial` / `conflict` / `failed` / `export` | 选导入导出区的哪一态(不设 = 只有入口) |
|
||||
//!
|
||||
//! 夹具**不直接造视图模型**,而是造 presenter 侧的快照(`ImportState` / `ExportState`),
|
||||
//! 再走产品路径同一个 [`Pack::of`]。这样截图里看到的每一行字都是产品路径会画的那一行,
|
||||
@@ -68,6 +68,21 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
|
||||
|
||||
match state {
|
||||
"importing" => import.progress = Some(progress()),
|
||||
// M160:导入前的冲突清单(分页 + 三个入口 + 计数摘要)
|
||||
"pick" => {
|
||||
import.scan = Some(scan(312));
|
||||
// 停在第 2 页,且已经逐项点过两条「覆盖」——图上能同时看见分页与逐项状态
|
||||
page.pick_page = 1;
|
||||
// 两条落到**第 2 页**的路径上:图上能同时看见「保留」与「覆盖」两种行状态
|
||||
page.pick_choices.insert(
|
||||
"config/generated/module-041.toml".to_string(),
|
||||
unlucky_presenter::dto::ConflictChoiceView::Overwrite,
|
||||
);
|
||||
page.pick_choices.insert(
|
||||
"config/generated/module-044.toml".to_string(),
|
||||
unlucky_presenter::dto::ConflictChoiceView::Overwrite,
|
||||
);
|
||||
}
|
||||
"partial" => import.report = Some(report(ImportStatusView::Partial, false)),
|
||||
"conflict" => {
|
||||
import.report = Some(report(ImportStatusView::Partial, true));
|
||||
@@ -194,6 +209,59 @@ fn conflict(path: &str, reason: ConflictReasonView) -> ConflictFileView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条待决冲突(M160 预览清单的一行)。
|
||||
fn pending(index: usize) -> unlucky_presenter::dto::ImportConflictView {
|
||||
// 前几条用真实形态的路径,其余按序号生成:截图里既要有可读的真实感,也要有"很多条"的证据。
|
||||
let path = match index {
|
||||
0 => "config/sodium-options.json".to_string(),
|
||||
1 => "mods/create-1.2.3.jar".to_string(),
|
||||
2 => "config/jei/jei-client.ini".to_string(),
|
||||
3 => "options.txt".to_string(),
|
||||
_ => format!("config/generated/module-{index:03}.toml"),
|
||||
};
|
||||
unlucky_presenter::dto::ImportConflictView {
|
||||
path,
|
||||
source: if index.is_multiple_of(3) {
|
||||
unlucky_presenter::dto::ConflictSourceView::ClientOverrides
|
||||
} else {
|
||||
unlucky_presenter::dto::ConflictSourceView::Overrides
|
||||
},
|
||||
reason: if index.is_multiple_of(2) {
|
||||
unlucky_presenter::dto::ConflictReasonView::PreExisting
|
||||
} else {
|
||||
unlucky_presenter::dto::ConflictReasonView::UserModified
|
||||
},
|
||||
disabled: false,
|
||||
pack_sha1: "9f2c41ab77de3350aa1b8c0d5e6f708192a3b4c5".to_string(),
|
||||
pack_size: 3_800 + (index as u64 * 37) % 900,
|
||||
disk_sha1: "c1d2e3f4050617283940a5b6c7d8e9f0a1b2c3d4".to_string(),
|
||||
disk_size: 4_100 + (index as u64 * 53) % 1_200,
|
||||
base_sha1: (!index.is_multiple_of(2))
|
||||
.then(|| "3a7d90ff11bc22445566ef7788990011aabbccdd".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 预检结果(`count` 条冲突;图上靠分页只画当前这一页)。
|
||||
fn scan(count: usize) -> unlucky_presenter::dto::ImportScanView {
|
||||
let conflicts: Vec<unlucky_presenter::dto::ImportConflictView> =
|
||||
(0..count).map(pending).collect();
|
||||
let lost_bytes = conflicts.iter().map(|each| each.disk_size).sum();
|
||||
let incoming_bytes = conflicts.iter().map(|each| each.pack_size).sum();
|
||||
unlucky_presenter::dto::ImportScanView {
|
||||
target: PathBuf::from("C:/ULL/instances/survival/.minecraft"),
|
||||
format: Some(PackFormatView::Modrinth),
|
||||
pack_name: Some("Better MC".to_string()),
|
||||
pack_version: Some("1.2.0".to_string()),
|
||||
game_version: Some("1.20.1".to_string()),
|
||||
conflicts,
|
||||
unknown_remote: 4,
|
||||
lost_bytes,
|
||||
incoming_bytes,
|
||||
notes: Vec::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出预检:12 内联 / 1 跳过(包不完整)。
|
||||
fn preview() -> ExportPreviewView {
|
||||
let mut files = vec![ExportFileView {
|
||||
|
||||
@@ -30,10 +30,22 @@ pub enum Message {
|
||||
Cancel,
|
||||
/// 关掉结果面板(报告 / 致命错误都走这一条)
|
||||
Dismiss,
|
||||
/// 以包为准重新导入(对应 `overwrite = true`;**只在用户显式点过之后**)
|
||||
/// 以包为准重新导入(**只在用户显式点过之后**;载荷是整批覆盖档)
|
||||
Overwrite,
|
||||
/// 展开/收起某一条冲突的三方明细
|
||||
ToggleConflict(String),
|
||||
/// 冲突清单翻页(M160;页码会夹在 `[0, pages)` 里)
|
||||
PickPage(usize),
|
||||
/// 逐项切换某一条的处置(保留 ⇄ 覆盖)
|
||||
TogglePick(String),
|
||||
/// 整批档:全部保留(逐项里"覆盖"的那些一起清掉)
|
||||
PickKeepAll,
|
||||
/// 整批档:全部覆盖
|
||||
PickOverwriteAll,
|
||||
/// 按当前选择开始导入
|
||||
ConfirmImport,
|
||||
/// 取消这次导入(不写盘,回到入口态)
|
||||
CancelImport,
|
||||
/// 换目标实例(`None` = 按包名新建)
|
||||
SetInstance(Option<InstanceId>),
|
||||
/// 换新实例名(`None` 语义由 `SetInstance` 承担,这里只改名字)
|
||||
|
||||
@@ -26,15 +26,22 @@
|
||||
//! 是「已整包回滚」。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。
|
||||
|
||||
mod export;
|
||||
mod pick;
|
||||
mod progress;
|
||||
mod report;
|
||||
|
||||
pub use export::{format_label, request_of as request_export, Export, Preview};
|
||||
pub use pick::{Pick, PickRow};
|
||||
pub use progress::Running;
|
||||
pub use report::{ConflictRow, Report, Row};
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{ExportState, ImportState, InstanceId, PackFormatView, ProbeView};
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictDecisionsView, ExportState, ImportState, InstanceId,
|
||||
PackFormatView, ProbeView,
|
||||
};
|
||||
|
||||
use crate::pages::content::Target;
|
||||
|
||||
@@ -61,6 +68,12 @@ pub struct PackPageState {
|
||||
pub export_open: bool,
|
||||
/// 展开的那条冲突路径
|
||||
pub expanded: Option<String>,
|
||||
/// 冲突清单翻到第几页(0 起;M160)
|
||||
pub pick_page: usize,
|
||||
/// 逐项决策(**只存"覆盖"的路径**:保留 = 表里没有这条,于是"没点过"永远是保留)
|
||||
pub pick_choices: BTreeMap<String, ConflictChoiceView>,
|
||||
/// 「全部覆盖」这一档是否被点过(整批档;逐项决策仍然优先于它)
|
||||
pub pick_overwrite_all: bool,
|
||||
}
|
||||
|
||||
impl Default for PackPageState {
|
||||
@@ -78,10 +91,65 @@ impl Default for PackPageState {
|
||||
output: String::new(),
|
||||
export_open: false,
|
||||
expanded: None,
|
||||
pick_page: 0,
|
||||
pick_choices: BTreeMap::new(),
|
||||
pick_overwrite_all: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PackPageState {
|
||||
/// 用户在冲突清单上点的处置(逐项优先,其次整批档,缺省保留)。
|
||||
#[must_use]
|
||||
pub fn pick_choice(&self, path: &str) -> ConflictChoiceView {
|
||||
if let Some(choice) = self.pick_choices.get(path) {
|
||||
return *choice;
|
||||
}
|
||||
if self.pick_overwrite_all {
|
||||
ConflictChoiceView::Overwrite
|
||||
} else {
|
||||
ConflictChoiceView::KeepUser
|
||||
}
|
||||
}
|
||||
|
||||
/// 把这一屏的选择变成发给宿主的那份决策。
|
||||
///
|
||||
/// 一个都没点 ⇒ `KeepAll`(与 core 的"全部保留"同一个语义);点过 ⇒ `PerItem { default, items }`,
|
||||
/// 其中 `default` 就是整批档。于是"全部覆盖之后又单独放回两条"也能表达(不需要把剩下几百条
|
||||
/// 全点一遍),而"没点过"永远落在保留上。
|
||||
#[must_use]
|
||||
pub fn decisions(
|
||||
&self,
|
||||
scan: Option<&unlucky_presenter::dto::ImportScanView>,
|
||||
) -> ConflictDecisionsView {
|
||||
use unlucky_presenter::dto::{ConflictDecisionView, ConflictDecisionsView};
|
||||
let default = if self.pick_overwrite_all {
|
||||
ConflictChoiceView::Overwrite
|
||||
} else {
|
||||
ConflictChoiceView::KeepUser
|
||||
};
|
||||
// 只发"与缺省档不同"的项:其余在 core 侧自然落到缺省档。
|
||||
let items: Vec<ConflictDecisionView> = self
|
||||
.pick_choices
|
||||
.iter()
|
||||
.filter(|(_, choice)| **choice != default)
|
||||
.map(|(path, choice)| ConflictDecisionView {
|
||||
path: path.clone(),
|
||||
choice: *choice,
|
||||
})
|
||||
.collect();
|
||||
// 冲突清单为空时,`PerItem` 没有对象,直接给整批档(语义等价、日志更短)。
|
||||
let empty = scan.is_none_or(|scan| scan.conflicts.is_empty());
|
||||
if items.is_empty() || empty {
|
||||
return match default {
|
||||
ConflictChoiceView::KeepUser => ConflictDecisionsView::KeepAll,
|
||||
ConflictChoiceView::Overwrite => ConflictDecisionsView::OverwriteAll,
|
||||
};
|
||||
}
|
||||
ConflictDecisionsView::per_item(default, items)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个实例候选(导入目标与导出源共用)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PackTarget {
|
||||
@@ -132,6 +200,8 @@ pub struct Pack {
|
||||
pub cancelled_note: &'static str,
|
||||
/// 导入结果
|
||||
pub report: Option<Report>,
|
||||
/// **导入前的冲突清单**(M160:`Some` 就画那一屏,别的东西都让位)
|
||||
pub pick: Option<Pick>,
|
||||
/// 导出块
|
||||
pub export: Export,
|
||||
}
|
||||
@@ -181,6 +251,15 @@ impl Pack {
|
||||
.report
|
||||
.as_ref()
|
||||
.map(|report| report::report_of(report, page, catalog)),
|
||||
pick: import.scan.as_ref().and_then(|scan| {
|
||||
pick::pick_of(
|
||||
scan,
|
||||
page.pick_page,
|
||||
&page.pick_choices,
|
||||
page.pick_overwrite_all,
|
||||
catalog,
|
||||
)
|
||||
}),
|
||||
export: export::export_of(export, page, candidates, catalog),
|
||||
}
|
||||
}
|
||||
@@ -190,6 +269,7 @@ impl Pack {
|
||||
self.running.is_none()
|
||||
&& self.probe.is_none()
|
||||
&& self.report.is_none()
|
||||
&& self.pick.is_none()
|
||||
&& !self.cancelled
|
||||
&& !self.export.open
|
||||
&& self.draft.is_empty()
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! 导入**冲突清单**的视图模型(M160):分页、计数、逐项处置。
|
||||
//!
|
||||
//! # 为什么是分页而不是"一次画完"
|
||||
//!
|
||||
//! 冲突清单可能有几千条(一个用户改过很多配置的老实例)。`iced` 的每一行都是真实 widget,
|
||||
//! 一次画几千行会同时拖慢布局与滚动,而用户真正要看的永远只有"这一屏"。所以模型里只出
|
||||
//! **当前这一页**([`PAGE_SIZE`] 条),页面上的「上一页 / 下一页」改的是
|
||||
//! [`PackPageState::pick_page`]——数据本身留在 presenter 的 `ImportScanView` 里,页面不复制。
|
||||
//!
|
||||
//! # 三个入口如何体现在计数上
|
||||
//!
|
||||
//! | 用户点了 | 决策表 | 计数 |
|
||||
//! |---|---|---|
|
||||
//! | 全部保留 | [`ConflictDecisionsView::KeepAll`] | 覆盖 0 |
|
||||
//! | 全部覆盖 | [`ConflictDecisionsView::OverwriteAll`] | 覆盖 = 全部 |
|
||||
//! | 逐项 | `PerItem`(只列"覆盖"的那些,其余按保留) | 覆盖 = 点过的条数 |
|
||||
//!
|
||||
//! 页面显示的两个数直接来自 [`PackPageState::decisions`]、[`PackPageState::pick_choice`] 的计算,
|
||||
//! 与发给 host 的那份决策**同源**——不会出现"界面说覆盖 3 个、实际覆盖 0 个"。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{
|
||||
human_bytes, ConflictChoiceView, ConflictReasonView, ConflictSourceView, ImportConflictView,
|
||||
ImportScanView,
|
||||
};
|
||||
|
||||
/// 一页画多少行(一次画几千行是性能陷阱;40 行在一屏内可读且不滚动太多)。
|
||||
pub const PAGE_SIZE: usize = 40;
|
||||
|
||||
/// 冲突清单块(导入前那一屏)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Pick {
|
||||
/// 标题(`导入前:有 12 个文件已存在且内容不同`)
|
||||
pub title: String,
|
||||
/// 计数摘要(`保留 9 · 覆盖 3 · 覆盖将丢弃 2.4 MB`)
|
||||
pub summary: String,
|
||||
/// 缺省说明("没改动的一律按保留")
|
||||
pub default_note: &'static str,
|
||||
/// 判不了的远端条目(`Some` 才画那一行)
|
||||
pub unknown_note: Option<String>,
|
||||
/// **当前页**的行(最多 [`PAGE_SIZE`] 条)
|
||||
pub rows: Vec<PickRow>,
|
||||
/// 当前页(0 起)
|
||||
pub page: usize,
|
||||
/// 总页数(至少 1)
|
||||
pub pages: usize,
|
||||
/// 区间说明(`第 1-40 项,共 312 项`)
|
||||
pub range: String,
|
||||
/// 保留几条
|
||||
pub keep_count: usize,
|
||||
/// 覆盖几条
|
||||
pub overwrite_count: usize,
|
||||
/// 能不能翻页
|
||||
pub can_prev: bool,
|
||||
/// 能不能翻页
|
||||
pub can_next: bool,
|
||||
/// 三个入口的文案
|
||||
pub keep_all: &'static str,
|
||||
/// 三个入口的文案
|
||||
pub overwrite_all: &'static str,
|
||||
/// 逐项按钮(保留)的文案
|
||||
pub row_keep: &'static str,
|
||||
/// 逐项按钮(覆盖)的文案
|
||||
pub row_overwrite: &'static str,
|
||||
/// 翻页文案
|
||||
pub prev: &'static str,
|
||||
/// 翻页文案
|
||||
pub next: &'static str,
|
||||
/// 确认导入
|
||||
pub confirm: String,
|
||||
/// 取消导入
|
||||
pub cancel: &'static str,
|
||||
/// 「全部保留」这一档当前是否生效(整批档切到保留时高亮)
|
||||
pub keep_all_active: bool,
|
||||
/// 「全部覆盖」这一档当前是否生效
|
||||
pub overwrite_all_active: bool,
|
||||
}
|
||||
|
||||
/// 清单里的一行。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PickRow {
|
||||
/// 实例内路径
|
||||
pub path: String,
|
||||
/// 来源(`包内 overrides/` / `清单 files[]`)
|
||||
pub source: String,
|
||||
/// 成因(`本来就在` / `你改过`)
|
||||
pub reason: String,
|
||||
/// 两侧体积(`你的:2.1 MB · 包内:1.8 MB`)
|
||||
pub sizes: String,
|
||||
/// 当前处置([`ConflictChoiceView::KeepUser`] = 保留)
|
||||
pub choice: ConflictChoiceView,
|
||||
/// 这一行按钮上写的字(点了就变成另一侧)
|
||||
pub toggle: &'static str,
|
||||
}
|
||||
|
||||
impl PickRow {
|
||||
/// 这一行是"覆盖"吗。
|
||||
#[must_use]
|
||||
pub const fn overwrites(&self) -> bool {
|
||||
matches!(self.choice, ConflictChoiceView::Overwrite)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从预检结果 + 页面选择造出这一块(`None` = 没有待决冲突,页面不该画它)。
|
||||
#[must_use]
|
||||
pub fn pick_of(
|
||||
scan: &ImportScanView,
|
||||
page_number: usize,
|
||||
per_item: &BTreeMap<String, ConflictChoiceView>,
|
||||
batch_overwrite: bool,
|
||||
catalog: Catalog,
|
||||
) -> Option<Pick> {
|
||||
if scan.conflicts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let total = scan.conflicts.len();
|
||||
let pages = total.div_ceil(PAGE_SIZE).max(1);
|
||||
let page = page_number.min(pages - 1);
|
||||
let from = page * PAGE_SIZE;
|
||||
let to = (from + PAGE_SIZE).min(total);
|
||||
|
||||
let rows: Vec<PickRow> = scan.conflicts[from..to]
|
||||
.iter()
|
||||
.map(|conflict| row_of(conflict, per_item, batch_overwrite, catalog))
|
||||
.collect();
|
||||
|
||||
let overwrite_count = scan
|
||||
.conflicts
|
||||
.iter()
|
||||
.filter(|conflict| {
|
||||
effective(conflict.path.as_str(), per_item, batch_overwrite)
|
||||
== ConflictChoiceView::Overwrite
|
||||
})
|
||||
.count();
|
||||
let keep_count = total - overwrite_count;
|
||||
// 覆盖会丢掉多少:只算"你真选了覆盖"的那些,避免用最坏数字吓人。
|
||||
let lost = scan
|
||||
.conflicts
|
||||
.iter()
|
||||
.filter(|conflict| {
|
||||
effective(conflict.path.as_str(), per_item, batch_overwrite)
|
||||
== ConflictChoiceView::Overwrite
|
||||
})
|
||||
.map(|conflict| conflict.disk_size)
|
||||
.fold(0_u64, u64::saturating_add);
|
||||
|
||||
Some(Pick {
|
||||
title: catalog.render(Key::PackPickTitle, &[("count", &total.to_string())]),
|
||||
summary: catalog.render(
|
||||
Key::PackPickSummary,
|
||||
&[
|
||||
("keep", &keep_count.to_string()),
|
||||
("overwrite", &overwrite_count.to_string()),
|
||||
("bytes", &human_bytes(lost)),
|
||||
],
|
||||
),
|
||||
default_note: catalog.text(Key::PackPickDefaultNote),
|
||||
unknown_note: (scan.unknown_remote > 0).then(|| {
|
||||
catalog.render(
|
||||
Key::PackPickUnknownRemote,
|
||||
&[("count", &scan.unknown_remote.to_string())],
|
||||
)
|
||||
}),
|
||||
rows,
|
||||
page,
|
||||
pages,
|
||||
range: catalog.render(
|
||||
Key::PackPickPage,
|
||||
&[
|
||||
("from", &(from + 1).to_string()),
|
||||
("to", &to.to_string()),
|
||||
("total", &total.to_string()),
|
||||
],
|
||||
),
|
||||
keep_count,
|
||||
overwrite_count,
|
||||
can_prev: page > 0,
|
||||
can_next: page + 1 < pages,
|
||||
keep_all: catalog.text(Key::PackPickKeepAll),
|
||||
overwrite_all: catalog.text(Key::PackPickOverwriteAll),
|
||||
row_keep: catalog.text(Key::PackPickRowKeep),
|
||||
row_overwrite: catalog.text(Key::PackPickRowOverwrite),
|
||||
prev: catalog.text(Key::PackPickPrev),
|
||||
next: catalog.text(Key::PackPickNext),
|
||||
confirm: catalog.render(Key::PackPickConfirm, &[]),
|
||||
cancel: catalog.text(Key::PackPickCancel),
|
||||
keep_all_active: !batch_overwrite,
|
||||
overwrite_all_active: batch_overwrite,
|
||||
})
|
||||
}
|
||||
|
||||
/// 一条冲突的**有效**处置:逐项优先,其次整批档,缺省保留(与 core 侧同一条规则)。
|
||||
fn effective(
|
||||
path: &str,
|
||||
per_item: &BTreeMap<String, ConflictChoiceView>,
|
||||
batch_overwrite: bool,
|
||||
) -> ConflictChoiceView {
|
||||
if let Some(choice) = per_item.get(path) {
|
||||
return *choice;
|
||||
}
|
||||
if batch_overwrite {
|
||||
ConflictChoiceView::Overwrite
|
||||
} else {
|
||||
ConflictChoiceView::KeepUser
|
||||
}
|
||||
}
|
||||
|
||||
fn row_of(
|
||||
conflict: &ImportConflictView,
|
||||
per_item: &BTreeMap<String, ConflictChoiceView>,
|
||||
batch_overwrite: bool,
|
||||
catalog: Catalog,
|
||||
) -> PickRow {
|
||||
let choice = effective(conflict.path.as_str(), per_item, batch_overwrite);
|
||||
PickRow {
|
||||
path: conflict.path.clone(),
|
||||
source: source_label(conflict.source, catalog).to_owned(),
|
||||
reason: reason_label(conflict.reason, catalog).to_owned(),
|
||||
sizes: format!(
|
||||
"{} · {}",
|
||||
catalog.render(
|
||||
Key::PackPickDisk,
|
||||
&[("size", &human_bytes(conflict.disk_size))]
|
||||
),
|
||||
catalog.render(
|
||||
Key::PackPickIncoming,
|
||||
&[("size", &human_bytes(conflict.pack_size))]
|
||||
)
|
||||
),
|
||||
choice,
|
||||
// 按钮写"点了会变成什么":当前保留 ⇒ 按钮是「覆盖」。
|
||||
toggle: if matches!(choice, ConflictChoiceView::Overwrite) {
|
||||
catalog.text(Key::PackPickRowKeep)
|
||||
} else {
|
||||
catalog.text(Key::PackPickRowOverwrite)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 来源的人读串。
|
||||
fn source_label(source: ConflictSourceView, catalog: Catalog) -> &'static str {
|
||||
match source {
|
||||
ConflictSourceView::Overrides => catalog.text(Key::PackPickSourceOverrides),
|
||||
ConflictSourceView::ClientOverrides => catalog.text(Key::PackPickSourceClientOverrides),
|
||||
ConflictSourceView::CurseForge | ConflictSourceView::Modrinth => {
|
||||
catalog.text(Key::PackPickSourceManifest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 成因的人读串(复用导入报告那三句,不另造一套说法)。
|
||||
fn reason_label(reason: ConflictReasonView, catalog: Catalog) -> &'static str {
|
||||
match reason {
|
||||
ConflictReasonView::UserModified => catalog.text(Key::PackConflictUserModified),
|
||||
ConflictReasonView::UserDeleted => catalog.text(Key::PackConflictUserDeleted),
|
||||
ConflictReasonView::PreExisting => catalog.text(Key::PackConflictPreExisting),
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
//! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、
|
||||
//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。
|
||||
|
||||
mod pick;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! 冲突清单(M160)的视图模型守门:分页、计数、三个入口 → 决策表。
|
||||
//!
|
||||
//! 这里钉的是**不画**的那部分——一屏只出 `PAGE_SIZE` 行、翻页只换数据、计数与最终决策同源。
|
||||
//! 画法本身靠出图(`ULL_CONTENT_PACK=pick`)。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::super::pick::{pick_of, PAGE_SIZE};
|
||||
use super::super::PackPageState;
|
||||
use super::catalog;
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictDecisionsView, ConflictReasonView, ConflictSourceView,
|
||||
ImportConflictView, ImportScanView,
|
||||
};
|
||||
|
||||
/// `count` 条冲突的预检结果。
|
||||
fn scan(count: usize) -> ImportScanView {
|
||||
ImportScanView {
|
||||
target: std::path::PathBuf::from("instance"),
|
||||
format: None,
|
||||
pack_name: Some("Better MC".to_string()),
|
||||
pack_version: None,
|
||||
game_version: None,
|
||||
conflicts: (0..count)
|
||||
.map(|index| ImportConflictView {
|
||||
path: format!("config/row-{index:03}.toml"),
|
||||
source: ConflictSourceView::Overrides,
|
||||
reason: ConflictReasonView::PreExisting,
|
||||
disabled: false,
|
||||
pack_sha1: "aa".repeat(20),
|
||||
pack_size: 1_000,
|
||||
disk_sha1: "bb".repeat(20),
|
||||
disk_size: 2_000,
|
||||
base_sha1: None,
|
||||
})
|
||||
.collect(),
|
||||
unknown_remote: 0,
|
||||
lost_bytes: 0,
|
||||
incoming_bytes: 0,
|
||||
notes: Vec::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pick(
|
||||
scan: &ImportScanView,
|
||||
page: usize,
|
||||
per_item: &BTreeMap<String, ConflictChoiceView>,
|
||||
batch: bool,
|
||||
) -> super::super::pick::Pick {
|
||||
pick_of(scan, page, per_item, batch, catalog()).expect("有冲突就该画这一块")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_current_page_is_drawn() {
|
||||
let scan = scan(312);
|
||||
let first = pick(&scan, 0, &BTreeMap::new(), false);
|
||||
assert_eq!(first.rows.len(), PAGE_SIZE, "一屏只许画一页的行");
|
||||
assert_eq!(first.pages, 8, "312 条 / 40 条一页 = 8 页");
|
||||
assert_eq!(first.page, 0);
|
||||
assert!(first.can_next, "还有下一页");
|
||||
assert!(!first.can_prev, "第一页没有上一页");
|
||||
assert_eq!(first.range, "Items 1-40 of 312");
|
||||
assert_eq!(first.rows[0].path, "config/row-000.toml");
|
||||
|
||||
let second = pick(&scan, 1, &BTreeMap::new(), false);
|
||||
assert_eq!(second.page, 1);
|
||||
assert_eq!(
|
||||
second.rows[0].path,
|
||||
format!("config/row-{PAGE_SIZE:03}.toml")
|
||||
);
|
||||
assert_eq!(second.range, "Items 41-80 of 312");
|
||||
assert!(second.can_prev && second.can_next);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_page_is_short_and_the_page_number_is_clamped() {
|
||||
let scan = scan(312);
|
||||
let last = pick(&scan, 7, &BTreeMap::new(), false);
|
||||
assert_eq!(last.rows.len(), 312 - 7 * PAGE_SIZE, "最后一页只剩尾巴");
|
||||
assert_eq!(last.range, "Items 281-312 of 312");
|
||||
assert!(!last.can_next);
|
||||
|
||||
// 页码越界(例如用户先在 300 条的清单上翻到第 6 页,再重扫一份 10 条的)⇒ 夹到最后一页。
|
||||
let clamped = pick(&scan, 99, &BTreeMap::new(), false);
|
||||
assert_eq!(clamped.page, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_summary_counts_what_the_user_actually_picked() {
|
||||
let scan = scan(10);
|
||||
let keep_all = pick(&scan, 0, &BTreeMap::new(), false);
|
||||
assert_eq!(keep_all.keep_count, 10);
|
||||
assert_eq!(keep_all.overwrite_count, 0);
|
||||
assert!(keep_all.summary.contains("Keep 10"), "{}", keep_all.summary);
|
||||
assert!(keep_all.keep_all_active && !keep_all.overwrite_all_active);
|
||||
|
||||
let mut per_item = BTreeMap::new();
|
||||
per_item.insert(
|
||||
"config/row-001.toml".to_string(),
|
||||
ConflictChoiceView::Overwrite,
|
||||
);
|
||||
per_item.insert(
|
||||
"config/row-004.toml".to_string(),
|
||||
ConflictChoiceView::Overwrite,
|
||||
);
|
||||
let mixed = pick(&scan, 0, &per_item, false);
|
||||
assert_eq!(mixed.overwrite_count, 2);
|
||||
assert_eq!(mixed.keep_count, 8);
|
||||
// 丢弃量只算"真选了覆盖"的那两条(各 2 000 字节)。
|
||||
assert!(
|
||||
mixed.summary.contains("Overwrite 2") || mixed.summary.contains("overwrite 2"),
|
||||
"{}",
|
||||
mixed.summary
|
||||
);
|
||||
assert_eq!(
|
||||
mixed.rows[1].choice,
|
||||
ConflictChoiceView::Overwrite,
|
||||
"点过的那一行要显示成覆盖"
|
||||
);
|
||||
assert_eq!(mixed.rows[0].choice, ConflictChoiceView::KeepUser);
|
||||
|
||||
let all = pick(&scan, 0, &BTreeMap::new(), true);
|
||||
assert_eq!(all.overwrite_count, 10);
|
||||
assert!(all.overwrite_all_active && !all.keep_all_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_conflict_means_no_block() {
|
||||
let scan = scan(0);
|
||||
assert!(
|
||||
pick_of(&scan, 0, &BTreeMap::new(), false, catalog()).is_none(),
|
||||
"没有冲突就不该画这一屏(不打扰用户)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decisions_default_to_keep_all() {
|
||||
let page = PackPageState::default();
|
||||
assert_eq!(page.decisions(None), ConflictDecisionsView::KeepAll);
|
||||
// 逐项点一个"覆盖" ⇒ 逐项表里只有它,缺省档仍是保留。
|
||||
let mut page = PackPageState::default();
|
||||
page.pick_choices
|
||||
.insert("mods/a.jar".to_string(), ConflictChoiceView::Overwrite);
|
||||
let decisions = page.decisions(Some(&scan(3)));
|
||||
assert_eq!(decisions.default_choice(), ConflictChoiceView::KeepUser);
|
||||
assert_eq!(decisions.overwrite_count(), 1);
|
||||
assert_eq!(decisions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_overwrite_plus_a_kept_file_is_expressible() {
|
||||
// 全部覆盖之后单独放回一条:缺省档是覆盖、逐项里那条是保留。
|
||||
let mut page = PackPageState {
|
||||
pick_overwrite_all: true,
|
||||
..PackPageState::default()
|
||||
};
|
||||
assert_eq!(
|
||||
page.decisions(Some(&scan(3))),
|
||||
ConflictDecisionsView::OverwriteAll,
|
||||
"只点整批覆盖时不需要逐项表"
|
||||
);
|
||||
page.pick_choices
|
||||
.insert("mods/pin.jar".to_string(), ConflictChoiceView::KeepUser);
|
||||
let decisions = page.decisions(Some(&scan(3)));
|
||||
assert_eq!(decisions.default_choice(), ConflictChoiceView::Overwrite);
|
||||
assert_eq!(decisions.keep_count(), 1);
|
||||
assert_eq!(decisions.overwrite_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggling_flips_the_row_and_clearing_the_batch_drops_per_item_choices() {
|
||||
let mut page = PackPageState::default();
|
||||
let path = "mods/a.jar";
|
||||
assert_eq!(page.pick_choice(path), ConflictChoiceView::KeepUser);
|
||||
page.pick_choices
|
||||
.insert(path.to_string(), ConflictChoiceView::Overwrite);
|
||||
assert_eq!(page.pick_choice(path), ConflictChoiceView::Overwrite);
|
||||
|
||||
// 整批覆盖之后,那条逐项"保留"仍然优先于整批档。
|
||||
page.pick_overwrite_all = true;
|
||||
page.pick_choices
|
||||
.insert(path.to_string(), ConflictChoiceView::KeepUser);
|
||||
assert_eq!(page.pick_choice(path), ConflictChoiceView::KeepUser);
|
||||
assert_eq!(
|
||||
page.pick_choice("mods/other.jar"),
|
||||
ConflictChoiceView::Overwrite
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,8 @@ use unlucky_i18n::Lang;
|
||||
use super::demo::pack_in;
|
||||
use super::model::Pack;
|
||||
|
||||
/// D30 要求的四态(导入中 / 部分成功 / 冲突选择 / 导出预检)。
|
||||
const STATES: [&str; 4] = ["importing", "partial", "conflict", "export"];
|
||||
/// 出图态:导入中 / **导入前冲突清单(M160)** / 部分成功 / 导入后冲突账 / 导出预检。
|
||||
const STATES: [&str; 5] = ["importing", "pick", "partial", "conflict", "export"];
|
||||
|
||||
#[test]
|
||||
fn every_screenshot_state_fills_something() {
|
||||
@@ -28,6 +28,33 @@ fn every_screenshot_state_fills_something() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pick_state_shows_a_paginated_conflict_list() {
|
||||
// M160 的那一屏:只画一页、三个入口都在、计数与逐项状态一致。
|
||||
let (model, page) = pack_in("pick", Lang::En);
|
||||
let pick = model.pick.expect("pick 态必须有冲突清单");
|
||||
// 一屏只画一页:行数上限来自 `model::pick::PAGE_SIZE`(40)。
|
||||
assert_eq!(pick.rows.len(), 40, "一屏一页(PAGE_SIZE)");
|
||||
assert_eq!(pick.pages, 8, "312 条 / 40 条一页");
|
||||
assert_eq!(pick.page, 1, "夹具停在第 2 页(0 起)");
|
||||
assert!(pick.can_prev && pick.can_next);
|
||||
assert_eq!(pick.overwrite_count, 2, "夹具逐项点了两条覆盖");
|
||||
assert!(
|
||||
pick.keep_all_active && !pick.overwrite_all_active,
|
||||
"整批档停在缺省那一档(全部保留)"
|
||||
);
|
||||
assert_eq!(
|
||||
pick.rows[0].choice,
|
||||
unlucky_presenter::dto::ConflictChoiceView::KeepUser,
|
||||
"没点过的那一行必须是保留"
|
||||
);
|
||||
// 页面自留的选择与模型一致(逐项那条显示成覆盖)
|
||||
assert_eq!(
|
||||
page.pick_choice("config/generated/module-041.toml"),
|
||||
unlucky_presenter::dto::ConflictChoiceView::Overwrite
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_shows_the_shared_install_panel() {
|
||||
let (model, _page) = pack_in("importing", Lang::En);
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
mod export;
|
||||
mod parts;
|
||||
mod pick;
|
||||
mod progress;
|
||||
mod report;
|
||||
|
||||
@@ -64,6 +65,7 @@ use crate::pages::Message as PageMessage;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use self::export::export_block;
|
||||
use self::pick::pick_block;
|
||||
use self::progress::{probe_block, running_block};
|
||||
use self::report::report_block;
|
||||
use super::model::Pack;
|
||||
@@ -88,6 +90,9 @@ pub(crate) fn section<'a>(
|
||||
// 事件,任何时候把文件拖进来都算数。
|
||||
if model.export.open {
|
||||
block = block.push(export_block(ui, catalog, &model.export));
|
||||
} else if let Some(pick) = &model.pick {
|
||||
// 冲突清单优先于一切:用户此刻要做的决定就是"哪几个文件覆盖"
|
||||
block = block.push(pick_block(ui, catalog, pick));
|
||||
} else if let Some(running) = &model.running {
|
||||
block = block.push(running_block(ui, catalog, running));
|
||||
} else if let Some(report) = &model.report {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
//! 冲突清单那一屏的画法(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};
|
||||
|
||||
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;
|
||||
|
||||
/// 整块。
|
||||
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,
|
||||
));
|
||||
|
||||
block = block.push(
|
||||
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),
|
||||
);
|
||||
|
||||
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))),
|
||||
)
|
||||
};
|
||||
row![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
pick.range.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
previous,
|
||||
next,
|
||||
]
|
||||
.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> {
|
||||
let toggle = if item.overwrites() {
|
||||
// 已经是覆盖 ⇒ 用描边按钮(弱化:覆盖是更危险的那一侧),字面写"点了会退回保留"
|
||||
buttons::outlined(
|
||||
ui,
|
||||
Button::new(
|
||||
item.toggle,
|
||||
intent(PackMessage::TogglePick(item.path.clone())),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(
|
||||
item.toggle,
|
||||
intent(PackMessage::TogglePick(item.path.clone())),
|
||||
)
|
||||
.leading(IconKind::Refresh),
|
||||
)
|
||||
};
|
||||
let head = row![
|
||||
crate::pages::frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
item.path.as_str(),
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
toggle,
|
||||
]
|
||||
.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()
|
||||
}
|
||||
Reference in new issue
Block a user