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.
This commit is contained in:
Kayshen-X 2026-07-04 18:58:45 +08:00
parent ec45fd99c1
commit eb853f6ccf
3 changed files with 197 additions and 186 deletions

View file

@ -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<DropTarget> {
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<LayerPanelHit> {
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)]

View file

@ -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<DropTarget> {
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<LayerPanelHit> {
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
}
}

View file

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