fix(ai): align chat panel surface
This commit is contained in:
parent
31312eaf08
commit
cbf0491c74
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<'_>,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
|
|
|
|||
Loading…
Reference in a new issue