refactor(shell): split web widget_host + harden layer-drag lifecycle
Three codex broad-review findings:
BLOCK — `openpencil-shell-web/src/widget_host.rs` was 886 lines
(over the 800 cap). Extracted `apply_press` + `apply_click`
(~301 lines combined) into a new sibling
`crates/openpencil-shell-web/src/widget_host/press.rs`,
mirroring the native split pattern. Spine drops to 590 lines.
Explicit `use super::{ChatDragState, DragState, LayerDragState,
MarqueeDragState, rect_contains, WidgetHost}` so the type
references inside the moved methods stay readable (Rust resolves
them via descendant module privacy, but the imports document
the dependency).
CONCERN — Stale `layer_drag` could outlive its dragged node if
the document mutated mid-drag (delete / cut / page switch).
Added a source-validity guard at three sites per host (native
+ web parity):
- `apply_cursor_move`: clears `layer_drag` if
`active_page().find(d.source)` returns None.
- Both paint passes: suppress the drop-indicator when the
source is no longer in the active page.
- `commit_layer_drag` already silently no-ops on missing
source via the existing `reorder_before/after` guards.
CONCERN — Missing host-level end-to-end test for the drag-to-
reorder gesture. Added two tests in
`openpencil-shell-native/src/widget_host/input_tests.rs`:
- `layer_drag_to_reorder_commits_on_release_with_threshold_move`
— full press → 4-px-threshold move → release; asserts the
tree was reordered and `layer_drag` is cleared.
- `layer_drag_below_activation_threshold_is_a_click_not_a_reorder`
— sub-threshold move; asserts click semantics (selection
set, tree unchanged).
Verification:
- cargo test --workspace: all green (184+ tests).
- cargo fmt --all --check: clean.
- bash tools/check-widget-boundary.sh: PASS.
- find / awk file-size scan: zero files over 800 lines.
This commit is contained in:
parent
44a2895bb0
commit
33ee2f37ed
|
|
@ -113,6 +113,20 @@ impl WidgetHostNative {
|
|||
return true;
|
||||
}
|
||||
if let Some(d) = self.layer_drag.as_mut() {
|
||||
// Drop the gesture if the source has disappeared from the
|
||||
// active page (e.g., user deleted it via Cmd-X or
|
||||
// switched pages mid-drag). Avoids stale drop-indicator
|
||||
// paint that invites a no-op release.
|
||||
let source_id = d.source;
|
||||
let still_present = self
|
||||
.document
|
||||
.active_page()
|
||||
.map(|p| p.find(source_id).is_some())
|
||||
.unwrap_or(false);
|
||||
if !still_present {
|
||||
self.layer_drag = None;
|
||||
return true;
|
||||
}
|
||||
d.current_x = x;
|
||||
d.current_y = y;
|
||||
// Activation is VERTICAL-ONLY by design: layer drag-to-
|
||||
|
|
|
|||
|
|
@ -213,3 +213,85 @@ fn marquee_drag_with_shift_extends_existing_selection() {
|
|||
ids.sort();
|
||||
assert_eq!(ids, vec![60, 61, 62]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_drag_to_reorder_commits_on_release_with_threshold_move() {
|
||||
use openpencil_shell_core::document::{Node, NodeKind};
|
||||
use openpencil_shell_core::widgets::TOP_BAR_HEIGHT;
|
||||
let mut host = WidgetHostNative::new();
|
||||
let page_idx = host.document.active_page_index;
|
||||
// Three top-level nodes, ids 70 / 71 / 72 — children
|
||||
// already painted as flat layer rows.
|
||||
host.document.pages[page_idx].children = vec![
|
||||
Node::leaf(70, NodeKind::Rect, "A"),
|
||||
Node::leaf(71, NodeKind::Rect, "B"),
|
||||
Node::leaf(72, NodeKind::Rect, "C"),
|
||||
];
|
||||
host.document.clear_selection();
|
||||
// LayerPanel row geometry — has to match the panel paint
|
||||
// (8 px top inset + Pages section header + 1 page row +
|
||||
// section gap + Layers section header, all walked from
|
||||
// TOP_BAR_HEIGHT).
|
||||
let row_h = 28.0; // LAYER_ROW_HEIGHT
|
||||
let page_row_h = 32.0; // PAGE_ROW_HEIGHT
|
||||
let section_header_h = 28.0;
|
||||
let section_gap = 8.0;
|
||||
let viewport_w = 1440.0;
|
||||
let viewport_h = 900.0;
|
||||
let layers_top =
|
||||
TOP_BAR_HEIGHT + 8.0 + section_header_h + page_row_h + section_gap + section_header_h;
|
||||
let row_y = |i: usize| layers_top + (i as f32) * row_h + row_h / 2.0;
|
||||
let row_x = host.document.ui.layer_panel_width / 2.0;
|
||||
// Press on row "A" (index 0) — seeds layer_drag.
|
||||
host.apply_press(row_x, row_y(0), viewport_w, viewport_h);
|
||||
assert!(host.layer_drag.is_some());
|
||||
assert!(!host.layer_drag.unwrap().active);
|
||||
// Move past threshold to row "C" (index 2) — activates drag,
|
||||
// updates current_y so drop_target_at picks "C" After on
|
||||
// release.
|
||||
host.apply_cursor_move(row_x, row_y(2) + row_h / 2.0 - 4.0);
|
||||
assert!(host.layer_drag.unwrap().active);
|
||||
host.apply_release_with_viewport(viewport_w, viewport_h);
|
||||
assert!(host.layer_drag.is_none(), "drag must be cleared on release");
|
||||
// A moved after C → final order [B, C, A].
|
||||
let order: Vec<u64> = host.document.pages[page_idx]
|
||||
.children
|
||||
.iter()
|
||||
.map(|n| n.id.raw())
|
||||
.collect();
|
||||
assert_eq!(order, vec![71, 72, 70]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_drag_below_activation_threshold_is_a_click_not_a_reorder() {
|
||||
use openpencil_shell_core::document::{Node, NodeKind};
|
||||
use openpencil_shell_core::widgets::TOP_BAR_HEIGHT;
|
||||
let mut host = WidgetHostNative::new();
|
||||
let page_idx = host.document.active_page_index;
|
||||
host.document.pages[page_idx].children = vec![
|
||||
Node::leaf(80, NodeKind::Rect, "X"),
|
||||
Node::leaf(81, NodeKind::Rect, "Y"),
|
||||
];
|
||||
host.document.clear_selection();
|
||||
let row_y_first = TOP_BAR_HEIGHT + 8.0 + 28.0 + 32.0 + 8.0 + 28.0 + 14.0;
|
||||
let row_x = host.document.ui.layer_panel_width / 2.0;
|
||||
let viewport_w = 1440.0;
|
||||
let viewport_h = 900.0;
|
||||
host.apply_press(row_x, row_y_first, viewport_w, viewport_h);
|
||||
// Sub-threshold move (2 px, less than 4 px activation).
|
||||
host.apply_cursor_move(row_x, row_y_first + 2.0);
|
||||
assert!(
|
||||
host.layer_drag.is_some() && !host.layer_drag.unwrap().active,
|
||||
"sub-threshold move must not activate"
|
||||
);
|
||||
host.apply_release_with_viewport(viewport_w, viewport_h);
|
||||
// Click semantics: selection is on the first row, tree is
|
||||
// unchanged.
|
||||
let order: Vec<u64> = host.document.pages[page_idx]
|
||||
.children
|
||||
.iter()
|
||||
.map(|n| n.id.raw())
|
||||
.collect();
|
||||
assert_eq!(order, vec![80, 81]);
|
||||
assert_eq!(host.document.selected, NodeId::new(80));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,18 @@ impl WidgetHostNative {
|
|||
if !d.active {
|
||||
return None;
|
||||
}
|
||||
// Don't paint the indicator if the dragged source
|
||||
// has disappeared from the active page (delete /
|
||||
// page switch mid-drag would otherwise paint a
|
||||
// ghost indicator that no release can resolve).
|
||||
if self
|
||||
.document
|
||||
.active_page()
|
||||
.map(|p| p.find(d.source).is_none())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let probe = LayerPanel::from_document(&self.document);
|
||||
probe.drop_target_at(layer_panel_rect, Point2D::new(d.current_x, d.current_y))
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ use openpencil_shell_core::{Point2D, Rect, Theme};
|
|||
|
||||
mod keyboard;
|
||||
mod paint;
|
||||
mod press;
|
||||
|
||||
pub(in crate::widget_host) const TOOLBAR_INSET_X: f32 = 12.0;
|
||||
pub(in crate::widget_host) const TOOLBAR_INSET_Y: f32 = 12.0;
|
||||
|
|
@ -276,210 +277,6 @@ impl WidgetHost {
|
|||
/// 3. apply_click handles AI chat focus/send/example +
|
||||
/// LayerPanel hit + chat-defocus side effect
|
||||
/// 4. Otherwise: start canvas pan-drag.
|
||||
pub fn apply_press(
|
||||
&mut self,
|
||||
x: f32,
|
||||
y: f32,
|
||||
viewport_width: f32,
|
||||
viewport_height: f32,
|
||||
) -> bool {
|
||||
// 0a. Locale picker overlay — top-most when open. Row hit
|
||||
// sets locale + closes; ANY other hit (including the
|
||||
// Globe button itself) closes the picker AND swallows
|
||||
// the click so the same press doesn't re-toggle open.
|
||||
if self.document.ui.locale_picker_open {
|
||||
let panel_rect = self.locale_picker_rect(viewport_width);
|
||||
let picker = LocalePicker::for_document(&self.document);
|
||||
if let Some(locale) = picker.hit_test(panel_rect, Point2D::new(x, y)) {
|
||||
self.document.ui.locale = locale;
|
||||
self.document.ui.locale_picker_open = false;
|
||||
return true;
|
||||
}
|
||||
self.document.ui.locale_picker_open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 0b. TopBar — sidebar toggle button. Mirrors the native
|
||||
// host so web + native behave identically.
|
||||
let top_bar_rect = Rect {
|
||||
origin: Point2D::new(0.0, 0.0),
|
||||
size: Point2D::new(viewport_width, TOP_BAR_HEIGHT),
|
||||
};
|
||||
let top_bar = TopBar::for_document(&self.document);
|
||||
if let Some(hit) = top_bar.hit_test(top_bar_rect, Point2D::new(x, y)) {
|
||||
match hit {
|
||||
TopBarHit::ToggleSidebar => {
|
||||
self.document.ui.sidebar_open = !self.document.ui.sidebar_open;
|
||||
return true;
|
||||
}
|
||||
TopBarHit::ToggleTheme => {
|
||||
self.document.ui.theme_mode = self.document.ui.theme_mode.flipped();
|
||||
return true;
|
||||
}
|
||||
TopBarHit::ToggleLocale => {
|
||||
self.document.ui.locale_picker_open = !self.document.ui.locale_picker_open;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if rect_contains(top_bar_rect, Point2D::new(x, y)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 0c. PropertyPanel button / checkbox — flex modes + size
|
||||
// flags. Runs AFTER locale picker + TopBar so the
|
||||
// dropdown overlays still win (codex stop-hook fix:
|
||||
// "web property-panel action hit-test intercepts the
|
||||
// locale picker").
|
||||
if let Some(panel) = PropertyPanel::for_selection(&self.document) {
|
||||
let property_rect = Rect {
|
||||
origin: Point2D::new(
|
||||
viewport_width - self.document.ui.property_panel_width,
|
||||
TOP_BAR_HEIGHT,
|
||||
),
|
||||
size: Point2D::new(
|
||||
self.document.ui.property_panel_width,
|
||||
(viewport_height - TOP_BAR_HEIGHT).max(0.0),
|
||||
),
|
||||
};
|
||||
if let Some(action) = panel.hit_test_action(property_rect, Point2D::new(x, y)) {
|
||||
self.apply_property_action(action);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. AI chat panel — painted on top of toolbar so a
|
||||
// click inside its rect is consumed here, even when
|
||||
// that point lies inside the toolbar rect underneath.
|
||||
if let Some(chat_rect) = self.ai_chat_rect(viewport_width, viewport_height) {
|
||||
let panel = AIChatPlaceholder::from_document(&self.document);
|
||||
if let Some(hit) = panel.hit_test(chat_rect, Point2D::new(x, y)) {
|
||||
if matches!(hit, AIChatHit::DragHandle) {
|
||||
self.chat_drag = Some(ChatDragState {
|
||||
grab_dx: x - chat_rect.origin.x,
|
||||
grab_dy: y - chat_rect.origin.y,
|
||||
pos_x: chat_rect.origin.x,
|
||||
pos_y: chat_rect.origin.y,
|
||||
});
|
||||
self.document.chat.focused = false;
|
||||
return true;
|
||||
}
|
||||
let _ = self.apply_click(x, y, viewport_width, viewport_height);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Toolbar — second-highest overlay. Bounding rect
|
||||
// consumes all clicks (gaps + padding too) so it
|
||||
// never falls through to the canvas for tool gaps
|
||||
// that lie outside the chat panel.
|
||||
let toolbar_rect = self.toolbar_rect(viewport_width);
|
||||
let toolbar = Toolbar::for_document(&self.document);
|
||||
if rect_contains(toolbar_rect, Point2D::new(x, y)) {
|
||||
if let Some(hit) = toolbar.hit_test(toolbar_rect, Point2D::new(x, y)) {
|
||||
match hit {
|
||||
openpencil_shell_core::widgets::ToolbarHit::Tool(tool) => {
|
||||
self.document.tool = tool;
|
||||
self.document.ui.shape_picker_open = false;
|
||||
return true;
|
||||
}
|
||||
openpencil_shell_core::widgets::ToolbarHit::Action(_) => {
|
||||
self.document.ui.shape_picker_open = false;
|
||||
return false;
|
||||
}
|
||||
openpencil_shell_core::widgets::ToolbarHit::ToggleShapePicker => {
|
||||
self.document.ui.shape_picker_open = !self.document.ui.shape_picker_open;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. apply_click — LayerPanel + chat-defocus.
|
||||
// Pre-seed a `layer_drag` candidate when the press lands
|
||||
// on a Layer row so a subsequent move past the threshold
|
||||
// promotes the gesture to a drag-to-reorder (mirrors
|
||||
// native; see `widget_host/press.rs`).
|
||||
if self.document.ui.sidebar_open {
|
||||
use openpencil_shell_core::widgets::{LayerPanel, LayerPanelHit};
|
||||
let layer_rect = self.layer_panel_rect(viewport_height);
|
||||
let panel = LayerPanel::from_document(&self.document);
|
||||
if let Some(LayerPanelHit::Layer(node_id)) =
|
||||
panel.hit_test(layer_rect, Point2D::new(x, y))
|
||||
{
|
||||
self.layer_drag = Some(LayerDragState {
|
||||
source: node_id,
|
||||
start_y: y,
|
||||
current_x: x,
|
||||
current_y: y,
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if self.apply_click(x, y, viewport_width, viewport_height) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. Canvas click — branch on tool.
|
||||
// - Hand: pan-drag.
|
||||
// - Select + node hit: set/toggle selection.
|
||||
// - Select + empty: marquee.
|
||||
if self.over_canvas(x, y, viewport_width, viewport_height) {
|
||||
use openpencil_shell_core::document::Tool;
|
||||
if matches!(self.document.tool, Tool::Hand) {
|
||||
self.drag = Some(DragState {
|
||||
last_x: x,
|
||||
last_y: y,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if matches!(self.document.tool, Tool::Select) {
|
||||
// Convert screen → doc to ask which node (if any)
|
||||
// is under the cursor.
|
||||
let (cx0, cy0, _cw, _ch) = self.canvas_region(viewport_width, viewport_height);
|
||||
let canvas_local = Point2D::new(x - cx0, y - cy0);
|
||||
let doc_point = self.document.viewport.to_document(canvas_local);
|
||||
if let Some(node_id) = self.document.node_at_doc_point(doc_point) {
|
||||
if self.shift_held {
|
||||
self.document.toggle_selection(node_id);
|
||||
} else {
|
||||
let already_in_set = self.document.is_selected(node_id);
|
||||
if !already_in_set || self.document.selection_count() == 1 {
|
||||
self.document.set_single_selection(node_id);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Empty canvas with Select → marquee.
|
||||
let cleared_now = if !self.shift_held {
|
||||
let was_set = !self.document.selected_set.is_empty();
|
||||
if was_set {
|
||||
self.document.clear_selection();
|
||||
}
|
||||
was_set
|
||||
} else {
|
||||
false
|
||||
};
|
||||
self.marquee_drag = Some(MarqueeDragState {
|
||||
start_screen_x: x,
|
||||
start_screen_y: y,
|
||||
current_screen_x: x,
|
||||
current_screen_y: y,
|
||||
additive: self.shift_held,
|
||||
});
|
||||
return cleared_now;
|
||||
}
|
||||
// Any other tool on empty canvas — fall back to pan
|
||||
// (web doesn't ship shape-creation drag yet).
|
||||
self.drag = Some(DragState {
|
||||
last_x: x,
|
||||
last_y: y,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Update `Document.ui.hovered_layer_id` from the cursor.
|
||||
/// Returns true if hover state changed (caller should
|
||||
|
|
@ -526,6 +323,18 @@ impl WidgetHost {
|
|||
return true;
|
||||
}
|
||||
if let Some(d) = self.layer_drag.as_mut() {
|
||||
// Drop the gesture if the source disappeared mid-drag —
|
||||
// see the native host for the rationale.
|
||||
let source_id = d.source;
|
||||
let still_present = self
|
||||
.document
|
||||
.active_page()
|
||||
.map(|p| p.find(source_id).is_some())
|
||||
.unwrap_or(false);
|
||||
if !still_present {
|
||||
self.layer_drag = None;
|
||||
return true;
|
||||
}
|
||||
d.current_x = x;
|
||||
d.current_y = y;
|
||||
// VERTICAL-ONLY activation (4 px). See the native host
|
||||
|
|
@ -752,99 +561,6 @@ impl WidgetHost {
|
|||
/// hit-tests, in floating-z-order. Returns `true` if the click
|
||||
/// was consumed by a widget so the caller knows whether to
|
||||
/// repaint.
|
||||
pub fn apply_click(&mut self, x: f32, y: f32, viewport_w: f32, viewport_h: f32) -> bool {
|
||||
// glue:
|
||||
// Floating chat panel sits on top — check first so its
|
||||
// clicks don't fall through to the canvas.
|
||||
if let Some(chat_rect) = self.ai_chat_rect(viewport_w, viewport_h) {
|
||||
let panel = AIChatPlaceholder::from_document(&self.document);
|
||||
if let Some(hit) = panel.hit_test(chat_rect, Point2D::new(x, y)) {
|
||||
match hit {
|
||||
AIChatHit::FocusInput => {
|
||||
self.document.chat.focused = true;
|
||||
return true;
|
||||
}
|
||||
AIChatHit::Send => {
|
||||
self.document.chat.send();
|
||||
return true;
|
||||
}
|
||||
AIChatHit::Example(text) => {
|
||||
self.document.chat.input = text;
|
||||
self.document.chat.focused = true;
|
||||
return true;
|
||||
}
|
||||
AIChatHit::DragHandle => {
|
||||
return false;
|
||||
}
|
||||
AIChatHit::ToggleCollapse => {
|
||||
self.document.chat.collapsed = !self.document.chat.collapsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let was_focused = self.document.chat.focused;
|
||||
self.document.chat.focused = false;
|
||||
|
||||
let toolbar_rect = self.toolbar_rect(viewport_w);
|
||||
let toolbar = Toolbar::for_document(&self.document);
|
||||
if let Some(hit) = toolbar.hit_test(toolbar_rect, Point2D::new(x, y)) {
|
||||
match hit {
|
||||
openpencil_shell_core::widgets::ToolbarHit::Tool(tool) => {
|
||||
self.document.tool = tool;
|
||||
return true;
|
||||
}
|
||||
openpencil_shell_core::widgets::ToolbarHit::Action(_) => {
|
||||
return false;
|
||||
}
|
||||
openpencil_shell_core::widgets::ToolbarHit::ToggleShapePicker => {
|
||||
self.document.ui.shape_picker_open = !self.document.ui.shape_picker_open;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.document.ui.sidebar_open {
|
||||
return was_focused;
|
||||
}
|
||||
let layer_rect = self.layer_panel_rect(viewport_h);
|
||||
let panel = LayerPanel::from_document(&self.document);
|
||||
if let Some(hit) = panel.hit_test(layer_rect, Point2D::new(x, y)) {
|
||||
match hit {
|
||||
openpencil_shell_core::widgets::LayerPanelHit::Page(idx) => {
|
||||
self.document.active_page_index = idx;
|
||||
self.document.clear_selection();
|
||||
return true;
|
||||
}
|
||||
openpencil_shell_core::widgets::LayerPanelHit::Layer(node_id) => {
|
||||
if self.shift_held {
|
||||
self.document.toggle_selection(node_id);
|
||||
} else {
|
||||
self.document.set_single_selection(node_id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
openpencil_shell_core::widgets::LayerPanelHit::ToggleHidden(node_id) => {
|
||||
self.document.toggle_node_hidden(node_id);
|
||||
return true;
|
||||
}
|
||||
openpencil_shell_core::widgets::LayerPanelHit::ToggleLocked(node_id) => {
|
||||
self.document.toggle_node_locked(node_id);
|
||||
return true;
|
||||
}
|
||||
openpencil_shell_core::widgets::LayerPanelHit::ToggleCollapsed(node_id) => {
|
||||
self.document.toggle_node_collapsed(node_id);
|
||||
return true;
|
||||
}
|
||||
openpencil_shell_core::widgets::LayerPanelHit::AddPage => {
|
||||
let _ = self.document.add_page();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Defocusing the chat input itself is a visible change —
|
||||
// the caller should still repaint to drop the caret.
|
||||
was_focused
|
||||
}
|
||||
|
||||
pub(in crate::widget_host) fn layer_panel_rect(&self, viewport_h: f32) -> Rect {
|
||||
Rect {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,17 @@ impl WidgetHost {
|
|||
if !d.active {
|
||||
return None;
|
||||
}
|
||||
// Suppress the indicator when the source has been
|
||||
// removed from the active page mid-drag — see the
|
||||
// native host for the rationale.
|
||||
if self
|
||||
.document
|
||||
.active_page()
|
||||
.map(|p| p.find(d.source).is_none())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let probe = LayerPanel::from_document(&self.document);
|
||||
probe.drop_target_at(layer_panel_rect, Point2D::new(d.current_x, d.current_y))
|
||||
});
|
||||
|
|
|
|||
314
crates/openpencil-shell-web/src/widget_host/press.rs
Normal file
314
crates/openpencil-shell-web/src/widget_host/press.rs
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
//! Web `apply_press` + `apply_click` — extracted from
|
||||
//! `widget_host.rs` so the spine stays under the 800-line cap.
|
||||
//! Mirrors the native `widget_host/press.rs` split.
|
||||
|
||||
use openpencil_shell_core::document::Tool;
|
||||
use openpencil_shell_core::widgets::{
|
||||
AIChatHit, AIChatPlaceholder, LayerPanel, LayerPanelHit, LocalePicker, PropertyPanel, Toolbar,
|
||||
TopBar, TopBarHit, AI_CHAT_COLLAPSED_HEIGHT, AI_CHAT_HEIGHT, LOCALE_PICKER_WIDTH,
|
||||
TOOLBAR_WIDTH, TOP_BAR_HEIGHT,
|
||||
};
|
||||
use openpencil_shell_core::{Point2D, Rect};
|
||||
|
||||
use super::{
|
||||
rect_contains, ChatDragState, DragState, LayerDragState, MarqueeDragState, WidgetHost,
|
||||
};
|
||||
|
||||
impl WidgetHost {
|
||||
pub fn apply_press(
|
||||
&mut self,
|
||||
x: f32,
|
||||
y: f32,
|
||||
viewport_width: f32,
|
||||
viewport_height: f32,
|
||||
) -> bool {
|
||||
// 0a. Locale picker overlay — top-most when open. Row hit
|
||||
// sets locale + closes; ANY other hit (including the
|
||||
// Globe button itself) closes the picker AND swallows
|
||||
// the click so the same press doesn't re-toggle open.
|
||||
if self.document.ui.locale_picker_open {
|
||||
let panel_rect = self.locale_picker_rect(viewport_width);
|
||||
let picker = LocalePicker::for_document(&self.document);
|
||||
if let Some(locale) = picker.hit_test(panel_rect, Point2D::new(x, y)) {
|
||||
self.document.ui.locale = locale;
|
||||
self.document.ui.locale_picker_open = false;
|
||||
return true;
|
||||
}
|
||||
self.document.ui.locale_picker_open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 0b. TopBar — sidebar toggle button. Mirrors the native
|
||||
// host so web + native behave identically.
|
||||
let top_bar_rect = Rect {
|
||||
origin: Point2D::new(0.0, 0.0),
|
||||
size: Point2D::new(viewport_width, TOP_BAR_HEIGHT),
|
||||
};
|
||||
let top_bar = TopBar::for_document(&self.document);
|
||||
if let Some(hit) = top_bar.hit_test(top_bar_rect, Point2D::new(x, y)) {
|
||||
match hit {
|
||||
TopBarHit::ToggleSidebar => {
|
||||
self.document.ui.sidebar_open = !self.document.ui.sidebar_open;
|
||||
return true;
|
||||
}
|
||||
TopBarHit::ToggleTheme => {
|
||||
self.document.ui.theme_mode = self.document.ui.theme_mode.flipped();
|
||||
return true;
|
||||
}
|
||||
TopBarHit::ToggleLocale => {
|
||||
self.document.ui.locale_picker_open = !self.document.ui.locale_picker_open;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if rect_contains(top_bar_rect, Point2D::new(x, y)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 0c. PropertyPanel button / checkbox — flex modes + size
|
||||
// flags. Runs AFTER locale picker + TopBar so the
|
||||
// dropdown overlays still win (codex stop-hook fix:
|
||||
// "web property-panel action hit-test intercepts the
|
||||
// locale picker").
|
||||
if let Some(panel) = PropertyPanel::for_selection(&self.document) {
|
||||
let property_rect = Rect {
|
||||
origin: Point2D::new(
|
||||
viewport_width - self.document.ui.property_panel_width,
|
||||
TOP_BAR_HEIGHT,
|
||||
),
|
||||
size: Point2D::new(
|
||||
self.document.ui.property_panel_width,
|
||||
(viewport_height - TOP_BAR_HEIGHT).max(0.0),
|
||||
),
|
||||
};
|
||||
if let Some(action) = panel.hit_test_action(property_rect, Point2D::new(x, y)) {
|
||||
self.apply_property_action(action);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. AI chat panel — painted on top of toolbar so a
|
||||
// click inside its rect is consumed here, even when
|
||||
// that point lies inside the toolbar rect underneath.
|
||||
if let Some(chat_rect) = self.ai_chat_rect(viewport_width, viewport_height) {
|
||||
let panel = AIChatPlaceholder::from_document(&self.document);
|
||||
if let Some(hit) = panel.hit_test(chat_rect, Point2D::new(x, y)) {
|
||||
if matches!(hit, AIChatHit::DragHandle) {
|
||||
self.chat_drag = Some(ChatDragState {
|
||||
grab_dx: x - chat_rect.origin.x,
|
||||
grab_dy: y - chat_rect.origin.y,
|
||||
pos_x: chat_rect.origin.x,
|
||||
pos_y: chat_rect.origin.y,
|
||||
});
|
||||
self.document.chat.focused = false;
|
||||
return true;
|
||||
}
|
||||
let _ = self.apply_click(x, y, viewport_width, viewport_height);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Toolbar — second-highest overlay. Bounding rect
|
||||
// consumes all clicks (gaps + padding too) so it
|
||||
// never falls through to the canvas for tool gaps
|
||||
// that lie outside the chat panel.
|
||||
let toolbar_rect = self.toolbar_rect(viewport_width);
|
||||
let toolbar = Toolbar::for_document(&self.document);
|
||||
if rect_contains(toolbar_rect, Point2D::new(x, y)) {
|
||||
if let Some(hit) = toolbar.hit_test(toolbar_rect, Point2D::new(x, y)) {
|
||||
match hit {
|
||||
openpencil_shell_core::widgets::ToolbarHit::Tool(tool) => {
|
||||
self.document.tool = tool;
|
||||
self.document.ui.shape_picker_open = false;
|
||||
return true;
|
||||
}
|
||||
openpencil_shell_core::widgets::ToolbarHit::Action(_) => {
|
||||
self.document.ui.shape_picker_open = false;
|
||||
return false;
|
||||
}
|
||||
openpencil_shell_core::widgets::ToolbarHit::ToggleShapePicker => {
|
||||
self.document.ui.shape_picker_open = !self.document.ui.shape_picker_open;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. apply_click — LayerPanel + chat-defocus.
|
||||
// Pre-seed a `layer_drag` candidate when the press lands
|
||||
// on a Layer row so a subsequent move past the threshold
|
||||
// promotes the gesture to a drag-to-reorder (mirrors
|
||||
// native; see `widget_host/press.rs`).
|
||||
if self.document.ui.sidebar_open {
|
||||
let layer_rect = self.layer_panel_rect(viewport_height);
|
||||
let panel = LayerPanel::from_document(&self.document);
|
||||
if let Some(LayerPanelHit::Layer(node_id)) =
|
||||
panel.hit_test(layer_rect, Point2D::new(x, y))
|
||||
{
|
||||
self.layer_drag = Some(LayerDragState {
|
||||
source: node_id,
|
||||
start_y: y,
|
||||
current_x: x,
|
||||
current_y: y,
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if self.apply_click(x, y, viewport_width, viewport_height) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. Canvas click — branch on tool.
|
||||
// - Hand: pan-drag.
|
||||
// - Select + node hit: set/toggle selection.
|
||||
// - Select + empty: marquee.
|
||||
if self.over_canvas(x, y, viewport_width, viewport_height) {
|
||||
if matches!(self.document.tool, Tool::Hand) {
|
||||
self.drag = Some(DragState {
|
||||
last_x: x,
|
||||
last_y: y,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if matches!(self.document.tool, Tool::Select) {
|
||||
// Convert screen → doc to ask which node (if any)
|
||||
// is under the cursor.
|
||||
let (cx0, cy0, _cw, _ch) = self.canvas_region(viewport_width, viewport_height);
|
||||
let canvas_local = Point2D::new(x - cx0, y - cy0);
|
||||
let doc_point = self.document.viewport.to_document(canvas_local);
|
||||
if let Some(node_id) = self.document.node_at_doc_point(doc_point) {
|
||||
if self.shift_held {
|
||||
self.document.toggle_selection(node_id);
|
||||
} else {
|
||||
let already_in_set = self.document.is_selected(node_id);
|
||||
if !already_in_set || self.document.selection_count() == 1 {
|
||||
self.document.set_single_selection(node_id);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Empty canvas with Select → marquee.
|
||||
let cleared_now = if !self.shift_held {
|
||||
let was_set = !self.document.selected_set.is_empty();
|
||||
if was_set {
|
||||
self.document.clear_selection();
|
||||
}
|
||||
was_set
|
||||
} else {
|
||||
false
|
||||
};
|
||||
self.marquee_drag = Some(MarqueeDragState {
|
||||
start_screen_x: x,
|
||||
start_screen_y: y,
|
||||
current_screen_x: x,
|
||||
current_screen_y: y,
|
||||
additive: self.shift_held,
|
||||
});
|
||||
return cleared_now;
|
||||
}
|
||||
// Any other tool on empty canvas — fall back to pan
|
||||
// (web doesn't ship shape-creation drag yet).
|
||||
self.drag = Some(DragState {
|
||||
last_x: x,
|
||||
last_y: y,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn apply_click(&mut self, x: f32, y: f32, viewport_w: f32, viewport_h: f32) -> bool {
|
||||
// glue:
|
||||
// Floating chat panel sits on top — check first so its
|
||||
// clicks don't fall through to the canvas.
|
||||
if let Some(chat_rect) = self.ai_chat_rect(viewport_w, viewport_h) {
|
||||
let panel = AIChatPlaceholder::from_document(&self.document);
|
||||
if let Some(hit) = panel.hit_test(chat_rect, Point2D::new(x, y)) {
|
||||
match hit {
|
||||
AIChatHit::FocusInput => {
|
||||
self.document.chat.focused = true;
|
||||
return true;
|
||||
}
|
||||
AIChatHit::Send => {
|
||||
self.document.chat.send();
|
||||
return true;
|
||||
}
|
||||
AIChatHit::Example(text) => {
|
||||
self.document.chat.input = text;
|
||||
self.document.chat.focused = true;
|
||||
return true;
|
||||
}
|
||||
AIChatHit::DragHandle => {
|
||||
return false;
|
||||
}
|
||||
AIChatHit::ToggleCollapse => {
|
||||
self.document.chat.collapsed = !self.document.chat.collapsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let was_focused = self.document.chat.focused;
|
||||
self.document.chat.focused = false;
|
||||
|
||||
let toolbar_rect = self.toolbar_rect(viewport_w);
|
||||
let toolbar = Toolbar::for_document(&self.document);
|
||||
if let Some(hit) = toolbar.hit_test(toolbar_rect, Point2D::new(x, y)) {
|
||||
match hit {
|
||||
openpencil_shell_core::widgets::ToolbarHit::Tool(tool) => {
|
||||
self.document.tool = tool;
|
||||
return true;
|
||||
}
|
||||
openpencil_shell_core::widgets::ToolbarHit::Action(_) => {
|
||||
return false;
|
||||
}
|
||||
openpencil_shell_core::widgets::ToolbarHit::ToggleShapePicker => {
|
||||
self.document.ui.shape_picker_open = !self.document.ui.shape_picker_open;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.document.ui.sidebar_open {
|
||||
return was_focused;
|
||||
}
|
||||
let layer_rect = self.layer_panel_rect(viewport_h);
|
||||
let panel = LayerPanel::from_document(&self.document);
|
||||
if let Some(hit) = panel.hit_test(layer_rect, Point2D::new(x, y)) {
|
||||
match hit {
|
||||
openpencil_shell_core::widgets::LayerPanelHit::Page(idx) => {
|
||||
self.document.active_page_index = idx;
|
||||
self.document.clear_selection();
|
||||
return true;
|
||||
}
|
||||
openpencil_shell_core::widgets::LayerPanelHit::Layer(node_id) => {
|
||||
if self.shift_held {
|
||||
self.document.toggle_selection(node_id);
|
||||
} else {
|
||||
self.document.set_single_selection(node_id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
openpencil_shell_core::widgets::LayerPanelHit::ToggleHidden(node_id) => {
|
||||
self.document.toggle_node_hidden(node_id);
|
||||
return true;
|
||||
}
|
||||
openpencil_shell_core::widgets::LayerPanelHit::ToggleLocked(node_id) => {
|
||||
self.document.toggle_node_locked(node_id);
|
||||
return true;
|
||||
}
|
||||
openpencil_shell_core::widgets::LayerPanelHit::ToggleCollapsed(node_id) => {
|
||||
self.document.toggle_node_collapsed(node_id);
|
||||
return true;
|
||||
}
|
||||
openpencil_shell_core::widgets::LayerPanelHit::AddPage => {
|
||||
let _ = self.document.add_page();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Defocusing the chat input itself is a visible change —
|
||||
// the caller should still repaint to drop the caret.
|
||||
was_focused
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue