A2 侧边栏(右栗 AI 栏)折叠/展开: - 帧泵第三条轨道(pump/rail):展开 medium1 250ms + Emphasized、收起 short4 200ms + EmphasizedAccelerate;拆出档走 snap(不做过渡);MotionScale::NONE 下不插轨道 - 宽度在 metrics::rail_width 逐帧插值;面板内容按目标宽排版 + 按动画宽裁切 (不重排 / 不溢出),宽度回到 48 时换成图标条(图标贴右端,位置不跳) - pages/agent/mod.rs 的展开档面板纯搬移到 panel.rs(371 → 289 行) A8 状态层过渡: - interaction/layer:令牌化的进度时钟(short1 50ms),NONE 当帧落终值 - interaction/hover:取样控件把起跑搬到 Widget::update(早于订阅表求值) - 按钮五式 + IconButton + 导航项药丸统一走 interaction::layer_color (没有取样控件时逐位退回旧口径,出图基线不变) - components/motion/mod.rs 的 needs_frames / advance 各加一句委托, 帧订阅仍然只有一处判断点(app/subscription.rs 未改)
357 lines
15 KiB
Rust
357 lines
15 KiB
Rust
//! 右栗的**纯 UI 状态**:三档状态机 + 展开宽度 + 草稿 + 键盘焦点环 + [`AgentUi`]。
|
||
//!
|
||
//! 布局数学(宽度 / 磁铁吸附 / 中栗下限)在 [`super::metrics`];这里只管「UI 状态怎么变」。
|
||
//!
|
||
//! ## 会话内容为什么不在这个文件里(M119)
|
||
//!
|
||
//! M104 的 `AgentUi` 自带 `transcript` / `busy` / `unread` 三个字段,数据来自内置样本
|
||
//! ([`super::demo`])——那是「壳先搭好、等 M103 接」的过渡态。M119 把这三个字段**删掉**:
|
||
//! 会话的唯一来源是 presenter 的 `AgentRail`(`app/state.rs` 的 `agent_host`),右栗与独立页
|
||
//! 直接读它。于是在这个文件里再放一份就是第二份真相,而两份真相必然漂。
|
||
//!
|
||
//! ## 三档状态(用户定案 D16)
|
||
//!
|
||
//! | 档位 | 右栗宽度 | 会话视图在哪 | 切法 |
|
||
//! |---|---|---|---|
|
||
//! | [`RailState::Collapsed`] | 48(图标条 + 未读点) | 不画 | `⌄` 收起 / `Esc` |
|
||
//! | [`RailState::Expanded`] | 280~360(默认 320) | 右栗面板内 | 点图标条 / 收回 |
|
||
//! | [`RailState::Detached`] | 0 | `Route::Agent` 独立页 | `⇥` 拆出 |
|
||
//!
|
||
//! 三档是**同一个会话**的三种呈现:切档只改 [`RailState`] 与路由栈,草稿与焦点一条都不丢
|
||
//! (会话本身在 presenter 上,三档共享得比原来更彻底)。
|
||
//!
|
||
//! ## 未读 / busy 从哪来
|
||
//!
|
||
//! 都在 `AgentRail` 上:未读由 presenter 按「栏可见吗」累加、`agent_mark_read()` 清零;
|
||
//! `busy` 由「有流式消息 || 有写操作待确认」算出来。壳里**不再自己数**未读。
|
||
|
||
use iced::widget::text_editor;
|
||
|
||
use super::metrics::{
|
||
content_width, effective_state, magnet_width, rail_width, DEFAULT_EXPANDED_WIDTH,
|
||
};
|
||
|
||
use crate::app::components::motion;
|
||
|
||
/// 三档状态(顺序 = 展开程度,测试与文件名按它遍历)。
|
||
#[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)
|
||
}
|
||
}
|
||
|
||
/// 键盘软焦点顺序(Tab 环)。顺序 = 屏幕上的阅读顺序:头部动作 → 输入区动作 → 输入框。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Focus {
|
||
/// `⌄` 收起。
|
||
Collapse,
|
||
/// `⇥` 拆出(独立页上则是 `⇤` 收回,同一个焦点位)。
|
||
Detach,
|
||
/// 确认条的「允许」(只在有待确认写操作时存在于环上)。
|
||
///
|
||
/// M119 r1 / 复审 P2-2:确认条原本只有鼠标路径——键盘用户在写操作确认这一步**卡死**
|
||
/// (`busy` 锁住发送,`Stop` 走的是主后端、清不掉 presenter 的待确认)。这两格就是那条键盘通路。
|
||
ConfirmAllow,
|
||
/// 确认条的「拒绝」。
|
||
ConfirmDeny,
|
||
/// 发送。
|
||
Send,
|
||
/// 中断(只在流式中出现)。
|
||
Stop,
|
||
/// 输入框(真实焦点由 iced 的 `text_editor` 持有,这里只记「轮到它了」)。
|
||
Composer,
|
||
}
|
||
|
||
impl Focus {
|
||
/// 稳定名字(日志 / 测试)。
|
||
pub const fn name(self) -> &'static str {
|
||
match self {
|
||
Focus::Collapse => "collapse",
|
||
Focus::Detach => "detach",
|
||
Focus::ConfirmAllow => "confirm-allow",
|
||
Focus::ConfirmDeny => "confirm-deny",
|
||
Focus::Send => "send",
|
||
Focus::Stop => "stop",
|
||
Focus::Composer => "composer",
|
||
}
|
||
}
|
||
|
||
/// Tab 环上「这一档真正存在的控件」。
|
||
///
|
||
/// `confirm_pending` = 有待确认的写操作(那时条上多两格:允许 / 拒绝)。`busy` 在等确认时
|
||
/// 也是真(presenter 的 `busy = streaming || confirm.is_some()`),所以环上还会多一格中断。
|
||
fn order(state: RailState, busy: bool, confirm_pending: 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 confirm_pending {
|
||
order.push(Focus::ConfirmAllow);
|
||
order.push(Focus::ConfirmDeny);
|
||
}
|
||
if state != RailState::Collapsed {
|
||
order.push(Focus::Send);
|
||
if busy {
|
||
order.push(Focus::Stop);
|
||
}
|
||
order.push(Focus::Composer);
|
||
}
|
||
order
|
||
}
|
||
|
||
/// 下一个焦点(`shift` = 反向)。当前焦点不在环上时从头/尾进。
|
||
///
|
||
/// `busy` 由 presenter 的 rail 给(壳里不自己数):流式中环上多一格 `Stop`。
|
||
pub fn step(self, shift: bool, state: RailState, busy: bool, confirm_pending: bool) -> Focus {
|
||
let order = Self::order(state, busy, confirm_pending);
|
||
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,
|
||
/// 输入框(多行;iced 的 `text_editor` 自己持有编辑状态,这里只放内容)。
|
||
pub draft: text_editor::Content,
|
||
/// 键盘软焦点。
|
||
pub focus: Focus,
|
||
/// 焦点环是否「被键盘走过」(`Tab` 置 true,鼠标点输入框 / 换档置 false)。
|
||
///
|
||
/// 为什么需要它:`Enter` 全局订阅与输入框自己的 `key_binding` 共用同一个键。若不加这道闸,
|
||
/// 用户在**别的页面**用鼠标点过发送键之后,随手按 Enter 也会把草稿发出去。只有真的按过
|
||
/// `Tab`(= 用户在用键盘巡航助手栏)才让 `Activate` 生效。
|
||
pub keyboard_mode: bool,
|
||
/// 拖拽中的指针 x(`Some` = 正在拖)。
|
||
pointer: Option<f32>,
|
||
}
|
||
|
||
impl AgentUi {
|
||
/// 出厂态:**折叠** + 空草稿 + 无未读(未读由 presenter 按事件累加,M119)。
|
||
///
|
||
/// 为什么默认折叠:1180 下展开 320 会把实例页挤成单列(中栗 852 < 816 的两列门槛之外还够,
|
||
/// 但 1024 就掉档了),用户第一次打开启动器不该先被一个助手栏改掉主页面版面。
|
||
pub fn new() -> Self {
|
||
Self {
|
||
rail: RailState::Collapsed,
|
||
expanded: DEFAULT_EXPANDED_WIDTH,
|
||
draft: text_editor::Content::new(),
|
||
focus: Focus::Composer,
|
||
keyboard_mode: false,
|
||
pointer: None,
|
||
}
|
||
}
|
||
|
||
/// 当前展开宽度(吸附后的值)。
|
||
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)
|
||
}
|
||
|
||
/// 折叠过渡的**目标档**(`true` = 展开)。
|
||
pub fn rail_open(&self) -> bool {
|
||
self.rail == RailState::Expanded
|
||
}
|
||
|
||
/// 折叠进度(0 = 48 图标条,1 = 展开档)。**只采样**,起跑在下面四个档位迁移里。
|
||
pub fn collapse_progress(&self) -> f32 {
|
||
motion::rail(self.rail_open())
|
||
}
|
||
|
||
/// 右栗当前宽度(窗口宽度是判据之一:窗口变窄时展开档会被夹住)。
|
||
///
|
||
/// M257 起它是**逐帧宽度**:档位切换的那 200~250ms 里返回过渡中的值(端点与静息值逐位相同)。
|
||
///
|
||
/// 名字故意不叫 `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,
|
||
self.collapse_progress(),
|
||
)
|
||
}
|
||
|
||
/// **交给页面布局系统的宽度**(窗口 − 右栗 − 拖拽缝)。
|
||
///
|
||
/// `refresh_view` 用它算视图模型:面板展开后页面按更窄的宽度排版,内容才会「收缩」而不是
|
||
/// 顶到面板底下。
|
||
pub fn content_width(&self, window_width: f32) -> f32 {
|
||
content_width(window_width, self.rail, self.expanded)
|
||
}
|
||
|
||
/// 收起(`⌄` / `Esc`)。未读**不**清零:收起后新消息仍要能显示成点(清零由 app 层在
|
||
/// 「打开栏」时调 `agent_mark_read` 做——那是 presenter 的语义,不是 UI 的)。
|
||
pub fn collapse(&mut self) {
|
||
self.keyboard_mode = false;
|
||
if self.rail == RailState::Expanded {
|
||
self.rail = RailState::Collapsed;
|
||
// 起跑在 update 侧(本函数由 `app/update/agent.rs` 调)——折叠那 200ms 的宽度轨道。
|
||
motion::observe_rail(false);
|
||
self.focus = Focus::Collapse;
|
||
} else if self.rail == RailState::Detached {
|
||
self.dock();
|
||
}
|
||
}
|
||
|
||
/// 展开(点图标条)。展开即视为读过——**清零动作在 app 层**(`mark_read`),这里只改档位。
|
||
pub fn expand(&mut self) {
|
||
self.keyboard_mode = false;
|
||
// 只有「从折叠档展开」才是宽度的连续变化;从独立页收回(`dock`)与首次落档都是**布局切换**。
|
||
let animate = self.rail == RailState::Collapsed;
|
||
self.rail = RailState::Expanded;
|
||
if animate {
|
||
motion::observe_rail(true);
|
||
} else {
|
||
motion::snap_rail(true);
|
||
}
|
||
self.focus = Focus::Collapse;
|
||
}
|
||
|
||
/// 拆成独立页(`⇥`)。UI 状态一条不动(会话在 presenter 上,本来就不归这里管)。
|
||
pub fn detach(&mut self) {
|
||
self.keyboard_mode = false;
|
||
self.rail = RailState::Detached;
|
||
// 拆出档**不做过渡**(右栗消失、会话搬到独立页,是布局切换):目标落终值。
|
||
motion::snap_rail(false);
|
||
self.focus = Focus::Detach;
|
||
}
|
||
|
||
/// 收回右栗(`⇤` / 独立页上的 `Esc`)。
|
||
pub fn dock(&mut self) {
|
||
self.keyboard_mode = false;
|
||
self.rail = RailState::Expanded;
|
||
// 从独立页收回到「常驻展开」:右栗是从 0 出现的布局切换,不是 48→320 的宽度过渡。
|
||
motion::snap_rail(true);
|
||
self.focus = Focus::Collapse;
|
||
}
|
||
|
||
/// 输入框内容变化(`text_editor::Action` 由 app 层转过来)。
|
||
///
|
||
/// 这里**不**分 Enter / Shift+Enter:分流在 `conversation::composer::binding_for`(非 shift 的
|
||
/// Enter 根本不会变成 `Action`,它直接发 `Message::Submit`)。所以走到这里的 `Edit(Enter)`
|
||
/// 一定是「Shift+Enter 换行」那一支,`perform` 就够。
|
||
pub fn edit(&mut self, action: text_editor::Action) {
|
||
use iced::widget::text_editor::Action;
|
||
|
||
if matches!(action, Action::Click(_)) {
|
||
// 鼠标点了输入框 = 用户切回鼠标,键盘巡航结束(见 `keyboard_mode` 的理由)
|
||
self.focus = Focus::Composer;
|
||
self.keyboard_mode = false;
|
||
}
|
||
self.draft.perform(action);
|
||
}
|
||
|
||
/// 清空输入框(发送成功之后调:草稿已经交给 presenter 了)。
|
||
pub fn clear_draft(&mut self) {
|
||
self.draft = text_editor::Content::new();
|
||
}
|
||
|
||
/// 输入框是否为空(发送键的禁用判据)。
|
||
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 走一格(走一次就把键盘巡航打开)。`busy` / `confirm_pending` 都由 presenter 的 rail 给。
|
||
///
|
||
/// **待确认的那一次 Tab 只点亮焦点环、不移动焦点**:待确认出现时 app 层已经把焦点放到
|
||
/// 「允许」上(阻塞态,见 `App::sync_confirm_focus`),但**不会**替用户把键盘巡航打开
|
||
/// (M119 r2 / 复审 P2-2:自动 arm 会让用户随手敲的空格变成「批准」)。所以第一次 `Tab`
|
||
/// 是「我要用键盘」的显式表态,此时焦点原地不动——否则第一次 Tab 会把「允许」挪到「拒绝」,
|
||
/// 用户按 `Enter` 就是反着裁决。
|
||
pub fn step_focus(&mut self, shift: bool, busy: bool, confirm_pending: bool) {
|
||
let arming = !self.keyboard_mode
|
||
&& confirm_pending
|
||
&& matches!(self.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
|
||
self.keyboard_mode = true;
|
||
if !arming {
|
||
self.focus = self.focus.step(shift, self.rail, busy, confirm_pending);
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for AgentUi {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|