Skip to main content

mittens_engine/engine/ecs/component/
scrolling.rs

1use crate::engine::ecs::ComponentId;
2use crate::engine::ecs::component::Component;
3use crate::engine::ecs::{IntentValue, SignalEmitter};
4
5/// Generic scroll state for moving a content track inside a clipped viewport.
6///
7/// `ScrollingComponent` does not own clipping; it only tracks viewport/content sizes,
8/// current offset, which transform should be moved by the scroll runtime, and the
9/// drag scope currently bridged into scroll events.
10///
11/// Expected topology:
12/// ```text
13/// viewport_root                  ← usually clipped by StyleComponent::overflow
14///   └── ScrollingComponent       ← owned by ScrollingSystem
15///         └── scroll_track       ← moved in +Y as scroll_offset increases
16///               ├── child_0
17///               ├── child_1
18///               └── ...
19/// ```
20///
21/// If no explicit `track` is assigned, `ScrollingSystem` now creates an internal `Router` plus an
22/// owned `__scroll_track` transform under the `ScrollingComponent`, and routed children are sent
23/// there both at init time and on later direct attaches.
24#[derive(Debug, Clone)]
25pub struct ScrollingComponent {
26    /// Height of the clipped viewport in scroll-local/layout units.
27    pub viewport_height: f32,
28    /// Height of the scrollable content in scroll-local/layout units.
29    pub content_height: f32,
30    /// Current scroll offset in scroll-local/layout units. 0.0 = top.
31    pub scroll_offset: f32,
32    /// Transform moved by the scroll runtime.
33    ///
34    /// If unset at init time, the runtime will create an owned internal track by default.
35    pub track: Option<ComponentId>,
36    /// Base local-space position of `track` before any scrolling is applied.
37    pub track_base_pos: [f32; 3],
38    /// Ancestor scope currently forwarding drag motion into this scrolling component.
39    pub drag_scope: Option<ComponentId>,
40
41    component: Option<ComponentId>,
42}
43
44impl ScrollingComponent {
45    pub fn new(viewport_height: f32, content_height: f32) -> Self {
46        Self {
47            viewport_height,
48            content_height,
49            scroll_offset: 0.0,
50            track: None,
51            track_base_pos: [0.0, 0.0, 0.0],
52            drag_scope: None,
53            component: None,
54        }
55    }
56
57    pub fn set_track(&mut self, track: ComponentId, base_pos: [f32; 3]) {
58        self.track = Some(track);
59        self.track_base_pos = base_pos;
60    }
61
62    pub fn set_drag_scope(&mut self, drag_scope: ComponentId) {
63        self.drag_scope = Some(drag_scope);
64    }
65
66    pub fn set_content_height(&mut self, content_height: f32) -> bool {
67        self.content_height = content_height.max(0.0);
68        self.clamp_to_content()
69    }
70
71    /// Maximum scroll distance in scroll-local/layout units.
72    pub fn max_scroll(&self) -> f32 {
73        (self.content_height - self.viewport_height).max(0.0)
74    }
75
76    /// Update `scroll_offset` by a scroll-local Y drag delta.
77    ///
78    /// Sign convention: dragging up (positive `delta_y`) reveals content lower in the list.
79    pub fn apply_drag(&mut self, delta_y: f32) -> bool {
80        let prev_offset = self.scroll_offset;
81        self.scroll_offset -= delta_y;
82        self.scroll_offset = self.scroll_offset.clamp(0.0, self.max_scroll());
83        (self.scroll_offset - prev_offset).abs() > f32::EPSILON
84    }
85
86    /// Current translation that should be applied to the scroll track.
87    pub fn track_translation(&self) -> [f32; 3] {
88        [
89            self.track_base_pos[0],
90            self.track_base_pos[1] + self.scroll_offset,
91            self.track_base_pos[2],
92        ]
93    }
94
95    /// Clamp scroll after content size changes. Returns true if the position changed.
96    pub fn clamp_to_content(&mut self) -> bool {
97        let clamped = self.scroll_offset.clamp(0.0, self.max_scroll());
98        if (clamped - self.scroll_offset).abs() > f32::EPSILON {
99            self.scroll_offset = clamped;
100            true
101        } else {
102            false
103        }
104    }
105}
106
107impl Component for ScrollingComponent {
108    fn name(&self) -> &'static str {
109        "scrolling"
110    }
111
112    fn set_id(&mut self, id: ComponentId) {
113        self.component = Some(id);
114    }
115
116    fn as_any(&self) -> &dyn std::any::Any {
117        self
118    }
119
120    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
121        self
122    }
123
124    fn init(&mut self, emit: &mut dyn SignalEmitter, component: ComponentId) {
125        emit.push_intent_now(
126            component,
127            IntentValue::RegisterScrolling {
128                component_ids: vec![component],
129            },
130        );
131    }
132
133    fn to_mms_ast(
134        &self,
135        _world: &crate::engine::ecs::World,
136    ) -> crate::scripting::ast::ComponentExpression {
137        use crate::engine::ecs::component::ce_helpers::*;
138        ce_call(
139            "Scrolling",
140            "new",
141            vec![
142                num(self.viewport_height as f64),
143                num(self.content_height as f64),
144            ],
145        )
146    }
147}