From e4125fd75dd4e369e4474bf8499f986e76db6ac5 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 14 Jun 2026 01:13:03 +0800 Subject: [PATCH] refactor(editor): migrate scroll fields to shared scroll state --- crates/op-editor-core/src/agent_settings.rs | 26 +++++- crates/op-editor-core/src/chat.rs | 19 +++- crates/op-editor-core/src/codegen.rs | 23 +++-- crates/op-editor-core/src/editor_ui_state.rs | 53 +++++++---- .../src/widgets/agent_settings_builtin.rs | 10 +- .../widgets/agent_settings_builtin_parts.rs | 28 +++--- .../src/widgets/agent_settings_panel.rs | 21 +++-- .../src/widgets/ai_chat_model_picker.rs | 17 ++-- .../op-editor-ui/src/widgets/ai_chat_panel.rs | 2 +- .../op-editor-ui/src/widgets/layer_panel.rs | 16 ++-- .../src/widgets/layer_panel_tests.rs | 2 +- .../src/widgets/property_panel.rs | 2 +- .../src/widgets/property_panel_code.rs | 8 +- .../widgets/property_panel_code_complete.rs | 3 +- .../src/widgets/property_panel_code_tests.rs | 4 +- .../src/widgets/variables_panel.rs | 2 +- .../src/widgets/variables_panel/ux_tests.rs | 2 +- crates/op-host-desktop/src/codegen_session.rs | 2 +- .../src/widget_host/input_tests.rs | 7 +- .../src/widget_host/keyboard.rs | 4 +- .../src/widget_host/press_helpers.rs | 7 +- .../src/widget_host/property_dispatch.rs | 17 ++-- .../op-host-native/src/widget_host/scroll.rs | 91 +++++++++++-------- .../src/widget_host/scroll_tests.rs | 4 +- .../widget_host/variables_panel_ux_tests.rs | 8 +- crates/op-host-web/src/codegen_web.rs | 2 +- crates/op-host-web/src/widget_host.rs | 22 +++-- .../src/widget_host/agent_settings_press.rs | 7 +- .../op-host-web/src/widget_host/keyboard.rs | 4 +- .../src/widget_host/property_dispatch.rs | 11 ++- .../src/widget_host/property_hover_tests.rs | 7 +- crates/op-host-web/src/widget_host/scroll.rs | 49 ++++++---- .../src/widget_host/variables_panel_tests.rs | 6 +- 33 files changed, 292 insertions(+), 194 deletions(-) diff --git a/crates/op-editor-core/src/agent_settings.rs b/crates/op-editor-core/src/agent_settings.rs index 7029c9ea5..ce015db6d 100644 --- a/crates/op-editor-core/src/agent_settings.rs +++ b/crates/op-editor-core/src/agent_settings.rs @@ -410,14 +410,14 @@ pub struct AgentSettings { pub builtin_agents: Vec, pub builtin_agent_draft: Option, pub builtin_preset_menu_open: Option, - pub builtin_preset_menu_scroll: f32, + pub builtin_preset_menu_scroll: jian_core::scroll::ScrollState, pub builtin_preset_menu_hover: Option, pub next_builtin_agent_id: u64, pub acp_agents: Vec, pub acp_agent_draft: Option, pub next_acp_agent_id: u64, /// Vertical scroll offset of the right content pane in px. - pub scroll_y: f32, + pub scroll_y: jian_core::scroll::ScrollState, pub mcp_server: McpServer, pub mcp_cli_enabled: [bool; 6], pub mcp_client_config_copied_at_ms: Option, @@ -468,13 +468,13 @@ impl Default for AgentSettings { builtin_agents: Vec::new(), builtin_agent_draft: None, builtin_preset_menu_open: None, - builtin_preset_menu_scroll: 0.0, + builtin_preset_menu_scroll: Default::default(), builtin_preset_menu_hover: None, next_builtin_agent_id: 1, acp_agents: Vec::new(), acp_agent_draft: None, next_acp_agent_id: 1, - scroll_y: 0.0, + scroll_y: Default::default(), mcp_server: McpServer::default(), mcp_cli_enabled: [false; 6], mcp_client_config_copied_at_ms: None, @@ -586,7 +586,7 @@ impl AgentSettings { pub fn cancel_builtin_agent_draft(&mut self) { self.builtin_agent_draft = None; self.builtin_preset_menu_open = None; - self.builtin_preset_menu_scroll = 0.0; + self.builtin_preset_menu_scroll.offset = 0.0; self.builtin_preset_menu_hover = None; } @@ -777,3 +777,19 @@ impl AgentSettings { pub enum AgentSettingsDrag { Reserved, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agent_settings_scroll_fields_use_scroll_state() { + let mut s = AgentSettings::default(); + + s.builtin_preset_menu_scroll.offset = 18.0; + s.scroll_y.offset = 42.0; + + assert_eq!(s.builtin_preset_menu_scroll.offset, 18.0); + assert_eq!(s.scroll_y.offset, 42.0); + } +} diff --git a/crates/op-editor-core/src/chat.rs b/crates/op-editor-core/src/chat.rs index 79b0ac85b..0744cab48 100644 --- a/crates/op-editor-core/src/chat.rs +++ b/crates/op-editor-core/src/chat.rs @@ -344,7 +344,7 @@ pub struct ChatState { /// pinned above the input, mirroring the TS checklist header. pub checklist_collapsed: bool, /// Vertical scroll offset inside the fixed design checklist rows. - pub checklist_scroll: f32, + pub checklist_scroll: jian_core::scroll::ScrollState, /// Set by `begin_send` to the just-sent user text; the desktop /// event loop drains this each frame. `None` = idle. pub pending_send: Option, @@ -418,7 +418,7 @@ impl Default for ChatState { collapsed: false, maximized: false, checklist_collapsed: false, - checklist_scroll: 0.0, + checklist_scroll: Default::default(), pending_send: None, pending_new_chat: false, pending_stop_chat: false, @@ -532,7 +532,7 @@ impl ChatState { // Empty streaming assistant bubble — provider deltas append here. self.messages.push(ChatMessage::assistant_streaming()); self.input.set_text(""); - self.checklist_scroll = 0.0; + self.checklist_scroll.offset = 0.0; self.pending_send = Some(trimmed); true } @@ -578,7 +578,7 @@ impl ChatState { self.pending_stop_chat = false; self.pending_copy_text = None; self.transcript_selection = None; - self.checklist_scroll = 0.0; + self.checklist_scroll.offset = 0.0; self.pending_attachments.clear(); self.pending_attachment_pick = false; self.pending_new_chat = true; @@ -739,7 +739,7 @@ impl ChatState { pub fn toggle_checklist_collapsed(&mut self) { self.checklist_collapsed = !self.checklist_collapsed; if self.checklist_collapsed { - self.checklist_scroll = 0.0; + self.checklist_scroll.offset = 0.0; } } @@ -1182,6 +1182,15 @@ mod tests { assert!(!chat.checklist_collapsed); } + #[test] + fn checklist_scroll_uses_scroll_state() { + let mut chat = ChatState::default(); + + chat.checklist_scroll.offset = 20.0; + + assert_eq!(chat.checklist_scroll.offset, 20.0); + } + #[test] fn nearest_anchor_picks_corner() { let p = crate::render_backend::Point2D::new(10.0, 10.0); diff --git a/crates/op-editor-core/src/codegen.rs b/crates/op-editor-core/src/codegen.rs index d41b69b7a..661ab39cc 100644 --- a/crates/op-editor-core/src/codegen.rs +++ b/crates/op-editor-core/src/codegen.rs @@ -161,13 +161,13 @@ pub struct AssetMeta { } /// The Code panel's full state. Mirror of `ChatState`'s role for chat. -/// `PartialEq` only (not `Eq`) — `framework_scroll` carries an `f32`. +/// `PartialEq` only (not `Eq`) — scroll offsets carry `f32` values. #[derive(Debug, Clone, PartialEq)] pub struct CodegenState { pub framework: Framework, /// Horizontal scroll offset (px, ≥ 0) of the framework tab strip, so the /// single-row selector scrolls to reach off-screen frameworks (TS parity). - pub framework_scroll: f32, + pub framework_scroll: jian_core::scroll::ScrollState, /// The inactive framework chip the cursor is hovering, for a subtle /// background highlight. `None` when the cursor is off the strip. pub framework_hover: Option, @@ -177,7 +177,7 @@ pub struct CodegenState { pub progress: CodeGenProgress, pub code: String, /// Vertical scroll offset (px, >= 0) inside the generated-code preview. - pub code_scroll: f32, + pub code_scroll: jian_core::scroll::ScrollState, /// Text selection inside the generated-code preview. pub code_selection: Option, pub degraded: bool, @@ -210,13 +210,13 @@ impl Default for CodegenState { fn default() -> Self { Self { framework: Framework::React, - framework_scroll: 0.0, + framework_scroll: Default::default(), framework_hover: None, action_hover: None, phase: CodegenPhase::Idle, progress: CodeGenProgress::default(), code: String::new(), - code_scroll: 0.0, + code_scroll: Default::default(), code_selection: None, degraded: false, assets: Vec::new(), @@ -282,7 +282,7 @@ mod tests { assert_eq!(s.framework, Framework::React); assert_eq!(s.phase, CodegenPhase::Idle); assert!(s.code.is_empty()); - assert_eq!(s.code_scroll, 0.0); + assert_eq!(s.code_scroll.offset, 0.0); assert!(!s.degraded); assert!(s.error.is_none()); assert!(!s.pending_generate); @@ -292,6 +292,17 @@ mod tests { assert!(!s.pending_cancel); } + #[test] + fn codegen_scroll_fields_use_scroll_state() { + let mut s = CodegenState::default(); + + s.framework_scroll.offset = 16.0; + s.code_scroll.offset = 32.0; + + assert_eq!(s.framework_scroll.offset, 16.0); + assert_eq!(s.code_scroll.offset, 32.0); + } + #[test] fn selected_code_text_returns_non_collapsed_range() { let s = CodegenState { diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index 04c03271d..b967c0cc6 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -835,20 +835,20 @@ pub struct EditorUiState { /// (≥ 0). A wheel / trackpad pan over the inspector advances it; /// paint + hit-test shift the section content up by this amount /// so a tall inspector (many effects, etc.) stays reachable. - pub property_panel_scroll: f32, + pub property_panel_scroll: jian_core::scroll::ScrollState, /// Vertical scroll offset (px, ≥ 0) of the LayerPanel's 页面 /// (Pages) section — that section has a bounded height, so a /// long page list scrolls within it. - pub layer_pages_scroll: f32, + pub layer_pages_scroll: jian_core::scroll::ScrollState, /// Vertical scroll offset (px, ≥ 0) of the LayerPanel's 图层 /// (Layers) section row viewport. - pub layer_layers_scroll: f32, + pub layer_layers_scroll: jian_core::scroll::ScrollState, /// Horizontal scroll offset (px, ≥ 0) of the LayerPanel's 页面 /// row content. The row chrome stays fixed; only tree content shifts. - pub layer_pages_h_scroll: f32, + pub layer_pages_h_scroll: jian_core::scroll::ScrollState, /// Horizontal scroll offset (px, ≥ 0) of the LayerPanel's 图层 /// tree content. Needed for deeply nested layer trees. - pub layer_layers_h_scroll: f32, + pub layer_layers_h_scroll: jian_core::scroll::ScrollState, /// "Import from Figma" modal. pub figma_import_open: bool, /// Which Figma-import target the cursor is over (close / drop-zone) @@ -1045,7 +1045,7 @@ pub struct EditorUiState { /// Whether the variables-panel search box owns the keyboard. pub variables_search_focus: bool, /// Vertical scroll offset (px) of the variables row list. - pub variables_scroll: f32, + pub variables_scroll: jian_core::scroll::ScrollState, /// Variable row whose `⋯` overflow menu (Rename / Delete) is open. /// Indexes the UNFILTERED `doc.variables` order. pub variables_row_menu: Option, @@ -1198,11 +1198,11 @@ impl Default for EditorUiState { export_picker_hover: None, property_action_hover: None, property_tab_hover: None, - property_panel_scroll: 0.0, - layer_pages_scroll: 0.0, - layer_layers_scroll: 0.0, - layer_pages_h_scroll: 0.0, - layer_layers_h_scroll: 0.0, + property_panel_scroll: Default::default(), + layer_pages_scroll: Default::default(), + layer_layers_scroll: Default::default(), + layer_pages_h_scroll: Default::default(), + layer_layers_h_scroll: Default::default(), figma_import_open: false, figma_import_hover: None, figma_import_in_progress: false, @@ -1274,7 +1274,7 @@ impl Default for EditorUiState { variable_row_input: jian_core::text_input::TextInputState::default(), variables_search: String::new(), variables_search_focus: false, - variables_scroll: 0.0, + variables_scroll: Default::default(), variables_row_menu: None, variables_panel_size: None, effect_param_focus: None, @@ -1498,10 +1498,10 @@ impl EditorUiState { self.hovered_layer_id = None; self.hovered_page_index = None; self.layer_context_menu = None; - self.layer_pages_scroll = 0.0; - self.layer_layers_scroll = 0.0; - self.layer_pages_h_scroll = 0.0; - self.layer_layers_h_scroll = 0.0; + self.layer_pages_scroll.offset = 0.0; + self.layer_layers_scroll.offset = 0.0; + self.layer_pages_h_scroll.offset = 0.0; + self.layer_layers_h_scroll.offset = 0.0; self.collapsed_layers.clear(); self.last_layer_click = None; self.last_canvas_click = None; @@ -1523,7 +1523,7 @@ impl EditorUiState { // metric and is preserved. self.variables_search.clear(); self.variables_search_focus = false; - self.variables_scroll = 0.0; + self.variables_scroll.offset = 0.0; self.variables_row_menu = None; self.effect_param_focus = None; // Document-derived: set true only by a Figma import to keep that @@ -1555,6 +1555,25 @@ mod tests { assert!(c.collapsed_layers.is_empty()); } + #[test] + fn editor_pixel_scroll_fields_use_scroll_state() { + let mut s = EditorUiState::default(); + + s.property_panel_scroll.offset = 12.0; + s.layer_pages_scroll.offset = 24.0; + s.layer_layers_scroll.offset = 36.0; + s.layer_pages_h_scroll.offset = 48.0; + s.layer_layers_h_scroll.offset = 60.0; + s.variables_scroll.offset = 72.0; + + assert_eq!(s.property_panel_scroll.offset, 12.0); + assert_eq!(s.layer_pages_scroll.offset, 24.0); + assert_eq!(s.layer_layers_scroll.offset, 36.0); + assert_eq!(s.layer_pages_h_scroll.offset, 48.0); + assert_eq!(s.layer_layers_h_scroll.offset, 60.0); + assert_eq!(s.variables_scroll.offset, 72.0); + } + #[test] fn theme_mode_flips() { assert_eq!(ThemeMode::Dark.flipped(), ThemeMode::Light); diff --git a/crates/op-editor-ui/src/widgets/agent_settings_builtin.rs b/crates/op-editor-ui/src/widgets/agent_settings_builtin.rs index 5deb45fec..6bd1b2099 100644 --- a/crates/op-editor-ui/src/widgets/agent_settings_builtin.rs +++ b/crates/op-editor-ui/src/widgets/agent_settings_builtin.rs @@ -93,7 +93,7 @@ pub fn hit_test(content: Rect, settings: &AgentSettings, point: Point2D) -> Buil if let Some(preset) = agent_settings_builtin_parts::preset_at( card, point, - settings.builtin_preset_menu_scroll, + settings.builtin_preset_menu_scroll.offset, ) { return BuiltinHit::SelectPreset { index: Some(index), @@ -146,7 +146,7 @@ pub fn hit_test(content: Rect, settings: &AgentSettings, point: Point2D) -> Buil if let Some(preset) = agent_settings_builtin_parts::preset_at( card, point, - settings.builtin_preset_menu_scroll, + settings.builtin_preset_menu_scroll.offset, ) { return BuiltinHit::SelectPreset { index: None, @@ -207,7 +207,11 @@ pub fn preset_hover_at( point: Point2D, ) -> Option { let card = open_preset_menu_card(content, settings, point)?; - agent_settings_builtin_parts::preset_hover_at(card, point, settings.builtin_preset_menu_scroll) + agent_settings_builtin_parts::preset_hover_at( + card, + point, + settings.builtin_preset_menu_scroll.offset, + ) } pub fn preset_scroll_max_at( diff --git a/crates/op-editor-ui/src/widgets/agent_settings_builtin_parts.rs b/crates/op-editor-ui/src/widgets/agent_settings_builtin_parts.rs index c131be49a..08ecf84c6 100644 --- a/crates/op-editor-ui/src/widgets/agent_settings_builtin_parts.rs +++ b/crates/op-editor-ui/src/widgets/agent_settings_builtin_parts.rs @@ -159,8 +159,10 @@ pub fn paint_preset_menu( cx.backend.stroke_round_rect(menu, 6.0, theme.border, 1.0); cx.backend.save(); cx.backend.clip_rect(menu); - cx.backend - .translate(Point2D::new(0.0, -settings.builtin_preset_menu_scroll)); + cx.backend.translate(Point2D::new( + 0.0, + -settings.builtin_preset_menu_scroll.offset, + )); for (i, preset) in BUILTIN_AGENT_PRESETS.iter().enumerate() { let item = preset_item_rect(card, i); let active = agent.preset == preset.key; @@ -335,21 +337,23 @@ fn preset_menu_view_height() -> f32 { preset_content_height().min(max_h) } -fn paint_menu_scrollbar(cx: &mut PaintCx<'_>, theme: &Theme, menu: Rect, scroll: f32) { - let max = preset_scroll_max(); - if max <= 0.0 { - return; - } +fn paint_menu_scrollbar( + cx: &mut PaintCx<'_>, + theme: &Theme, + menu: Rect, + scroll: jian_core::scroll::ScrollState, +) { let content_h = preset_content_height(); - let thumb_h = (menu.size.y * menu.size.y / content_h).clamp(24.0, menu.size.y); - let range = (menu.size.y - thumb_h).max(1.0); - let t = (scroll.clamp(0.0, max) / max).clamp(0.0, 1.0); + let track_h = (menu.size.y - 8.0).max(0.0); + let Some(thumb_geom) = scroll.thumb(track_h, content_h, menu.size.y, 24.0) else { + return; + }; let thumb = Rect { origin: Point2D::new( menu.origin.x + menu.size.x - 5.0, - menu.origin.y + 4.0 + t * range, + menu.origin.y + 4.0 + thumb_geom.offset, ), - size: Point2D::new(2.0, (thumb_h - 8.0).max(16.0)), + size: Point2D::new(2.0, thumb_geom.len), }; cx.backend.fill_round_rect( thumb, diff --git a/crates/op-editor-ui/src/widgets/agent_settings_panel.rs b/crates/op-editor-ui/src/widgets/agent_settings_panel.rs index 8f89aa180..f1ded7e8c 100644 --- a/crates/op-editor-ui/src/widgets/agent_settings_panel.rs +++ b/crates/op-editor-ui/src/widgets/agent_settings_panel.rs @@ -149,7 +149,7 @@ impl<'a> AgentSettingsPanel<'a> { } // Translate the cursor into the scrolled content frame // for hit-tests over scrollable rows. - let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y); + let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y.offset); match self.settings.tab { AgentSettingsTab::Agents => { match agent_settings_builtin::hit_test( @@ -303,7 +303,7 @@ impl<'a> AgentSettingsPanel<'a> { if !(panel).contains(point) { return None; } - let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y); + let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y.offset); (0..AgentProvider::ALL.len()) .find(|&i| (agent_card_rect_in(panel, i, &self.settings)).contains(scrolled)) } @@ -312,7 +312,7 @@ impl<'a> AgentSettingsPanel<'a> { if !(panel).contains(point) { return None; } - let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y); + let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y.offset); agent_settings_builtin::card_at(content_rect(panel), &self.settings, scrolled) } @@ -321,7 +321,7 @@ impl<'a> AgentSettingsPanel<'a> { return None; } let content = content_rect(panel); - let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y); + let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y.offset); let section_y = acp_section_y(content, &self.settings); agent_settings_acp::card_at(content, &self.settings, scrolled, section_y) } @@ -334,7 +334,7 @@ impl<'a> AgentSettingsPanel<'a> { if !(panel).contains(point) { return None; } - let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y); + let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y.offset); agent_settings_builtin::preset_hover_at(content_rect(panel), &self.settings, scrolled) } @@ -342,7 +342,7 @@ impl<'a> AgentSettingsPanel<'a> { if !(panel).contains(point) { return None; } - let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y); + let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y.offset); agent_settings_builtin::preset_scroll_max_at(content_rect(panel), &self.settings, scrolled) } @@ -350,7 +350,7 @@ impl<'a> AgentSettingsPanel<'a> { if !(panel).contains(point) { return false; } - let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y); + let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y.offset); agent_settings_images::search_test_button_hover_at( content_rect(panel), &self.settings, @@ -362,7 +362,7 @@ impl<'a> AgentSettingsPanel<'a> { if !(panel).contains(point) { return false; } - let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y); + let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y.offset); agent_settings_images::add_gen_button_hover_at( content_rect(panel), &self.settings, @@ -378,7 +378,7 @@ impl<'a> AgentSettingsPanel<'a> { if !(panel).contains(point) { return None; } - let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y); + let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y.offset); agent_settings_images::profile_test_button_hover_at( content_rect(panel), &self.settings, @@ -447,7 +447,8 @@ fn paint_panel( let content_rect = content_rect(panel); cx.backend.save(); cx.backend.clip_rect(content_rect); - cx.backend.translate(Point2D::new(0.0, -settings.scroll_y)); + cx.backend + .translate(Point2D::new(0.0, -settings.scroll_y.offset)); match settings.tab { AgentSettingsTab::Agents => { paint_agents_tab(cx, theme, settings, _ui, content_rect, now_ms) diff --git a/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs b/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs index a716bbbbc..1ca58069b 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs @@ -434,19 +434,14 @@ pub fn paint_model_picker( // unscrolled card space. Shown only when the content overflows. let content_h = picker_list_height(models, search); let view_h = list_rect.size.y; - if content_h > view_h + 0.5 { - let track_h = view_h - 8.0; - let thumb_h = (track_h * view_h / content_h).max(24.0); - let max_scroll = (content_h - view_h).max(0.0); - let t = if max_scroll > 0.0 { - (scroll / max_scroll).clamp(0.0, 1.0) - } else { - 0.0 - }; - let thumb_y = list_rect.origin.y + 4.0 + t * (track_h - thumb_h); + let track_h = (view_h - 8.0).max(0.0); + if let Some(thumb_geom) = + (jian_core::scroll::ScrollState { offset: scroll }).thumb(track_h, content_h, view_h, 24.0) + { + let thumb_y = list_rect.origin.y + 4.0 + thumb_geom.offset; let thumb = Rect { origin: Point2D::new(rect.origin.x + rect.size.x - 6.0, thumb_y), - size: Point2D::new(3.0, thumb_h), + size: Point2D::new(3.0, thumb_geom.len), }; cx.backend .fill_round_rect(thumb, 1.5, theme.muted_foreground); diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs index ba0bbda80..92363f452 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -476,7 +476,7 @@ impl<'a> Widget for AIChatPlaceholder<'a> { fixed_checklist_rect(rect, input_h, checklist_h), &self.state.messages, self.state.checklist_collapsed, - self.state.checklist_scroll, + self.state.checklist_scroll.offset, ); } let input_rect = Rect { diff --git a/crates/op-editor-ui/src/widgets/layer_panel.rs b/crates/op-editor-ui/src/widgets/layer_panel.rs index afaa8c440..030300d22 100644 --- a/crates/op-editor-ui/src/widgets/layer_panel.rs +++ b/crates/op-editor-ui/src/widgets/layer_panel.rs @@ -118,10 +118,10 @@ 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, - layers_scroll: state.editor_ui.layer_layers_scroll, - pages_h_scroll: state.editor_ui.layer_pages_h_scroll, - layers_h_scroll: state.editor_ui.layer_layers_h_scroll, + 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, } } @@ -174,10 +174,10 @@ 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, - layers_scroll: state.editor_ui.layer_layers_scroll, - pages_h_scroll: state.editor_ui.layer_pages_h_scroll, - layers_h_scroll: state.editor_ui.layer_layers_h_scroll, + 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, } } 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 fed0df0ec..2ae7fa61f 100644 --- a/crates/op-editor-ui/src/widgets/layer_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/layer_panel_tests.rs @@ -508,7 +508,7 @@ fn deep_layer_tree_exposes_horizontal_scroll_range() { fn layer_horizontal_scroll_offset_is_clamped() { run_deep_layer_fixture(|| { let mut state = state_from(&nested_frame_doc(50)); - state.editor_ui.layer_layers_h_scroll = 10_000.0; + state.editor_ui.layer_layers_h_scroll.offset = 10_000.0; let panel = LayerPanel::from_editor(&state); let rect = Rect { origin: Point2D::new(0.0, 0.0), diff --git a/crates/op-editor-ui/src/widgets/property_panel.rs b/crates/op-editor-ui/src/widgets/property_panel.rs index 5c3ad4c15..1ed050cd4 100644 --- a/crates/op-editor-ui/src/widgets/property_panel.rs +++ b/crates/op-editor-ui/src/widgets/property_panel.rs @@ -403,7 +403,7 @@ impl PropertyPanel { export_scale_picker_open: ui.export_scale_picker_open, export_format_picker_open: ui.export_format_picker_open, export_picker_hover: ui.export_picker_hover, - scroll: ui.property_panel_scroll.max(0.0), + scroll: ui.property_panel_scroll.offset.max(0.0), locale: ui.locale, // Inert in the multi-select aggregate view. effect_param_focus: if is_multi { diff --git a/crates/op-editor-ui/src/widgets/property_panel_code.rs b/crates/op-editor-ui/src/widgets/property_panel_code.rs index b9563acee..dc95d3979 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_code.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_code.rs @@ -479,7 +479,7 @@ fn paint_framework_chips( // Clip the strip so scrolled-off chips don't bleed past the panel edges. cx.backend.save(); cx.backend.clip_rect(band); - for (fw, chip) in framework_chip_rects(x, y, w, state.framework_scroll) { + for (fw, chip) in framework_chip_rects(x, y, w, state.framework_scroll.offset) { // Skip chips fully outside the visible band (cheap cull). if chip.origin.x + chip.size.x < band.origin.x || chip.origin.x > band.origin.x + band.size.x @@ -523,7 +523,7 @@ fn paint_framework_chips( theme, Icon::ChevronLeft, left, - state.framework_scroll > 0.0, + state.framework_scroll.offset > 0.0, action_hovered(state, CodegenHover::ScrollFrameworksLeft), ); paint_chevron( @@ -531,7 +531,7 @@ fn paint_framework_chips( theme, Icon::ChevronRight, right, - state.framework_scroll < max, + state.framework_scroll.offset < max, action_hovered(state, CodegenHover::ScrollFrameworksRight), ); } @@ -908,7 +908,7 @@ fn code_action_rects_with_bottom( } let inset = if zones.is_some() { CHEVRON_ZONE_W } else { 0.0 }; let (band_l, band_r) = (x + PAD_X + inset, x + w - PAD_X - inset); - for (fw, rect) in framework_chip_rects(x, chips_y, w, state.framework_scroll) { + for (fw, rect) in framework_chip_rects(x, chips_y, w, state.framework_scroll.offset) { // Clamp the clickable rect to the visible (chevron-inset) band so a // chip's scrolled-off / clipped portion is NOT clickable (matches the // painter's clip and the hover hit-test in `framework_at`). diff --git a/crates/op-editor-ui/src/widgets/property_panel_code_complete.rs b/crates/op-editor-ui/src/widgets/property_panel_code_complete.rs index c9b78e2d2..9d52b03c1 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_code_complete.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_code_complete.rs @@ -126,7 +126,7 @@ pub(super) fn paint_complete_body_in_panel( theme, &state.code, state.code_selection, - state.code_scroll, + state.code_scroll.offset, code_rect, ); let actions = [ @@ -158,6 +158,7 @@ pub(super) fn code_text_offset_at_in_panel( } let scroll = state .code_scroll + .offset .clamp(0.0, max_scroll_for_code(&state.code, rect)); let line_index = ((point.y - code_text_top(rect) + scroll) / CODE_LINE_H) .floor() diff --git a/crates/op-editor-ui/src/widgets/property_panel_code_tests.rs b/crates/op-editor-ui/src/widgets/property_panel_code_tests.rs index 4686274b4..9485a1376 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_code_tests.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_code_tests.rs @@ -532,7 +532,9 @@ fn complete_code_preview_hit_test_respects_vertical_scroll() { .join("\n"); let state = CodegenState { phase: CodegenPhase::Complete, - code_scroll: 16.0 * 10.0, + code_scroll: jian_core::scroll::ScrollState { + offset: 16.0 * 10.0, + }, code, ..CodegenState::default() }; diff --git a/crates/op-editor-ui/src/widgets/variables_panel.rs b/crates/op-editor-ui/src/widgets/variables_panel.rs index 13e03668c..718d46656 100644 --- a/crates/op-editor-ui/src/widgets/variables_panel.rs +++ b/crates/op-editor-ui/src/widgets/variables_panel.rs @@ -251,7 +251,7 @@ impl VariablesPanel { search: state.editor_ui.variables_search.clone(), search_focus: state.editor_ui.variables_search_focus, search_input, - scroll: state.editor_ui.variables_scroll, + scroll: state.editor_ui.variables_scroll.offset, row_menu_open: state.editor_ui.variables_row_menu, hover: state.editor_ui.variables_panel_hover, editing_name_row: state.editor_ui.variable_row_focus.and_then(|f| match f { diff --git a/crates/op-editor-ui/src/widgets/variables_panel/ux_tests.rs b/crates/op-editor-ui/src/widgets/variables_panel/ux_tests.rs index 555837623..4f2c71445 100644 --- a/crates/op-editor-ui/src/widgets/variables_panel/ux_tests.rs +++ b/crates/op-editor-ui/src/widgets/variables_panel/ux_tests.rs @@ -64,7 +64,7 @@ fn filtered_rows_keep_unfiltered_source_indices() { #[test] fn scroll_clamps_and_offsets_row_hits() { let mut state = state_with_n_colors(20); - state.editor_ui.variables_scroll = 1.0e9; // stale offset self-corrects + state.editor_ui.variables_scroll.offset = 1.0e9; // stale offset self-corrects let panel = VariablesPanel::for_editor(&state); let rect = panel_rect(&panel); let max = panel.max_scroll(rect); diff --git a/crates/op-host-desktop/src/codegen_session.rs b/crates/op-host-desktop/src/codegen_session.rs index a76821dd1..1cc1bb28a 100644 --- a/crates/op-host-desktop/src/codegen_session.rs +++ b/crates/op-host-desktop/src/codegen_session.rs @@ -300,7 +300,7 @@ pub fn pump( { let cg = &mut host.editor_state_mut().codegen; cg.code = code.clone(); - cg.code_scroll = 0.0; + cg.code_scroll.offset = 0.0; cg.code_selection = None; cg.degraded = degraded; cg.assets = metas; diff --git a/crates/op-host-native/src/widget_host/input_tests.rs b/crates/op-host-native/src/widget_host/input_tests.rs index 3d1aa3970..bda42b769 100644 --- a/crates/op-host-native/src/widget_host/input_tests.rs +++ b/crates/op-host-native/src/widget_host/input_tests.rs @@ -2088,8 +2088,11 @@ fn codegen_preview_wheel_scrolls_code_not_property_panel() { viewport_h )); - assert!(host.editor_state().codegen.code_scroll > 0.0); - assert_eq!(host.editor_state().editor_ui.property_panel_scroll, 0.0); + assert!(host.editor_state().codegen.code_scroll.offset > 0.0); + assert_eq!( + host.editor_state().editor_ui.property_panel_scroll.offset, + 0.0 + ); } #[test] diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index 119471021..97e180d74 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -170,7 +170,7 @@ impl WidgetHostNative { self.editor_state.ui.property_caret_anchor_ms = self.now_ms; // A narrower list invalidates the scroll offset — the // widget clamps, but reset for a stable reveal-from-top. - self.editor_state.editor_ui.variables_scroll = 0.0; + self.editor_state.editor_ui.variables_scroll.offset = 0.0; self.mark_dirty(); return true; } @@ -521,7 +521,7 @@ impl WidgetHostNative { if self.variables_search_active() { if self.editor_state.editor_ui.variables_search.pop().is_some() { self.editor_state.ui.property_caret_anchor_ms = self.now_ms; - self.editor_state.editor_ui.variables_scroll = 0.0; + self.editor_state.editor_ui.variables_scroll.offset = 0.0; self.mark_dirty(); return true; } diff --git a/crates/op-host-native/src/widget_host/press_helpers.rs b/crates/op-host-native/src/widget_host/press_helpers.rs index 447007fde..7b42e1aa0 100644 --- a/crates/op-host-native/src/widget_host/press_helpers.rs +++ b/crates/op-host-native/src/widget_host/press_helpers.rs @@ -68,7 +68,7 @@ impl WidgetHostNative { AgentSettingsHit::SelectTab(t) => { self.commit_settings_focus_if_any(); self.editor_state.editor_ui.agent_settings.tab = t; - self.editor_state.editor_ui.agent_settings.scroll_y = 0.0; + self.editor_state.editor_ui.agent_settings.scroll_y.offset = 0.0; } AgentSettingsHit::Connect(p) => { // `connected` is indexed by `AgentProvider::ALL` order. @@ -365,7 +365,7 @@ impl WidgetHostNative { let settings = &mut self.editor_state.editor_ui.agent_settings; settings.builtin_preset_menu_open = (settings.builtin_preset_menu_open != Some(target)).then_some(target); - settings.builtin_preset_menu_scroll = 0.0; + settings.builtin_preset_menu_scroll.offset = 0.0; settings.builtin_preset_menu_hover = None; } AgentSettingsHit::SelectBuiltinAgentPreset { index, preset } => { @@ -391,7 +391,8 @@ impl WidgetHostNative { self.editor_state .editor_ui .agent_settings - .builtin_preset_menu_scroll = 0.0; + .builtin_preset_menu_scroll + .offset = 0.0; self.editor_state .editor_ui .agent_settings diff --git a/crates/op-host-native/src/widget_host/property_dispatch.rs b/crates/op-host-native/src/widget_host/property_dispatch.rs index 6c4430cac..d614c4d9e 100644 --- a/crates/op-host-native/src/widget_host/property_dispatch.rs +++ b/crates/op-host-native/src/widget_host/property_dispatch.rs @@ -437,14 +437,14 @@ impl WidgetHostNative { cg.pending_generate = true; cg.phase = CodegenPhase::Generating; cg.error = None; - cg.code_scroll = 0.0; + cg.code_scroll.offset = 0.0; cg.code_selection = None; } CodegenAction::Regenerate => { cg.pending_regenerate = true; cg.phase = CodegenPhase::Generating; cg.error = None; - cg.code_scroll = 0.0; + cg.code_scroll.offset = 0.0; cg.code_selection = None; } CodegenAction::Cancel => { @@ -481,12 +481,13 @@ impl WidgetHostNative { op_editor_ui::widgets::property_panel_code::framework_row_overflow(pw); let step = 100.0; let cg = &mut self.editor_state.codegen; - cg.framework_scroll = - if matches!(codegen_action, CodegenAction::ScrollFrameworksLeft) { - (cg.framework_scroll - step).clamp(0.0, max) - } else { - (cg.framework_scroll + step).clamp(0.0, max) - }; + let delta = if matches!(codegen_action, CodegenAction::ScrollFrameworksLeft) + { + -step + } else { + step + }; + cg.framework_scroll.scroll_by(delta, max, 0.0); } } } diff --git a/crates/op-host-native/src/widget_host/scroll.rs b/crates/op-host-native/src/widget_host/scroll.rs index 5ed734583..cfdfc1f71 100644 --- a/crates/op-host-native/src/widget_host/scroll.rs +++ b/crates/op-host-native/src/widget_host/scroll.rs @@ -4,9 +4,16 @@ //! otherwise zoom / pan the canvas. use super::WidgetHostNative; +use jian_core::scroll::ScrollState; use op_editor_ui::widgets::GitPanel; use op_editor_ui::Point2D; +fn scroll_by_max(scroll: &mut ScrollState, delta: f32, max: f32) -> bool { + let before = scroll.offset; + scroll.scroll_by(delta, max, 0.0); + scroll.offset != before +} + impl WidgetHostNative { fn try_scroll_chat_checklist( &mut self, @@ -31,9 +38,7 @@ impl WidgetHostNative { if !(checklist).contains(point) { return false; } - let next = (self.editor_state.chat.checklist_scroll - delta).clamp(0.0, max); - if next != self.editor_state.chat.checklist_scroll { - self.editor_state.chat.checklist_scroll = next; + if scroll_by_max(&mut self.editor_state.chat.checklist_scroll, -delta, max) { self.mark_dirty(); } true @@ -56,9 +61,7 @@ impl WidgetHostNative { return false; }; let settings = &mut self.editor_state.editor_ui.agent_settings; - let next = (settings.builtin_preset_menu_scroll - delta).clamp(0.0, max); - if next != settings.builtin_preset_menu_scroll { - settings.builtin_preset_menu_scroll = next; + if scroll_by_max(&mut settings.builtin_preset_menu_scroll, -delta, max) { self.mark_dirty(); } true @@ -88,9 +91,11 @@ impl WidgetHostNative { use op_editor_ui::widgets::variables_panel::VariablesPanel; let panel = VariablesPanel::for_editor(&self.editor_state); let max = panel.max_scroll(panel_rect); - let next = (self.editor_state.editor_ui.variables_scroll - delta_y).clamp(0.0, max); - if next != self.editor_state.editor_ui.variables_scroll { - self.editor_state.editor_ui.variables_scroll = next; + if scroll_by_max( + &mut self.editor_state.editor_ui.variables_scroll, + -delta_y, + max, + ) { self.mark_dirty(); } true @@ -165,9 +170,7 @@ impl WidgetHostNative { if y >= band_top && y <= band_bottom { let max = op_editor_ui::widgets::property_panel_code::framework_row_overflow(pw); let cg = &mut self.editor_state.codegen; - let next = (cg.framework_scroll - delta).clamp(0.0, max); - if next != cg.framework_scroll { - cg.framework_scroll = next; + if scroll_by_max(&mut cg.framework_scroll, -delta, max) { self.mark_dirty(); } return true; @@ -184,18 +187,18 @@ impl WidgetHostNative { ) .unwrap_or(0.0); let cg = &mut self.editor_state.codegen; - let next = (cg.code_scroll - delta).clamp(0.0, max); - if next != cg.code_scroll { - cg.code_scroll = next; + if scroll_by_max(&mut cg.code_scroll, -delta, max) { self.mark_dirty(); } return true; } } let max = (panel.content_height(property_rect) - property_rect.size.y).max(0.0); - let next = (self.editor_state.editor_ui.property_panel_scroll - delta).clamp(0.0, max); - if next != self.editor_state.editor_ui.property_panel_scroll { - self.editor_state.editor_ui.property_panel_scroll = next; + if scroll_by_max( + &mut self.editor_state.editor_ui.property_panel_scroll, + -delta, + max, + ) { self.mark_dirty(); } true @@ -230,35 +233,39 @@ impl WidgetHostNative { let mut changed = false; if y >= r.layers_rows_top { if delta_y != 0.0 { - let next = (self.editor_state.editor_ui.layer_layers_scroll - delta_y) - .clamp(0.0, r.layers_max_scroll); - if next != self.editor_state.editor_ui.layer_layers_scroll { - self.editor_state.editor_ui.layer_layers_scroll = next; + if scroll_by_max( + &mut self.editor_state.editor_ui.layer_layers_scroll, + -delta_y, + r.layers_max_scroll, + ) { changed = true; } } if delta_x != 0.0 { - let next = (self.editor_state.editor_ui.layer_layers_h_scroll - delta_x) - .clamp(0.0, r.layers_max_h_scroll); - if next != self.editor_state.editor_ui.layer_layers_h_scroll { - self.editor_state.editor_ui.layer_layers_h_scroll = next; + if scroll_by_max( + &mut self.editor_state.editor_ui.layer_layers_h_scroll, + -delta_x, + r.layers_max_h_scroll, + ) { changed = true; } } } else { if delta_y != 0.0 { - let next = (self.editor_state.editor_ui.layer_pages_scroll - delta_y) - .clamp(0.0, r.pages_max_scroll); - if next != self.editor_state.editor_ui.layer_pages_scroll { - self.editor_state.editor_ui.layer_pages_scroll = next; + if scroll_by_max( + &mut self.editor_state.editor_ui.layer_pages_scroll, + -delta_y, + r.pages_max_scroll, + ) { changed = true; } } if delta_x != 0.0 { - let next = (self.editor_state.editor_ui.layer_pages_h_scroll - delta_x) - .clamp(0.0, r.pages_max_h_scroll); - if next != self.editor_state.editor_ui.layer_pages_h_scroll { - self.editor_state.editor_ui.layer_pages_h_scroll = next; + if scroll_by_max( + &mut self.editor_state.editor_ui.layer_pages_h_scroll, + -delta_x, + r.pages_max_h_scroll, + ) { changed = true; } } @@ -342,9 +349,11 @@ impl WidgetHostNative { let total = panel.content_total_height(); let viewport_h_inner = panel_rect.size.y - 48.0; let max_scroll = (total - viewport_h_inner).max(0.0); - let next = (self.editor_state.editor_ui.agent_settings.scroll_y - delta_y) - .clamp(0.0, max_scroll); - self.editor_state.editor_ui.agent_settings.scroll_y = next; + self.editor_state + .editor_ui + .agent_settings + .scroll_y + .scroll_by(-delta_y, max_scroll, 0.0); self.mark_dirty(); return true; } @@ -484,9 +493,11 @@ impl WidgetHostNative { let total = panel.content_total_height(); let viewport_h_inner = panel_rect.size.y - 48.0; let max_scroll = (total - viewport_h_inner).max(0.0); - let next = (self.editor_state.editor_ui.agent_settings.scroll_y - dy) - .clamp(0.0, max_scroll); - self.editor_state.editor_ui.agent_settings.scroll_y = next; + self.editor_state + .editor_ui + .agent_settings + .scroll_y + .scroll_by(-dy, max_scroll, 0.0); self.mark_dirty(); return 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 a02e3ee15..a0a622578 100644 --- a/crates/op-host-native/src/widget_host/scroll_tests.rs +++ b/crates/op-host-native/src/widget_host/scroll_tests.rs @@ -60,7 +60,7 @@ fn layer_panel_trackpad_pan_scrolls_horizontally() { viewport_h )); - assert!(host.editor_state().editor_ui.layer_layers_h_scroll > 0.0); + assert!(host.editor_state().editor_ui.layer_layers_h_scroll.offset > 0.0); }); } @@ -89,7 +89,7 @@ fn chat_checklist_wheel_scrolls_pinned_task_list() { viewport_h )); - assert!(host.editor_state().chat.checklist_scroll > 0.0); + assert!(host.editor_state().chat.checklist_scroll.offset > 0.0); } #[test] diff --git a/crates/op-host-native/src/widget_host/variables_panel_ux_tests.rs b/crates/op-host-native/src/widget_host/variables_panel_ux_tests.rs index e9b6a5d96..d9ad3e660 100644 --- a/crates/op-host-native/src/widget_host/variables_panel_ux_tests.rs +++ b/crates/op-host-native/src/widget_host/variables_panel_ux_tests.rs @@ -332,22 +332,22 @@ fn wheel_over_panel_scrolls_rows_and_clamps() { // Wheel scroll-down advances the list. assert!(host.apply_wheel(cx, cy, -60.0, VIEWPORT_W, VIEWPORT_H)); - assert!(host.editor_state().editor_ui.variables_scroll > 0.0); + assert!(host.editor_state().editor_ui.variables_scroll.offset > 0.0); // Huge scroll clamps to max. assert!(host.apply_wheel(cx, cy, -1.0e6, VIEWPORT_W, VIEWPORT_H)); let panel = VariablesPanel::for_editor(host.editor_state()); let max = panel.max_scroll(rect); assert!(max > 0.0); - assert_eq!(host.editor_state().editor_ui.variables_scroll, max); + assert_eq!(host.editor_state().editor_ui.variables_scroll.offset, max); // Scroll back past the top clamps to 0. assert!(host.apply_wheel(cx, cy, 1.0e6, VIEWPORT_W, VIEWPORT_H)); - assert_eq!(host.editor_state().editor_ui.variables_scroll, 0.0); + assert_eq!(host.editor_state().editor_ui.variables_scroll.offset, 0.0); // A scrolled list maps hits through the offset: with max scroll, // the LAST row sits just above the footer. - host.editor_state_mut().editor_ui.variables_scroll = max; + host.editor_state_mut().editor_ui.variables_scroll.offset = max; let panel = VariablesPanel::for_editor(host.editor_state()); let footer_top = rect.origin.y + rect.size.y - 40.0; let hit = panel.hit_test(rect, Point2D::new(rect.origin.x + 60.0, footer_top - 20.0)); diff --git a/crates/op-host-web/src/codegen_web.rs b/crates/op-host-web/src/codegen_web.rs index 942a1faef..258fdebde 100644 --- a/crates/op-host-web/src/codegen_web.rs +++ b/crates/op-host-web/src/codegen_web.rs @@ -545,7 +545,7 @@ fn start_pump(inner: Rc>, shared: Shared) { assets, } => { cg.code = code; - cg.code_scroll = 0.0; + cg.code_scroll.offset = 0.0; cg.code_selection = None; cg.degraded = degraded; cg.assets = assets; diff --git a/crates/op-host-web/src/widget_host.rs b/crates/op-host-web/src/widget_host.rs index fbb560bea..8ed545eb6 100644 --- a/crates/op-host-web/src/widget_host.rs +++ b/crates/op-host-web/src/widget_host.rs @@ -544,9 +544,12 @@ impl WidgetHost { if !(checklist).contains(point) { return false; } - let next = (self.editor_state.chat.checklist_scroll - delta).clamp(0.0, max); - if next != self.editor_state.chat.checklist_scroll { - self.editor_state.chat.checklist_scroll = next; + let before = self.editor_state.chat.checklist_scroll.offset; + self.editor_state + .chat + .checklist_scroll + .scroll_by(-delta, max, 0.0); + if self.editor_state.chat.checklist_scroll.offset != before { self.mark_dirty(); } true @@ -573,8 +576,9 @@ impl WidgetHost { .builtin_preset_scroll_max_at(panel_rect, point) { let settings = &mut self.editor_state.editor_ui.agent_settings; - settings.builtin_preset_menu_scroll = - (settings.builtin_preset_menu_scroll - delta_y).clamp(0.0, max); + settings + .builtin_preset_menu_scroll + .scroll_by(-delta_y, max, 0.0); self.mark_dirty(); return true; } @@ -582,9 +586,11 @@ impl WidgetHost { let total = panel.content_total_height(); let viewport_h_inner = panel_rect.size.y - 48.0; let max_scroll = (total - viewport_h_inner).max(0.0); - self.editor_state.editor_ui.agent_settings.scroll_y = - (self.editor_state.editor_ui.agent_settings.scroll_y - delta_y) - .clamp(0.0, max_scroll); + self.editor_state + .editor_ui + .agent_settings + .scroll_y + .scroll_by(-delta_y, max_scroll, 0.0); self.mark_dirty(); return true; } diff --git a/crates/op-host-web/src/widget_host/agent_settings_press.rs b/crates/op-host-web/src/widget_host/agent_settings_press.rs index e747ab5b3..96d1d5da6 100644 --- a/crates/op-host-web/src/widget_host/agent_settings_press.rs +++ b/crates/op-host-web/src/widget_host/agent_settings_press.rs @@ -33,7 +33,7 @@ impl WidgetHost { AgentSettingsHit::SelectTab(tab) => { self.commit_settings_focus(); self.editor_state.editor_ui.agent_settings.tab = tab; - self.editor_state.editor_ui.agent_settings.scroll_y = 0.0; + self.editor_state.editor_ui.agent_settings.scroll_y.offset = 0.0; } AgentSettingsHit::Connect(provider) => { let idx = AgentProvider::ALL @@ -334,7 +334,7 @@ impl WidgetHost { let settings = &mut self.editor_state.editor_ui.agent_settings; settings.builtin_preset_menu_open = (settings.builtin_preset_menu_open != Some(target)).then_some(target); - settings.builtin_preset_menu_scroll = 0.0; + settings.builtin_preset_menu_scroll.offset = 0.0; settings.builtin_preset_menu_hover = None; } AgentSettingsHit::SelectBuiltinAgentPreset { index, preset } => { @@ -360,7 +360,8 @@ impl WidgetHost { self.editor_state .editor_ui .agent_settings - .builtin_preset_menu_scroll = 0.0; + .builtin_preset_menu_scroll + .offset = 0.0; self.editor_state .editor_ui .agent_settings diff --git a/crates/op-host-web/src/widget_host/keyboard.rs b/crates/op-host-web/src/widget_host/keyboard.rs index 7f007baa7..3ffb807c0 100644 --- a/crates/op-host-web/src/widget_host/keyboard.rs +++ b/crates/op-host-web/src/widget_host/keyboard.rs @@ -43,7 +43,7 @@ impl WidgetHost { if self.variables_search_active() && !c.is_control() { self.editor_state.editor_ui.variables_search.push(c); self.editor_state.ui.property_caret_anchor_ms = self.now_ms; - self.editor_state.editor_ui.variables_scroll = 0.0; + self.editor_state.editor_ui.variables_scroll.offset = 0.0; self.mark_dirty(); return true; } @@ -222,7 +222,7 @@ impl WidgetHost { if self.variables_search_active() { if self.editor_state.editor_ui.variables_search.pop().is_some() { self.editor_state.ui.property_caret_anchor_ms = self.now_ms; - self.editor_state.editor_ui.variables_scroll = 0.0; + self.editor_state.editor_ui.variables_scroll.offset = 0.0; self.mark_dirty(); return true; } diff --git a/crates/op-host-web/src/widget_host/property_dispatch.rs b/crates/op-host-web/src/widget_host/property_dispatch.rs index 101fea27b..a137267a9 100644 --- a/crates/op-host-web/src/widget_host/property_dispatch.rs +++ b/crates/op-host-web/src/widget_host/property_dispatch.rs @@ -491,14 +491,14 @@ impl WidgetHost { cg.pending_generate = true; cg.phase = CodegenPhase::Generating; cg.error = None; - cg.code_scroll = 0.0; + cg.code_scroll.offset = 0.0; cg.code_selection = None; } CodegenAction::Regenerate => { cg.pending_regenerate = true; cg.phase = CodegenPhase::Generating; cg.error = None; - cg.code_scroll = 0.0; + cg.code_scroll.offset = 0.0; cg.code_selection = None; } CodegenAction::Cancel => { @@ -552,11 +552,12 @@ impl WidgetHost { let max = op_editor_ui::widgets::property_panel_code::framework_row_overflow(pw); let step = 100.0; let cg = &mut self.editor_state.codegen; - cg.framework_scroll = if matches!(action, CodegenAction::ScrollFrameworksLeft) { - (cg.framework_scroll - step).clamp(0.0, max) + let delta = if matches!(action, CodegenAction::ScrollFrameworksLeft) { + -step } else { - (cg.framework_scroll + step).clamp(0.0, max) + step }; + cg.framework_scroll.scroll_by(delta, max, 0.0); } } } diff --git a/crates/op-host-web/src/widget_host/property_hover_tests.rs b/crates/op-host-web/src/widget_host/property_hover_tests.rs index 3ef077a03..771d32201 100644 --- a/crates/op-host-web/src/widget_host/property_hover_tests.rs +++ b/crates/op-host-web/src/widget_host/property_hover_tests.rs @@ -114,6 +114,9 @@ fn codegen_preview_wheel_scrolls_code_not_property_panel() { viewport_h )); - assert!(host.editor_state.codegen.code_scroll > 0.0); - assert_eq!(host.editor_state.editor_ui.property_panel_scroll, 0.0); + assert!(host.editor_state.codegen.code_scroll.offset > 0.0); + assert_eq!( + host.editor_state.editor_ui.property_panel_scroll.offset, + 0.0 + ); } diff --git a/crates/op-host-web/src/widget_host/scroll.rs b/crates/op-host-web/src/widget_host/scroll.rs index 2818d1f1d..0321f52da 100644 --- a/crates/op-host-web/src/widget_host/scroll.rs +++ b/crates/op-host-web/src/widget_host/scroll.rs @@ -2,11 +2,18 @@ //! `widget_host.rs` so the spine stays under the 800-line cap. //! Mirrors the native host's `widget_host/scroll.rs`. +use jian_core::scroll::ScrollState; use op_editor_ui::widgets::{LayerPanel, PropertyPanel, TOP_BAR_HEIGHT}; use op_editor_ui::{Point2D, Rect}; use super::WidgetHost; +fn scroll_by_max(scroll: &mut ScrollState, delta: f32, max: f32) -> bool { + let before = scroll.offset; + scroll.scroll_by(delta, max, 0.0); + scroll.offset != before +} + impl WidgetHost { /// Scroll the floating VariablesPanel row list when the wheel /// fires over the open panel (TS `overflow-y-auto` rows region). @@ -32,9 +39,11 @@ impl WidgetHost { use op_editor_ui::widgets::variables_panel::VariablesPanel; let panel = VariablesPanel::for_editor(&self.editor_state); let max = panel.max_scroll(panel_rect); - let next = (self.editor_state.editor_ui.variables_scroll - delta_y).clamp(0.0, max); - if next != self.editor_state.editor_ui.variables_scroll { - self.editor_state.editor_ui.variables_scroll = next; + if scroll_by_max( + &mut self.editor_state.editor_ui.variables_scroll, + -delta_y, + max, + ) { self.mark_dirty(); } true @@ -113,9 +122,7 @@ impl WidgetHost { if y >= band_top && y <= band_bottom { let max = op_editor_ui::widgets::property_panel_code::framework_row_overflow(pw); let cg = &mut self.editor_state.codegen; - let next = (cg.framework_scroll - delta_y).clamp(0.0, max); - if next != cg.framework_scroll { - cg.framework_scroll = next; + if scroll_by_max(&mut cg.framework_scroll, -delta_y, max) { self.mark_dirty(); } return true; @@ -132,18 +139,18 @@ impl WidgetHost { ) .unwrap_or(0.0); let cg = &mut self.editor_state.codegen; - let next = (cg.code_scroll - delta_y).clamp(0.0, max); - if next != cg.code_scroll { - cg.code_scroll = next; + if scroll_by_max(&mut cg.code_scroll, -delta_y, max) { self.mark_dirty(); } return true; } } let max = (panel.content_height(property_rect) - property_rect.size.y).max(0.0); - let next = (self.editor_state.editor_ui.property_panel_scroll - delta_y).clamp(0.0, max); - if next != self.editor_state.editor_ui.property_panel_scroll { - self.editor_state.editor_ui.property_panel_scroll = next; + if scroll_by_max( + &mut self.editor_state.editor_ui.property_panel_scroll, + -delta_y, + max, + ) { self.mark_dirty(); } true @@ -172,17 +179,19 @@ impl WidgetHost { } let r = LayerPanel::from_editor(&self.editor_state).regions(rect); if y >= r.layers_rows_top { - let next = (self.editor_state.editor_ui.layer_layers_scroll - delta_y) - .clamp(0.0, r.layers_max_scroll); - if next != self.editor_state.editor_ui.layer_layers_scroll { - self.editor_state.editor_ui.layer_layers_scroll = next; + if scroll_by_max( + &mut self.editor_state.editor_ui.layer_layers_scroll, + -delta_y, + r.layers_max_scroll, + ) { self.mark_dirty(); } } else { - let next = (self.editor_state.editor_ui.layer_pages_scroll - delta_y) - .clamp(0.0, r.pages_max_scroll); - if next != self.editor_state.editor_ui.layer_pages_scroll { - self.editor_state.editor_ui.layer_pages_scroll = next; + if scroll_by_max( + &mut self.editor_state.editor_ui.layer_pages_scroll, + -delta_y, + r.pages_max_scroll, + ) { self.mark_dirty(); } } diff --git a/crates/op-host-web/src/widget_host/variables_panel_tests.rs b/crates/op-host-web/src/widget_host/variables_panel_tests.rs index 8cfe21455..24da661cc 100644 --- a/crates/op-host-web/src/widget_host/variables_panel_tests.rs +++ b/crates/op-host-web/src/widget_host/variables_panel_tests.rs @@ -377,16 +377,16 @@ fn wheel_scrolls_rows_and_resize_drag_resizes() { let cx = rect.origin.x + rect.size.x / 2.0; let cy = rect.origin.y + rect.size.y / 2.0; assert!(host.apply_wheel(cx, cy, -60.0, W, H)); - assert!(host.editor_state.editor_ui.variables_scroll > 0.0); + assert!(host.editor_state.editor_ui.variables_scroll.offset > 0.0); // Huge values clamp. assert!(host.apply_wheel(cx, cy, -1.0e6, W, H)); let panel = VariablesPanel::for_editor(&host.editor_state); assert_eq!( - host.editor_state.editor_ui.variables_scroll, + host.editor_state.editor_ui.variables_scroll.offset, panel.max_scroll(rect) ); assert!(host.apply_wheel(cx, cy, 1.0e6, W, H)); - assert_eq!(host.editor_state.editor_ui.variables_scroll, 0.0); + assert_eq!(host.editor_state.editor_ui.variables_scroll.offset, 0.0); // Right-edge press arms a resize; drag narrows; release ends. let edge_x = rect.origin.x + rect.size.x - 2.0;