fix(ai): align chat panel surface

This commit is contained in:
Fini 2026-05-30 15:34:22 +08:00
parent 31312eaf08
commit cbf0491c74
5 changed files with 208 additions and 26 deletions

View file

@ -6,7 +6,7 @@ use crate::widgets::ai_chat_panel_controls::{
attachment_row_hit, paint_attachment_row, ATTACHMENT_ROW_HEIGHT,
};
use crate::widgets::ai_chat_panel_paint::{
example_card_rects, paint_examples, paint_panel_surface,
example_card_rects, paint_examples, paint_panel_body_chrome, paint_panel_surface,
};
use crate::widgets::editor_state_ext::{theme_for, translate};
use crate::widgets::icons::{draw_icon, Icon};
@ -182,7 +182,7 @@ impl<'a> AIChatPlaceholder<'a> {
// TS stores the chat title as UI state and defaults it to
// this English title even under a Chinese locale.
label_new_chat: "New Chat".to_string(),
label_start_with_ai: translate(ui, "ai.tryExample").to_string(),
label_start_with_ai: translate(ui, "ai.startDesigning").to_string(),
label_input_placeholder: translate(ui, "ai.designWithAgent").to_string(),
label_tip_select_elements: translate(ui, "ai.tipSelectElements").to_string(),
label_no_models: translate(ui, "ai.noModelsConnected").to_string(),
@ -462,6 +462,9 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
}
paint_panel_surface(cx, &self.theme, rect);
let input_h = self.input_height();
let sep_y = rect.origin.y + rect.size.y - input_h;
paint_panel_body_chrome(cx, &self.theme, rect, sep_y);
// Expanded header.
let header_y = rect.origin.y + 8.0;
@ -502,7 +505,6 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
);
// Body — either messages or examples.
let input_h = self.input_height();
let checklist_h = fixed_checklist_height(&self.state.messages);
if self.state.messages.is_empty() {
paint_examples(
@ -532,17 +534,6 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
);
}
// Separator hairline between body and input area
// (matches the TS panel's bottom-bordered body region).
let sep_y = rect.origin.y + rect.size.y - input_h;
cx.backend.fill_rect(
Rect {
origin: Point2D::new(rect.origin.x + PAD, sep_y),
size: Point2D::new(rect.size.x - PAD * 2.0, 1.0),
},
self.theme.border,
);
// Textarea region — borderless, 14 px to mirror the TS app's
// textarea style. Long input wraps across up to `MAX_LINES`
// visible rows; beyond that the view anchors to the bottom

View file

