1#![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))]
2pub mod ffi;
16
17pub mod scroller;
18pub mod tree;
19
20#[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 #[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 #[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 #[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#[derive(Clone, Copy, Debug)]
75pub struct Settings {
76 pub gap: f64,
78 pub ratio: f64,
80 pub column: f64,
82 pub padding: f64,
84 pub split: f64,
86 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#[derive(Clone, Copy, Debug, Default, PartialEq)]
119pub struct Minimum {
120 pub w: f64,
121 pub h: f64,
122}
123
124pub type Floors = std::collections::HashMap<u64, Minimum>;
134
135#[derive(Clone, Copy, Debug)]
137pub struct Column {
138 pub width: f64,
141 pub windows: usize,
143}
144
145#[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#[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#[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#[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#[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#[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 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 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 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 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 #[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 #[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 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}