fix(app-iced): M104 r2 —— Route::Agent ⟺ Detached 的不变式 + 复审 P3 六条
p2-1(唯一 p2):`Route::Agent` 与 `RailState::Detached` 之前没有绑定——从左侧导航第五项 (本 mission 点名的入口)进独立页时档位还是 Collapsed/Expanded,于是页头出现「Open assistant」 「Collapse/Pop out」、挂着不化的未读胶囊、Esc 也收不走页面(复审用产品 bin 复现)。 - **单一入口**:`App::sync_agent_route()`(update/agent.rs)在 `refresh_view` 开头调用, 两条对向规则:在独立页 ⇒ Detached(顺带清未读);离开独立页而档位还是 Detached ⇒ 收回右栗。 每一次路由变化都经过 refresh_view,所以导航 / ULL_START_ROUTE / detach / back 共用这一处。 - **结构性兜底**:`conversation::actions` 先按宿主分流,`Host::Page` 的动作恒为「Dock back」。 - **启动顺序**:`boot_agent()`(窗口尺寸 adopt + 拆出档直达独立页)必须在 `dispatch(RefreshAll)` 之前——那次 dispatch 会 pump→auto_select 把路由拉到实例详情,同步器随即把档位收回(实测踩到)。 - 测试:`entering_the_agent_route_detaches_the_rail`(走 `Message::Select(NavItem::Ai)` 真路径, 含未读清零 / Esc 离开 / 从页上点别的导航收回)、`the_page_navigation_entry_also_detaches`。 变异自证:把 `sync_agent_route` 改成 no-op ⇒ 3 红(含这两条)。 - 图 13/14:导航入口(折叠档带 2 条未读 / 展开档)进页后都是 Dock back、无未读胶囊、副标题单行。 P3-2 Enter/Shift+Enter 有测试了:分流抽成纯函数 `composer::binding_for`, `enter_sends_and_shift_enter_breaks_the_line` 覆盖「发送 / 换行 / 未聚焦不抢 / 其它键交回默认」; `AgentUi::edit` 里与 fallthrough 等价的冗余 Enter 分支删掉;三档测试改走 `agent.edit(...)`。 P3-4 自证式断言换字面量(320+8+852=1180 等);变异 RESIZE_GUTTER 8→0 ⇒ 6 红(含这条)。 P3-5 两条死文案真用上:8px 拖拽缝有了 tooltip(AgentResize + AgentWidthLabel「当前多宽」)。 P3-6 `on_boot` 的两块逻辑搬进 `update/agent.rs::boot_agent`:update.rs 372→383(+11,见报告逐行)。 P3-7 拖拽层补 on_press/on_exit:窗外松手不再僵住(窗内按下→清态;离窗→收在当前值)。 P3-9 顺手:只有几何/路由会变的消息才重建视图模型(每个字符、每次指针移动不再重建)。 门禁:fmt 0 / clippy(workspace,-D warnings) 0 / pages_demo 0 / 全文件 ≤400(最大 view.rs 373 之外 conversation.rs 383、update.rs 383)
This commit is contained in:
1 parent
8db1c5ab16
commit
9253684387
11 files changed
+495
-208
No files matched your search
@@ -10,7 +10,7 @@
|
||||
//! **只在输入框聚焦时生效**——`KeyPress.status` 不是 `Focused` 时原样交回默认绑定,
|
||||
//! 于是别处的 Enter 不会被助手栏抢走。
|
||||
|
||||
use iced::widget::text_editor::{self, Binding};
|
||||
use iced::widget::text_editor::{self, Binding, KeyPress};
|
||||
use iced::widget::{column, row, text, text_editor as editor};
|
||||
use iced::{keyboard, Background, Border, Element, Length, Pixels, Theme};
|
||||
|
||||
@@ -39,25 +39,7 @@ pub(super) fn composer<'a>(
|
||||
let field = editor(spec.draft)
|
||||
.placeholder(catalog.text(Key::AgentPlaceholder))
|
||||
.on_action(Message::Edit)
|
||||
.key_binding(|press| {
|
||||
// Enter = 发送 / Shift+Enter = 换行(任务第 4 条)。
|
||||
//
|
||||
// 走 `key_binding` 而不是全局键盘订阅:`KeyPress` 直接带 `modifiers`,而 iced 的
|
||||
// 默认绑定把两种 Enter 都折成 `Binding::Enter`(所以**必须**在这里分流)。
|
||||
// 未聚焦时一律交回默认绑定(它自己不响应),于是别处的 Enter 不会被助手栏抢走。
|
||||
let enter = matches!(
|
||||
press.modified_key.as_ref(),
|
||||
keyboard::Key::Named(keyboard::key::Named::Enter)
|
||||
);
|
||||
if !enter || !matches!(press.status, text_editor::Status::Focused { .. }) {
|
||||
return Binding::from_key_press(press);
|
||||
}
|
||||
if press.modifiers.shift() {
|
||||
Some(Binding::Enter)
|
||||
} else {
|
||||
Some(Binding::Custom(Message::Submit))
|
||||
}
|
||||
})
|
||||
.key_binding(binding_for)
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.height(Length::Fixed(
|
||||
@@ -115,6 +97,29 @@ pub(super) fn composer<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Enter = 发送 / Shift+Enter = 换行(任务第 4 条)。
|
||||
///
|
||||
/// 为什么走 `key_binding` 而不是全局键盘订阅:`KeyPress` 直接带 `modifiers`,而 iced 的默认绑定
|
||||
/// 把两种 Enter 都折成 `Binding::Enter`(`text_editor.rs:1210`),所以**必须**在这里分流。
|
||||
/// 未聚焦时一律交回默认绑定(`from_key_press` 对非聚焦返回 `None`),于是别处的 Enter 不会被
|
||||
/// 助手栏抢走。
|
||||
///
|
||||
/// 抽成**纯函数**是为了能被单测钉住(复审 P3-2:之前一个测试都没覆盖这条分流)。
|
||||
pub(super) fn binding_for(press: KeyPress) -> Option<Binding<Message>> {
|
||||
let enter = matches!(
|
||||
press.modified_key.as_ref(),
|
||||
keyboard::Key::Named(keyboard::key::Named::Enter)
|
||||
);
|
||||
if !enter || !matches!(press.status, text_editor::Status::Focused { .. }) {
|
||||
return Binding::from_key_press(press);
|
||||
}
|
||||
if press.modifiers.shift() {
|
||||
Some(Binding::Enter)
|
||||
} else {
|
||||
Some(Binding::Custom(Message::Submit))
|
||||
}
|
||||
}
|
||||
|
||||
/// 一档文字(字号/行高/字重来自令牌;`shell::label` 同口径,但这里要自己持消息类型)。
|
||||
pub(crate) fn text_role<'a>(
|
||||
ui: &'a Ui,
|
||||
@@ -140,3 +145,59 @@ pub(crate) fn text_role<'a>(
|
||||
.width(width)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use iced::keyboard::key::{Code, Named, Physical};
|
||||
use iced::keyboard::{Key, Modifiers};
|
||||
|
||||
use super::super::super::Message;
|
||||
use super::*;
|
||||
|
||||
fn press(key: Key, modifiers: Modifiers, focused: bool) -> KeyPress {
|
||||
let status = if focused {
|
||||
text_editor::Status::Focused { is_hovered: false }
|
||||
} else {
|
||||
text_editor::Status::Active
|
||||
};
|
||||
KeyPress {
|
||||
key: key.clone(),
|
||||
modified_key: key,
|
||||
physical_key: Physical::Code(Code::Enter),
|
||||
modifiers,
|
||||
text: None,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
fn enter() -> Key {
|
||||
Key::Named(Named::Enter)
|
||||
}
|
||||
|
||||
/// **Enter 发送 / Shift+Enter 换行**(复审 P3-2 点名缺的那条):
|
||||
/// 抓得住两条分支各自选对了 `Binding`(非 shift 只发消息、不插换行;shift 才插换行)。
|
||||
/// 抓不住真实按键(`KeyPress` 是这里手搓的,不是 winit 送上来的)。
|
||||
#[test]
|
||||
fn enter_sends_and_shift_enter_breaks_the_line() {
|
||||
// 非 shift + 聚焦 ⇒ 发消息(**不**插换行)
|
||||
let plain = binding_for(press(enter(), Modifiers::default(), true));
|
||||
assert!(
|
||||
matches!(plain, Some(Binding::Custom(Message::Submit))),
|
||||
"Enter 应当发 Submit,实际 {plain:?}"
|
||||
);
|
||||
|
||||
// shift + 聚焦 ⇒ 插换行
|
||||
let shifted = binding_for(press(enter(), Modifiers::SHIFT, true));
|
||||
assert!(
|
||||
matches!(shifted, Some(Binding::Enter)),
|
||||
"Shift+Enter 应当插换行,实际 {shifted:?}"
|
||||
);
|
||||
|
||||
// 未聚焦 ⇒ 原样交回默认绑定(它自己对非聚焦返回 None),别处的 Enter 不受影响
|
||||
assert!(binding_for(press(enter(), Modifiers::default(), false)).is_none());
|
||||
|
||||
// 其它键 ⇒ 交回默认绑定(这里用 Tab:默认绑定里没有它)
|
||||
let tab = press(Key::Named(Named::Tab), Modifiers::default(), true);
|
||||
assert!(binding_for(tab).is_none());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user