fix: alt-tab focus history MRU ordering, repeat handling, and quick release

This commit is contained in:
2026-09-09 12:39:10 +02:00
parent 50caed0110
commit 53cebf14f3
5 changed files with 131 additions and 91 deletions
+3 -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,7 @@ 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. /// The navigation key currently held down, and when the next repeat fires.
pub(crate) repeat_key: Option<u32>, pub(crate) repeat_key: Option<u32>,
@@ -188,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 {
@@ -237,10 +229,7 @@ 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_key: None,
repeat_next: None, repeat_next: None,
repeat_delay_ms: 600, repeat_delay_ms: 600,
+21 -11
View File
@@ -174,6 +174,9 @@ 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 // 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 // timer rather than blocking indefinitely. Use a timed poll so we
// wake up when the next repeat is due without burning the CPU. // wake up when the next repeat is due without burning the CPU.
@@ -189,18 +192,24 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
queue.dispatch_pending(&mut app)?; queue.dispatch_pending(&mut app)?;
if !app.finished() { if !app.finished() {
conn.flush()?; conn.flush()?;
let Some(guard) = conn.prepare_read() else { continue }; if let Some(guard) = conn.prepare_read() {
let fd = guard.connection_fd(); let fd = guard.connection_fd();
let mut fds = [PollFd::new(&fd, PollFlags::IN)]; let mut fds = [PollFd::new(&fd, PollFlags::IN)];
let timeout = Timespec { let timeout = Timespec {
tv_sec: left.as_secs() as _, tv_sec: left.as_secs() as _,
tv_nsec: left.subsec_nanos() as _, tv_nsec: left.subsec_nanos() as _,
}; };
match rustix::event::poll(&mut fds, Some(&timeout)) { match rustix::event::poll(&mut fds, Some(&timeout)) {
Ok(_) | Err(rustix::io::Errno::INTR) => {} Ok(0) => {
Err(e) => return Err(Box::new(e)), // 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)),
}
} }
guard.read()?;
} }
continue; continue;
} }
@@ -236,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 => {
+18 -37
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};
@@ -338,7 +337,7 @@ 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. // Arm client-side repeat for navigation keys.
@@ -380,10 +379,7 @@ impl App {
self.repeat_key = None; self.repeat_key = None;
self.repeat_next = None; self.repeat_next = None;
} }
if self.is_alt_tab if self.latched_modifiers.remove(&code) && self.latched_modifiers.is_empty() {
&& 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;
@@ -412,7 +408,7 @@ impl App {
} }
} }
fn keyboard_enter(&mut self, keys: Vec<u8>, qh: &QueueHandle<Self>) { 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)
@@ -429,41 +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::Auto { if self.latched_modifiers.is_empty() {
if self.latched_modifiers.is_empty() { if self.ending == Ending::Running {
// Auto mode: no modifier held on enter means the mod was
// released before focus arrived — commit immediately.
if self.ending == Ending::Running {
self.picked = self.tiles.get(self.sel).map(|t| t.target.clone());
self.ending = Ending::Picked;
}
} else {
// Modifier is held: step selection now.
let step = if self.shift { -1 } else { 1 };
self.move_sel(step, qh);
}
}
}
// For both Yes and Auto: if we are in alt-tab mode but no modifier is
// currently held, the mod was released before keyboard focus arrived.
// Commit the current selection immediately.
if self.is_alt_tab && self.latched_modifiers.is_empty() && self.ending == Ending::Running {
if self.alt_tab == AltTabMode::Yes
|| (self.alt_tab == AltTabMode::Auto && self.initial_stepped)
{
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;
} }
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);
} }
} }
} }
+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);
} }