//! 右栗的三档状态机 + 会话数据 + 键盘焦点环 + [`AgentUi`](挂在 `App` 上的 UI 状态)。 //! //! 布局数学(宽度 / 磁铁吸附 / 中栗下限)在 [`super::metrics`];这里只管「状态怎么变」。 //! //! ## 三档状态(用户定案 D16) //! //! | 档位 | 右栗宽度 | 会话视图在哪 | 切法 | //! |---|---|---|---| //! | [`RailState::Collapsed`] | 48(图标条 + 未读点) | 不画 | `⌄` 收起 / `Esc` | //! | [`RailState::Expanded`] | 280~360(默认 320) | 右栗面板内 | 点图标条 / 收回 | //! | [`RailState::Detached`] | 0 | `Route::Agent` 独立页 | `⇥` 拆出 | //! //! 三档是**同一个会话**的三种呈现:切档只改 [`RailState`] 与路由栈,`transcript` / `draft` / //! `busy` / `unread` 一条都不丢(「拆出去再接回来,话说了一半还在」)。 //! //! ## 为什么状态是 `App` 的字段 //! //! 这棵树在 `view(&self)` 里只读、在 `update(&mut self)` 里改,状态挂在 `App` 上最自然。 //! 早先想过 `OnceLock>`(当时 `App` 字段不在 scope),tower 明确否掉:那要测试串行化、 //! 而且不是 per-App。 //! //! **持久化欠账**:`presenter::prefs::UiPrefs` 里的键不在本 mission 的 scope,所以三档与吸附宽度 //! **只在本次进程内有效**(重启回到折叠 / 320)。接手点:`UiPrefs` 加两个键 + `App::new` 读一次, //! 本文件的 [`AgentUi::rail`] / [`AgentUi::set_expanded`] 就是那两个落点。 use iced::widget::text_editor; use unlucky_i18n::{Catalog, Key}; use super::metrics::{ content_width, effective_state, magnet_width, rail_width, DEFAULT_EXPANDED_WIDTH, }; /// 三档状态(顺序 = 展开程度,测试与文件名按它遍历)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RailState { /// 48px 图标条。 Collapsed, /// 280~360 的会话面板。 Expanded, /// 收进 `Route::Agent` 独立页(右栗消失)。 Detached, } impl RailState { /// 三档全序(截图矩阵按它铺)。 pub const ALL: [RailState; 3] = [ RailState::Collapsed, RailState::Expanded, RailState::Detached, ]; /// 稳定名字(文件名 / `ULL_AGENT_RAIL` 的取值 / 失败信息)。 pub const fn name(self) -> &'static str { match self { RailState::Collapsed => "collapsed", RailState::Expanded => "expanded", RailState::Detached => "detached", } } /// 反向解析(取证开关用)。 pub fn from_name(name: &str) -> Option { Self::ALL.into_iter().find(|state| state.name() == name) } } /// 会话里的一个说话人。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Speaker { /// 用户。 You, /// 助手(占位样本数据;真模型由 M103 的 presenter 供)。 Assistant, /// 系统提示(占位说明 / 中断记录)。 Notice, } /// 一条会话消息(**纯数据**:视图只读它,不改它)。 #[derive(Debug, Clone)] pub struct Turn { /// 说话人。 pub speaker: Speaker, /// 正文(多行;空会话时不会有 `Turn`)。 pub text: String, } impl Turn { /// 造一条。 pub fn new(speaker: Speaker, text: impl Into) -> Self { Self { speaker, text: text.into(), } } } /// 键盘软焦点顺序(Tab 环)。顺序 = 屏幕上的阅读顺序:头部动作 → 输入区动作 → 输入框。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Focus { /// `⌄` 收起。 Collapse, /// `⇥` 拆出(独立页上则是 `⇤` 收回,同一个焦点位)。 Detach, /// 发送。 Send, /// 中断(只在流式中出现)。 Stop, /// 输入框(真实焦点由 iced 的 `text_editor` 持有,这里只记「轮到它了」)。 Composer, } impl Focus { /// 稳定名字(日志 / 测试)。 pub const fn name(self) -> &'static str { match self { Focus::Collapse => "collapse", Focus::Detach => "detach", Focus::Send => "send", Focus::Stop => "stop", Focus::Composer => "composer", } } /// Tab 环上「这一档真正存在的控件」(流式中才多一个中断键;折叠档没有会话动作)。 fn order(state: RailState, busy: bool) -> Vec { let mut order = Vec::new(); match state { RailState::Collapsed => order.push(Focus::Collapse), RailState::Expanded => { order.push(Focus::Collapse); order.push(Focus::Detach); } RailState::Detached => order.push(Focus::Detach), } if state != RailState::Collapsed { order.push(Focus::Send); if busy { order.push(Focus::Stop); } order.push(Focus::Composer); } order } /// 下一个焦点(`shift` = 反向)。当前焦点不在环上时从头/尾进。 pub fn step(self, shift: bool, state: RailState, busy: bool) -> Focus { let order = Self::order(state, busy); let Some(at) = order.iter().position(|focus| *focus == self) else { return if shift { order.last() } else { order.first() } .copied() .unwrap_or(Focus::Collapse); }; let next = if shift { (at + order.len() - 1) % order.len() } else { (at + 1) % order.len() }; order[next] } } /// 助手栏的 UI 状态(挂在 `App` 上;**唯一**的写入口是 `app/update/agent.rs`)。 pub struct AgentUi { /// 三档状态。 pub rail: RailState, /// 展开档的吸附宽度(三档共享一个值:收起再展开回到同一宽度)。 expanded: f32, /// 会话内容(占位样本数据,真模型由 M103 接)。 pub transcript: Vec, /// 输入框(多行;iced 的 `text_editor` 自己持有编辑状态,这里只放内容)。 pub draft: text_editor::Content, /// 是否在流式回复(`false` = 没有中断键、发送键可用)。 pub busy: bool, /// 未读数(折叠条画点 + 一行可读文字;展开/拆出即清零)。 pub unread: usize, /// 键盘软焦点。 pub focus: Focus, /// 焦点环是否「被键盘走过」(`Tab` 置 true,鼠标点输入框 / 换档置 false)。 /// /// 为什么需要它:`Enter` 全局订阅与输入框自己的 `key_binding` 共用同一个键。若不加这道闸, /// 用户在**别的页面**用鼠标点过发送键之后,随手按 Enter 也会把草稿发出去。只有真的按过 /// `Tab`(= 用户在用键盘巡航助手栏)才让 `Activate` 生效。 pub keyboard_mode: bool, /// 拖拽中的指针 x(`Some` = 正在拖)。 pointer: Option, } impl AgentUi { /// 出厂态:**折叠** + 一段占位样本会话 + 2 条未读。 /// /// 为什么默认折叠:1180 下展开 320 会把实例页挤成单列(中栗 852 < 816 的两列门槛之外还够, /// 但 1024 就掉档了),用户第一次打开启动器不该先被一个助手栏改掉主页面版面。 pub fn new() -> Self { Self { rail: RailState::Collapsed, expanded: DEFAULT_EXPANDED_WIDTH, transcript: super::demo::transcript(), draft: text_editor::Content::new(), busy: false, unread: 2, focus: Focus::Composer, keyboard_mode: false, pointer: None, } } /// 空会话(出图矩阵的「零消息」那一张:清空 transcript 与未读)。 pub fn empty() -> Self { let mut ui = Self::new(); ui.transcript.clear(); ui.unread = 0; ui } /// 当前展开宽度(吸附后的值)。 pub fn expanded(&self) -> f32 { self.expanded } /// 直接指定展开宽度(只给取证开关用:三档预设值之一)。 pub fn set_expanded(&mut self, width: f32) { self.expanded = width; } /// 是否正在拖拽(shell 据此铺一层取指针的透明层)。 pub fn is_dragging(&self) -> bool { self.pointer.is_some() } /// 这一窗宽下**实际**生效的档位(窄窗里 `Expanded` 会退化成 `Collapsed`)。 pub fn effective_rail(&self, window_width: f32) -> RailState { effective_state(window_width, self.rail, self.expanded) } /// 右栗当前宽度(窗口宽度是判据之一:窗口变窄时展开档会被夹住)。 /// /// 名字故意不叫 `width`:`tests/no_hardcoded_tokens.rs` 会把任何 `.width(数字)` 当成 /// 「布局尺寸写死」拦下来,而测试里 `agent.width(1180.0)` 会被误判(那条门禁扫的是控件调用点, /// 分不清是谁的方法)。 pub fn rail_width(&self, window_width: f32) -> f32 { rail_width(window_width, self.rail, self.expanded) } /// **交给页面布局系统的宽度**(窗口 − 右栗 − 拖拽缝)。 /// /// `refresh_view` 用它算视图模型:面板展开后页面按更窄的宽度排版,内容才会「收缩」而不是 /// 顶到面板底下。 pub fn content_width(&self, window_width: f32) -> f32 { content_width(window_width, self.rail, self.expanded) } /// 收起(`⌄` / `Esc`)。未读不清零:收起后新消息仍要能显示成点。 pub fn collapse(&mut self) { self.keyboard_mode = false; if self.rail == RailState::Expanded { self.rail = RailState::Collapsed; self.focus = Focus::Collapse; } else if self.rail == RailState::Detached { self.dock(); } } /// 展开(点图标条)。展开即视为读过:未读清零(与主流聊天客户端的「打开即读」同口径)。 pub fn expand(&mut self) { self.keyboard_mode = false; self.rail = RailState::Expanded; self.unread = 0; self.focus = Focus::Collapse; } /// 拆成独立页(`⇥`)。会话状态一条不动。 pub fn detach(&mut self) { self.keyboard_mode = false; self.rail = RailState::Detached; self.unread = 0; self.focus = Focus::Detach; } /// 收回右栗(`⇤` / 独立页上的 `Esc`)。 pub fn dock(&mut self) { self.keyboard_mode = false; self.rail = RailState::Expanded; self.unread = 0; self.focus = Focus::Collapse; } /// 输入框内容变化(`text_editor::Action` 由 app 层转过来)。 pub fn edit(&mut self, action: text_editor::Action) { use iced::widget::text_editor::{Action, Edit}; // 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; self.keyboard_mode = false; } self.draft.perform(action); } /// 发送:把草稿记成一条「你」的消息 + 一条占位说明。 /// /// **不接后端**(M103 的 presenter API 不在本 mission 里调用):所以既不改 `busy`, /// 也不假装有回复——只追加一条**说明自己没接模型**的提示。返回是否真的发了。 pub fn submit(&mut self, catalog: Catalog) -> bool { let text = self.draft.text(); let trimmed = text.trim(); if trimmed.is_empty() || self.busy { return false; } self.transcript .push(Turn::new(Speaker::You, trimmed.to_owned())); self.transcript.push(Turn::new( Speaker::Notice, catalog.text(Key::AgentNotePending), )); self.draft = text_editor::Content::new(); true } /// 中断流式回复(`Stop`)。占位期只在**视图态**上停:记一条提示,不派任何业务命令。 pub fn stop(&mut self, catalog: Catalog) { if !self.busy { return; } self.busy = false; self.transcript.push(Turn::new( Speaker::Notice, catalog.text(Key::AgentNoteStopped), )); } /// 一键置为「流式中」(出图矩阵的 `streaming` 态与 `Stop` 键要靠它;真流式由 M103 驱动)。 pub fn pretend_streaming(&mut self) { self.busy = true; self.rail = RailState::Expanded; self.unread = 0; } /// 输入框是否为空(发送键的禁用判据)。 pub fn draft_is_blank(&self) -> bool { self.draft.text().trim().is_empty() } /// 拖拽开始(按下分隔条)。`0.0` 只是占位:第一次 `drag_to` 就会换成真实指针位置。 pub fn drag_start(&mut self) { self.pointer = Some(0.0); } /// 拖拽中:`x` = 指针在**窗口**里的横坐标(0 = 窗口左缘)。 pub fn drag_to(&mut self, window_width: f32, x: f32) { if self.pointer.is_none() { return; } self.pointer = Some(x); self.expanded = magnet_width(window_width, window_width - x); } /// 拖拽结束(松手)。 pub fn drag_end(&mut self) { self.pointer = None; } /// Tab 走一格(走一次就把键盘巡航打开)。 pub fn step_focus(&mut self, shift: bool) { self.focus = self.focus.step(shift, self.rail, self.busy); self.keyboard_mode = true; } } impl Default for AgentUi { fn default() -> Self { Self::new() } }