Skip to main content

solium_layout/
scroller.rs

1//! Scrolling, as niri does it: columns, and a view anchored to one of them.
2//!
3//! `fit` is adapted from niri's `compute_new_view_offset`
4//! (`src/layout/scrolling.rs`, niri-wm/niri@97c96a1, GPL-3.0-or-later,
5//! © the niri contributors), which this project's GPL-3.0-only can use. The
6//! rest is behaviour modelled on niri's scrolling layout: the behaviour is the
7//! specification, and niri's code is written against its own tile and
8//! animation types.
9//!
10//! Two ideas carry the whole design, and a strip of evenly-spaced windows has
11//! neither of them.
12//!
13//! **The unit is a column, not a window.** A column holds a stack of windows
14//! sharing its width, and its width is a share of the *view*, never of the
15//! strip. Opening a tenth window cannot make the first nine thinner, because
16//! nothing in here divides anything by how many windows exist. That is the
17//! property that makes a scroller work the same on a laptop and a 49-inch
18//! display.
19//!
20//! **The view offset is measured from the active column**, not from the left
21//! end of the strip. Moving focus rebases it by the distance between the old
22//! column and the new one, so the view and the focus can never disagree about
23//! where they are — which is what going wrong feels like when a scroller feels
24//! "fake". An absolute scroll position would drift away from focus the moment
25//! a column ahead of it changed width.
26
27use crate::{Floors, Rect, Settings};
28
29/// A column of the strip.
30#[derive(Clone, Debug)]
31pub struct Column {
32    /// Windows stacked in this column, top to bottom.
33    pub windows: Vec<u64>,
34    /// Which of them has focus.
35    pub active: usize,
36    /// Width as a share of the view.
37    pub width: f64,
38}
39
40impl Column {
41    fn new(id: u64, width: f64) -> Self {
42        Self {
43            windows: vec![id],
44            active: 0,
45            width,
46        }
47    }
48}
49
50/// The widths a column cycles through when the caller names none, as shares of
51/// the view.
52///
53/// A fallback rather than the policy since #117. `lua/config.lua` has
54/// advertised a `scrolling.widths` list since it was written, and this constant
55/// is why that list did nothing: the configuration named three numbers and the
56/// strip used these three instead. A caller supplies its own through
57/// [`Scroller::with_widths`]; these are what a caller that says nothing gets,
58/// and what a caller whose list turns out to hold nothing usable falls back to.
59pub const PRESETS: [f64; 3] = [1.0 / 3.0, 0.5, 2.0 / 3.0];
60
61/// A scrolling workspace.
62#[derive(Clone, Debug)]
63pub struct Scroller {
64    columns: Vec<Column>,
65    active: usize,
66    /// Where the view sits relative to the active column's left edge.
67    view_offset: f64,
68    /// The widths this strip cycles through, as shares of the view.
69    ///
70    /// Held per strip rather than carried in [`crate::Settings`] because
71    /// `Settings` is `Copy` and is rebuilt from a Lua table on every call —
72    /// a list allocated per keystroke to describe something that cannot change
73    /// between them. It is also per strip in truth: the widths are read once,
74    /// where the strip is made.
75    ///
76    /// **Never empty.** Every read indexes it, and the two constructors below
77    /// are the only things that fill it.
78    widths: Vec<f64>,
79    preset: usize,
80    /// Each window's own floor, as the caller last handed them in (#115). See
81    /// [`Self::set_floors`].
82    floors: Floors,
83}
84
85impl Default for Scroller {
86    fn default() -> Self {
87        Self {
88            columns: Vec::new(),
89            active: 0,
90            view_offset: 0.0,
91            widths: PRESETS.to_vec(),
92            // A third of the view, not a half. Half-width columns mean two on
93            // screen and everything else off the edge, which for a terminal is
94            // far wider than anyone reads at.
95            preset: 0,
96            floors: Floors::new(),
97        }
98    }
99}
100
101impl Scroller {
102    #[must_use]
103    pub fn new() -> Self {
104        Self::default()
105    }
106
107    /// A strip cycling through widths of the caller's choosing.
108    ///
109    /// `start` is a zero-based index into `widths` — the width a new column
110    /// opens at, and the point the cycle begins from. `lua/config.lua` spells
111    /// it `default_width` and counts from one, the way Lua counts; the
112    /// subtraction happens at that boundary, not here.
113    ///
114    /// **What it does with a list it cannot use.** Anything not a share of a
115    /// view has already been dropped by the caller, which is where the log is;
116    /// this sees the survivors. If there are none it falls back to [`PRESETS`],
117    /// because every read of `widths` indexes it and a strip with no widths at
118    /// all has no width to open a column at. An out-of-range `start` is clamped
119    /// to the last width rather than refused, for the same reason: there is no
120    /// answer to "start at the fourth of three" that is not a choice, and
121    /// refusing would mean a session with no scrolling layout over one number.
122    #[must_use]
123    pub fn with_widths(widths: &[f64], start: usize) -> Self {
124        let widths: Vec<f64> = if widths.is_empty() {
125            PRESETS.to_vec()
126        } else {
127            widths.to_vec()
128        };
129        let preset = start.min(widths.len() - 1);
130        Self {
131            widths,
132            preset,
133            ..Self::default()
134        }
135    }
136
137    /// The width a column takes now, as a share of the view.
138    ///
139    /// `preset` is kept in range by both constructors and by `cycle_width`'s
140    /// modulo, so this cannot miss; the `unwrap_or` is what stands in for the
141    /// index that would panic if one of those three ever stopped being true,
142    /// and the crate denies panicking outright.
143    fn width_now(&self) -> f64 {
144        self.widths
145            .get(self.preset)
146            .or_else(|| self.widths.first())
147            .copied()
148            .unwrap_or(PRESETS[0])
149    }
150
151    #[must_use]
152    pub fn is_empty(&self) -> bool {
153        self.columns.is_empty()
154    }
155
156    #[must_use]
157    pub fn columns(&self) -> &[Column] {
158        &self.columns
159    }
160
161    #[must_use]
162    pub fn active_column(&self) -> usize {
163        self.active
164    }
165
166    /// The focused window, if there is one.
167    #[must_use]
168    pub fn focused(&self) -> Option<u64> {
169        let column = self.columns.get(self.active)?;
170        column.windows.get(column.active).copied()
171    }
172
173    #[must_use]
174    pub fn contains(&self, id: u64) -> bool {
175        self.columns
176            .iter()
177            .any(|column| column.windows.contains(&id))
178    }
179
180    /// Each window's own floor, by id, for every call after this one (#115).
181    ///
182    /// Replaced whole, as `Tiling::set_floors` replaces its own: what every
183    /// window says now, and a window missing from it has no floor. Only the
184    /// width is read: a column is never laid out narrower than the widest
185    /// floor in it -- see [`Self::width_of`]. Its height is the view's, which
186    /// the strip does not scroll, so a stack too tall for it has nowhere else
187    /// to go.
188    pub fn set_floors(&mut self, floors: Floors) {
189        self.floors = floors;
190    }
191
192    /// The widest floor among the windows of the column at `index`, or zero.
193    fn floor_of(&self, index: usize) -> f64 {
194        self.columns.get(index).map_or(0.0, |column| {
195            column
196                .windows
197                .iter()
198                .filter_map(|id| self.floors.get(id))
199                .map(|floor| floor.w)
200                .filter(|width| width.is_finite())
201                .fold(0.0, f64::max)
202        })
203    }
204
205    /// How wide the column at `index` is laid out, in `view`: its share of
206    /// the view, or the widest floor among its windows where that is wider.
207    ///
208    /// Everything that measures a column asks this, so where a column is drawn,
209    /// where the next one starts and what the view scrolls to agree about its
210    /// width. `floors::a_column_is_never_narrower_than_its_widest_floor` and
211    /// `floors::the_next_column_starts_after_a_widened_one`.
212    fn width_of(&self, index: usize, view: Rect) -> f64 {
213        let share = self
214            .columns
215            .get(index)
216            .map_or(0.0, |column| (view.w * column.width).max(1.0));
217        share.max(self.floor_of(index))
218    }
219
220    /// Open a window in a new column, to the right of the active one.
221    pub fn insert(&mut self, id: u64, area: Rect, settings: Settings) {
222        if self.contains(id) {
223            return;
224        }
225        let width = self.width_now();
226        let at = if self.columns.is_empty() {
227            0
228        } else {
229            self.active + 1
230        };
231        self.columns.insert(at, Column::new(id, width));
232        self.focus_column(at, area, settings);
233    }
234
235    /// Add a window to the active column instead of beside it.
236    pub fn insert_into_active(&mut self, id: u64, area: Rect, settings: Settings) {
237        if self.contains(id) {
238            return;
239        }
240        let Some(column) = self.columns.get_mut(self.active) else {
241            self.insert(id, area, settings);
242            return;
243        };
244        column.windows.push(id);
245        column.active = column.windows.len() - 1;
246    }
247
248    pub fn remove(&mut self, id: u64) {
249        let Some(index) = self
250            .columns
251            .iter()
252            .position(|column| column.windows.contains(&id))
253        else {
254            return;
255        };
256        let column = &mut self.columns[index];
257        column.windows.retain(|other| *other != id);
258        if column.windows.is_empty() {
259            self.columns.remove(index);
260            if self.active >= self.columns.len() {
261                self.active = self.columns.len().saturating_sub(1);
262            }
263        } else {
264            column.active = column.active.min(column.windows.len() - 1);
265        }
266    }
267
268    /// Focus a particular window, bringing its column into view.
269    ///
270    /// This is what makes clicking a half-visible column work: the click says
271    /// which window, and the view follows. Without it a column can hold focus
272    /// while hanging off the edge of the screen, which is the state that makes
273    /// a scroller feel like it is fighting you.
274    pub fn focus_window(&mut self, id: u64, area: Rect, settings: Settings) {
275        let Some(column) = self
276            .columns
277            .iter()
278            .position(|column| column.windows.contains(&id))
279        else {
280            return;
281        };
282        if let Some(row) = self.columns[column].windows.iter().position(|w| *w == id) {
283            self.columns[column].active = row;
284        }
285        self.focus_column(column, area, settings);
286    }
287
288    /// Move focus one column left or right, taking the view with it.
289    pub fn focus_sideways(&mut self, by: isize, area: Rect, settings: Settings) {
290        if self.columns.is_empty() {
291            return;
292        }
293        let last = self.columns.len() - 1;
294        let target = usize::try_from(
295            isize::try_from(self.active)
296                .unwrap_or(0)
297                .saturating_add(by)
298                .clamp(0, isize::try_from(last).unwrap_or(0)),
299        )
300        .unwrap_or(0);
301        self.focus_column(target, area, settings);
302    }
303
304    /// Move focus within the active column.
305    pub fn focus_vertically(&mut self, by: isize) {
306        let Some(column) = self.columns.get_mut(self.active) else {
307            return;
308        };
309        let last = column.windows.len().saturating_sub(1);
310        column.active = usize::try_from(
311            isize::try_from(column.active)
312                .unwrap_or(0)
313                .saturating_add(by)
314                .clamp(0, isize::try_from(last).unwrap_or(0)),
315        )
316        .unwrap_or(0);
317    }
318
319    /// Swap the focused window with the one above it in its column (`by` is
320    /// -1) or below it (1), carrying focus along: niri's `move-window-up` and
321    /// `move-window-down` (#150). Returns whether anything moved, which it
322    /// does not at the top or the bottom of the column.
323    /// `move_vertically_swaps_within_the_column_and_stops_at_its_ends`.
324    pub fn move_vertically(&mut self, by: isize) -> bool {
325        let Some(column) = self.columns.get_mut(self.active) else {
326            return false;
327        };
328        let Some(to) = column
329            .active
330            .checked_add_signed(by)
331            .filter(|to| *to < column.windows.len())
332        else {
333            return false;
334        };
335        if to == column.active {
336            return false;
337        }
338        column.windows.swap(column.active, to);
339        column.active = to;
340        true
341    }
342
343    /// Swap the active column with its neighbour, carrying focus along.
344    /// Returns whether it moved, which it does not at either end of the strip
345    /// (#150). `move_column_says_whether_it_moved`.
346    pub fn move_column(&mut self, by: isize, area: Rect, settings: Settings) -> bool {
347        if self.columns.len() < 2 {
348            return false;
349        }
350        let last = self.columns.len() - 1;
351        let Ok(from) = isize::try_from(self.active) else {
352            return false;
353        };
354        let to = usize::try_from(
355            from.saturating_add(by)
356                .clamp(0, isize::try_from(last).unwrap_or(0)),
357        )
358        .unwrap_or(0);
359        if to == self.active {
360            return false;
361        }
362        self.columns.swap(self.active, to);
363        self.focus_column(to, area, settings);
364        true
365    }
366
367    /// Pull the next column's focused window into this one.
368    pub fn consume(&mut self) {
369        let next = self.active + 1;
370        let Some(taken) = self.columns.get_mut(next).and_then(|column| {
371            let index = column.active.min(column.windows.len().saturating_sub(1));
372            (!column.windows.is_empty()).then(|| column.windows.remove(index))
373        }) else {
374            return;
375        };
376        if self.columns[next].windows.is_empty() {
377            self.columns.remove(next);
378        }
379        if let Some(column) = self.columns.get_mut(self.active) {
380            column.windows.push(taken);
381            column.active = column.windows.len() - 1;
382        }
383    }
384
385    /// Push the focused window out into a column of its own.
386    pub fn expel(&mut self, area: Rect, settings: Settings) {
387        let Some(column) = self.columns.get_mut(self.active) else {
388            return;
389        };
390        if column.windows.len() < 2 {
391            return;
392        }
393        let index = column.active.min(column.windows.len() - 1);
394        let taken = column.windows.remove(index);
395        column.active = column.active.min(column.windows.len() - 1);
396        let width = column.width;
397        let at = self.active + 1;
398        self.columns.insert(at, Column::new(taken, width));
399        self.focus_column(at, area, settings);
400    }
401
402    /// Move a window into the column another window is in.
403    ///
404    /// What a drag means in a scroller: not a free position, but a change of
405    /// which column something belongs to. Dropping onto a window in another
406    /// column stacks it there.
407    pub fn move_to_column_of(&mut self, id: u64, target: u64, area: Rect, settings: Settings) {
408        if id == target {
409            return;
410        }
411        let Some(to) = self
412            .columns
413            .iter()
414            .position(|column| column.windows.contains(&target))
415        else {
416            return;
417        };
418        let Some(from) = self
419            .columns
420            .iter()
421            .position(|column| column.windows.contains(&id))
422        else {
423            return;
424        };
425        if from == to {
426            return;
427        }
428
429        self.columns[from].windows.retain(|other| *other != id);
430        let emptied = self.columns[from].windows.is_empty();
431        if emptied {
432            self.columns.remove(from);
433        } else {
434            let column = &mut self.columns[from];
435            column.active = column.active.min(column.windows.len() - 1);
436        }
437
438        // The target may have shifted left if the column it followed was
439        // removed.
440        let to = if emptied && to > from { to - 1 } else { to };
441        let Some(column) = self.columns.get_mut(to) else {
442            return;
443        };
444        let at = column
445            .windows
446            .iter()
447            .position(|other| *other == target)
448            .map_or(column.windows.len(), |index| index + 1);
449        column.windows.insert(at, id);
450        column.active = at;
451        self.focus_column(to, area, settings);
452    }
453
454    /// Widen or narrow the column a window sits in.
455    ///
456    /// Every window in a column shares its width, so a drag on one window's
457    /// edge can only mean the column. Clamped so a column can neither vanish
458    /// nor grow past the view — past that it would be left-aligned anyway and
459    /// dragging further would appear to do nothing.
460    pub fn widen(&mut self, id: u64, by: f64, area: Rect, settings: Settings) {
461        let Some(index) = self
462            .columns
463            .iter()
464            .position(|column| column.windows.contains(&id))
465        else {
466            return;
467        };
468        // Never below the share its widest floor already takes (#115): a
469        // column a floor holds wider than its share does not narrow on
470        // screen, and a share dragged on down under it would have to be
471        // dragged all the way back before the column widened again. Only as
472        // much of a floor as the view can hold; wider than that is
473        // `width_of`'s. `floors::narrowing_a_column_stops_at_its_floor`.
474        //
475        // And from the share it is drawn at, which the floor may hold above
476        // the one it has: from the one it has, a press that did not reach
477        // past the floor changed nothing on screen.
478        // `floors::widening_a_column_its_floor_holds_widens_it_at_once`.
479        let view = area.inset(settings.gap);
480        let least = (self.floor_of(index) / view.w.max(1.0)).clamp(0.1, 1.0);
481        let column = &mut self.columns[index];
482        column.width = (column.width.max(least) + by).clamp(least, 1.0);
483        self.focus_column(index, area, settings);
484    }
485
486    /// Cycle the active column through the preset widths.
487    pub fn cycle_width(&mut self, area: Rect, settings: Settings) {
488        self.preset = (self.preset + 1) % self.widths.len();
489        let width = self.width_now();
490        if let Some(column) = self.columns.get_mut(self.active) {
491            column.width = width;
492        }
493        let active = self.active;
494        self.focus_column(active, area, settings);
495    }
496
497    /// Take the widths `like` was made with, and keep everything else.
498    ///
499    /// For a strip that outlives the configuration that made it, which is what
500    /// a reload does to one (#118). The columns, their widths, the focus and
501    /// the view are the arrangement, and a reload does not change the
502    /// arrangement. The list a column is cycled through and the width the next
503    /// one opens at are configuration, so they come from `like`, which is
504    /// made from the file as it reads now. See
505    /// `a_strip_configured_again_keeps_its_columns_and_takes_the_new_widths`.
506    pub fn configure_like(&mut self, like: &Self) {
507        self.widths.clone_from(&like.widths);
508        self.preset = like.preset;
509    }
510
511    /// Scroll the view by a distance, without moving focus.
512    pub fn scroll_by(&mut self, delta: f64) {
513        self.view_offset += delta;
514    }
515
516    /// Where every window goes.
517    #[must_use]
518    pub fn layout(&self, area: Rect, settings: Settings) -> Vec<(u64, Rect)> {
519        let view = area.inset(settings.gap);
520        let origin = self.view_position(view, settings);
521        let mut out = Vec::new();
522
523        for (index, column) in self.columns.iter().enumerate() {
524            let x = view.x + self.column_x(index, view, settings) - origin;
525            let width = self.width_of(index, view);
526            let count = column.windows.len().max(1);
527            #[expect(clippy::cast_precision_loss, reason = "a column holds a handful")]
528            let rows = count as f64;
529            let height = ((view.h - settings.gap * (rows - 1.0)) / rows).max(1.0);
530
531            for (row, id) in column.windows.iter().enumerate() {
532                #[expect(clippy::cast_precision_loss, reason = "as above")]
533                let row = row as f64;
534                out.push((
535                    *id,
536                    Rect::new(x, view.y + row * (height + settings.gap), width, height),
537                ));
538            }
539        }
540        out
541    }
542
543    /// The left edge of the view, in strip coordinates.
544    fn view_position(&self, view: Rect, settings: Settings) -> f64 {
545        self.column_x(self.active, view, settings) + self.view_offset
546    }
547
548    /// Where a column starts along the strip.
549    fn column_x(&self, index: usize, view: Rect, settings: Settings) -> f64 {
550        (0..index.min(self.columns.len()))
551            .map(|each| self.width_of(each, view) + settings.gap)
552            .sum()
553    }
554
555    /// Point focus at a column and bring it into view.
556    ///
557    /// The offset is rebased first, by the distance between the old column and
558    /// the new one. Skipping that is what makes a scroller feel disconnected:
559    /// the view would keep an offset measured from a column that is no longer
560    /// the one it is describing.
561    fn focus_column(&mut self, index: usize, area: Rect, settings: Settings) {
562        if self.columns.is_empty() {
563            return;
564        }
565        let index = index.min(self.columns.len() - 1);
566        let view = area.inset(settings.gap);
567
568        let old_x = self.column_x(self.active, view, settings);
569        let new_x = self.column_x(index, view, settings);
570        self.view_offset += old_x - new_x;
571        self.active = index;
572
573        let width = self.width_of(index, view);
574        let current = new_x + self.view_offset;
575        self.view_offset = fit(current, view.w, new_x, width, settings.gap);
576    }
577}
578
579/// Where the view should sit so a column is visible, moving as little as
580/// possible.
581///
582/// Three rules, in order, and the order is the point:
583///
584///   * A column wider than the view is left-aligned — there is no arrangement
585///     that shows all of it, so showing the start beats showing the middle.
586///   * A column already fully visible does not move the view at all. A
587///     scroller that recentres on every focus change makes the whole screen
588///     lurch when nothing needed to move.
589///   * Otherwise the alignment that travels less wins.
590fn fit(current: f64, view_width: f64, column_x: f64, column_width: f64, gap: f64) -> f64 {
591    if view_width <= column_width {
592        return 0.0;
593    }
594    let padding = ((view_width - column_width) / 2.0).clamp(0.0, gap);
595    let left = column_x - padding;
596    let right = column_x + column_width + padding;
597
598    if current <= left && right <= current + view_width {
599        return current - column_x;
600    }
601
602    let to_left = (current - left).abs();
603    let to_right = ((current + view_width) - right).abs();
604    if to_left <= to_right {
605        left - column_x
606    } else {
607        right - view_width - column_x
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::{PRESETS, Scroller};
614    use crate::{Rect, Settings};
615
616    fn area() -> Rect {
617        Rect::new(0.0, 0.0, 1000.0, 600.0)
618    }
619    fn settings() -> Settings {
620        Settings {
621            gap: 0.0,
622            ..Settings::default()
623        }
624    }
625    fn rect_of(scroller: &Scroller, id: u64) -> Rect {
626        scroller
627            .layout(area(), settings())
628            .into_iter()
629            .find(|(other, _)| *other == id)
630            .expect("window is in the strip")
631            .1
632    }
633    fn with(count: u64) -> Scroller {
634        let mut scroller = Scroller::new();
635        for id in 1..=count {
636            scroller.insert(id, area(), settings());
637        }
638        scroller
639    }
640
641    /// **A window moves up and down its own column, and no further** (#150).
642    /// Three stacked in one column: moving the bottom one up trades it with
643    /// the middle one and keeps it focused; at the top a move up does
644    /// nothing, and neither does one in a column of one.
645    #[test]
646    fn move_vertically_swaps_within_the_column_and_stops_at_its_ends() {
647        let mut scroller = with(3);
648        scroller.focus_sideways(-2, area(), settings());
649        scroller.consume();
650        scroller.consume();
651        assert_eq!(
652            scroller.columns().len(),
653            1,
654            "the premise: one column of three"
655        );
656        assert_eq!(scroller.columns()[0].windows, vec![1, 2, 3]);
657        assert_eq!(scroller.focused(), Some(3));
658
659        assert!(scroller.move_vertically(-1));
660        assert_eq!(scroller.columns()[0].windows, vec![1, 3, 2]);
661        assert_eq!(scroller.focused(), Some(3), "focus went with the window");
662        assert!(scroller.move_vertically(-1));
663        assert_eq!(scroller.columns()[0].windows, vec![3, 1, 2]);
664        assert!(!scroller.move_vertically(-1), "at the top of the column");
665        assert_eq!(scroller.columns()[0].windows, vec![3, 1, 2]);
666        assert!(scroller.move_vertically(1));
667        assert_eq!(scroller.columns()[0].windows, vec![1, 3, 2]);
668
669        let mut alone = with(2);
670        assert!(!alone.move_vertically(1), "a column of one has no below");
671        assert!(!alone.move_vertically(-1));
672        assert!(!Scroller::new().move_vertically(1), "an empty strip");
673    }
674
675    /// The defining property. Nothing here divides by how many windows exist,
676    /// so a tenth window cannot make the first nine thinner.
677    #[test]
678    fn a_column_keeps_its_width_however_many_there_are() {
679        let two = rect_of(&with(2), 1).w;
680        let ten = rect_of(&with(10), 1).w;
681        assert!((two - ten).abs() < f64::EPSILON, "{two} vs {ten}");
682    }
683
684    #[test]
685    fn a_new_window_opens_beside_the_active_column_not_at_the_end() {
686        let mut scroller = with(3);
687        scroller.focus_sideways(-2, area(), settings());
688        assert_eq!(scroller.active_column(), 0);
689        scroller.insert(99, area(), settings());
690        assert_eq!(
691            scroller.active_column(),
692            1,
693            "it went right of the active one"
694        );
695        assert_eq!(scroller.focused(), Some(99));
696    }
697
698    /// The view is measured from the active column, so focusing a column that
699    /// is already fully on screen must not move anything at all.
700    #[test]
701    fn focusing_a_visible_column_does_not_move_the_view() {
702        let mut scroller = with(2); // two half-width columns fill the view
703        let before = rect_of(&scroller, 1).x;
704        scroller.focus_sideways(-1, area(), settings());
705        assert!((rect_of(&scroller, 1).x - before).abs() < 1.0);
706    }
707
708    #[test]
709    fn focusing_a_column_off_the_edge_brings_it_into_view() {
710        let scroller = with(4);
711        let last = rect_of(&scroller, 4);
712        assert!(last.x >= 0.0 && last.x + last.w <= 1000.0 + 1.0, "{last:?}");
713    }
714
715    /// Focus and view can never disagree: whatever is focused is on screen.
716    #[test]
717    fn the_focused_column_is_always_visible() {
718        let mut scroller = with(6);
719        for step in [-1, -1, -1, 1, 1, -4, 5] {
720            scroller.focus_sideways(step, area(), settings());
721            let focused = scroller.focused().expect("something is focused");
722            let rect = rect_of(&scroller, focused);
723            assert!(
724                rect.x >= -1.0 && rect.x + rect.w <= 1001.0,
725                "focused column {focused} off screen at {rect:?}"
726            );
727        }
728    }
729
730    #[test]
731    fn windows_in_a_column_share_its_width_and_split_its_height() {
732        let mut scroller = with(1);
733        scroller.insert_into_active(2, area(), settings());
734        scroller.insert_into_active(3, area(), settings());
735        let (one, three) = (rect_of(&scroller, 1), rect_of(&scroller, 3));
736        assert!(
737            (one.w - three.w).abs() < f64::EPSILON,
738            "same column, same width"
739        );
740        assert!(
741            (one.h - 200.0).abs() < 1.0,
742            "three share the height: {one:?}"
743        );
744        assert!(three.y > one.y);
745    }
746
747    #[test]
748    fn consuming_takes_the_next_column_into_this_one() {
749        let mut scroller = with(2);
750        scroller.focus_sideways(-1, area(), settings());
751        scroller.consume();
752        assert_eq!(scroller.columns().len(), 1);
753        assert_eq!(scroller.columns()[0].windows.len(), 2);
754    }
755
756    #[test]
757    fn expelling_puts_a_window_back_in_its_own_column() {
758        let mut scroller = with(1);
759        scroller.insert_into_active(2, area(), settings());
760        assert_eq!(scroller.columns().len(), 1);
761        scroller.expel(area(), settings());
762        assert_eq!(scroller.columns().len(), 2);
763        assert_eq!(scroller.focused(), Some(2));
764    }
765
766    #[test]
767    fn cycling_width_walks_the_presets() {
768        let mut scroller = with(1);
769        let first = rect_of(&scroller, 1).w;
770        scroller.cycle_width(area(), settings());
771        let second = rect_of(&scroller, 1).w;
772        assert!((first - second).abs() > 1.0, "width changed");
773        // A full lap returns to the same preset, so it takes as many steps as
774        // there are presets — not one fewer.
775        for _ in 0..PRESETS.len() {
776            scroller.cycle_width(area(), settings());
777        }
778        assert!(
779            (rect_of(&scroller, 1).w - second).abs() < 1.0,
780            "and comes back around"
781        );
782    }
783
784    /// **The configured widths are the widths, and the configured index is
785    /// where the cycle starts.**
786    ///
787    /// `scrolling.widths` and `scrolling.default_width` were documented in
788    /// `lua/config.lua` and read by nothing: [`PRESETS`] decided both, so a
789    /// configuration naming quarters got thirds and could not tell whether it
790    /// had misunderstood the setting or been ignored (#117). Two numbers
791    /// nothing else in this file uses, so a regression here cannot hide behind
792    /// another test agreeing with it by coincidence.
793    #[test]
794    fn a_strip_cycles_through_the_widths_it_was_given() {
795        // Deliberately unlike PRESETS in both membership and length: a lap of
796        // two proves the cycle is over *this* list rather than over three of
797        // anything.
798        let mut scroller = Scroller::with_widths(&[0.25, 0.8], 1);
799        scroller.insert(1, area(), settings());
800        assert!(
801            (rect_of(&scroller, 1).w - 800.0).abs() < 1.0,
802            "a new column opens at `default_width`, which is the second of the \
803             two given: {:?}",
804            rect_of(&scroller, 1)
805        );
806
807        scroller.cycle_width(area(), settings());
808        assert!(
809            (rect_of(&scroller, 1).w - 250.0).abs() < 1.0,
810            "and cycling wraps to the first of them: {:?}",
811            rect_of(&scroller, 1)
812        );
813
814        scroller.cycle_width(area(), settings());
815        assert!(
816            (rect_of(&scroller, 1).w - 800.0).abs() < 1.0,
817            "a lap of a two-entry list is two steps, not three"
818        );
819    }
820
821    /// **A strip configured again keeps its columns and takes the new
822    /// widths**: what a reload hands a strip it carried across (#118).
823    #[test]
824    fn a_strip_configured_again_keeps_its_columns_and_takes_the_new_widths() {
825        let mut scroller = with(3);
826        scroller.cycle_width(area(), settings());
827        scroller.focus_sideways(-2, area(), settings());
828        let before = scroller.layout(area(), settings());
829
830        scroller.configure_like(&Scroller::with_widths(&[0.25, 0.8], 1));
831        assert_eq!(
832            scroller.layout(area(), settings()),
833            before,
834            "the columns, their widths, the focus and the view are unchanged"
835        );
836        scroller.insert(4, area(), settings());
837        assert!(
838            (rect_of(&scroller, 4).w - 800.0).abs() < 1.0,
839            "a new column opens at the new `default_width`: {:?}",
840            rect_of(&scroller, 4)
841        );
842        scroller.cycle_width(area(), settings());
843        assert!(
844            (rect_of(&scroller, 4).w - 250.0).abs() < 1.0,
845            "and the cycle walks the new list: {:?}",
846            rect_of(&scroller, 4)
847        );
848    }
849
850    /// A list with nothing usable in it, and an index past the end of one.
851    ///
852    /// Both are reachable from a `user.lua`, and neither may cost the session
853    /// its scrolling layout — `with_widths` is called while the configuration
854    /// is being read, where an error would take every other setting with it.
855    #[test]
856    fn an_unusable_width_list_falls_back_rather_than_failing() {
857        let mut scroller = Scroller::with_widths(&[], 0);
858        scroller.insert(1, area(), settings());
859        assert!(
860            (rect_of(&scroller, 1).w - 1000.0 * PRESETS[0]).abs() < 1.0,
861            "an empty list is the shipped presets, not a column of no width"
862        );
863
864        let mut scroller = Scroller::with_widths(&[0.4, 0.6], 9);
865        scroller.insert(1, area(), settings());
866        assert!(
867            (rect_of(&scroller, 1).w - 600.0).abs() < 1.0,
868            "an index past the end is the last width, not a missing one"
869        );
870    }
871
872    #[test]
873    fn removing_a_column_keeps_focus_somewhere_real() {
874        let mut scroller = with(3);
875        scroller.remove(3);
876        assert!(scroller.focused().is_some());
877        scroller.remove(2);
878        scroller.remove(1);
879        assert!(scroller.is_empty());
880        assert!(scroller.layout(area(), settings()).is_empty());
881    }
882
883    /// Removing a window from a stacked column leaves the column standing.
884    #[test]
885    fn removing_one_of_a_stack_leaves_the_column() {
886        let mut scroller = with(1);
887        scroller.insert_into_active(2, area(), settings());
888        scroller.remove(1);
889        assert_eq!(scroller.columns().len(), 1);
890        assert_eq!(scroller.columns()[0].windows, vec![2]);
891    }
892
893    #[test]
894    fn moving_a_column_carries_focus_with_it() {
895        let mut scroller = with(3);
896        let focused = scroller.focused();
897        assert!(scroller.move_column(-1, area(), settings()));
898        assert_eq!(scroller.focused(), focused, "the window went with the move");
899        assert_eq!(scroller.active_column(), 1);
900    }
901
902    /// **A column move says whether it moved** (#150), so the layout can tell
903    /// the end of the strip -- where a move goes on to the next monitor --
904    /// from a move that happened. Three columns, the last focused: right is
905    /// the end, left is not, and the first column's left is the other end.
906    #[test]
907    fn move_column_says_whether_it_moved() {
908        let mut scroller = with(3);
909        let order = |scroller: &Scroller| -> Vec<u64> {
910            scroller
911                .columns()
912                .iter()
913                .flat_map(|column| column.windows.clone())
914                .collect()
915        };
916        assert!(
917            !scroller.move_column(1, area(), settings()),
918            "the last column"
919        );
920        assert_eq!(order(&scroller), vec![1, 2, 3]);
921        assert!(scroller.move_column(-1, area(), settings()));
922        assert!(scroller.move_column(-1, area(), settings()));
923        assert_eq!(order(&scroller), vec![3, 1, 2]);
924        assert!(
925            !scroller.move_column(-1, area(), settings()),
926            "the first column"
927        );
928        assert!(
929            !with(1).move_column(-1, area(), settings()),
930            "a strip of one"
931        );
932    }
933
934    /// A column wider than the view is left-aligned, because no offset shows
935    /// all of it and showing the start beats showing the middle.
936    #[test]
937    fn an_oversized_column_is_left_aligned() {
938        let mut scroller = Scroller::new();
939        scroller.insert(1, area(), settings());
940        for _ in 0..6 {
941            scroller.cycle_width(area(), settings());
942        }
943        let wide = Rect::new(0.0, 0.0, 300.0, 600.0);
944        let placed = scroller
945            .layout(wide, settings())
946            .into_iter()
947            .next()
948            .expect("one window");
949        assert!(placed.1.x.abs() < 1.0, "left-aligned: {:?}", placed.1);
950    }
951}
952
953#[cfg(test)]
954mod focus_and_width {
955    use super::Scroller;
956    use crate::{Rect, Settings};
957
958    fn area() -> Rect {
959        Rect::new(0.0, 0.0, 1000.0, 600.0)
960    }
961    fn settings() -> Settings {
962        Settings {
963            gap: 0.0,
964            ..Settings::default()
965        }
966    }
967    fn rect_of(scroller: &Scroller, id: u64) -> Rect {
968        scroller
969            .layout(area(), settings())
970            .into_iter()
971            .find(|(other, _)| *other == id)
972            .expect("in the strip")
973            .1
974    }
975
976    /// Clicking a column that is only half on screen must bring it fully into
977    /// view. A column holding focus while hanging off the edge is the state
978    /// that makes a scroller feel like it is fighting you.
979    #[test]
980    fn focusing_a_half_visible_column_brings_it_fully_on_screen() {
981        let mut scroller = Scroller::new();
982        for id in 1..=4 {
983            scroller.insert(id, area(), settings());
984        }
985        scroller.focus_sideways(-3, area(), settings());
986        let off = rect_of(&scroller, 4);
987        assert!(
988            off.x + off.w > 1000.0,
989            "window 4 starts off the edge: {off:?}"
990        );
991
992        scroller.focus_window(4, area(), settings());
993        let now = rect_of(&scroller, 4);
994        assert!(
995            now.x >= -1.0 && now.x + now.w <= 1001.0,
996            "still not fully visible: {now:?}"
997        );
998    }
999
1000    #[test]
1001    fn widening_changes_the_column_and_every_window_in_it() {
1002        let mut scroller = Scroller::new();
1003        scroller.insert(1, area(), settings());
1004        scroller.insert_into_active(2, area(), settings());
1005        let before = rect_of(&scroller, 1).w;
1006        scroller.widen(1, 0.2, area(), settings());
1007        let (one, two) = (rect_of(&scroller, 1), rect_of(&scroller, 2));
1008        assert!(one.w > before, "the column grew");
1009        assert!((one.w - two.w).abs() < f64::EPSILON, "both windows with it");
1010    }
1011
1012    #[test]
1013    fn a_column_can_neither_vanish_nor_outgrow_the_view() {
1014        let mut scroller = Scroller::new();
1015        scroller.insert(1, area(), settings());
1016        scroller.widen(1, -10.0, area(), settings());
1017        assert!(rect_of(&scroller, 1).w >= 99.0, "floor holds");
1018        scroller.widen(1, 10.0, area(), settings());
1019        assert!(rect_of(&scroller, 1).w <= 1001.0, "ceiling holds");
1020    }
1021}
1022
1023#[cfg(test)]
1024mod moving {
1025    use super::Scroller;
1026    use crate::{Rect, Settings};
1027
1028    fn area() -> Rect {
1029        Rect::new(0.0, 0.0, 1000.0, 600.0)
1030    }
1031    fn settings() -> Settings {
1032        Settings {
1033            gap: 0.0,
1034            ..Settings::default()
1035        }
1036    }
1037
1038    #[test]
1039    fn a_window_dropped_on_another_column_joins_it() {
1040        let mut scroller = Scroller::new();
1041        for id in 1..=3 {
1042            scroller.insert(id, area(), settings());
1043        }
1044        assert_eq!(scroller.columns().len(), 3);
1045        scroller.move_to_column_of(3, 1, area(), settings());
1046        assert_eq!(scroller.columns().len(), 2, "one column absorbed another");
1047        assert!(scroller.columns()[0].windows.contains(&3));
1048        assert_eq!(scroller.focused(), Some(3), "focus went with it");
1049    }
1050
1051    #[test]
1052    fn moving_out_of_a_stack_leaves_the_column_standing() {
1053        let mut scroller = Scroller::new();
1054        scroller.insert(1, area(), settings());
1055        scroller.insert_into_active(2, area(), settings());
1056        scroller.insert(3, area(), settings());
1057        scroller.move_to_column_of(2, 3, area(), settings());
1058        assert_eq!(scroller.columns().len(), 2);
1059        assert_eq!(scroller.columns()[0].windows, vec![1]);
1060        assert!(scroller.columns()[1].windows.contains(&2));
1061    }
1062
1063    #[test]
1064    fn a_window_cannot_be_moved_onto_itself() {
1065        let mut scroller = Scroller::new();
1066        scroller.insert(1, area(), settings());
1067        scroller.insert(2, area(), settings());
1068        scroller.move_to_column_of(1, 1, area(), settings());
1069        assert_eq!(scroller.columns().len(), 2);
1070    }
1071}
1072
1073/// **#115: a column is never narrower than its window's floor.** A 1000-wide
1074/// view at no gap, columns opening at a third.
1075#[cfg(test)]
1076mod floors {
1077    use super::Scroller;
1078    use crate::{Floors, Minimum, Rect, Settings};
1079
1080    fn area() -> Rect {
1081        Rect::new(0.0, 0.0, 1000.0, 600.0)
1082    }
1083    fn settings() -> Settings {
1084        Settings {
1085            gap: 0.0,
1086            ..Settings::default()
1087        }
1088    }
1089    fn rect_of(scroller: &Scroller, id: u64) -> Rect {
1090        scroller
1091            .layout(area(), settings())
1092            .into_iter()
1093            .find(|(other, _)| *other == id)
1094            .expect("window is in the strip")
1095            .1
1096    }
1097    fn wide(of: &[(u64, f64)]) -> Floors {
1098        of.iter()
1099            .map(|&(id, w)| (id, Minimum { w, h: 0.0 }))
1100            .collect()
1101    }
1102
1103    /// A column holding a window that cannot go under 600 is 600 wide, and
1104    /// so is everything stacked in it; the column beside it keeps its third.
1105    #[test]
1106    fn a_column_is_never_narrower_than_its_widest_floor() {
1107        let mut scroller = Scroller::new();
1108        scroller.insert(1, area(), settings());
1109        scroller.insert(2, area(), settings());
1110        scroller.insert_into_active(3, area(), settings());
1111        scroller.set_floors(wide(&[(3, 600.0)]));
1112        for id in [2, 3] {
1113            let rect = rect_of(&scroller, id);
1114            assert!((rect.w - 600.0).abs() < 1e-6, "window {id}: {rect:?}");
1115        }
1116        assert!((rect_of(&scroller, 1).w - 1000.0 / 3.0).abs() < 1e-6);
1117    }
1118
1119    /// The strip agrees with itself about a widened column: the next one
1120    /// starts where it ends, rather than under it.
1121    #[test]
1122    fn the_next_column_starts_after_a_widened_one() {
1123        let mut scroller = Scroller::new();
1124        for id in 1..=3 {
1125            scroller.insert(id, area(), settings());
1126        }
1127        scroller.set_floors(wide(&[(1, 600.0)]));
1128        let (one, two) = (rect_of(&scroller, 1), rect_of(&scroller, 2));
1129        assert!(
1130            (two.x - (one.x + 600.0)).abs() < 1e-6,
1131            "column 2 starts inside column 1: {one:?} {two:?}"
1132        );
1133    }
1134
1135    /// Narrowing a column a floor holds wide stops at the floor's share, so
1136    /// widening it again widens it at once. Without the stop the share went on
1137    /// down out of sight, and the drag back had to pay it all back first.
1138    #[test]
1139    fn narrowing_a_column_stops_at_its_floor() {
1140        let mut scroller = Scroller::new();
1141        scroller.insert(1, area(), settings());
1142        scroller.set_floors(wide(&[(1, 500.0)]));
1143        scroller.widen(1, -0.1, area(), settings());
1144        assert!((rect_of(&scroller, 1).w - 500.0).abs() < 1e-6);
1145        scroller.widen(1, 0.1, area(), settings());
1146        let one = rect_of(&scroller, 1);
1147        assert!(
1148            (one.w - 600.0).abs() < 1e-6,
1149            "the column did not widen from its floor: {one:?}"
1150        );
1151    }
1152
1153    /// Widening a column its floor holds wider than its share widens it by
1154    /// the step from where it is drawn. A third of the view, held at 500 by
1155    /// its floor: one press of a tenth is 600, and not the 433 the share
1156    /// alone reaches, which the floor draws at the same 500 as before.
1157    #[test]
1158    fn widening_a_column_its_floor_holds_widens_it_at_once() {
1159        let mut scroller = Scroller::new();
1160        scroller.insert(1, area(), settings());
1161        scroller.set_floors(wide(&[(1, 500.0)]));
1162        assert!(
1163            (rect_of(&scroller, 1).w - 500.0).abs() < 1e-6,
1164            "the premise"
1165        );
1166        scroller.widen(1, 0.1, area(), settings());
1167        let one = rect_of(&scroller, 1);
1168        assert!(
1169            (one.w - 600.0).abs() < 1e-6,
1170            "the press was swallowed by the floor: {one:?}"
1171        );
1172    }
1173}