refactor(editor): keep layer panel scroll state typed

This commit is contained in:
Kayshen-X 2026-06-19 00:34:25 +08:00
parent 0f4364f868
commit f996b54dd4
8 changed files with 153 additions and 111 deletions

View file

@ -13,7 +13,7 @@ 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,
LayerRegionInput, LayerRegions, RenameView, WalkCx,
LayerRegionInput, LayerRegions, LayerScrollSnapshot, RenameView, WalkCx,
};
use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId};
use crate::{Color, Point2D, Rect, TextLayout};
@ -88,13 +88,9 @@ pub struct LayerPanel {
pub drag_ghost: Option<(LayerItem, f32)>,
pub now_ms: u64,
pub rename_input: Option<TextInputState>,
/// Scroll offsets (px) for the bounded Pages / Layers regions.
pub pages_scroll: f32,
pub layers_scroll: f32,
pub pages_h_scroll: f32,
pub layers_h_scroll: f32,
pub pages_content_w: f32,
pub layers_content_w: f32,
/// Scroll state for the bounded Pages / Layers regions.
pub pages_scroll: LayerScrollSnapshot,
pub layers_scroll: LayerScrollSnapshot,
}
impl LayerPanel {
@ -109,8 +105,8 @@ impl LayerPanel {
walk(child, &cx, 0, &mut items);
}
apply_layer_rename(&mut items, &rename);
let pages_content_w = pages_content_width(&pages, LAYER_PANEL_WIDTH);
let layers_content_w = layers_content_width(&items, LAYER_PANEL_WIDTH);
let pages_content_width = pages_content_width(&pages, LAYER_PANEL_WIDTH);
let layers_content_width = layers_content_width(&items, LAYER_PANEL_WIDTH);
Self {
id: WidgetId::new(1000),
pages,
@ -122,12 +118,16 @@ impl LayerPanel {
drag_ghost: None,
now_ms: 0,
rename_input: state.ui.layer_rename.as_ref().map(|r| r.input.clone()),
pages_scroll: state.editor_ui.layer_pages_scroll.offset,
layers_scroll: state.editor_ui.layer_layers_scroll.offset,
pages_h_scroll: state.editor_ui.layer_pages_h_scroll.offset,
layers_h_scroll: state.editor_ui.layer_layers_h_scroll.offset,
pages_content_w,
layers_content_w,
pages_scroll: LayerScrollSnapshot::new(
state.editor_ui.layer_pages_scroll,
state.editor_ui.layer_pages_h_scroll,
pages_content_width,
),
layers_scroll: LayerScrollSnapshot::new(
state.editor_ui.layer_layers_scroll,
state.editor_ui.layer_layers_h_scroll,
layers_content_width,
),
}
}
@ -174,8 +174,8 @@ impl LayerPanel {
walk_excluding(child, &cx, drag_source, 0, &mut items);
}
apply_layer_rename(&mut items, &rename);
let pages_content_w = pages_content_width(&pages, LAYER_PANEL_WIDTH);
let layers_content_w = layers_content_width(&items, LAYER_PANEL_WIDTH);
let pages_content_width = pages_content_width(&pages, LAYER_PANEL_WIDTH);
let layers_content_width = layers_content_width(&items, LAYER_PANEL_WIDTH);
Self {
id: WidgetId::new(1000),
pages,
@ -187,12 +187,16 @@ impl LayerPanel {
drag_ghost: None,
now_ms: 0,
rename_input: state.ui.layer_rename.as_ref().map(|r| r.input.clone()),
pages_scroll: state.editor_ui.layer_pages_scroll.offset,
layers_scroll: state.editor_ui.layer_layers_scroll.offset,
pages_h_scroll: state.editor_ui.layer_pages_h_scroll.offset,
layers_h_scroll: state.editor_ui.layer_layers_h_scroll.offset,
pages_content_w,
layers_content_w,
pages_scroll: LayerScrollSnapshot::new(
state.editor_ui.layer_pages_scroll,
state.editor_ui.layer_pages_h_scroll,
pages_content_width,
),
layers_scroll: LayerScrollSnapshot::new(
state.editor_ui.layer_layers_scroll,
state.editor_ui.layer_layers_h_scroll,
layers_content_width,
),
}
}
@ -208,12 +212,8 @@ impl LayerPanel {
drag_ghost: None,
now_ms: 0,
rename_input: None,
pages_scroll: 0.0,
layers_scroll: 0.0,
pages_h_scroll: 0.0,
layers_h_scroll: 0.0,
pages_content_w: 0.0,
layers_content_w: 0.0,
pages_scroll: LayerScrollSnapshot::default(),
layers_scroll: LayerScrollSnapshot::default(),
}
}
@ -232,12 +232,8 @@ impl LayerPanel {
rect,
pages_len: self.pages.len(),
items_len: self.items.len(),
pages_scroll: self.pages_scroll,
layers_scroll: self.layers_scroll,
pages_h_scroll: self.pages_h_scroll,
layers_h_scroll: self.layers_h_scroll,
pages_content_w: self.pages_content_w,
layers_content_w: self.layers_content_w,
pages: self.pages_scroll,
layers: self.layers_scroll,
})
}
@ -258,11 +254,11 @@ impl LayerPanel {
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_scroll;
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_scroll,
r.layers.offset,
r.layers_view_h,
point.y,
) {
@ -338,7 +334,7 @@ impl LayerPanel {
if let Some((index, y)) = row_index_at(
self.pages.len(),
r.pages_rows_top,
r.pages_scroll,
r.pages.offset,
r.pages_view_h,
point.y,
) {
@ -368,7 +364,7 @@ impl LayerPanel {
if let Some((index, y)) = row_index_at(
self.items.len(),
r.layers_rows_top,
r.layers_scroll,
r.layers.offset,
r.layers_view_h,
point.y,
) {
@ -409,7 +405,7 @@ impl LayerPanel {
}
if item.has_children {
let indent = ROW_PAD_X + item.depth as f32 * 12.0;
let chev_x = inner.origin.x + indent - r.layers_h_scroll;
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
@ -506,9 +502,9 @@ impl Widget for LayerPanel {
origin: Point2D::new(rect.origin.x, r.pages_rows_top),
size: Point2D::new(rect.size.x, r.pages_view_h),
});
for index in visible_row_range(self.pages.len(), r.pages_scroll, r.pages_view_h) {
for index in visible_row_range(self.pages.len(), r.pages.offset, r.pages_view_h) {
let page = &self.pages[index];
y = r.pages_rows_top - r.pages_scroll + index as f32 * PAGE_ROW_HEIGHT;
y = r.pages_rows_top - r.pages.offset + index as f32 * PAGE_ROW_HEIGHT;
let row = Rect {
origin: Point2D::new(rect.origin.x + 6.0, y + 2.0),
size: Point2D::new(rect.size.x - 12.0, PAGE_ROW_HEIGHT - 4.0),
@ -593,9 +589,9 @@ impl Widget for LayerPanel {
origin: Point2D::new(rect.origin.x, r.layers_rows_top),
size: Point2D::new(rect.size.x, r.layers_view_h),
});
for index in visible_row_range(self.items.len(), r.layers_scroll, r.layers_view_h) {
for index in visible_row_range(self.items.len(), r.layers.offset, r.layers_view_h) {
let item = &self.items[index];
y = r.layers_rows_top - r.layers_scroll + index as f32 * LAYER_ROW_HEIGHT;
y = r.layers_rows_top - r.layers.offset + index as f32 * LAYER_ROW_HEIGHT;
let row = Rect {
origin: Point2D::new(rect.origin.x + 6.0, y + 2.0),
size: Point2D::new(rect.size.x - 12.0, LAYER_ROW_HEIGHT - 4.0),
@ -638,7 +634,8 @@ impl Widget for LayerPanel {
};
cx.backend.save();
cx.backend.clip_rect(row);
cx.backend.translate(Point2D::new(-r.layers_h_scroll, 0.0));
cx.backend
.translate(Point2D::new(-r.layers.horizontal_offset, 0.0));
if item.has_children {
let chev_icon = if item.collapsed {
Icon::ChevronRight
@ -671,7 +668,7 @@ impl Widget for LayerPanel {
dim(self.theme.card_foreground, dim_factor)
};
let label_x = icon_x + 20.0;
let label_max_x = row.origin.x + r.layers_content_w - 16.0;
let label_max_x = row.origin.x + r.layers.content_width - 16.0;
let available_w = (label_max_x - label_x).max(0.0);
if item.renaming {
paint_rename_input(

View file

@ -495,11 +495,11 @@ fn deep_layer_tree_exposes_horizontal_scroll_range() {
let regions = panel.regions(rect);
assert!(
regions.layers_content_w > rect.size.x,
regions.layers.content_width > rect.size.x,
"deep layer content should be wider than the fixed panel viewport"
);
assert!(
regions.layers_max_h_scroll > 0.0,
regions.layers.max_horizontal_offset > 0.0,
"deep layer rows need a horizontal scroll range"
);
});
@ -518,7 +518,10 @@ fn layer_horizontal_scroll_offset_is_clamped() {
let regions = panel.regions(rect);
assert_eq!(regions.layers_h_scroll, regions.layers_max_h_scroll);
assert_eq!(
regions.layers.horizontal_offset,
regions.layers.max_horizontal_offset
);
});
}
@ -532,9 +535,15 @@ fn layer_panel_caches_content_widths_on_build() {
size: Point2D::new(LAYER_PANEL_WIDTH, 700.0),
};
assert!(panel.layers_content_w > rect.size.x);
assert_eq!(panel.regions(rect).layers_content_w, panel.layers_content_w);
assert_eq!(panel.regions(rect).pages_content_w, panel.pages_content_w);
assert!(panel.layers_scroll.content_width > rect.size.x);
assert_eq!(
panel.regions(rect).layers.content_width,
panel.layers_scroll.content_width
);
assert_eq!(
panel.regions(rect).pages.content_width,
panel.pages_scroll.content_width
);
});
}

View file

@ -15,6 +15,7 @@ use crate::widgets::layer_panel::{
};
use crate::widgets::layer_panel_paint::{approx_text_width, ROW_FONT};
use crate::Rect;
use jian_core::scroll::{self, ScrollState};
use op_editor_core::NodeId;
use jian_ops_schema::node::PenNode;
@ -277,6 +278,34 @@ pub(super) fn icon_for_node(node: &PenNode) -> Icon {
/// roughly six rows; a longer page list scrolls within it.
pub const LAYER_PAGES_VIEW_MAX: f32 = PAGE_ROW_HEIGHT * 6.0;
/// Stored scroll state for one bounded LayerPanel region.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct LayerScrollSnapshot {
pub vertical: ScrollState,
pub horizontal: ScrollState,
pub content_width: f32,
}
impl LayerScrollSnapshot {
pub fn new(vertical: ScrollState, horizontal: ScrollState, content_width: f32) -> Self {
Self {
vertical,
horizontal,
content_width,
}
}
}
/// Clamped scroll metrics for one bounded LayerPanel region.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct LayerResolvedScroll {
pub offset: f32,
pub max_offset: f32,
pub horizontal_offset: f32,
pub max_horizontal_offset: f32,
pub content_width: f32,
}
/// Resolved geometry of the LayerPanel's two bounded scroll regions
/// (Pages + Layers). Paint, hit-test and the drop-target walk all
/// derive from this single source so they stay aligned.
@ -288,26 +317,16 @@ pub struct LayerRegions {
pub pages_rows_top: f32,
/// Height of the page-row viewport.
pub pages_view_h: f32,
/// Pages scroll offset, clamped to the scrollable range.
pub pages_scroll: f32,
/// Largest valid Pages scroll offset (`content - viewport`).
pub pages_max_scroll: f32,
pub pages_h_scroll: f32,
pub pages_max_h_scroll: f32,
pub pages_content_w: f32,
/// Pages-region scroll metrics, clamped to the scrollable range.
pub pages: LayerResolvedScroll,
/// y of the Layers section header.
pub layers_header_y: f32,
/// Top y of the clipped layer-row viewport.
pub layers_rows_top: f32,
/// Height of the layer-row viewport (fills the rail's tail).
pub layers_view_h: f32,
/// Layers scroll offset, clamped to the scrollable range.
pub layers_scroll: f32,
/// Largest valid Layers scroll offset (`content - viewport`).
pub layers_max_scroll: f32,
pub layers_h_scroll: f32,
pub layers_max_h_scroll: f32,
pub layers_content_w: f32,
/// Layers-region scroll metrics, clamped to the scrollable range.
pub layers: LayerResolvedScroll,
}
#[derive(Debug, Clone, Copy)]
@ -315,68 +334,62 @@ pub struct LayerRegionInput {
pub rect: Rect,
pub pages_len: usize,
pub items_len: usize,
pub pages_scroll: f32,
pub layers_scroll: f32,
pub pages_h_scroll: f32,
pub layers_h_scroll: f32,
pub pages_content_w: f32,
pub layers_content_w: f32,
pub pages: LayerScrollSnapshot,
pub layers: LayerScrollSnapshot,
}
/// Compute the bounded Pages / Layers scroll-region geometry for a
/// LayerPanel painted into `rect`. `pages_scroll` / `layers_scroll`
/// are the raw stored offsets; the returned values are clamped to
/// LayerPanel painted into `rect`. Stored offsets are kept as
/// [`ScrollState`] snapshots; the returned metrics are clamped to
/// each region's scrollable range.
pub fn layer_regions(input: LayerRegionInput) -> LayerRegions {
let LayerRegionInput {
rect,
pages_len,
items_len,
pages_scroll,
layers_scroll,
pages_h_scroll,
layers_h_scroll,
pages_content_w,
layers_content_w,
pages,
layers,
} = input;
let pages_header_y = rect.origin.y + 8.0;
let pages_rows_top = pages_header_y + SECTION_HEADER_HEIGHT;
let pages_content = pages_len as f32 * PAGE_ROW_HEIGHT;
let pages_view_h = pages_content.min(LAYER_PAGES_VIEW_MAX);
let pages_max_scroll = (pages_content - pages_view_h).max(0.0);
let pages_scroll = pages_scroll.clamp(0.0, pages_max_scroll);
let pages_content_w = pages_content_w.max(rect.size.x);
let pages_max_h_scroll = (pages_content_w - rect.size.x).max(0.0);
let pages_h_scroll = pages_h_scroll.clamp(0.0, pages_max_h_scroll);
let pages = resolve_layer_scroll(pages, pages_content, pages_view_h, rect.size.x);
let layers_header_y = pages_rows_top + pages_view_h + SECTION_GAP;
let layers_rows_top = layers_header_y + SECTION_HEADER_HEIGHT;
let layers_view_h = (rect.origin.y + rect.size.y - 8.0 - layers_rows_top).max(0.0);
let layers_content = items_len.max(1) as f32 * LAYER_ROW_HEIGHT;
let layers_max_scroll = (layers_content - layers_view_h).max(0.0);
let layers_scroll = layers_scroll.clamp(0.0, layers_max_scroll);
let layers_content_w = layers_content_w.max(rect.size.x);
let layers_max_h_scroll = (layers_content_w - rect.size.x).max(0.0);
let layers_h_scroll = layers_h_scroll.clamp(0.0, layers_max_h_scroll);
let layers = resolve_layer_scroll(layers, layers_content, layers_view_h, rect.size.x);
LayerRegions {
pages_header_y,
pages_rows_top,
pages_view_h,
pages_scroll,
pages_max_scroll,
pages_h_scroll,
pages_max_h_scroll,
pages_content_w,
pages,
layers_header_y,
layers_rows_top,
layers_view_h,
layers_scroll,
layers_max_scroll,
layers_h_scroll,
layers_max_h_scroll,
layers_content_w,
layers,
}
}
fn resolve_layer_scroll(
stored: LayerScrollSnapshot,
content_height: f32,
view_height: f32,
viewport_width: f32,
) -> LayerResolvedScroll {
let max_offset = scroll::max_offset(content_height, view_height);
let content_width = stored.content_width.max(viewport_width);
let max_horizontal_offset = scroll::max_offset(content_width, viewport_width);
LayerResolvedScroll {
offset: stored.vertical.offset.clamp(0.0, max_offset),
max_offset,
horizontal_offset: stored.horizontal.offset.clamp(0.0, max_horizontal_offset),
max_horizontal_offset,
content_width,
}
}

View file

@ -221,3 +221,26 @@ fn property_row_advertises_label_and_value() {
assert_eq!(node.role(), accesskit::Role::Group);
assert_eq!(node.label(), Some("Width 960"));
}
#[test]
fn layer_panel_scroll_view_model_keeps_offsets_in_scroll_state() {
let layer_panel = include_str!("../src/widgets/layer_panel.rs");
let walkers = include_str!("../src/widgets/layer_panel_walkers.rs");
for source in [layer_panel, walkers] {
for needle in [
"pages_scroll: f32",
"layers_scroll: f32",
"pages_h_scroll: f32",
"layers_h_scroll: f32",
"pages_max_scroll: f32",
"layers_max_scroll: f32",
"pages_max_h_scroll: f32",
"layers_max_h_scroll: f32",
] {
assert!(
!source.contains(needle),
"LayerPanel scroll state should not expose naked `{needle}` fields"
);
}
}
}

View file

@ -325,7 +325,7 @@ impl WidgetHostNative {
&& scroll_by_max(
&mut self.editor_state.editor_ui.layer_layers_scroll,
-delta_y,
r.layers_max_scroll,
r.layers.max_offset,
)
{
changed = true;
@ -334,7 +334,7 @@ impl WidgetHostNative {
&& scroll_by_max(
&mut self.editor_state.editor_ui.layer_layers_h_scroll,
-delta_x,
r.layers_max_h_scroll,
r.layers.max_horizontal_offset,
)
{
changed = true;
@ -344,7 +344,7 @@ impl WidgetHostNative {
&& scroll_by_max(
&mut self.editor_state.editor_ui.layer_pages_scroll,
-delta_y,
r.pages_max_scroll,
r.pages.max_offset,
)
{
changed = true;
@ -353,7 +353,7 @@ impl WidgetHostNative {
&& scroll_by_max(
&mut self.editor_state.editor_ui.layer_pages_h_scroll,
-delta_x,
r.pages_max_h_scroll,
r.pages.max_horizontal_offset,
)
{
changed = true;

View file

@ -49,7 +49,7 @@ fn layer_panel_trackpad_pan_scrolls_horizontally() {
),
};
let regions = panel.regions(rect);
assert!(regions.layers_max_h_scroll > 0.0);
assert!(regions.layers.max_horizontal_offset > 0.0);
assert!(host.apply_pan_gesture(
80.0,

View file

@ -102,7 +102,7 @@ fn layer_panel_trackpad_pan_scrolls_horizontally() {
),
};
let regions = panel.regions(rect);
assert!(regions.layers_max_h_scroll > 0.0);
assert!(regions.layers.max_horizontal_offset > 0.0);
assert!(host.apply_pan_gesture(
80.0,

View file

@ -214,7 +214,7 @@ impl WidgetHost {
&& scroll_by_max(
&mut self.editor_state.editor_ui.layer_layers_scroll,
-delta_y,
r.layers_max_scroll,
r.layers.max_offset,
)
{
changed = true;
@ -223,7 +223,7 @@ impl WidgetHost {
&& scroll_by_max(
&mut self.editor_state.editor_ui.layer_layers_h_scroll,
-delta_x,
r.layers_max_h_scroll,
r.layers.max_horizontal_offset,
)
{
changed = true;
@ -233,7 +233,7 @@ impl WidgetHost {
&& scroll_by_max(
&mut self.editor_state.editor_ui.layer_pages_scroll,
-delta_y,
r.pages_max_scroll,
r.pages.max_offset,
)
{
changed = true;
@ -242,7 +242,7 @@ impl WidgetHost {
&& scroll_by_max(
&mut self.editor_state.editor_ui.layer_pages_h_scroll,
-delta_x,
r.pages_max_h_scroll,
r.pages.max_horizontal_offset,
)
{
changed = true;