openpencil/crates/op-host-native/src/widget_host/geometry.rs

797 lines
32 KiB
Rust

//! Geometry + cursor-affordance methods on `WidgetHostNative`.
//! Pure-math helpers that map (viewport_w, viewport_h, x, y) into
//! the rects + cursor hints the host serves.
//!
//! Scalar / chrome reads go straight to `editor_state`; node-tree
//! hit-tests run against the layout-resolved `LayoutScene`. The
//! input-dispatch contract keeps `layout_scene` fresh before any
//! hit-testing input event (see `widget_host.rs`).
use super::helpers::{
rect_contains, PANEL_RESIZE_GUTTER, STATUS_INSET, TOOLBAR_INSET_X, TOOLBAR_INSET_Y,
};
use super::{CursorHint, PanelResizeKind, WidgetHostNative};
use op_editor_ui::widgets::{
rotation_corner_at_point, selection_handle_at_point, GitPanel, LayoutCx, LocalePicker,
ShapePicker, Toolbar, TopBar, Widget, GIT_PANEL_INSET, ICON_PICKER_PANEL_H,
ICON_PICKER_PANEL_W, LOCALE_PICKER_WIDTH, SHAPE_PICKER_WIDTH, STATUS_BAR_HEIGHT,
STATUS_BAR_WIDTH, TOOLBAR_WIDTH, TOP_BAR_HEIGHT,
};
use op_editor_ui::{Point2D, Rect};
impl WidgetHostNative {
/// Hit-test which screen region the cursor is over. Used by
/// the wheel + drag handlers so wheel-zoom + Hand-pan only
/// fire when the cursor is over the canvas (not over a panel).
pub(in crate::widget_host) fn over_canvas(
&self,
x: f32,
y: f32,
viewport_w: f32,
viewport_h: f32,
) -> bool {
let (cx0, cy0, cw, ch) = self.canvas_region(viewport_w, viewport_h);
x >= cx0 && x <= cx0 + cw && y >= cy0 && y <= cy0 + ch
}
/// True when the cursor is over either resize gutter — used by
/// the runner to set `CursorIcon::EwResize`. None = no gutter.
pub fn panel_resize_hover(&self, x: f32, y: f32, viewport_w: f32) -> Option<PanelResizeKind> {
if y < TOP_BAR_HEIGHT {
return None;
}
if self.editor_state.editor_ui.sidebar_open {
let edge = self.editor_state.editor_ui.layer_panel_width;
if (x - edge).abs() <= PANEL_RESIZE_GUTTER {
return Some(PanelResizeKind::LayerRight);
}
}
if self.editor_state.property_panel_visible() {
let edge = viewport_w - self.editor_state.editor_ui.property_panel_width;
if (x - edge).abs() <= PANEL_RESIZE_GUTTER {
return Some(PanelResizeKind::PropertyLeft);
}
}
None
}
/// Whether a panel resize is in progress. Runner uses this to
/// keep the resize cursor active even when the cursor briefly
/// leaves the gutter mid-drag.
pub fn is_resizing_panel(&self) -> bool {
self.panel_resize.is_some()
}
/// 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
/// highlight set just before does not linger beneath it. Returns
/// `true` if anything changed.
pub(in crate::widget_host) fn clear_lower_overlay_hover(&mut self) -> bool {
let mut changed = false;
{
let ui = &mut self.editor_state.editor_ui;
changed |= ui.file_menu_hover.take().is_some();
changed |= ui.locale_picker_hover.take().is_some();
changed |= ui.shape_picker_hover.take().is_some();
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.export_picker_hover.take().is_some();
if let Some(menu) = ui.layer_context_menu.as_mut() {
changed |= menu.hovered_row.take().is_some();
}
}
if changed {
self.mark_dirty();
}
changed
}
/// Update the file-menu / locale-picker / shape-picker dropdown
/// hover highlights from the cursor. At most one of the three is
/// open at a time. `over_topmost` suppresses updates when a
/// floating panel covers the chrome. Returns `true` on change.
pub(in crate::widget_host) fn update_dropdown_hover(
&mut self,
x: f32,
y: f32,
over_topmost: bool,
) -> bool {
use op_editor_ui::{Point2D, Rect};
if over_topmost {
return false;
}
if self.editor_state.editor_ui.file_menu_open {
use op_editor_ui::widgets::file_menu::FileMenu;
use op_editor_ui::widgets::top_bar::TopBar;
self.refresh_layout_scene();
let top_bar_rect = Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(self.last_viewport_w, op_editor_ui::widgets::TOP_BAR_HEIGHT),
};
let anchor =
TopBar::file_menu_rect(top_bar_rect, self.editor_state.editor_ui.window_fullscreen);
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let menu = FileMenu::from_editor_ui(&self.editor_state.editor_ui, now_secs);
let panel = menu.rect_at(anchor);
let new_hover = menu
.hovered_at(panel, Point2D::new(x, y))
.map(op_editor_ui::widgets::editor_state_ext::file_menu_choice);
if new_hover != self.editor_state.editor_ui.file_menu_hover {
self.editor_state.editor_ui.file_menu_hover = new_hover;
self.mark_dirty();
return true;
}
}
if self.editor_state.editor_ui.locale_picker_open {
use op_editor_ui::widgets::locale_picker::LocalePicker;
self.refresh_layout_scene();
let panel = self.locale_picker_rect(self.last_viewport_w);
let picker = LocalePicker::for_editor_ui(&self.editor_state.editor_ui);
let new_hover = picker.hit_test(panel, Point2D::new(x, y));
if new_hover != self.editor_state.editor_ui.locale_picker_hover {
self.editor_state.editor_ui.locale_picker_hover = new_hover;
self.mark_dirty();
return true;
}
}
if self.editor_state.editor_ui.shape_picker_open {
use op_editor_ui::widgets::shape_picker::ShapePicker;
self.refresh_layout_scene();
let panel = self.shape_picker_rect(self.last_viewport_w, self.last_viewport_h);
let picker = ShapePicker::for_editor_ui(&self.editor_state.editor_ui);
let new_hover = picker
.hit_test(panel, Point2D::new(x, y))
.map(op_editor_ui::widgets::editor_state_ext::shape_choice);
if new_hover != self.editor_state.editor_ui.shape_picker_hover {
self.editor_state.editor_ui.shape_picker_hover = new_hover;
self.mark_dirty();
return true;
}
}
false
}
/// Update the Export-section select-popup row hover from the
/// current cursor position. A no-op (returns `false`) when no
/// export popup is open. Returns `true` if the hover changed.
pub(in crate::widget_host) fn update_export_picker_hover(
&mut self,
x: f32,
y: f32,
viewport_w: f32,
viewport_h: f32,
) -> bool {
use op_editor_ui::widgets::{PropertyPanel, TOP_BAR_HEIGHT};
use op_editor_ui::{Point2D, Rect};
if !self.editor_state.editor_ui.export_scale_picker_open
&& !self.editor_state.editor_ui.export_format_picker_open
{
return false;
}
self.refresh_layout_scene();
let Some(panel) = PropertyPanel::for_selection(&self.editor_state) else {
return false;
};
let property_rect = Rect {
origin: Point2D::new(
viewport_w - self.editor_state.editor_ui.property_panel_width,
TOP_BAR_HEIGHT,
),
size: Point2D::new(
self.editor_state.editor_ui.property_panel_width,
(viewport_h - TOP_BAR_HEIGHT).max(0.0),
),
};
let new_hover = panel.export_picker_row_at(property_rect, Point2D::new(x, y));
if new_hover != self.editor_state.editor_ui.export_picker_hover {
self.editor_state.editor_ui.export_picker_hover = new_hover;
self.mark_dirty();
true
} else {
false
}
}
/// Update the layer-panel hover id from the current cursor
/// position. Returns `true` if the hover state changed.
pub fn update_layer_hover(&mut self, x: f32, y: f32, viewport_w: f32, viewport_h: f32) -> bool {
use op_editor_ui::widgets::{LayerPanel, LayerPanelHit};
self.refresh_layout_scene();
let sidebar_open = self.editor_state.editor_ui.sidebar_open;
let panel_w = self.editor_state.editor_ui.layer_panel_width;
// A top-most floating panel covers the layer rail when dragged
// over it — no row highlights underneath it.
let over_topmost = self.over_topmost_panel(x, y, viewport_w, viewport_h);
let (new_layer, new_page) =
if sidebar_open && !over_topmost && y >= TOP_BAR_HEIGHT && x >= 0.0 && x <= panel_w {
let layer_rect = Rect {
origin: Point2D::new(0.0, TOP_BAR_HEIGHT),
size: Point2D::new(panel_w, (viewport_h - TOP_BAR_HEIGHT).max(0.0)),
};
let panel = LayerPanel::from_editor(&self.editor_state);
match panel.hit_test(layer_rect, Point2D::new(x, y)) {
Some(LayerPanelHit::Layer(id))
| Some(LayerPanelHit::ToggleHidden(id))
| Some(LayerPanelHit::ToggleLocked(id))
| Some(LayerPanelHit::ToggleCollapsed(id)) => (Some(id), None),
Some(LayerPanelHit::Page(idx)) | Some(LayerPanelHit::DeletePage(idx)) => {
(None, Some(idx))
}
_ => (None, None),
}
} else {
(None, None)
};
// shell-core hit-test returns shell-core `NodeId`s; translate
// to op-editor-core ids for storage on `editor_ui`.
let new_layer_ec = new_layer.clone();
let changed = new_layer_ec != self.editor_state.editor_ui.hovered_layer_id
|| new_page != self.editor_state.editor_ui.hovered_page_index;
if changed {
self.editor_state.editor_ui.hovered_layer_id = new_layer_ec;
self.editor_state.editor_ui.hovered_page_index = new_page;
self.mark_dirty();
}
changed
}
/// True when the cursor is over a draggable node inside the
/// canvas region (used by the runner to flip cursor → Move).
pub fn cursor_over_node(&self, x: f32, y: f32, viewport_w: f32, viewport_h: f32) -> bool {
if matches!(self.editor_state.tool, op_editor_core::Tool::Hand) {
return false;
}
if !self.over_canvas(x, y, viewport_w, viewport_h) {
return false;
}
let (cx0, cy0, _cw, _ch) = self.canvas_region(viewport_w, viewport_h);
let canvas_local = Point2D::new(x - cx0, y - cy0);
let doc_point = self.editor_state.viewport.to_document(canvas_local);
let zoom = self.editor_state.viewport.zoom;
self.layout_scene
.node_at_doc_point(doc_point, zoom)
.is_some()
}
/// True while a node-drag is in flight.
pub fn is_dragging_node(&self) -> bool {
self.node_drag.is_some()
}
pub fn update_agent_settings_hover(&mut self, x: f32, y: f32) -> bool {
use op_editor_core::AgentSettingsTab;
use op_editor_ui::widgets::agent_settings_panel::AgentSettingsPanel;
self.refresh_layout_scene();
let point = Point2D::new(x, y);
let (new_nav, new_card, new_builtin) = {
let panel = AgentSettingsPanel::for_editor(&self.editor_state);
let panel_rect = panel.rect(self.last_viewport_w, self.last_viewport_h);
let nav = panel.nav_at(panel_rect, point);
let is_agents = matches!(
self.editor_state.editor_ui.agent_settings.tab,
AgentSettingsTab::Agents
);
let card = if is_agents {
panel.card_at(panel_rect, point).unwrap_or(usize::MAX)
} else {
usize::MAX
};
let builtin = if is_agents {
panel
.builtin_card_at(panel_rect, point)
.unwrap_or(usize::MAX)
} else {
usize::MAX
};
(nav, card, builtin)
};
let mut changed = false;
if new_nav != self.editor_state.editor_ui.agent_settings.hover_nav {
self.editor_state.editor_ui.agent_settings.hover_nav = new_nav;
changed = true;
}
if new_card != self.editor_state.editor_ui.agent_settings.hover_provider {
self.editor_state.editor_ui.agent_settings.hover_provider = new_card;
changed = true;
}
if new_builtin
!= self
.editor_state
.editor_ui
.agent_settings
.hover_builtin_agent
{
self.editor_state
.editor_ui
.agent_settings
.hover_builtin_agent = new_builtin;
changed = true;
}
if changed {
self.mark_dirty();
}
changed
}
pub fn cursor_hint(&self, x: f32, y: f32, viewport_w: f32, viewport_h: f32) -> CursorHint {
use op_editor_core::Tool;
// Modal overlays — keep the pointer the OS default.
if self.editor_state.editor_ui.agent_settings_open
|| self.editor_state.ui.color_picker.is_some()
{
return CursorHint::Default;
}
// Any top-most floating panel — a neutral cursor over them,
// never a canvas action cursor bleeding through.
if self.over_topmost_panel(x, y, viewport_w, viewport_h) {
return CursorHint::Default;
}
// The floating Git panel paints on top of the right-rail
// resize gutter (and in diff mode is wide enough to cover
// it), so don't show the resize cursor over the panel.
let over_git_panel = self
.git_panel_rect(viewport_w, viewport_h)
.is_some_and(|r| rect_contains(r, Point2D::new(x, y)));
if self.is_resizing_panel()
|| (!over_git_panel && self.panel_resize_hover(x, y, viewport_w).is_some())
{
return CursorHint::ResizeEw;
}
if self.is_dragging_node() {
return CursorHint::Grabbing;
}
if self.rotate_drag.is_some() {
return CursorHint::Rotate;
}
if let Some(handle) = self.handle_drag.map(|d| d.handle) {
return CursorHint::for_handle(handle);
}
if !self.over_canvas(x, y, viewport_w, viewport_h) {
return CursorHint::Default;
}
match self.editor_state.tool {
Tool::Hand => CursorHint::Grab,
Tool::Rect | Tool::Ellipse | Tool::Polygon | Tool::Line | Tool::Pen | Tool::Frame => {
CursorHint::Crosshair
}
Tool::Text => CursorHint::Text,
Tool::Select => {
let (cx0, cy0, cw, ch) = self.canvas_region(viewport_w, viewport_h);
let canvas_rect = Rect {
origin: Point2D::new(cx0, cy0),
size: Point2D::new(cw, ch),
};
let point = Point2D::new(x, y);
if let Some(handle) = selection_handle_at_point(
canvas_rect,
&self.layout_scene,
&self.editor_state,
point,
) {
return CursorHint::for_handle(handle);
}
if rotation_corner_at_point(
canvas_rect,
&self.layout_scene,
&self.editor_state,
point,
)
.is_some()
{
return CursorHint::Rotate;
}
let canvas_local = Point2D::new(x - cx0, y - cy0);
let doc_point = self.editor_state.viewport.to_document(canvas_local);
let zoom = self.editor_state.viewport.zoom;
if self
.layout_scene
.node_at_doc_point(doc_point, zoom)
.is_some()
{
return CursorHint::Move;
}
CursorHint::Default
}
}
}
/// Canvas origin (logical px).
pub(in crate::widget_host) fn canvas_origin(&self) -> (f32, f32) {
let cx0 = if self.editor_state.editor_ui.sidebar_open {
self.editor_state.editor_ui.layer_panel_width
} else {
0.0
};
(cx0, TOP_BAR_HEIGHT)
}
/// Canvas region (logical px, viewport-relative).
pub(in crate::widget_host) fn canvas_region(
&self,
viewport_w: f32,
viewport_h: f32,
) -> (f32, f32, f32, f32) {
let canvas_left = if self.editor_state.editor_ui.sidebar_open {
self.editor_state.editor_ui.layer_panel_width
} else {
0.0
};
let rail_occupied = self.editor_state.right_rail_visible();
let canvas_right = if rail_occupied {
viewport_w - self.editor_state.editor_ui.property_panel_width
} else {
viewport_w
};
let canvas_w = (canvas_right - canvas_left).max(0.0);
let canvas_h = (viewport_h - TOP_BAR_HEIGHT).max(0.0);
(canvas_left, TOP_BAR_HEIGHT, canvas_w, canvas_h)
}
/// Bottom-right floating StatusBar rect — mirrors the placement in
/// `widget_host/paint.rs` §8. `None` when the canvas is too narrow
/// to float the pill (matching the paint guard).
pub(in crate::widget_host) fn status_bar_rect(
&self,
viewport_w: f32,
viewport_h: f32,
) -> Option<Rect> {
let (canvas_left, _top, canvas_w, canvas_h) = self.canvas_region(viewport_w, viewport_h);
if canvas_w <= STATUS_BAR_WIDTH + STATUS_INSET * 2.0 {
return None;
}
let canvas_right = canvas_left + canvas_w;
Some(Rect {
origin: Point2D::new(
canvas_right - STATUS_BAR_WIDTH - STATUS_INSET,
TOP_BAR_HEIGHT + canvas_h - STATUS_BAR_HEIGHT - STATUS_INSET,
),
size: Point2D::new(STATUS_BAR_WIDTH, STATUS_BAR_HEIGHT),
})
}
/// `true` when `(x, y)` is over the StatusBar's search icon — the
/// left section of the pill before the `[- N% +]` zoom cluster.
/// `SIDE_PAD(14) + ICON(14) + half SECTION_GAP(9) ≈ 37 px`; 38 is
/// a generous target that stops short of the minus button.
pub(in crate::widget_host) fn status_bar_search_hit(
&self,
x: f32,
y: f32,
viewport_w: f32,
viewport_h: f32,
) -> bool {
let Some(r) = self.status_bar_rect(viewport_w, viewport_h) else {
return false;
};
const SEARCH_SECTION_W: f32 = 38.0;
x >= r.origin.x
&& x <= r.origin.x + SEARCH_SECTION_W
&& y >= r.origin.y
&& y <= r.origin.y + r.size.y
}
/// Zoom + pan so the active page's content is framed within the
/// canvas region (the StatusBar search action). No-op for an empty
/// page.
pub(in crate::widget_host) fn zoom_to_fit(&mut self, viewport_w: f32, viewport_h: f32) {
self.refresh_layout_scene();
let Some(content) = self.layout_scene.content_bounds() else {
return;
};
let (_l, _t, canvas_w, canvas_h) = self.canvas_region(viewport_w, viewport_h);
self.editor_state
.viewport
.fit_to_with_max_zoom(content, canvas_w, canvas_h, 64.0, 1.0);
self.mark_dirty();
}
/// Floating Git-panel rect — `None` when the panel is closed.
/// Mirrors the placement in `widget_host/paint.rs`.
pub(in crate::widget_host) fn git_panel_rect(
&self,
viewport_w: f32,
viewport_h: f32,
) -> Option<Rect> {
let panel = GitPanel::for_editor(&self.editor_state)?;
let (canvas_left, _cy, _cw, _ch) = self.canvas_region(viewport_w, viewport_h);
Some(Rect {
origin: Point2D::new(
canvas_left + GIT_PANEL_INSET,
TOP_BAR_HEIGHT + GIT_PANEL_INSET,
),
size: Point2D::new(panel.panel_width(), panel.height()),
})
}
/// Floating Component-Browser panel rect — `None` when closed.
/// Same centred-on-open + clamped placement as the Design-MD panel.
pub(in crate::widget_host) fn component_browser_panel_rect(
&self,
viewport_w: f32,
viewport_h: f32,
) -> Option<Rect> {
use op_editor_ui::widgets::{COMPONENT_BROWSER_PANEL_H, COMPONENT_BROWSER_PANEL_W};
let ui = &self.editor_state.editor_ui;
if !ui.component_browser_open {
return None;
}
let (px, py) = ui.component_browser_pos.unwrap_or_else(|| {
(
((viewport_w - COMPONENT_BROWSER_PANEL_W) / 2.0).max(0.0),
((viewport_h - COMPONENT_BROWSER_PANEL_H) / 2.0).max(0.0),
)
});
let x = px.clamp(0.0, (viewport_w - 80.0).max(0.0));
let y = py.clamp(0.0, (viewport_h - 40.0).max(0.0));
Some(Rect {
origin: Point2D::new(x, y),
size: Point2D::new(COMPONENT_BROWSER_PANEL_W, COMPONENT_BROWSER_PANEL_H),
})
}
/// Floating Icon-picker panel rect — `None` when closed.
/// The TS picker is a dialog; native centers a compact searchable
/// panel because the built-in Rust catalog is local and finite.
pub(in crate::widget_host) fn icon_picker_panel_rect(
&self,
viewport_w: f32,
viewport_h: f32,
) -> Option<Rect> {
if !self.editor_state.editor_ui.icon_picker_open {
return None;
}
let ui = &self.editor_state.editor_ui;
let (px, py) = ui.icon_picker_panel_pos.unwrap_or_else(|| {
(
((viewport_w - ICON_PICKER_PANEL_W) / 2.0).max(0.0),
((viewport_h - ICON_PICKER_PANEL_H) / 2.0).max(0.0),
)
});
let x = px.clamp(0.0, (viewport_w - 80.0).max(0.0));
let y = py.clamp(0.0, (viewport_h - 40.0).max(0.0));
Some(Rect {
origin: Point2D::new(x, y),
size: Point2D::new(ICON_PICKER_PANEL_W, ICON_PICKER_PANEL_H),
})
}
/// Whether `point` is inside ANY top-most floating panel
/// (Design-MD or Component-Browser). Used by the input gates so
/// wheel / pan / right-press / hover side-effects do not leak to
/// the canvas / lower layers beneath the panel.
pub(in crate::widget_host) fn over_topmost_panel(
&self,
x: f32,
y: f32,
viewport_w: f32,
viewport_h: f32,
) -> bool {
let p = Point2D::new(x, y);
self.design_md_panel_rect(viewport_w, viewport_h)
.is_some_and(|r| rect_contains(r, p))
|| self
.icon_picker_panel_rect(viewport_w, viewport_h)
.is_some_and(|r| rect_contains(r, p))
|| self
.component_browser_panel_rect(viewport_w, viewport_h)
.is_some_and(|r| rect_contains(r, p))
}
/// Floating Design-MD panel rect — `None` when the panel is
/// closed. The top-left comes from `editor_ui.design_md_panel_pos`
/// (centred by the host on open), clamped to keep the header bar
/// reachable after a viewport resize.
pub(in crate::widget_host) fn design_md_panel_rect(
&self,
viewport_w: f32,
viewport_h: f32,
) -> Option<Rect> {
use op_editor_ui::widgets::{DESIGN_MD_PANEL_H, DESIGN_MD_PANEL_W};
let ui = &self.editor_state.editor_ui;
if !ui.design_md_panel_open {
return None;
}
let (px, py) = ui.design_md_panel_pos.unwrap_or_else(|| {
(
((viewport_w - DESIGN_MD_PANEL_W) / 2.0).max(0.0),
((viewport_h - DESIGN_MD_PANEL_H) / 2.0).max(0.0),
)
});
// Keep at least the header bar on-screen.
let x = px.clamp(0.0, (viewport_w - 80.0).max(0.0));
let y = py.clamp(0.0, (viewport_h - 40.0).max(0.0));
Some(Rect {
origin: Point2D::new(x, y),
size: Point2D::new(DESIGN_MD_PANEL_W, DESIGN_MD_PANEL_H),
})
}
pub(in crate::widget_host) fn shape_picker_rect(
&self,
viewport_w: f32,
viewport_h: f32,
) -> Rect {
let (cx0, _cy, cw, _ch) = self.canvas_region(viewport_w, viewport_h);
let toolbar = Toolbar::for_editor(&self.editor_state);
let toolbar_h = toolbar
.layout(&LayoutCx {
available_width: TOOLBAR_WIDTH,
dpi: 1.0,
})
.rect
.size
.y;
let toolbar_rect = Rect {
origin: Point2D::new(cx0 + TOOLBAR_INSET_X, TOP_BAR_HEIGHT + TOOLBAR_INSET_Y),
size: Point2D::new(TOOLBAR_WIDTH, toolbar_h),
};
let slot = toolbar
.shape_slot_rect(toolbar_rect)
.unwrap_or(toolbar_rect);
let panel_h = ShapePicker::panel_height();
let max_x = cx0 + cw - SHAPE_PICKER_WIDTH - 4.0;
let toolbar_right = toolbar_rect.origin.x + toolbar_rect.size.x;
let x = (toolbar_right + 8.0).min(max_x);
let y = slot.origin.y;
Rect {
origin: Point2D::new(x, y),
size: Point2D::new(SHAPE_PICKER_WIDTH, panel_h),
}
}
pub(in crate::widget_host) fn locale_picker_rect(&self, viewport_w: f32) -> Rect {
let top_bar_rect = Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(viewport_w, TOP_BAR_HEIGHT),
};
let globe = TopBar::globe_rect(top_bar_rect);
let panel_h = LocalePicker::panel_height();
let x = (globe.origin.x + globe.size.x / 2.0 - LOCALE_PICKER_WIDTH / 2.0)
.max(8.0)
.min(viewport_w - LOCALE_PICKER_WIDTH - 8.0);
let y = globe.origin.y + globe.size.y + 6.0;
Rect {
origin: Point2D::new(x, y),
size: Point2D::new(LOCALE_PICKER_WIDTH, panel_h),
}
}
/// When the active selection is a single Path node + the Pen
/// tool is selected, hit-test whether `(x, y)` lands on an anchor
/// or one of its bezier handles. Anchors are checked before
/// handles; returns the node id, anchor index, and which target.
pub(in crate::widget_host) fn path_anchor_hit(
&self,
x: f32,
y: f32,
viewport_w: f32,
viewport_h: f32,
) -> Option<(String, usize, super::AnchorDragTarget)> {
use super::AnchorDragTarget;
use op_editor_core::pen::PathHandleSide;
use op_editor_ui::layout_scene::NodeKind;
use op_editor_ui::widgets::path_handle_positions;
if !matches!(self.editor_state.tool, op_editor_core::Tool::Pen) {
return None;
}
if self.editor_state.selection_count() != 1 {
return None;
}
let sel = self.editor_state.selection.anchor.as_str().to_string();
let node = self.layout_scene.active_page()?.find(&sel)?;
if !matches!(node.kind, NodeKind::Path) {
return None;
}
let (cx0, cy0, _cw, _ch) = self.canvas_region(viewport_w, viewport_h);
let zoom = self.editor_state.viewport.zoom.max(0.0001);
let canvas_local = Point2D::new(x - cx0, y - cy0);
let mut doc = self.editor_state.viewport.to_document(canvas_local);
// Un-rotate the cursor into the node's local frame — handle
// positions are stored unrotated but the path paints rotated.
if node.rotation.abs() > f32::EPSILON {
let b = node.aggregate_bounds();
let centre = Point2D::new(b.origin.x + b.size.x / 2.0, b.origin.y + b.size.y / 2.0);
doc = op_editor_ui::widgets::rotate_point(doc, centre, -node.rotation);
}
// ~7 screen-px grab radius, expressed in doc space.
let r2 = 49.0 / (zoom * zoom);
let hit = |p: Point2D| (doc.x - p.x).powi(2) + (doc.y - p.y).powi(2) <= r2;
// Anchors take priority over handles within their tight body.
for (i, a) in node.path_anchors.iter().enumerate() {
if hit(a.pos) {
return Some((sel.clone(), i, AnchorDragTarget::Anchor));
}
}
for (i, a) in node.path_anchors.iter().enumerate() {
let (hin, hout) = path_handle_positions(a, zoom);
if hit(hout) {
return Some((
sel.clone(),
i,
AnchorDragTarget::Handle(PathHandleSide::Out),
));
}
if hit(hin) {
return Some((sel.clone(), i, AnchorDragTarget::Handle(PathHandleSide::In)));
}
}
// Paths without resolved anchor data fall back to `points`.
if node.path_anchors.is_empty() {
for (i, p) in node.points.iter().enumerate() {
if hit(*p) {
return Some((sel.clone(), i, AnchorDragTarget::Anchor));
}
}
}
None
}
/// When a single Ellipse is selected with the Select tool,
/// hit-test whether `(x, y)` lands on one of its three arc
/// handles. Returns the node id + which handle on a hit.
pub(in crate::widget_host) fn arc_handle_hit(
&self,
x: f32,
y: f32,
viewport_w: f32,
viewport_h: f32,
) -> Option<(String, op_editor_ui::widgets::ArcHandle)> {
if !matches!(self.editor_state.tool, op_editor_core::Tool::Select) {
return None;
}
if self.editor_state.selection_count() != 1 {
return None;
}
let sel = self.editor_state.selection.anchor.as_str().to_string();
let node = self.layout_scene.active_page()?.find(&sel)?;
let handles = op_editor_ui::widgets::arc_handle_positions(node)?;
let (cx0, cy0, _cw, _ch) = self.canvas_region(viewport_w, viewport_h);
let zoom = self.editor_state.viewport.zoom.max(0.0001);
let canvas_local = Point2D::new(x - cx0, y - cy0);
let mut doc_point = self.editor_state.viewport.to_document(canvas_local);
// Un-rotate the cursor into the ellipse's local frame.
if node.rotation.abs() > f32::EPSILON {
let b = node.bounds;
let centre = Point2D::new(b.origin.x + b.size.x / 2.0, b.origin.y + b.size.y / 2.0);
doc_point = op_editor_ui::widgets::rotate_point(doc_point, centre, -node.rotation);
}
// ~7 screen-px grab radius, expressed in doc space.
let r2 = 49.0 / (zoom * zoom);
// Reverse paint order so the topmost-painted handle wins —
// on a full-sweep ellipse the Start + Sweep handles coincide,
// and Sweep is painted last, so it must hit-test first.
for (handle, p) in handles.into_iter().rev() {
let dx = doc_point.x - p.x;
let dy = doc_point.y - p.y;
if dx * dx + dy * dy <= r2 {
return Some((sel, handle));
}
}
None
}
/// Resolve a screen point to an `AlignAction` if it lands on the
/// floating align toolbar (visible when 2+ selected).
pub(in crate::widget_host) fn align_toolbar_hit(
&self,
x: f32,
y: f32,
viewport_w: f32,
viewport_h: f32,
) -> Option<op_editor_core::AlignAction> {
use op_editor_ui::widgets::AlignToolbar;
let (cx, _, cw, ch) = self.canvas_region(viewport_w, viewport_h);
let canvas_region = Rect {
origin: Point2D::new(cx, TOP_BAR_HEIGHT),
size: Point2D::new(cw, ch),
};
AlignToolbar::for_canvas_region(canvas_region, &self.editor_state)?
.hit_test(Point2D::new(x, y))
}
}