Skip to main content

rumatui_tui/
layout.rs

1use std::cell::RefCell;
2use std::cmp::{max, min};
3use std::collections::HashMap;
4
5use cassowary::strength::{REQUIRED, WEAK};
6use cassowary::WeightedRelation::*;
7use cassowary::{Constraint as CassowaryConstraint, Expression, Solver, Variable};
8
9#[derive(Debug, Hash, Clone, Copy, PartialEq, Eq)]
10pub enum Corner {
11    TopLeft,
12    TopRight,
13    BottomRight,
14    BottomLeft,
15}
16
17#[derive(Debug, Hash, Clone, PartialEq, Eq)]
18pub enum Direction {
19    Horizontal,
20    Vertical,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum Constraint {
25    // TODO: enforce range 0 - 100
26    Percentage(u16),
27    Ratio(u32, u32),
28    Length(u16),
29    Max(u16),
30    Min(u16),
31}
32
33impl Constraint {
34    pub fn apply(&self, length: u16) -> u16 {
35        match *self {
36            Constraint::Percentage(p) => length * p / 100,
37            Constraint::Ratio(num, den) => {
38                let r = num * u32::from(length) / den;
39                r as u16
40            }
41            Constraint::Length(l) => length.min(l),
42            Constraint::Max(m) => length.min(m),
43            Constraint::Min(m) => length.max(m),
44        }
45    }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
49pub struct Margin {
50    pub vertical: u16,
51    pub horizontal: u16,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq)]
55pub enum Alignment {
56    Left,
57    Center,
58    Right,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub enum ScrollMode {
63    Normal,
64    Tail,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Hash)]
68pub struct Layout {
69    direction: Direction,
70    margin: Margin,
71    constraints: Vec<Constraint>,
72}
73
74thread_local! {
75    static LAYOUT_CACHE: RefCell<HashMap<(Rect, Layout), Vec<Rect>>> = RefCell::new(HashMap::new());
76}
77
78impl Default for Layout {
79    fn default() -> Layout {
80        Layout {
81            direction: Direction::Vertical,
82            margin: Margin {
83                horizontal: 0,
84                vertical: 0,
85            },
86            constraints: Vec::new(),
87        }
88    }
89}
90
91impl Layout {
92    pub fn constraints<C>(mut self, constraints: C) -> Layout
93    where
94        C: Into<Vec<Constraint>>,
95    {
96        self.constraints = constraints.into();
97        self
98    }
99
100    pub fn margin(mut self, margin: u16) -> Layout {
101        self.margin = Margin {
102            horizontal: margin,
103            vertical: margin,
104        };
105        self
106    }
107
108    pub fn horizontal_margin(mut self, horizontal: u16) -> Layout {
109        self.margin.horizontal = horizontal;
110        self
111    }
112
113    pub fn vertical_margin(mut self, vertical: u16) -> Layout {
114        self.margin.vertical = vertical;
115        self
116    }
117
118    pub fn direction(mut self, direction: Direction) -> Layout {
119        self.direction = direction;
120        self
121    }
122
123    /// Wrapper function around the cassowary-rs solver to be able to split a given
124    /// area into smaller ones based on the preferred widths or heights and the direction.
125    ///
126    /// # Examples
127    /// ```
128    /// # use rumatui_tui::layout::{Rect, Constraint, Direction, Layout};
129    /// let chunks = Layout::default()
130    ///     .direction(Direction::Vertical)
131    ///     .constraints([Constraint::Length(5), Constraint::Min(0)].as_ref())
132    ///     .split(Rect {
133    ///         x: 2,
134    ///         y: 2,
135    ///         width: 10,
136    ///         height: 10,
137    ///     });
138    /// assert_eq!(
139    ///     chunks,
140    ///     vec![
141    ///         Rect {
142    ///             x: 2,
143    ///             y: 2,
144    ///             width: 10,
145    ///             height: 5
146    ///         },
147    ///         Rect {
148    ///             x: 2,
149    ///             y: 7,
150    ///             width: 10,
151    ///             height: 5
152    ///         }
153    ///     ]
154    /// );
155    ///
156    /// let chunks = Layout::default()
157    ///     .direction(Direction::Horizontal)
158    ///     .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)].as_ref())
159    ///     .split(Rect {
160    ///         x: 0,
161    ///         y: 0,
162    ///         width: 9,
163    ///         height: 2,
164    ///     });
165    /// assert_eq!(
166    ///     chunks,
167    ///     vec![
168    ///         Rect {
169    ///             x: 0,
170    ///             y: 0,
171    ///             width: 3,
172    ///             height: 2
173    ///         },
174    ///         Rect {
175    ///             x: 3,
176    ///             y: 0,
177    ///             width: 6,
178    ///             height: 2
179    ///         }
180    ///     ]
181    /// );
182    /// ```
183    pub fn split(self, area: Rect) -> Vec<Rect> {
184        // TODO: Maybe use a fixed size cache ?
185        LAYOUT_CACHE.with(|c| {
186            c.borrow_mut()
187                .entry((area, self.clone()))
188                .or_insert_with(|| split(area, &self))
189                .clone()
190        })
191    }
192}
193
194fn split(area: Rect, layout: &Layout) -> Vec<Rect> {
195    let mut solver = Solver::new();
196    let mut vars: HashMap<Variable, (usize, usize)> = HashMap::new();
197    let elements = layout
198        .constraints
199        .iter()
200        .map(|_| Element::new())
201        .collect::<Vec<Element>>();
202    let mut results = layout
203        .constraints
204        .iter()
205        .map(|_| Rect::default())
206        .collect::<Vec<Rect>>();
207
208    let dest_area = area.inner(&layout.margin);
209    for (i, e) in elements.iter().enumerate() {
210        vars.insert(e.x, (i, 0));
211        vars.insert(e.y, (i, 1));
212        vars.insert(e.width, (i, 2));
213        vars.insert(e.height, (i, 3));
214    }
215    let mut ccs: Vec<CassowaryConstraint> =
216        Vec::with_capacity(elements.len() * 4 + layout.constraints.len() * 6);
217    for elt in &elements {
218        ccs.push(elt.left() | GE(REQUIRED) | f64::from(dest_area.left()));
219        ccs.push(elt.top() | GE(REQUIRED) | f64::from(dest_area.top()));
220        ccs.push(elt.right() | LE(REQUIRED) | f64::from(dest_area.right()));
221        ccs.push(elt.bottom() | LE(REQUIRED) | f64::from(dest_area.bottom()));
222    }
223    if let Some(first) = elements.first() {
224        ccs.push(match layout.direction {
225            Direction::Horizontal => first.left() | EQ(REQUIRED) | f64::from(dest_area.left()),
226            Direction::Vertical => first.top() | EQ(REQUIRED) | f64::from(dest_area.top()),
227        });
228    }
229    if let Some(last) = elements.last() {
230        ccs.push(match layout.direction {
231            Direction::Horizontal => last.right() | EQ(REQUIRED) | f64::from(dest_area.right()),
232            Direction::Vertical => last.bottom() | EQ(REQUIRED) | f64::from(dest_area.bottom()),
233        });
234    }
235    match layout.direction {
236        Direction::Horizontal => {
237            for pair in elements.windows(2) {
238                ccs.push((pair[0].x + pair[0].width) | EQ(REQUIRED) | pair[1].x);
239            }
240            for (i, size) in layout.constraints.iter().enumerate() {
241                ccs.push(elements[i].y | EQ(REQUIRED) | f64::from(dest_area.y));
242                ccs.push(elements[i].height | EQ(REQUIRED) | f64::from(dest_area.height));
243                ccs.push(match *size {
244                    Constraint::Length(v) => elements[i].width | EQ(WEAK) | f64::from(v),
245                    Constraint::Percentage(v) => {
246                        elements[i].width | EQ(WEAK) | (f64::from(v * dest_area.width) / 100.0)
247                    }
248                    Constraint::Ratio(n, d) => {
249                        elements[i].width
250                            | EQ(WEAK)
251                            | (f64::from(dest_area.width) * f64::from(n) / f64::from(d))
252                    }
253                    Constraint::Min(v) => elements[i].width | GE(WEAK) | f64::from(v),
254                    Constraint::Max(v) => elements[i].width | LE(WEAK) | f64::from(v),
255                });
256            }
257        }
258        Direction::Vertical => {
259            for pair in elements.windows(2) {
260                ccs.push((pair[0].y + pair[0].height) | EQ(REQUIRED) | pair[1].y);
261            }
262            for (i, size) in layout.constraints.iter().enumerate() {
263                ccs.push(elements[i].x | EQ(REQUIRED) | f64::from(dest_area.x));
264                ccs.push(elements[i].width | EQ(REQUIRED) | f64::from(dest_area.width));
265                ccs.push(match *size {
266                    Constraint::Length(v) => elements[i].height | EQ(WEAK) | f64::from(v),
267                    Constraint::Percentage(v) => {
268                        elements[i].height | EQ(WEAK) | (f64::from(v * dest_area.height) / 100.0)
269                    }
270                    Constraint::Ratio(n, d) => {
271                        elements[i].height
272                            | EQ(WEAK)
273                            | (f64::from(dest_area.height) * f64::from(n) / f64::from(d))
274                    }
275                    Constraint::Min(v) => elements[i].height | GE(WEAK) | f64::from(v),
276                    Constraint::Max(v) => elements[i].height | LE(WEAK) | f64::from(v),
277                });
278            }
279        }
280    }
281    solver.add_constraints(&ccs).unwrap();
282    for &(var, value) in solver.fetch_changes() {
283        let (index, attr) = vars[&var];
284        let value = if value.is_sign_negative() {
285            0
286        } else {
287            value as u16
288        };
289        match attr {
290            0 => {
291                results[index].x = value;
292            }
293            1 => {
294                results[index].y = value;
295            }
296            2 => {
297                results[index].width = value;
298            }
299            3 => {
300                results[index].height = value;
301            }
302            _ => {}
303        }
304    }
305
306    // Fix imprecision by extending the last item a bit if necessary
307    if let Some(last) = results.last_mut() {
308        match layout.direction {
309            Direction::Vertical => {
310                last.height = dest_area.bottom() - last.y;
311            }
312            Direction::Horizontal => {
313                last.width = dest_area.right() - last.x;
314            }
315        }
316    }
317    results
318}
319
320/// A container used by the solver inside split
321struct Element {
322    x: Variable,
323    y: Variable,
324    width: Variable,
325    height: Variable,
326}
327
328impl Element {
329    fn new() -> Element {
330        Element {
331            x: Variable::new(),
332            y: Variable::new(),
333            width: Variable::new(),
334            height: Variable::new(),
335        }
336    }
337
338    fn left(&self) -> Variable {
339        self.x
340    }
341
342    fn top(&self) -> Variable {
343        self.y
344    }
345
346    fn right(&self) -> Expression {
347        self.x + self.width
348    }
349
350    fn bottom(&self) -> Expression {
351        self.y + self.height
352    }
353}
354
355/// A simple rectangle used in the computation of the layout and to give widgets an hint about the
356/// area they are supposed to render to.
357#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
358pub struct Rect {
359    pub x: u16,
360    pub y: u16,
361    pub width: u16,
362    pub height: u16,
363}
364
365impl Default for Rect {
366    fn default() -> Rect {
367        Rect {
368            x: 0,
369            y: 0,
370            width: 0,
371            height: 0,
372        }
373    }
374}
375
376impl Rect {
377    /// Creates a new rect, with width and height limited to keep the area under max u16.
378    /// If clipped, aspect ratio will be preserved.
379    pub fn new(x: u16, y: u16, width: u16, height: u16) -> Rect {
380        let max_area = u16::max_value();
381        let (clipped_width, clipped_height) =
382            if u32::from(width) * u32::from(height) > u32::from(max_area) {
383                let aspect_ratio = f64::from(width) / f64::from(height);
384                let max_area_f = f64::from(max_area);
385                let height_f = (max_area_f / aspect_ratio).sqrt();
386                let width_f = height_f * aspect_ratio;
387                (width_f as u16, height_f as u16)
388            } else {
389                (width, height)
390            };
391        Rect {
392            x,
393            y,
394            width: clipped_width,
395            height: clipped_height,
396        }
397    }
398
399    pub fn area(self) -> u16 {
400        self.width * self.height
401    }
402
403    pub fn left(self) -> u16 {
404        self.x
405    }
406
407    pub fn right(self) -> u16 {
408        self.x + self.width
409    }
410
411    pub fn top(self) -> u16 {
412        self.y
413    }
414
415    pub fn bottom(self) -> u16 {
416        self.y + self.height
417    }
418
419    pub fn inner(self, margin: &Margin) -> Rect {
420        if self.width < 2 * margin.horizontal || self.height < 2 * margin.vertical {
421            Rect::default()
422        } else {
423            Rect {
424                x: self.x + margin.horizontal,
425                y: self.y + margin.vertical,
426                width: self.width - 2 * margin.horizontal,
427                height: self.height - 2 * margin.vertical,
428            }
429        }
430    }
431
432    pub fn union(self, other: Rect) -> Rect {
433        let x1 = min(self.x, other.x);
434        let y1 = min(self.y, other.y);
435        let x2 = max(self.x + self.width, other.x + other.width);
436        let y2 = max(self.y + self.height, other.y + other.height);
437        Rect {
438            x: x1,
439            y: y1,
440            width: x2 - x1,
441            height: y2 - y1,
442        }
443    }
444
445    pub fn intersection(self, other: Rect) -> Rect {
446        let x1 = max(self.x, other.x);
447        let y1 = max(self.y, other.y);
448        let x2 = min(self.x + self.width, other.x + other.width);
449        let y2 = min(self.y + self.height, other.y + other.height);
450        Rect {
451            x: x1,
452            y: y1,
453            width: x2 - x1,
454            height: y2 - y1,
455        }
456    }
457
458    pub fn intersects(self, other: Rect) -> bool {
459        self.x < other.x + other.width
460            && self.x + self.width > other.x
461            && self.y < other.y + other.height
462            && self.y + self.height > other.y
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn test_rect_size_truncation() {
472        for width in 256u16..300u16 {
473            for height in 256u16..300u16 {
474                let rect = Rect::new(0, 0, width, height);
475                rect.area(); // Should not panic.
476                assert!(rect.width < width || rect.height < height);
477                // The target dimensions are rounded down so the math will not be too precise
478                // but let's make sure the ratios don't diverge crazily.
479                assert!(
480                    (f64::from(rect.width) / f64::from(rect.height)
481                        - f64::from(width) / f64::from(height))
482                    .abs()
483                        < 1.0
484                )
485            }
486        }
487
488        // One dimension below 255, one above. Area above max u16.
489        let width = 900;
490        let height = 100;
491        let rect = Rect::new(0, 0, width, height);
492        assert_ne!(rect.width, 900);
493        assert_ne!(rect.height, 100);
494        assert!(rect.width < width || rect.height < height);
495    }
496
497    #[test]
498    fn test_rect_size_preservation() {
499        for width in 0..256u16 {
500            for height in 0..256u16 {
501                let rect = Rect::new(0, 0, width, height);
502                rect.area(); // Should not panic.
503                assert_eq!(rect.width, width);
504                assert_eq!(rect.height, height);
505            }
506        }
507
508        // One dimension below 255, one above. Area below max u16.
509        let rect = Rect::new(0, 0, 300, 100);
510        assert_eq!(rect.width, 300);
511        assert_eq!(rect.height, 100);
512    }
513}