用户定案(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 条全绿
368 lines
13 KiB
Rust
368 lines
13 KiB
Rust
//! **会话视图组件**:右栗面板与 `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()
|
||
}
|