//! **会话视图组件**:右栗面板与 `Route::Agent` 独立页的**同一份**对话 UI。 //! //! ## 输入契约(纯数据入、消息出) //! //! ```text //! Conversation { transcript, draft, busy, unread, blank, rail, host, focus } ──view()──▶ Element //! ``` //! //! - **入**:全部是**借用**的纯数据(`&[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, } /// 画会话视图(**右栗与独立页的唯一实现**)。 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> = 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, 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::(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() }