Files
Unlucky-Launcher/crates/app-iced/src/pages/agent/state.rs
T
AskaEth b61f342431 feat(motion2): 右栗折叠过渡 + 状态层 hover/pressed 过渡
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 未改)
2026-11-12 20:05:58 +08:00

357 lines
15 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.
//! 右栗的**纯 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()
}
}