fix(ai): reveal design json copy on hover
This commit is contained in:
parent
1be8b4ac80
commit
0113cccbcf
|
|
@ -698,6 +698,9 @@ pub struct EditorUiState {
|
|||
/// Index into `chat.available_models` of the model row the cursor
|
||||
/// is over, or `None`. Drives the picker's hover-row tint.
|
||||
pub chat_model_picker_hover: Option<usize>,
|
||||
/// Hovered chat design JSON card `(message_index, block_index)`;
|
||||
/// drives the TS-style hover reveal of the card's copy affordance.
|
||||
pub chat_design_block_hover: Option<(usize, usize)>,
|
||||
/// Index into `AgentProvider::ALL` of the agent driving the chat.
|
||||
pub chat_selected_agent: usize,
|
||||
|
||||
|
|
@ -894,6 +897,7 @@ impl Default for EditorUiState {
|
|||
chat_model_picker_caret: None,
|
||||
chat_model_picker_caret_anchor_ms: 0,
|
||||
chat_model_picker_hover: None,
|
||||
chat_design_block_hover: None,
|
||||
chat_selected_agent: 0,
|
||||
topbar_traffic_hover: false,
|
||||
window_fullscreen: false,
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ pub struct AIChatPlaceholder<'a> {
|
|||
pub model_picker_caret: Option<usize>,
|
||||
/// Last focus / edit timestamp for the model-picker search caret.
|
||||
pub model_picker_caret_anchor_ms: u64,
|
||||
pub design_hover: Option<(usize, usize)>,
|
||||
/// Localised empty-state example cards.
|
||||
pub(crate) examples: [ExampleCard; 4],
|
||||
/// Active UI locale.
|
||||
|
|
@ -122,6 +123,7 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
model_picker_search: ui.chat_model_picker_search.clone(),
|
||||
model_picker_caret: ui.chat_model_picker_caret,
|
||||
model_picker_caret_anchor_ms: ui.chat_model_picker_caret_anchor_ms,
|
||||
design_hover: ui.chat_design_block_hover,
|
||||
examples: example_cards(ui.locale),
|
||||
locale: ui.locale,
|
||||
}
|
||||
|
|
@ -150,7 +152,6 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
self.state.messages.iter().any(|message| message.streaming)
|
||||
}
|
||||
|
||||
/// Bounds of the transcript body region.
|
||||
fn body_rect(&self, rect: Rect) -> Rect {
|
||||
let body_top = rect.origin.y + HEADER_HEIGHT;
|
||||
let body_bottom = rect.origin.y + rect.size.y
|
||||
|
|
@ -164,7 +165,6 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Bounds of the model-picker dropdown.
|
||||
fn model_picker_rect(&self, rect: Rect, input_rect: Rect) -> Rect {
|
||||
let height = crate::widgets::ai_chat_model_picker::picker_view_height(
|
||||
&self.state.available_models,
|
||||
|
|
@ -178,7 +178,6 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Public bounds of the open model-picker dropdown.
|
||||
pub fn model_picker_bounds(&self, rect: Rect) -> Option<Rect> {
|
||||
if !self.model_picker_open {
|
||||
return None;
|
||||
|
|
@ -362,6 +361,19 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
}
|
||||
Some(AIChatHit::DragHandle)
|
||||
}
|
||||
|
||||
pub fn design_block_hover_at(&self, rect: Rect, point: Point2D) -> Option<(usize, usize)> {
|
||||
if self.state.messages.is_empty() {
|
||||
return None;
|
||||
}
|
||||
crate::widgets::ai_chat_transcript_hit::design_block_at(
|
||||
&self.state.messages,
|
||||
self.body_rect(rect),
|
||||
point.x,
|
||||
point.y,
|
||||
self.locale,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn rect_contains(r: Rect, p: Point2D) -> bool {
|
||||
|
|
@ -489,13 +501,14 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
|
|||
!can_use_model || self.is_streaming(),
|
||||
);
|
||||
} else {
|
||||
crate::widgets::ai_chat_transcript::paint_transcript(
|
||||
crate::widgets::ai_chat_transcript::paint_transcript_with_design_hover(
|
||||
cx,
|
||||
&self.theme,
|
||||
self.body_rect(rect),
|
||||
&self.state.messages,
|
||||
self.now_ms,
|
||||
self.locale,
|
||||
self.design_hover,
|
||||
);
|
||||
}
|
||||
if checklist_h > 0.0 {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ use op_editor_core::chat::{ChatMessage, ChatRole};
|
|||
use super::ai_chat_transcript_design::{
|
||||
extract_design_json_blocks, paint_design_block, place_design_blocks, DesignBlock,
|
||||
};
|
||||
pub(crate) use super::ai_chat_transcript_hit::{transcript_hit, TranscriptHit};
|
||||
use super::ai_chat_transcript_steps::{
|
||||
extract_step_blocks, split_design_progress, strip_tool_call_xml, ParsedStep, ParsedStepStatus,
|
||||
};
|
||||
|
|
@ -75,23 +76,6 @@ const CHAR_UNIT_PX: f32 = 6.6;
|
|||
/// Bubble width as a fraction of the transcript body width.
|
||||
const BUBBLE_FRAC: f32 = 0.84;
|
||||
|
||||
/// What a click inside the transcript resolved to. Both variants
|
||||
/// carry the index into the full `messages` slice.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TranscriptHit {
|
||||
/// The thinking-block header — toggle its collapsed state.
|
||||
ToggleThinking(usize),
|
||||
/// The tool-calls panel header — toggle its collapsed state.
|
||||
ToggleToolCalls(usize),
|
||||
/// One tool-call card header — set just that card's expanded
|
||||
/// state. Carries `(message_index, tool_call_index, expanded)`.
|
||||
SetToolCallCardExpanded(usize, usize, bool),
|
||||
/// One design JSON card header — set just that card's expanded
|
||||
/// state. Carries `(message_index, design_block_index, expanded)`.
|
||||
SetDesignBlockExpanded(usize, usize, bool),
|
||||
CopyDesignBlock(String),
|
||||
}
|
||||
|
||||
/// A collapsible block (thinking text or tool-call list) — a
|
||||
/// clickable `header` row plus an optional `body` box. When
|
||||
/// `collapsed`, `body` has zero height and `lines` is empty.
|
||||
|
|
@ -138,10 +122,6 @@ pub(crate) struct TranscriptItem {
|
|||
pub streaming: bool,
|
||||
}
|
||||
|
||||
fn rect_contains(r: Rect, x: f32, y: f32) -> bool {
|
||||
x >= r.origin.x && x <= r.origin.x + r.size.x && y >= r.origin.y && y <= r.origin.y + r.size.y
|
||||
}
|
||||
|
||||
/// Wrap-unit budget for an inner text width.
|
||||
fn unit_budget(inner_w: f32) -> u32 {
|
||||
(inner_w / CHAR_UNIT_PX).floor().max(1.0) as u32
|
||||
|
|
@ -203,6 +183,7 @@ fn build_item(
|
|||
top: f32,
|
||||
body: Rect,
|
||||
locale: op_editor_core::Locale,
|
||||
design_hover: Option<(usize, usize)>,
|
||||
) -> (TranscriptItem, f32) {
|
||||
let is_user = msg.role == ChatRole::User;
|
||||
let bubble_w = if is_user {
|
||||
|
|
@ -318,6 +299,9 @@ fn build_item(
|
|||
bubble_w,
|
||||
SUB_GAP,
|
||||
&msg.design_block_expanded_overrides,
|
||||
design_hover.and_then(|(message_index, block_index)| {
|
||||
(message_index == msg_index).then_some(block_index)
|
||||
}),
|
||||
);
|
||||
y = next_y;
|
||||
|
||||
|
|
@ -426,6 +410,15 @@ pub(crate) fn build_transcript(
|
|||
messages: &[ChatMessage],
|
||||
body_rect: Rect,
|
||||
locale: op_editor_core::Locale,
|
||||
) -> Vec<TranscriptItem> {
|
||||
build_transcript_with_design_hover(messages, body_rect, locale, None)
|
||||
}
|
||||
|
||||
pub(crate) fn build_transcript_with_design_hover(
|
||||
messages: &[ChatMessage],
|
||||
body_rect: Rect,
|
||||
locale: op_editor_core::Locale,
|
||||
design_hover: Option<(usize, usize)>,
|
||||
) -> Vec<TranscriptItem> {
|
||||
if messages.is_empty() {
|
||||
return Vec::new();
|
||||
|
|
@ -435,7 +428,7 @@ pub(crate) fn build_transcript(
|
|||
let mut start = messages.len();
|
||||
let mut used = 0.0f32;
|
||||
for i in (0..messages.len()).rev() {
|
||||
let (_, bottom) = build_item(&messages[i], i, 0.0, body_rect, locale);
|
||||
let (_, bottom) = build_item(&messages[i], i, 0.0, body_rect, locale, None);
|
||||
let h = bottom
|
||||
+ if start == messages.len() {
|
||||
0.0
|
||||
|
|
@ -452,64 +445,13 @@ pub(crate) fn build_transcript(
|
|||
let mut items = Vec::new();
|
||||
let mut top = body_rect.origin.y;
|
||||
for (i, msg) in messages.iter().enumerate().skip(start) {
|
||||
let (item, bottom) = build_item(msg, i, top, body_rect, locale);
|
||||
let (item, bottom) = build_item(msg, i, top, body_rect, locale, design_hover);
|
||||
items.push(item);
|
||||
top = bottom + MSG_GAP;
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Resolve a click inside the transcript body to a [`TranscriptHit`]
|
||||
/// — only the collapsible headers are interactive.
|
||||
pub(crate) fn transcript_hit(
|
||||
messages: &[ChatMessage],
|
||||
body_rect: Rect,
|
||||
x: f32,
|
||||
y: f32,
|
||||
locale: op_editor_core::Locale,
|
||||
) -> Option<TranscriptHit> {
|
||||
// Paint clips the transcript to `body_rect`; gate hit-test the
|
||||
// same way so a click in the body/input gap can't toggle a
|
||||
// header that an over-tall latest message placed off-screen.
|
||||
if !rect_contains(body_rect, x, y) {
|
||||
return None;
|
||||
}
|
||||
for item in build_transcript(messages, body_rect, locale) {
|
||||
if let Some(t) = &item.thinking {
|
||||
if rect_contains(t.header, x, y) {
|
||||
return Some(TranscriptHit::ToggleThinking(item.msg_index));
|
||||
}
|
||||
}
|
||||
if let Some(t) = &item.tools {
|
||||
if rect_contains(t.header, x, y) {
|
||||
return Some(TranscriptHit::ToggleToolCalls(item.msg_index));
|
||||
}
|
||||
for (tool_index, card) in t.cards.iter().enumerate() {
|
||||
if rect_contains(card.header, x, y) {
|
||||
return Some(TranscriptHit::SetToolCallCardExpanded(
|
||||
item.msg_index,
|
||||
tool_index,
|
||||
!card.expanded,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (block_index, block) in item.design_blocks.iter().enumerate() {
|
||||
if rect_contains(block.copy, x, y) {
|
||||
return Some(TranscriptHit::CopyDesignBlock(block.code.clone()));
|
||||
}
|
||||
if rect_contains(block.header, x, y) {
|
||||
return Some(TranscriptHit::SetDesignBlockExpanded(
|
||||
item.msg_index,
|
||||
block_index,
|
||||
!block.expanded,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Draw one wrapped text line. Small shared helper so the bubble,
|
||||
/// thinking body and tool list paint identically.
|
||||
fn draw_line(
|
||||
|
|
@ -685,6 +627,7 @@ fn paint_typing_dots(
|
|||
/// Paint the chat transcript — the tail of `messages` that fits in
|
||||
/// `body_rect`, with collapsible thinking / tool blocks, image
|
||||
/// thumbnails and the streaming animation on the in-flight message.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn paint_transcript(
|
||||
cx: &mut PaintCx<'_>,
|
||||
theme: &Theme,
|
||||
|
|
@ -692,10 +635,22 @@ pub(crate) fn paint_transcript(
|
|||
messages: &[ChatMessage],
|
||||
now_ms: u64,
|
||||
locale: op_editor_core::Locale,
|
||||
) {
|
||||
paint_transcript_with_design_hover(cx, theme, body_rect, messages, now_ms, locale, None);
|
||||
}
|
||||
|
||||
pub(crate) fn paint_transcript_with_design_hover(
|
||||
cx: &mut PaintCx<'_>,
|
||||
theme: &Theme,
|
||||
body_rect: Rect,
|
||||
messages: &[ChatMessage],
|
||||
now_ms: u64,
|
||||
locale: op_editor_core::Locale,
|
||||
design_hover: Option<(usize, usize)>,
|
||||
) {
|
||||
cx.backend.save();
|
||||
cx.backend.clip_rect(body_rect);
|
||||
for item in build_transcript(messages, body_rect, locale) {
|
||||
for item in build_transcript_with_design_hover(messages, body_rect, locale, design_hover) {
|
||||
for step in &item.steps {
|
||||
paint_action_step(cx, theme, step);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,3 +23,84 @@ fn transcript_hit_resolves_design_json_copy_button_like_ts() {
|
|||
fn body() -> Rect {
|
||||
Rect::xywh(0.0, 0.0, 340.0, 300.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_design_json_copy_icon_is_hidden_until_card_hover_like_ts() {
|
||||
let message = ChatMessage::assistant(
|
||||
r#"```json
|
||||
[{"id":"frame-1","type":"Frame"}]
|
||||
```"#,
|
||||
);
|
||||
let messages = [message];
|
||||
let mut backend = CopyPaintBackend::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.copy_icon_strokes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_design_json_copy_icon_is_visible_for_hovered_card_like_ts() {
|
||||
let message = ChatMessage::assistant(
|
||||
r#"```json
|
||||
[{"id":"frame-1","type":"Frame"}]
|
||||
```"#,
|
||||
);
|
||||
let messages = [message];
|
||||
let mut backend = CopyPaintBackend::default();
|
||||
let mut cx = PaintCx {
|
||||
backend: &mut backend,
|
||||
};
|
||||
|
||||
paint_transcript_with_design_hover(
|
||||
&mut cx,
|
||||
&crate::Theme::dark(),
|
||||
body(),
|
||||
&messages,
|
||||
0,
|
||||
op_editor_core::Locale::EnUs,
|
||||
Some((0, 0)),
|
||||
);
|
||||
|
||||
assert!(backend.copy_icon_strokes > 0);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CopyPaintBackend {
|
||||
copy_icon_strokes: usize,
|
||||
}
|
||||
|
||||
impl crate::RenderBackend for CopyPaintBackend {
|
||||
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) {}
|
||||
fn stroke_round_rect(&mut self, _: Rect, _: f32, _: crate::Color, _: f32) {}
|
||||
fn stroke_svg_path(&mut self, _: &str, point: Point2D, _: f32, _: crate::Color, _: f32) {
|
||||
if (290.0..=305.0).contains(&point.x) {
|
||||
self.copy_icon_strokes += 1;
|
||||
}
|
||||
}
|
||||
fn fill_oval(&mut self, _: Rect, _: crate::Color) {}
|
||||
fn resize(&mut self, _: u32, _: u32) {}
|
||||
fn dpi_scale(&self) -> f32 {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ pub(crate) struct DesignBlock {
|
|||
pub label: String,
|
||||
pub streaming: bool,
|
||||
pub code: String,
|
||||
pub copy_visible: bool,
|
||||
pub code_lines: Vec<String>,
|
||||
}
|
||||
|
||||
|
|
@ -146,6 +147,7 @@ pub(crate) fn place_design_blocks(
|
|||
width: f32,
|
||||
gap: f32,
|
||||
expanded_overrides: &[Option<bool>],
|
||||
hovered_index: Option<usize>,
|
||||
) -> (Vec<DesignBlock>, f32) {
|
||||
const BODY_PAD_Y: f32 = 8.0;
|
||||
const BODY_LINE_H: f32 = 13.0;
|
||||
|
|
@ -189,6 +191,7 @@ pub(crate) fn place_design_blocks(
|
|||
label: pending.label,
|
||||
streaming: pending.streaming,
|
||||
code: pending.code,
|
||||
copy_visible: hovered_index == Some(index),
|
||||
code_lines,
|
||||
});
|
||||
y += DESIGN_BLOCK_H + body_h + gap;
|
||||
|
|
@ -252,16 +255,18 @@ pub(crate) fn paint_design_block(cx: &mut PaintCx<'_>, theme: &Theme, block: &De
|
|||
);
|
||||
cx.backend.restore();
|
||||
|
||||
let mut copy_color = theme.muted_foreground;
|
||||
copy_color.a *= 0.5;
|
||||
draw_icon(
|
||||
cx.backend,
|
||||
Icon::Copy,
|
||||
Point2D::new(block.copy.origin.x + 4.0, block.copy.origin.y + 4.0),
|
||||
12.0,
|
||||
copy_color,
|
||||
1.5,
|
||||
);
|
||||
if block.copy_visible {
|
||||
let mut copy_color = theme.muted_foreground;
|
||||
copy_color.a *= 0.5;
|
||||
draw_icon(
|
||||
cx.backend,
|
||||
Icon::Copy,
|
||||
Point2D::new(block.copy.origin.x + 4.0, block.copy.origin.y + 4.0),
|
||||
12.0,
|
||||
copy_color,
|
||||
1.5,
|
||||
);
|
||||
}
|
||||
|
||||
let mut chevron_color = theme.muted_foreground;
|
||||
chevron_color.a *= 0.45;
|
||||
|
|
|
|||
82
crates/op-editor-ui/src/widgets/ai_chat_transcript_hit.rs
Normal file
82
crates/op-editor-ui/src/widgets/ai_chat_transcript_hit.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
use super::ai_chat_transcript::build_transcript;
|
||||
use crate::Rect;
|
||||
use op_editor_core::chat::ChatMessage;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TranscriptHit {
|
||||
ToggleThinking(usize),
|
||||
ToggleToolCalls(usize),
|
||||
SetToolCallCardExpanded(usize, usize, bool),
|
||||
SetDesignBlockExpanded(usize, usize, bool),
|
||||
CopyDesignBlock(String),
|
||||
}
|
||||
|
||||
fn rect_contains(r: Rect, x: f32, y: f32) -> bool {
|
||||
x >= r.origin.x && x <= r.origin.x + r.size.x && y >= r.origin.y && y <= r.origin.y + r.size.y
|
||||
}
|
||||
|
||||
pub(crate) fn transcript_hit(
|
||||
messages: &[ChatMessage],
|
||||
body_rect: Rect,
|
||||
x: f32,
|
||||
y: f32,
|
||||
locale: op_editor_core::Locale,
|
||||
) -> Option<TranscriptHit> {
|
||||
if !rect_contains(body_rect, x, y) {
|
||||
return None;
|
||||
}
|
||||
for item in build_transcript(messages, body_rect, locale) {
|
||||
if let Some(t) = &item.thinking {
|
||||
if rect_contains(t.header, x, y) {
|
||||
return Some(TranscriptHit::ToggleThinking(item.msg_index));
|
||||
}
|
||||
}
|
||||
if let Some(t) = &item.tools {
|
||||
if rect_contains(t.header, x, y) {
|
||||
return Some(TranscriptHit::ToggleToolCalls(item.msg_index));
|
||||
}
|
||||
for (tool_index, card) in t.cards.iter().enumerate() {
|
||||
if rect_contains(card.header, x, y) {
|
||||
return Some(TranscriptHit::SetToolCallCardExpanded(
|
||||
item.msg_index,
|
||||
tool_index,
|
||||
!card.expanded,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (block_index, block) in item.design_blocks.iter().enumerate() {
|
||||
if rect_contains(block.copy, x, y) {
|
||||
return Some(TranscriptHit::CopyDesignBlock(block.code.clone()));
|
||||
}
|
||||
if rect_contains(block.header, x, y) {
|
||||
return Some(TranscriptHit::SetDesignBlockExpanded(
|
||||
item.msg_index,
|
||||
block_index,
|
||||
!block.expanded,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn design_block_at(
|
||||
messages: &[ChatMessage],
|
||||
body_rect: Rect,
|
||||
x: f32,
|
||||
y: f32,
|
||||
locale: op_editor_core::Locale,
|
||||
) -> Option<(usize, usize)> {
|
||||
if !rect_contains(body_rect, x, y) {
|
||||
return None;
|
||||
}
|
||||
for item in build_transcript(messages, body_rect, locale) {
|
||||
for (block_index, block) in item.design_blocks.iter().enumerate() {
|
||||
if rect_contains(block.rect, x, y) {
|
||||
return Some((item.msg_index, block_index));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
|
@ -126,6 +126,7 @@ pub mod ai_chat_panel_controls;
|
|||
pub mod ai_chat_panel_paint;
|
||||
pub mod ai_chat_transcript;
|
||||
pub(crate) mod ai_chat_transcript_design;
|
||||
pub(crate) mod ai_chat_transcript_hit;
|
||||
pub(crate) mod ai_chat_transcript_steps;
|
||||
pub(crate) mod ai_chat_transcript_text;
|
||||
pub(crate) mod ai_chat_transcript_tools;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ mod agent_settings_draft_dispatch;
|
|||
#[cfg(test)]
|
||||
mod agent_settings_tests;
|
||||
mod ai_chat_geometry;
|
||||
mod chat_design_hover;
|
||||
#[cfg(test)]
|
||||
mod chat_design_hover_tests;
|
||||
mod chat_model_picker_caret;
|
||||
#[cfg(test)]
|
||||
mod chat_model_picker_caret_tests;
|
||||
|
|
|
|||
28
crates/op-host-native/src/widget_host/chat_design_hover.rs
Normal file
28
crates/op-host-native/src/widget_host/chat_design_hover.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
use super::WidgetHostNative;
|
||||
use op_editor_ui::widgets::AIChatPlaceholder;
|
||||
use op_editor_ui::Point2D;
|
||||
|
||||
impl WidgetHostNative {
|
||||
pub(in crate::widget_host) fn update_chat_design_hover(
|
||||
&mut self,
|
||||
x: f32,
|
||||
y: f32,
|
||||
over_topmost: bool,
|
||||
) -> bool {
|
||||
let new_hover = if !over_topmost {
|
||||
self.ai_chat_rect(self.last_viewport_w, self.last_viewport_h)
|
||||
.and_then(|chat_rect| {
|
||||
let panel = AIChatPlaceholder::from_editor_at(&self.editor_state, self.now_ms);
|
||||
panel.design_block_hover_at(chat_rect, Point2D::new(x, y))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if new_hover == self.editor_state.editor_ui.chat_design_block_hover {
|
||||
return false;
|
||||
}
|
||||
self.editor_state.editor_ui.chat_design_block_hover = new_hover;
|
||||
self.mark_dirty();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
use super::*;
|
||||
use op_editor_ui::widgets::AIChatPlaceholder;
|
||||
use op_editor_ui::Point2D;
|
||||
|
||||
#[test]
|
||||
fn cursor_move_tracks_hovered_design_json_card_for_copy_reveal() {
|
||||
let mut host = WidgetHostNative::new();
|
||||
host.editor_state_mut()
|
||||
.chat
|
||||
.messages
|
||||
.push(op_editor_core::ChatMessage::assistant(
|
||||
r#"```json
|
||||
[{"id":"frame-1","type":"Frame"}]
|
||||
```"#,
|
||||
));
|
||||
let viewport_w = 1440.0;
|
||||
let viewport_h = 900.0;
|
||||
host.last_viewport_w = viewport_w;
|
||||
host.last_viewport_h = viewport_h;
|
||||
let chat_rect = host.ai_chat_rect(viewport_w, viewport_h).unwrap();
|
||||
let panel = AIChatPlaceholder::from_editor(host.editor_state());
|
||||
let point = Point2D::new(chat_rect.origin.x + 24.0, chat_rect.origin.y + 52.0);
|
||||
assert_eq!(panel.design_block_hover_at(chat_rect, point), Some((0, 0)));
|
||||
|
||||
assert!(host.apply_cursor_move(point.x, point.y));
|
||||
|
||||
assert_eq!(
|
||||
host.editor_state().editor_ui.chat_design_block_hover,
|
||||
Some((0, 0))
|
||||
);
|
||||
}
|
||||
|
|
@ -73,6 +73,7 @@ impl WidgetHostNative {
|
|||
changed |= ui.toolbar_hover.take().is_some();
|
||||
changed |= ui.align_toolbar_hover.take().is_some();
|
||||
changed |= ui.chat_model_picker_hover.take().is_some();
|
||||
changed |= ui.chat_design_block_hover.take().is_some();
|
||||
changed |= ui.export_picker_hover.take().is_some();
|
||||
if let Some(menu) = ui.layer_context_menu.as_mut() {
|
||||
changed |= menu.hovered_row.take().is_some();
|
||||
|
|
|
|||
|
|
@ -314,6 +314,9 @@ impl WidgetHostNative {
|
|||
}
|
||||
}
|
||||
}
|
||||
if self.update_chat_design_hover(x, y, over_topmost) {
|
||||
return true;
|
||||
}
|
||||
if let Some(drag) = self.rotate_drag {
|
||||
let cursor_angle = (y - drag.center_screen_y).atan2(x - drag.center_screen_x);
|
||||
let new_rotation = drag.start_rotation + (cursor_angle - drag.start_cursor_angle);
|
||||
|
|
|
|||
Loading…
Reference in a new issue