feat(app-iced): --force-state 穿过设置页与外观区(M285 FORCEDWIRE)

pages::Model.forced 此前只到得了外壳 + 实例页(唯一消费者 pages/instances.rs:159),
设置页「外观」区的滑块/文本框/裸 iced::button 全都不消费它 ⇒ M284 那四处状态层
(语言档 / 主题档 / 日志 action_button / 外观 choice)只有单测证据,做不出像素判据。

改动:
- settings/mod.rs: resolve_state(forced, status) 与 forced_button(button, forced)
  成为全仓唯一两个「forced 怎么解释」的点;view → form → section_columns 一路
  新增 forced 形参(默认 None ⇒ 今天的行为逐字节不变)。
- Group.body(registry.rs)加第 4 个形参 Option<Interaction>,六组统一签名。
- 四类裸 iced::button 控件把 forced 递进样式闭包并经 resolve_state 落地:
  语言档 chip(general/language.rs)、主题档 chip(general/theme.rs)、
  日志 action_button(logs_row.rs)、外观 choice(appearance/section/mod.rs,
  含 accent/window/bg_image/colour_source 共 10 处调用)。
- components::buttons::Button 的三组(update / ai / java)走 forced_button 的 .state()。
- harness: request.rs 解析 --force-state=rest|hover|pressed(未知值报错,词与产品
  cli.rs 同一套),slug 加 -hover/-pressed;app.rs 把它写进 model.forced,
  截图通道默认 Rest 的纪律保持(显式值优先)。
- pages/mod.rs: 唯一调用点把 model.forced 传给 settings::view(397 → 399 行)。

单测:resolve_state 的 Some 覆盖 / None 退回;forced_button 的 Some 钉态 / None 原样。
This commit is contained in:
AskaEth committed 2026-11-12 23:12:20 +08:00
1 parent 767727efb7
commit 7b80b0e314
19 files changed
+397 -79

No files matched your search

