Skip to main content

fc_dioxus/components/
splitter.rs

1//! Resizable split panes and edge handles on the WAI-ARIA window splitter pattern, from
2//! splitter.css. The behaviour ports upstream's `splitter.ts`; the arithmetic is in
3//! [`crate::behavior::splitter`].
4use crate::{
5    behavior::splitter::{drag_value, key_value, settle, KeyContext, SplitUnit},
6    core::{
7        assets,
8        attrs::component_attributes,
9        measure::{custom_property, CustomProperty},
10        platform::DomBridge,
11    },
12    prelude::*,
13    theme::use_stylesheets,
14};
15use std::rc::Rc;
16const SPLIT: Bem = Bem::new("fc-split");
17const PANE: Bem = Bem::new("fc-split__pane");
18const BAR: Bem = Bem::new("fc-splitter");
19const RESIZABLE: Bem = Bem::new("fc-resizable");
20fc_modifier! { enum SplitFlow for SPLIT { Row => None, Stacked => "stacked" } }
21fc_modifier! { enum PaneRole for PANE { Secondary => None, Primary => "primary" } }
22fc_modifier! { enum BarKind for BAR { Between => None, Edge => "edge" } }
23fc_modifier! { enum ResizeAxis for RESIZABLE { Inline => None, Block => "block" } }
24
25/// A size change. `settled` is false while a drag is in progress, so an app can persist
26/// only sizes the user let go of.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct Resize {
29    /// The new value, in the splitter's unit.
30    pub value: f64,
31    /// Whether the size has settled.
32    pub settled: bool,
33}
34
35#[derive(Clone, Copy, PartialEq)]
36struct Drag {
37    start: f64,
38    origin: f64,
39    per_pixel: f64,
40}
41
42#[derive(Clone, Copy)]
43struct Bar {
44    unit: SplitUnit,
45    inline: bool,
46    growth: f64,
47    min: f64,
48    max: f64,
49    step: f64,
50    disabled: bool,
51}
52
53/// The separator itself, shared by [`Split`] and [`Resizable`].
54#[allow(clippy::too_many_arguments)]
55fn separator(
56    bar: Bar,
57    label: &AccessibleName,
58    controls: &Id,
59    mut value: Signal<f64>,
60    mut restore: Signal<Option<f64>>,
61    mut drag: Signal<Option<Drag>>,
62    frame: Signal<Option<Rc<MountedData>>>,
63    onresize: Option<EventHandler<Resize>>,
64    handle: &Id,
65    rtl: bool,
66) -> Element {
67    let Bar {
68        unit,
69        inline,
70        growth,
71        min,
72        max,
73        step,
74        disabled,
75    } = bar;
76    let mut set = move |next: f64, settled: bool| {
77        let next = settle(next, min, max, unit);
78        // Values are rounded to tenths or whole pixels, so a small margin is exact.
79        if (*value.peek() - next).abs() > f64::EPSILON || settled {
80            value.set(next);
81            if let Some(onresize) = onresize {
82                onresize.call(Resize {
83                    value: next,
84                    settled,
85                });
86            }
87        }
88        next
89    };
90    let class = ClassList::new(BAR.block())
91        .with_modifier(
92            BAR,
93            if unit == SplitUnit::Px {
94                BarKind::Edge
95            } else {
96                BarKind::Between
97            },
98        )
99        .to_string();
100    let capture = handle.clone();
101    rsx! {
102        div { class, id: "{handle}", role: "separator", tabindex: "0",
103            aria_orientation: if inline { "vertical" } else { "horizontal" },
104            aria_controls: "{controls}", aria_label: label.to_string(),
105            aria_valuenow: "{value}", aria_valuemin: "{min}", aria_valuemax: "{max}",
106            aria_disabled: disabled.then_some("true"),
107            onkeydown: move |event| {
108                if disabled { return; }
109                if event.key() == Key::Escape {
110                    if let Some(start) = drag() {
111                        event.prevent_default();
112                        event.stop_propagation();
113                        drag.set(None);
114                        set(start.start, true);
115                    }
116                    return;
117                }
118                let context = KeyContext { inline, rtl, growth, current: value(), min, max, step, restore: restore() };
119                let Some((next, remember)) = key_value(&event.key(), event.modifiers().shift(), context) else { return };
120                event.prevent_default();
121                if let Some(remember) = remember { restore.set(Some(remember)); }
122                set(next, true);
123            },
124            onpointerdown: move |event| {
125                if disabled || event.trigger_button() != Some(dioxus::html::input_data::MouseButton::Primary) { return; }
126                event.prevent_default();
127                let point = event.client_coordinates();
128                let origin = if inline { point.x } else { point.y };
129                let pointer = event.pointer_id();
130                let start = value();
131                let capture = capture.clone();
132                spawn(async move {
133                    let per_pixel = if unit == SplitUnit::Percent {
134                        let Some(frame) = frame() else { return };
135                        let Ok(rect) = frame.get_client_rect().await else { return };
136                        let total = if inline { rect.width() } else { rect.height() };
137                        if total <= 0.0 { return; }
138                        100.0 / total
139                    } else {
140                        1.0
141                    };
142                    let _ = DomBridge.capture_pointer(&capture, pointer).await;
143                    let _ = DomBridge.focus(&capture).await;
144                    drag.set(Some(Drag { start, origin, per_pixel }));
145                });
146            },
147            onpointermove: move |event| {
148                let Some(start) = drag() else { return };
149                let point = event.client_coordinates();
150                let delta = (if inline { point.x } else { point.y }) - start.origin;
151                set(drag_value(start.start, delta, start.per_pixel, growth, inline && rtl), false);
152            },
153            onpointerup: move |_| {
154                if drag().is_some() {
155                    drag.set(None);
156                    let settled = set(value(), true);
157                    if settled > min { restore.set(Some(settled)); }
158                }
159            },
160            onpointercancel: move |_| {
161                if drag().is_some() {
162                    drag.set(None);
163                    set(value(), true);
164                }
165            },
166        }
167    }
168}
169
170/// Two panes and a bar between them. The value is the primary pane's share of the split,
171/// in percent, so the layout survives a window resize.
172///
173/// Arrows move the bar (Shift for ten times as far), Home and End go to the bounds, and
174/// Enter collapses the primary pane to the minimum and restores it. A pane at zero is
175/// `hidden`, so Tab cannot walk into controls nobody can see. Drag with any pointer;
176/// Escape during a drag puts the bar back.
177#[component]
178pub fn Split(
179    label: AccessibleName,
180    primary: Element,
181    secondary: Element,
182    /// Put the secondary pane first, e.g. a context panel at the inline end.
183    #[props(default)]
184    primary_last: bool,
185    /// Stack the panes and drag a horizontal bar.
186    #[props(default)]
187    stacked: bool,
188    /// The split's block size when stacked, as a CSS length.
189    #[props(default)]
190    block_size: Option<String>,
191    #[props(default)] value: Option<ReadSignal<f64>>,
192    #[props(default = 50.0)] default_value: f64,
193    #[props(default = 0.0)] min: f64,
194    #[props(default = 100.0)] max: f64,
195    #[props(default = 5.0)] step: f64,
196    #[props(default)] onresize: Option<EventHandler<Resize>>,
197    /// Name the primary pane as a focusable region, for a long, scrollable pane.
198    #[props(default)]
199    primary_label: Option<AccessibleName>,
200    #[props(default)] availability: Availability,
201    #[props(default)] class: Option<String>,
202    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
203) -> Element {
204    let mut current = use_signal(|| settle(default_value, min, max, SplitUnit::Percent));
205    if let Some(value) = value {
206        let outer = settle(*value.read(), min, max, SplitUnit::Percent);
207        if (*current.peek() - outer).abs() > f64::EPSILON {
208            current.set(outer);
209        }
210    }
211    let restore = use_signal(|| None);
212    let drag = use_signal(|| None);
213    let mut frame = use_signal(|| None);
214    let pane = use_id();
215    let handle = use_id();
216    let rtl = use_direction() == Direction::Rtl;
217    let mut attributes = component_attributes(
218        attributes,
219        ClassList::new(SPLIT.block())
220            .with_modifier(
221                SPLIT,
222                if stacked {
223                    SplitFlow::Stacked
224                } else {
225                    SplitFlow::Row
226                },
227            )
228            .extend(class.as_deref()),
229        &["style"],
230    );
231    if let (true, Some(block_size)) = (stacked, &block_size) {
232        attributes.push(custom_property(CustomProperty::SplitBlockSize, block_size));
233    }
234    let size = current();
235    let primary_view = rsx! {
236        div { class: ClassList::new(PANE.block()).with_modifier(PANE, PaneRole::Primary).to_string(), id: "{pane}",
237            hidden: size <= 0.0,
238            role: primary_label.as_ref().map(|_| "region"), aria_label: primary_label.as_ref().map(ToString::to_string),
239            tabindex: primary_label.as_ref().map(|_| "0"),
240            ..crate::spread![custom_property(CustomProperty::SplitSize, format!("{size}%"))],
241            {primary}
242        }
243    };
244    let bar = Bar {
245        unit: SplitUnit::Percent,
246        inline: !stacked,
247        // Moving the bar toward the end grows a pane that comes before it.
248        growth: if primary_last { -1.0 } else { 1.0 },
249        min,
250        max,
251        step,
252        disabled: availability != Availability::Enabled,
253    };
254    let bar = separator(
255        bar, &label, &pane, current, restore, drag, frame, onresize, &handle, rtl,
256    );
257    let secondary_view = rsx! { div { class: PANE.block(), {secondary} } };
258    rsx! {
259        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_SPLITTER_CSS } }
260        div { onmounted: move |event| frame.set(Some(event.data())), ..attributes,
261            if primary_last { {secondary_view} {bar} {primary_view} } else { {primary_view} {bar} {secondary_view} }
262        }
263    }
264}
265
266/// An element with a handle on its inline or block end, sized in pixels.
267#[component]
268pub fn Resizable(
269    label: AccessibleName,
270    /// Resize the block size (a handle on the bottom) instead of the inline size.
271    #[props(default)]
272    block: bool,
273    #[props(default)] value: Option<ReadSignal<f64>>,
274    default_value: f64,
275    #[props(default = 0.0)] min: f64,
276    max: f64,
277    #[props(default = 16.0)] step: f64,
278    #[props(default)] onresize: Option<EventHandler<Resize>>,
279    #[props(default)] availability: Availability,
280    #[props(default)] class: Option<String>,
281    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
282    children: Element,
283) -> Element {
284    let mut current = use_signal(|| settle(default_value, min, max, SplitUnit::Px));
285    if let Some(value) = value {
286        let outer = settle(*value.read(), min, max, SplitUnit::Px);
287        if (*current.peek() - outer).abs() > f64::EPSILON {
288            current.set(outer);
289        }
290    }
291    let restore = use_signal(|| None);
292    let drag = use_signal(|| None);
293    let frame = use_signal(|| None);
294    let id = use_id();
295    let handle = use_id();
296    let rtl = use_direction() == Direction::Rtl;
297    let mut attributes = component_attributes(
298        attributes,
299        ClassList::new(RESIZABLE.block())
300            .with_modifier(
301                RESIZABLE,
302                if block {
303                    ResizeAxis::Block
304                } else {
305                    ResizeAxis::Inline
306                },
307            )
308            .extend(class.as_deref()),
309        &["id", "style"],
310    );
311    attributes.push(custom_property(
312        CustomProperty::SplitSize,
313        format!("{}px", current()),
314    ));
315    let bar = Bar {
316        unit: SplitUnit::Px,
317        inline: !block,
318        growth: 1.0,
319        min,
320        max,
321        step,
322        disabled: availability != Availability::Enabled,
323    };
324    rsx! {
325        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_SPLITTER_CSS } }
326        div { id: "{id}", ..attributes,
327            {children}
328            {separator(bar, &label, &id, current, restore, drag, frame, onresize, &handle, rtl)}
329        }
330    }
331}