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

@@ -31,6 +31,7 @@ use unlucky_i18n::Lang;
use unlucky_presenter::prefs::{AccentError, ACCENT_PRESETS};
use crate::app::components::inputs::{text_field, Field};
use crate::app::components::interaction::Interaction;
use crate::pages::Message;
use crate::theme::{to_iced, Ui, STROKE_PX};
@@ -44,6 +45,7 @@ pub(super) fn push<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
forced: Option<Interaction>,
rows: &mut Vec<Element<'a, Message>>,
) {
let accent = &state.accent;
@@ -68,7 +70,7 @@ pub(super) fn push<'a>(
.into(),
);
rows.push(presets(ui, accent, lang));
rows.push(presets(ui, accent, lang, forced));
// 非法值:**结构化的原因**,不是「格式不对」这种没法行动的一句话。
if let Some(error) = accent.error() {
@@ -97,7 +99,12 @@ fn accent_commit() -> Message {
///
/// 为什么分两行:一行放 9 个控件在设置卡片的正文宽度里会被挤成「7 + 1」折行,最后一颗色块
/// 孤零零掉到下一行(出图实测)。分两行是**有意**的排版,不是被挤出来的。
fn presets<'a>(ui: &'a Ui, accent: &'a Accent, lang: Lang) -> Element<'a, Message> {
fn presets<'a>(
ui: &'a Ui,
accent: &'a Accent,
lang: Lang,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let current = accent.persist_value();
let head = row![
container(label(ui, t(Key::SettingsAppearanceAccentPresets, lang)))
@@ -107,6 +114,7 @@ fn presets<'a>(ui: &'a Ui, accent: &'a Accent, lang: Lang) -> Element<'a, Messag
t(Key::SettingsAppearanceAccentFactory, lang),
current.is_empty(),
Change::Accent(String::new()),
forced,
),
]
.spacing(ui.space(Space::X2))
@@ -13,6 +13,7 @@ use unlucky_i18n::Lang;
use unlucky_presenter::prefs::BgFill;
use crate::app::components::inputs::{slider, text_field, Field, Slider};
use crate::app::components::interaction::Interaction;
use crate::pages::Message;
use crate::theme::Ui;
@@ -27,6 +28,7 @@ pub(super) fn push<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
forced: Option<Interaction>,
rows: &mut Vec<Element<'a, Message>>,
) {
// ④ 背景图开关
@@ -39,12 +41,14 @@ pub(super) fn push<'a>(
t(Key::SettingsAppearanceOff, lang),
!state.bg_enabled,
Change::BgEnabled(false),
forced,
),
choice(
ui,
t(Key::SettingsAppearanceOn, lang),
state.bg_enabled,
Change::BgEnabled(true),
forced,
),
],
));
@@ -109,18 +113,21 @@ pub(super) fn push<'a>(
t(Key::SettingsAppearanceBgFillCover, lang),
state.bg_fill == BgFill::Cover,
Change::BgFill(BgFill::Cover),
forced,
),
choice(
ui,
t(Key::SettingsAppearanceBgFillContain, lang),
state.bg_fill == BgFill::Contain,
Change::BgFill(BgFill::Contain),
forced,
),
choice(
ui,
t(Key::SettingsAppearanceBgFillTile, lang),
state.bg_fill == BgFill::Tile,
Change::BgFill(BgFill::Tile),
forced,
),
],
));
@@ -5,6 +5,7 @@ use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::PaletteSource;
use crate::app::components::interaction::Interaction;
use crate::pages::Message;
use crate::theme::Ui;
@@ -17,6 +18,7 @@ pub(super) fn push<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
forced: Option<Interaction>,
rows: &mut Vec<Element<'a, Message>>,
) {
// ⑨ 配色来源
@@ -29,18 +31,21 @@ pub(super) fn push<'a>(
t(Key::SettingsAppearancePaletteBackground, lang),
state.palette_source == PaletteSource::BackgroundImage,
Change::PaletteSource(PaletteSource::BackgroundImage),
forced,
),
choice(
ui,
t(Key::SettingsAppearancePaletteWallpaper, lang),
state.palette_source == PaletteSource::Wallpaper,
Change::PaletteSource(PaletteSource::Wallpaper),
forced,
),
choice(
ui,
t(Key::SettingsAppearancePalettePreset, lang),
state.palette_source == PaletteSource::Preset,
Change::PaletteSource(PaletteSource::Preset),
forced,
),
],
));
@@ -85,15 +85,24 @@ mod window;
///
/// `lang` 是**这一次生效的语言**:`settings::view` 的 `Language.effective`(M179)。壳把各段按
/// **视觉顺序**拼起来;每一段的语句逐字来自拆分前的 `section()`。
pub fn section<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
///
/// `forced` 是 `--force-state` 的落点(M285,`pages::Model::forced` 一路递到 `Group.body`):`None` =
/// 按控件自己的观测态画(产品路径的常态);`Some(..)` 只服务取证出图。它只改**画出来的**态——
/// 每一段把它交给 [`choice`],由 [`super::super::settings::resolve_state`] 那个唯一 resolve 点落地。
pub fn section<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let mut rows: Vec<Element<'a, Message>> = Vec::new();
// M250:强调色排在最前 —— 它是「配色可自定义」这一页最直接的入口(键一直在,控件是新的)。
accent::push(ui, state, lang, &mut rows);
window::push(ui, state, lang, &mut rows);
accent::push(ui, state, lang, forced, &mut rows);
window::push(ui, state, lang, forced, &mut rows);
panel::push(ui, state, lang, &mut rows);
bg_image::push(ui, state, lang, &mut rows);
colour_source::push(ui, state, lang, &mut rows);
bg_image::push(ui, state, lang, forced, &mut rows);
colour_source::push(ui, state, lang, forced, &mut rows);
card(
ui,
@@ -237,11 +246,14 @@ pub(super) fn choice_row<'a>(
}
/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。
///
/// `forced`(M285)只往下递给 [`choice_style`]:`Some` 时按钮按那一态画,`None` 时按观测态画。
pub(super) fn choice<'a>(
ui: &'a Ui,
text: &'a str,
selected: bool,
change: Change,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let height = ui.metrics().control_height;
let radius = ui.radius(ShapeScale::Full, height);
@@ -263,7 +275,7 @@ pub(super) fn choice<'a>(
)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X3)]))
.style(choice_style(ui, selected, content_color, radius))
.style(choice_style(ui, selected, content_color, radius, forced))
.on_press(Message::Appearance(change))
.into()
}
@@ -282,6 +294,7 @@ fn choice_style<'a>(
selected: bool,
content_color: Color,
radius: Radius,
forced: Option<Interaction>,
) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a {
move |_theme, status| {
let background = if selected {
@@ -289,7 +302,7 @@ fn choice_style<'a>(
} else {
interaction::layer_color(
ui,
Interaction::from_status(status),
crate::pages::settings::resolve_state(forced, status),
Role::OnSurface,
frame::BASE.role(),
)
@@ -353,6 +366,7 @@ mod tests {
false,
ui.color(Role::OnSurfaceVariant),
ui.radius(ShapeScale::Full, ui.metrics().control_height),
None,
);
let theme = ui.theme();
let hover = solid(style(&theme, button::Status::Hovered));
@@ -14,6 +14,7 @@ use unlucky_i18n::Lang;
use unlucky_presenter::prefs::WindowCorner;
use crate::app::components::inputs::{slider, Slider};
use crate::app::components::interaction::Interaction;
use crate::pages::Message;
use crate::theme::Ui;
@@ -27,6 +28,7 @@ pub(super) fn push<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
forced: Option<Interaction>,
rows: &mut Vec<Element<'a, Message>>,
) {
// ① 整窗透明度
@@ -59,12 +61,14 @@ pub(super) fn push<'a>(
t(Key::SettingsAppearanceCornerSystem, lang),
corner == WindowCorner::System,
Change::Corner(WindowCorner::System),
forced,
),
choice(
ui,
t(Key::SettingsAppearanceCornerSquare, lang),
corner == WindowCorner::Square,
Change::Corner(WindowCorner::Square),
forced,
),
],
));
+9 -6
View File
@@ -146,12 +146,15 @@ pub fn run(request: Request) -> Result<(), String> {
iced::application(
move || {
let mut model = demo_model(boot.screen, boot.width, boot.height);
// 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot`
// 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced),
// 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。
if boot.screenshot.is_some() {
model.forced = Some(Interaction::Enabled);
}
// 截图通道把消费 forced 的控件钉成常态,与产品 `--screenshot` 的 `ForcedState::Rest`
// 同一条纪律;显式的 `--force-state=hover|pressed` **优先**(那是故意要画那个态)。
//
// M285:这条线现在真的到得了设置页 / 外观区——`pages::Model.forced` 经
// `settings::view(.., forced)` → `Group.body` → 语言档 / 主题档 / 日志按钮 / 外观 choice,
// 由 `settings::resolve_state` 落到样式闭包。此前那四处的状态层只有单测证据。
model.forced = boot
.force_state
.or_else(|| boot.screenshot.is_some().then_some(Interaction::Enabled));
// M250:gallery 出图也要能看见强调色。**规则不在这里抄** —— 调产品用的同一个
// `theme::tokens`(优先级只定义一次)。如实补一句:harness 不建 `App`,所以这里
// 没有 D38 取色方案(第三个实参恒 `None`),出图**不覆盖**那条分支。
+36 -1
View File
@@ -12,6 +12,7 @@ use std::path::PathBuf;
use unlucky_design::color::SchemeFlavor;
use crate::app::components::interaction::Interaction;
use crate::cli::Backend;
use crate::pages::{layout::WidthClass, Screen};
@@ -47,6 +48,18 @@ pub struct Request {
///
/// 只对 `--screen=settings` 有意义:别的页没有外观分组,静默接受会造出「以为接了线」的假图(U1)。
pub real_appearance: bool,
/// 强制交互态(`--force-state=rest|hover|pressed`):`Some` 时**控件按那一态画**(M285)。
///
/// 为什么这个开关在本文件而不在产品 bin:产品 bin 的 `--screen` 只认 `shell|gallery`,设置页 /
/// 外观区只能走本 harness 出图(`examples/pages_demo.rs`)。M282 / M284 各自撞过一次——
/// `pages::Model.forced` 到不了设置页,于是「hover 与 pressed 必须不同」这条判据**做不出像素证据**,
/// 只能降级成单测。接上之后两条态各自一张图。
///
/// 词与语义与产品的 `cli::ForcedState` 一致(`rest|hover|pressed`),但**刻意不共用那个 enum**:
/// 产品那条通道的默认值与守卫(`--screenshot` 恒钉 `Rest`)是另一套,把两者拧在一起会让
/// 「取证通道的默认值」和「产品窗口的默认值」互相污染。这里存的是下游形状 [`Interaction`],
/// 落点是 `pages::Model.forced`(`harness/app.rs`)。
pub force_state: Option<Interaction>,
/// 截图窗口开**指针穿透**(`window::enable_mouse_passthrough` ⇒ winit `set_cursor_hittest(false)`)。
///
/// 默认:给了 `--screenshot` 就开;**交互模式(没有截图)一律关**——交互窗口收不到鼠标是产品事故,
@@ -68,6 +81,7 @@ impl Default for Request {
backend: Backend::Auto,
scale_factor: None,
real_appearance: false,
force_state: None,
passthrough: false,
}
}
@@ -138,6 +152,20 @@ impl Request {
}
};
}
"--force-state" => {
// 三档词与产品 bin 同一套(`cli.rs` 的 `--force-state`),报错文案也照抄。
request.force_state =
Some(match take(&mut args, inline, "--force-state")?.as_str() {
"rest" => Interaction::Enabled,
"hover" => Interaction::Hovered,
"pressed" => Interaction::Pressed,
other => {
return Err(format!(
"--force-state 只认 rest|hover|pressed,收到 {other:?}"
))
}
});
}
"--passthrough" => {
passthrough = Some(match take(&mut args, inline, "--passthrough")?.as_str() {
"on" => true,
@@ -183,7 +211,7 @@ impl Request {
Ok(request)
}
/// 出图文件名片段(`<screen>[-real]-<class>-<flavor>-w<宽>`,归档用)。
/// 出图文件名片段(`<screen>[-real][-hover|-pressed]-<class>-<flavor>-w<宽>`,归档用)。
pub fn slug(&self) -> String {
let class = WidthClass::of(self.width);
let flavor = if self.flavor.is_dark() {
@@ -198,6 +226,13 @@ impl Request {
} else {
self.screen.slug().to_owned()
};
// 强制态也进文件名(`-hover` / `-pressed`):同一屏在两个 forced 下是**两张不同的证据图**,
// 归档里必须能分开(与 `-real` 同一条理由)。`rest` / 不给 = 不加后缀 ⇒ 既有文件名逐字不变。
let screen = match self.force_state {
Some(Interaction::Hovered) => format!("{screen}-hover"),
Some(Interaction::Pressed) => format!("{screen}-pressed"),
_ => screen,
};
format!("{screen}-{}-{flavor}-w{width}", class.name())
}
}
+3 -1
View File
@@ -282,7 +282,9 @@ fn body<'a>(
content::view(ui, metrics, &model.content, model.lang, model.window_height)
}
// M159:设置页「更新」组要画 presenter 的更新快照(不在 `States` 里,理由见该组文档)。
Screen::Settings => settings::view(ui, metrics, states, language, &model.update),
Screen::Settings => {
settings::view(ui, metrics, states, language, &model.update, model.forced)
}
Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang),
};
+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,
),
));
}