Compare commits
3
Commits
1367b03b97
...
01c1b31636
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01c1b31636 | ||
|
|
53cebf14f3 | ||
|
|
50caed0110 |
+15
-14
@@ -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.
|
||||||
|
|||||||
+42
@@ -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 {
|
||||||
|
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)?;
|
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 => {
|
||||||
|
|||||||
+89
-20
@@ -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 {
|
|
||||||
self.is_alt_tab = true;
|
|
||||||
for &m in &held_modifiers {
|
for &m in &held_modifiers {
|
||||||
self.latched_modifiers.insert(m);
|
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,
|
|
||||||
// the modifier was released before focus was acquired: commit immediately!
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
} else if self.alt_tab == AltTabMode::Auto {
|
return;
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-24
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record currently focused window into history
|
||||||
if let Some(pos) = out.iter().position(|t| t.focused) {
|
if let Some(pos) = out.iter().position(|t| t.focused) {
|
||||||
if pos > 0 {
|
if let Some(con_id) = out[pos].con_id {
|
||||||
let focused = out.remove(pos);
|
record_focus(con_id);
|
||||||
out.insert(0, focused);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if order == Order::Mru {
|
||||||
|
let history = read_focus_history();
|
||||||
|
out.sort_by_key(|t| {
|
||||||
|
if t.focused {
|
||||||
|
return (0, 0, 0);
|
||||||
}
|
}
|
||||||
Order::Tree => collect_tree(&tree, &mut out),
|
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 {
|
||||||
|
(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
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user