From 28dab69245f7b9ee77400888c8db040a19d4897a Mon Sep 17 00:00:00 2001 From: Fini Date: Sun, 31 May 2026 20:57:15 +0800 Subject: [PATCH] fix(ai): wrap chat quick action cards --- .../src/widgets/ai_chat_panel/tests.rs | 16 +-- .../src/widgets/ai_chat_panel_paint.rs | 126 ++++++++++++++---- 2 files changed, 108 insertions(+), 34 deletions(-) diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs index d53206663..06400d96b 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs @@ -225,18 +225,18 @@ fn hit_test_resolves_first_example_when_empty() { } #[test] -fn hit_test_keeps_quick_action_card_height_compact() { - let s = EditorState::new(); +fn hit_test_uses_taller_ts_quick_action_card_height() { + let mut s = EditorState::new(); + seed_available_model(&mut s); let panel = AIChatPlaceholder::from_editor(&s); let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); let card_w = (AI_CHAT_WIDTH - PAD * 2.0 - 8.0) / 2.0; - let compact_card_h = 58.0; - let p = Point2D::new( - PAD + card_w / 2.0, - HEADER_HEIGHT + 32.0 + compact_card_h + 4.0, - ); + let p = Point2D::new(PAD + card_w / 2.0, HEADER_HEIGHT + 32.0 + 64.0); - assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::DragHandle)); + match panel.hit_test(rect, p) { + Some(AIChatHit::Example(prompt)) => assert_eq!(prompt, panel.examples[0].prompt), + other => panic!("expected first example hit in taller TS-style card, got {other:?}"), + } } #[test] diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel_paint.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel_paint.rs index f8d9d2f63..dd6d89a76 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel_paint.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel_paint.rs @@ -9,8 +9,11 @@ use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; pub(crate) const EXAMPLE_CARD_GAP: f32 = 8.0; -pub(crate) const EXAMPLE_CARD_HEIGHT: f32 = 58.0; +pub(crate) const EXAMPLE_CARD_HEIGHT: f32 = 72.0; const EXAMPLE_CARD_PAD: f32 = 12.0; +const EXAMPLE_TITLE_FONT: f32 = 12.0; +const EXAMPLE_SUBTITLE_FONT: f32 = 10.0; +const EXAMPLE_SUBTITLE_LINE_H: f32 = 12.0; pub(crate) fn example_card_rects(rect: Rect) -> [Rect; 4] { let grid_y = rect.origin.y + HEADER_HEIGHT + 32.0; @@ -32,6 +35,51 @@ fn with_alpha(color: Color, a: f32) -> Color { Color { a, ..color } } +fn wrapped_lines( + cx: &mut PaintCx<'_>, + value: &str, + max_w: f32, + size: f32, + max_lines: usize, +) -> Vec { + if max_lines == 0 || value.is_empty() { + return Vec::new(); + } + let chars: Vec = value.chars().collect(); + let mut lines = Vec::new(); + let mut idx = 0; + while idx < chars.len() && lines.len() < max_lines { + while idx < chars.len() && chars[idx].is_whitespace() { + idx += 1; + } + let mut line = String::new(); + while idx < chars.len() { + let candidate = format!("{}{}", line, chars[idx]); + if !line.is_empty() && cx.backend.measure_text(&candidate, size) > max_w { + break; + } + line.push(chars[idx]); + idx += 1; + } + if line.is_empty() && idx < chars.len() { + line.push(chars[idx]); + idx += 1; + } + if !line.is_empty() { + lines.push(line.trim_end().to_string()); + } + } + if idx < chars.len() { + if let Some(last) = lines.last_mut() { + while !last.is_empty() && cx.backend.measure_text(&format!("{last}..."), size) > max_w { + last.pop(); + } + last.push_str("..."); + } + } + lines +} + /// Paint the floating chat panel shell. TS uses /// `rounded-xl border bg-card/95 shadow-2xl backdrop-blur-sm`; /// Skia here has no blur primitive, so we layer translucent rounded @@ -123,34 +171,60 @@ pub(crate) fn paint_examples( card.origin.y + EXAMPLE_CARD_PAD + 10.0, ), ); - let title_layout = TextLayout::single_run( + let title_lines = wrapped_lines( + cx, &ex.title, - "system-ui", - 12.0, - to_jian_color(title_color), - Point2D::new(0.0, 0.0), + card.size.x - EXAMPLE_CARD_PAD * 2.0 - 20.0, + EXAMPLE_TITLE_FONT, + 2, ); - cx.backend.draw_text( - &title_layout, - Point2D::new( - card.origin.x + EXAMPLE_CARD_PAD + 20.0, - card.origin.y + EXAMPLE_CARD_PAD + 10.0, - ), - ); - let subtitle_layout = TextLayout::single_run( + for (line_index, line) in title_lines.iter().enumerate() { + let title_layout = TextLayout::single_run( + line, + "system-ui", + EXAMPLE_TITLE_FONT, + to_jian_color(title_color), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text( + &title_layout, + Point2D::new( + card.origin.x + EXAMPLE_CARD_PAD + 20.0, + card.origin.y + + EXAMPLE_CARD_PAD + + 10.0 + + line_index as f32 * EXAMPLE_SUBTITLE_LINE_H, + ), + ); + } + let title_extra = title_lines.len().saturating_sub(1) as f32 * EXAMPLE_SUBTITLE_LINE_H; + let subtitle_lines = wrapped_lines( + cx, &ex.subtitle, - "system-ui", - 11.0, - to_jian_color(subtitle_color), - Point2D::new(0.0, 0.0), - ); - cx.backend.draw_text( - &subtitle_layout, - Point2D::new( - card.origin.x + EXAMPLE_CARD_PAD, - card.origin.y + EXAMPLE_CARD_PAD + 28.0, - ), + card.size.x - EXAMPLE_CARD_PAD * 2.0, + EXAMPLE_SUBTITLE_FONT, + 2, ); + for (line_index, line) in subtitle_lines.iter().enumerate() { + let subtitle_layout = TextLayout::single_run( + line, + "system-ui", + EXAMPLE_SUBTITLE_FONT, + to_jian_color(subtitle_color), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text( + &subtitle_layout, + Point2D::new( + card.origin.x + EXAMPLE_CARD_PAD, + card.origin.y + + EXAMPLE_CARD_PAD + + 29.0 + + title_extra + + line_index as f32 * EXAMPLE_SUBTITLE_LINE_H, + ), + ); + } cx.backend.restore(); } let tip = TextLayout::single_run( @@ -169,7 +243,7 @@ pub(crate) fn paint_examples( + HEADER_HEIGHT + 32.0 + EXAMPLE_CARD_HEIGHT * 2.0 - + EXAMPLE_CARD_GAP * 2.0 + + EXAMPLE_CARD_GAP + 22.0, ), );