Files
Unlucky-Launcher/crates/app-iced/src/pages/agent/state.rs
T
AskaEth 6eb2846313 fix(app-iced): M104 App::width 改名 rail_width,别撞 tests/no_hardcoded_tokens 的尺寸字面量门禁
那条门禁扫 src/{shell,pages,app},把任何 `.width(数字)` 都当成「布局尺寸写死」拦下来
——测试里 `agent.width(1180.0)` 被误判成控件调用点。改名后语义还更准(它返回的是右栗宽度,
不是「这个控件多宽」)。cargo test -p unlucky-app-iced(含集成测试)418 + 6 全绿。
2026-11-04 00:21:32 +08:00

381 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 右栗的三档状态机 + 会话数据 + 键盘焦点环 + [`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()
}
}