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

+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 时必须退回观测值(既有行为不变)"
);
}
}