Skip to main content

solium_layout/
lib.rs

1#![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))]
2//! Solium's window arrangements.
3//!
4//! Where each window goes, given how many there are and how much room. Nothing
5//! here knows what a window *is* — no surfaces, no compositor, no Wayland — so
6//! the same code arranges real windows and arranges the boxes in the preview
7//! page. That is the point: a layout you can only judge by opening four
8//! terminals is a layout nobody tunes, and a layout reimplemented in the
9//! preview is a layout the preview is lying about.
10//!
11//! Scripts choose *which* arrangement and *when*. These are the arrangements.
12
13// The arrangements themselves are safe; `ffi` needs `unsafe` to export C
14// symbols and to hand the preview a buffer to read, and says so at each use.
15pub mod ffi;
16
17pub mod scroller;
18pub mod tree;
19
20/// A rectangle, in whatever coordinates the caller is using.
21#[derive(Clone, Copy, Debug, Default, PartialEq)]
22pub struct Rect {
23    pub x: f64,
24    pub y: f64,
25    pub w: f64,
26    pub h: f64,
27}
28
29impl Rect {
30    #[must_use]
31    pub fn new(x: f64, y: f64, w: f64, h: f64) -> Self {
32        Self { x, y, w, h }
33    }
34
35    /// Whether a point falls inside, top-left inclusive.
36    #[must_use]
37    pub fn contains(self, x: f64, y: f64) -> bool {
38        x >= self.x && y >= self.y && x < self.x + self.w && y < self.y + self.h
39    }
40
41    /// Shrunk by `amount` on every side.
42    #[must_use]
43    pub fn inset(self, amount: f64) -> Self {
44        Self {
45            x: self.x + amount,
46            y: self.y + amount,
47            w: (self.w - amount * 2.0).max(1.0),
48            h: (self.h - amount * 2.0).max(1.0),
49        }
50    }
51
52    /// This rectangle scaled to fit inside `box`, keeping its aspect ratio and
53    /// never growing.
54    ///
55    /// Never growing matters: a small window blown up to fill an overview cell
56    /// reads as a different window.
57    #[must_use]
58    pub fn fitted(self, into: Self) -> Self {
59        if self.w <= 0.0 || self.h <= 0.0 {
60            return into;
61        }
62        let scale = (into.w / self.w).min(into.h / self.h).min(1.0);
63        let (w, h) = (self.w * scale, self.h * scale);
64        Self {
65            x: into.x + (into.w - w) / 2.0,
66            y: into.y + (into.h - h) / 2.0,
67            w,
68            h,
69        }
70    }
71}
72
73/// How the standard arrangements are tuned.
74#[derive(Clone, Copy, Debug)]
75pub struct Settings {
76    /// Space between windows and around the edge.
77    pub gap: f64,
78    /// Share of the width the master window takes, for master-and-stack.
79    pub ratio: f64,
80    /// Share of the width one column takes, for the scrolling strip.
81    pub column: f64,
82    /// Space around each thumbnail in the grid.
83    pub padding: f64,
84    /// Where a dwindle split falls, as a share of the space being divided.
85    pub split: f64,
86    /// The smallest a dwindle tile may be. See [`Minimum`].
87    pub minimum: Minimum,
88}
89
90impl Default for Settings {
91    fn default() -> Self {
92        Self {
93            gap: 12.0,
94            ratio: 0.6,
95            column: 0.5,
96            padding: 24.0,
97            split: 0.5,
98            minimum: Minimum::default(),
99        }
100    }
101}
102
103/// The smallest a dwindle tile may be, frame included, in the caller's units.
104///
105/// A tile and not a window's own minimum size. A new window's tile is decided
106/// when the window is asked for, before its application has connected, so
107/// there is no client yet to say how small it can go (#134).
108///
109/// Zero on a side is no minimum on that side, and zero on both is the default
110/// here: a caller that never names one -- the preview, a script written before
111/// #134 -- gets exactly the arithmetic it had. The compositor's tiling passes
112/// `config.tiling.minimum` in every options table it builds.
113///
114/// Read by [`tree::Tiling::insert_fitting`] and [`tree::Tiling::insert_largest`],
115/// which refuse a split that would go under it, and by
116/// [`tree::Tiling::drag_seam`] and [`tree::Tiling::resize`], which will not move
117/// a seam to make a tile any smaller than it.
118#[derive(Clone, Copy, Debug, Default, PartialEq)]
119pub struct Minimum {
120    pub w: f64,
121    pub h: f64,
122}
123
124/// Each window's own floor, by id: the smallest its tile can be for its client
125/// to fit, frame included (#115).
126///
127/// Not [`Minimum`], which is one floor for every tile and a rule about
128/// splitting. This is what an application says it cannot go under, one window
129/// at a time. [`tree::Tiling`] gives a window under its floor a larger share,
130/// taken from its siblings, and [`scroller::Scroller`] never lays a column out
131/// narrower than the widest floor in it. A window with no entry has no floor
132/// of its own, and an empty map is exactly the arithmetic before #115.
133pub type Floors = std::collections::HashMap<u64, Minimum>;
134
135/// A column of the scrolling strip: how wide, and how many windows share it.
136#[derive(Clone, Copy, Debug)]
137pub struct Column {
138    /// Width as a share of the viewport. Not of the strip — the strip has no
139    /// width, which is the point of it.
140    pub width: f64,
141    /// Windows stacked in this column, sharing its height.
142    pub windows: usize,
143}
144
145/// The scrolling strip, as niri means it: columns, not windows.
146///
147/// The difference matters and is the whole design. A row of single windows is
148/// a list you pan across; a row of *columns*, each holding a stack, is a
149/// workspace you never run out of. A window keeps the width its column was
150/// given no matter how many others exist, so opening a tenth window does not
151/// make the first nine thinner — the strip gets longer and the view moves.
152///
153/// `offset` moves the viewport. Nothing here moves the strip, and no window is
154/// ever resized to make room; that is the property that makes this usable on a
155/// laptop and a 49-inch display without changing anything.
156#[must_use]
157pub fn strip(columns: &[Column], area: Rect, settings: Settings, offset: f64) -> Vec<Rect> {
158    let area = area.inset(settings.gap);
159    let mut slots = Vec::new();
160    let mut x = area.x - offset;
161
162    for column in columns {
163        let width = (area.w * column.width.clamp(0.1, 1.0)).max(1.0);
164        let count = column.windows.max(1);
165        #[expect(
166            clippy::cast_precision_loss,
167            reason = "a column holds a handful of windows"
168        )]
169        let rows = count as f64;
170        let height = ((area.h - settings.gap * (rows - 1.0)) / rows).max(1.0);
171
172        for row in 0..column.windows {
173            #[expect(clippy::cast_precision_loss, reason = "as above")]
174            let row = row as f64;
175            slots.push(Rect::new(
176                x,
177                area.y + row * (height + settings.gap),
178                width,
179                height,
180            ));
181        }
182        x += width + settings.gap;
183    }
184    slots
185}
186
187/// The viewport offset that brings a column fully into view, moving least.
188///
189/// Least is the point. A scroller that recentres on every focus change makes
190/// the whole screen move when nothing needed to; one that only moves when the
191/// target is actually off the edge keeps the rest of the strip where the eye
192/// left it.
193#[must_use]
194pub fn strip_scroll_to(
195    index: usize,
196    columns: &[Column],
197    area: Rect,
198    settings: Settings,
199    offset: f64,
200) -> f64 {
201    let area = area.inset(settings.gap);
202    let mut left = 0.0;
203
204    for (position, column) in columns.iter().enumerate() {
205        let width = (area.w * column.width.clamp(0.1, 1.0)).max(1.0);
206        if position == index {
207            let right = left + width;
208            if left < offset {
209                return left;
210            }
211            if right > offset + area.w {
212                return right - area.w;
213            }
214            return offset;
215        }
216        left += width + settings.gap;
217    }
218    offset
219}
220
221/// One large window, the rest stacked beside it.
222///
223/// The arrangement worth having first, and the one most tiling users reach for
224/// before anything else.
225#[must_use]
226pub fn master_stack(count: usize, area: Rect, settings: Settings) -> Vec<Rect> {
227    if count == 0 {
228        return Vec::new();
229    }
230    let area = area.inset(settings.gap);
231    if count == 1 {
232        return vec![area];
233    }
234
235    let master = (area.w * settings.ratio - settings.gap / 2.0).max(1.0);
236    let mut slots = vec![Rect::new(area.x, area.y, master, area.h)];
237
238    #[expect(
239        clippy::cast_precision_loss,
240        reason = "a window count large enough to lose precision is not reachable"
241    )]
242    let stacked = (count - 1) as f64;
243    let height = ((area.h - settings.gap * (stacked - 1.0)) / stacked).max(1.0);
244
245    for index in 1..count {
246        #[expect(clippy::cast_precision_loss, reason = "as above")]
247        let row = (index - 1) as f64;
248        slots.push(Rect::new(
249            area.x + master + settings.gap,
250            area.y + row * (height + settings.gap),
251            (area.w - master - settings.gap).max(1.0),
252            height,
253        ));
254    }
255    slots
256}
257
258/// An endless horizontal strip, with the area as a viewport.
259///
260/// Windows keep their width and run off both edges; `offset` moves the view,
261/// never the strip. Nothing is squeezed to fit, which is what makes this work
262/// on a screen too small for anything else.
263#[must_use]
264pub fn scrolling(count: usize, area: Rect, settings: Settings, offset: f64) -> Vec<Rect> {
265    let inner = area.inset(settings.gap);
266    let width = (area.w * settings.column).max(1.0);
267
268    (0..count)
269        .map(|index| {
270            #[expect(
271                clippy::cast_precision_loss,
272                reason = "a window count large enough to lose precision is not reachable"
273            )]
274            let column = index as f64;
275            Rect::new(
276                inner.x + column * (width + settings.gap) - offset,
277                inner.y,
278                width,
279                inner.h,
280            )
281        })
282        .collect()
283}
284
285/// How far the viewport must move to bring a column fully into view.
286///
287/// Returns the new offset. A column already on screen does not move the view:
288/// scrolling something into sight that is already in sight is the kind of
289/// motion that makes a layout feel twitchy.
290#[must_use]
291pub fn scroll_to(index: usize, count: usize, area: Rect, settings: Settings, offset: f64) -> f64 {
292    if count == 0 {
293        return 0.0;
294    }
295    let index = index.min(count - 1);
296    let width = (area.w * settings.column).max(1.0);
297    #[expect(
298        clippy::cast_precision_loss,
299        reason = "a window count large enough to lose precision is not reachable"
300    )]
301    let left = index as f64 * (width + settings.gap);
302    let viewport = area.w - settings.gap * 2.0;
303
304    if left < offset {
305        left
306    } else if left + width > offset + viewport {
307        left + width - viewport
308    } else {
309        offset
310    }
311}
312
313/// A grid of thumbnails, as close to square as the count allows.
314///
315/// `sizes` are the windows' own rectangles, so each keeps its aspect ratio
316/// inside its cell — the arrangement overview uses.
317#[must_use]
318pub fn grid(sizes: &[Rect], area: Rect, settings: Settings) -> Vec<Rect> {
319    let count = sizes.len();
320    if count == 0 {
321        return Vec::new();
322    }
323
324    #[expect(
325        clippy::cast_precision_loss,
326        clippy::cast_possible_truncation,
327        clippy::cast_sign_loss,
328        reason = "a window count large enough to lose precision is not reachable"
329    )]
330    let columns = ((count as f64).sqrt().ceil() as usize).max(1);
331    let rows = count.div_ceil(columns);
332
333    #[expect(clippy::cast_precision_loss, reason = "grid dimensions are small")]
334    let (columns_f, rows_f) = (columns as f64, rows.max(1) as f64);
335    let (cell_w, cell_h) = (area.w / columns_f, area.h / rows_f);
336
337    sizes
338        .iter()
339        .enumerate()
340        .map(|(index, size)| {
341            #[expect(clippy::cast_precision_loss, reason = "grid dimensions are small")]
342            let (column, row) = ((index % columns) as f64, (index / columns) as f64);
343            let cell = Rect::new(
344                area.x + column * cell_w + settings.padding,
345                area.y + row * cell_h + settings.padding,
346                (cell_w - settings.padding * 2.0).max(1.0),
347                (cell_h - settings.padding * 2.0).max(1.0),
348            );
349            size.fitted(cell)
350        })
351        .collect()
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    const AREA: Rect = Rect {
359        x: 0.0,
360        y: 34.0,
361        w: 1600.0,
362        h: 866.0,
363    };
364
365    #[test]
366    fn one_window_fills_the_area_minus_the_gap() {
367        let slots = master_stack(1, AREA, Settings::default());
368        assert_eq!(slots.len(), 1);
369        assert_eq!(slots[0], AREA.inset(12.0));
370    }
371
372    #[test]
373    fn master_and_stack_share_the_width_without_overlapping() {
374        let settings = Settings::default();
375        let slots = master_stack(3, AREA, settings);
376        assert_eq!(slots.len(), 3);
377
378        let master = slots[0];
379        assert!(
380            master.x + master.w <= slots[1].x,
381            "the stack must start after the master ends"
382        );
383        // And the stack does not overlap itself.
384        assert!(slots[1].y + slots[1].h <= slots[2].y);
385    }
386
387    #[test]
388    fn every_slot_stays_inside_the_area() {
389        for count in 1..8 {
390            for slot in master_stack(count, AREA, Settings::default()) {
391                assert!(
392                    slot.x >= AREA.x,
393                    "count {count}: {slot:?} starts left of the area"
394                );
395                assert!(
396                    slot.y >= AREA.y,
397                    "count {count}: {slot:?} starts above the area"
398                );
399                assert!(
400                    slot.x + slot.w <= AREA.x + AREA.w + 0.001,
401                    "count {count}: {slot:?} runs past the right edge"
402                );
403                assert!(
404                    slot.y + slot.h <= AREA.y + AREA.h + 0.001,
405                    "count {count}: {slot:?} runs past the bottom"
406                );
407            }
408        }
409    }
410
411    #[test]
412    fn the_strip_keeps_its_column_width_however_many_there_are() {
413        let settings = Settings::default();
414        let two = scrolling(2, AREA, settings, 0.0);
415        let ten = scrolling(10, AREA, settings, 0.0);
416        assert!(
417            (two[0].w - ten[0].w).abs() < 1e-9,
418            "columns must not shrink to fit"
419        );
420        // And it runs off the edge rather than being squeezed.
421        assert!(ten[9].x + ten[9].w > AREA.x + AREA.w);
422    }
423
424    #[test]
425    fn scrolling_moves_the_view_and_not_the_strip() {
426        let settings = Settings::default();
427        let at_rest = scrolling(5, AREA, settings, 0.0);
428        let scrolled = scrolling(5, AREA, settings, 300.0);
429        for (a, b) in at_rest.iter().zip(&scrolled) {
430            assert!(
431                (a.x - b.x - 300.0).abs() < 1e-9,
432                "every column moves by the same amount"
433            );
434            assert!((a.w - b.w).abs() < 1e-9, "and none of them changes size");
435        }
436    }
437
438    #[test]
439    fn a_column_already_in_view_does_not_move_the_viewport() {
440        let settings = Settings::default();
441        assert!((scroll_to(0, 5, AREA, settings, 0.0) - 0.0).abs() < 1e-9);
442        // The far one does.
443        assert!(scroll_to(4, 5, AREA, settings, 0.0) > 0.0);
444    }
445
446    #[test]
447    fn the_grid_keeps_each_window_its_own_shape() {
448        // A wide window and a tall one in the same grid must not come out the
449        // same shape as each other.
450        let wide = Rect::new(0.0, 0.0, 1200.0, 400.0);
451        let tall = Rect::new(0.0, 0.0, 400.0, 1000.0);
452        let slots = grid(&[wide, tall], AREA, Settings::default());
453        let ratio = |r: Rect| r.w / r.h;
454        assert!((ratio(slots[0]) - ratio(wide)).abs() < 1e-6);
455        assert!((ratio(slots[1]) - ratio(tall)).abs() < 1e-6);
456    }
457
458    #[test]
459    fn nothing_divides_by_zero_on_an_empty_desktop() {
460        assert!(master_stack(0, AREA, Settings::default()).is_empty());
461        assert!(scrolling(0, AREA, Settings::default(), 0.0).is_empty());
462        assert!(grid(&[], AREA, Settings::default()).is_empty());
463        assert!((scroll_to(0, 0, AREA, Settings::default(), 0.0) - 0.0).abs() < 1e-9);
464    }
465}
466
467#[cfg(test)]
468mod dwindle_and_strip_tests {
469    use super::{Column, Rect, Settings, strip, strip_scroll_to};
470
471    fn area() -> Rect {
472        Rect::new(0.0, 0.0, 1000.0, 600.0)
473    }
474
475    fn settings() -> Settings {
476        Settings {
477            gap: 0.0,
478            ..Settings::default()
479        }
480    }
481
482    fn columns(widths: &[f64], windows: usize) -> Vec<Column> {
483        widths
484            .iter()
485            .map(|width| Column {
486                width: *width,
487                windows,
488            })
489            .collect()
490    }
491
492    /// The property that makes a scroller a scroller: a column's width does
493    /// not depend on how many other columns exist. Opening a tenth window must
494    /// not make the first nine thinner.
495    #[test]
496    fn a_column_keeps_its_width_however_many_there_are() {
497        let two = strip(&columns(&[0.5, 0.5], 1), area(), settings(), 0.0);
498        let ten = strip(&columns(&[0.5; 10], 1), area(), settings(), 0.0);
499        assert!((two[0].w - ten[0].w).abs() < f64::EPSILON);
500        assert!((two[1].w - ten[1].w).abs() < f64::EPSILON);
501    }
502
503    #[test]
504    fn windows_in_a_column_share_its_height_and_its_width() {
505        let slots = strip(&columns(&[0.5], 3), area(), settings(), 0.0);
506        assert_eq!(slots.len(), 3);
507        for slot in &slots {
508            assert!((slot.w - 500.0).abs() < 1.0);
509            assert!((slot.h - 200.0).abs() < 1.0);
510        }
511        assert!(slots[0].y < slots[1].y && slots[1].y < slots[2].y);
512    }
513
514    #[test]
515    fn the_offset_moves_the_view_not_the_strip() {
516        let still = strip(&columns(&[0.5, 0.5], 1), area(), settings(), 0.0);
517        let moved = strip(&columns(&[0.5, 0.5], 1), area(), settings(), 200.0);
518        assert!((still[0].x - moved[0].x - 200.0).abs() < f64::EPSILON);
519        assert!((still[1].x - moved[1].x - 200.0).abs() < f64::EPSILON);
520    }
521
522    /// A column already fully in view does not move the world to celebrate.
523    #[test]
524    fn scrolling_to_a_visible_column_changes_nothing() {
525        let columns = columns(&[0.5, 0.5], 1);
526        let offset = strip_scroll_to(1, &columns, area(), settings(), 0.0);
527        assert!((offset - 0.0).abs() < f64::EPSILON);
528    }
529
530    #[test]
531    fn scrolling_right_moves_only_as_far_as_it_must() {
532        let columns = columns(&[0.5, 0.5, 0.5], 1);
533        // Third column spans 1000..1500 in strip space; the view is 1000 wide.
534        let offset = strip_scroll_to(2, &columns, area(), settings(), 0.0);
535        assert!(
536            (offset - 500.0).abs() < 1.0,
537            "just enough, not centred: {offset}"
538        );
539    }
540
541    #[test]
542    fn scrolling_left_brings_the_column_to_the_edge() {
543        let columns = columns(&[0.5, 0.5, 0.5], 1);
544        let offset = strip_scroll_to(0, &columns, area(), settings(), 500.0);
545        assert!((offset - 0.0).abs() < f64::EPSILON);
546    }
547}