Skip to main content

bexa_ui_core/
tree.rs

1use taffy::geometry::Point;
2use taffy::prelude::*;
3use winit::event::WindowEvent;
4
5use crate::framework::{DrawContext, EventContext, Widget};
6use crate::renderer::Renderer;
7
8const SCROLLBAR_WIDTH: f32 = 8.0;
9const SCROLLBAR_MARGIN: f32 = 2.0;
10const SCROLLBAR_MIN_THUMB: f32 = 20.0;
11
12pub struct WidgetNode {
13    pub(crate) widget: Box<dyn Widget>,
14    pub(crate) children: Vec<WidgetNode>,
15    pub(crate) node: Option<NodeId>,
16    pub(crate) scroll_y: f32,
17    // Scrollbar drag state
18    pub(crate) scrollbar_dragging: bool,
19    pub(crate) scrollbar_drag_start_y: f32,
20    pub(crate) scrollbar_drag_start_scroll: f32,
21}
22
23impl WidgetNode {
24    pub fn new(widget: impl Widget + 'static, children: Vec<WidgetNode>) -> Self {
25        Self {
26            widget: Box::new(widget),
27            children,
28            node: None,
29            scroll_y: 0.0,
30            scrollbar_dragging: false,
31            scrollbar_drag_start_y: 0.0,
32            scrollbar_drag_start_scroll: 0.0,
33        }
34    }
35}
36
37pub fn build_taffy(node: &mut WidgetNode, taffy: &mut TaffyTree) -> NodeId {
38    let child_nodes = node
39        .children
40        .iter_mut()
41        .map(|child| build_taffy(child, taffy))
42        .collect::<Vec<_>>();
43
44    let style = node.widget.style();
45    let node_id = if child_nodes.is_empty() {
46        taffy.new_leaf(style).expect("create leaf")
47    } else {
48        taffy
49            .new_with_children(style, &child_nodes)
50            .expect("create node")
51    };
52
53    node.node = Some(node_id);
54    node_id
55}
56
57pub fn sync_styles(node: &mut WidgetNode, taffy: &mut TaffyTree, width: f32, height: f32, is_root: bool) {
58    let Some(node_id) = node.node else {
59        return;
60    };
61
62    let mut style = node.widget.style();
63    if is_root {
64        style.size = Size {
65            width: Dimension::Length(width),
66            height: Dimension::Length(height),
67        };
68    }
69
70    taffy.set_style(node_id, style).expect("set style");
71
72    for child in &mut node.children {
73        sync_styles(child, taffy, width, height, false);
74    }
75}
76
77pub fn collect_focus_paths(node: &WidgetNode, path: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
78    if node.widget.is_focusable() {
79        out.push(path.clone());
80    }
81
82    for (index, child) in node.children.iter().enumerate() {
83        path.push(index);
84        collect_focus_paths(child, path, out);
85        path.pop();
86    }
87}
88
89pub fn widget_mut_at_path<'a>(node: &'a mut WidgetNode, path: &[usize]) -> Option<&'a mut dyn Widget> {
90    if path.is_empty() {
91        return Some(node.widget.as_mut());
92    }
93
94    let idx = path[0];
95    if idx >= node.children.len() {
96        return None;
97    }
98
99    widget_mut_at_path(&mut node.children[idx], &path[1..])
100}
101
102pub fn draw_widgets(node: &WidgetNode, taffy: &TaffyTree, renderer: &mut Renderer) {
103    draw_widgets_offset(node, taffy, renderer, 0.0, 0.0);
104}
105
106fn draw_widgets_offset(node: &WidgetNode, taffy: &TaffyTree, renderer: &mut Renderer, parent_x: f32, parent_y: f32) {
107    let Some(node_id) = node.node else {
108        return;
109    };
110
111    let layout = taffy.layout(node_id).expect("layout");
112    let abs_x = parent_x + layout.location.x;
113    let abs_y = parent_y + layout.location.y;
114
115    let mut absolute_layout = *layout;
116    absolute_layout.location = Point { x: abs_x, y: abs_y };
117
118    let mut ctx = DrawContext {
119        renderer,
120        layout: &absolute_layout,
121    };
122    node.widget.draw(&mut ctx);
123
124    let is_scroll = node.widget.is_scrollable();
125    if is_scroll {
126        renderer.push_clip((abs_x, abs_y, layout.size.width, layout.size.height));
127    }
128
129    let child_y = abs_y - node.scroll_y;
130    for child in &node.children {
131        draw_widgets_offset(child, taffy, renderer, abs_x, child_y);
132    }
133
134    if is_scroll {
135        renderer.pop_clip();
136
137        // Draw scrollbar overlay (after pop_clip so it's not clipped with children)
138        let container_h = layout.size.height;
139        let content_h = content_height(node, taffy);
140        if content_h > container_h {
141            draw_scrollbar(renderer, abs_x, abs_y, layout.size.width, container_h, content_h, node.scroll_y);
142        }
143    }
144}
145
146fn content_height(node: &WidgetNode, taffy: &TaffyTree) -> f32 {
147    let mut h: f32 = 0.0;
148    for child in &node.children {
149        if let Some(child_id) = child.node {
150            let cl = taffy.layout(child_id).expect("child layout");
151            let bottom = cl.location.y + cl.size.height;
152            h = h.max(bottom);
153        }
154    }
155    h
156}
157
158fn draw_scrollbar(
159    renderer: &mut Renderer,
160    container_x: f32,
161    container_y: f32,
162    container_w: f32,
163    container_h: f32,
164    content_h: f32,
165    scroll_y: f32,
166) {
167    let track_x = container_x + container_w - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN;
168    let track_y = container_y + SCROLLBAR_MARGIN;
169    let track_h = container_h - SCROLLBAR_MARGIN * 2.0;
170
171    // Track background
172    renderer.fill_rect_rounded(
173        (track_x, track_y, SCROLLBAR_WIDTH, track_h),
174        [0.3, 0.3, 0.3, 0.15],
175        SCROLLBAR_WIDTH / 2.0,
176    );
177
178    // Thumb
179    let ratio = container_h / content_h;
180    let thumb_h = (ratio * track_h).max(SCROLLBAR_MIN_THUMB);
181    let max_scroll = (content_h - container_h).max(0.0);
182    let scroll_ratio = if max_scroll > 0.0 { scroll_y / max_scroll } else { 0.0 };
183    let thumb_y = track_y + scroll_ratio * (track_h - thumb_h);
184
185    renderer.fill_rect_rounded(
186        (track_x, thumb_y, SCROLLBAR_WIDTH, thumb_h),
187        [0.6, 0.6, 0.6, 0.5],
188        SCROLLBAR_WIDTH / 2.0,
189    );
190}
191
192pub fn dispatch_event(
193    node: &mut WidgetNode,
194    taffy: &TaffyTree,
195    event: &WindowEvent,
196    path: &mut Vec<usize>,
197) -> Option<Vec<usize>> {
198    dispatch_event_offset(node, taffy, event, path, 0.0, 0.0)
199}
200
201fn dispatch_event_offset(
202    node: &mut WidgetNode,
203    taffy: &TaffyTree,
204    event: &WindowEvent,
205    path: &mut Vec<usize>,
206    parent_x: f32,
207    parent_y: f32,
208) -> Option<Vec<usize>> {
209    let Some(node_id) = node.node else {
210        return None;
211    };
212    let layout = taffy.layout(node_id).expect("layout");
213    let abs_x = parent_x + layout.location.x;
214    let abs_y = parent_y + layout.location.y;
215
216    let child_y = abs_y - node.scroll_y;
217    for (index, child) in node.children.iter_mut().enumerate() {
218        path.push(index);
219        if let Some(found) = dispatch_event_offset(child, taffy, event, path, abs_x, child_y) {
220            return Some(found);
221        }
222        path.pop();
223    }
224
225    let mut absolute_layout = *layout;
226    absolute_layout.location = Point { x: abs_x, y: abs_y };
227
228    let mut ctx = EventContext {
229        event,
230        layout: &absolute_layout,
231    };
232    if node.widget.handle_event(&mut ctx) {
233        return Some(path.clone());
234    }
235
236    None
237}
238
239/// Dispatches a scroll event to the deepest scrollable node under cursor,
240/// falling back to the root node.
241pub fn dispatch_scroll(node: &mut WidgetNode, delta_y: f32, cursor_x: f32, cursor_y: f32, taffy: &TaffyTree) {
242    if !dispatch_scroll_offset(node, delta_y, cursor_x, cursor_y, taffy, 0.0, 0.0) {
243        // Fallback: scroll root
244        scroll_node(node, delta_y, taffy);
245    }
246}
247
248fn dispatch_scroll_offset(
249    node: &mut WidgetNode,
250    delta_y: f32,
251    cx: f32,
252    cy: f32,
253    taffy: &TaffyTree,
254    parent_x: f32,
255    parent_y: f32,
256) -> bool {
257    let Some(node_id) = node.node else { return false; };
258    let layout = taffy.layout(node_id).expect("layout");
259    let abs_x = parent_x + layout.location.x;
260    let abs_y = parent_y + layout.location.y;
261
262    // Check if cursor is inside this node
263    let inside = cx >= abs_x
264        && cx <= abs_x + layout.size.width
265        && cy >= abs_y
266        && cy <= abs_y + layout.size.height;
267
268    if !inside {
269        return false;
270    }
271
272    // Try children first (deepest scrollable wins)
273    let child_y = abs_y - node.scroll_y;
274    for child in &mut node.children {
275        if dispatch_scroll_offset(child, delta_y, cx, cy, taffy, abs_x, child_y) {
276            return true;
277        }
278    }
279
280    // If this node is scrollable, consume the scroll
281    if node.widget.is_scrollable() {
282        scroll_node(node, delta_y, taffy);
283        return true;
284    }
285
286    false
287}
288
289fn scroll_node(node: &mut WidgetNode, delta_y: f32, taffy: &TaffyTree) {
290    let Some(node_id) = node.node else { return; };
291    let layout = taffy.layout(node_id).expect("layout");
292    let container_h = layout.size.height;
293
294    // Content height = max bottom edge of all children
295    let mut content_h: f32 = 0.0;
296    for child in &node.children {
297        if let Some(child_id) = child.node {
298            let cl = taffy.layout(child_id).expect("child layout");
299            let bottom = cl.location.y + cl.size.height;
300            content_h = content_h.max(bottom);
301        }
302    }
303
304    let max_scroll = (content_h - container_h).max(0.0);
305    node.scroll_y = (node.scroll_y - delta_y).clamp(0.0, max_scroll);
306}
307
308/// Scrolls the root node (backward compat).
309pub fn scroll_root(node: &mut WidgetNode, delta_y: f32, viewport_h: f32, taffy: &TaffyTree) {
310    let _ = viewport_h;
311    scroll_node(node, delta_y, taffy);
312}
313
314pub fn update_widget_measures(node: &mut WidgetNode, measures: &[Vec<f32>]) {
315    node.widget.update_measures(measures);
316    for child in &mut node.children {
317        update_widget_measures(child, measures);
318    }
319}
320
321/// Handle mouse events on scrollbar overlays. Returns true if a scrollbar consumed the event.
322pub fn handle_scrollbar_event(
323    node: &mut WidgetNode,
324    taffy: &TaffyTree,
325    event: &WindowEvent,
326) -> bool {
327    handle_scrollbar_event_offset(node, taffy, event, 0.0, 0.0)
328}
329
330fn handle_scrollbar_event_offset(
331    node: &mut WidgetNode,
332    taffy: &TaffyTree,
333    event: &WindowEvent,
334    parent_x: f32,
335    parent_y: f32,
336) -> bool {
337    let Some(node_id) = node.node else { return false; };
338    let layout = taffy.layout(node_id).expect("layout");
339    let abs_x = parent_x + layout.location.x;
340    let abs_y = parent_y + layout.location.y;
341
342    // Check children first
343    let child_y = abs_y - node.scroll_y;
344    for child in &mut node.children {
345        if handle_scrollbar_event_offset(child, taffy, event, abs_x, child_y) {
346            return true;
347        }
348    }
349
350    if !node.widget.is_scrollable() {
351        return false;
352    }
353
354    let container_h = layout.size.height;
355    let content_h = content_height(node, taffy);
356    if content_h <= container_h {
357        return false;
358    }
359
360    let _track_y = abs_y + SCROLLBAR_MARGIN;
361    let track_h = container_h - SCROLLBAR_MARGIN * 2.0;
362    let max_scroll = (content_h - container_h).max(0.0);
363    let ratio = container_h / content_h;
364    let thumb_h = (ratio * track_h).max(SCROLLBAR_MIN_THUMB);
365
366    match event {
367        WindowEvent::CursorMoved { position, .. } => {
368            let _cx = position.x as f32;
369            let cy = position.y as f32;
370
371            if node.scrollbar_dragging {
372                // Update scroll based on drag delta
373                let delta_y = cy - node.scrollbar_drag_start_y;
374                let scroll_per_pixel = max_scroll / (track_h - thumb_h);
375                node.scroll_y = (node.scrollbar_drag_start_scroll + delta_y * scroll_per_pixel)
376                    .clamp(0.0, max_scroll);
377                return true;
378            }
379            false
380        }
381        _ => false,
382    }
383}
384
385/// Start scrollbar drag if the press landed on the scrollbar thumb/track.
386/// Call this specifically on MouseInput::Pressed events with cursor position.
387pub fn try_start_scrollbar_drag(
388    node: &mut WidgetNode,
389    taffy: &TaffyTree,
390    cx: f32,
391    cy: f32,
392) -> bool {
393    try_start_scrollbar_drag_offset(node, taffy, cx, cy, 0.0, 0.0)
394}
395
396fn try_start_scrollbar_drag_offset(
397    node: &mut WidgetNode,
398    taffy: &TaffyTree,
399    cx: f32,
400    cy: f32,
401    parent_x: f32,
402    parent_y: f32,
403) -> bool {
404    let Some(node_id) = node.node else { return false; };
405    let layout = taffy.layout(node_id).expect("layout");
406    let abs_x = parent_x + layout.location.x;
407    let abs_y = parent_y + layout.location.y;
408
409    let child_y = abs_y - node.scroll_y;
410    for child in &mut node.children {
411        if try_start_scrollbar_drag_offset(child, taffy, cx, cy, abs_x, child_y) {
412            return true;
413        }
414    }
415
416    if !node.widget.is_scrollable() {
417        return false;
418    }
419
420    let container_h = layout.size.height;
421    let content_h = content_height(node, taffy);
422    if content_h <= container_h {
423        return false;
424    }
425
426    let track_x = abs_x + layout.size.width - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN;
427    let track_y = abs_y + SCROLLBAR_MARGIN;
428    let track_h = container_h - SCROLLBAR_MARGIN * 2.0;
429
430    // Check if click is in the scrollbar area
431    let in_scrollbar = cx >= track_x
432        && cx <= track_x + SCROLLBAR_WIDTH + SCROLLBAR_MARGIN
433        && cy >= abs_y
434        && cy <= abs_y + container_h;
435
436    if !in_scrollbar {
437        return false;
438    }
439
440    let max_scroll = (content_h - container_h).max(0.0);
441    let ratio = container_h / content_h;
442    let thumb_h = (ratio * track_h).max(SCROLLBAR_MIN_THUMB);
443    let scroll_ratio = if max_scroll > 0.0 { node.scroll_y / max_scroll } else { 0.0 };
444    let thumb_y = track_y + scroll_ratio * (track_h - thumb_h);
445
446    // Check if click is on the thumb
447    if cy >= thumb_y && cy <= thumb_y + thumb_h {
448        // Start dragging from thumb
449        node.scrollbar_dragging = true;
450        node.scrollbar_drag_start_y = cy;
451        node.scrollbar_drag_start_scroll = node.scroll_y;
452    } else {
453        // Click on track: jump to position
454        let click_ratio = (cy - track_y) / track_h;
455        node.scroll_y = (click_ratio * max_scroll).clamp(0.0, max_scroll);
456    }
457
458    true
459}
460
461/// Release scrollbar drag on all scrollable nodes.
462pub fn release_scrollbar_drag(node: &mut WidgetNode) {
463    node.scrollbar_dragging = false;
464    for child in &mut node.children {
465        release_scrollbar_drag(child);
466    }
467}
468
469pub fn clear_active_widgets(node: &mut WidgetNode) {
470    node.widget.clear_active();
471    for child in &mut node.children {
472        clear_active_widgets(child);
473    }
474}