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:
Kayshen-X 2026-05-12 02:18:01 +08:00
parent 44a2895bb0
commit 33ee2f37ed
6 changed files with 446 additions and 297 deletions

View file

@ -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-

View file

@ -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));
}

View file

@ -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))
});

View file

@ -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 {

View file

@ -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))
});

View 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
}
}