fix(ai): add chat panel resize handles
This commit is contained in:
parent
31df6308d0
commit
6fc604a184
|
|
@ -285,6 +285,9 @@ impl ChatAnchor {
|
|||
}
|
||||
}
|
||||
|
||||
pub const DEFAULT_CHAT_PANEL_WIDTH: f32 = 360.0;
|
||||
pub const DEFAULT_CHAT_PANEL_HEIGHT: f32 = 400.0;
|
||||
|
||||
/// Floating AI chat panel state — mirrors shell-core's `ChatState`
|
||||
/// (messages, input draft, focused flag, panel anchor, model catalog).
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -294,6 +297,16 @@ pub struct ChatState {
|
|||
pub focused: bool,
|
||||
/// Which canvas corner the floating chat panel snaps to.
|
||||
pub anchor: ChatAnchor,
|
||||
/// Non-maximized panel width. TS persists this as
|
||||
/// `panelWidth` in the AI UI store.
|
||||
pub panel_width: f32,
|
||||
/// Non-maximized panel height. TS persists this as
|
||||
/// `panelHeight` in the AI UI store.
|
||||
pub panel_height: f32,
|
||||
/// Absolute top-left while the user has resized from an edge
|
||||
/// that moves the panel origin. `None` falls back to the snapped
|
||||
/// corner anchor.
|
||||
pub panel_position: Option<(f32, f32)>,
|
||||
/// Collapsed state — when true the panel paints only its header.
|
||||
pub collapsed: bool,
|
||||
/// Maximized state — when true the host lays the panel out across
|
||||
|
|
@ -370,6 +383,9 @@ impl Default for ChatState {
|
|||
input: String::new(),
|
||||
focused: false,
|
||||
anchor: ChatAnchor::BottomLeft,
|
||||
panel_width: DEFAULT_CHAT_PANEL_WIDTH,
|
||||
panel_height: DEFAULT_CHAT_PANEL_HEIGHT,
|
||||
panel_position: None,
|
||||
collapsed: false,
|
||||
maximized: false,
|
||||
checklist_collapsed: false,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ pub enum AIChatHit {
|
|||
/// Click landed on the header / margin — host should start a
|
||||
/// drag so the user can move the panel between canvas corners.
|
||||
DragHandle,
|
||||
/// Press landed on one of the invisible TS-style resize handles.
|
||||
Resize(ChatResizeEdge),
|
||||
/// Click on the chevron at the top-left of the header — host
|
||||
/// flips the `ChatState::collapsed` flag.
|
||||
ToggleCollapse,
|
||||
|
|
@ -66,6 +68,18 @@ pub enum AIChatHit {
|
|||
ToggleChecklist,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChatResizeEdge {
|
||||
N,
|
||||
S,
|
||||
E,
|
||||
W,
|
||||
Ne,
|
||||
Nw,
|
||||
Se,
|
||||
Sw,
|
||||
}
|
||||
|
||||
impl From<super::ai_chat_transcript::TranscriptHit> for AIChatHit {
|
||||
fn from(hit: super::ai_chat_transcript::TranscriptHit) -> Self {
|
||||
match hit {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use crate::theme::Theme;
|
|||
use crate::widgets::ai_chat_checklist::{
|
||||
fixed_checklist_height, fixed_checklist_rect, paint_fixed_checklist, HEADER_H, PROGRESS_H,
|
||||
};
|
||||
use crate::widgets::ai_chat_hit::AIChatHit;
|
||||
use crate::widgets::ai_chat_hit::{AIChatHit, ChatResizeEdge};
|
||||
use crate::widgets::ai_chat_panel_controls::{
|
||||
attachment_row_hit, paint_attachment_row, ATTACHMENT_ROW_HEIGHT,
|
||||
};
|
||||
|
|
@ -16,12 +16,17 @@ use crate::{Color, Point2D, Rect, TextLayout};
|
|||
use op_editor_core::chat::ChatState;
|
||||
use op_editor_core::EditorState;
|
||||
|
||||
pub const AI_CHAT_WIDTH: f32 = 360.0;
|
||||
pub const AI_CHAT_HEIGHT: f32 = 400.0;
|
||||
pub const AI_CHAT_WIDTH: f32 = op_editor_core::chat::DEFAULT_CHAT_PANEL_WIDTH;
|
||||
pub const AI_CHAT_HEIGHT: f32 = op_editor_core::chat::DEFAULT_CHAT_PANEL_HEIGHT;
|
||||
pub const AI_CHAT_MIN_WIDTH: f32 = 280.0;
|
||||
pub const AI_CHAT_MIN_HEIGHT: f32 = 250.0;
|
||||
pub const AI_CHAT_MAX_RATIO: f32 = 0.8;
|
||||
pub const AI_CHAT_COLLAPSED_WIDTH: f32 = 150.0;
|
||||
pub const AI_CHAT_COLLAPSED_HEIGHT: f32 = 36.0;
|
||||
pub(crate) const PAD: f32 = 16.0;
|
||||
pub(crate) const HEADER_HEIGHT: f32 = 36.0;
|
||||
const RESIZE_GUTTER: f32 = 4.0;
|
||||
const RESIZE_CORNER: f32 = 12.0;
|
||||
const INPUT_AREA_HEIGHT: f32 = 56.0;
|
||||
const INPUT_TOOLBAR_HEIGHT: f32 = 40.0;
|
||||
const MODEL_CHIP_W: f32 = 150.0;
|
||||
|
|
@ -194,6 +199,9 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
}
|
||||
|
||||
pub fn hit_test(&self, rect: Rect, point: Point2D) -> Option<AIChatHit> {
|
||||
if let Some(edge) = self.resize_edge_at(rect, point) {
|
||||
return Some(AIChatHit::Resize(edge));
|
||||
}
|
||||
if !rect_contains(rect, point) {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -362,6 +370,58 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
Some(AIChatHit::DragHandle)
|
||||
}
|
||||
|
||||
pub fn resize_edge_at(&self, rect: Rect, point: Point2D) -> Option<ChatResizeEdge> {
|
||||
if self.state.collapsed || self.state.maximized {
|
||||
return None;
|
||||
}
|
||||
let left = rect.origin.x;
|
||||
let right = rect.origin.x + rect.size.x;
|
||||
let top = rect.origin.y;
|
||||
let bottom = rect.origin.y + rect.size.y;
|
||||
let outer = Rect::xywh(
|
||||
left - RESIZE_GUTTER,
|
||||
top - RESIZE_GUTTER,
|
||||
rect.size.x + RESIZE_GUTTER * 2.0,
|
||||
rect.size.y + RESIZE_GUTTER * 2.0,
|
||||
);
|
||||
if !rect_contains(outer, point) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let near_top = (point.y - top).abs() <= RESIZE_GUTTER;
|
||||
let near_bottom = (point.y - bottom).abs() <= RESIZE_GUTTER;
|
||||
let near_left = (point.x - left).abs() <= RESIZE_GUTTER;
|
||||
let near_right = (point.x - right).abs() <= RESIZE_GUTTER;
|
||||
let in_left_corner = point.x <= left + RESIZE_CORNER;
|
||||
let in_right_corner = point.x >= right - RESIZE_CORNER;
|
||||
let in_top_corner = point.y <= top + RESIZE_CORNER;
|
||||
let in_bottom_corner = point.y >= bottom - RESIZE_CORNER;
|
||||
|
||||
match (
|
||||
near_top && in_left_corner,
|
||||
near_top && in_right_corner,
|
||||
near_bottom && in_left_corner,
|
||||
near_bottom && in_right_corner,
|
||||
) {
|
||||
(true, _, _, _) => return Some(ChatResizeEdge::Nw),
|
||||
(_, true, _, _) => return Some(ChatResizeEdge::Ne),
|
||||
(_, _, true, _) => return Some(ChatResizeEdge::Sw),
|
||||
(_, _, _, true) => return Some(ChatResizeEdge::Se),
|
||||
_ => {}
|
||||
}
|
||||
if near_top {
|
||||
Some(ChatResizeEdge::N)
|
||||
} else if near_bottom {
|
||||
Some(ChatResizeEdge::S)
|
||||
} else if near_left && !in_top_corner && !in_bottom_corner {
|
||||
Some(ChatResizeEdge::W)
|
||||
} else if near_right && !in_top_corner && !in_bottom_corner {
|
||||
Some(ChatResizeEdge::E)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn design_block_hover_at(&self, rect: Rect, point: Point2D) -> Option<(usize, usize)> {
|
||||
if self.state.messages.is_empty() {
|
||||
return None;
|
||||
|
|
@ -392,7 +452,7 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
|
|||
LayoutBox {
|
||||
rect: Rect {
|
||||
origin: Point2D::new(0.0, 0.0),
|
||||
size: Point2D::new(AI_CHAT_WIDTH, AI_CHAT_HEIGHT),
|
||||
size: Point2D::new(self.state.panel_width, self.state.panel_height),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -249,6 +249,42 @@ fn hit_test_header_returns_drag_handle() {
|
|||
assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::DragHandle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_edge_at_resolves_all_ts_handles_when_not_maximized() {
|
||||
let s = EditorState::new();
|
||||
let panel = AIChatPlaceholder::from_editor(&s);
|
||||
let rect = Rect::xywh(100.0, 80.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
|
||||
let mid_x = rect.origin.x + rect.size.x / 2.0;
|
||||
let mid_y = rect.origin.y + rect.size.y / 2.0;
|
||||
let right = rect.origin.x + rect.size.x;
|
||||
let bottom = rect.origin.y + rect.size.y;
|
||||
|
||||
let cases = [
|
||||
(Point2D::new(mid_x, rect.origin.y + 2.0), ChatResizeEdge::N),
|
||||
(Point2D::new(mid_x, bottom - 2.0), ChatResizeEdge::S),
|
||||
(Point2D::new(right - 2.0, mid_y), ChatResizeEdge::E),
|
||||
(Point2D::new(rect.origin.x + 2.0, mid_y), ChatResizeEdge::W),
|
||||
(
|
||||
Point2D::new(right - 2.0, rect.origin.y + 2.0),
|
||||
ChatResizeEdge::Ne,
|
||||
),
|
||||
(
|
||||
Point2D::new(rect.origin.x + 2.0, rect.origin.y + 2.0),
|
||||
ChatResizeEdge::Nw,
|
||||
),
|
||||
(Point2D::new(right - 2.0, bottom - 2.0), ChatResizeEdge::Se),
|
||||
(
|
||||
Point2D::new(rect.origin.x + 2.0, bottom - 2.0),
|
||||
ChatResizeEdge::Sw,
|
||||
),
|
||||
];
|
||||
|
||||
for (point, edge) in cases {
|
||||
assert_eq!(panel.resize_edge_at(rect, point), Some(edge));
|
||||
assert_eq!(panel.hit_test(rect, point), Some(AIChatHit::Resize(edge)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_resolves_header_maximize_button() {
|
||||
let s = EditorState::new();
|
||||
|
|
|
|||
|
|
@ -177,10 +177,10 @@ pub use canvas_viewport::{
|
|||
|
||||
pub use icons::{draw_icon, draw_icon_catalog_entry, draw_icon_data, Icon, IconPathData};
|
||||
|
||||
pub use ai_chat_hit::AIChatHit;
|
||||
pub use ai_chat_hit::{AIChatHit, ChatResizeEdge};
|
||||
pub use ai_chat_panel::{
|
||||
AIChatPlaceholder, AI_CHAT_COLLAPSED_HEIGHT, AI_CHAT_COLLAPSED_WIDTH, AI_CHAT_HEIGHT,
|
||||
AI_CHAT_WIDTH,
|
||||
AI_CHAT_MAX_RATIO, AI_CHAT_MIN_HEIGHT, AI_CHAT_MIN_WIDTH, AI_CHAT_WIDTH,
|
||||
};
|
||||
pub use align_toolbar::{AlignToolbar, AlignToolbarHit, ALIGN_TOOLBAR_HEIGHT, ALIGN_TOOLBAR_WIDTH};
|
||||
pub use component_browser_panel::{
|
||||
|
|
|
|||
|
|
@ -153,6 +153,9 @@ pub struct WidgetHostNative {
|
|||
/// host computes the nearest corner via `ChatAnchor::nearest`
|
||||
/// and snaps.
|
||||
pub(in crate::widget_host) chat_drag: Option<ChatDragState>,
|
||||
/// Active chat-panel resize — mirrors the TS panel's invisible
|
||||
/// edge/corner handles.
|
||||
pub(in crate::widget_host) chat_resize: Option<ChatResizeState>,
|
||||
/// Active Design-MD panel drag — present while the user drags the
|
||||
/// floating panel by its header bar. The live top-left is written
|
||||
/// straight back into `editor_ui.design_md_panel_pos`.
|
||||
|
|
@ -404,6 +407,14 @@ pub(in crate::widget_host) struct ChatDragState {
|
|||
pub(in crate::widget_host) pos_y: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(in crate::widget_host) struct ChatResizeState {
|
||||
pub(in crate::widget_host) edge: op_editor_ui::widgets::ChatResizeEdge,
|
||||
pub(in crate::widget_host) start_x: f32,
|
||||
pub(in crate::widget_host) start_y: f32,
|
||||
pub(in crate::widget_host) start_rect: Rect,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(in crate::widget_host) struct DesignMdDragState {
|
||||
/// Pointer offset within the panel rect when the drag began —
|
||||
|
|
@ -439,6 +450,7 @@ impl WidgetHostNative {
|
|||
theme: Theme::dark(),
|
||||
drag: None,
|
||||
chat_drag: None,
|
||||
chat_resize: None,
|
||||
design_md_drag: None,
|
||||
component_browser_drag: None,
|
||||
icon_picker_drag: None,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use super::helpers::{AICHAT_INSET_BOTTOM, AICHAT_INSET_LEFT};
|
|||
use super::WidgetHostNative;
|
||||
use op_editor_core::ChatAnchor;
|
||||
use op_editor_ui::widgets::{
|
||||
AI_CHAT_COLLAPSED_HEIGHT, AI_CHAT_COLLAPSED_WIDTH, AI_CHAT_HEIGHT, AI_CHAT_WIDTH,
|
||||
AI_CHAT_COLLAPSED_HEIGHT, AI_CHAT_COLLAPSED_WIDTH, AI_CHAT_MIN_HEIGHT, AI_CHAT_MIN_WIDTH,
|
||||
};
|
||||
use op_editor_ui::{Point2D, Rect};
|
||||
|
||||
|
|
@ -13,7 +13,10 @@ impl WidgetHostNative {
|
|||
if self.editor_state.chat.collapsed {
|
||||
(AI_CHAT_COLLAPSED_WIDTH, AI_CHAT_COLLAPSED_HEIGHT)
|
||||
} else {
|
||||
(AI_CHAT_WIDTH, AI_CHAT_HEIGHT)
|
||||
(
|
||||
self.editor_state.chat.panel_width.max(AI_CHAT_MIN_WIDTH),
|
||||
self.editor_state.chat.panel_height.max(AI_CHAT_MIN_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,6 +46,12 @@ impl WidgetHostNative {
|
|||
size: Point2D::new(panel_w, panel_h),
|
||||
});
|
||||
}
|
||||
if let Some((x, y)) = self.editor_state.chat.panel_position {
|
||||
return Some(Rect {
|
||||
origin: Point2D::new(x, y),
|
||||
size: Point2D::new(panel_w, panel_h),
|
||||
});
|
||||
}
|
||||
let (x, y) = match self.editor_state.chat.anchor {
|
||||
ChatAnchor::TopLeft => (cx0 + AICHAT_INSET_LEFT, cy0 + AICHAT_INSET_BOTTOM),
|
||||
ChatAnchor::TopRight => (
|
||||
|
|
|
|||
|
|
@ -162,6 +162,10 @@ impl WidgetHostNative {
|
|||
// this; reaching here is a path bypass.
|
||||
return false;
|
||||
}
|
||||
AIChatHit::Resize(_) => {
|
||||
// Resize handles are press-drag only.
|
||||
return false;
|
||||
}
|
||||
AIChatHit::ToggleCollapse => {
|
||||
self.editor_state.chat.collapsed = !self.editor_state.chat.collapsed;
|
||||
self.mark_dirty();
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
use super::helpers::{rect_contains, PANEL_RESIZE_GUTTER, STATUS_INSET};
|
||||
use super::{CursorHint, PanelResizeKind, WidgetHostNative};
|
||||
use op_editor_ui::widgets::{
|
||||
rotation_corner_at_point, selection_handle_at_point, STATUS_BAR_HEIGHT, STATUS_BAR_WIDTH,
|
||||
TOP_BAR_HEIGHT,
|
||||
rotation_corner_at_point, selection_handle_at_point, AIChatPlaceholder, ChatResizeEdge,
|
||||
STATUS_BAR_HEIGHT, STATUS_BAR_WIDTH, TOP_BAR_HEIGHT,
|
||||
};
|
||||
use op_editor_ui::{Point2D, Rect};
|
||||
|
||||
|
|
@ -58,6 +58,27 @@ impl WidgetHostNative {
|
|||
self.panel_resize.is_some()
|
||||
}
|
||||
|
||||
pub fn chat_resize_hover(
|
||||
&self,
|
||||
x: f32,
|
||||
y: f32,
|
||||
viewport_w: f32,
|
||||
viewport_h: f32,
|
||||
) -> Option<ChatResizeEdge> {
|
||||
let rect = self.ai_chat_rect(viewport_w, viewport_h)?;
|
||||
let panel = AIChatPlaceholder::from_editor(&self.editor_state);
|
||||
panel.resize_edge_at(rect, Point2D::new(x, y))
|
||||
}
|
||||
|
||||
fn chat_resize_cursor(edge: ChatResizeEdge) -> CursorHint {
|
||||
match edge {
|
||||
ChatResizeEdge::E | ChatResizeEdge::W => CursorHint::ResizeEw,
|
||||
ChatResizeEdge::N | ChatResizeEdge::S => CursorHint::ResizeNs,
|
||||
ChatResizeEdge::Nw | ChatResizeEdge::Se => CursorHint::ResizeNwse,
|
||||
ChatResizeEdge::Ne | ChatResizeEdge::Sw => CursorHint::ResizeNesw,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear every lower-overlay hover highlight — file menu, locale
|
||||
/// / shape pickers, align toolbar, layer-context menu. Called
|
||||
/// when the cursor moves over the top-most Design-MD panel so a
|
||||
|
|
@ -356,6 +377,12 @@ impl WidgetHostNative {
|
|||
if self.over_disabled_init_card(x, y, viewport_w, viewport_h) {
|
||||
return CursorHint::NotAllowed;
|
||||
}
|
||||
if let Some(resize) = self.chat_resize {
|
||||
return Self::chat_resize_cursor(resize.edge);
|
||||
}
|
||||
if let Some(edge) = self.chat_resize_hover(x, y, viewport_w, viewport_h) {
|
||||
return Self::chat_resize_cursor(edge);
|
||||
}
|
||||
// Any floating overlay (panels, Git popover, Toolbar /
|
||||
// StatusBar / chat, open dropdowns) — a neutral cursor over
|
||||
// them, never a canvas action cursor (Move / Crosshair)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
use super::helpers::{resize_bounds, PANEL_MAX_WIDTH, PANEL_MIN_WIDTH};
|
||||
use super::{PanelResizeKind, WidgetHostNative};
|
||||
use op_editor_ui::widgets::{
|
||||
ChatResizeEdge, AI_CHAT_MAX_RATIO, AI_CHAT_MIN_HEIGHT, AI_CHAT_MIN_WIDTH,
|
||||
};
|
||||
use op_editor_ui::{Point2D, Rect};
|
||||
|
||||
/// Minimum cursor travel (logical px) from the node-drag press point
|
||||
|
|
@ -536,6 +539,95 @@ impl WidgetHostNative {
|
|||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
if let Some(resize) = self.chat_resize {
|
||||
let (cx0, cy0, cw, ch) = self.canvas_region(self.last_viewport_w, self.last_viewport_h);
|
||||
let max_w = (cw * AI_CHAT_MAX_RATIO).max(AI_CHAT_MIN_WIDTH);
|
||||
let max_h = (ch * AI_CHAT_MAX_RATIO).max(AI_CHAT_MIN_HEIGHT);
|
||||
let dx = x - resize.start_x;
|
||||
let dy = y - resize.start_y;
|
||||
let mut new_w = resize.start_rect.size.x;
|
||||
let mut new_h = resize.start_rect.size.y;
|
||||
let mut new_left = resize.start_rect.origin.x;
|
||||
let mut new_top = resize.start_rect.origin.y;
|
||||
|
||||
if matches!(
|
||||
resize.edge,
|
||||
ChatResizeEdge::E | ChatResizeEdge::Ne | ChatResizeEdge::Se
|
||||
) {
|
||||
new_w = resize.start_rect.size.x + dx;
|
||||
}
|
||||
if matches!(
|
||||
resize.edge,
|
||||
ChatResizeEdge::W | ChatResizeEdge::Nw | ChatResizeEdge::Sw
|
||||
) {
|
||||
new_w = resize.start_rect.size.x - dx;
|
||||
new_left = resize.start_rect.origin.x + dx;
|
||||
}
|
||||
if matches!(
|
||||
resize.edge,
|
||||
ChatResizeEdge::S | ChatResizeEdge::Se | ChatResizeEdge::Sw
|
||||
) {
|
||||
new_h = resize.start_rect.size.y + dy;
|
||||
}
|
||||
if matches!(
|
||||
resize.edge,
|
||||
ChatResizeEdge::N | ChatResizeEdge::Ne | ChatResizeEdge::Nw
|
||||
) {
|
||||
new_h = resize.start_rect.size.y - dy;
|
||||
new_top = resize.start_rect.origin.y + dy;
|
||||
}
|
||||
|
||||
if new_w < AI_CHAT_MIN_WIDTH {
|
||||
let diff = AI_CHAT_MIN_WIDTH - new_w;
|
||||
new_w = AI_CHAT_MIN_WIDTH;
|
||||
if matches!(
|
||||
resize.edge,
|
||||
ChatResizeEdge::W | ChatResizeEdge::Nw | ChatResizeEdge::Sw
|
||||
) {
|
||||
new_left -= diff;
|
||||
}
|
||||
}
|
||||
if new_w > max_w {
|
||||
let diff = new_w - max_w;
|
||||
new_w = max_w;
|
||||
if matches!(
|
||||
resize.edge,
|
||||
ChatResizeEdge::W | ChatResizeEdge::Nw | ChatResizeEdge::Sw
|
||||
) {
|
||||
new_left += diff;
|
||||
}
|
||||
}
|
||||
if new_h < AI_CHAT_MIN_HEIGHT {
|
||||
let diff = AI_CHAT_MIN_HEIGHT - new_h;
|
||||
new_h = AI_CHAT_MIN_HEIGHT;
|
||||
if matches!(
|
||||
resize.edge,
|
||||
ChatResizeEdge::N | ChatResizeEdge::Ne | ChatResizeEdge::Nw
|
||||
) {
|
||||
new_top -= diff;
|
||||
}
|
||||
}
|
||||
if new_h > max_h {
|
||||
let diff = new_h - max_h;
|
||||
new_h = max_h;
|
||||
if matches!(
|
||||
resize.edge,
|
||||
ChatResizeEdge::N | ChatResizeEdge::Ne | ChatResizeEdge::Nw
|
||||
) {
|
||||
new_top += diff;
|
||||
}
|
||||
}
|
||||
|
||||
let max_left = cx0 + cw - new_w;
|
||||
let max_top = cy0 + ch - new_h;
|
||||
new_left = new_left.clamp(cx0, max_left.max(cx0));
|
||||
new_top = new_top.clamp(cy0, max_top.max(cy0));
|
||||
self.editor_state.chat.panel_width = new_w.round();
|
||||
self.editor_state.chat.panel_height = new_h.round();
|
||||
self.editor_state.chat.panel_position = Some((new_left.round(), new_top.round()));
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
if let Some(d) = self.chat_drag.as_mut() {
|
||||
d.pos_x = x - d.grab_dx;
|
||||
d.pos_y = y - d.grab_dy;
|
||||
|
|
@ -584,6 +676,9 @@ impl WidgetHostNative {
|
|||
if self.panel_resize.take().is_some() {
|
||||
return true;
|
||||
}
|
||||
if self.chat_resize.take().is_some() {
|
||||
return true;
|
||||
}
|
||||
if self.rotate_drag.take().is_some() {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -644,6 +739,7 @@ impl WidgetHostNative {
|
|||
let (cx0, cy0, cw, ch) = self.canvas_region(viewport_w, viewport_h);
|
||||
self.editor_state.chat.anchor =
|
||||
op_editor_core::ChatAnchor::nearest(center, cx0, cy0, cw, ch);
|
||||
self.editor_state.chat.panel_position = None;
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
|
|
@ -657,6 +753,9 @@ impl WidgetHostNative {
|
|||
if self.panel_resize.take().is_some() {
|
||||
return true;
|
||||
}
|
||||
if self.chat_resize.take().is_some() {
|
||||
return true;
|
||||
}
|
||||
if self.rotate_drag.take().is_some() {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
//! `host.editor_state` from canonical-schema JSON and assert against
|
||||
//! `editor_state` + the derived `LayoutScene` render scene.
|
||||
|
||||
use super::WidgetHostNative;
|
||||
use super::{CursorHint, WidgetHostNative};
|
||||
use op_editor_core::ui_draft::PropertyFocus;
|
||||
use op_editor_core::NodeId;
|
||||
use op_editor_core::PenNodeExt;
|
||||
|
|
@ -76,6 +76,40 @@ fn ai_chat_new_chat_click_clears_transcript_and_queues_abort() {
|
|||
assert!(host.editor_state().chat.pending_new_chat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_chat_east_edge_shows_resize_cursor() {
|
||||
let host = WidgetHostNative::new();
|
||||
let rect = host
|
||||
.ai_chat_rect(1200.0, 800.0)
|
||||
.expect("chat panel visible");
|
||||
let x = rect.origin.x + rect.size.x - 2.0;
|
||||
let y = rect.origin.y + rect.size.y / 2.0;
|
||||
|
||||
assert_eq!(host.cursor_hint(x, y, 1200.0, 800.0), CursorHint::ResizeEw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_chat_east_edge_drag_resizes_panel_width() {
|
||||
let mut host = WidgetHostNative::new();
|
||||
let before = host
|
||||
.ai_chat_rect(1200.0, 800.0)
|
||||
.expect("chat panel visible");
|
||||
let x = before.origin.x + before.size.x - 2.0;
|
||||
let y = before.origin.y + before.size.y / 2.0;
|
||||
|
||||
assert!(host.apply_press(x, y, 1200.0, 800.0));
|
||||
assert!(host.apply_cursor_move(x + 72.0, y));
|
||||
|
||||
let after = host
|
||||
.ai_chat_rect(1200.0, 800.0)
|
||||
.expect("chat panel visible after resize");
|
||||
assert!(
|
||||
after.size.x > before.size.x + 60.0,
|
||||
"dragging the east edge should grow chat width; before={before:?}, after={after:?}"
|
||||
);
|
||||
assert_eq!(after.origin.x, before.origin.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_chat_stop_click_keeps_transcript_and_queues_abort() {
|
||||
let mut host = WidgetHostNative::new();
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
|
||||
use super::helpers::{rect_contains, TOOLBAR_INSET_X, TOOLBAR_INSET_Y};
|
||||
use super::{
|
||||
ChatDragState, CreateDragState, DragState, HandleDragState, NodeDragState, PanelResize,
|
||||
PanelResizeKind, RotateDragState, WidgetHostNative,
|
||||
ChatDragState, ChatResizeState, CreateDragState, DragState, HandleDragState, NodeDragState,
|
||||
PanelResize, PanelResizeKind, RotateDragState, WidgetHostNative,
|
||||
};
|
||||
use op_editor_ui::widgets::{
|
||||
rotation_corner_at_point, selection_handle_at_point, AIChatHit, AIChatPlaceholder, LayoutCx,
|
||||
|
|
@ -530,6 +530,17 @@ impl WidgetHostNative {
|
|||
if let Some(chat_rect) = self.ai_chat_rect(viewport_width, viewport_height) {
|
||||
let panel = AIChatPlaceholder::from_editor(&self.editor_state);
|
||||
if let Some(hit) = panel.hit_test(chat_rect, Point2D::new(x, y)) {
|
||||
if let AIChatHit::Resize(edge) = hit {
|
||||
self.chat_resize = Some(ChatResizeState {
|
||||
edge,
|
||||
start_x: x,
|
||||
start_y: y,
|
||||
start_rect: chat_rect,
|
||||
});
|
||||
self.editor_state.chat.focused = false;
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
if matches!(hit, AIChatHit::DragHandle) {
|
||||
self.chat_drag = Some(ChatDragState {
|
||||
grab_dx: x - chat_rect.origin.x,
|
||||
|
|
|
|||
|
|
@ -405,6 +405,9 @@ impl WidgetHost {
|
|||
if let Some(chat_rect) = self.ai_chat_rect(viewport_width, viewport_height) {
|
||||
let panel = AIChatPlaceholder::from_editor_at(&self.editor_state, self.now_ms);
|
||||
if let Some(hit) = panel.hit_test(chat_rect, Point2D::new(x, y)) {
|
||||
if matches!(hit, AIChatHit::Resize(_)) {
|
||||
return true;
|
||||
}
|
||||
if matches!(hit, AIChatHit::DragHandle) {
|
||||
self.chat_drag = Some(ChatDragState {
|
||||
grab_dx: x - chat_rect.origin.x,
|
||||
|
|
@ -627,6 +630,9 @@ impl WidgetHost {
|
|||
AIChatHit::DragHandle => {
|
||||
return false;
|
||||
}
|
||||
AIChatHit::Resize(_) => {
|
||||
return false;
|
||||
}
|
||||
AIChatHit::ToggleCollapse => {
|
||||
let v = &mut self.editor_state.chat.collapsed;
|
||||
*v = !*v;
|
||||
|
|
|
|||
Loading…
Reference in a new issue