Skip to main content

eui_core/
resizable.rs

1//! Round 2 (docs/round-2-contract.md §1, Resizable): the panel arithmetic
2//! every renderer shares, so a drag or a key press gives the same sizes
3//! everywhere. Sizes are PERCENTAGES of the group's main axis minus its
4//! handles (they sum to 100); only the two panels beside the moved handle
5//! change. Results are rounded to 1e-6. Mirrors `src/resizable.ts`;
6//! `fixtures/resizable.json` locks it.
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11pub use crate::layout::{PANEL_MIN, RESIZE_HANDLE_HIT, RESIZE_STEP};
12
13/// One panel's limits (`Resizable.panels[i]`), percent.
14#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
15pub struct PanelLimits {
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub min: Option<f64>,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub max: Option<f64>,
20    /// Dragging below half its `min` collapses it to 0; Enter toggles.
21    #[serde(default)]
22    pub collapsible: bool,
23}
24
25impl PanelLimits {
26    /// `panels` as authored (a JSON array of `{min?, max?, collapsible?}`).
27    pub fn list(v: Option<&Value>) -> Vec<PanelLimits> {
28        v.and_then(Value::as_array)
29            .map(|a| {
30                a.iter()
31                    .map(|p| PanelLimits {
32                        min: p.get("min").and_then(Value::as_f64),
33                        max: p.get("max").and_then(Value::as_f64),
34                        collapsible: p.get("collapsible") == Some(&Value::Bool(true)),
35                    })
36                    .collect()
37            })
38            .unwrap_or_default()
39    }
40}
41
42/// The panel group's axis.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45pub enum Orientation {
46    #[default]
47    Horizontal,
48    Vertical,
49}
50
51impl Orientation {
52    pub fn parse(s: &str) -> Option<Orientation> {
53        match s {
54            "horizontal" => Some(Orientation::Horizontal),
55            "vertical" => Some(Orientation::Vertical),
56            _ => None,
57        }
58    }
59}
60
61const EPS: f64 = 1e-9;
62
63fn clamp(v: f64, lo: f64, hi: f64) -> f64 {
64    v.min(hi).max(lo)
65}
66
67/// Rounded to 1e-6 (every platform prints the same numbers), `-0` → 0.
68fn tidy(v: f64) -> f64 {
69    let r = (v * 1e6).round() / 1e6;
70    if r == 0.0 {
71        0.0
72    } else {
73        r
74    }
75}
76
77struct Lim {
78    min: f64,
79    max: f64,
80    collapsible: bool,
81}
82
83fn limits_of(limits: &[PanelLimits], i: usize, count: usize) -> Lim {
84    let l = limits.get(i).copied().unwrap_or_default();
85    let cap = 100.0 / count.max(1) as f64;
86    let min = clamp(l.min.filter(|m| m.is_finite()).unwrap_or(PANEL_MIN), 0.0, cap);
87    let max = clamp(l.max.filter(|m| m.is_finite()).unwrap_or(100.0), min, 100.0);
88    Lim { min, max, collapsible: l.collapsible }
89}
90
91/// The sizes a group of `count` panels starts with: the valid given sizes
92/// (finite, ≥ 0), the missing ones sharing what is left of 100 (equal split
93/// when none is given), scaled to sum 100, each clamped into its [min, max]
94/// (a collapsible panel may stay 0) with the difference handed to the
95/// panels with room, in order. A `min` above 100 / count is lowered to it.
96pub fn normalize_sizes(sizes: Option<&[Value]>, count: usize, limits: &[PanelLimits]) -> Vec<f64> {
97    if count == 0 {
98        return Vec::new();
99    }
100    let given: Vec<Option<f64>> = (0..count).map(|i| sizes.and_then(|s| s.get(i)).and_then(Value::as_f64).filter(|v| v.is_finite() && *v >= 0.0)).collect();
101    let missing = given.iter().filter(|v| v.is_none()).count();
102    let mut out: Vec<f64> = if missing == count {
103        vec![100.0 / count as f64; count]
104    } else {
105        let sum: f64 = given.iter().map(|v| v.unwrap_or(0.0)).sum();
106        let rest = (100.0 - sum).max(0.0);
107        given.iter().map(|v| v.unwrap_or(rest / missing as f64)).collect()
108    };
109    let total: f64 = out.iter().sum();
110    out = if total > EPS { out.iter().map(|v| v * 100.0 / total).collect() } else { vec![100.0 / count as f64; count] };
111    let lim: Vec<Lim> = (0..count).map(|i| limits_of(limits, i, count)).collect();
112    for (i, v) in out.iter_mut().enumerate() {
113        *v = if lim[i].collapsible && *v <= EPS { 0.0 } else { clamp(*v, lim[i].min, lim[i].max) };
114    }
115    let mut diff = 100.0 - out.iter().sum::<f64>();
116    for i in 0..count {
117        if diff.abs() <= EPS {
118            break;
119        }
120        if lim[i].collapsible && out[i] == 0.0 {
121            continue;
122        }
123        let room = if diff > 0.0 { lim[i].max - out[i] } else { lim[i].min - out[i] };
124        let take = if diff > 0.0 { diff.min(room) } else { diff.max(room) };
125        out[i] += take;
126        diff -= take;
127    }
128    out.into_iter().map(tidy).collect()
129}
130
131/// Move handle `handle` (between panels `handle` and `handle + 1`) by
132/// `delta` percentage points (positive = the first panel grows), from the
133/// sizes at the START of the drag. Clamped so both panels keep their
134/// limits; a collapsible panel dragged below half its min collapses to 0,
135/// a collapsed one dragged past half its min reopens at max(min, the
136/// pointer), clamped the same way. Other panels never change.
137pub fn resize_panels(sizes: &[f64], handle: usize, delta: f64, limits: &[PanelLimits]) -> Vec<f64> {
138    let mut out = sizes.to_vec();
139    let (a, b) = (handle, handle + 1);
140    if b >= sizes.len() || !delta.is_finite() {
141        return out.into_iter().map(tidy).collect();
142    }
143    let la = limits_of(limits, a, sizes.len());
144    let lb = limits_of(limits, b, sizes.len());
145    let lo = (la.min - sizes[a]).max(sizes[b] - lb.max);
146    let hi = (la.max - sizes[a]).min(sizes[b] - lb.min);
147    let mut d = clamp(delta, lo.min(0.0), hi.max(0.0));
148    if la.collapsible && delta < 0.0 && sizes[a] > 0.0 && sizes[a] + delta < la.min / 2.0 && sizes[b] + sizes[a] <= lb.max + EPS {
149        d = -sizes[a];
150    } else if lb.collapsible && delta > 0.0 && sizes[b] > 0.0 && sizes[b] - delta < lb.min / 2.0 && sizes[a] + sizes[b] <= la.max + EPS {
151        d = sizes[b];
152    } else if la.collapsible && sizes[a] == 0.0 {
153        // A collapsed panel dragged past half its min reopens at its min at
154        // least, then follows the pointer (up to its max and the
155        // neighbour's min).
156        d = if delta >= la.min / 2.0 && hi >= la.min - EPS { clamp(delta, la.min, hi) } else { 0.0 };
157    } else if lb.collapsible && sizes[b] == 0.0 {
158        d = if -delta >= lb.min / 2.0 && -lo >= lb.min - EPS { -clamp(-delta, lb.min, -lo) } else { 0.0 };
159    }
160    out[a] = sizes[a] + d;
161    out[b] = sizes[b] - d;
162    out.into_iter().map(tidy).collect()
163}
164
165/// The keys a focused handle handles ([`keyboard_resize`]); hosts may
166/// forward every key, the rest are ignored.
167pub const RESIZE_KEYS: [&str; 7] = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "Enter"];
168
169/// A key on a focused handle (`ArrowLeft|ArrowRight|ArrowUp|ArrowDown|Home|
170/// End|Enter`). Arrows move the HANDLE on screen by [`RESIZE_STEP`]
171/// (horizontal: Left/Right, rtl flips; vertical: Up/Down); Home / End take
172/// the first panel to its min / max; Enter collapses the first panel when
173/// collapsible (else the second), reopening a collapsed one at its min.
174/// Other keys change nothing.
175pub fn keyboard_resize(sizes: &[f64], handle: usize, key: &str, orientation: Orientation, rtl: bool, limits: &[PanelLimits]) -> Vec<f64> {
176    let same = || sizes.iter().copied().map(tidy).collect::<Vec<_>>();
177    let (a, b) = (handle, handle + 1);
178    if b >= sizes.len() {
179        return same();
180    }
181    let la = limits_of(limits, a, sizes.len());
182    let lb = limits_of(limits, b, sizes.len());
183    let flip = if orientation == Orientation::Horizontal && rtl { -1.0 } else { 1.0 };
184    let step = |sign: f64| resize_panels(sizes, handle, sign * RESIZE_STEP, limits);
185    let horizontal = orientation == Orientation::Horizontal;
186    match key {
187        "ArrowRight" if horizontal => step(flip),
188        "ArrowLeft" if horizontal => step(-flip),
189        "ArrowDown" if !horizontal => step(1.0),
190        "ArrowUp" if !horizontal => step(-1.0),
191        "Home" => resize_panels(sizes, handle, la.min - sizes[a], limits),
192        "End" => resize_panels(sizes, handle, la.max - sizes[a], limits),
193        "Enter" => {
194            let mut out = sizes.to_vec();
195            if la.collapsible {
196                let d = if sizes[a] > 0.0 { -sizes[a] } else { la.min.min(sizes[b] - lb.min) };
197                if sizes[b] - d > lb.max + EPS || d == 0.0 {
198                    return same();
199                }
200                out[a] += d;
201                out[b] -= d;
202            } else if lb.collapsible {
203                let d = if sizes[b] > 0.0 { sizes[b] } else { -lb.min.min(sizes[a] - la.min) };
204                if sizes[a] + d > la.max + EPS || d == 0.0 {
205                    return same();
206                }
207                out[a] += d;
208                out[b] -= d;
209            }
210            out.into_iter().map(tidy).collect()
211        }
212        _ => same(),
213    }
214}
215
216/// Panel lengths in px on the main axis: the container minus every handle
217/// (`handle_extent` each, `$control.hairline`), shared by the sizes.
218pub fn panel_extents(sizes: &[f64], container: f64, handle_extent: f64) -> Vec<f64> {
219    let avail = (container - sizes.len().saturating_sub(1) as f64 * handle_extent).max(0.0);
220    sizes.iter().map(|s| tidy(avail * s / 100.0)).collect()
221}
222
223/// A pointer movement (px on the main axis, screen direction) as the
224/// percentage delta [`resize_panels`] takes (rtl horizontal groups flip).
225pub fn drag_delta(px: f64, container: f64, panel_count: usize, orientation: Orientation, rtl: bool, handle_extent: f64) -> f64 {
226    let avail = (container - panel_count.saturating_sub(1) as f64 * handle_extent).max(1.0);
227    let flip = if orientation == Orientation::Horizontal && rtl { -1.0 } else { 1.0 };
228    tidy(px * 100.0 / avail * flip)
229}