From 9253684387e65dde2b5e5016deb21e2c0e656e39 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 23:01:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20M104=20r2=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20Route::Agent=20=E2=9F=BA=20Detached=20=E7=9A=84=E4=B8=8D?= =?UTF-8?q?=E5=8F=98=E5=BC=8F=20+=20=E5=A4=8D=E5=AE=A1=20P3=20=E5=85=AD?= =?UTF-8?q?=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/app-iced/src/app/state.rs | 5 + crates/app-iced/src/app/tests.rs | 69 ++++++++- crates/app-iced/src/app/update.rs | 28 +--- crates/app-iced/src/app/update/agent.rs | 70 ++++++++- crates/app-iced/src/app/view.rs | 94 +++++------- .../app-iced/src/pages/agent/conversation.rs | 16 ++ .../src/pages/agent/conversation/composer.rs | 101 ++++++++++--- crates/app-iced/src/pages/agent/mod.rs | 82 ++++++++--- crates/app-iced/src/pages/agent/state.rs | 13 +- crates/app-iced/src/pages/agent/tests.rs | 88 ++--------- crates/app-iced/src/pages/agent/tests/drag.rs | 137 ++++++++++++++++++ 11 files changed, 495 insertions(+), 208 deletions(-) create mode 100644 crates/app-iced/src/pages/agent/tests/drag.rs diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 56a1eed..d56ba33 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -272,6 +272,11 @@ impl App { /// /// 代价是「克隆 N 个实例的名字/版本串」,所以只在 [`App::view_dirty`] 置位时调用(P2-1)。 pub(super) fn refresh_view(&mut self) { + // **不变式先对齐再算宽度**(M104 / 复审 r1 P2-1):`Route::Agent` ⟺ `RailState::Detached`。 + // 每一次路由变化都会走到这里,所以它是「档位 ↔ 路由」的唯一点;放在算宽度之前, + // 是为了让模型拿到的 `content_width` 与页面头部读到的档位来自同一份状态。 + self.sync_agent_route(); + // 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数): // 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。 let category = self.view.download.category; diff --git a/crates/app-iced/src/app/tests.rs b/crates/app-iced/src/app/tests.rs index b3dfbeb..101f2b3 100644 --- a/crates/app-iced/src/app/tests.rs +++ b/crates/app-iced/src/app/tests.rs @@ -19,6 +19,8 @@ use super::state::App; use super::{APP_TITLE, WINDOW_SIZE}; use crate::cli::{Options, Screen}; use crate::pages; +use crate::shell::agent::RailState; +use crate::shell::NavItem; use crate::theme::Families; const FAMILIES: Families = Families { @@ -249,8 +251,10 @@ fn resizing_the_window_rebuilds_the_view_model() { app.view.window_width ); - // Detached 档:右栗不占宽,中栗 = 窗口 = 1180 - app.agent.detach(); + // Detached 档:右栗不占宽,中栗 = 窗口 = 1180。 + // **必须走路由**(`Select(Ai)`):r1 之后「档位 = Detached」与「路由 = Agent」是一条不变式, + // 直接 `agent.detach()` 再手工 refresh 会被 `sync_agent_route` 立刻收回右栗(那正是要的语义)。 + let _ = app.update(Message::Select(NavItem::Ai)); app.view_dirty = true; app.refresh_view(); assert!( @@ -262,6 +266,67 @@ fn resizing_the_window_rebuilds_the_view_model() { let _ = app.view(); } +/// **不变式(复审 r1 的 P2-1)**:`Route::Agent` ⟺ `RailState::Detached`。 +/// +/// 走的是**真实路由入口**(外壳导航第五项 `Assistant` → `Message::Select(NavItem::Ai)`),不是 +/// 直接调 `agent.detach()`:r1 复现的 bug 正是「导航进页面时档位没变」,只测 `detach()` 抓不到它。 +/// +/// 抓得住:①进独立页后档位 = Detached(页头才会是 `Dock back` 而不是 `Open assistant`/`Pop out`); +/// ②未读在进页时清零;③`Esc` 在独立页真的离开(不是只把档位改成折叠);④从页上点别的导航, +/// 会话收回右栗(档位不再停在 Detached)。 +/// 抓不住:按钮在像素上长什么样(那要出图,见 `13/14` 两张)。 +#[test] +fn entering_the_agent_route_detaches_the_rail() { + let mut app = headless(Options::default(), SchemeFlavor::Light); + let _ = app.update(Message::Boot); + assert_eq!(app.agent.rail, RailState::Collapsed, "出厂是折叠档"); + assert_eq!(app.agent.unread, 2, "出厂带两条未读"); + + // ① 外壳导航第五项(真实入口) + let _ = app.update(Message::Select(NavItem::Ai)); + assert_eq!(app.state.route(), &Route::Agent); + assert_eq!( + app.agent.rail, + RailState::Detached, + "进了独立页,档位必须是 Detached——否则页头会画右栗才有的动作" + ); + assert_eq!(app.agent.unread, 0, "进独立页即读"); + + // ③ Esc 真的离开这一页 + let _ = app.update(Message::Agent(crate::shell::agent::Message::Escape)); + assert_ne!(app.state.route(), &Route::Agent, "独立页上 Esc 必须离开"); + assert_eq!(app.agent.rail, RailState::Expanded, "收回到右栗展开档"); + + // ④ 从独立页点别的导航:会话收回右栗(档位不能停在 Detached) + let _ = app.update(Message::Select(NavItem::Ai)); + assert_eq!(app.agent.rail, RailState::Detached); + let _ = app.update(Message::Select(NavItem::Instances)); + assert_eq!(app.state.route(), &Route::Instances); + assert_eq!( + app.agent.rail, + RailState::Expanded, + "离开独立页 ⇒ 会话收回右栗(否则页走了、栏还是 0 宽)" + ); +} + +/// 同一条不变式的另一个入口:页面层的 `Navigate(Route::Agent)`(右栗里的 `pages::Message` 路径)。 +#[test] +fn the_page_navigation_entry_also_detaches() { + let mut app = headless(Options::default(), SchemeFlavor::Light); + let _ = app.update(Message::Boot); + app.agent.expand(); + assert_eq!(app.agent.rail, RailState::Expanded); + + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Agent))); + assert_eq!(app.state.route(), &Route::Agent); + assert_eq!(app.agent.rail, RailState::Detached); + assert_eq!(app.agent.unread, 0); + + // 页面层回退(不是 Dock 消息)也要把档位收回来 + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); + assert_eq!(app.agent.rail, RailState::Expanded); +} + /// 截图模式不读设置文件里的主题(D30 可复现:两台机器出同一张图)。 #[test] fn screenshot_mode_ignores_the_settings_file_theme() { diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index 970f05f..96feb8c 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -40,7 +40,7 @@ use crate::renderer::Kind; use super::message::{route_of, Message}; use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW}; -use super::{CAPTURE_AT_FRAME, MIN_SIZE, WINDOW_SIZE}; +use super::CAPTURE_AT_FRAME; mod agent; mod download; @@ -157,19 +157,15 @@ impl App { tasks.push(iced::system::theme().map(Message::SystemTheme)); } + // M104:助手栏的启动尾巴(窗口尺寸 adopt + 「拆出档直接落到独立页」)在 update/agent.rs。 + // **必须在 RefreshAll 之前**:那次 dispatch 会 `pump → auto_select`,把路由拉到实例详情, + // 而 `sync_agent_route` 看到「不在 Agent 页却还是 Detached」就会把档位收回来——顺序反了 + // 就会出现「`ULL_AGENT_RAIL=detached` 启动后停在实例页」(r2 实测踩到)。 + self.boot_agent(); + // 「重新扫描实例目录 + 全部页面快照」:真 host 在这里发首批事件,mock 在构造时已预置 self.dispatch(AppCommand::RefreshAll); - // 窗口尺寸的**唯一真源**是 `boot.rs` 开窗时用的那一个(`ULL_WINDOW_SIZE` + 最小尺寸钳位)。 - // `App::with_presenter` 只认识常量 1180×760,所以这里 adopt 一次:不 adopt 的话,验收矩阵 - // 开 900 或 1520 的窗时页面还按 1180 排版(图上就是「窄窗里内容被裁掉」)。 - let (window_size, _source) = crate::renderer::acceptance::window::window_size( - (WINDOW_SIZE.width, WINDOW_SIZE.height), - (MIN_SIZE.width, MIN_SIZE.height), - ); - self.width = window_size.0; - self.height = window_size.1; - // 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope, // 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。 if let Some(route) = env_start_route() { @@ -179,16 +175,6 @@ impl App { self.auto_selected = true; } - // M104:三档状态若从「拆出」开始(`ULL_AGENT_RAIL=detached` 或将来持久化的偏好), - // 会话就在独立页里——直接落到那一页,别再被「自动选中第一个实例」拉回详情页。 - if self.agent.rail == crate::shell::agent::RailState::Detached - && self.state.route() != &Route::Agent - { - eprintln!("[agent] rail=detached → Route::Agent"); - self.state.navigate(Route::Agent); - self.auto_selected = true; - } - self.auto_select(); self.view_dirty = true; self.refresh_view(); diff --git a/crates/app-iced/src/app/update/agent.rs b/crates/app-iced/src/app/update/agent.rs index 5c66640..902875c 100644 --- a/crates/app-iced/src/app/update/agent.rs +++ b/crates/app-iced/src/app/update/agent.rs @@ -24,6 +24,7 @@ use iced::Task; use unlucky_i18n::{Catalog, Lang}; +use crate::app::{MIN_SIZE, WINDOW_SIZE}; use crate::shell::agent::{self, Focus, RailState}; use super::*; @@ -33,6 +34,22 @@ impl App { pub(super) fn on_agent(&mut self, message: agent::Message) -> Task { let catalog = Catalog::of(Lang::En); + // 只有**几何/路由**会变的几支才需要重建视图模型(`pages::Model` 里没有会话内容: + // transcript / draft 全在 `App.agent` 上)。复审 P3-9:以前每个 `Edit`(= 每敲一个字符) + // 和每个 `DragTo`(= 指针频率)都重建一次整张模型,白白克隆 N 个实例的名字。 + let geometry_changed = matches!( + message, + agent::Message::Expand + | agent::Message::Collapse + | agent::Message::Detach + | agent::Message::Dock + | agent::Message::DragTo(_) + | agent::Message::DragEnd + | agent::Message::Escape + | agent::Message::Activate + | agent::Message::Key(_) + ); + match message { agent::Message::Expand => self.agent.expand(), agent::Message::Collapse | agent::Message::Escape => self.collapse_agent(), @@ -59,12 +76,59 @@ impl App { agent::Message::Key(event) => return self.on_agent_key(event), } - // 右栗的宽度/档位都会改「页面能用多宽」,所以视图模型必须重建(不是只重绘)。 - self.view_dirty = true; - self.refresh_view(); + if geometry_changed { + self.view_dirty = true; + self.refresh_view(); + } Task::none() } + /// **不变式**:`route() == Route::Agent` ⟺ `agent.rail == RailState::Detached`(唯一同步点)。 + /// + /// 为什么需要它(复审 r1 的 P2-1,实测复现):`agent.rail` 只在自己发 `Detach`/`Dock` 时变, + /// 而路由还能从三条**别的**路径进 `Route::Agent`——左侧导航第五项、`pages::Message::Navigate`、 + /// `ULL_START_ROUTE=agent`。脱钩的后果:从导航进独立页时头部出现「Open assistant」(那是 + /// 「展开右栗」的意思)、标题旁挂着不化的未读胶囊、`Esc` 也收不走页面。 + /// + /// 两条对向规则: + /// - **在**独立页上 ⇒ `Detached`(顺带按「拆出即读」清未读); + /// - **不在**独立页上而档位还是 `Detached`(用户从页上点了别的导航)⇒ 会话收回右栗(`dock()`), + /// 否则会话会「两头都没有」:页走了、栏还是 0 宽。 + /// + /// 调用点只有一个:`App::refresh_view` 的开头。每一次路由变化都会经过它,于是**模型算宽度时** + /// 与**画头部动作时**读到的永远是同一份档位。 + pub(crate) fn sync_agent_route(&mut self) { + let on_page = self.state.route() == &Route::Agent; + match (on_page, self.agent.rail) { + (true, RailState::Detached) | (false, RailState::Collapsed | RailState::Expanded) => {} + (true, _) => self.agent.detach(), + (false, RailState::Detached) => self.agent.dock(), + } + } + + /// 启动时助手栏要做的两件事(从 `on_boot` 搬出来:那边只许留一行调用,复审 P3-6)。 + /// + /// 1. **窗口尺寸 adopt**:唯一真源是 `boot.rs` 开窗时用的那一个(`ULL_WINDOW_SIZE` + 最小尺寸 + /// 钳位)。`App::with_presenter` 只认识常量 1180×760,不 adopt 的话验收矩阵开 900 / 1520 的窗 + /// 时页面还按 1180 排版(窄窗里内容被裁)。 + /// 2. **拆出档直接落到独立页**:`ULL_AGENT_RAIL=detached`(以及将来持久化的偏好)时,会话就在 + /// 独立页里,启动后不该停在实例页。 + pub(super) fn boot_agent(&mut self) { + let (window_size, _source) = crate::renderer::acceptance::window::window_size( + (WINDOW_SIZE.width, WINDOW_SIZE.height), + (MIN_SIZE.width, MIN_SIZE.height), + ); + self.width = window_size.0; + self.height = window_size.1; + + if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent { + eprintln!("[agent] rail=detached → Route::Agent"); + self.state.navigate(Route::Agent); + // 直达独立页时不要再被「自动选中第一条实例」拉回详情页。 + self.auto_selected = true; + } + } + /// 键盘订阅来的按键(`app/subscription.rs` 把整条键盘事件搬过来,解释权在这一层)。 /// /// 为什么不让订阅层直接翻成三个枚举:那样 `Escape`/`Tab`/`Enter` 的语义(什么状态下该做什么) diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index 9f6743b..b8bc50d 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -44,67 +44,49 @@ impl App { } // 产品页走**内容槽位**:外壳(标题栏 / 拖动与缩放热区 / 三个 caption 键 / rail)由 - // `shell` 画,中间那块才是页面。M90 r2 之前这里用的是 `pages::app`(自带一条**没有任何 - // 控件**的标题栏留白带),而 `DragWindow` / `ResizeStart` / `Minimize` / `ToggleMaximize` / - // `Close` 五个消息只在外壳里构造 ⇒ 产品页「拖不动、缩不了、点不到关闭」。 + // `shell` 画,中间那块才是页面(`DragWindow`/`ResizeStart`/`Minimize`/`ToggleMaximize`/`Close` + // 五种消息的构造点只在外壳里,页面树自己画的标题栏拖不动窗口)。 // // M93:三样东西从外观状态进来——①背景层(垫在最底下)②面板 α(外壳层的底色)③设置页要读 - // 的那份状态本身。M101 r2 起 ③ 走 `page_with_settings`:外观状态 + 各组自己的状态 - // (`App.settings`)一起递进去,四条填页线加字段不用动这一行。 + // 的那份状态。M101 r2 起 ③ 走 `page_with_settings`:外观状态 + 各组自己的状态一起递进去。 + let background = + pages::appearance::layer(&self.ui, &self.appearance, (self.width, self.height)) + .map(|element| element.map(Message::Page)); + // 右栗(M104):拆出档返回 `None`(会话已在独立页里);拖拽期间另有铺满窗口的取指针层。 + let overlay = agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent)); + match pages::Screen::of(self.state.route()) { - Some(_) => { - let background = - pages::appearance::layer(&self.ui, &self.appearance, (self.width, self.height)) - .map(|element| element.map(Message::Page)); - - // 右栗(M104):`Detached` 档返回 `None`(会话已经在独立页里);拖拽期间另有一层 - // 铺满窗口的取指针层,所以两件东西分开传。 - let right = agent::docked(&self.ui, &self.agent, self.width) - .map(|element| element.map(Message::Agent)); - let overlay = - agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent)); - - shell::view_with_background( + Some(_) => shell::view_with_background( + &self.ui, + nav_item_of(self.state.route()), + APP_TITLE, + background, + // M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。 + pages::page_with_settings( &self.ui, - nav_item_of(self.state.route()), - APP_TITLE, - background, - // M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。 - // `settings` 的所有者是 `App`;这里只借一帧,加/改某一组的字段不用动这行。 - pages::page_with_settings( - &self.ui, - &self.view, - pages::settings::States { - appearance: Some(&self.appearance), - groups: &self.settings, - }, - ) - .map(Message::Page), - right, - overlay, - self.options.force_state, + &self.view, + pages::settings::States { + appearance: Some(&self.appearance), + groups: &self.settings, + }, ) - } - // `Route::Agent`:AI 独立页(M104)。它与右栗**共用**同一个会话视图 - // (`agent::page` 内部调的是 `conversation::view`),这里只是把会话摆进页面槽位。 - None => { - let background = - pages::appearance::layer(&self.ui, &self.appearance, (self.width, self.height)) - .map(|element| element.map(Message::Page)); - let overlay = - agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent)); - - shell::view_with_background( - &self.ui, - NavItem::Ai, - APP_TITLE, - background, - agent::page(&self.ui, &self.agent, self.width).map(Message::Agent), - None, - overlay, - self.options.force_state, - ) - } + .map(Message::Page), + agent::docked(&self.ui, &self.agent, self.width) + .map(|element| element.map(Message::Agent)), + overlay, + self.options.force_state, + ), + // `Route::Agent`:独立页与右栗共用同一个 `conversation::view`(`agent::page` 内部调的它)。 + None => shell::view_with_background( + &self.ui, + NavItem::Ai, + APP_TITLE, + background, + agent::page(&self.ui, &self.agent, self.width).map(Message::Agent), + None, + overlay, + self.options.force_state, + ), } } } diff --git a/crates/app-iced/src/pages/agent/conversation.rs b/crates/app-iced/src/pages/agent/conversation.rs index fcffd88..fb257d9 100644 --- a/crates/app-iced/src/pages/agent/conversation.rs +++ b/crates/app-iced/src/pages/agent/conversation.rs @@ -174,6 +174,22 @@ fn header<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<' fn actions<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'a, Message> { let mut items: Vec> = Vec::new(); + // 页宿主只有一个正确动作:收回右栗。这里先按**宿主**分流(而不是先按档位)是结构性兜底: + // 就算哪天档位同步漏了,独立页也不会再出现「Open assistant / Collapse / Pop out」这种 + // 右栗才该有的按钮(复审 r1 P2-1 的可见症状)。 + if spec.host == Host::Page { + return row![label_button( + ui, + catalog.text(Key::AgentDock), + IconKind::ChevronLeft, + Some(Message::Dock), + spec.focus == Some(Focus::Detach), + )] + .spacing(ui.space(Space::X2)) + .align_y(iced::Alignment::Center) + .into(); + } + match spec.rail { RailState::Collapsed => items.push(label_button( ui, diff --git a/crates/app-iced/src/pages/agent/conversation/composer.rs b/crates/app-iced/src/pages/agent/conversation/composer.rs index 7ccb69f..3fdbf53 100644 --- a/crates/app-iced/src/pages/agent/conversation/composer.rs +++ b/crates/app-iced/src/pages/agent/conversation/composer.rs @@ -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> { + 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()); + } +} diff --git a/crates/app-iced/src/pages/agent/mod.rs b/crates/app-iced/src/pages/agent/mod.rs index d3d4444..409cca4 100644 --- a/crates/app-iced/src/pages/agent/mod.rs +++ b/crates/app-iced/src/pages/agent/mod.rs @@ -149,7 +149,15 @@ pub fn page<'a>(ui: &'a Ui, agent: &'a AgentUi, window_width: f32) -> Element<'a /// /// 为什么铺满整窗:`mouse_area::on_move` 只在**它自己的边界内**回报坐标,而拖拽时指针一定会离开 /// 那 8px 的缝隙。铺满整窗后 `Point.x` 就是窗口坐标 ⇒ `目标宽 = 窗口宽 − x`。 -/// 松手由同一层的 `on_release` 收尾(否则会一直跟着指针走)。 +/// +/// **三条收尾路径**(复审 P3-7:`mouse_area` 在 `!cursor.is_over` 时直接 return,窗**外**松手 +/// 的 `on_release` 永远不来,于是叠层会留在最上层、右栗继续跟着指针缩放): +/// +/// | 路径 | 触发 | 语义 | +/// |---|---|---| +/// | `on_release` | 窗内松手 | 正常结束 | +/// | `on_exit` | 指针离开窗口 | 「拖到窗外」= 收在当前值(不是把指针丢掉继续跟) | +/// | `on_press` | 拖拽中再按一次 | 兜底清态(万一上两条都没来,一次点击就能恢复) | pub fn drag_overlay<'a>(agent: &AgentUi) -> Option> { if !agent.is_dragging() { return None; @@ -164,6 +172,8 @@ pub fn drag_overlay<'a>(agent: &AgentUi) -> Option> { .interaction(mouse::Interaction::ResizingHorizontally) .on_move(|point| Message::DragTo(point.x)) .on_release(Message::DragEnd) + .on_press(Message::DragEnd) + .on_exit(Message::DragEnd) .into(), ) } @@ -284,33 +294,34 @@ fn unread_text<'a>(ui: &'a Ui, unread: usize, _catalog: Catalog) -> Element<'a, fn panel<'a>(ui: &'a Ui, agent: &'a AgentUi, window_width: f32) -> Element<'a, Message> { let width = agent.rail_width(window_width); + let catalog = Catalog::of(LANG); - let body = container(conversation::view( - ui, - spec(agent, Host::Rail), - Catalog::of(LANG), - )) - .width(Length::Fixed(width)) - .height(Length::Fill) - .padding(Padding { - top: ui.space(Space::X6), - right: ui.space(Space::X4), - bottom: ui.space(Space::X4), - left: ui.space(Space::X4), - }) - .style(move |_theme| container::Style { - background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))), - ..container::Style::default() - }); + let body = container(conversation::view(ui, spec(agent, Host::Rail), catalog)) + .width(Length::Fixed(width)) + .height(Length::Fill) + .padding(Padding { + top: ui.space(Space::X6), + right: ui.space(Space::X4), + bottom: ui.space(Space::X4), + left: ui.space(Space::X4), + }) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))), + ..container::Style::default() + }); - row![splitter(ui), body] + row![splitter(ui, width, catalog), body] .width(Length::Shrink) .height(Length::Fill) .into() } -/// 8px 拖拽缝:1px 分隔线居中 + 水平缩放光标 + 按下即开拖。 -fn splitter<'a>(ui: &'a Ui) -> Element<'a, Message> { +/// 8px 拖拽缝:1px 分隔线居中 + 水平缩放光标 + 按下即开拖 + **可读标签**(tooltip)。 +/// +/// 复审 P3-5:这条 8px 的缝此前没有任何可读文字,而 `AgentResize` / `AgentWidthLabel` 两条 key +/// 在视图里一次没用(只在测试里被断言「不空」)——那是空头支票。现在 tooltip 同时给出 +/// 「拖它做什么」(`AgentResize`)与「当前多宽」(`AgentWidthLabel`),两个 key 都真用上了。 +fn splitter<'a>(ui: &'a Ui, width: f32, catalog: Catalog) -> Element<'a, Message> { let line = container( rule::vertical::(ui.metrics().divider_thickness).style(move |_theme| { rule::Style { @@ -325,10 +336,33 @@ fn splitter<'a>(ui: &'a Ui) -> Element<'a, Message> { .height(Length::Fill) .align_x(Alignment::Center); - mouse_area(line) + let grab = mouse_area(line) .interaction(mouse::Interaction::ResizingHorizontally) - .on_press(Message::DragStart) - .into() + .on_press(Message::DragStart); + + let tip_style = ui.style(TypeRole::BodySmall); + let pixels = format!("{width:.0}"); + let tip = container(column![ + text(catalog.text(Key::AgentResize)) + .size(tip_style.size_px) + .font(ui.font_of(tip_style)) + .color(ui.color(Role::OnSurface)), + text(catalog.render(Key::AgentWidthLabel, &[("width", pixels.as_str())])) + .size(tip_style.size_px) + .font(ui.font_of(tip_style)) + .color(ui.color(Role::OnSurfaceVariant)), + ]) + .padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)])) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))), + border: Border { + radius: ui.radius_dp(ShapeScale::Small), + ..Border::default() + }, + ..container::Style::default() + }); + + tooltip(grab, tip, tooltip::Position::Left).into() } /// 会话视图的入参(**唯一**的组装点:两个宿主都走这里,不许各拼一份)。 diff --git a/crates/app-iced/src/pages/agent/state.rs b/crates/app-iced/src/pages/agent/state.rs index 951111f..ff3613b 100644 --- a/crates/app-iced/src/pages/agent/state.rs +++ b/crates/app-iced/src/pages/agent/state.rs @@ -285,15 +285,14 @@ impl AgentUi { } /// 输入框内容变化(`text_editor::Action` 由 app 层转过来)。 + /// + /// 这里**不**分 Enter / Shift+Enter:分流在 `conversation::composer::binding_for`(非 shift 的 + /// Enter 根本不会变成 `Action`,它直接发 `Message::Submit`)。所以走到这里的 `Edit(Enter)` + /// 一定是「Shift+Enter 换行」那一支,`perform` 就够——早先这里有一段与之等价的 `if` 分支, + /// 读起来像关键拦截,实际是冗余(复审 P3-2 指出后删掉)。 pub fn edit(&mut self, action: text_editor::Action) { - use iced::widget::text_editor::{Action, Edit}; + use iced::widget::text_editor::Action; - // Enter:**发送**(`Shift+Enter` 才换行)。这条语义在 `conversation::composer` 的 - // `key_binding` 里已经分好流,走到这里的 `Enter` 只可能是「换行」那一支。 - if let Action::Edit(Edit::Enter) = action { - self.draft.perform(Action::Edit(Edit::Enter)); - return; - } if matches!(action, Action::Click(_)) { // 鼠标点了输入框 = 用户切回鼠标,键盘巡航结束(见 `keyboard_mode` 的理由) self.focus = Focus::Composer; diff --git a/crates/app-iced/src/pages/agent/tests.rs b/crates/app-iced/src/pages/agent/tests.rs index ba93c64..d65073e 100644 --- a/crates/app-iced/src/pages/agent/tests.rs +++ b/crates/app-iced/src/pages/agent/tests.rs @@ -8,8 +8,9 @@ use unlucky_i18n::{Catalog, Key, Lang}; use crate::theme::{Families, Ui}; +mod drag; + use super::conversation::{Conversation, Host}; -use super::metrics::{layout_for, PRESETS, RESIZE_GUTTER}; use super::state::{AgentUi, Focus, RailState, Speaker, Turn}; use super::*; @@ -19,14 +20,15 @@ const FAMILIES: Families = Families { mono: "Consolas", }; -fn ui() -> Ui { +pub(super) fn ui() -> Ui { Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES) } -/// 三档迁移:会话、草稿、未读的语义。 +/// 三档迁移:会话与草稿跟着走、未读按「打开即读」清零。 /// -/// 抓得住:拆出去再收回,话说了一半还在(transcript + draft 不丢);未读只在收起态累积。 -/// 抓不住:切换时有没有一次「闪一下」的重排(那要出图看)。 +/// 抓得住:拆出去再收回,**transcript 与 draft** 不丢(`busy` 不在这条测试的断言里); +/// `expand()/detach()/dock()` 都把 `unread` 清零;`collapse()` 不清(收起后新消息还要能点亮)。 +/// 抓不住:切换时有没有一次「闪一下」的重排(那要出图看),也不测 `busy` 的迁移。 #[test] fn the_three_states_share_one_conversation() { let mut agent = AgentUi::new(); @@ -37,14 +39,17 @@ fn the_three_states_share_one_conversation() { assert_eq!(agent.rail, RailState::Expanded); assert_eq!(agent.unread, 0, "展开即读"); + // 走 `AgentUi::edit`(= app 层真正的落点),而不是直接操作 iced 的 `Content`: + // 走到这里的 `Edit(Enter)` 只可能是 Shift+Enter(非 shift 的 Enter 在 composer 的 + // `key_binding` 里就变成 `Message::Submit` 了,见 composer 的 `enter_sends_and_shift_enter_breaks_the_line`)。 agent.draft = iced::widget::text_editor::Content::with_text("kick off Survival"); - agent.draft.perform(iced::widget::text_editor::Action::Edit( + agent.edit(iced::widget::text_editor::Action::Edit( iced::widget::text_editor::Edit::Enter, )); assert_eq!( agent.draft.line_count(), 2, - "多行:Enter 的编辑动作能落进草稿" + "多行:一条 Edit(Enter) 经过 `edit()` 之后草稿变成两行" ); let turns = agent.transcript.len(); @@ -184,6 +189,7 @@ fn every_action_has_a_readable_label() { Key::AgentStop, Key::AgentStripLabel, Key::AgentResize, + Key::AgentWidthLabel, ] { assert!( !catalog.text(key).trim().is_empty(), @@ -305,74 +311,6 @@ fn every_state_builds_in_both_hosts() { } } -/// 拖拽层只在拖拽期间存在(否则它会吃掉整窗的点击)。 -#[test] -fn the_drag_overlay_only_exists_while_dragging() { - let mut agent = AgentUi::new(); - agent.expand(); - assert!(drag_overlay(&agent).is_none()); - - agent.drag_start(); - assert!(agent.is_dragging()); - assert!(drag_overlay(&agent).is_some()); - - agent.drag_end(); - assert!(drag_overlay(&agent).is_none()); -} - -/// 拆出档右栗必须**不占地**(否则会话会在两个地方各画一份)。 -#[test] -fn the_detached_state_frees_the_rail_slot() { - let ui = ui(); - let mut agent = AgentUi::new(); - agent.detach(); - assert!(docked(&ui, &agent, 1180.0).is_none()); - assert_eq!(agent.rail_width(1180.0), 0.0); - assert_eq!(agent.content_width(1180.0), 1180.0); -} - -/// 拖拽把「展开宽度」写进状态(松手后保有该宽度)。 -#[test] -fn the_drag_writes_back_the_snapped_width() { - let mut agent = AgentUi::new(); - agent.expand(); - agent.drag_start(); - agent.drag_to(1180.0, 1180.0 - 280.0); - agent.drag_end(); - assert_eq!(agent.expanded(), 280.0); - assert!(!agent.is_dragging()); - - // 没按下就收到的 DragTo(例如上一步松手后的残留)不该改宽度 - let before = agent.expanded(); - agent.drag_to(1180.0, 100.0); - assert_eq!(agent.expanded(), before); - - // 三档预设都能被拖到(1180 下上限 612 > 360) - for preset in PRESETS { - agent.drag_start(); - agent.drag_to(1180.0, 1180.0 - preset); - agent.drag_end(); - assert_eq!(agent.expanded(), preset); - } -} - -/// 面板与中栗之间的缝在布局里被真的留出来了(宽度常量与 `layout_for` 同源)。 -#[test] -fn the_resize_gutter_is_part_of_the_budget() { - let mut agent = AgentUi::new(); - agent.expand(); - assert_eq!( - agent.rail_width(1180.0) + RESIZE_GUTTER, - 1180.0 - agent.content_width(1180.0) - ); - - // 折叠与拆出档没有缝 - let collapsed = layout_for(1180.0, RailState::Collapsed, 320.0); - assert_eq!(collapsed.gutter, 0.0); - let detached = layout_for(1180.0, RailState::Detached, 320.0); - assert_eq!(detached.gutter, 0.0); -} - /// 会话数据结构不许在手边偷偷长字段(`Turn` 只有说话人 + 正文)。 #[test] fn a_turn_is_only_a_speaker_and_a_body() { diff --git a/crates/app-iced/src/pages/agent/tests/drag.rs b/crates/app-iced/src/pages/agent/tests/drag.rs new file mode 100644 index 0000000..c5993c5 --- /dev/null +++ b/crates/app-iced/src/pages/agent/tests/drag.rs @@ -0,0 +1,137 @@ +//! 拖拽与「缝」的判据(从 `tests.rs` 拆出来:那边加完复审 r1 的断言后顶到 436 行, +//! 而 AGENTS.md §3-1 是 400 行硬线)。 +//! +//! 拆的位置是「指针/几何」这一族:拖拽三条收尾路径、吸附宽度写回、缝的预算、缝与折叠条的标签。 + +use super::super::metrics::{layout_for, PRESETS, RESIZE_GUTTER}; +use super::super::state::{AgentUi, RailState}; + +use super::super::{docked, drag_overlay}; +use super::ui; + +/// 拖拽层只在拖拽期间存在(否则它会吃掉整窗的点击)。 +#[test] +fn the_drag_overlay_only_exists_while_dragging() { + let mut agent = AgentUi::new(); + agent.expand(); + assert!(drag_overlay(&agent).is_none()); + + agent.drag_start(); + assert!(agent.is_dragging()); + assert!(drag_overlay(&agent).is_some()); + + agent.drag_end(); + assert!(drag_overlay(&agent).is_none()); +} + +/// 拆出档右栗必须**不占地**(否则会话会在两个地方各画一份)。 +#[test] +fn the_detached_state_frees_the_rail_slot() { + let ui = ui(); + let mut agent = AgentUi::new(); + agent.detach(); + assert!(docked(&ui, &agent, 1180.0).is_none()); + assert_eq!(agent.rail_width(1180.0), 0.0); + assert_eq!(agent.content_width(1180.0), 1180.0); +} + +/// 拖拽把「展开宽度」写进状态(松手后保有该宽度)。 +#[test] +fn the_drag_writes_back_the_snapped_width() { + let mut agent = AgentUi::new(); + agent.expand(); + agent.drag_start(); + agent.drag_to(1180.0, 1180.0 - 280.0); + agent.drag_end(); + assert_eq!(agent.expanded(), 280.0); + assert!(!agent.is_dragging()); + + // 没按下就收到的 DragTo(例如上一步松手后的残留)不该改宽度 + let before = agent.expanded(); + agent.drag_to(1180.0, 100.0); + assert_eq!(agent.expanded(), before); + + // 三档预设都能被拖到(1180 下上限 612 > 360) + for preset in PRESETS { + agent.drag_start(); + agent.drag_to(1180.0, 1180.0 - preset); + agent.drag_end(); + assert_eq!(agent.expanded(), preset); + } +} + +/// 面板与中栗之间的缝在布局里被真的留出来了。 +/// +/// **全用字面量**(复审 P3-4:早先写成 `rail + GUTTER == window − content`,两边同一组常量, +/// 把 `RESIZE_GUTTER` 改成 0 照样绿 = 自证)。现在的算式是写死的: +/// `320(面板)+ 8(缝)+ 852(中栗)= 1180`。 +#[test] +fn the_resize_gutter_is_part_of_the_budget() { + let mut agent = AgentUi::new(); + agent.expand(); + + assert_eq!(agent.rail_width(1180.0), 320.0, "1180 下展开档 = 令牌 320"); + assert_eq!(agent.content_width(1180.0), 852.0, "中栗 = 1180 − 320 − 8"); + assert_eq!(320.0 + 8.0 + 852.0, 1180.0, "三段之和必须回到窗口宽"); + assert_eq!(RESIZE_GUTTER, 8.0, "缝宽就是 8(`Space::X2`)"); + + // 折叠档 48 / 拆出档 0:这两档**没有**缝(中栗 = 1180 − 48 / 1180 − 0) + let collapsed = layout_for(1180.0, RailState::Collapsed, 320.0); + assert_eq!( + (collapsed.rail, collapsed.gutter, collapsed.content), + (48.0, 0.0, 1132.0) + ); + let detached = layout_for(1180.0, RailState::Detached, 320.0); + assert_eq!( + (detached.rail, detached.gutter, detached.content), + (0.0, 0.0, 1180.0) + ); +} + +/// 拖拽的三条收尾路径里,**能测的两条**(复审 P3-7)。 +/// +/// 真实鼠标在窗口外松手无法注入(这台机器连 `SetCursorPos` 都失败),所以这里只钉状态机: +/// ①松手(`DragEnd`)之后指针再动不改宽度;②拖拽中「再按一次」(叠层的 `on_press`)能清态, +/// 也就是上一条事件丢了的兜底。叠层本身挂了 `on_exit`(指针离窗 = 收在当前值),那条只能真窗口验。 +#[test] +fn the_drag_recovers_when_a_release_is_missed() { + let mut agent = AgentUi::new(); + agent.expand(); + + // ① 正常松手 + agent.drag_start(); + agent.drag_to(1180.0, 1180.0 - 360.0); + agent.drag_end(); + assert_eq!(agent.expanded(), 360.0); + agent.drag_to(1180.0, 900.0); + assert_eq!(agent.expanded(), 360.0, "松手后指针再动不该改宽度"); + + // ② 拖拽中「再按一次」= 叠层的 on_press 兜底:清态,后续 DragTo 不再改宽度 + agent.drag_start(); + agent.drag_to(1180.0, 1180.0 - 280.0); + assert_eq!(agent.expanded(), 280.0); + agent.drag_end(); // ← 叠层 on_press → DragEnd + assert!( + !agent.is_dragging(), + "再按一次必须能清态(窗内松手丢失时的兜底)" + ); + agent.drag_to(1180.0, 100.0); + assert_eq!(agent.expanded(), 280.0); + assert!(drag_overlay(&agent).is_none(), "清态后叠层必须消失"); +} + +/// 折叠条与拖拽缝都有可读文字(复审 P3-5:两条 key 以前在视图里一次没用)。 +/// +/// 抓得住:谁把 `AgentResize` / `AgentWidthLabel` 从视图里删掉(那样它们又变回空头支票)。 +/// 抓不住:tooltip 真弹出来长什么样(那要 hover 出图)。 +#[test] +fn the_gutter_and_strip_are_labelled() { + let hosts = include_str!("../mod.rs"); + for key in [ + "Key::AgentResize", + "Key::AgentWidthLabel", + "Key::AgentStripLabel", + ] { + assert!(hosts.contains(key), "视图里没用上 {key}"); + } +}