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:
AskaEth committed 2026-11-04 00:21:32 +08:00
1 parent 875ac159ee
commit c3f20784a8
20 files changed
+2463 -153

No files matched your search

@@ -0,0 +1,367 @@
//! **会话视图组件**:右栗面板与 `Route::Agent` 独立页的**同一份**对话 UI。
//!
//! ## 输入契约(纯数据入、消息出)
//!
//! ```text
//! Conversation { transcript, draft, busy, unread, blank, rail, host, focus } ──view()──▶ Element<Message>
//! ```
//!
//! - **入**:全部是**借用**的纯数据(`&[Turn]` / `&text_editor::Content` / `bool` / 枚举),
//! 组件自己不持有会话状态、不读环境、不碰 presenter(任务里「不许提前调用 M103 的 API」);
//! - **出**:控件树只发 [`super::Message`](`Collapse` / `Detach` / `Dock` / `Submit` / `Stop` /
//! `Edit` / `Expand`),app 层再包一层 `Message::Agent(..)`。于是同一份视图可以被两个宿主
//! 摆在不同宽度里,而「点了发生什么」只有一处实现(`app/update/agent.rs`)。
//!
//! ## 两个宿主只差「外壳」
//!
//! | 宿主 | 谁调用 | 给的宽度 | 差别 |
//! |---|---|---|---|
//! | [`Host::Rail`] | [`super::rail`](右栗面板) | 280~360(本组件吃 Fill) | 头部动作是 `⌄` 收起 + `⇥` 拆出 |
//! | [`Host::Page`] | [`super::page`](独立页) | 行宽上限 424 | 头部动作是 `⇤` 收回;标题是页级标题 |
//!
//! **判据不是「长得像」而是「同一个函数」**:`tests::both_hosts_render_the_same_conversation`
//! 用源码扫描钉住「两个宿主都只通过 [`view`] 画对话」,`mod.rs` 里不许出现第二份气泡/输入框装配。
//!
//! ## 控件与可达性(任务第 4 条逐项)
//!
//! | 要求 | 落点 |
//! |---|---|
//! | 输入框多行 | `text_editor`(真多行,不是 `text_input` 的单行) |
//! | Enter 发送 / Shift+Enter 换行 | [`composer`] 的 `key_binding`:`Binding::Custom(Submit)` vs `Binding::Enter`,用 `modifiers.shift()` 分流(**只有输入框聚焦时生效**,不会抢别处的 Enter) |
//! | 发送禁用态 | 草稿空白或流式中 → `Button::disabled`(M3 的 10% 容器 / 38% 内容,不是自创灰色) |
//! | 流式中断 | 只在 `busy` 时出现 `Stop`(`IconKind::Close` + 文字标签) |
//! | 未读点 | 折叠条上的圆点([`super::rail`])+ 面板头部的「N unread」胶囊 |
//! | `⌄` / `⇥` / `⇤` | 头部两个文字按钮,图标只是**前导**,标签一律可读 |
//! | 键盘 | `Esc` 收栗 / `Tab` 走焦点环 / `Enter` 激活焦点键(订阅在 `app/subscription.rs`) |
use iced::widget::text_editor;
use iced::widget::{column, container, row, rule, scrollable, space, text};
use iced::{Background, Border, Element, Length, Padding, Theme};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, Button};
use crate::app::components::feedback::empty_state::{empty_state, EmptyState};
use crate::app::components::interaction::Interaction;
use crate::pages::frame;
use crate::theme::Ui;
use super::state::{Focus, RailState, Speaker, Turn};
use super::Message;
mod composer;
pub(super) use composer::text_role;
/// 会话视图落在哪种外壳里(只影响头部动作与标题密度,**不影响**对话本体)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Host {
/// 右侧栏(280~360)。
Rail,
/// 独立页(列宽 ≤424)。
Page,
}
/// 会话视图的**输入契约**(见模块文档;全部是借用的纯数据)。
#[derive(Clone, Copy)]
pub struct Conversation<'a> {
/// 会话内容(零条 = 空会话,画引导空态)。
pub transcript: &'a [Turn],
/// 输入框内容(iced 的 `text_editor` 自己持有编辑状态)。
pub draft: &'a text_editor::Content,
/// 是否在流式回复(决定 `Stop` 是否出现、`Send` 是否禁用)。
pub busy: bool,
/// 未读数(>0 时头部画胶囊)。
pub unread: usize,
/// 草稿是否空白(发送键的禁用判据,由 `AgentUi::draft_is_blank` 给)。
pub blank: bool,
/// 三档状态(决定头部画哪组动作)。
pub rail: RailState,
/// 宿主。
pub host: Host,
/// 键盘软焦点(`None` = 用户在用鼠标,**不画焦点环**)。
///
/// 为什么不直接给 `Focus`:焦点环是键盘巡航的可视证据,鼠标操作时还挂着 10% 状态层会让人
/// 以为那是一个 tonal 按钮(r1 出图实测)。`AgentUi::keyboard_mode` 就是这道闸。
pub focus: Option<Focus>,
}
/// 画会话视图(**右栗与独立页的唯一实现**)。
pub fn view<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'a, Message> {
let body = if spec.transcript.is_empty() {
empty(ui, catalog)
} else {
transcript(ui, spec, catalog)
};
column![
header(ui, spec, catalog),
divider(ui),
body,
divider(ui),
composer::composer(ui, spec, catalog),
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.height(Length::Fill)
.into()
}
// ---------------------------------------------------------------- 头部
fn header<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'a, Message> {
let title = row![
text_role(
ui,
TypeRole::TitleLarge,
true,
catalog.text(Key::AgentTitle),
ui.color(Role::OnSurface),
Length::Shrink,
),
unread_badge(ui, spec.unread, catalog),
]
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center);
let titles = column![
title,
text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::AgentSubtitle),
ui.color(Role::OnSurfaceVariant),
Length::Fill,
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
// 独立页的头部与页面同一套(标题在左、动作在右);右栗太窄,动作单独一行,免得挤成两截。
match spec.host {
Host::Rail => column![
titles,
row![actions(ui, spec, catalog)]
.width(Length::Fill)
.spacing(ui.space(Space::X2)),
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.into(),
Host::Page => row![
titles,
row![actions(ui, spec, catalog)]
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center),
]
.width(Length::Fill)
.align_y(iced::Alignment::Center)
.into(),
}
}
/// 头部动作:`⌄` 收起 / `⇥` 拆出(停靠时)或 `⇤` 收回(独立页)。
///
/// 每个按钮都带**文字标签**(图标只做前导):任务要求「不得只有图标」。焦点环用
/// `Interaction::Focused`(组件的 10% 状态层),键序见 [`Focus`]。
fn actions<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'a, Message> {
let mut items: Vec<Element<'a, Message>> = Vec::new();
match spec.rail {
RailState::Collapsed => items.push(label_button(
ui,
catalog.text(Key::AgentOpen),
IconKind::ChevronRight,
Some(Message::Expand),
spec.focus == Some(Focus::Collapse),
)),
RailState::Expanded => {
items.push(label_button(
ui,
catalog.text(Key::AgentCollapse),
IconKind::ChevronDown,
Some(Message::Collapse),
spec.focus == Some(Focus::Collapse),
));
items.push(label_button(
ui,
catalog.text(Key::AgentDetach),
IconKind::Expand,
Some(Message::Detach),
spec.focus == Some(Focus::Detach),
));
}
RailState::Detached => items.push(label_button(
ui,
catalog.text(Key::AgentDock),
IconKind::ChevronLeft,
Some(Message::Dock),
spec.focus == Some(Focus::Detach),
)),
}
row(items)
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center)
.into()
}
fn label_button<'a>(
ui: &'a Ui,
label: &'a str,
icon: IconKind,
on_press: Option<Message>,
focused: bool,
) -> Element<'a, Message> {
let button = match on_press {
Some(message) => Button::new(label, message).leading(icon),
None => Button::disabled(label).leading(icon),
};
buttons::text(ui, focused_state(button, focused))
}
/// 焦点环:键盘走到这一格时叠 10% 的 `Focused` 状态层(组件库的取证状态也是它)。
fn focused_state<'a>(button: Button<'a, Message>, focused: bool) -> Button<'a, Message> {
if focused {
button.state(Interaction::Focused)
} else {
button
}
}
fn unread_badge<'a>(ui: &'a Ui, unread: usize, catalog: Catalog) -> Element<'a, Message> {
if unread == 0 {
return space::horizontal().into();
}
let count = unread.to_string();
let label = catalog.render(Key::AgentUnread, &[("count", count.as_str())]);
let style = ui.style(TypeRole::LabelMedium);
container(
text(label)
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSecondaryContainer)),
)
.padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)]))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::SecondaryContainer))),
border: Border {
radius: ui.radius(ShapeScale::Full, ui.metrics().control_height / 2.0),
..Border::default()
},
..container::Style::default()
})
.into()
}
// ---------------------------------------------------------------- 对话本体
fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
container(empty_state(
ui,
EmptyState::new(
IconKind::Ai,
catalog.text(Key::AgentEmptyTitle),
catalog.text(Key::AgentEmptyBody),
),
))
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn transcript<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'a, Message> {
let mut list = column![].spacing(ui.space(Space::X3)).width(Length::Fill);
for turn in spec.transcript {
list = list.push(bubble(ui, turn, catalog));
}
scrollable(container(list).padding(Padding::from([ui.space(Space::X1), 0.0])))
.width(Length::Fill)
.height(Length::Fill)
.style(frame::scroll_style(ui))
.into()
}
/// 一条消息:角色小标 + 正文。三种说话人三种容器(用户 = `SecondaryContainer`、
/// 助手 = 卡片层、提示 = 无容器只留文字),气泡整体右/左对齐。
fn bubble<'a>(ui: &'a Ui, turn: &'a Turn, catalog: Catalog) -> Element<'a, Message> {
let (role, fill, ink, align) = match turn.speaker {
Speaker::You => (
Key::AgentRoleYou,
Some(ui.color(Role::SecondaryContainer)),
ui.color(Role::OnSecondaryContainer),
iced::Alignment::End,
),
Speaker::Assistant => (
Key::AgentRoleAssistant,
Some(ui.layer(MaterialLayer::Card)),
ui.color(Role::OnSurface),
iced::Alignment::Start,
),
Speaker::Notice => (
Key::AgentRoleNotice,
None,
ui.color(Role::OnSurfaceVariant),
iced::Alignment::Start,
),
};
let inner = column![
text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(role),
ui.color(Role::OnSurfaceVariant),
Length::Fill,
),
text_role(
ui,
TypeRole::BodyMedium,
false,
turn.text.as_str(),
ink,
Length::Fill,
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
let card = container(inner)
.width(Length::Fill)
.padding(Padding::from([ui.space(Space::X2), ui.space(Space::X3)]))
.style(move |_theme| container::Style {
background: fill.map(Background::Color),
border: if fill.is_some() {
ui.outlined(ShapeScale::Medium)
} else {
Border::default()
},
..container::Style::default()
});
container(card).width(Length::Fill).align_x(align).into()
}
/// 一像素分隔线(`pages::frame::divider` 绑在 `pages::Message` 上,这里要自己的消息类型)。
fn divider<'a>(ui: &'a Ui) -> Element<'a, Message> {
rule::horizontal::<Theme>(ui.metrics().divider_thickness)
.style(move |_theme| rule::Style {
color: ui.color(Role::OutlineVariant),
radius: ui.radius_dp(ShapeScale::None),
fill_mode: rule::FillMode::Full,
snap: true,
})
.into()
}
@@ -0,0 +1,142 @@
//! 输入区(多行输入框 + 发送/中断):会话视图的**下半截**。
//!
//! 从 `conversation.rs` 拆出来的理由只有一条:那边贴着 400 行硬上限(AGENTS.md §3-1)。
//! 拆的位置是「输入区」这一整块 + 一个排版小工具,语义边界与文件边界一致。
//!
//! ## Enter / Shift+Enter 的分流(任务第 4 条)
//!
//! `text_editor` 的默认绑定把两种 Enter 都折成 `Binding::Enter`,所以必须自己接管
//! (见 [`composer`] 里的 `key_binding`):`modifiers.shift()` 决定「换行」还是「发送」。
//! **只在输入框聚焦时生效**——`KeyPress.status` 不是 `Focused` 时原样交回默认绑定,
//! 于是别处的 Enter 不会被助手栏抢走。
use iced::widget::text_editor::{self, Binding};
use iced::widget::{column, row, text, text_editor as editor};
use iced::{keyboard, Background, Border, Element, Length, Pixels, Theme};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, Button};
use crate::theme::{Ui, STROKE_PX};
use super::super::state::Focus;
use super::super::Message;
use super::{focused_state, Conversation};
pub(super) fn composer<'a>(
ui: &'a Ui,
spec: Conversation<'a>,
catalog: Catalog,
) -> Element<'a, Message> {
let style = ui.style(TypeRole::BodyMedium);
let field = editor(spec.draft)
.placeholder(catalog.text(Key::AgentPlaceholder))
.on_action(Message::Edit)
.key_binding(|press| {
// Enter = 发送 / Shift+Enter = 换行(任务第 4 条)。
//
// 走 `key_binding` 而不是全局键盘订阅:`KeyPress` 直接带 `modifiers`,而 iced 的
// 默认绑定把两种 Enter 都折成 `Binding::Enter`(所以**必须**在这里分流)。
// 未聚焦时一律交回默认绑定(它自己不响应),于是别处的 Enter 不会被助手栏抢走。
let enter = matches!(
press.modified_key.as_ref(),
keyboard::Key::Named(keyboard::key::Named::Enter)
);
if !enter || !matches!(press.status, text_editor::Status::Focused { .. }) {
return Binding::from_key_press(press);
}
if press.modifiers.shift() {
Some(Binding::Enter)
} else {
Some(Binding::Custom(Message::Submit))
}
})
.size(style.size_px)
.font(ui.font_of(style))
.height(Length::Fixed(
ui.space(Space::X8) + ui.space(Space::X8) + ui.space(Space::X1),
))
.padding(ui.space(Space::X2))
.style(move |_theme: &Theme, status| {
let focused = matches!(status, text_editor::Status::Focused { .. });
text_editor::Style {
background: Background::Color(ui.layer(MaterialLayer::Card)),
border: if focused {
Border {
color: ui.color(Role::Primary),
width: STROKE_PX * 2.0,
radius: ui.radius_dp(ShapeScale::Small),
}
} else {
ui.outlined(ShapeScale::Small)
},
placeholder: ui.color(Role::OnSurfaceVariant),
value: ui.color(Role::OnSurface),
selection: ui.color(Role::PrimaryContainer),
}
});
let send_label = catalog.text(Key::AgentSend);
let send = if spec.blank || spec.busy {
Button::disabled(send_label)
} else {
Button::new(send_label, Message::Submit)
};
// 键盘焦点在「发送」上时用 `Interaction::Focused`(10% 状态层),与头部动作同一套焦点语言。
let send = focused_state(
send.leading(IconKind::Send),
spec.focus == Some(Focus::Send),
);
let mut actions = row![]
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center);
if spec.busy {
actions = actions.push(buttons::outlined(
ui,
focused_state(
Button::new(catalog.text(Key::AgentStop), Message::Stop).leading(IconKind::Close),
spec.focus == Some(Focus::Stop),
),
));
}
actions = actions.push(buttons::filled(ui, send));
column![field, actions]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.into()
}
/// 一档文字(字号/行高/字重来自令牌;`shell::label` 同口径,但这里要自己持消息类型)。
pub(crate) fn text_role<'a>(
ui: &'a Ui,
role: TypeRole,
emphasized: bool,
content: &'a str,
color: iced::Color,
width: Length,
) -> Element<'a, Message> {
let style = if emphasized {
ui.emphasized(role)
} else {
ui.style(role)
};
text(content)
.size(style.size_px)
.line_height(iced::widget::text::LineHeight::Absolute(Pixels(
style.line_height_px,
)))
.font(ui.font_of(style))
.color(color)
.width(width)
.into()
}
+113
View File
@@ -0,0 +1,113 @@
//! 占位样本数据:一段内置会话 + 出图矩阵要用的 `ULL_AGENT_*` 取证开关。
//!
//! ## 为什么是内置样本而不是 presenter
//!
//! 本 mission 只做**壳**:真数据由 M103(`presenter::agent::AgentRail` + 四个签名)供,
//! 这里一行 presenter 调用都没有(任务里点名「不许提前调用」)。样本的职责只有两个:
//! ①让三档状态在图上都有内容;②让「空会话」有对照([`FIXTURE_ENV`])。
//!
//! 样本正文是**演示数据**,与 `pages/instances/data.rs` 的 demo 同一性质:它不是界面文案,
//! 不进 i18n 词表(界面上会随真模型一起消失)。界面自己的文案(标题/按钮/空态/提示)全部走 `Key`。
//!
//! ## 取证开关(只影响这一次进程,语义与 `ULL_APPEARANCE_*` / `ULL_START_ROUTE` 一致)
//!
//! | 变量 | 取值 | 作用 |
//! |---|---|---|
//! | [`RAIL_ENV`] | `collapsed` / `expanded` / `detached` | 启动时直接落到某一档(三态矩阵) |
//! | [`FIXTURE_ENV`] | `empty` / `streaming` / `unread` / `focused` | 空会话(零消息)/ 流式中(中断键可见)/ 展开但带未读(单独看徽标)/ 键盘焦点环(`Tab` 走过一次的样子) |
//! | [`WIDTH_ENV`] | `280` / `320` / `360` | 展开档宽度(磁铁档位的对照图) |
use super::metrics::PRESETS;
use super::state::{AgentUi, RailState, Speaker, Turn};
/// 启动档位。
pub const RAIL_ENV: &str = "ULL_AGENT_RAIL";
/// 会话样本(`empty` / `streaming`)。
pub const FIXTURE_ENV: &str = "ULL_AGENT_FIXTURE";
/// 展开档宽度(只接受三档预设值)。
pub const WIDTH_ENV: &str = "ULL_AGENT_WIDTH";
/// 内置会话样本(4 条,两类说话人 + 一条工具提示)。
pub fn transcript() -> Vec<Turn> {
vec![
Turn::new(Speaker::You, "Which instance should I start for a 1.20.4 test?"),
Turn::new(
Speaker::Assistant,
"Survival is on 1.20.4 with Fabric 0.16.5 and its files are present. Redstone Lab is the same \
version but its folder is missing a jar.",
),
Turn::new(Speaker::You, "Use Survival then. Anything I should know first?"),
Turn::new(
Speaker::Assistant,
"Java 21 is already adopted for that instance, and the last launch exited normally. \
Say the word and I will queue it.",
),
]
}
/// 按环境变量构造一份状态(没设变量 = `None`,调用方用出厂态)。
///
/// 解析失败一律**忽略**(取证开关不该让进程起不来),并在 stderr 留一行,方便对照日志读图。
pub fn from_env() -> Option<AgentUi> {
let rail = std::env::var(RAIL_ENV).ok();
let fixture = std::env::var(FIXTURE_ENV).ok();
let width = std::env::var(WIDTH_ENV).ok();
if rail.is_none() && fixture.is_none() && width.is_none() {
return None;
}
let fixture = fixture.as_deref().map(str::trim);
let mut ui = match fixture {
Some("empty") => AgentUi::empty(),
_ => AgentUi::new(),
};
if let Some(raw) = width.as_deref().map(str::trim) {
match raw.parse::<f32>() {
Ok(value) if PRESETS.contains(&value) => ui.set_expanded(value),
_ => eprintln!("[agent] {WIDTH_ENV}={raw:?} 不是预设档位 {PRESETS:?} 之一,忽略"),
}
}
if fixture == Some("streaming") {
ui.pretend_streaming();
}
// 档位走**真迁移**(`expand` / `detach` / `collapse`)而不是直接写字段:这样未读的语义
// (展开即读)在取证图里也成立,不会出现「展开着还挂着 2 条未读」这种自相矛盾的画面。
// 唯一要单独看徽标的情形用 `ULL_AGENT_FIXTURE=unread` 显式造(见模块文档的表)。
match rail.as_deref().map(str::trim) {
None => ui.expand(),
Some(name) => match RailState::from_name(name) {
Some(RailState::Collapsed) => ui.collapse(),
Some(RailState::Detached) => ui.detach(),
Some(RailState::Expanded) => ui.expand(),
None => eprintln!(
"[agent] {RAIL_ENV}={name:?} 不是三档之一({}),忽略",
RailState::ALL.map(RailState::name).join("/")
),
},
}
if fixture == Some("unread") {
ui.unread = 3;
}
if fixture == Some("focused") {
// 键盘巡航一次的样子(焦点环 = 10% 状态层)。真窗口里按一次 `Tab` 就会到这一态,
// 但截图通道收不到键盘输入,所以只能这样取证。
ui.step_focus(false);
}
eprintln!(
"[agent] rail={} width={} turns={} busy={} unread={}",
ui.rail.name(),
ui.expanded(),
ui.transcript.len(),
ui.busy,
ui.unread
);
Some(ui)
}
+176
View File
@@ -0,0 +1,176 @@
//! 右侧助手栏的**布局数学**:三档宽度、磁铁吸附、中栗下限。纯函数 + 常量,不碰 iced 控件树。
//!
//! ## 一屏的横向预算
//!
//! ```text
//! 窗口宽 = 左轨 88 + [中栗] + [拖拽缝 8(仅展开档)] + [右栗]
//! ```
//!
//! - 右栗宽度 = [`rail_width`]:折叠 48 / 展开 280~360(吸附值)/ 拆出 0;
//! - 中栗宽度 = 窗口 − 右栗 − 拖拽缝([`content_width`],页面层再扣左轨与页边距);
//! - **中栗下限** [`min_content_width`] = 行宽上限 424 + 两侧页边距 48 = **472**:
//! 再窄正文列就装不下一行满宽文,所以拖拽被夹在这里(不是「拖到 0 再说」)。
//!
//! 「展开时中栗**收缩而不是溢出**」这句话的落点就是 [`content_width`]:页面布局系统仍以
//! 「一个窗口宽」为输入(它内部扣 rail 与页边距),所以右栗占掉的那一段必须在这里先减掉。
//! 四个对照宽度的实际数值由 `metrics/tests.rs` 的表钉住(报告里贴的是同一张表)。
//!
//! ## 三档预设的取值理由(tower 要求写明,不许默默定)
//!
//! | 档位 | 值 | 依据 |
//! |---|---|---|
//! | 折叠 | **48** | `LayoutMetrics::touch_target`:M3 无障碍下限,图标按钮 40 + 未读点要放得下 |
//! | 展开(锚) | **320** | 令牌 `ai_panel_width`:M69 起这个令牌就叫「AI 面板宽度」,改锚等于改令牌语义 |
//! | 展开 ±1 档 | **280 / 360** | 在 4px 栅格上、离锚正好 40(`Space::X8 + Space::X2`);280 = 官方 supporting-pane 侧列下限 240 + 两份 20 内边距,仍能排 40 字符/行 |
//!
//! **不取 420**:900 宽窗上右栗上限只有 340([`max_rail_width`]),420 这个预设永远点不到 = 假档位。
//! **不取 240**:减去两侧 16 内边距只剩 208,一行不到 35 字符,会话气泡会碎成柱状。
use unlucky_design::spacing::{snap_to_grid, LayoutMetrics, Space};
use crate::pages::layout;
/// 折叠态图标条宽 = M3 触控目标 **48**(`LayoutMetrics::touch_target`,不是新字面量)。
pub const COLLAPSED_WIDTH: f32 = LayoutMetrics::DESKTOP.touch_target;
/// 展开态面板宽 = 令牌 `ai_panel_width` = **320**。
pub const DEFAULT_EXPANDED_WIDTH: f32 = LayoutMetrics::DESKTOP.ai_panel_width;
/// 磁铁档位间距 = 一个页级留白(32)+ 一个小留白(8)= **40**。
pub const PRESET_STEP: f32 = Space::X8.dp() + Space::X2.dp();
/// 三个预设档位:**280 / 320 / 360**(理由见模块文档)。
pub const PRESETS: [f32; 3] = [
DEFAULT_EXPANDED_WIDTH - PRESET_STEP,
DEFAULT_EXPANDED_WIDTH,
DEFAULT_EXPANDED_WIDTH + PRESET_STEP,
];
/// 磁铁半径(`Space::X6` = 24):离预设档位 24px 以内就吸过去,否则按 4px 栅格落地。
///
/// 24 > 档位间距的一半(20),所以 280~360 之间**任何**位置都会被吸到某一档;360 以上到上限
/// 之间不再有档位,那段才走 4px 栅格(用户想要 500 宽的面板也给)。
pub const MAGNET_RADIUS: f32 = Space::X6.dp();
/// 面板与中栗之间的可拖拽缝隙宽(`Space::X2` = 8)。
///
/// 它既是分隔线(1px `OutlineVariant` 居中)也是命中区:不是「看起来细就抓不到」。
pub const RESIZE_GUTTER: f32 = Space::X2.dp();
/// 中栗的最小可用宽度 = 行宽上限 424 + 两侧页边距 24×2 = **472**。
///
/// 依据是布局系统自己的判据([`layout::measure_cap_px`]):1180 展开 320 后中栗 **852**,
/// 880(进程最小窗宽)展开 320 后中栗 **552** —— 都还在 472 之上。
pub fn min_content_width() -> f32 {
layout::measure_cap_px() + 2.0 * Space::X6.dp()
}
/// 右栗能占的最大宽度:中栗不许低于 [`min_content_width`](拖拽缝也算右栗的开销)。
///
/// 下限取 [`COLLAPSED_WIDTH`](窗口再窄也不把右栗夹成负数)。
pub fn max_rail_width(window_width: f32) -> f32 {
(window_width - layout::rail_width() - min_content_width() - RESIZE_GUTTER).max(COLLAPSED_WIDTH)
}
/// 展开宽度的合法化:夹到可用范围。低于 280 就吸到 280(不是变细)。
///
/// 窗口窄到连 280 都不成立时返回 [`COLLAPSED_WIDTH`]——那时「展开」这件事本身不成立,
/// 画一条 280 的栏会把中栗压到 472 以下。
pub fn clamp_width(window_width: f32, requested: f32) -> f32 {
let ceiling = max_rail_width(window_width);
if ceiling < PRESETS[0] {
return COLLAPSED_WIDTH;
}
requested.clamp(PRESETS[0], ceiling)
}
/// 拖拽落点 → 吸附后的右栗宽度(磁铁 → 4px 栅格 → 夹紧)。
pub fn magnet_width(window_width: f32, raw: f32) -> f32 {
let nearest = PRESETS
.iter()
.copied()
.min_by(|left, right| (left - raw).abs().total_cmp(&(right - raw).abs()))
.unwrap_or(DEFAULT_EXPANDED_WIDTH);
let settled = if (nearest - raw).abs() <= MAGNET_RADIUS {
nearest
} else {
snap_to_grid(raw)
};
clamp_width(window_width, settled)
}
/// 窗口窄到连最小展开档都放不下时,`Expanded` **事实上**就是折叠档。
///
/// 不是「自动帮用户收起」的一时兴起:`clamp_width` 在这种情况下返回的就是 [`COLLAPSED_WIDTH`],
/// 若仍报 `Expanded`,宿主会画一条 48 宽的面板、还白扣 8px 拖拽缝。
pub fn effective_state(
window_width: f32,
state: crate::shell::agent::RailState,
expanded: f32,
) -> crate::shell::agent::RailState {
use crate::shell::agent::RailState;
match state {
RailState::Expanded if clamp_width(window_width, expanded) <= COLLAPSED_WIDTH => {
RailState::Collapsed
}
other => other,
}
}
/// 一屏的横向预算(窗口 → 左轨 / 中栗 / 右栗)。纯数据,供单测与日志用。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RailLayout {
/// 窗口宽度(输入)。
pub window: f32,
/// 左侧导航轨宽度(令牌 `nav_rail_width` = 88)。
pub left: f32,
/// 右栗面板宽度(0 / 48 / 280~360)。
pub rail: f32,
/// 右栗前面的拖拽缝宽度(只有展开档有,其余 0)。
pub gutter: f32,
/// 中栗宽度 = 窗口 − 右栗 − 拖拽缝(页面层再扣左轨与页边距)。
pub content: f32,
}
/// 算一屏的横向预算(档位先经 [`effective_state`] 归一)。
pub fn layout_for(
window_width: f32,
state: crate::shell::agent::RailState,
expanded: f32,
) -> RailLayout {
use crate::shell::agent::RailState;
let (rail, gutter) = match effective_state(window_width, state, expanded) {
RailState::Collapsed => (COLLAPSED_WIDTH, 0.0),
RailState::Expanded => (clamp_width(window_width, expanded), RESIZE_GUTTER),
RailState::Detached => (0.0, 0.0),
};
RailLayout {
window: window_width,
left: layout::rail_width(),
rail,
gutter,
content: (window_width - rail - gutter).max(0.0),
}
}
/// 右栗面板宽度(`App::view` 与 shell 都用这一个入口,避免两处各算一套)。
pub fn rail_width(window_width: f32, state: crate::shell::agent::RailState, expanded: f32) -> f32 {
layout_for(window_width, state, expanded).rail
}
/// **交给页面布局系统的宽度** = 窗口 − 右栗 − 拖拽缝。
pub fn content_width(
window_width: f32,
state: crate::shell::agent::RailState,
expanded: f32,
) -> f32 {
layout_for(window_width, state, expanded).content
}
#[cfg(test)]
#[path = "metrics/tests.rs"]
mod tests;
@@ -0,0 +1,178 @@
//! 布局数学的数值判据(M104 任务第 2 条那张表就长在这里)。
//!
//! 每条断言都写清「抓得住什么 / 抓不住什么」——纯函数能钉住算式,**钉不住**像素与手感。
use crate::pages::layout::{self, PaneKit};
use crate::shell::agent::RailState;
use super::{
clamp_width, content_width, effective_state, layout_for, magnet_width, max_rail_width,
min_content_width, COLLAPSED_WIDTH, DEFAULT_EXPANDED_WIDTH, MAGNET_RADIUS, PRESETS,
RESIZE_GUTTER,
};
/// **布局数学的核心表**(1180 / 1024 / 900 / 700,任务第 2 条点名要的四个宽度)。
///
/// 抓得住:右栗占宽之后中栗是**收缩**的(`content = window − rail − gutter`),以及页面布局系统
/// 拿到的列宽确实跟着变(1180 展开时实例页详情列 424 → 356)。
/// 抓不住:真实的像素宽度(那要量图),也抓不住「右栗和外框之间有没有缝」这种观感问题。
#[test]
fn the_column_table_matches_the_mission_widths() {
// (窗口, 档位, 右栗面板, 拖拽缝, 中栗, 实例详情列, 单列页列宽)
let expect = [
(1180.0, RailState::Detached, 0.0, 0.0, 1180.0, 424.0, 424.0),
(
1180.0,
RailState::Collapsed,
48.0,
0.0,
1132.0,
424.0,
424.0,
),
(1180.0, RailState::Expanded, 320.0, 8.0, 852.0, 356.0, 424.0),
(1024.0, RailState::Detached, 0.0, 0.0, 1024.0, 424.0, 424.0),
(1024.0, RailState::Collapsed, 48.0, 0.0, 976.0, 424.0, 424.0),
// 1024 展开:中栗 696 掉到 medium 档 ⇒ 实例页退化成单列(不再两列硬挤)
(1024.0, RailState::Expanded, 320.0, 8.0, 696.0, 424.0, 424.0),
// 900 收起:中栗 852 仍够 list-detail(几何门槛 816),详情列 356
(900.0, RailState::Collapsed, 48.0, 0.0, 852.0, 356.0, 424.0),
(900.0, RailState::Expanded, 320.0, 8.0, 572.0, 424.0, 424.0),
// 700 < 进程最小窗宽 880:展开档不成立(中栗会被压到 472 以下),退回收起档
(700.0, RailState::Expanded, 48.0, 0.0, 652.0, 424.0, 424.0),
];
for (window, rail, width, gutter, content, detail, single) in expect {
let laid_out = layout_for(window, rail, DEFAULT_EXPANDED_WIDTH);
assert_eq!(
(laid_out.rail, laid_out.gutter, laid_out.content),
(width, gutter, content),
"{window} / {} 的横向预算不对",
rail.name()
);
assert_eq!(laid_out.left, layout::rail_width(), "左轨宽度只有一个真源");
// 页面布局系统吃的就是这个 content 宽:实例页(list-detail)与单列页各算一次
let instances = layout::frame_metrics(PaneKit::ListDetail, content);
assert_eq!(
instances.column,
detail,
"{window} / {} 的实例详情列宽不对",
rail.name()
);
let page = layout::frame_metrics(PaneKit::Single, content);
assert_eq!(
page.column,
single,
"{window} / {} 的单列页列宽不对",
rail.name()
);
}
}
/// **不溢出**的硬判据:窗口 ≥ 最小窗宽时,任何档位、任何预设,中栗都不低于 472。
///
/// 抓得住:有人把 [`min_content_width`] 调小、或让 [`content_width`] 忘了扣右栗/拖拽缝。
/// 抓不住:`row!` 的兄弟顺序写错(那要出图看)。
#[test]
fn the_middle_column_never_drops_below_the_usable_width() {
assert_eq!(
min_content_width(),
472.0,
"472 = 行宽上限 424 + 两侧页边距 48"
);
for window in [880.0, 900.0, 1024.0, 1180.0, 1440.0, 2560.0] {
for rail in RailState::ALL {
for preset in PRESETS {
let width = content_width(window, rail, preset);
assert!(
width >= 472.0,
"{window} / {} / 预设 {preset} 把中栗压到 {width}",
rail.name()
);
}
}
}
// 边界:最小窗宽 880 上,展开档被夹到上限 312(880 − 88 − 472 − 8),中栗留 560
assert_eq!(max_rail_width(880.0), 312.0);
assert_eq!(content_width(880.0, RailState::Expanded, 320.0), 560.0);
}
/// 拖拽:磁铁吸附 + 夹紧。
///
/// 抓得住:①拖动结果落在预设档或 4px 栅格上(不会一像素一像素抖);②离预设 24px 内被吸住;
/// ③往左拖过头会被夹在 [`max_rail_width`],而不是把中栗压扁。
/// 抓不住:真实指针手感(`mouse_area::on_move` 在真窗口里的采样率只能人试)。
#[test]
fn the_drag_magnetises_and_refuses_to_squeeze_the_middle() {
// 1180 窗口:上限 = 1180 − 88(左轨)− 472(中栗下限)− 8(拖拽缝)= 612
assert_eq!(max_rail_width(1180.0), 612.0);
// 离 320 只有 6px ⇒ 吸到 320
assert_eq!(magnet_width(1180.0, 326.0), 320.0);
// 两档之间(310):离 320 只有 10px ⇒ 还是吸住
assert_eq!(magnet_width(1180.0, 310.0), 320.0);
// 360 之上再没有档位 ⇒ 走 4px 栅格
assert_eq!(magnet_width(1180.0, 500.0), 500.0);
assert_eq!(magnet_width(1180.0, 497.0), 496.0);
// 低于最小档 ⇒ 吸到 280(不是变细)
assert_eq!(magnet_width(1180.0, 120.0), 280.0);
// 超过上限 ⇒ 夹在 612(中栗正好 472)
assert_eq!(magnet_width(1180.0, 900.0), 612.0);
assert_eq!(
content_width(1180.0, RailState::Expanded, 612.0),
1180.0 - 612.0 - 8.0
);
// 900 窗口:上限 = 900 − 88 − 472 − 8 = 332 ⇒ 360 档被夹到 332
assert_eq!(max_rail_width(900.0), 332.0);
assert_eq!(magnet_width(900.0, 360.0), 332.0);
// 窄到连 280 都放不下(700):展开档不成立,返回折叠宽
assert_eq!(clamp_width(700.0, 320.0), COLLAPSED_WIDTH);
assert_eq!(
effective_state(700.0, RailState::Expanded, 320.0),
RailState::Collapsed
);
assert_eq!(
effective_state(1180.0, RailState::Expanded, 320.0),
RailState::Expanded
);
}
/// 三段常量必须互相自洽(改一个忘一个,图上就是「面板 320 但内容按 280 排」)。
#[test]
fn the_rail_constants_stay_consistent() {
assert_eq!(COLLAPSED_WIDTH, 48.0, "折叠条 = M3 触控目标");
assert_eq!(
DEFAULT_EXPANDED_WIDTH, 320.0,
"展开档 = 令牌 ai_panel_width"
);
assert_eq!(PRESETS, [280.0, 320.0, 360.0]);
assert_eq!(RESIZE_GUTTER, 8.0);
assert_eq!(MAGNET_RADIUS, 24.0);
assert!(
PRESETS.contains(&DEFAULT_EXPANDED_WIDTH),
"锚档必须在预设里"
);
// 下面两条用**变量**而不是常量表达式:clippy 的 `assertions_on_constants` 会把
// 「拿常量断言常量」判成无用断言,而这里断的正是两个常量之间的关系。
let shortest_rail = COLLAPSED_WIDTH;
let narrowest_panel = PRESETS[0];
assert!(shortest_rail < narrowest_panel, "折叠比最小展开还窄");
// 磁铁半径 > 档位间距的一半 ⇒ 280~360 之间没有「吸不住」的空档
let magnet = MAGNET_RADIUS;
let half_step = (PRESETS[1] - PRESETS[0]) / 2.0;
assert!(magnet > half_step);
// 三档名字是取证开关的取值,改名等于改命令行契约
for rail in RailState::ALL {
assert_eq!(RailState::from_name(rail.name()), Some(rail));
}
assert_eq!(RailState::from_name("open"), None);
}
+347
View File
@@ -0,0 +1,347 @@
//! # AI 助手(右栗三档 + `Route::Agent` 独立页)
//!
//! 用户定案(D16):「右侧常驻可折叠,允许拆分成独立页」+「中栗宽度可调(磁铁吸附)」。
//! 本模块交付的是**壳**:三档状态、布局数学、两个宿主共用的一份会话视图。真数据来自
//! M103(`presenter::agent::AgentRail`,四个签名见任务上下文)——本 mission **一行都不调**,
//! 会话内容用的是内置样本([`demo`])。
//!
//! ## 文件与职责
//!
//! | 文件 | 职责 |
//! |---|---|
//! | [`state`] | 三档状态机 + **布局数学**(纯函数,可脱机单测)+ [`AgentUi`](挂在 `App` 上的状态) |
//! | `conversation` | **会话视图组件**:右栗与独立页唯一的对话 UI(输入契约见该文件) |
//! | [`demo`] | 占位样本会话 + `ULL_AGENT_*` 取证开关 |
//! | 本文件 | 两个**宿主**([`docked`] 右栗 / [`page`] 独立页)+ 消息枚举 + 拖拽层 |
//! | `tests.rs` / `metrics/tests.rs` | 三档迁移 / 单实现判据 / a11y 文案;数值表与磁铁在后者 |
//!
//! ## 为什么物理路径在 `pages/agent/`、逻辑路径却是 `crate::shell::agent`
//!
//! 本 mission 的 scope 给了 `pages/agent/**`,但**没给** `pages/mod.rs`(那是一个四条页面线共用的
//! 声明文件)。于是 `shell/mod.rs` 用 `#[path = "../pages/agent/mod.rs"]` 挂载它——与
//! `app/mod.rs` 挂 `../components/mod.rs` 是同一个先例(理由也一样:别动共用声明文件)。
//!
//! **没有第二份实现**:物理目录只有 `crates/app-iced/src/pages/agent/`,`crate::shell::agent`
//! 只是它的逻辑名字。搬回 `pages::agent` 只需在 `pages/mod.rs` 加一行 `mod agent;`,
//! 那一步不属于本 mission(报告里登记)。
//!
//! ## 三档状态([`RailState`])
//!
//! | 档位 | 右栗 | 会话在哪 | 切法 |
//! |---|---|---|---|
//! | `Collapsed` | 48 图标条 + 未读点 | 不画 | 点条上的 AI 图标 / `Esc` |
//! | `Expanded` | 280~360(默认 320)+ 8 拖拽缝 | 面板内 | 条上图标 / `⌄` 收起 / 拆出后收回 |
//! | `Detached` | 0 | `Route::Agent` 独立页 | `⇥` 拆出 / 导航第五项 `Assistant` |
//!
//! ## 为什么消息不走 `pages::Message`
//!
//! 助手栏是**外壳级视图**(挂在任何页面右边),不是某一页的消息:塞进 `pages::Message` 要动
//! `app/update/page_messages.rs` 的穷尽 match(那个文件正被未合的 M101 改),语义也不对。
//! 所以是一等公民 [`Message`] + `app::Message::Agent(..)` 一次映射,落点 `app/update/agent.rs`。
pub(crate) mod demo;
mod metrics;
mod state;
mod conversation;
#[cfg(test)]
mod tests;
pub use state::{AgentUi, Focus, RailState};
use iced::widget::{column, container, mouse_area, row, rule, space, stack, text, tooltip};
use iced::{mouse, Alignment, Background, Border, Element, Length, Padding, Theme as IcedTheme};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use crate::app::components::buttons::{self, IconVariant};
use crate::pages::{self, layout};
use crate::theme::Ui;
use conversation::{Conversation, Host};
use metrics::{COLLAPSED_WIDTH, RESIZE_GUTTER};
/// 界面语言(与产品其它页面同一口径:i18n 接线前是英文基线,D28 的「英文环境零中文负担」)。
const LANG: Lang = Lang::En;
/// 助手栏消息(**唯一**的消息类型;`app/view.rs` 用 `Message::Agent(..)` 包一层)。
#[derive(Debug, Clone)]
pub enum Message {
/// 折叠条 → 展开。
Expand,
/// `⌄` 收起 / 独立页上 `Esc`。
Collapse,
/// `⇥` 拆成独立页(同时压一层 `Route::Agent`)。
Detach,
/// `⇤` 收回右栗(离开 `Route::Agent`)。
Dock,
/// 输入框的编辑动作(由 `text_editor::on_action` 来)。
Edit(iced::widget::text_editor::Action),
/// 发送(`Send` 键 / 输入框里按 Enter)。
Submit,
/// 中断流式回复。
Stop,
/// 按下拖拽缝。
DragStart,
/// 拖拽中:窗口坐标里的指针 x。
DragTo(f32),
/// 松手。
DragEnd,
/// 键盘:`Esc`(收栗 / 独立页收回)。
Escape,
/// 键盘:`Tab`(走焦点环;`shift` = 反向)。
Tab { shift: bool },
/// 键盘:`Enter`(激活软焦点上的按钮)。
Activate,
/// 原始键盘事件(`app/subscription.rs` 的 `keyboard::listen()` 搬过来的)。
///
/// 订阅层只负责搬运,**解释权**在 `app/update/agent.rs` 的 `on_agent_key`:
/// `Esc` / `Tab` / `Enter` 各自在什么状态下做什么,集中在一个函数里一眼看完。
Key(iced::keyboard::Event),
}
/// 停靠档的右栗(`None` = 已拆成独立页,右栗不占地)。
///
/// 两档共用同一条 `Element` 通道交给外壳的 `row!`:折叠是 48 的条,展开是「8 拖拽缝 + 面板」。
pub fn docked<'a>(
ui: &'a Ui,
agent: &'a AgentUi,
window_width: f32,
) -> Option<Element<'a, Message>> {
// 判档用**这一窗宽下实际生效**的档位:窗窄到放不下 280 时,`Expanded` 事实上就是折叠条
// (否则会画一条 48 宽、还白扣 8px 拖拽缝的「面板」)。
match agent.effective_rail(window_width) {
RailState::Detached => None,
RailState::Collapsed => Some(strip(ui, agent)),
RailState::Expanded => Some(panel(ui, agent, window_width)),
}
}
/// `Route::Agent` 独立页(与右栗**同一个** `conversation::view`)。
///
/// 页级骨架(页边距 + 正文列宽)与其它页面同源:列宽取 [`layout::frame_metrics`],单列 ≤424。
pub fn page<'a>(ui: &'a Ui, agent: &'a AgentUi, window_width: f32) -> Element<'a, Message> {
let metrics = layout::frame_metrics(pages::PaneKit::Single, window_width);
let body = conversation::view(ui, spec(agent, Host::Page), Catalog::of(LANG));
container(
container(body)
.width(Length::Fixed(metrics.column))
.height(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fill)
.padding(metrics.gutter)
.align_x(Alignment::Center)
.align_y(Alignment::Start)
.into()
}
/// 拖拽层:拖拽期间铺满窗口,把指针 x 变成 [`Message::DragTo`]。
///
/// 为什么铺满整窗:`mouse_area::on_move` 只在**它自己的边界内**回报坐标,而拖拽时指针一定会离开
/// 那 8px 的缝隙。铺满整窗后 `Point.x` 就是窗口坐标 ⇒ `目标宽 = 窗口宽 − x`。
/// 松手由同一层的 `on_release` 收尾(否则会一直跟着指针走)。
pub fn drag_overlay<'a>(agent: &AgentUi) -> Option<Element<'a, Message>> {
if !agent.is_dragging() {
return None;
}
Some(
mouse_area(
container(space::horizontal())
.width(Length::Fill)
.height(Length::Fill),
)
.interaction(mouse::Interaction::ResizingHorizontally)
.on_move(|point| Message::DragTo(point.x))
.on_release(Message::DragEnd)
.into(),
)
}
// ---------------------------------------------------------------- 折叠档:48 图标条
fn strip<'a>(ui: &'a Ui, agent: &'a AgentUi) -> Element<'a, Message> {
let catalog = Catalog::of(LANG);
let side = ui.metrics().control_height;
// 图标按钮 40×40(M3 icon button),未读点叠在右上角;下面两行小字保证「不得只有图标」:
// 条上每个可点元素都有可读文字(完整名字在 tooltip 里)。
let icon: Element<'a, Message> = buttons::icon(
ui,
IconVariant::Standard,
IconKind::Ai,
Some(Message::Expand),
None,
);
let glyph: Element<'a, Message> = if agent.unread > 0 {
stack![
icon,
container(dot(ui))
.width(Length::Fixed(side))
.height(Length::Fixed(side))
.align_x(Alignment::End)
.align_y(Alignment::Start)
.padding(Padding::from([0.0, ui.space(Space::X1)])),
]
.into()
} else {
icon
};
let labelled = container(column![
glyph,
conversation::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::AgentStripLabel),
ui.color(Role::OnSurfaceVariant),
// `Shrink` 而不是 `Fill`:48 宽的条上 Fill 会让文字贴左缘,"2 unread" 会被窗角裁掉
Length::Shrink,
),
unread_text(ui, agent.unread, catalog),
])
.width(Length::Fixed(COLLAPSED_WIDTH))
.align_x(Alignment::Center)
.padding(Padding::from([ui.space(Space::X6), 0.0]));
let tip_style = ui.style(TypeRole::BodySmall);
let tip = container(
text(catalog.text(Key::AgentOpen))
.size(tip_style.size_px)
.font(ui.font_of(tip_style))
.color(ui.color(Role::OnSurface)),
)
.padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)]))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: Border {
radius: ui.radius_dp(ShapeScale::Small),
..Border::default()
},
..container::Style::default()
});
container(tooltip(labelled, tip, tooltip::Position::Left))
.width(Length::Fixed(COLLAPSED_WIDTH))
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
..container::Style::default()
})
.into()
}
/// 未读点(8×8,`Primary`)。
fn dot<'a>(ui: &'a Ui) -> Element<'a, Message> {
let side = ui.space(Space::X2);
container(space::horizontal())
.width(Length::Fixed(side))
.height(Length::Fixed(side))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::Primary))),
border: Border {
radius: ui.radius(ShapeScale::Full, side),
..Border::default()
},
..container::Style::default()
})
.into()
}
/// 未读的**可读形式**:条上只放计数(`2`),完整那句(`2 unread`)在面板头部的胶囊里。
///
/// 为什么不是「2 unread」:48px 的条装不下那 8 个字符(实测会贴到窗角被裁),条上本来只需
/// 「有几条」。零未读时给一个等高留白,免得条上元素上下跳。
fn unread_text<'a>(ui: &'a Ui, unread: usize, _catalog: Catalog) -> Element<'a, Message> {
let style = ui.style(TypeRole::LabelMedium);
if unread == 0 {
return space::vertical().height(style.line_height_px).into();
}
text(unread.to_string())
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::Primary))
.width(Length::Shrink)
.into()
}
// ---------------------------------------------------------------- 展开档:拖拽缝 + 面板
fn panel<'a>(ui: &'a Ui, agent: &'a AgentUi, window_width: f32) -> Element<'a, Message> {
let width = agent.width(window_width);
let body = container(conversation::view(
ui,
spec(agent, Host::Rail),
Catalog::of(LANG),
))
.width(Length::Fixed(width))
.height(Length::Fill)
.padding(Padding {
top: ui.space(Space::X6),
right: ui.space(Space::X4),
bottom: ui.space(Space::X4),
left: ui.space(Space::X4),
})
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
..container::Style::default()
});
row![splitter(ui), body]
.width(Length::Shrink)
.height(Length::Fill)
.into()
}
/// 8px 拖拽缝:1px 分隔线居中 + 水平缩放光标 + 按下即开拖。
fn splitter<'a>(ui: &'a Ui) -> Element<'a, Message> {
let line = container(
rule::vertical::<IcedTheme>(ui.metrics().divider_thickness).style(move |_theme| {
rule::Style {
color: ui.color(Role::OutlineVariant),
radius: ui.radius_dp(ShapeScale::None),
fill_mode: rule::FillMode::Full,
snap: true,
}
}),
)
.width(Length::Fixed(RESIZE_GUTTER))
.height(Length::Fill)
.align_x(Alignment::Center);
mouse_area(line)
.interaction(mouse::Interaction::ResizingHorizontally)
.on_press(Message::DragStart)
.into()
}
/// 会话视图的入参(**唯一**的组装点:两个宿主都走这里,不许各拼一份)。
fn spec<'a>(agent: &'a AgentUi, host: Host) -> Conversation<'a> {
Conversation {
transcript: &agent.transcript,
draft: &agent.draft,
busy: agent.busy,
unread: agent.unread,
blank: agent.draft_is_blank(),
rail: agent.rail,
host,
// 只有键盘巡航过才画焦点环(鼠标操作时那是噪音,而且会被误读成 tonal 按钮)
focus: agent.keyboard_mode.then_some(agent.focus),
}
}
+376
View File
@@ -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()
}
}
+381
View File
@@ -0,0 +1,381 @@
//! 三档状态机、两个宿主、a11y 文案的判据(数值表与磁铁在 `metrics/tests.rs`)。
//!
//! 每条断言都写清「抓得住什么 / 抓不住什么」——纯函数与源码扫描能钉住语义,**钉不住**像素。
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use unlucky_i18n::{Catalog, Key, Lang};
use crate::theme::{Families, Ui};
use super::conversation::{Conversation, Host};
use super::metrics::{layout_for, PRESETS, RESIZE_GUTTER};
use super::state::{AgentUi, Focus, RailState, Speaker, Turn};
use super::*;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui() -> Ui {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
}
/// 三档迁移:会话、草稿、未读的语义。
///
/// 抓得住:拆出去再收回,话说了一半还在(transcript + draft 不丢);未读只在收起态累积。
/// 抓不住:切换时有没有一次「闪一下」的重排(那要出图看)。
#[test]
fn the_three_states_share_one_conversation() {
let mut agent = AgentUi::new();
assert_eq!(agent.rail, RailState::Collapsed, "出厂是折叠档");
assert_eq!(agent.unread, 2, "出厂带两条未读(折叠条上要有点)");
agent.expand();
assert_eq!(agent.rail, RailState::Expanded);
assert_eq!(agent.unread, 0, "展开即读");
agent.draft = iced::widget::text_editor::Content::with_text("kick off Survival");
agent.draft.perform(iced::widget::text_editor::Action::Edit(
iced::widget::text_editor::Edit::Enter,
));
assert_eq!(
agent.draft.line_count(),
2,
"多行:Enter 的编辑动作能落进草稿"
);
let turns = agent.transcript.len();
let draft = agent.draft.text();
agent.detach();
assert_eq!(agent.rail, RailState::Detached);
assert_eq!(agent.transcript.len(), turns, "拆出去不丢会话");
assert_eq!(agent.draft.text(), draft, "拆出去不丢草稿");
agent.dock();
assert_eq!(agent.rail, RailState::Expanded);
assert_eq!(agent.draft.text(), draft);
// 收起 / Esc:只改档位,不清未读记录(下一批消息仍会点亮)
agent.collapse();
assert_eq!(agent.rail, RailState::Collapsed);
// 独立页上的 Esc = 收回(`collapse` 对 Detached 等价于 dock)
agent.rail = RailState::Detached;
agent.collapse();
assert_eq!(agent.rail, RailState::Expanded);
}
/// 发送 / 中断:**只做视图态**(没有后端),但绝不静默——两条都会在会话里留一条提示。
#[test]
fn submit_and_stop_leave_a_visible_notice() {
let catalog = Catalog::of(Lang::En);
let mut agent = AgentUi::new();
agent.expand();
assert!(agent.draft_is_blank());
assert!(
!agent.submit(catalog),
"空草稿发不出去(发送键同时是禁用态)"
);
agent.draft = iced::widget::text_editor::Content::with_text(" hello ");
let before = agent.transcript.len();
assert!(agent.submit(catalog));
assert_eq!(agent.transcript.len(), before + 2, "一条「你」+ 一条提示");
assert_eq!(agent.transcript[before].speaker, Speaker::You);
assert_eq!(agent.transcript[before].text, "hello", "两侧空白被剪掉");
assert_eq!(agent.transcript[before + 1].speaker, Speaker::Notice);
assert_eq!(
agent.transcript[before + 1].text,
catalog.text(Key::AgentNotePending)
);
assert!(agent.draft_is_blank(), "发出去后草稿清空");
// 流式中:发送键禁用、中断键出现;中断也留一条提示
agent.pretend_streaming();
assert!(agent.busy);
agent.draft = iced::widget::text_editor::Content::with_text("second");
assert!(!agent.submit(catalog), "流式中不许再发");
let before = agent.transcript.len();
agent.stop(catalog);
assert!(!agent.busy);
assert_eq!(agent.transcript.len(), before + 1);
assert_eq!(
agent.transcript[before].text,
catalog.text(Key::AgentNoteStopped)
);
// 没在流式时按中断是 no-op(不刷屏)
let before = agent.transcript.len();
agent.stop(catalog);
assert_eq!(agent.transcript.len(), before);
}
/// 空会话:零消息 + 一句引导文案(任务第 7 条)。
#[test]
fn the_empty_session_has_a_guidance_line() {
let empty = AgentUi::empty();
assert!(empty.transcript.is_empty(), "空会话 = 零消息");
assert_eq!(empty.unread, 0);
let catalog = Catalog::of(Lang::En);
assert!(!catalog.text(Key::AgentEmptyTitle).is_empty());
assert!(
catalog.text(Key::AgentEmptyBody).split_whitespace().count() >= 5,
"空态要有一句能读懂下一步的引导,而不是一个词"
);
// 中文也得有且不同(D28:两套目录一行一个 key,缺译会红)
let zh = Catalog::of(Lang::ZhCn);
assert_ne!(
zh.text(Key::AgentEmptyBody),
catalog.text(Key::AgentEmptyBody)
);
}
/// **单实现判据**:右栗与独立页都只通过 `conversation::view` 画对话。
///
/// 抓得住:有人把「独立页要更宽」当成理由另写一份气泡/输入框装配(复制粘贴是这类任务最常见的坏法)。
/// 抓不住:`conversation::view` **内部**再分叉成两套(那要人读那个文件)。
#[test]
fn both_hosts_render_the_same_conversation() {
let hosts = include_str!("mod.rs");
let conversation = include_str!("conversation.rs");
let composer = include_str!("conversation/composer.rs");
assert_eq!(
conversation.matches("pub fn view<").count(),
1,
"会话视图只能有一个 `view`"
);
assert_eq!(
hosts.matches("conversation::view(").count(),
2,
"右栗与独立页各调用一次同一个 `conversation::view`(不多不少)"
);
assert!(
!hosts.contains("text_editor("),
"输入框的装配只许在 conversation/composer.rs 里(宿主不许自己拼一份)"
);
assert!(
!hosts.contains("scrollable("),
"滚动区的装配只许在 conversation.rs 里"
);
assert!(
composer.contains("text_editor(") || composer.contains("editor(spec.draft)"),
"输入框就在 composer 里"
);
}
/// a11y:所有动作按钮的标签都来自词表,折叠条也不是「只有图标」。
///
/// 抓得住:有人写死一句英文/中文在视图里("Cancel"/"发送"),或给折叠条只留一个图标按钮。
/// 抓不住:译文本身好不好读(那要人看)。
#[test]
fn every_action_has_a_readable_label() {
let catalog = Catalog::of(Lang::En);
for key in [
Key::AgentCollapse,
Key::AgentDetach,
Key::AgentDock,
Key::AgentOpen,
Key::AgentSend,
Key::AgentStop,
Key::AgentStripLabel,
Key::AgentResize,
] {
assert!(
!catalog.text(key).trim().is_empty(),
"{} 没有可读标签",
key.name()
);
}
for source in [
include_str!("conversation.rs"),
include_str!("conversation/composer.rs"),
include_str!("mod.rs"),
] {
for literal in ["Button::new(\"", "Button::disabled(\""] {
assert!(
!source.contains(literal),
"按钮标签不许写死在视图里:{literal}"
);
}
assert!(
!source.contains("text(\"Assistant\""),
"文案不许写死在视图里(走 i18n 的 Key)"
);
}
// 折叠条的 48px 条上,图标下面必须有文字标签(否则就是「只有图标」)
assert!(
include_str!("mod.rs").contains("Key::AgentStripLabel"),
"折叠条必须有可读的条上标签"
);
}
/// Tab 环:只在**真正存在**的控件之间走,且顺序自左向右可预期。
#[test]
fn the_focus_ring_walks_the_controls_that_exist() {
// 展开且空闲:收起 → 拆出 → 发送 → 输入框(没有中断键)
let mut focus = Focus::Collapse;
let mut walked = vec![focus];
for _ in 0..4 {
focus = focus.step(false, RailState::Expanded, false);
walked.push(focus);
}
assert_eq!(
walked,
vec![
Focus::Collapse,
Focus::Detach,
Focus::Send,
Focus::Composer,
Focus::Collapse,
]
);
// 反向:Collapse 往回走是 Composer
assert_eq!(
Focus::Collapse.step(true, RailState::Expanded, false),
Focus::Composer
);
// 流式中多一档中断键
assert_eq!(
Focus::Send.step(false, RailState::Expanded, true),
Focus::Stop
);
// 独立页只有「收回 + 输入区」
assert_eq!(
Focus::Detach.step(false, RailState::Detached, false),
Focus::Send
);
// 折叠档没有会话动作:环上只有「展开」一格
assert_eq!(
Focus::Collapse.step(false, RailState::Collapsed, false),
Focus::Collapse
);
// `Tab` 真的把键盘巡航打开(`Enter` 的闸门)
let mut agent = AgentUi::new();
assert!(!agent.keyboard_mode);
agent.step_focus(false);
assert!(agent.keyboard_mode);
}
/// 两个宿主都能构图(含空会话 / 折叠 / 流式中),且能吃 880~2560 的窗口宽度。
///
/// 抓得住:某档位下 `view` 的某条分支 panic(例如空 transcript + 空未读)。
/// 抓不住:像素级观感(出图另算)。
#[test]
fn every_state_builds_in_both_hosts() {
let ui = ui();
let catalog = Catalog::of(Lang::En);
for window in [880.0, 900.0, 1180.0, 2560.0] {
for rail in RailState::ALL {
for empty in [false, true] {
let mut agent = if empty {
AgentUi::empty()
} else {
AgentUi::new()
};
agent.rail = rail;
let _: iced::Element<'_, Message> = page(&ui, &agent, window);
let spec = Conversation {
transcript: &agent.transcript,
draft: &agent.draft,
busy: agent.busy,
unread: agent.unread,
blank: agent.draft_is_blank(),
rail: agent.rail,
host: Host::Rail,
focus: agent.keyboard_mode.then_some(agent.focus),
};
let _: iced::Element<'_, Message> = conversation::view(&ui, spec, catalog);
let _ = docked(&ui, &agent, window);
}
}
}
}
/// 拖拽层只在拖拽期间存在(否则它会吃掉整窗的点击)。
#[test]
fn the_drag_overlay_only_exists_while_dragging() {
let mut agent = AgentUi::new();
agent.expand();
assert!(drag_overlay(&agent).is_none());
agent.drag_start();
assert!(agent.is_dragging());
assert!(drag_overlay(&agent).is_some());
agent.drag_end();
assert!(drag_overlay(&agent).is_none());
}
/// 拆出档右栗必须**不占地**(否则会话会在两个地方各画一份)。
#[test]
fn the_detached_state_frees_the_rail_slot() {
let ui = ui();
let mut agent = AgentUi::new();
agent.detach();
assert!(docked(&ui, &agent, 1180.0).is_none());
assert_eq!(agent.width(1180.0), 0.0);
assert_eq!(agent.content_width(1180.0), 1180.0);
}
/// 拖拽把「展开宽度」写进状态(松手后保有该宽度)。
#[test]
fn the_drag_writes_back_the_snapped_width() {
let mut agent = AgentUi::new();
agent.expand();
agent.drag_start();
agent.drag_to(1180.0, 1180.0 - 280.0);
agent.drag_end();
assert_eq!(agent.expanded(), 280.0);
assert!(!agent.is_dragging());
// 没按下就收到的 DragTo(例如上一步松手后的残留)不该改宽度
let before = agent.expanded();
agent.drag_to(1180.0, 100.0);
assert_eq!(agent.expanded(), before);
// 三档预设都能被拖到(1180 下上限 612 > 360)
for preset in PRESETS {
agent.drag_start();
agent.drag_to(1180.0, 1180.0 - preset);
agent.drag_end();
assert_eq!(agent.expanded(), preset);
}
}
/// 面板与中栗之间的缝在布局里被真的留出来了(宽度常量与 `layout_for` 同源)。
#[test]
fn the_resize_gutter_is_part_of_the_budget() {
let mut agent = AgentUi::new();
agent.expand();
assert_eq!(
agent.width(1180.0) + RESIZE_GUTTER,
1180.0 - agent.content_width(1180.0)
);
// 折叠与拆出档没有缝
let collapsed = layout_for(1180.0, RailState::Collapsed, 320.0);
assert_eq!(collapsed.gutter, 0.0);
let detached = layout_for(1180.0, RailState::Detached, 320.0);
assert_eq!(detached.gutter, 0.0);
}
/// 会话数据结构不许在手边偷偷长字段(`Turn` 只有说话人 + 正文)。
#[test]
fn a_turn_is_only_a_speaker_and_a_body() {
let turn = Turn::new(Speaker::You, "hi");
assert_eq!((turn.speaker, turn.text.as_str()), (Speaker::You, "hi"));
}