feat(app-iced): M104 AGENTRAIL —— 右栗三档 + 布局数学 + 独立页复用同一会话视图
用户定案(D16):右侧常驻可折叠、允许拆分成独立页、中栗宽度可调(磁铁吸附)。 本 mission 只做壳:三档状态 + 布局数学 + 一份会话视图;真数据仍归 M103,一行 presenter 都不调。 - 右栗三档:Collapsed(48) / Expanded(280~360,默认 320,锚=令牌 ai_panel_width) / Detached(Route::Agent); 会话、草稿、未读在切档时一条不丢(拆出去再收回,话说了一半还在) - 布局数学:中栗 = 窗口 − 右栗 − 拖拽缝(8),下限 472(= 行宽上限 424 + 两侧页边距); 右栗一展开,页面按更窄的宽度重新排版(收缩而不是溢出);拖拽磁铁吸附到 280/320/360,超上限夹紧 - 独立页与右栗共用 conversation::view 这一份实现(输入=纯数据,输出=agent::Message) - 控件:多行输入框(Enter 发送 / Shift+Enter 换行,走 text_editor::key_binding 的 modifiers 分流)、 发送禁用态、流式中断、未读点 + 可读计数、⌄ 收起 / ⇥ 拆出 / ⇤ 收回;Esc 收栗、Tab 焦点环、Enter 激活 - 文案全部进 crates/i18n 的新域 catalog/agent.rs(+ golden 追加 20 行),零硬编码中文 - 取证:ULL_AGENT_RAIL / ULL_AGENT_FIXTURE / ULL_AGENT_WIDTH(截图走产品 bin,标题栏与三键可见) - 消息不走 pages::Message(外壳级视图),走 app::Message::Agent(..) → app/update/agent.rs - 删掉 M68 骨架回落(shell::view + shell/fallback.rs):Route::Agent 现在有真页面 门禁:fmt 0 / clippy(workspace,-D warnings) 0 / pages_demo 0 / app-iced lib 418 条全绿
This commit is contained in:
1 parent
875ac159ee
commit
c3f20784a8
20 files changed
+2463
-153
No files matched your search
@@ -0,0 +1,376 @@
|
||||
//! 右栗的三档状态机 + 会话数据 + 键盘焦点环 + [`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)
|
||||
}
|
||||
|
||||
/// 右栗当前宽度(窗口宽度是判据之一:窗口变窄时展开档会被夹住)。
|
||||
pub fn 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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user