feat(app-iced): 单行输入框透出 on_submit,回车接上存档改名与强调色提交
债务台账 P18(M250 + M247 的共同缺口):iced 0.14 的 text_input 有 on_submit、 没有 on_unfocus(builder 逐条核过:on_input/on_input_maybe/on_submit/on_submit_maybe/ on_paste/on_paste_maybe,on_unfocus 零命中),而本仓的 text_field 只透出 on_input ⇒ 行内重命名只能点按钮、强调色拿不到「用户确认」这一下。 - field.rs:Field 变 `Field<'a, M>`(M 只被新加的 on_submit 用到,调用点的类型由 text_field 的 on_input 一并推出,一行都不用改),回车消息唯一从 submit_message() 取,`on_submit_maybe(None)` 是默认档 ⇒ 既有调用点逐位不变;禁用态与 on_input 同一 条规矩被摘掉。 - 存档改名:回车与「改名」按钮共用 commit_rename() 一个消息产地,空名/原名不变/ 非法字符那几道校验因此对回车同样成立。 - 强调色:回车发 Change::AccentCommit(不是又一次 Accent(原文))⇒ 立即 commit(), 把「打了一半就放着」的 #12 亮成红框 + 错行;逐键不报短错的规矩不变,落盘仍取 上一次合法值。 - 判据:submit_is_opt_in(未接就是 None)、text_field_forwards_the_submit_callback、 submit_is_wired_only_at_the_two_call_sites(白名单扫源码)、 enter_takes_the_same_message_as_the_rename_button、the_rename_field_wires_enter、 the_accent_field_wires_enter_to_commit、enter_commits_a_half_typed_value_and_ persists_only_legal_ones。真键盘回车量不到(Element 无内省、造不出 iced::Renderer 的测试替身),报告里单列。
This commit is contained in:
1 parent
026b9f6e94
commit
f7cfc8ded6
13 files changed
+384
-22
No files matched your search
@@ -17,6 +17,22 @@
|
||||
//!
|
||||
//! 1. **状态差异落在指示线/描边/标签上,容器底色不叠状态层**(A1 的口径,与按钮家族的 8%/10% 状态层不同);press 无独立 token,按 hover 处理(D11)。
|
||||
//! 2. 标签是「**常浮起**」的两段式(占上面 16px,不做上浮动画,登记 D12);缺省宽度 `Fill`(最小宽 280 无法在 iced 表达,G21)。
|
||||
//!
|
||||
//! ## 回车与失焦(P18 / M252):只有回车是真的事件
|
||||
//!
|
||||
//! `iced_widget-0.14.2/src/text_input.rs` 的 builder 一共这几条(**逐条从源码抄的**,别凭记忆):
|
||||
//!
|
||||
//! | 方法 | 行 | 产物 |
|
||||
//! |---|---|---|
|
||||
//! | `on_input` / `on_input_maybe` | 172 / 184 | 每次改动后发一条消息 |
|
||||
//! | `on_submit` / `on_submit_maybe` | 194 / 201 | **焦点在框内时**按回车发**一条**消息(`:1049-1053`:一个 `KeyPressed` 事件只 `publish` 一次;不在焦点内的回车走不到这里) |
|
||||
//! | `on_paste` / `on_paste_maybe` | 208 / 218 | 粘贴 |
|
||||
//! | ~~`on_unfocus`~~ | **整个文件 0 命中** | 失焦**没有**消息通道:`State::unfocus()`(`:1524`)与 `Focusable for State`(`:1563`)只改控件内部状态,不发消息 |
|
||||
//!
|
||||
//! 所以本封装的取舍是:**把有的事件透出去**([`Field::on_submit`]),失焦继续用代理近似
|
||||
//! (回车 + 点到别的控件),**不为它去 fork iced**。`on_submit` 是**可选**的:不调
|
||||
//! [`Field::on_submit`] 时 `text_input` 的 `on_submit` 保持空(`on_submit_maybe(None)`),
|
||||
//! 回车**什么消息都不发**——全站既有调用点因此逐位不变(回归见 `tests.rs`)。
|
||||
|
||||
use iced::widget::text::LineHeight;
|
||||
use iced::widget::{column, container, row, text, text_input};
|
||||
@@ -62,8 +78,10 @@ impl FieldVariant {
|
||||
}
|
||||
|
||||
/// 一个文本框的装配描述(不持有 `String`:受控组件只借值,契约 §2)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Field<'a> {
|
||||
///
|
||||
/// `M` 是**消息类型**,只被 [`Field::on_submit`] 用到:不给提交回调时它跟 `on_input` 的消息同型,
|
||||
/// 调用点照旧写 `Field::new(value)…`(类型由 `text_field` 的 `on_input` 一并推出,不用标注)。
|
||||
pub struct Field<'a, M> {
|
||||
variant: FieldVariant,
|
||||
value: &'a str,
|
||||
label: Option<&'a str>,
|
||||
@@ -77,9 +95,11 @@ pub struct Field<'a> {
|
||||
secure: bool,
|
||||
/// 脚下那一层的底(M234,禁用态合成用)。
|
||||
base: Base,
|
||||
/// 回车要发的那条消息(P18;`None` = 回车什么都不发生,既有调用点全是这一档)。
|
||||
on_submit: Option<Box<dyn Fn() -> M + 'a>>,
|
||||
}
|
||||
|
||||
impl<'a> Field<'a> {
|
||||
impl<'a, M> Field<'a, M> {
|
||||
/// 一个 Filled 文本框。
|
||||
pub fn new(value: &'a str) -> Self {
|
||||
Self {
|
||||
@@ -95,6 +115,7 @@ impl<'a> Field<'a> {
|
||||
width: Length::Fill,
|
||||
secure: false,
|
||||
base: Base::CANVAS,
|
||||
on_submit: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,21 +187,46 @@ impl<'a> Field<'a> {
|
||||
self.secure = secure;
|
||||
self
|
||||
}
|
||||
|
||||
/// **回车提交**(P18 / M252):焦点在框内时按回车,发这一条消息。
|
||||
///
|
||||
/// 不调就是「回车什么都不发生」——这是默认档,也是全站既有调用点的现状。
|
||||
/// 禁用态([`Field::state`] = `Disabled`)里回车**不发**(与 `on_input` 同一条规矩:
|
||||
/// 点不动的框也不该能提交)。
|
||||
pub fn on_submit(mut self, on_submit: impl Fn() -> M + 'a) -> Self {
|
||||
self.on_submit = Some(Box::new(on_submit));
|
||||
self
|
||||
}
|
||||
|
||||
/// 回车那条消息(`None` = 没接 [`Field::on_submit`])。**唯一的读取口**:
|
||||
/// `text_field` 只从这里取,于是「接没接」与「发不发」是同一个事实。
|
||||
fn submit_message(&self) -> Option<M> {
|
||||
self.on_submit.as_ref().map(|on_submit| on_submit())
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个文本框(Filled / Outlined 由 [`Field::variant`] 选)。
|
||||
///
|
||||
/// 受控:值由外部持有,输入经 `on_input` 回传;`state` 强制成 `Disabled` 时输入被摘掉
|
||||
/// (`on_input_maybe(None)`),所以禁用字段真的点不动,不只是变灰。
|
||||
/// 回车(P18):只在 [`Field::on_submit`] 被调过时才接(`on_submit_maybe`),没接就是
|
||||
/// 「回车什么都不发生」。
|
||||
pub fn text_field<'a, M: Clone + 'a>(
|
||||
ui: &'a Ui,
|
||||
field: Field<'a>,
|
||||
field: Field<'a, M>,
|
||||
on_input: impl Fn(String) -> M + 'a,
|
||||
) -> Element<'a, M> {
|
||||
let geometry = FieldGeometry::of(ui, field.label.is_some(), field.leading);
|
||||
let state = field.state.unwrap_or(Interaction::Enabled);
|
||||
let colors = palette(ui, field.base, field.variant, state, field.error);
|
||||
|
||||
// 提交消息只在**可交互**时接:禁用字段与 `on_input` 一样被摘掉。
|
||||
let submit = if state.is_disabled() {
|
||||
None
|
||||
} else {
|
||||
field.submit_message()
|
||||
};
|
||||
|
||||
let input_style = ui.style(TypeRole::BodyLarge);
|
||||
let label_style = ui.style(TypeRole::BodySmall);
|
||||
|
||||
@@ -190,6 +236,7 @@ pub fn text_field<'a, M: Clone + 'a>(
|
||||
} else {
|
||||
Some(on_input)
|
||||
})
|
||||
.on_submit_maybe(submit)
|
||||
.secure(field.secure)
|
||||
.size(input_style.size_px)
|
||||
.line_height(LineHeight::Absolute(Pixels(input_style.line_height_px)))
|
||||
|
||||
@@ -17,7 +17,7 @@ fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
fn build(field: Field<'_>, flavor: SchemeFlavor) -> Element<'_, u8> {
|
||||
fn build(field: Field<'_, u8>, flavor: SchemeFlavor) -> Element<'_, u8> {
|
||||
let ui = Box::leak(Box::new(ui(flavor)));
|
||||
text_field(ui, field, |_| 0_u8)
|
||||
}
|
||||
@@ -163,3 +163,111 @@ fn filled_carries_the_container_role() {
|
||||
);
|
||||
assert_eq!(FieldGeometry::of(&ui, true, None).shape.dp(), 4.0);
|
||||
}
|
||||
|
||||
/// 本文件所在模块的源码(源码结构断言用,`pages::layout::source` 的同一条纪律)。
|
||||
const FIELD_SOURCE: &str = include_str!("../field.rs");
|
||||
|
||||
/// P18 / M252 判据 ④-a:`on_submit` 是**可选**的——不接就没有提交消息。
|
||||
///
|
||||
/// 抓得住:`Field::new(..)` 这条链(其余构造器全接上)给出 `None` ⇒ `text_field` 交给
|
||||
/// `text_input` 的是 `on_submit_maybe(None)`,回车**什么消息都不发**(全站既有调用点因此逐位
|
||||
/// 不变);接了之后取到的正是那条消息(回调没被构造器吞掉)。
|
||||
/// 抓不住:回车键真的走到控件(iced 的键路径在 `Widget::update` 里,`Element` 没有内省接口,
|
||||
/// 见 `pages::layout::source` 的模块文档;本仓也造不出 `iced::Renderer` 的测试替身)。
|
||||
#[test]
|
||||
fn submit_is_opt_in() {
|
||||
let plain: Field<'_, u8> = Field::new("实例一")
|
||||
.variant(FieldVariant::Outlined)
|
||||
.label("名称")
|
||||
.placeholder("请输入")
|
||||
.supporting("辅助说明")
|
||||
.leading(IconKind::Folder)
|
||||
.trailing(IconKind::Close)
|
||||
.error()
|
||||
.state(Interaction::Enabled)
|
||||
.secure(false)
|
||||
.base(Base::CANVAS);
|
||||
assert_eq!(
|
||||
plain.submit_message(),
|
||||
None,
|
||||
"没调 `.on_submit` ⇒ 回车不发消息(既有调用点的行为)"
|
||||
);
|
||||
|
||||
let wired: Field<'_, u8> = Field::new("实例一").on_submit(|| 7_u8);
|
||||
assert_eq!(wired.submit_message(), Some(7), "接上的回调要被原样取出来");
|
||||
}
|
||||
|
||||
/// 回归(T27 反例 ①):`text_field` 必须把 `Field` 上的提交回调**转发**给 `text_input`。
|
||||
///
|
||||
/// iced 的 `Element` 没有内省接口,所以这条守的是**源码结构**(本仓既有同一条纪律,见
|
||||
/// `pages::layout::source` 的模块文档)。两条断言合起来才等于「转发」:消息只从
|
||||
/// `Field::submit_message()` 取(唯一读取口),且它被交给 `on_submit_maybe`。
|
||||
///
|
||||
/// 抓得住:删掉 `.on_submit_maybe(..)` 那一行 / 改成 `on_submit_maybe(None)` / 把
|
||||
/// `Field::on_submit` 存着不读(变异实测见 M252 报告)——本条立刻红。
|
||||
/// 抓不住:`text_input` 内部的键处理(那是 iced 的实现,不在这里)。
|
||||
#[test]
|
||||
fn text_field_forwards_the_submit_callback() {
|
||||
let body = crate::pages::layout::source::function_body(FIELD_SOURCE, "pub fn text_field");
|
||||
assert!(
|
||||
body.contains("submit_message()"),
|
||||
"提交消息必须从 `Field::submit_message()` 取(唯一读取口),实际函数体:\n{body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(".on_submit_maybe("),
|
||||
"取到之后必须交给 `text_input` 的 `on_submit_maybe`,实际函数体:\n{body}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 回归(T27 反例 ②):全站**只有那两处**(存档改名 / 强调色)接 `Field::on_submit`。
|
||||
///
|
||||
/// 为什么要扫源码:`text_field` 是全站共用控件,加了一个可选参数之后最容易出的错是
|
||||
/// **顺手把别的调用点也接上**——那会让一个「回车本来什么都不做」的输入框开始提交。
|
||||
/// 这条不靠「我看了一遍所有调用点」:白名单对不上就红;新增一处接线必须来这里登记,
|
||||
/// 这份摩擦是故意的。
|
||||
///
|
||||
/// 抓得住:任何一处**新增/删除**接在构建链上的 `.on_submit(..)`(本仓的链一律一行一条)。
|
||||
/// 抓不住:写在别处的同名文本(注释/字符串里的例子)、写成同一行的接线,也不管那两处接的
|
||||
/// 消息对不对(由 `pages` 侧各自的用例钉)。
|
||||
#[test]
|
||||
fn submit_is_wired_only_at_the_two_call_sites() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut wired = Vec::new();
|
||||
scan(&root, &root, &mut wired);
|
||||
wired.sort();
|
||||
assert_eq!(
|
||||
wired,
|
||||
vec![
|
||||
"pages/appearance/section/accent.rs".to_owned(),
|
||||
"pages/instances/saves/rows.rs".to_owned(),
|
||||
],
|
||||
"`Field::on_submit` 的调用点白名单变了(新接线要在这里登记)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 递归找 `dir` 下**有一行以 `.on_submit(` 开头**的 `.rs`,记下相对 `root` 的路径(`/` 分隔)。
|
||||
///
|
||||
/// 为什么按「行首」而不是整篇 `contains`:注释与字符串里出现的 `.on_submit(` 例子(包括本文件
|
||||
/// 自己)会假红。代价是**写成同一行的接线扫不到**——本仓的构建链一律一行一条,这条取舍记在上面。
|
||||
fn scan(root: &std::path::Path, dir: &std::path::Path, found: &mut Vec<String>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan(root, &path, found);
|
||||
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if text
|
||||
.lines()
|
||||
.any(|line| line.trim_start().starts_with(".on_submit("))
|
||||
{
|
||||
let rel = path.strip_prefix(root).unwrap_or(&path);
|
||||
found.push(rel.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@
|
||||
//! 前后槽位 × 错误/辅助文本),所以照按钮家族的先例把「部件参数」收进构造器结构体:
|
||||
//! `text_field(ui, Field::new(value).label("名称"), on_input)`。**泛型纪律一字不改**,
|
||||
//! 差别只是位置参数变命名参数(按钮家族已登记同一决定,见 `buttons` 模块文档)。
|
||||
//!
|
||||
//! 回车提交(P18)也是命名参数:`Field::new(value).on_submit(|| Msg::Commit)`。**不接就没有**
|
||||
//! ——`Field` 的 `M` 由 `text_field` 的 `on_input` 推出,既有调用点一个都不用改。
|
||||
|
||||
pub mod geometry;
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
//!
|
||||
//! 1. **不可能再靠打字变合法**:出现非十六进制字符(`Digit`)或超过 6 位(`Long`)——任何继续
|
||||
//! 输入都只会更错,立刻亮;
|
||||
//! 2. **提交**([`Accent::commit`]):离开这一格、点到别的外观控件、或配置读回。
|
||||
//! 2. **提交**([`Accent::commit`]):离开这一格、点到别的外观控件、**按回车**(P18,M252 接的
|
||||
//! `Change::AccentCommit`)、或配置读回。
|
||||
//!
|
||||
//! 配置读回走 [`Accent::new`](`commit` 语义):文件里存着坏值必须**当场**看得见,不能等用户来点。
|
||||
//!
|
||||
@@ -81,6 +82,9 @@ impl Accent {
|
||||
}
|
||||
|
||||
/// 提交:把「输入中暂不报」的错亮出来。离开这一格就该走它(见 [`Accent::pending`])。
|
||||
///
|
||||
/// 三个触发点:点到别的外观控件 / 按回车(P18 `Change::AccentCommit`)/ 配置读回。
|
||||
/// 幂等:已经亮着错再提交一次什么都不做。
|
||||
pub fn commit(&mut self) {
|
||||
if self.error.is_none() {
|
||||
self.error = self.pending.take();
|
||||
|
||||
@@ -132,6 +132,13 @@ pub enum Change {
|
||||
Corner(unlucky_presenter::prefs::WindowCorner),
|
||||
/// 自定义强调色输入(M250:原文;空串 = 恢复出厂板)。
|
||||
Accent(String),
|
||||
/// **强调色回车提交**(P18 / M252):不带值,只表示「用户按了回车,把打了一半的东西定下来」。
|
||||
///
|
||||
/// 为什么不复用 `Accent(原文)`:那个是**每一次按键**(`Accent::set`,输入中不报短错),
|
||||
/// 而回车是 [`Accent::commit`](super::accent::Accent::commit) 的第三个触发点(另两个是
|
||||
/// 「点到别的外观控件」与「配置读回」)——把 `#12` 这种打了一半就放着的值亮成红框,
|
||||
/// 同时**不写盘**(落盘永远取上一次合法值)。
|
||||
AccentCommit,
|
||||
/// 设置页切分组(左列点选)。
|
||||
SelectGroup(usize),
|
||||
}
|
||||
@@ -150,6 +157,9 @@ impl Change {
|
||||
Self::PaletteSource(_) => unlucky_presenter::prefs::keys::PALETTE_SOURCE,
|
||||
Self::Corner(_) => unlucky_presenter::prefs::keys::WINDOW_CORNER,
|
||||
Self::Accent(_) => unlucky_presenter::prefs::keys::ACCENT_COLOR,
|
||||
// 回车提交不产生新值,但**属于**强调色那一格 ⇒ 仍然点名到这个键(走 bridge 再写一次
|
||||
// 上一次合法值,见 `App::on_appearance`)。
|
||||
Self::AccentCommit => unlucky_presenter::prefs::keys::ACCENT_COLOR,
|
||||
Self::SelectGroup(_) => return None,
|
||||
})
|
||||
}
|
||||
@@ -173,6 +183,8 @@ impl Change {
|
||||
Self::PaletteSource(source) => source.as_str().to_owned(),
|
||||
Self::Corner(corner) => corner.as_str().to_owned(),
|
||||
Self::Accent(text) => text.clone(),
|
||||
// 回车**不改值**:真正的落盘值一直是 `current_values()` 里的上一次合法值。
|
||||
Self::AccentCommit => return None,
|
||||
Self::SelectGroup(_) => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,7 +51,10 @@ pub(super) fn push<'a>(
|
||||
// 受控输入框 + 失败态:`Field::error()` 让框本身变成错误色,错行再补「错在哪」。
|
||||
let mut field = Field::new(accent.text())
|
||||
.label(t(Key::SettingsAppearanceAccent, lang))
|
||||
.placeholder("#RRGGBB");
|
||||
.placeholder("#RRGGBB")
|
||||
// P18:回车 = 立即提交(把「打了一半就放着」的 `#12` 亮成红框 + 错行)。
|
||||
// **不是**逐键报错:敲字那条路(`Change::Accent`)照旧只在「不可能再变合法」时才亮红。
|
||||
.on_submit(accent_commit);
|
||||
if accent.error().is_some() {
|
||||
field = field.error();
|
||||
}
|
||||
@@ -81,6 +84,15 @@ pub(super) fn push<'a>(
|
||||
));
|
||||
}
|
||||
|
||||
/// 回车那一下要发的那条消息——**唯一产地**(M252 / P18)。
|
||||
///
|
||||
/// 抽成函数是为了让「回车 = 提交」这件事在源码里只有一个写法:`section/accent.rs` 把它交给
|
||||
/// `Field::on_submit`,测试按它钉住「回车走的是 `AccentCommit` 而不是又一条 `Accent(原文)`」
|
||||
/// (后者是**逐键**路径,会把已经亮出来的错重新吞回去)。
|
||||
fn accent_commit() -> Message {
|
||||
Message::Appearance(Change::AccentCommit)
|
||||
}
|
||||
|
||||
/// 「预设」两行:先「标签 + 出厂」,再一排 8 颗色块。
|
||||
///
|
||||
/// 为什么分两行:一行放 9 个控件在设置卡片的正文宽度里会被挤成「7 + 1」折行,最后一颗色块
|
||||
@@ -217,6 +229,39 @@ fn visible(ch: char) -> String {
|
||||
mod tests {
|
||||
use super::visible;
|
||||
|
||||
/// 本文件源码(源码结构断言用,与 `pages/layout/source.rs` 同一条纪律)。
|
||||
const SOURCE: &str = include_str!("accent.rs");
|
||||
|
||||
/// **M252 / P18 回车接线的两条断言**:输入框接了 `on_submit`,且接的是「提交」那条消息。
|
||||
///
|
||||
/// iced 的 `Element` 没有内省接口(见 `pages::layout::source` 的模块文档),所以「回车真的
|
||||
/// 按下去」在这里量不到——能钉住的是**它接的是哪条消息**:
|
||||
///
|
||||
/// - 抓得住:`.on_submit(..)` 被删掉(T27 反例);接成 `Change::Accent(原文)`(那是**逐键**
|
||||
/// 路径,`retry(false)` 会把已经亮起来的错吞回去,等于回车把错误擦掉);接成别的控件消息。
|
||||
/// - 抓不住:按键真的到达控件(iced 的 `Widget::update`)、以及红框/错行画没画(出图负责)。
|
||||
#[test]
|
||||
fn the_accent_field_wires_enter_to_commit() {
|
||||
let body = crate::pages::layout::source::function_body(SOURCE, "fn push");
|
||||
assert!(
|
||||
body.contains(".on_submit("),
|
||||
"强调色输入框没接回车(去掉接线这条必须红),实际函数体:\n{body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("accent_commit"),
|
||||
"回车必须接 `accent_commit`(唯一产地),实际函数体:\n{body}"
|
||||
);
|
||||
// 那条产地发的到底是什么:`Message` 没有 `PartialEq`,比 `Debug` 文本(变体名 + 字段)。
|
||||
assert_eq!(
|
||||
format!("{:?}", super::accent_commit()),
|
||||
format!(
|
||||
"{:?}",
|
||||
crate::pages::Message::Appearance(super::Change::AccentCommit)
|
||||
),
|
||||
"回车那条消息必须是 `AccentCommit`(提交),不是 `Accent(原文)`(敲字)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 错行里的字符必须**看得见**:不可见字符写成 `U+XXXX`,可见字符原样(r1 小事 2)。
|
||||
///
|
||||
/// 抓得住:把 `visible` 改回 `ch.to_string()`——`U+00A0` / `U+200B` 那两条立刻红(错行会变成
|
||||
|
||||
@@ -111,10 +111,9 @@ impl State {
|
||||
state
|
||||
}
|
||||
|
||||
/// 截图/真机取证用:把 `ULL_APPEARANCE_*` 灌进来(不设 = 不动)。
|
||||
///
|
||||
/// 为什么用环境变量而不是 CLI 参数:`crates/app-iced/src/cli.rs` 不在本 mission 的 scope。
|
||||
/// 语义与既有取证开关(`ULL_TICK_TRACE`)一致:**只影响这次进程**,不落盘。
|
||||
/// 截图/真机取证用:把 `ULL_APPEARANCE_*` 灌进来(不设 = 不动)。为什么用环境变量而不是 CLI
|
||||
/// 参数:`crates/app-iced/src/cli.rs` 不在本 mission 的 scope。语义与既有取证开关(`ULL_TICK_TRACE`)
|
||||
/// 一致:**只影响这次进程**,不落盘。
|
||||
pub fn apply_env(&mut self) {
|
||||
if let Some(value) = env_f32(ENV_WINDOW_ALPHA) {
|
||||
self.window_opacity = value;
|
||||
@@ -244,7 +243,7 @@ impl State {
|
||||
/// 应用一条改动(设置页 → 状态)。返回**是否需要立刻下发整窗 α**。
|
||||
pub fn apply(&mut self, change: super::Change) -> bool {
|
||||
use super::Change;
|
||||
// M250 p2-3:其余外观改动都意味着用户离开了强调色那一格 ⇒ 提交(理由见 `accent` 模块文档)。
|
||||
// M250 p2-3 / P18:除「敲字」(`Accent`)以外都意味着这一格定下来了 ⇒ 提交;回车 `AccentCommit` 同路。
|
||||
if !matches!(change, Change::Accent(_)) {
|
||||
self.accent.commit();
|
||||
}
|
||||
@@ -291,6 +290,7 @@ impl State {
|
||||
self.accent.set(&text);
|
||||
false
|
||||
}
|
||||
Change::AccentCommit => false, // P18 回车:语义就是「提交」,上面那条规则已 `commit()`
|
||||
Change::SelectGroup(index) => {
|
||||
self.selected_group = index;
|
||||
false
|
||||
|
||||
@@ -50,6 +50,17 @@ fn every_change_names_its_own_schema_key() {
|
||||
}
|
||||
assert_eq!(Change::SelectGroup(2).key(), None, "切分组不落盘");
|
||||
assert_eq!(Change::SelectGroup(2).value(), None);
|
||||
// P18:回车提交不产生值,但它是强调色那一格的改动(`on_appearance` 据此再写一次上一次合法值)
|
||||
assert_eq!(
|
||||
Change::AccentCommit.key(),
|
||||
Some(keys::ACCENT_COLOR),
|
||||
"回车提交也要点名到自己那个 schema 键"
|
||||
);
|
||||
assert_eq!(
|
||||
Change::AccentCommit.value(),
|
||||
None,
|
||||
"回车不产生新值(落盘一直取上一次合法值)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 抓得住:落盘字符串必须是 schema 认识的词表(枚举档写错就整条配置读回默认值)。
|
||||
|
||||
@@ -252,3 +252,70 @@ fn accent_errors_only_appear_when_they_are_actionable() {
|
||||
);
|
||||
assert!(loaded.accent.color().is_none(), "坏值不生效,走出厂板");
|
||||
}
|
||||
|
||||
/// **P18 / M252 判据 ③**:回车 = 立即提交(`Change::AccentCommit`)。
|
||||
///
|
||||
/// 抓得住:① 把回车实现成「再走一次 `Accent(原文)`」——`retry(false)` 会把已经亮起来的错**吞回去**,
|
||||
/// 第一段当场红;② 把 `State::apply` 开头那条「非敲字即提交」的规则删掉(回车不再提交),
|
||||
/// 第一段同样红;③ 回车把非法值写进配置(`persist_value` 变了),第二、三段红。
|
||||
/// 抓不住:真按下回车(iced 的键路径,见 `section/accent.rs` 的接线用例),也抓不住写盘那一下
|
||||
/// (`SettingsBridge::persist` 在 `app` 侧)——这里钉的是 `current_values().accent_color`,
|
||||
/// 也就是**要写出去的那个值**。
|
||||
#[test]
|
||||
fn enter_commits_a_half_typed_value_and_persists_only_legal_ones() {
|
||||
use crate::cli::Backend;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::prefs::{AccentError, AppearanceBase, AppearancePrefs};
|
||||
|
||||
use super::super::State;
|
||||
|
||||
let mut state = State::new(
|
||||
&AppearancePrefs::factory(),
|
||||
&AppearanceBase::factory(),
|
||||
Backend::Gpu,
|
||||
SchemeFlavor::Light,
|
||||
);
|
||||
|
||||
// ① 打了一半就放着:输入中**不**亮(逐键报错的规矩不许被回车改回来)……
|
||||
state.apply(Change::Accent("#12".to_owned()));
|
||||
assert_eq!(
|
||||
state.accent.error(),
|
||||
None,
|
||||
"输入中先不亮(手打一个合法色不该闪好几次)"
|
||||
);
|
||||
// ……但回车必须亮,而且**不写盘**(落盘仍是上一次合法值 = 空串 = 出厂板)
|
||||
state.apply(Change::AccentCommit);
|
||||
assert_eq!(
|
||||
state.accent.error(),
|
||||
Some(AccentError::Short { digits: 2 }),
|
||||
"回车之后 #12 必须亮红(打了一半放着也能被用户主动确认)"
|
||||
);
|
||||
assert_eq!(
|
||||
state.accent.persist_value(),
|
||||
"",
|
||||
"#12: 回车不许把非法值写盘"
|
||||
);
|
||||
assert!(state.accent.color().is_none(), "#12: 也不许改生效色");
|
||||
assert_eq!(
|
||||
state.current_values().accent_color,
|
||||
"",
|
||||
"写回配置的值仍是上一次合法值"
|
||||
);
|
||||
|
||||
// ② 合法值回车:不报错、生效、写盘值就是规范化 hex
|
||||
state.apply(Change::Accent(TEAL.to_hex().to_lowercase()));
|
||||
state.apply(Change::AccentCommit);
|
||||
assert_eq!(state.accent.error(), None, "合法值回车不该冒出错误");
|
||||
assert!(state.accent.color().is_some(), "合法值回车后仍生效");
|
||||
assert_eq!(
|
||||
state.current_values().accent_color,
|
||||
TEAL.to_hex(),
|
||||
"合法值回车后写回配置的是规范化 hex"
|
||||
);
|
||||
|
||||
// ③ 幂等:同一个值连按两次回车不出错、不改值
|
||||
state.apply(Change::AccentCommit);
|
||||
state.apply(Change::AccentCommit);
|
||||
assert_eq!(state.accent.error(), None);
|
||||
assert_eq!(state.current_values().accent_color, TEAL.to_hex());
|
||||
}
|
||||
@@ -91,6 +91,9 @@ mod wall;
|
||||
#[cfg(test)]
|
||||
pub(super) use model::clear_handles;
|
||||
pub use model::{failure_line, from_state, Row, Saves, Shot, ShotPreview};
|
||||
/// 「改名」那条消息的产地(实现在 [`rows`] 里)——只给用例用,与 `op_line` 同一条口径。
|
||||
#[cfg(test)]
|
||||
pub(super) use rows::commit_rename;
|
||||
/// 写操作成功那句话的产地(实现在 [`rows`] 里)。
|
||||
///
|
||||
/// 生产代码走 `model::from_state → rows::op_line`;这条 re-export 只给**用例**用
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
//! 三枚按钮与「打开存档文件夹」同一条口径:`buttons::text` + `frame::BASE`(M237:卡片里的控件
|
||||
//! 把底传成卡底,hover/禁用状态才叠在卡实色上),出图取证时整行一起跟 `--force-state`。
|
||||
//!
|
||||
//! 输入框用 `components::inputs::text_field`(与启动参数区同一个控件)。**回车即提交**这件事
|
||||
//! 由 `text_input` 的 `on_submit` 给不了(`Field` 没有开这个口子),所以提交靠右边的「改名」按钮
|
||||
//! ——少一个键,但多一条谁都能看见的路。
|
||||
//! 输入框用 `components::inputs::text_field`(与启动参数区同一个控件)。**回车 = 点「改名」**
|
||||
//! (M252 / P18):`Field::on_submit` 与右边那枚按钮共用 [`commit_rename`] 这一个消息产地,
|
||||
//! 所以空名 / 原名不变 / 非法字符走的仍是原来那条校验(`app/update/instances.rs` 的
|
||||
//! 空名与「没改」两条短路 + presenter 那边的目录名规则),**回车没有绕开任何一道边界**。
|
||||
|
||||
use iced::widget::{column, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
@@ -98,17 +99,14 @@ fn rename_row<'a>(
|
||||
// M237:输入框住在卡里 ⇒ 状态层与描边叠卡底。
|
||||
.base(frame::BASE)
|
||||
.placeholder(texts.saves_rename_placeholder())
|
||||
.width(Length::Fill),
|
||||
.width(Length::Fill)
|
||||
// P18:回车与右枚按钮**同一个**消息产地(见 `commit_rename` 的文档)。
|
||||
.on_submit(|| Message::Instances(commit_rename())),
|
||||
|value| Message::Instances(PageMessage::RenameWorldChanged(value)),
|
||||
);
|
||||
row![
|
||||
field,
|
||||
action(
|
||||
ui,
|
||||
texts.saves_rename_confirm(),
|
||||
PageMessage::CommitRenameWorld,
|
||||
forced
|
||||
),
|
||||
action(ui, texts.saves_rename_confirm(), commit_rename(), forced),
|
||||
action(
|
||||
ui,
|
||||
texts.saves_cancel(),
|
||||
@@ -122,6 +120,15 @@ fn rename_row<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 「改名」那一下要发的那条页面意图——**按钮与回车共用的唯一产地**(M252 / P18)。
|
||||
///
|
||||
/// 为什么抽成一个函数:「回车走的是不是同一条路」在 iced 里量不到(`Element` 没有内省接口,
|
||||
/// 见 `pages::layout::source` 的模块文档),能钉住的只有**两条路拿的是同一个 Message**——
|
||||
/// 于是「空名 / 原名不变 / 非法字符不许提交」那几道校验自动对回车同样成立。
|
||||
pub(crate) fn commit_rename() -> PageMessage {
|
||||
PageMessage::CommitRenameWorld
|
||||
}
|
||||
|
||||
/// 名字 + 副文案(两行)。
|
||||
fn labels<'a>(ui: &'a Ui, item: &'a Row, color: Role) -> iced::widget::Column<'a, Message> {
|
||||
column![
|
||||
|
||||
@@ -10,6 +10,9 @@ mod launch_args;
|
||||
/// M235 的存档区模型判据(快照 → 行 / 计数 / 失败线 / UTC 时间)。
|
||||
mod saves;
|
||||
|
||||
/// M252 / P18 的**回车**接线(行内重命名:回车 = 点「改名」)。
|
||||
mod rename;
|
||||
|
||||
use super::super::layout::source;
|
||||
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! M252 / P18:行内重命名的**回车**接线。
|
||||
//!
|
||||
//! iced 的 `Element` 没有内省接口(见 `pages::layout::source` 的模块文档),所以「真按下回车会
|
||||
//! 发生什么」在单测里量不到。这里能钉住的两件事:
|
||||
//!
|
||||
//! | 断言 | 抓得住 | 抓不住 |
|
||||
//! |---|---|---|
|
||||
//! | [`enter_takes_the_same_message_as_the_rename_button`] | 「回车」与「点改名」共用的那条消息被换成别的(取消 / 重开…);空名 / 原名不变 / 非法字符那几道校验因此**自动**对回车成立——它们挂在同一个 Message 后面 | 校验本身对不对(在 `app/update/instances.rs` 与 presenter,本 mission 一字未改) |
|
||||
//! | [`the_rename_field_wires_enter`] | `rename_row` 里那枚输入框的 `.on_submit(..)` 被删掉 / 接成别的(T27 反例) | 回车键真的到达控件(那是 iced 的 `Widget::update`) |
|
||||
//!
|
||||
//! 也就是说:判据①「回车真的提交一次」里,**「提交的是什么」被钉住了,「按下去会不会到这儿」没有**
|
||||
//! (理由与替代验证写在 M252 报告的「没验的部分」一节)。
|
||||
|
||||
use crate::pages::layout::source;
|
||||
|
||||
use super::super::saves::commit_rename;
|
||||
use super::super::state::Message as PageMessage;
|
||||
|
||||
/// 回车与「改名」按钮共用同一个消息产地 ⇒ 走同一套校验。
|
||||
///
|
||||
/// `PageMessage` 只有 `Debug + Clone`(没有 `PartialEq`,那些枚举不在本 mission 的 scope),
|
||||
/// 所以这里比 `Debug` 文本:枚举的 `Debug` 打印变体名与字段,变体不同必然文本不同。
|
||||
#[test]
|
||||
fn enter_takes_the_same_message_as_the_rename_button() {
|
||||
assert_eq!(
|
||||
format!("{:?}", commit_rename()),
|
||||
format!("{:?}", PageMessage::CommitRenameWorld),
|
||||
"回车必须发「提交重命名」,不能是取消/重开/逐键输入"
|
||||
);
|
||||
assert!(
|
||||
!format!("{:?}", commit_rename()).contains("Cancel"),
|
||||
"回车不许接到取消上(那会让用户按回车丢掉草稿)"
|
||||
);
|
||||
}
|
||||
|
||||
/// `rename_row` 里那枚输入框真的接了 `on_submit`,且接的是 `commit_rename`(与按钮同一个)。
|
||||
///
|
||||
/// 抓得住(T27 反例):把 `.on_submit(..)` 那一行删掉 → 第一条断言红;把它接成
|
||||
/// `CancelRenameWorld` → 第二条断言红。
|
||||
/// 抓不住:回车键真的到达控件(iced 的键路径,见模块文档)。
|
||||
#[test]
|
||||
fn the_rename_field_wires_enter() {
|
||||
let body = source::function_body(include_str!("../saves/rows.rs"), "fn rename_row");
|
||||
assert!(
|
||||
body.contains(".on_submit("),
|
||||
"输入框没接回车(去掉接线这条必须红),实际函数体:\n{body}"
|
||||
);
|
||||
assert!(
|
||||
body.matches("commit_rename()").count() >= 2,
|
||||
"回车与「改名」按钮必须共用 `commit_rename()`(两条路同一个 Message),实际函数体:\n{body}"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user