diff --git a/crates/op-editor-ui/src/widgets/layer_panel.rs b/crates/op-editor-ui/src/widgets/layer_panel.rs index 04bbe608d..2e75d06cc 100644 --- a/crates/op-editor-ui/src/widgets/layer_panel.rs +++ b/crates/op-editor-ui/src/widgets/layer_panel.rs @@ -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, - /// 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( diff --git a/crates/op-editor-ui/src/widgets/layer_panel_tests.rs b/crates/op-editor-ui/src/widgets/layer_panel_tests.rs index 2c995843f..06132a9b0 100644 --- a/crates/op-editor-ui/src/widgets/layer_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/layer_panel_tests.rs @@ -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 + ); }); } diff --git a/crates/op-editor-ui/src/widgets/layer_panel_walkers.rs b/crates/op-editor-ui/src/widgets/layer_panel_walkers.rs index d7823185c..2351f4368 100644 --- a/crates/op-editor-ui/src/widgets/layer_panel_walkers.rs +++ b/crates/op-editor-ui/src/widgets/layer_panel_walkers.rs @@ -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, } } diff --git a/crates/op-editor-ui/tests/widgets_static.rs b/crates/op-editor-ui/tests/widgets_static.rs index 821e7b306..fa0c117f7 100644 --- a/crates/op-editor-ui/tests/widgets_static.rs +++ b/crates/op-editor-ui/tests/widgets_static.rs @@ -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" + ); + } + } +} diff --git a/crates/op-host-native/src/widget_host/scroll.rs b/crates/op-host-native/src/widget_host/scroll.rs index 2fb00f856..01918aacd 100644 --- a/crates/op-host-native/src/widget_host/scroll.rs +++ b/crates/op-host-native/src/widget_host/scroll.rs @@ -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; diff --git a/crates/op-host-native/src/widget_host/scroll_tests.rs b/crates/op-host-native/src/widget_host/scroll_tests.rs index 3299723f6..b6e4ff153 100644 --- a/crates/op-host-native/src/widget_host/scroll_tests.rs +++ b/crates/op-host-native/src/widget_host/scroll_tests.rs @@ -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, diff --git a/crates/op-host-web/src/widget_host/pan_tests.rs b/crates/op-host-web/src/widget_host/pan_tests.rs index 0e85988c6..e019e3688 100644 --- a/crates/op-host-web/src/widget_host/pan_tests.rs +++ b/crates/op-host-web/src/widget_host/pan_tests.rs @@ -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, diff --git a/crates/op-host-web/src/widget_host/scroll.rs b/crates/op-host-web/src/widget_host/scroll.rs index cd8fdf36f..69caeda07 100644 --- a/crates/op-host-web/src/widget_host/scroll.rs +++ b/crates/op-host-web/src/widget_host/scroll.rs @@ -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;