那条门禁扫 src/{shell,pages,app},把任何 `.width(数字)` 都当成「布局尺寸写死」拦下来
——测试里 `agent.width(1180.0)` 被误判成控件调用点。改名后语义还更准(它返回的是右栗宽度,
不是「这个控件多宽」)。cargo test -p unlucky-app-iced(含集成测试)418 + 6 全绿。
381 lines
14 KiB
Rust
381 lines
14 KiB
Rust
//! 右栗的三档状态机 + 会话数据 + 键盘焦点环 + [`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<Mutex<..>>`(当时 `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> {
|
||
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<String>) -> 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<Focus> {
|
||
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<Turn>,
|
||
/// 输入框(多行;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<f32>,
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|