+36 -8
View File
@@ -24,11 +24,12 @@ use unlucky_i18n::{Catalog, Key, Lang};
use crate::app::components::buttons::{self, Button};
use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::app::components::interaction::Interaction;
use crate::app::components::selection::{chip, switch, Chip, Switch};
use crate::pages::frame::{card, divider, text_role_owned};
use crate::pages::settings::ai::outcome::ProbeKind;
use crate::pages::settings::ai::state::{Probe, ProviderId, SaveNotice, Secret, State, Status};
use crate::pages::settings::{ai_about, GroupMessage, States};
use crate::pages::settings::{ai_about, forced_button, GroupMessage, States};
use crate::pages::Message;
use crate::theme::Ui;
@@ -38,11 +39,21 @@ fn msg(message: ai_about::Message) -> Message {
}
/// 一张卡片(`pages::settings::form` 按下标把 `Lang` 递进来,见 `settings/mod.rs`)。
pub(crate) fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Element<'a, Message> {
body_of(ui, &states.groups.ai_about, lang)
pub(crate) fn body<'a>(
ui: &'a Ui,
states: States<'a>,
lang: Lang,
forced: Option<Interaction>,
) -> Element<'a, Message> {
body_of(ui, &states.groups.ai_about, lang, forced)
}
fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
fn body_of<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let catalog = lang.catalog();
let mut stacked = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
@@ -56,7 +67,7 @@ fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message>
stacked = stacked.push(model_field(ui, catalog, state));
stacked = stacked.push(key_field(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(actions_row(ui, catalog, state));
stacked = stacked.push(actions_row(ui, catalog, state, forced));
stacked = stacked.push(hint_line(ui, catalog, state));
if let Some(notice) = &state.notice {
@@ -267,10 +278,21 @@ fn key_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a,
.into()
}
fn actions_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
fn actions_row<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &State,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let save_label = catalog.text(Key::SettingsAiSave);
let save = if state.dirty && state.storage {
buttons::filled(ui, Button::new(save_label, msg(ai_about::Message::Save)))
buttons::filled(
ui,
forced_button(
Button::new(save_label, msg(ai_about::Message::Save)),
forced,
),
)
} else {
buttons::filled(ui, Button::disabled(save_label))
};
@@ -281,7 +303,13 @@ fn actions_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, M
catalog.text(Key::SettingsAiTest)
};
let test = if state.can_test() {
buttons::tonal(ui, Button::new(test_label, msg(ai_about::Message::Test)))
buttons::tonal(
ui,
forced_button(
Button::new(test_label, msg(ai_about::Message::Test)),
forced,
),
)
} else {
buttons::tonal(ui, Button::disabled(test_label))
};
@@ -19,6 +19,7 @@ use iced::Element;
use unlucky_design::icons::IconKind;
use unlucky_i18n::Lang;
use crate::app::components::interaction::Interaction;
use crate::theme::Ui;
use super::{Group, Message, States};
@@ -39,9 +40,15 @@ pub(super) const GROUP: Group = Group {
/// 本组的真控件渲染器:有外观状态就画真控件(M93),没有(harness)返回 `None`。
///
/// `lang` 一路递给 `pages::appearance::section`——它是这一组唯一知道「界面现在是什么语言」的东西。
fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Option<Element<'a, Message>> {
/// `lang` 一路递给 `pages::appearance::section`——它是这一组唯一知道「界面现在是什么语言」的东西;
/// `forced`(`--force-state`,M285)同样一路递下去,由 `section` 分给各段控件。
fn body<'a>(
ui: &'a Ui,
states: States<'a>,
lang: Lang,
forced: Option<Interaction>,
) -> Option<Element<'a, Message>> {
states
.appearance
.map(|state| crate::pages::appearance::section(ui, state, lang))
.map(|state| crate::pages::appearance::section(ui, state, lang, forced))
}
+10 -1
View File
@@ -59,6 +59,7 @@ use unlucky_presenter::dto::find_row;
use crate::app::components::buttons::{self, Button};
use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::app::components::interaction::Interaction;
use crate::app::components::selection::{chip, switch, Chip, Switch};
use crate::pages::frame::text_role_owned;
use crate::pages::Message as PageMessage;
@@ -69,6 +70,9 @@ use super::{Group, States};
/// Downloads 分组。
///
/// `body` 的 `_forced`(`--force-state`,M285)**接住不用**:本组控件是 `components::selection`
/// 的 `chip` / `switch` 与 `Field`,components 层没有 forced 开口。留形参只为 `Group.body` 只有一个签名。
///
/// `rows` 只在**没有快照**时画(harness / 首帧):它们是语义行(出厂值 / 区间 / 三态),
/// 不是当前值——M193 用户的投诉正是「把演示值当成了真值」。
pub(super) const GROUP: Group = Group {
@@ -179,7 +183,12 @@ fn msg(message: Message) -> PageMessage {
///
/// 为什么不给 [`block`] 加 padding:那会把卡片的**第一行顶距**也撑开(卡内边距 20 + 12),与其它四组
/// 的第一行不再对齐;`spacing` 只作用在子节点之间,首行顶距与末行底距仍是卡片的 20。
fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Option<Element<'a, PageMessage>> {
fn body<'a>(
ui: &'a Ui,
states: States<'a>,
lang: Lang,
_forced: Option<Interaction>,
) -> Option<Element<'a, PageMessage>> {
let group = state_group(&states)?;
let drafts = &states.groups.download;
let catalog = lang.catalog();
+12 -3
View File
@@ -47,6 +47,7 @@ use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::catalog::Key;
use crate::app::components::interaction::Interaction;
use crate::fonts::install::{CjkInstall, FetchClass};
use crate::pages::{frame, Language, Message};
use crate::theme::Ui;
@@ -72,10 +73,17 @@ pub(super) const GROUP: Group = Group {
};
/// 这一组的正文(**产品路径**)。
pub(super) fn body<'a>(ui: &'a Ui, states: States<'a>, language: Language) -> Element<'a, Message> {
///
/// `forced` 是 `--force-state`(M285):本组只有语言行 / 主题行 / 日志行三处有可交互控件,三处都往下递。
pub(super) fn body<'a>(
ui: &'a Ui,
states: States<'a>,
language: Language,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let catalog = language.effective.catalog();
let mut stacked = column![].spacing(0.0).width(Length::Fill);
stacked = stacked.push(language::view(ui, language));
stacked = stacked.push(language::view(ui, language, forced));
// M168(D28 后半):中文字体的下载状态行。紧贴语言行——用户在这里手选中文,反馈就该在这行下面,
// 而不是等他切到别的页面才发现「刚才那几秒在干嘛」。
stacked = stacked.push(divider(ui));
@@ -88,12 +96,13 @@ pub(super) fn body<'a>(ui: &'a Ui, states: States<'a>, language: Language) -> El
ui,
&catalog,
states.groups.language_appearance.theme,
forced,
));
// M188:日志文件夹(路径 + 打开 + 复制路径)。放在「启动页/关闭按钮」之前——用户投诉
// 「找不到崩溃日志」要的答案,不该埋在只读行后面。
stacked = stacked.push(divider(ui));
stacked = stacked.push(super::logs_row::folder_row(ui, &catalog));
stacked = stacked.push(super::logs_row::folder_row(ui, &catalog, forced));
// 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。
for (label, value) in [
@@ -24,7 +24,11 @@ use crate::pages::{frame, Language, Message};
use crate::theme::Ui;
/// 语言行:标题 + 生效语言回显 + 三档互斥按钮 + 一句说明。
pub(super) fn view<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
pub(super) fn view<'a>(
ui: &'a Ui,
language: Language,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let catalog = language.effective.catalog();
let effective = LangChoice::Fixed(language.effective).label_key();
@@ -60,6 +64,7 @@ pub(super) fn view<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
catalog.text(choice.label_key()),
*choice == language.choice,
*choice,
forced,
)
})
.collect::<Vec<_>>())
@@ -90,7 +95,13 @@ pub(super) fn view<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
/// **几何(M229)**:取规格卡的 chip(高 32、圆角 8、内距 16、未选中 1px `OutlineVariant`),
/// 与同屏的主题三档 chips 完全同源——两行都是「并排的互斥筛选」,长得不一样会让读者以为是
/// 两种控件(M229 第一版就是这样:语言 40 高药丸、主题 32 高方角,出图一眼就不齐)。
fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> Element<'a, Message> {
fn chip<'a>(
ui: &'a Ui,
text: &'a str,
selected: bool,
choice: LangChoice,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let box_ = unlucky_design::inkcat::metrics::CHIP;
let height = box_.height;
let content_color = if selected {
@@ -124,6 +135,7 @@ fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> El
border_color,
stroke,
radius,
forced,
))
.on_press(Message::Language(choice))
.into()
@@ -131,16 +143,20 @@ fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> El
/// [`chip`] 的样式闭包(抽成自由函数:单测要**直接**喂 `Hovered` / `Pressed` 两态)。
///
/// `forced`(M285)是 `--force-state` 的落点,交给 `settings::resolve_state`(全仓唯一的 resolve 点);
/// `None` 时行为与 M285 之前逐字节相同。`pub(super)` 是因为 `general/theme.rs` 的单测要直接喂它。
///
/// - **α 只从 [`Interaction`] 来**:hover 8% / pressed 10%(M3)。M284 之前两种态合并成一条
/// `matches!(status, Hovered | Pressed)` 分支、都叠 `StateLayer::Hover`,按下于是被压成 8%;
/// - **合成底是 [`frame::BASE`]**(M237:这一组住在 `frame::card` 里,状态层叠卡底而不是画布)。
fn chip_style<'a>(
pub(super) fn chip_style<'a>(
ui: &'a Ui,
selected: bool,
content_color: Color,
border_color: Color,
stroke: f32,
radius: Radius,
forced: Option<Interaction>,
) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a {
move |_theme, status| {
let background = if selected {
@@ -148,7 +164,7 @@ fn chip_style<'a>(
} else {
interaction::layer_color(
ui,
Interaction::from_status(status),
crate::pages::settings::resolve_state(forced, status),
Role::OnSurface,
frame::BASE.role(),
)
@@ -31,7 +31,14 @@ use crate::theme::Ui;
pub(super) const THEME_MODES: [ThemeMode; 3] = [ThemeMode::Dark, ThemeMode::Light, ThemeMode::Auto];
/// 主题行:标题 + 三档互斥 chip + 一句说明(排版与语言行同族)。
pub(super) fn view<'a>(ui: &'a Ui, catalog: &Catalog, mode: ThemeMode) -> Element<'a, Message> {
///
/// `forced`(M285)只往下递给 chip:`None` = 按观测态画(产品路径的常态)。
pub(super) fn view<'a>(
ui: &'a Ui,
catalog: &Catalog,
mode: ThemeMode,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let head = row![
container(frame::text_role(
ui,
@@ -61,6 +68,7 @@ pub(super) fn view<'a>(ui: &'a Ui, catalog: &Catalog, mode: ThemeMode) -> Elemen
catalog.text(mode_label(*choice)),
*choice == mode,
*choice,
forced,
)
})
.collect::<Vec<_>>())
@@ -97,7 +105,13 @@ fn mode_label(mode: ThemeMode) -> Key {
///
/// 几何取规格卡的 chip(高 32、圆角 8,`inkcat::metrics::CHIP`):主题三档是**可并排的筛选**,
/// 不是主行动按钮,用 chip 档比语言行那枚 40 高药丸更贴规格卡。
fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, mode: ThemeMode) -> Element<'a, Message> {
fn chip<'a>(
ui: &'a Ui,
text: &'a str,
selected: bool,
mode: ThemeMode,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let box_ = chipbox::CHIP;
let height = box_.height;
let content_color = if selected {
@@ -128,6 +142,7 @@ fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, mode: ThemeMode) -> Eleme
border_color,
stroke,
radius,
forced,
))
.on_press(Message::Theme(mode))
.into()
@@ -145,6 +160,7 @@ fn chip_style<'a>(
border_color: Color,
stroke: f32,
radius: Radius,
forced: Option<Interaction>,
) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a {
move |_theme, status| {
let background = if selected {
@@ -152,7 +168,7 @@ fn chip_style<'a>(
} else {
interaction::layer_color(
ui,
Interaction::from_status(status),
crate::pages::settings::resolve_state(forced, status),
Role::OnSurface,
frame::BASE.role(),
)
@@ -254,17 +270,19 @@ mod tests {
ui.color(Role::OutlineVariant),
crate::theme::STROKE_PX,
ui.radius(chipbox::CHIP.shape, chipbox::CHIP.height),
None,
),
);
}
/// **语言行那枚 chip**(`general::chip` 的 `chip_style`)同理:hover 8% / pressed 10%。
///
/// 为什么这条断言住在 `theme.rs` 而不是 `general.rs`:后者已经 389 行(硬线 400),而
/// `general` 的私有项对子模块可见(Rust 的可见性包含后代模块),搬过来不用加 `pub`。
/// 为什么这条断言住在 `theme.rs` 而不是语言行自己的文件:M285 起语言行在
/// `general/language.rs`,它的 `chip_style` 声明成 `pub(super)`(`general` 子树可见),
/// 所以这里不必把它抬成 crate 面。
#[test]
fn the_language_chip_draws_pressed_darker_than_hover() {
use super::super::chip_style as language_chip_style;
use super::super::language::chip_style as language_chip_style;
let ui = ui();
assert_two_layers(
@@ -276,6 +294,7 @@ mod tests {
ui.color(Role::OutlineVariant),
crate::theme::STROKE_PX,
ui.radius(chipbox::CHIP.shape, chipbox::CHIP.height),
None,
),
);
}
+32 -10
View File
@@ -24,6 +24,7 @@ use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use crate::app::components::buttons::{self, Button};
use crate::app::components::interaction::Interaction;
use crate::app::components::selection::{chip, Chip};
use crate::pages::frame::{card, divider, text_role_owned, BASE};
use crate::pages::Message;
@@ -32,7 +33,7 @@ use crate::theme::Ui;
use super::fields::{actions_row, add_row, hint_line, jvm_field, memory_field, notice_line};
use super::state::{Progress, RuntimeRow, State};
use super::Message as JavaMessage;
use crate::pages::settings::{GroupMessage, States};
use crate::pages::settings::{forced_button, GroupMessage, States};
/// 一条本组消息 → 页面消息。
pub(super) fn msg(message: JavaMessage) -> Message {
@@ -40,15 +41,25 @@ pub(super) fn msg(message: JavaMessage) -> Message {
}
/// 一张卡片(`pages::settings::form` 按下标把 `Lang` 递进来)。
pub(crate) fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Element<'a, Message> {
body_of(ui, &states.groups.java, lang)
pub(crate) fn body<'a>(
ui: &'a Ui,
states: States<'a>,
lang: Lang,
forced: Option<Interaction>,
) -> Element<'a, Message> {
body_of(ui, &states.groups.java, lang, forced)
}
fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
fn body_of<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let catalog = lang.catalog();
let mut stacked = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
stacked = stacked.push(status_row(ui, catalog, state));
stacked = stacked.push(status_row(ui, catalog, state, forced));
stacked = stacked.push(divider(ui));
stacked = stacked.push(runtime_list(ui, catalog, state));
stacked = stacked.push(divider(ui));
@@ -77,7 +88,12 @@ fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message>
///
/// 「取消」的语义(r1 复审 P2-5):按下之后到线程真退出之间,界面显示的是**正在取消…**,
/// 「立即扫描」保持禁用——否则用户能在旧线程还活着的时候起第二个。
fn status_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
fn status_row<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &State,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let (text, scanning) = match (state.scanning, state.scanned) {
(Some(_), _) if state.cancelled => {
(catalog.text(Key::SettingsJavaCancelling).to_string(), true)
@@ -121,16 +137,22 @@ fn status_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Me
} else {
buttons::filled(
ui,
Button::new(scan_label, msg(JavaMessage::Scan)).base(BASE),
forced_button(
Button::new(scan_label, msg(JavaMessage::Scan)).base(BASE),
forced,
),
)
};
// 已经按过取消(线程还没退出)时,「取消」也灰掉:再按一次没有任何意义。
let cancel = buttons::tonal(
ui,
if scanning && !state.cancelled {
Button::new(
catalog.text(Key::SettingsJavaCancel),
msg(JavaMessage::CancelScan),
forced_button(
Button::new(
catalog.text(Key::SettingsJavaCancel),
msg(JavaMessage::CancelScan),
),
forced,
)
} else {
Button::disabled(catalog.text(Key::SettingsJavaCancel))
+17 -4
View File
@@ -29,7 +29,11 @@ use crate::pages::{frame, Action, Message};
use crate::theme::Ui;
/// 这一行(竖排:标签 / 路径 / 两个按钮 / 一句说明)。
pub(super) fn folder_row<'a>(ui: &'a Ui, catalog: &Catalog) -> Element<'a, Message> {
pub(super) fn folder_row<'a>(
ui: &'a Ui,
catalog: &Catalog,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let path = crate::app::logs_folder::logs_dir()
.map(|dir| dir.display().to_string())
.unwrap_or_else(|| catalog.text(Key::SettingsLogsUnknown).to_string());
@@ -40,11 +44,13 @@ pub(super) fn folder_row<'a>(ui: &'a Ui, catalog: &Catalog) -> Element<'a, Messa
ui,
catalog.text(Key::LogsOpenFolder),
Message::Action(Action::OpenLogs),
forced,
),
action_button(
ui,
catalog.text(Key::SettingsLogsCopy),
Message::CopySummary(path.clone()),
forced,
),
]
.spacing(ui.space(Space::X2))
@@ -97,7 +103,12 @@ fn frame_text_owned<'a>(
}
/// 描边小按钮(与设置页其他「次级动作」同款:透明底、hover 出状态层、全圆角)。
fn action_button<'a>(ui: &'a Ui, label: &'a str, message: Message) -> Element<'a, Message> {
fn action_button<'a>(
ui: &'a Ui,
label: &'a str,
message: Message,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let height = ui.metrics().control_height;
let content = ui.color(Role::OnSurface);
let outline = ui.color(Role::OutlineVariant);
@@ -107,7 +118,7 @@ fn action_button<'a>(ui: &'a Ui, label: &'a str, message: Message) -> Element<'a
)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X3)]))
.style(action_style(ui, content, outline, radius))
.style(action_style(ui, content, outline, radius, forced))
.on_press(message)
.into()
}
@@ -122,11 +133,12 @@ fn action_style<'a>(
content: Color,
outline: Color,
radius: Radius,
forced: Option<Interaction>,
) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a {
move |_theme, status| {
let background = interaction::layer_color(
ui,
Interaction::from_status(status),
crate::pages::settings::resolve_state(forced, status),
Role::OnSurface,
frame::BASE.role(),
)
@@ -189,6 +201,7 @@ mod tests {
ui.color(Role::OnSurface),
ui.color(Role::OutlineVariant),
ui.radius(ShapeScale::Full, ui.metrics().control_height),
None,
);
let theme = ui.theme();
let hover = solid(style(&theme, button::Status::Hovered));
+95 -9
View File
@@ -148,7 +148,7 @@ mod tests;
pub use message::GroupMessage;
pub use state::{demo_groups, Groups, States};
use iced::widget::column;
use iced::widget::{button, column};
use iced::{Element, Length};
use unlucky_design::color::Role;
@@ -156,6 +156,8 @@ use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Lang, LangChoice};
use crate::app::components::buttons::Button;
use crate::app::components::interaction::Interaction;
use crate::theme::Ui;
use super::layout::{FrameMetrics, PageLayout};
@@ -165,6 +167,36 @@ use super::Message;
pub(super) use registry::Group;
use registry::{AI_GROUP, GENERAL_GROUP, GROUPS, GROUP_LABELS, JAVA_GROUP, SELECTED, UPDATE_GROUP};
/// 这一帧某个控件**实际要画**的交互态:`forced`(`--force-state`)优先,否则退回观测到的 `status`。
///
/// **全仓唯一的 resolve 点(M285)**:语言档 / 主题档 / 日志 `action_button` / 外观 `choice` 四个站点
/// 一律调它。谁在自己那个样式闭包里再写一遍 `unwrap_or_else`,就是把这行语义复制成四份——下一次
/// 只改三处就等着漂。
///
/// 语义与 `pages/instances.rs` 的 `forced_state`(`Button::state(…)`,`Some` 覆盖、`None` 不动)相同;
/// 差别只在 iced 的裸 `button` 没有 `.state()`:样式闭包的签名固定收 `button::Status`,所以覆盖只能
/// 发生在闭包**里面**(换掉它拿到的那一态),而不是控件上。
pub(super) fn resolve_state(forced: Option<Interaction>, status: button::Status) -> Interaction {
forced.unwrap_or_else(|| Interaction::from_status(status))
}
/// 把一个 `components::buttons::Button` 钉到 `forced` 那一态(`pages/instances.rs` 的 `forced_state`
/// 在设置页的这一份)。
///
/// **这是第二份,不是抽象**(M285):实例页那条线的控件本身就是 `components::buttons::Button`
///(有 `state(Interaction)`),而设置页的 `choice` / `chip` / `action_button` 是裸 `iced::button`——
/// 覆盖只能落在样式闭包里(见 [`resolve_state`])。三次法则现在到 2;第三次真出现时落点是
/// `components/interaction/`,不是这里。
pub(super) fn forced_button<'a, M>(
button: Button<'a, M>,
forced: Option<Interaction>,
) -> Button<'a, M> {
match forced {
Some(state) => button.state(state),
None => button,
}
}
/// 设置页 General 分组要的两件(M106):用户选的档位 + 这一次生效的语言。
///
/// 为什么不塞进 `pages::Model`:那是页面与 presenter 之间的**展示契约**(由快照派生),而档位是
@@ -194,12 +226,17 @@ impl Default for Language {
///
/// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态
/// (`None` = harness / 演示路径,设置页仍画占位行),[`States::groups`] 是各组自己的状态。
///
/// `forced` 是 `pages::Model::forced`(`--force-state` 的落点,M285):一路递到各组的控件,由
/// [`resolve_state`] 决定「这一帧画哪一态」。`None` = 照旧按控件自己的观测态画——**产品路径恒为
/// `None`**(除了取证启动),所以默认行为逐字节不变。
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
states: States<'a>,
language: Language,
update: &'a Update,
forced: Option<Interaction>,
) -> Element<'a, Message> {
// 选中分组:有真实外观状态时看状态(点左列可切),否则退回演示值 0。
let selected = states
@@ -212,10 +249,10 @@ pub fn view<'a>(
frame::group_nav(ui, selected, language.effective),
frame::left_aligned(
metrics.column,
form(ui, Some(selected), states, language, update),
form(ui, Some(selected), states, language, update, forced),
),
),
_ => frame::single_column(metrics, form(ui, None, states, language, update)),
_ => frame::single_column(metrics, form(ui, None, states, language, update, forced)),
};
frame::page(ui, metrics, frame::header(ui, language.effective), body)
@@ -240,10 +277,11 @@ fn form<'a>(
states: States<'a>,
language: Language,
update: &'a Update,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill);
for section in section_columns(ui, only, states, language, update) {
for section in section_columns(ui, only, states, language, update, forced) {
stack = stack.push(section);
}
@@ -258,6 +296,7 @@ fn section_columns<'a>(
states: States<'a>,
language: Language,
update: &'a Update,
forced: Option<Interaction>,
) -> Vec<Element<'a, Message>> {
let catalog = language.effective.catalog();
let mut columns = Vec::new();
@@ -274,20 +313,20 @@ fn section_columns<'a>(
// 档位与生效语言不在 [`States`] 里,只能从 `pages::Language` 进来;它与 `appearance` 在不在场
// 无关(harness 那条路径也画得出真控件)。
let card = if index == GENERAL_GROUP {
general::body(ui, states, language)
general::body(ui, states, language, forced)
} else if index == UPDATE_GROUP {
// M159:更新组读 presenter 快照(`view` 的 `update` 形参),与 General 同一条按下标分发。
update::body(ui, update, language.effective)
update::body(ui, update, language.effective, forced)
} else if index == AI_GROUP {
// M174:助手组要 i18n 文案与「测试连接」结论,与 General / Update 同一条按下标分发。
ai::view::body(ui, states, language.effective)
ai::view::body(ui, states, language.effective, forced)
} else if index == JAVA_GROUP {
// M175:Java 组要 i18n 文案 + 扫描快照,同样按下标分发(`GROUP.body` 保持 `None`)。
java::view::body(ui, states, language.effective)
java::view::body(ui, states, language.effective, forced)
} else {
group
.body
.and_then(|body| body(ui, states, language.effective))
.and_then(|body| body(ui, states, language.effective, forced))
.unwrap_or_else(|| frame::demo_card(ui, group.rows))
};
@@ -313,3 +352,50 @@ fn section_columns<'a>(
columns
}
#[cfg(test)]
mod resolve_tests {
use super::*;
/// `forced` 必须**覆盖**观测到的 status;不给 `forced` 时必须**退回**观测值(M285)。
///
/// 抓得住:把 `Some` 分支改成忽略 forced(直接 `Interaction::from_status(status)`)——第一条立刻红,
/// 而那正好等于「`--force-state` 在四个控件上没接线」。
/// 抓不住:forced 真的画进了 PNG(那是 `pages_demo --force-state=hover|pressed` 的像素判据)。
/// `forced_button` 只做一件事:`Some` 钉态、`None` 原样(M285)。
///
/// `Button::state` 是私有字段、没有 getter,所以断 **derived Debug** 里的那一格。
/// 抓得住:`Some` 分支被改成恒 `None`(第一条红);`None` 分支也顺手 `.state(..)`(第二条红)。
/// 抓不住:按钮真画成那色(那是出图的事)。
#[test]
fn forced_button_forces_only_when_asked() {
let plain = Button::new("x", ());
let forced = format!(
"{:?}",
forced_button(plain.clone(), Some(Interaction::Hovered))
);
assert!(
forced.contains("state: Some(Hovered)"),
"forced 必须钉到那一态:{forced}"
);
let untouched = format!("{:?}", forced_button(plain, None));
assert!(
untouched.contains("state: None"),
"没给 forced 时必须原样返回:{untouched}"
);
}
#[test]
fn forced_state_wins_over_the_observed_status() {
assert_eq!(
resolve_state(Some(Interaction::Hovered), button::Status::Pressed),
Interaction::Hovered,
"forced 必须覆盖观测到的 status,否则 --force-state 在这四个控件上等于没接线"
);
assert_eq!(
resolve_state(None, button::Status::Pressed),
Interaction::Pressed,
"没给 forced 时必须退回观测值(既有行为不变)"
);
}
}
@@ -23,6 +23,7 @@ use iced::Element;
use unlucky_design::icons::IconKind;
use unlucky_i18n::Lang;
use crate::app::components::interaction::Interaction;
use crate::theme::Ui;
use super::{ai_about, appearance, download, general, java, update, Message, States};
@@ -36,7 +37,12 @@ use super::{ai_about, appearance, download, general, java, update, Message, Stat
///
/// `lang` 是 M179 加的:槽位此前拿不到语言,唯一挂了真控件的外观组只能写死英文;接进**唯一的那个
/// 槽位**比再给 [`super::form`] 加一条按下标分发的 `else if` 更省行数(本文件贴着 400 行硬顶)。
pub(crate) type Body = for<'a> fn(&'a Ui, States<'a>, Lang) -> Option<Element<'a, Message>>;
///
/// `forced` 是 M285 加的(`pages::Model::forced`,即 `--force-state`):`None` = 按控件自己的观测态画
/// (产品路径的常态),`Some(..)` 只服务取证。站点拿它去调 [`super::resolve_state`]——那是全仓唯一的
/// resolve 点,别在这里或各组里另写一遍。
pub(crate) type Body =
for<'a> fn(&'a Ui, States<'a>, Lang, Option<Interaction>) -> Option<Element<'a, Message>>;
/// 一个设置分组:名字、图标、演示行、(可选)把这一组正文画成真控件的渲染器。
///
+38 -13
View File
@@ -29,6 +29,7 @@ use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::human_bytes;
use crate::app::components::buttons::{self, Button};
use crate::app::components::interaction::Interaction;
use crate::app::components::selection::{chip, Chip};
use crate::pages::frame::text_role_owned;
@@ -38,7 +39,7 @@ use crate::pages::UpdateAction;
use crate::theme::Ui;
use super::frame::{card, divider};
use super::Group;
use super::{forced_button, Group};
/// 「更新」分组在 [`super::GROUPS`] 里的下标(**必须是最后一个**:加组只追加不重排)。
pub(super) const UPDATE_GROUP: usize = 4;
@@ -57,7 +58,12 @@ pub(super) const GROUP: Group = Group {
};
/// 渲染整组正文。
pub(super) fn body<'a>(ui: &'a Ui, update: &'a Update, lang: Lang) -> Element<'a, Message> {
pub(super) fn body<'a>(
ui: &'a Ui,
update: &'a Update,
lang: Lang,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let catalog = lang.catalog();
let mut stacked = column![].spacing(0.0).width(Length::Fill);
@@ -73,9 +79,9 @@ pub(super) fn body<'a>(ui: &'a Ui, update: &'a Update, lang: Lang) -> Element<'a
channel_label(catalog, &update.channel),
));
stacked = stacked.push(divider(ui));
stacked = stacked.push(auto_check_row(ui, catalog, update));
stacked = stacked.push(auto_check_row(ui, catalog, update, forced));
stacked = stacked.push(divider(ui));
stacked = stacked.push(status_block(ui, catalog, update));
stacked = stacked.push(status_block(ui, catalog, update, forced));
card(ui, stacked)
}
@@ -121,12 +127,20 @@ fn info_row<'a>(ui: &'a Ui, label: &'a str, value: String) -> Element<'a, Messag
/// **忙碌时「立即检查」置灰**(M161):此前它永远可点,而 presenter 的 `begin_check` 在
/// inflight 时静默 `return None`——用户点了没反应,还以为启动器卡了。禁用态与状态行同源
/// (同一个 [`Update::busy`]):按钮灰着的时候,下面那句状态行一定是「正在检查…」/「正在安装…」。
fn auto_check_row<'a>(ui: &'a Ui, catalog: Catalog, update: &Update) -> Element<'a, Message> {
fn auto_check_row<'a>(
ui: &'a Ui,
catalog: Catalog,
update: &Update,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let check_now = catalog.text(Key::SettingsUpdateCheckNow);
let check = if update.can_check() {
buttons::text(
ui,
Button::new(check_now, Message::Update(UpdateAction::CheckNow)),
forced_button(
Button::new(check_now, Message::Update(UpdateAction::CheckNow)),
forced,
),
)
} else {
buttons::text(ui, Button::disabled(check_now))
@@ -150,7 +164,12 @@ fn auto_check_row<'a>(ui: &'a Ui, catalog: Catalog, update: &Update) -> Element<
}
/// 上次检查时间 + 结论 + (有候选时)下载按钮 + 说明摘要 + 一句校验提示。
fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element<'a, Message> {
fn status_block<'a>(
ui: &'a Ui,
catalog: Catalog,
update: &'a Update,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let mut block = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
let checked = if update.checked_at.trim().is_empty() {
@@ -174,14 +193,17 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element
});
block = block.push(text_role_owned(ui, TypeRole::BodySmall, false, text, color));
if let Some((_version, _size, notes, forced)) = update.available() {
if let Some((_version, _size, notes, forced_update)) = update.available() {
// 有候选就**永远画出**这一颗按钮:忙碌时置灰(`can_apply` = false),而不是整颗消失。
// 消失会让人以为「装到一半的功能没了」;灰着 + 状态行那句「正在安装…」才说得清。
let apply_label = catalog.text(Key::SettingsUpdateApply);
let apply = if update.can_apply() {
buttons::filled(
ui,
Button::new(apply_label, Message::Update(UpdateAction::Apply)),
forced_button(
Button::new(apply_label, Message::Update(UpdateAction::Apply)),
forced,
),
)
} else {
buttons::filled(ui, Button::disabled(apply_label))
@@ -189,7 +211,7 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element
let mut actions = row![apply]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
if forced {
if forced_update {
actions = actions.push(text_role_owned(
ui,
TypeRole::BodySmall,
@@ -220,9 +242,12 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element
));
block = block.push(buttons::tonal(
ui,
Button::new(
catalog.text(Key::UpdateBannerRestartNow),
Message::Update(UpdateAction::RestartNow),
forced_button(
Button::new(
catalog.text(Key::UpdateBannerRestartNow),
Message::Update(UpdateAction::RestartNow),
),
forced,
),
));
}