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:
1 parent
875ac159ee
commit
c3f20784a8
20 files changed
+2463
-153
No files matched your search
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user