From eb853f6ccf6fb63c19e17c838e49a531d3e29c53 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sat, 4 Jul 2026 18:58:45 +0800 Subject: [PATCH] refactor(editor): split layer-panel hit-test into a sibling file The pages-row-height scroll fix pushed layer_panel.rs past the 800-line ceiling. Move hit_test + drop_target_at into layer_panel_hit.rs via an impl block; no behavior change. --- .../op-editor-ui/src/widgets/layer_panel.rs | 187 +---------------- .../src/widgets/layer_panel_hit.rs | 195 ++++++++++++++++++ crates/op-editor-ui/src/widgets/mod.rs | 1 + 3 files changed, 197 insertions(+), 186 deletions(-) create mode 100644 crates/op-editor-ui/src/widgets/layer_panel_hit.rs diff --git a/crates/op-editor-ui/src/widgets/layer_panel.rs b/crates/op-editor-ui/src/widgets/layer_panel.rs index 3c954145e..e321d7d67 100644 --- a/crates/op-editor-ui/src/widgets/layer_panel.rs +++ b/crates/op-editor-ui/src/widgets/layer_panel.rs @@ -12,7 +12,7 @@ use crate::widgets::editor_state_ext::theme_for; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::layer_panel_walkers::{ apply_layer_rename, icon_for_node, kind_label, layer_regions, layers_content_width, - pages_content_width, pages_from_state, row_index_at, visible_row_range, walk, walk_excluding, + pages_content_width, pages_from_state, visible_row_range, walk, walk_excluding, LayerRegionInput, LayerRegions, LayerScrollSnapshot, RenameView, WalkCx, }; use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; @@ -241,191 +241,6 @@ impl LayerPanel { layers: self.layers_scroll, }) } - - /// Drop target for a drag-in-progress. Over a row: top-25 % - /// Before, middle 50 % Into (container rows only), bottom 25 % - /// After. Below-rows area: After-last. Outside / above rows: - /// None. - pub fn drop_target_at(&self, rect: Rect, point: Point2D) -> Option { - if !(rect).contains(point) { - return None; - } - let r = self.regions(rect); - // A drop is only valid when the cursor is inside the visible - // (clipped) Layers viewport — a row scrolled out of view, or - // the cursor over the Pages section / headers, is no drop - // target. Mirrors the paint clip so the drop-indicator the - // user sees and where the node actually lands always agree. - if point.y < r.layers_rows_top || point.y > r.layers_rows_top + r.layers_view_h { - return None; - } - let layers_top = r.layers_rows_top - r.layers.offset; - if let Some((index, row_top)) = row_index_at( - self.items.len(), - r.layers_rows_top, - r.layers.offset, - r.layers_view_h, - LAYER_ROW_HEIGHT, - point.y, - ) { - let item = &self.items[index]; - let row_bottom = row_top + LAYER_ROW_HEIGHT; - // Container rows use Before / Into / After bands; leaves - // fall back to a two-way Before / After split. - let local = point.y - row_top; - let position = if item.is_container { - if local < LAYER_ROW_HEIGHT * 0.25 { - DropPosition::Before - } else if local > LAYER_ROW_HEIGHT * 0.75 { - DropPosition::After - } else { - DropPosition::Into - } - } else if local < LAYER_ROW_HEIGHT / 2.0 { - DropPosition::Before - } else { - DropPosition::After - }; - let indicator_y = match position { - DropPosition::Before => row_top, - DropPosition::After => row_bottom, - DropPosition::Into => row_top, - }; - return Some(DropTarget { - anchor: item.node_id.clone(), - position, - indicator_y, - }); - } - // Cursor is below the last row but still inside the panel — - // drop at end (anchor = last layer, position = After). `y` - // already points at the bottom of the final row, which is - // exactly where the indicator paints. - if point.y > layers_top { - if let Some(last) = self.items.last() { - let y = layers_top + self.items.len() as f32 * LAYER_ROW_HEIGHT; - return Some(DropTarget { - anchor: last.node_id.clone(), - position: DropPosition::After, - indicator_y: y, - }); - } - } - None - } - - /// Hit test a (rect, point) — returns `Page(idx)` for a page - /// row, `Layer(node_id)` for a layer row, eye/lock/chevron - /// toggles for the trailing icons, or `AddPage` for the `+` - /// on the Pages section header. - pub fn hit_test(&self, rect: Rect, point: Point2D) -> Option { - if !(rect).contains(point) { - return None; - } - // Pages section header — `+` add-page affordance at top-right. - let plus_x = rect.origin.x + rect.size.x - ROW_PAD_X - 12.0; - let plus_y = rect.origin.y + 8.0 + (SECTION_HEADER_HEIGHT - 14.0) / 2.0; - let slop = 4.0; - if point.x >= plus_x - slop - && point.x <= plus_x + 14.0 + slop - && point.y >= plus_y - slop - && point.y <= plus_y + 14.0 + slop - { - return Some(LayerPanelHit::AddPage); - } - // Bounded Pages / Layers viewports — a row only counts as a - // hit when the cursor is inside its (clipped) viewport, so a - // row scrolled out of view can't be clicked through. - let r = self.regions(rect); - if let Some((index, y)) = row_index_at( - self.pages.len(), - r.pages_rows_top, - r.pages.offset, - r.pages_view_h, - PAGE_ROW_HEIGHT, - point.y, - ) { - let page = &self.pages[index]; - let row = Rect { - origin: Point2D::new(rect.origin.x, y), - size: Point2D::new(rect.size.x, PAGE_ROW_HEIGHT), - }; - if (row).contains(point) && page.hovered { - // × delete button — only hit-tested when the row is - // hovered (paint shows it under the same gate). - let close_x = rect.origin.x + rect.size.x - ROW_PAD_X - 14.0; - let close_y = y + (PAGE_ROW_HEIGHT - 14.0) / 2.0; - let slop = 4.0; - if point.x >= close_x - slop - && point.x <= close_x + 14.0 + slop - && point.y >= close_y - slop - && point.y <= close_y + 14.0 + slop - { - return Some(LayerPanelHit::DeletePage(page.page_index)); - } - } - if (row).contains(point) { - return Some(LayerPanelHit::Page(page.page_index)); - } - } - if let Some((index, y)) = row_index_at( - self.items.len(), - r.layers_rows_top, - r.layers.offset, - r.layers_view_h, - LAYER_ROW_HEIGHT, - point.y, - ) { - let item = &self.items[index]; - let row = Rect { - origin: Point2D::new(rect.origin.x, y), - size: Point2D::new(rect.size.x, LAYER_ROW_HEIGHT), - }; - if !(row).contains(point) { - return None; - } - // Match the paint geometry — same 14 px icon boxes with - // 4 px slop so small mouse offsets still register. - let inner = Rect { - origin: Point2D::new(row.origin.x + 6.0, y + 2.0), - size: Point2D::new(row.size.x - 12.0, LAYER_ROW_HEIGHT - 4.0), - }; - let trailing_right = inner.origin.x + inner.size.x - 8.0; - let lock_x = trailing_right - 14.0; - let eye_x = lock_x - 22.0; - let icon_y = inner.origin.y + 6.0; - let slop = 4.0; - if item.hovered - && point.x >= lock_x - slop - && point.x <= lock_x + 14.0 + slop - && point.y >= icon_y - slop - && point.y <= icon_y + 14.0 + slop - { - return Some(LayerPanelHit::ToggleLocked(item.node_id.clone())); - } - if item.hovered - && point.x >= eye_x - slop - && point.x <= eye_x + 14.0 + slop - && point.y >= icon_y - slop - && point.y <= icon_y + 14.0 + slop - { - return Some(LayerPanelHit::ToggleHidden(item.node_id.clone())); - } - if item.has_children { - let indent = ROW_PAD_X + item.depth as f32 * 12.0; - let chev_x = inner.origin.x + indent - r.layers.horizontal_offset; - if point.x >= chev_x - slop - && point.x <= chev_x + 14.0 + slop - && point.y >= icon_y - slop - && point.y <= icon_y + 14.0 + slop - { - return Some(LayerPanelHit::ToggleCollapsed(item.node_id.clone())); - } - } - return Some(LayerPanelHit::Layer(item.node_id.clone())); - } - None - } } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/op-editor-ui/src/widgets/layer_panel_hit.rs b/crates/op-editor-ui/src/widgets/layer_panel_hit.rs new file mode 100644 index 000000000..1194424e4 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/layer_panel_hit.rs @@ -0,0 +1,195 @@ +//! `LayerPanel` hit-test + drop-target resolution — split out of +//! `layer_panel.rs` to honor the 800-line file ceiling. These methods +//! mirror the paint geometry (same clipped viewports + row heights) so +//! clicks and drop indicators land on the rows the user sees. + +use super::layer_panel::*; +use super::layer_panel_walkers::row_index_at; +use crate::{Point2D, Rect}; + +impl LayerPanel { + /// Drop target for a drag-in-progress. Over a row: top-25 % + /// Before, middle 50 % Into (container rows only), bottom 25 % + /// After. Below-rows area: After-last. Outside / above rows: + /// None. + pub fn drop_target_at(&self, rect: Rect, point: Point2D) -> Option { + if !(rect).contains(point) { + return None; + } + let r = self.regions(rect); + // A drop is only valid when the cursor is inside the visible + // (clipped) Layers viewport — a row scrolled out of view, or + // the cursor over the Pages section / headers, is no drop + // target. Mirrors the paint clip so the drop-indicator the + // user sees and where the node actually lands always agree. + if point.y < r.layers_rows_top || point.y > r.layers_rows_top + r.layers_view_h { + return None; + } + let layers_top = r.layers_rows_top - r.layers.offset; + if let Some((index, row_top)) = row_index_at( + self.items.len(), + r.layers_rows_top, + r.layers.offset, + r.layers_view_h, + LAYER_ROW_HEIGHT, + point.y, + ) { + let item = &self.items[index]; + let row_bottom = row_top + LAYER_ROW_HEIGHT; + // Container rows use Before / Into / After bands; leaves + // fall back to a two-way Before / After split. + let local = point.y - row_top; + let position = if item.is_container { + if local < LAYER_ROW_HEIGHT * 0.25 { + DropPosition::Before + } else if local > LAYER_ROW_HEIGHT * 0.75 { + DropPosition::After + } else { + DropPosition::Into + } + } else if local < LAYER_ROW_HEIGHT / 2.0 { + DropPosition::Before + } else { + DropPosition::After + }; + let indicator_y = match position { + DropPosition::Before => row_top, + DropPosition::After => row_bottom, + DropPosition::Into => row_top, + }; + return Some(DropTarget { + anchor: item.node_id.clone(), + position, + indicator_y, + }); + } + // Cursor is below the last row but still inside the panel — + // drop at end (anchor = last layer, position = After). `y` + // already points at the bottom of the final row, which is + // exactly where the indicator paints. + if point.y > layers_top { + if let Some(last) = self.items.last() { + let y = layers_top + self.items.len() as f32 * LAYER_ROW_HEIGHT; + return Some(DropTarget { + anchor: last.node_id.clone(), + position: DropPosition::After, + indicator_y: y, + }); + } + } + None + } + + /// Hit test a (rect, point) — returns `Page(idx)` for a page + /// row, `Layer(node_id)` for a layer row, eye/lock/chevron + /// toggles for the trailing icons, or `AddPage` for the `+` + /// on the Pages section header. + pub fn hit_test(&self, rect: Rect, point: Point2D) -> Option { + if !(rect).contains(point) { + return None; + } + // Pages section header — `+` add-page affordance at top-right. + let plus_x = rect.origin.x + rect.size.x - ROW_PAD_X - 12.0; + let plus_y = rect.origin.y + 8.0 + (SECTION_HEADER_HEIGHT - 14.0) / 2.0; + let slop = 4.0; + if point.x >= plus_x - slop + && point.x <= plus_x + 14.0 + slop + && point.y >= plus_y - slop + && point.y <= plus_y + 14.0 + slop + { + return Some(LayerPanelHit::AddPage); + } + // Bounded Pages / Layers viewports — a row only counts as a + // hit when the cursor is inside its (clipped) viewport, so a + // row scrolled out of view can't be clicked through. + let r = self.regions(rect); + if let Some((index, y)) = row_index_at( + self.pages.len(), + r.pages_rows_top, + r.pages.offset, + r.pages_view_h, + PAGE_ROW_HEIGHT, + point.y, + ) { + let page = &self.pages[index]; + let row = Rect { + origin: Point2D::new(rect.origin.x, y), + size: Point2D::new(rect.size.x, PAGE_ROW_HEIGHT), + }; + if (row).contains(point) && page.hovered { + // × delete button — only hit-tested when the row is + // hovered (paint shows it under the same gate). + let close_x = rect.origin.x + rect.size.x - ROW_PAD_X - 14.0; + let close_y = y + (PAGE_ROW_HEIGHT - 14.0) / 2.0; + let slop = 4.0; + if point.x >= close_x - slop + && point.x <= close_x + 14.0 + slop + && point.y >= close_y - slop + && point.y <= close_y + 14.0 + slop + { + return Some(LayerPanelHit::DeletePage(page.page_index)); + } + } + if (row).contains(point) { + return Some(LayerPanelHit::Page(page.page_index)); + } + } + if let Some((index, y)) = row_index_at( + self.items.len(), + r.layers_rows_top, + r.layers.offset, + r.layers_view_h, + LAYER_ROW_HEIGHT, + point.y, + ) { + let item = &self.items[index]; + let row = Rect { + origin: Point2D::new(rect.origin.x, y), + size: Point2D::new(rect.size.x, LAYER_ROW_HEIGHT), + }; + if !(row).contains(point) { + return None; + } + // Match the paint geometry — same 14 px icon boxes with + // 4 px slop so small mouse offsets still register. + let inner = Rect { + origin: Point2D::new(row.origin.x + 6.0, y + 2.0), + size: Point2D::new(row.size.x - 12.0, LAYER_ROW_HEIGHT - 4.0), + }; + let trailing_right = inner.origin.x + inner.size.x - 8.0; + let lock_x = trailing_right - 14.0; + let eye_x = lock_x - 22.0; + let icon_y = inner.origin.y + 6.0; + let slop = 4.0; + if item.hovered + && point.x >= lock_x - slop + && point.x <= lock_x + 14.0 + slop + && point.y >= icon_y - slop + && point.y <= icon_y + 14.0 + slop + { + return Some(LayerPanelHit::ToggleLocked(item.node_id.clone())); + } + if item.hovered + && point.x >= eye_x - slop + && point.x <= eye_x + 14.0 + slop + && point.y >= icon_y - slop + && point.y <= icon_y + 14.0 + slop + { + return Some(LayerPanelHit::ToggleHidden(item.node_id.clone())); + } + if item.has_children { + let indent = ROW_PAD_X + item.depth as f32 * 12.0; + let chev_x = inner.origin.x + indent - r.layers.horizontal_offset; + if point.x >= chev_x - slop + && point.x <= chev_x + 14.0 + slop + && point.y >= icon_y - slop + && point.y <= icon_y + 14.0 + slop + { + return Some(LayerPanelHit::ToggleCollapsed(item.node_id.clone())); + } + } + return Some(LayerPanelHit::Layer(item.node_id.clone())); + } + None + } +} diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index 4ae89c379..5116be7bf 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -35,6 +35,7 @@ pub mod tree; // `op_editor_core::EditorState`). pub mod layer_context_menu; pub mod layer_panel; +mod layer_panel_hit; mod layer_panel_paint; #[cfg(test)] mod layer_panel_tests;