@ -34,6 +34,17 @@ fn from_editor_tracks_selection_count_for_toolbar() {
assert_eq!(panel.selected_count, 2);
}
#[test]
fn from_editor_uses_ts_start_designing_hint() {
let s = EditorState::new();
let panel = AIChatPlaceholder::from_editor(&s);
assert_eq!(
panel.label_start_with_ai,
op_i18n::translate(s.editor_ui.locale, "ai.startDesigning")
);
}
/// Y-coordinate of the textarea's vertical center.
fn textarea_center_y() -> f32 {
AI_CHAT_HEIGHT - INPUT_BASE_HEIGHT + 1.0 + INPUT_AREA_HEIGHT / 2.0
@ -189,3 +200,73 @@ fn body_rect_reserves_space_for_fixed_step_checklist() {
"fixed step checklist should reserve bottom space outside transcript"
);
}
#[derive(Default)]
struct PanelPaintBackend {
fills: Vec<(Rect, crate::Color)>,
}
impl crate::RenderBackend for PanelPaintBackend {
fn begin_frame(&mut self) {}
fn end_frame(&mut self) {}
fn fill_rect(&mut self, rect: Rect, color: crate::Color) {
self.fills.push((rect, color));
}
fn stroke_rect(&mut self, _: Rect, _: crate::Color, _: f32) {}
fn draw_text(&mut self, _: &crate::TextLayout, _: Point2D) {}
fn clip_rect(&mut self, _: Rect) {}
fn save(&mut self) {}
fn restore(&mut self) {}
fn translate(&mut self, _: Point2D) {}
fn stroke_line(&mut self, _: Point2D, _: Point2D, _: crate::Color, _: f32) {}
fn fill_round_rect(&mut self, _: Rect, _: f32, _: crate::Color) {}
fn stroke_round_rect(&mut self, _: Rect, _: f32, _: crate::Color, _: f32) {}
fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: crate::Color, _: f32) {}
fn resize(&mut self, _: u32, _: u32) {}
fn dpi_scale(&self) -> f32 {
1.0
}
}
fn has_fill_rect(fills: &[(Rect, crate::Color)], expected: Rect) -> bool {
fills.iter().any(|(rect, _)| {
(rect.origin.x - expected.origin.x).abs() < 1e-4
&& (rect.origin.y - expected.origin.y).abs() < 1e-4
&& (rect.size.x - expected.size.x).abs() < 1e-4
&& (rect.size.y - expected.size.y).abs() < 1e-4
})
}
#[test]
fn paint_draws_header_divider_and_message_body_background() {
let s = EditorState::new();
let panel = AIChatPlaceholder::from_editor(&s);
let rect = Rect::xywh(10.0, 20.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
let input_h = INPUT_BASE_HEIGHT;
let sep_y = rect.origin.y + rect.size.y - input_h;
let mut backend = PanelPaintBackend::default();
let mut cx = PaintCx {
backend: &mut backend,
};
panel.paint(&mut cx, rect);
assert!(has_fill_rect(
&backend.fills,
Rect::xywh(
rect.origin.x + 1.0,
rect.origin.y + HEADER_HEIGHT,
rect.size.x - 2.0,
1.0
)
));
assert!(has_fill_rect(
&backend.fills,
Rect::xywh(
rect.origin.x + 1.0,
rect.origin.y + HEADER_HEIGHT + 1.0,
rect.size.x - 2.0,
sep_y - (rect.origin.y + HEADER_HEIGHT + 1.0),
)
));
}

View file

@ -54,6 +54,27 @@ pub(crate) fn paint_panel_surface(cx: &mut PaintCx<'_>, theme: &Theme, rect: Rec
cx.backend.stroke_round_rect(rect, 14.0, theme.border, 1.0);
}
/// Paint the message body's TS-style background and internal dividers.
pub(crate) fn paint_panel_body_chrome(cx: &mut PaintCx<'_>, theme: &Theme, rect: Rect, sep_y: f32) {
let inner_x = rect.origin.x + 1.0;
let inner_w = (rect.size.x - 2.0).max(0.0);
cx.backend.fill_rect(
Rect::xywh(inner_x, rect.origin.y + HEADER_HEIGHT, inner_w, 1.0),
theme.border,
);
let body_y = rect.origin.y + HEADER_HEIGHT + 1.0;
if sep_y > body_y {
cx.backend.fill_rect(
Rect::xywh(inner_x, body_y, inner_w, sep_y - body_y),
with_alpha(theme.background, 0.8),
);
}
cx.backend.fill_rect(
Rect::xywh(rect.origin.x + PAD, sep_y, rect.size.x - PAD * 2.0, 1.0),
theme.border,
);
}
/// Paint the empty-state hint line + the 2×2 example-card grid.
pub(crate) fn paint_examples(
cx: &mut PaintCx<'_>,

View file

@ -273,7 +273,11 @@ fn build_item(
})
} else if !visible_content.is_empty() {
let lines = wrap_units(&visible_content, budget);
let h = lines.len() as f32 * LINE_H + 2.0 * BUBBLE_PAD;
let h = if is_user {
lines.len() as f32 * LINE_H + 2.0 * BUBBLE_PAD
} else {
lines.len() as f32 * LINE_H
};
let r = Rect::xywh(x, y, bubble_w, h);
y += h;
Some(TextBubble {
@ -664,23 +668,27 @@ pub(crate) fn paint_transcript(
ChatRole::User => (theme.primary, theme.primary_foreground),
ChatRole::Assistant => (theme.muted, theme.foreground),
};
cx.backend.fill_round_rect(bubble.rect, 8.0, bg);
if bubble.typing {
cx.backend.fill_round_rect(bubble.rect, 8.0, bg);
paint_typing_dots(cx, theme, bubble.rect, now_ms);
} else {
// Clip to the bubble — over-long tokens stay inside.
cx.backend.save();
cx.backend.clip_rect(bubble.rect);
let mut baseline = bubble.rect.origin.y + BUBBLE_PAD + 11.0;
if item.role == ChatRole::User {
cx.backend.fill_round_rect(bubble.rect, 8.0, bg);
}
let text_x = match item.role {
ChatRole::User => bubble.rect.origin.x + BUBBLE_PAD,
ChatRole::Assistant => bubble.rect.origin.x,
};
let mut baseline = bubble.rect.origin.y
+ match item.role {
ChatRole::User => BUBBLE_PAD + 11.0,
ChatRole::Assistant => 11.0,
};
for line in &bubble.lines {
draw_line(
cx,
line,
bubble.rect.origin.x + BUBBLE_PAD,
baseline,
BODY_FONT,
fg,
);
draw_line(cx, line, text_x, baseline, BODY_FONT, fg);
baseline += LINE_H;
}
// Streaming caret — a blinking bar after the last
@ -688,7 +696,7 @@ pub(crate) fn paint_transcript(
if item.streaming && jian_core::anim::blink_visible(now_ms, 0, 500) {
let last = bubble.lines.last().map(String::as_str).unwrap_or("");
let units: u32 = last.chars().map(char_display_units).sum();
let caret_x = bubble.rect.origin.x + BUBBLE_PAD + units as f32 * CHAR_UNIT_PX;
let caret_x = text_x + units as f32 * CHAR_UNIT_PX;
let caret_y = baseline - LINE_H - 9.0;
cx.backend
.fill_rect(Rect::xywh(caret_x, caret_y, 2.0, 13.0), fg);

View file

@ -62,6 +62,20 @@ fn assistant_blocks_use_full_body_width_like_ts_transcript() {
assert!((bubble.rect.size.x - body.size.x).abs() < 1e-4);
}
#[test]
fn assistant_answer_uses_plain_text_height_without_bubble_padding() {
let msg = ChatMessage::assistant("first line\nsecond line");
let items = build_transcript(
std::slice::from_ref(&msg),
body(),
op_editor_core::Locale::EnUs,
);
let bubble = items[0].bubble.as_ref().expect("assistant answer text");
assert_eq!(bubble.lines.len(), 2);
assert!((bubble.rect.size.y - LINE_H * 2.0).abs() < 1e-4);
}
#[test]
fn user_bubbles_remain_compact_and_right_aligned() {
let msg = ChatMessage::user("user prompt");
@ -79,6 +93,73 @@ fn user_bubbles_remain_compact_and_right_aligned() {
);
}
#[derive(Default)]
struct TranscriptPaintBackend {
round_rects: usize,
}
impl crate::RenderBackend for TranscriptPaintBackend {
fn begin_frame(&mut self) {}
fn end_frame(&mut self) {}
fn fill_rect(&mut self, _: Rect, _: crate::Color) {}
fn stroke_rect(&mut self, _: Rect, _: crate::Color, _: f32) {}
fn draw_text(&mut self, _: &crate::TextLayout, _: Point2D) {}
fn clip_rect(&mut self, _: Rect) {}
fn save(&mut self) {}
fn restore(&mut self) {}
fn translate(&mut self, _: Point2D) {}
fn stroke_line(&mut self, _: Point2D, _: Point2D, _: crate::Color, _: f32) {}
fn fill_round_rect(&mut self, _: Rect, _: f32, _: crate::Color) {
self.round_rects += 1;
}
fn stroke_round_rect(&mut self, _: Rect, _: f32, _: crate::Color, _: f32) {}
fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: crate::Color, _: f32) {}
fn resize(&mut self, _: u32, _: u32) {}
fn dpi_scale(&self) -> f32 {
1.0
}
}
#[test]
fn paint_transcript_leaves_assistant_answer_unframed() {
let messages = [ChatMessage::assistant("assistant answer")];
let mut backend = TranscriptPaintBackend::default();
let mut cx = PaintCx {
backend: &mut backend,
};
paint_transcript(
&mut cx,
&crate::Theme::dark(),
body(),
&messages,
0,
op_editor_core::Locale::EnUs,
);
assert_eq!(backend.round_rects, 0);
}
#[test]
fn paint_transcript_keeps_user_answer_bubble_background() {
let messages = [ChatMessage::user("user prompt")];
let mut backend = TranscriptPaintBackend::default();
let mut cx = PaintCx {
backend: &mut backend,
};
paint_transcript(
&mut cx,
&crate::Theme::dark(),
body(),
&messages,
0,
op_editor_core::Locale::EnUs,
);
assert_eq!(backend.round_rects, 1);
}
#[test]
fn streaming_message_with_no_text_yields_a_typing_bubble() {
let msgs = vec![ChatMessage::assistant_streaming()];