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:
AskaEth committed 2026-11-11 19:55:21 +08:00
1 parent 026b9f6e94
commit f7cfc8ded6
13 files changed
+384 -22

No files matched your search

+51 -4
View File
@@ -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;