Skip to main content

azul_layout/widgets/
split_pane.rs

1//! Split-pane / splitter widget — a two-pane container (horizontal or vertical)
2//! holding two arbitrary child `Dom`s with a draggable divider between them that
3//! resizes the panes.
4//!
5//! This is [`crate::widgets::frame::Frame`]'s "two bordered boxes" composed with
6//! the pointer-drag state machine of [`crate::widgets::map`] /
7//! [`crate::widgets::slider::Slider`]: the drag callbacks live on the **container**
8//! (so the cursor stays inside the callback node for the whole drag, exactly like
9//! the map's pan), `MouseDown` near the divider begins the drag, `MouseOver` while
10//! dragging recomputes the split ratio from the cursor delta and live-resizes the
11//! two panes via `set_css_property` (`flex-grow`), and `MouseUp` / `MouseLeave`
12//! ends it.
13//!
14//! ## Layout model
15//! The container is a flex row (horizontal split: panes left/right) or column
16//! (vertical split: panes top/bottom). Its three children are
17//! `[first-pane, divider, second-pane]`. Both panes use `flex-basis: 0` and a
18//! `flex-grow` of `ratio` / `1 - ratio`, so they split the container's main-axis
19//! space proportionally while the divider keeps its fixed thickness. Dragging
20//! rewrites the two `flex-grow` values.
21//!
22//! ## Drag tracking (mirrors `map::MapTileCache`)
23//! The transient drag fields (`is_dragging`, `drag_start_px`, `ratio_at_drag_start`)
24//! live in [`SplitPaneStateWrapper`] (not the user-visible [`SplitPaneState`]),
25//! the same way the map keeps `drag_anchor` in its cache. On press we record the
26//! cursor's main-axis position and the ratio at that moment; each move applies
27//! `ratio_at_drag_start + delta / main_size`, so grabbing the divider anywhere
28//! keeps it under the cursor (the map's anchor-delta feel).
29//!
30//! TODO2 / PARTIAL — continuous drag is NOT verifiable in this headless build.
31//! Like `map.rs`'s pan, the live resize depends on the runtime delivering
32//! `MouseOver` (with a node-relative cursor) repeatedly while the button is held,
33//! and on `set_css_property(flex-grow)` triggering a relayout per move — both are
34//! GUI-runtime behaviours with no headless test here. The DOM, the divider, the
35//! proportional `flex-grow` sizing, and the press/move/release wiring all compile
36//! and mirror the proven map/slider pattern exactly; the moment-to-moment motion
37//! is the only unverified part. No motion is faked.
38//!
39//! Key types: [`SplitPane`], [`SplitPaneState`], [`SplitDirection`],
40//! [`SplitPaneOnResize`].
41
42use std::vec::Vec;
43
44use azul_core::{
45    callbacks::{CoreCallbackData, Update},
46    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
47    geom::{CursorNodePosition, LogicalSize},
48    refany::RefAny,
49};
50use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
51use azul_css::{
52    props::{
53        basic::{color::ColorU, FloatValue, PixelValue},
54        layout::{LayoutFlexGrow, LayoutFlexDirection, LayoutDisplay, LayoutWidth, LayoutHeight, LayoutOverflow, LayoutFlexBasis, LayoutMinWidth, LayoutMinHeight, LayoutFlexShrink},
55        property::{CssProperty, LayoutFlexGrowValue, LayoutWidthValue, LayoutHeightValue, LayoutFlexBasisValue},
56        style::{StyleBackgroundContent, StyleBackgroundContentVec, StyleCursor},
57    },
58    impl_option_inner, AzString,
59};
60
61use crate::callbacks::CallbackInfo;
62
63static SPLIT_PANE_CLASS: &[IdOrClass] =
64    &[Class(AzString::from_const_str("__azul-native-split-pane"))];
65static SPLIT_PANE_FIRST_CLASS: &[IdOrClass] =
66    &[Class(AzString::from_const_str("__azul-native-split-pane-first"))];
67static SPLIT_PANE_DIVIDER_CLASS: &[IdOrClass] =
68    &[Class(AzString::from_const_str("__azul-native-split-pane-divider"))];
69static SPLIT_PANE_SECOND_CLASS: &[IdOrClass] =
70    &[Class(AzString::from_const_str("__azul-native-split-pane-second"))];
71
72/// Orientation of a [`SplitPane`].
73#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
74#[repr(C)]
75pub enum SplitDirection {
76    /// Panes side by side (left / right); a vertical divider dragged horizontally.
77    #[default]
78    Horizontal,
79    /// Panes stacked (top / bottom); a horizontal divider dragged vertically.
80    Vertical,
81}
82
83/// Callback function type invoked when the split ratio changes (during a drag).
84pub type SplitPaneOnResizeCallbackType =
85    extern "C" fn(RefAny, CallbackInfo, SplitPaneState) -> Update;
86impl_widget_callback!(
87    SplitPaneOnResize,
88    OptionSplitPaneOnResize,
89    SplitPaneOnResizeCallback,
90    SplitPaneOnResizeCallbackType
91);
92
93azul_core::impl_managed_callback! {
94    wrapper:        SplitPaneOnResizeCallback,
95    info_ty:        CallbackInfo,
96    return_ty:      Update,
97    default_ret:    Update::DoNothing,
98    invoker_static: SPLIT_PANE_ON_RESIZE_INVOKER,
99    invoker_ty:     AzSplitPaneOnResizeCallbackInvoker,
100    thunk_fn:       az_split_pane_on_resize_callback_thunk,
101    setter_fn:      AzApp_setSplitPaneOnResizeCallbackInvoker,
102    from_handle_fn: AzSplitPaneOnResizeCallback_createFromHostHandle,
103    extra_args:     [ state: SplitPaneState ],
104}
105
106/// A two-pane resizable container with a draggable divider.
107#[derive(Debug, Clone)]
108#[repr(C)]
109pub struct SplitPane {
110    pub split_pane_state: SplitPaneStateWrapper,
111    /// The first pane's content (left for horizontal, top for vertical).
112    pub first: Dom,
113    /// The second pane's content (right for horizontal, bottom for vertical).
114    pub second: Dom,
115    /// Style for the outer container.
116    pub container_style: CssPropertyWithConditionsVec,
117}
118
119#[derive(Debug, Default, Clone, PartialEq)]
120#[repr(C)]
121pub struct SplitPaneStateWrapper {
122    /// The user-visible orientation + split ratio.
123    pub inner: SplitPaneState,
124    /// Optional: function to call when the split ratio changes.
125    pub on_resize: OptionSplitPaneOnResize,
126    /// `true` while a divider drag is in flight (mirrors `map::MapTileCache::drag_anchor`).
127    /// Transient — not part of the user-visible [`SplitPaneState`].
128    pub is_dragging: bool,
129    /// Cursor main-axis position (relative to the container) at drag start.
130    pub drag_start_px: f32,
131    /// Split ratio captured at drag start (the anchor for the delta-based update).
132    pub ratio_at_drag_start: f32,
133}
134
135/// State of a [`SplitPane`]: the orientation and the first pane's size fraction.
136#[derive(Debug, Copy, Clone, PartialEq)]
137#[repr(C)]
138pub struct SplitPaneState {
139    /// Orientation of the split.
140    pub direction: SplitDirection,
141    /// Fraction `[0, 1]` of the container's main-axis size taken by the FIRST
142    /// pane. Clamped to `[MIN_RATIO, MAX_RATIO]` so a pane never fully collapses.
143    pub ratio: f32,
144}
145
146impl Default for SplitPaneState {
147    fn default() -> Self {
148        Self {
149            direction: SplitDirection::Horizontal,
150            ratio: 0.5,
151        }
152    }
153}
154
155// ---- dimensions / limits ----
156/// Divider thickness in logical px.
157const DIVIDER_THICKNESS: isize = 6;
158/// How far (logical px) from the divider centre a press still grabs it.
159const GRAB_THRESHOLD: f32 = 9.0;
160/// Smallest / largest allowed first-pane fraction (keeps both panes visible).
161const MIN_RATIO: f32 = 0.05;
162const MAX_RATIO: f32 = 0.95;
163
164// ---- colours ----
165/// Divider colour (#adb5bd, mid grey).
166const DIVIDER_COLOR: ColorU = ColorU { r: 173, g: 181, b: 189, a: 255 };
167
168const DIVIDER_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(DIVIDER_COLOR)];
169const DIVIDER_BG: StyleBackgroundContentVec =
170    StyleBackgroundContentVec::from_const_slice(DIVIDER_BG_ITEMS);
171
172/// `flex-grow: v` as a runtime `CssProperty` (floating-point ratio).
173fn flex_grow_prop(v: f32) -> CssProperty {
174    CssProperty::FlexGrow(LayoutFlexGrowValue::Exact(LayoutFlexGrow {
175        inner: FloatValue::new(v),
176    }))
177}
178
179/// The cursor's main-axis (drag-axis) coordinate for the given direction.
180const fn main_axis(dir: SplitDirection, pos: CursorNodePosition) -> f32 {
181    match dir {
182        SplitDirection::Horizontal => pos.x,
183        SplitDirection::Vertical => pos.y,
184    }
185}
186
187/// The container's main-axis (drag-axis) size for the given direction.
188const fn main_size(dir: SplitDirection, size: LogicalSize) -> f32 {
189    match dir {
190        SplitDirection::Horizontal => size.width,
191        SplitDirection::Vertical => size.height,
192    }
193}
194
195/// Builds the outer-container style: a full-size flex box laid out along the
196/// split's main axis. Overridable via [`SplitPane::with_container_style`].
197fn container_style(dir: SplitDirection) -> CssPropertyWithConditionsVec {
198    let flex_dir = match dir {
199        SplitDirection::Horizontal => LayoutFlexDirection::Row,
200        SplitDirection::Vertical => LayoutFlexDirection::Column,
201    };
202    CssPropertyWithConditionsVec::from_vec(vec![
203        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
204        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(flex_dir)),
205        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
206        CssPropertyWithConditions::simple(CssProperty::Width(LayoutWidthValue::Exact(
207            LayoutWidth::Px(PixelValue::percent(100.0)),
208        ))),
209        CssPropertyWithConditions::simple(CssProperty::Height(LayoutHeightValue::Exact(
210            LayoutHeight::Px(PixelValue::percent(100.0)),
211        ))),
212        CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
213        CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
214    ])
215}
216
217/// Builds a pane's style: `flex-grow: grow; flex-basis: 0` so the two panes split
218/// the container's main-axis space proportionally, plus `overflow: hidden` and
219/// `min-width/height: 0` so a shrinking pane clips its content instead of forcing
220/// the container wider.
221fn pane_style(grow: f32) -> CssPropertyWithConditionsVec {
222    CssPropertyWithConditionsVec::from_vec(vec![
223        CssPropertyWithConditions::simple(flex_grow_prop(grow)),
224        CssPropertyWithConditions::simple(CssProperty::FlexBasis(LayoutFlexBasisValue::Exact(
225            LayoutFlexBasis::Exact(PixelValue::const_px(0)),
226        ))),
227        CssPropertyWithConditions::simple(CssProperty::const_min_width(LayoutMinWidth::const_px(0))),
228        CssPropertyWithConditions::simple(CssProperty::const_min_height(LayoutMinHeight::const_px(
229            0,
230        ))),
231        CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
232        CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
233    ])
234}
235
236/// Builds the divider's style: fixed thickness, no grow/shrink, a resize cursor
237/// matching the drag axis, and a visible fill. The cross-axis size is left to the
238/// flex default (stretch), so the divider spans the container.
239fn divider_style(dir: SplitDirection) -> CssPropertyWithConditionsVec {
240    let (size_prop, cursor) = match dir {
241        SplitDirection::Horizontal => (
242            CssProperty::const_width(LayoutWidth::const_px(DIVIDER_THICKNESS)),
243            StyleCursor::ColResize,
244        ),
245        SplitDirection::Vertical => (
246            CssProperty::const_height(LayoutHeight::const_px(DIVIDER_THICKNESS)),
247            StyleCursor::RowResize,
248        ),
249    };
250    CssPropertyWithConditionsVec::from_vec(vec![
251        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
252        CssPropertyWithConditions::simple(CssProperty::const_flex_shrink(LayoutFlexShrink {
253            inner: FloatValue::const_new(0),
254        })),
255        CssPropertyWithConditions::simple(size_prop),
256        CssPropertyWithConditions::simple(CssProperty::const_cursor(cursor)),
257        CssPropertyWithConditions::simple(CssProperty::const_background_content(DIVIDER_BG)),
258    ])
259}
260
261impl SplitPane {
262    /// Creates a split pane with the two child `Dom`s, split 50/50.
263    #[must_use] pub fn create(direction: SplitDirection, first: Dom, second: Dom) -> Self {
264        Self {
265            split_pane_state: SplitPaneStateWrapper {
266                inner: SplitPaneState {
267                    direction,
268                    ratio: 0.5,
269                },
270                ..Default::default()
271            },
272            first,
273            second,
274            container_style: container_style(direction),
275        }
276    }
277
278    /// Sets the first-pane fraction, clamped into `[MIN_RATIO, MAX_RATIO]`.
279    #[inline]
280    pub const fn set_ratio(&mut self, ratio: f32) {
281        self.split_pane_state.inner.ratio = ratio.clamp(MIN_RATIO, MAX_RATIO);
282    }
283
284    /// Builder-style setter for the first-pane fraction.
285    #[inline]
286    #[must_use] pub const fn with_ratio(mut self, ratio: f32) -> Self {
287        self.set_ratio(ratio);
288        self
289    }
290
291    /// Sets the orientation (also refreshes the default container style).
292    #[inline]
293    pub fn set_direction(&mut self, direction: SplitDirection) {
294        self.split_pane_state.inner.direction = direction;
295        self.container_style = container_style(direction);
296    }
297
298    /// Builder-style setter for the orientation.
299    #[inline]
300    #[must_use] pub fn with_direction(mut self, direction: SplitDirection) -> Self {
301        self.set_direction(direction);
302        self
303    }
304
305    /// Replaces the default container style.
306    #[inline]
307    #[must_use] pub fn with_container_style(mut self, css: CssPropertyWithConditionsVec) -> Self {
308        self.container_style = css;
309        self
310    }
311
312    #[inline]
313    #[must_use] pub fn swap_with_default(&mut self) -> Self {
314        let mut s = Self::create(
315            SplitDirection::Horizontal,
316            Dom::create_div(),
317            Dom::create_div(),
318        );
319        core::mem::swap(&mut s, self);
320        s
321    }
322
323    #[inline]
324    pub fn set_on_resize<C: Into<SplitPaneOnResizeCallback>>(&mut self, data: RefAny, on_resize: C) {
325        self.split_pane_state.on_resize = Some(SplitPaneOnResize {
326            callback: on_resize.into(),
327            refany: data,
328        })
329        .into();
330    }
331
332    #[inline]
333    #[must_use] pub fn with_on_resize<C: Into<SplitPaneOnResizeCallback>>(
334        mut self,
335        data: RefAny,
336        on_resize: C,
337    ) -> Self {
338        self.set_on_resize(data, on_resize);
339        self
340    }
341
342    #[must_use] pub fn dom(self) -> Dom {
343        use azul_core::{
344            callbacks::CoreCallback,
345            dom::{EventFilter, HoverEventFilter},
346            refany::OptionRefAny,
347        };
348
349        let direction = self.split_pane_state.inner.direction;
350        let ratio = self.split_pane_state.inner.ratio;
351
352        // One shared RefAny across all pointer callbacks so the transient drag
353        // fields set on press are visible to the move/release handlers (RefAny::clone
354        // shares the underlying data — same pattern as map.rs / slider.rs).
355        let state = RefAny::new(self.split_pane_state);
356        let mk = |event: EventFilter, cb: usize| CoreCallbackData {
357            event,
358            callback: CoreCallback {
359                cb,
360                ctx: OptionRefAny::None,
361            },
362            refany: state.clone(),
363        };
364        let callbacks = vec![
365            mk(
366                EventFilter::Hover(HoverEventFilter::MouseDown),
367                on_split_pointer_down as usize,
368            ),
369            mk(
370                EventFilter::Hover(HoverEventFilter::MouseOver),
371                on_split_pointer_move as usize,
372            ),
373            mk(
374                EventFilter::Hover(HoverEventFilter::MouseUp),
375                on_split_pointer_up as usize,
376            ),
377            mk(
378                EventFilter::Hover(HoverEventFilter::MouseLeave),
379                on_split_pointer_up as usize,
380            ),
381            mk(
382                EventFilter::Hover(HoverEventFilter::TouchStart),
383                on_split_pointer_down as usize,
384            ),
385            mk(
386                EventFilter::Hover(HoverEventFilter::TouchMove),
387                on_split_pointer_move as usize,
388            ),
389            mk(
390                EventFilter::Hover(HoverEventFilter::TouchEnd),
391                on_split_pointer_up as usize,
392            ),
393        ];
394
395        // Children: [first-pane, divider, second-pane] — the order the drag
396        // handler relies on (first_child = pane0, then divider, then pane1).
397        let first_pane = Dom::create_div()
398            .with_ids_and_classes(IdOrClassVec::from_const_slice(SPLIT_PANE_FIRST_CLASS))
399            .with_css_props(pane_style(ratio))
400            .with_children(vec![self.first].into());
401
402        let divider = Dom::create_div()
403            .with_ids_and_classes(IdOrClassVec::from_const_slice(SPLIT_PANE_DIVIDER_CLASS))
404            .with_css_props(divider_style(direction));
405
406        let second_pane = Dom::create_div()
407            .with_ids_and_classes(IdOrClassVec::from_const_slice(SPLIT_PANE_SECOND_CLASS))
408            .with_css_props(pane_style(1.0 - ratio))
409            .with_children(vec![self.second].into());
410
411        Dom::create_div()
412            .with_ids_and_classes(IdOrClassVec::from_const_slice(SPLIT_PANE_CLASS))
413            .with_css_props(self.container_style)
414            .with_callbacks(callbacks.into())
415            .with_tab_index(TabIndex::Auto)
416            .with_children(vec![first_pane, divider, second_pane].into())
417    }
418}
419
420impl Default for SplitPane {
421    fn default() -> Self {
422        Self::create(
423            SplitDirection::Horizontal,
424            Dom::create_div(),
425            Dom::create_div(),
426        )
427    }
428}
429
430/// Pointer down → if the press lands near the divider, begin a drag and record
431/// the anchor (cursor position + ratio at this moment). A press elsewhere is left
432/// alone so it can reach the pane content.
433extern "C" fn on_split_pointer_down(mut data: RefAny, info: CallbackInfo) -> Update {
434    let Some(pos) = info.get_cursor_relative_to_node().into_option() else {
435        return Update::DoNothing;
436    };
437    let size = match info.get_hit_node_rect() {
438        Some(r) => r.size,
439        None => return Update::DoNothing,
440    };
441    let Some(mut sp) = data.downcast_mut::<SplitPaneStateWrapper>() else {
442        return Update::DoNothing;
443    };
444    let dir = sp.inner.direction;
445    let msize = main_size(dir, size);
446    if msize <= 0.0 {
447        return Update::DoNothing;
448    }
449    let main = main_axis(dir, pos);
450    let divider_center = sp.inner.ratio * msize;
451    if (main - divider_center).abs() <= GRAB_THRESHOLD {
452        sp.is_dragging = true;
453        sp.drag_start_px = main;
454        sp.ratio_at_drag_start = sp.inner.ratio;
455    }
456    Update::DoNothing
457}
458
459/// Pointer move → while dragging, recompute the ratio from the cursor delta and
460/// live-resize the two panes' `flex-grow`, then fire the user's `on_resize`.
461extern "C" fn on_split_pointer_move(mut data: RefAny, mut info: CallbackInfo) -> Update {
462    let Some(mut sp) = data.downcast_mut::<SplitPaneStateWrapper>() else {
463        return Update::DoNothing;
464    };
465    if !sp.is_dragging {
466        return Update::DoNothing;
467    }
468    let dir = sp.inner.direction;
469    let Some(pos) = info.get_cursor_relative_to_node().into_option() else {
470        return Update::DoNothing;
471    };
472    let size = match info.get_hit_node_rect() {
473        Some(r) => r.size,
474        None => return Update::DoNothing,
475    };
476    let msize = main_size(dir, size);
477    if msize <= 0.0 {
478        return Update::DoNothing;
479    }
480    let main = main_axis(dir, pos);
481    let delta = main - sp.drag_start_px;
482    let new_ratio = (sp.ratio_at_drag_start + delta / msize).clamp(MIN_RATIO, MAX_RATIO);
483    sp.inner.ratio = new_ratio;
484
485    // Resize the two panes. Children are [pane0, divider, pane1]; the callback
486    // node (hit node) is the container.
487    let container = info.get_hit_node();
488    if let Some(pane0) = info.get_first_child(container) {
489        info.set_css_property(pane0, flex_grow_prop(new_ratio));
490        if let Some(divider) = info.get_next_sibling(pane0) {
491            if let Some(pane1) = info.get_next_sibling(divider) {
492                info.set_css_property(pane1, flex_grow_prop(1.0 - new_ratio));
493            }
494        }
495    }
496
497    let inner = sp.inner;
498    match sp.on_resize.as_mut() {
499        Some(SplitPaneOnResize { callback, refany }) => (callback.cb)(refany.clone(), info, inner),
500        None => Update::DoNothing,
501    }
502}
503
504/// Pointer up / leave → end the drag.
505extern "C" fn on_split_pointer_up(mut data: RefAny, _info: CallbackInfo) -> Update {
506    if let Some(mut sp) = data.downcast_mut::<SplitPaneStateWrapper>() {
507        sp.is_dragging = false;
508    }
509    Update::DoNothing
510}
511
512impl From<SplitPane> for Dom {
513    fn from(s: SplitPane) -> Self {
514        s.dom()
515    }
516}