Compare commits

..
3 Commits
Author SHA1 Message Date
N0VA 01c1b31636 horizontal 2026-09-09 15:23:53 +02:00
N0VA 53cebf14f3 fix: alt-tab focus history MRU ordering, repeat handling, and quick release 2026-09-09 12:39:10 +02:00
N0VA 50caed0110 modkey 2026-09-09 11:44:23 +02:00
6 changed files with 303 additions and 249 deletions
+15 -14
View File
@@ -65,6 +65,7 @@ pub struct Settings {
/// the overlay maps there rather than wherever the compositor would put it. /// the overlay maps there rather than wherever the compositor would put it.
pub scale: i32, pub scale: i32,
pub output: String, pub output: String,
#[allow(dead_code)]
pub alt_tab: AltTabMode, pub alt_tab: AltTabMode,
} }
@@ -123,10 +124,15 @@ pub struct App {
pub(crate) seat: Option<WlSeat>, pub(crate) seat: Option<WlSeat>,
pub(crate) inhibit_mgr: Option<ZwpKeyboardShortcutsInhibitManagerV1>, pub(crate) inhibit_mgr: Option<ZwpKeyboardShortcutsInhibitManagerV1>,
pub(crate) inhibitor: Option<ZwpKeyboardShortcutsInhibitorV1>, pub(crate) inhibitor: Option<ZwpKeyboardShortcutsInhibitorV1>,
pub(crate) alt_tab: AltTabMode,
pub(crate) is_alt_tab: bool,
pub(crate) latched_modifiers: std::collections::BTreeSet<u32>, pub(crate) latched_modifiers: std::collections::BTreeSet<u32>,
pub(crate) initial_stepped: bool,
/// The navigation key currently held down, and when the next repeat fires.
pub(crate) repeat_key: Option<u32>,
pub(crate) repeat_next: Option<std::time::Instant>,
/// Milliseconds before the first repeat fires. From wl_keyboard::RepeatInfo.
pub(crate) repeat_delay_ms: u32,
/// Milliseconds between subsequent repeats. From wl_keyboard::RepeatInfo.
pub(crate) repeat_rate_ms: u32,
} }
/// Counters worth reporting with --verbose. Live capture is easy to get subtly /// Counters worth reporting with --verbose. Live capture is easy to get subtly
@@ -180,15 +186,9 @@ impl App {
fps, fps,
scale, scale,
output, output,
alt_tab, ..
} = settings; } = settings;
let focused_idx = targets.iter().position(|t| t.focused).unwrap_or(0); let sel = if targets.len() > 1 { 1 } else { 0 };
let sel = if alt_tab == AltTabMode::Yes && targets.len() > 1 {
(focused_idx + 1) % targets.len()
} else {
focused_idx
};
let is_alt_tab = alt_tab == AltTabMode::Yes;
// Bind everything up front so a compositor missing a protocol fails // Bind everything up front so a compositor missing a protocol fails
// here, with a name, rather than halfway through a capture. // here, with a name, rather than halfway through a capture.
let mut app = Self { let mut app = Self {
@@ -229,10 +229,11 @@ impl App {
seat: None, seat: None,
inhibit_mgr: None, inhibit_mgr: None,
inhibitor: None, inhibitor: None,
alt_tab,
is_alt_tab,
latched_modifiers: std::collections::BTreeSet::new(), latched_modifiers: std::collections::BTreeSet::new(),
initial_stepped: alt_tab == AltTabMode::Yes, repeat_key: None,
repeat_next: None,
repeat_delay_ms: 600,
repeat_rate_ms: 25,
}; };
let _: ExtForeignToplevelListV1 = globals.bind(qh, 1..=1, ())?; let _: ExtForeignToplevelListV1 = globals.bind(qh, 1..=1, ())?;
// One wl_output per display, bound at v4 so it tells us its name. // One wl_output per display, bound at v4 so it tells us its name.
+43 -1
View File
@@ -174,7 +174,48 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
// A leave only counts once it has failed to come back, because sway also // A leave only counts once it has failed to come back, because sway also
// cycles focus off and on in a single batch as the pointer crosses us. // cycles focus off and on in a single batch as the pointer crosses us.
loop { loop {
queue.blocking_dispatch(&mut app)?; if app.finished() {
break;
}
// When a navigation key is held, we need to fire repeat events on a
// timer rather than blocking indefinitely. Use a timed poll so we
// wake up when the next repeat is due without burning the CPU.
if let Some(next) = app.repeat_next {
let now = Instant::now();
if now >= next {
let qh = queue.handle();
app.fire_repeat(&qh);
conn.flush()?;
} else {
// Poll for events with a timeout set to when the next repeat fires.
let left = next - now;
queue.dispatch_pending(&mut app)?;
if !app.finished() {
conn.flush()?;
if let Some(guard) = conn.prepare_read() {
let fd = guard.connection_fd();
let mut fds = [PollFd::new(&fd, PollFlags::IN)];
let timeout = Timespec {
tv_sec: left.as_secs() as _,
tv_nsec: left.subsec_nanos() as _,
};
match rustix::event::poll(&mut fds, Some(&timeout)) {
Ok(0) => {
// Timeout expired: do NOT call guard.read() because
// nothing is pending on the socket. Dropping guard cancels read.
}
Ok(_) | Err(rustix::io::Errno::INTR) => {
let _ = guard.read();
}
Err(e) => return Err(Box::new(e)),
}
}
}
continue;
}
} else {
queue.blocking_dispatch(&mut app)?;
}
if !app.finished() if !app.finished()
&& !app.focused && !app.focused
&& !pump_for( && !pump_for(
@@ -204,6 +245,7 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
target::Kind::Window => { target::Kind::Window => {
if let Some(con_id) = target.con_id { if let Some(con_id) = target.con_id {
let _ = sway.run_command(format!("[con_id={con_id}] focus")); let _ = sway.run_command(format!("[con_id={con_id}] focus"));
sway::record_focus(con_id);
} }
} }
target::Kind::Output => { target::Kind::Output => {
+95 -26
View File
@@ -24,7 +24,6 @@ use wayland_protocols_wlr::layer_shell::v1::client::{
use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape; use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape;
use crate::app::{App, Ending}; use crate::app::{App, Ending};
use crate::config::AltTabMode;
use crate::shm; use crate::shm;
use crate::theme::{Rect, fit_centred}; use crate::theme::{Rect, fit_centred};
@@ -69,6 +68,26 @@ fn is_trigger_modifier(code: u32) -> bool {
) )
} }
/// Keys that should fire repeatedly while held.
fn is_repeatable_key(code: u32) -> bool {
matches!(
code,
KEY_TAB
| KEY_RIGHT
| KEY_LEFT
| KEY_DOWN
| KEY_UP
| KEY_HOME
| KEY_END
| KEY_PGUP
| KEY_PGDN
| KEY_H
| KEY_J
| KEY_K
| KEY_L
)
}
/// evdev button code, as wl_pointer reports it. /// evdev button code, as wl_pointer reports it.
const BTN_LEFT: u32 = 0x110; const BTN_LEFT: u32 = 0x110;
@@ -318,9 +337,19 @@ impl App {
} }
fn key(&mut self, code: u32, qh: &QueueHandle<Self>) { fn key(&mut self, code: u32, qh: &QueueHandle<Self>) {
if self.is_alt_tab && is_trigger_modifier(code) { if is_trigger_modifier(code) {
self.latched_modifiers.insert(code); self.latched_modifiers.insert(code);
} }
// Arm client-side repeat for navigation keys.
if is_repeatable_key(code) {
let delay = std::time::Duration::from_millis(self.repeat_delay_ms as u64);
self.repeat_key = Some(code);
self.repeat_next = Some(std::time::Instant::now() + delay);
} else {
// Non-repeating key clears any held repeat.
self.repeat_key = None;
self.repeat_next = None;
}
match code { match code {
KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true, KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true,
KEY_ESC | KEY_Q => self.ending = Ending::Cancelled, KEY_ESC | KEY_Q => self.ending = Ending::Cancelled,
@@ -345,10 +374,12 @@ impl App {
if code == KEY_LEFTSHIFT || code == KEY_RIGHTSHIFT { if code == KEY_LEFTSHIFT || code == KEY_RIGHTSHIFT {
self.shift = false; self.shift = false;
} }
if self.is_alt_tab // Clear repeat if this is the key that was held.
&& self.latched_modifiers.remove(&code) if self.repeat_key == Some(code) {
&& self.latched_modifiers.is_empty() self.repeat_key = None;
{ self.repeat_next = None;
}
if self.latched_modifiers.remove(&code) && self.latched_modifiers.is_empty() {
if self.ending == Ending::Running { if self.ending == Ending::Running {
self.picked = self.tiles.get(self.sel).map(|t| t.target.clone()); self.picked = self.tiles.get(self.sel).map(|t| t.target.clone());
self.ending = Ending::Picked; self.ending = Ending::Picked;
@@ -356,7 +387,28 @@ impl App {
} }
} }
fn keyboard_enter(&mut self, keys: Vec<u8>, qh: &QueueHandle<Self>) { /// Called by the main loop when the key-repeat timer fires. Fires the
/// currently held navigation action, then arms the next repeat tick.
pub fn fire_repeat(&mut self, qh: &QueueHandle<Self>) {
let Some(code) = self.repeat_key else { return };
let rate = std::time::Duration::from_millis(self.repeat_rate_ms as u64);
self.repeat_next = Some(std::time::Instant::now() + rate);
// Re-run the navigation action without re-arming the delay.
match code {
KEY_TAB if self.shift => self.move_sel(-1, qh),
KEY_TAB | KEY_RIGHT | KEY_L => self.move_sel(1, qh),
KEY_LEFT | KEY_H => self.move_sel(-1, qh),
KEY_DOWN | KEY_J => self.move_row(1, qh),
KEY_UP | KEY_K => self.move_row(-1, qh),
KEY_HOME => self.select(0, qh),
KEY_END => self.select(self.tiles.len().saturating_sub(1), qh),
KEY_PGUP => self.move_row(-self.layout.visible_rows, qh),
KEY_PGDN => self.move_row(self.layout.visible_rows, qh),
_ => {}
}
}
fn keyboard_enter(&mut self, keys: Vec<u8>, _qh: &QueueHandle<Self>) {
self.focused = true; self.focused = true;
let held_keys: Vec<u32> = keys let held_keys: Vec<u32> = keys
.chunks_exact(4) .chunks_exact(4)
@@ -373,27 +425,26 @@ impl App {
.filter(|&k| is_trigger_modifier(k)) .filter(|&k| is_trigger_modifier(k))
.collect(); .collect();
if !held_modifiers.is_empty() && self.alt_tab != AltTabMode::No { for &m in &held_modifiers {
self.is_alt_tab = true; self.latched_modifiers.insert(m);
for &m in &held_modifiers {
self.latched_modifiers.insert(m);
}
} }
if self.is_alt_tab && !self.initial_stepped { // If no modifier is held on enter, the modifier (and/or Tab) was
self.initial_stepped = true; // released before focus was acquired: commit selection immediately!
if self.alt_tab == AltTabMode::Yes && self.latched_modifiers.is_empty() { if self.latched_modifiers.is_empty() {
// In explicit alt-tab mode, if no modifier was held on enter, if self.ending == Ending::Running {
// the modifier was released before focus was acquired: commit immediately! self.picked = self.tiles.get(self.sel).map(|t| t.target.clone());
if self.ending == Ending::Running { self.ending = Ending::Picked;
self.picked = self.tiles.get(self.sel).map(|t| t.target.clone());
self.ending = Ending::Picked;
}
} else if self.alt_tab == AltTabMode::Auto {
// In auto mode, step selection now that we know a modifier was held:
let step = if self.shift { -1 } else { 1 };
self.move_sel(step, qh);
} }
return;
}
// A modifier is held. If Tab is also held upon enter, arm key-repeat
// immediately so holding Tab cycles through windows.
if held_keys.iter().any(|&k| k == KEY_TAB) {
let delay = std::time::Duration::from_millis(self.repeat_delay_ms as u64);
self.repeat_key = Some(KEY_TAB);
self.repeat_next = Some(std::time::Instant::now() + delay);
} }
} }
} }
@@ -463,12 +514,30 @@ impl Dispatch<WlKeyboard, ()> for App {
WEnum::Value(wl_keyboard::KeyState::Released) => app.key_up(key), WEnum::Value(wl_keyboard::KeyState::Released) => app.key_up(key),
_ => {} _ => {}
}, },
// Store compositor key-repeat settings for our client-side timer.
wl_keyboard::Event::RepeatInfo { rate, delay } => {
// rate == 0 means repeat is disabled.
if rate > 0 {
app.repeat_rate_ms = (1000 / rate as u32).max(1);
app.repeat_delay_ms = delay as u32;
} else {
app.repeat_key = None;
app.repeat_next = None;
app.repeat_delay_ms = 0;
app.repeat_rate_ms = 0;
}
}
// Focus is only tracked here. sway sends leave immediately // Focus is only tracked here. sway sends leave immediately
// followed by enter on the same surface when the pointer crosses // followed by enter on the same surface when the pointer crosses
// it, so whether the grab is really gone is decided by the main // it, so whether the grab is really gone is decided by the main
// loop, once the event batch has been dispatched. // loop, once the event batch has been dispatched.
wl_keyboard::Event::Enter { keys, .. } => app.keyboard_enter(keys, qh), wl_keyboard::Event::Enter { keys, .. } => app.keyboard_enter(keys, qh),
wl_keyboard::Event::Leave { .. } => app.focused = false, wl_keyboard::Event::Leave { .. } => {
app.focused = false;
// Clear any held repeat — we no longer have the keyboard.
app.repeat_key = None;
app.repeat_next = None;
}
_ => {} _ => {}
} }
} }
+68 -26
View File
@@ -119,22 +119,79 @@ impl Order {
} }
} }
fn history_path() -> PathBuf {
std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join("wl-pick-history")
}
pub fn record_focus(con_id: i64) {
let path = history_path();
let mut ids: Vec<i64> = std::fs::read_to_string(&path)
.ok()
.map(|s| {
s.lines()
.filter_map(|l| l.trim().parse::<i64>().ok())
.collect()
})
.unwrap_or_default();
ids.retain(|&id| id != con_id);
ids.insert(0, con_id);
ids.truncate(50);
let content = ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join("\n");
let _ = std::fs::write(&path, content);
}
pub fn read_focus_history() -> Vec<i64> {
std::fs::read_to_string(history_path())
.ok()
.map(|s| {
s.lines()
.filter_map(|l| l.trim().parse::<i64>().ok())
.collect()
})
.unwrap_or_default()
}
/// Views in the tree, either in MRU focus order or tree layout order. /// Views in the tree, either in MRU focus order or tree layout order.
pub fn windows(conn: &mut Connection, order: Order) -> Result<Vec<Target>, swayipc::Error> { pub fn windows(conn: &mut Connection, order: Order) -> Result<Vec<Target>, swayipc::Error> {
let mut out = Vec::new(); let mut out = Vec::new();
let tree = conn.get_tree()?; let tree = conn.get_tree()?;
match order { collect_tree(&tree, &mut out);
Order::Mru => {
collect_mru(&tree, &mut out); if out.is_empty() {
// Ensure the currently focused window is at index 0 return Ok(out);
if let Some(pos) = out.iter().position(|t| t.focused) { }
if pos > 0 {
let focused = out.remove(pos); // Record currently focused window into history
out.insert(0, focused); if let Some(pos) = out.iter().position(|t| t.focused) {
if let Some(con_id) = out[pos].con_id {
record_focus(con_id);
}
}
if order == Order::Mru {
let history = read_focus_history();
out.sort_by_key(|t| {
if t.focused {
return (0, 0, 0);
}
if let Some(con_id) = t.con_id {
if let Some(idx) = history.iter().position(|&id| id == con_id) {
return (1, idx, 0);
} }
} }
} if t.visible {
Order::Tree => collect_tree(&tree, &mut out), (2, 0, 0)
} else {
(3, 0, 0)
}
});
} }
Ok(out) Ok(out)
} }
@@ -152,6 +209,7 @@ fn collect_target(node: &Node) -> Option<Target> {
node.app_id.clone().or(class).unwrap_or_default(), node.app_id.clone().or(class).unwrap_or_default(),
node.name.clone().unwrap_or_default(), node.name.clone().unwrap_or_default(),
node.focused, node.focused,
node.visible.unwrap_or(true),
)) ))
} else { } else {
None None
@@ -167,22 +225,6 @@ fn collect_tree(node: &Node, out: &mut Vec<Target>) {
} }
} }
fn collect_mru(node: &Node, out: &mut Vec<Target>) {
if let Some(target) = collect_target(node) {
out.push(target);
}
let mut children: Vec<&Node> = node.nodes.iter().chain(node.floating_nodes.iter()).collect();
children.sort_by_key(|child| {
node.focus
.iter()
.position(|&id| id == child.id)
.unwrap_or(usize::MAX)
});
for child in children {
collect_mru(child, out);
}
}
/// One active display: what the overlay needs to size itself against. /// One active display: what the overlay needs to size itself against.
/// ///
/// The overlay maps on the focused display, so percentages and the buffer scale /// The overlay maps on the focused display, so percentages and the buffer scale
+21 -3
View File
@@ -57,10 +57,19 @@ pub struct Target {
pub title: String, pub title: String,
/// Whether this window was the focused container when sway was queried. /// Whether this window was the focused container when sway was queried.
pub focused: bool, pub focused: bool,
/// Whether this window is currently visible on screen.
pub visible: bool,
} }
impl Target { impl Target {
pub fn window(con_id: i64, ft_id: String, app: String, title: String, focused: bool) -> Self { pub fn window(
con_id: i64,
ft_id: String,
app: String,
title: String,
focused: bool,
visible: bool,
) -> Self {
Self { Self {
kind: Kind::Window, kind: Kind::Window,
id: con_id.to_string(), id: con_id.to_string(),
@@ -69,6 +78,7 @@ impl Target {
app, app,
title, title,
focused, focused,
visible,
} }
} }
@@ -83,6 +93,7 @@ impl Target {
app: "display".to_string(), app: "display".to_string(),
title: name, title: name,
focused: false, focused: false,
visible: true,
} }
} }
@@ -198,7 +209,14 @@ mod tests {
use super::*; use super::*;
fn win() -> Target { fn win() -> Target {
Target::window(42, "abc123".into(), "kitty".into(), "zsh\tin\na tab".into(), false) Target::window(
42,
"abc123".into(),
"kitty".into(),
"zsh\tin\na tab".into(),
false,
true,
)
} }
#[test] #[test]
@@ -232,7 +250,7 @@ mod tests {
Some("Monitor: DP-1") Some("Monitor: DP-1")
); );
// No identifier means the portal cannot be told about this window. // No identifier means the portal cannot be told about this window.
let anon = Target::window(7, String::new(), "x".into(), "y".into(), false); let anon = Target::window(7, String::new(), "x".into(), "y".into(), false, false);
assert_eq!(anon.portal(), None); assert_eq!(anon.portal(), None);
} }
+61 -179
View File
@@ -110,45 +110,54 @@ impl Layout {
/// scroll. Columns follow ceil(sqrt(n)) up to the cap, so a handful of /// scroll. Columns follow ceil(sqrt(n)) up to the cap, so a handful of
/// windows makes a tidy grid rather than one long row — the rule rofigrid /// windows makes a tidy grid rather than one long row — the rule rofigrid
/// used — and the overlay hugs whatever is there. /// used — and the overlay hugs whatever is there.
/// Lay out `n` tiles in a single row for a display of the given logical size.
///
/// The overlay and the window previews resize automatically to fit all `n`
/// windows side-by-side within the display bounds.
pub fn new(t: &Theme, n: i32, display: (i32, i32)) -> Self { pub fn new(t: &Theme, n: i32, display: (i32, i32)) -> Self {
let n = n.max(0); let n = n.max(0);
let (cap_cols, cap_rows) = (t.max_cols.max(1), t.max_rows.max(1)); let cols = n.max(1);
// The box may never exceed the display, whatever the config says.
let box_w = t.max_w.clamp(1, display.0.max(1)); let box_w = t.max_w.clamp(1, display.0.max(1));
let box_h = t.max_h.clamp(1, display.1.max(1)); let box_h = t.max_h.clamp(1, display.1.max(1));
let aspect = display.0 as f64 / (display.1.max(1) as f64);
let margin = t.margin.min(box_w / 10).max(2);
let avail_for_items = (box_w - 2 * margin).max(cols);
let pitch = avail_for_items / cols;
// Gap and padding adapt when many items crowd the available width:
let gap = (pitch / 6).min(t.gap).max(0);
let max_elem_w = (pitch - gap).max(1);
let pad = (max_elem_w / 8).min(t.pad).max(1);
let label_row = if t.labels { t.spacing + t.line_h } else { 0 }; let label_row = if t.labels { t.spacing + t.line_h } else { 0 };
let furniture_h = 2 * margin + 2 * pad + label_row;
let max_thumb_h = (box_h - furniture_h)
.min((display.1 as f64 * 0.35).round() as i32)
.max(1);
let ideal_tile_w = (max_thumb_h as f64 * aspect).round() as i32;
// Divide the box by the caps: what is left after the furniture is one let max_fit_tile_w = (max_elem_w - 2 * pad).max(1);
// thumbnail. let tile_w = ideal_tile_w.min(max_fit_tile_w).max(1);
let per_col = 2 * t.pad + t.gap; let tile_h = ((tile_w as f64 / aspect).round() as i32).max(1);
let per_row = 2 * t.pad + label_row + t.gap;
let tile_w = ((box_w - 2 * t.margin + t.gap) / cap_cols - per_col).max(1);
let tile_h = ((box_h - 2 * t.margin + t.gap) / cap_rows - per_row).max(1);
let (elem_w, elem_h) = (tile_w + 2 * t.pad, tile_h + label_row + 2 * t.pad);
// Columns: the balanced rule, so a handful of windows makes a tidy grid let elem_w = tile_w + 2 * pad;
// rather than one long row, capped by the config. let elem_h = tile_h + label_row + 2 * pad;
let mut cols = (n as f64).sqrt() as i32; let rows = 1;
if cols * cols < n { let visible_rows = 1;
cols += 1;
}
cols = cols.clamp(1, cap_cols);
// i32::div_ceil is still unstable; only the unsigned one is not.
let rows = (n + cols - 1) / cols;
let visible_rows = cap_rows.clamp(1, rows.max(1));
Self { Self {
cols, cols,
rows, rows,
visible_rows, visible_rows,
n, n,
width: cols * elem_w + (cols - 1) * t.gap + 2 * t.margin, width: (cols * elem_w + (cols - 1) * gap + 2 * margin).min(box_w),
height: visible_rows * elem_h + (visible_rows - 1) * t.gap + 2 * t.margin, height: (elem_h + 2 * margin).min(box_h),
elem_w, elem_w,
elem_h, elem_h,
margin: t.margin, margin,
gap: t.gap, gap,
pad: t.pad, pad,
tile_h, tile_h,
spacing: t.spacing, spacing: t.spacing,
line_h: t.line_h, line_h: t.line_h,
@@ -323,53 +332,37 @@ mod tests {
} }
#[test] #[test]
fn a_thumbnail_is_the_box_divided_by_the_caps() { fn previews_and_overlay_resize_automatically() {
let t = theme(1000, 900, 4, 3);
let l = Layout::new(&t, 12, ROOMY);
let tile = l.tile(0, 0).expect("visible");
// Four columns of (tile + padding) plus three gaps plus two margins fill
// the box, give or take integer division.
let used = 4 * (tile.w + 2 * t.pad) + 3 * t.gap + 2 * t.margin;
assert!((1000 - used).abs() <= 4, "width {used} should fill 1000");
let label_row = t.spacing + t.line_h;
let used = 3 * (tile.h + label_row + 2 * t.pad) + 2 * t.gap + 2 * t.margin;
assert!((900 - used).abs() <= 4, "height {used} should fill 900");
}
#[test]
fn one_window_gets_the_same_thumbnail_as_thirty() {
let t = theme(1000, 900, 4, 3); let t = theme(1000, 900, 4, 3);
let one = Layout::new(&t, 1, ROOMY); let one = Layout::new(&t, 1, ROOMY);
let many = Layout::new(&t, 30, ROOMY); let two = Layout::new(&t, 2, ROOMY);
assert_eq!( let eight = Layout::new(&t, 8, ROOMY);
one.tile(0, 0).expect("visible").w,
many.tile(0, 0).expect("visible").w, assert_eq!((one.rows, one.visible_rows), (1, 1));
"thumbnail size must not depend on how many windows are open" assert_eq!((two.rows, two.visible_rows), (1, 1));
); assert_eq!((eight.rows, eight.visible_rows), (1, 1));
// The overlay hugs what is there: one tile is a small window.
assert_eq!((one.cols, one.rows), (1, 1)); assert_eq!(one.cols, 1);
assert_eq!(two.cols, 2);
assert_eq!(eight.cols, 8);
// Previews shrink automatically as more windows are added
assert!( assert!(
one.width < many.width && one.height < many.height, two.tile(0, 0).expect("visible").w >= eight.tile(0, 0).expect("visible").w,
"{one:?}" "previews should scale down to fit"
); );
assert!(!one.scrollable() && many.scrollable()); // Overlay width adjusts with the count
assert!(one.width <= two.width);
assert!(eight.width <= 1000);
} }
#[test] #[test]
fn grids_stay_balanced_and_within_the_caps() { fn single_row_holds_all_windows() {
let t = theme(1000, 900, 4, 3); let t = theme(1000, 900, 4, 3);
// (n, cols, rows): ceil(sqrt(n)) columns, capped at four. for n in 1..=10 {
for (n, cols, rows) in [
(1, 1, 1),
(2, 2, 1),
(4, 2, 2),
(6, 3, 2),
(12, 4, 3),
(30, 4, 8),
] {
let l = Layout::new(&t, n, ROOMY); let l = Layout::new(&t, n, ROOMY);
assert_eq!((l.cols, l.rows), (cols, rows), "n = {n}"); assert_eq!((l.cols, l.rows, l.visible_rows), (n, 1, 1), "n = {n}");
assert!(l.visible_rows <= t.max_rows, "n = {n}"); assert!(!l.scrollable());
} }
} }
@@ -389,29 +382,14 @@ mod tests {
#[test] #[test]
fn labels_take_their_room_from_the_thumbnail() { fn labels_take_their_room_from_the_thumbnail() {
let mut t = theme(1000, 900, 4, 3); let mut t = theme(1000, 900, 4, 3);
let with = Layout::new(&t, 12, ROOMY); let with = Layout::new(&t, 4, ROOMY);
t.labels = false; t.labels = false;
let without = Layout::new(&t, 12, ROOMY); let without = Layout::new(&t, 4, ROOMY);
// The box is fixed, so dropping labels makes thumbnails taller rather
// than the window shorter.
assert!( assert!(
without.tile(0, 0).expect("visible").h > with.tile(0, 0).expect("visible").h, without.tile(0, 0).expect("visible").h >= with.tile(0, 0).expect("visible").h,
"thumbnails should grow into the freed row" "thumbnails should grow into the freed space"
); );
assert!(with.label(0, 0).is_some() && without.label(0, 0).is_none()); assert!(with.label(0, 0).is_some() && without.label(0, 0).is_none());
let t = theme(1000, 900, 4, 3);
let l = Layout::new(&t, 4, ROOMY);
for i in 0..4 {
let (tile, label, elem) = (
l.tile(i, 0).expect("visible"),
l.label(i, 0).unwrap(),
l.elem(i, 0).expect("visible"),
);
assert_eq!(label.y, tile.y + tile.h + t.spacing);
assert_eq!(label.w, tile.w);
assert!(label.y + label.h + t.pad <= elem.y + elem.h);
}
} }
#[test] #[test]
@@ -421,15 +399,13 @@ mod tests {
let l = Layout::new(&t, 30, (640, 480)); let l = Layout::new(&t, 30, (640, 480));
assert!(l.width <= 640 && l.height <= 480, "{l:?}"); assert!(l.width <= 640 && l.height <= 480, "{l:?}");
assert!(l.tile(0, 0).expect("visible").w >= 1); assert!(l.tile(0, 0).expect("visible").w >= 1);
assert!(l.scrollable());
} }
#[test] #[test]
fn hit_testing_is_the_inverse_of_the_layout() { fn hit_testing_is_the_inverse_of_the_layout() {
let t = Theme::default(); let t = Theme::default();
// 7 tiles over 3 columns: the last row holds one, so two cells are empty. let l = Layout::new(&t, 5, ROOMY);
let l = Layout::new(&t, 7, ROOMY); for i in 0..5 {
for i in 0..7 {
let e = l.elem(i, 0).expect("visible"); let e = l.elem(i, 0).expect("visible");
for (x, y, what) in [ for (x, y, what) in [
(e.x, e.y, "top left"), (e.x, e.y, "top left"),
@@ -439,8 +415,6 @@ mod tests {
assert_eq!(l.hit(x, y, 0), Some(i as usize), "{what} of element {i}"); assert_eq!(l.hit(x, y, 0), Some(i as usize), "{what} of element {i}");
} }
} }
// The window margin, the gap between elements, and the empty cells of
// the last row all belong to no tile.
assert_eq!(l.hit(0, 0, 0), None, "margin"); assert_eq!(l.hit(0, 0, 0), None, "margin");
let first = l.elem(0, 0).expect("visible"); let first = l.elem(0, 0).expect("visible");
assert_eq!( assert_eq!(
@@ -448,101 +422,9 @@ mod tests {
None, None,
"gap between columns" "gap between columns"
); );
assert_eq!(
l.hit(first.x, first.y + first.h + 1, 0),
None,
"gap between rows"
);
// Row 2, column 2 is past the seventh tile: take its column from the top
// row and its row from the first column.
let col2 = l.elem(2, 0).expect("visible");
let row2 = l.elem(6, 0).expect("visible");
assert_eq!(l.hit(col2.x + 4, row2.y + 4, 0), None, "empty cell");
assert_eq!(l.hit(-5, -5, 0), None, "outside"); assert_eq!(l.hit(-5, -5, 0), None, "outside");
} }
#[test]
fn rows_beyond_the_display_scroll_instead_of_shrinking() {
let t = theme(1000, 900, 4, 3);
// Thirty tiles need more rows than the cap allows, so they scroll.
let l = Layout::new(&t, 30, ROOMY);
assert!(l.scrollable(), "{l:?} should scroll");
assert!(l.visible_rows < l.rows);
// The viewport shows a window of rows, and nothing outside it.
let per_screen = (l.visible_rows * l.cols) as usize;
assert!(l.elem(0, 0).is_some());
assert!(
l.elem(per_screen as i32, 0).is_none(),
"first row below the fold"
);
assert!(
l.elem(per_screen as i32, 1).is_some(),
"and visible once scrolled"
);
}
#[test]
fn max_rows_keeps_the_grid_compact() {
let mut t = theme(1000, 900, 4, 3);
let full = Layout::new(&t, 30, ROOMY);
t.max_rows = 2;
let capped = Layout::new(&t, 30, ROOMY);
assert!(
capped.visible_rows == 2 && full.visible_rows > 2,
"{capped:?}"
);
assert!(capped.height < full.height, "a shorter overlay");
assert!(capped.scrollable());
// The cap cannot invent rows: four tiles make a 2x2 grid, and a cap of
// five leaves it alone.
t.max_rows = 5;
let few = Layout::new(&t, 4, ROOMY);
assert_eq!((few.cols, few.rows, few.visible_rows), (2, 2, 2), "{few:?}");
assert!(!few.scrollable());
}
#[test]
fn revealing_moves_the_viewport_as_little_as_possible() {
let t = theme(1000, 900, 4, 3);
let l = Layout::new(&t, 30, ROOMY);
let last_visible = (l.visible_rows * l.cols - 1) as usize;
assert_eq!(l.reveal(0, 0), 0, "already on screen");
assert_eq!(l.reveal(last_visible, 0), 0, "still on screen");
// One row further down scrolls by exactly one row.
assert_eq!(l.reveal(last_visible + 1, 0), 1);
// Jumping to the end goes as far as it can, and no further.
assert_eq!(l.reveal(29, 0), l.max_scroll());
// Coming back up scrolls the other way.
assert_eq!(l.reveal(0, l.max_scroll()), 0);
}
#[test]
fn hit_testing_follows_the_scroll() {
let t = theme(1000, 900, 4, 3);
let l = Layout::new(&t, 30, ROOMY);
let first = l.elem(0, 0).expect("visible");
let probe = (first.x + first.w / 2, first.y + first.h / 2);
assert_eq!(l.hit(probe.0, probe.1, 0), Some(0));
// The same pixel is a different tile once the grid has scrolled.
assert_eq!(l.hit(probe.0, probe.1, 1), Some(l.cols as usize));
}
#[test]
fn a_scrollbar_appears_only_when_there_is_more_to_see() {
let t = theme(1000, 900, 4, 3);
assert!(Layout::new(&t, 4, ROOMY).scrollbar(0, 4).is_none());
let l = Layout::new(&t, 30, ROOMY);
let (track, top) = l.scrollbar(0, 4).expect("scrollable");
assert_eq!(top.y, track.y, "thumb starts at the top");
assert!(top.h < track.h, "thumb is shorter than its track");
let (_, bottom) = l.scrollbar(l.max_scroll(), 4).expect("scrollable");
assert_eq!(
bottom.y + bottom.h,
track.y + track.h,
"and ends at the bottom"
);
}
#[test] #[test]
fn fit_preserves_aspect_and_centres() { fn fit_preserves_aspect_and_centres() {
let box_ = Rect { let box_ = Rect {