diff --git a/README.md b/README.md deleted file mode 100644 index e5edb4c..0000000 --- a/README.md +++ /dev/null @@ -1,279 +0,0 @@ -# wl-pick - -A window switcher for wlroots compositors: a grid overlay of **live** window -previews that looks like a rofi theme, and tells you which one you picked. - -It replaces a `wlthumbs | rofi` pipeline, and doubles as a screencast source -picker for the desktop portal. The difference is that no thumbnails -exist: each window is captured straight into a `wl_shm` buffer that is handed to -its own `wl_subsurface`, and `wp_viewporter` tells the compositor which rectangle -to scale it into. There is no image encoding, no scaler, and no full-resolution -bitmap in this process — which is also why it appears in about 60 ms and holds -~18 MB of RSS however many windows are open. - -``` -sway-tree 0.6ms window list + con_ids over sway IPC -toplevels 0.2ms ext-foreign-toplevel-list handles -constraints 1.5ms every capture session's buffer size, in one roundtrip -capture 52.5ms 8 windows, all frames in flight at once -labels 0.0ms shaped on a worker thread while the captures ran -mapped 4.9ms layer surface + subsurfaces on screen -``` - -The capture phase is the compositor reading full-resolution window pixels out of -the GPU. It is bandwidth-bound (~1.1 GB/s here) and unaffected by how large the -thumbnails are — which also makes it a free window to do other work in. Loading -a font and rasterising its first glyphs costs ~20ms, so labels are shaped on a -worker thread started before the captures and joined after them, and cost -nothing in wall clock. - -## Status - -Working: a labelled grid of live previews with keyboard navigation. Type-to-filter -is the one thing the rofi version had that this doesn't — see the roadmap. - -## Usage - -``` -wl-pick [--format tsv|json|portal] [--live all|current|none] [--fps N] - [--outputs|--no-outputs] [--labels|--no-labels] - [--order mru|tree] [--alt-tab|--no-alt-tab] - [--font FAMILY] [--font-size PX] - [--timeout SECS] [--verbose] -``` - -- `--format tsv|json|portal` how to report the pick (default `tsv`) -- `--live all|current|none` which tiles keep updating (default `all`; displays - are always a single snapshot) -- `--fps N` cap on live updates per tile per second (default 12) -- `--outputs` / `--no-outputs` whether whole displays are tiles too (default - off). Both directions exist so either can override the config file -- `--labels` / `--no-labels` whether a label is drawn under each thumbnail - (default on); `--hide-labels` is the old spelling and still works -- `--order mru|tree` window ordering: Most-Recently-Used focus order or tree - layout order (default `mru`) -- `--alt-tab` / `--no-alt-tab` switcher mode: automatically pick on modifier - release (default `auto`, enabled whenever a modifier was held on enter) -- `--font FAMILY` label font family (default: the system monospace font) -- `--font-size PX` label size in logical px -- `--config PATH` config file (default `~/.config/wl-pick/config`) -- `--timeout SECS` exits after a deadline, in case the keyboard grab ever traps - you -- `--verbose` phase timings, the tile list, and capture stats - -wl-pick is a chooser: the pick goes to stdout, nothing does if you cancel, and -the exit status is 0 for a pick and 1 for a cancel. It never acts on the choice — -it has no idea what you want to do with it. Focusing on sway looks like this: - -```sh -#!/usr/bin/env bash -# ~/.local/bin/winmenu, bound to $mod+Tab -IFS=$'\t' read -r type id toplevel app title < <(wl-pick) || exit 0 -case $type in - window) swaymsg "[con_id=$id] focus" ;; - output) swaymsg "focus output $id" ;; -esac -``` - -Windows only, as a one-liner: - -```sh -swaymsg "[con_id=$(wl-pick --no-outputs | cut -f2)] focus" -``` - -Three formats, because the identifiers different consumers need differ: - -| `--format` | output | -|---|---| -| `tsv` (default) | `TYPE⇥ID⇥TOPLEVEL_ID⇥APP⇥TITLE` — `ID` is the sway `con_id`, or the output name for a display; `TOPLEVEL_ID` is the ext-foreign-toplevel-list-v1 identifier that `grim -T` and the portal capture by | -| `json` | the same record with every key always present, for `jq` | -| `portal` | `Monitor: NAME` or `Window: TOPLEVEL_ID` | - -`portal` is exactly what xdg-desktop-portal-wlr's `simple` chooser reads, so -wl-pick can be the picker for `getDisplayMedia` and friends — with live previews -of both windows and displays: - -```ini -[screencast] -chooser_type=simple -chooser_cmd=wl-pick --format portal -``` - -**Starting a second wl-pick replaces the first.** The new overlay takes the -keyboard grab, and the one that loses it exits without printing anything — so -hitting the keybinding twice leaves you with one overlay, not a stranded -process. The catch is that sway answers a capture request for a toplevel -another client is already capturing with silence — no frame, no failure — so -the replacement's thumbnails are mostly blank until the first instance has -gone. Every wait before the overlay is interactive is capped at two seconds -for that reason: a tile that never arrives is drawn as a bare label, and the -grid still works. - -| key | | -|---|---| -| `→` `←` / `l` `h` / `Tab` `Shift+Tab` | next / previous tile | -| `↓` `↑` / `j` `k` | move a row | -| `Home` `End` / `PgUp` `PgDn` | first / last, or a screen at a time | -| `Enter` / release modifier | pick the selection (release Alt/Super in Alt+Tab mode) | -| `Escape` / `q` | cancel | -| click | pick that tile | -| scroll | next / previous tile | - -Hovering deliberately does not move the selection — the keyboard keeps it, and a -click acts on whatever is under the cursor. Clicking the margin, a gap, or an -empty cell of a ragged last row does nothing. Tiles are subsurfaces, so a click -on a thumbnail identifies its tile by surface; only clicks on the chrome around -them need hit-testing. - -Navigation reads raw evdev keycodes, so it is layout-independent — but it also -means virtual-keyboard clients such as `wtype` (which invent their own keymap) -cannot drive it. That goes away with xkb support, which filtering needs anyway. - -## Live previews - -Capture sessions stay open, so a tile can be refreshed. Three things keep that -from being expensive: - -- **It is damage-driven.** After a session's first frame the compositor only - produces another once the window content changes, so a request left - outstanding on an idle window costs nothing. Measured over 4s with one - animating window out of ten: `52,52,1,1,1,1,1,1,13,1` frames — the static - windows delivered exactly their first frame and nothing more. -- **Frame callbacks are the clock.** Re-captures are driven by the overlay's own - `wl_surface.frame` callbacks, so they stop when it isn't being presented, and - `--fps` throttles per tile on top of that (12 fps measured as 12.1). -- **Two buffers per window, alternating.** A capture must not write into a buffer - the compositor is reading, so each window gets two and `wl_buffer.release` - decides which is free. Note that release is the entire contract: with `wl_shm` - the compositor copies the pixels out at commit and hands the buffer straight - back, so the slot on screen is usually free too — waiting for it to stop being - displayed instead deadlocks after two frames. - -The cost is memory and bandwidth: two full-resolution buffers per window (138 MB -of shm for eight windows and a display here, against 83 MB with `--live none`) -and a readback per refreshed frame. `--live current` refreshes only the selected tile, -which is much cheaper and still reads as alive. - -## Config - -`~/.config/wl-pick/config`, or `--config PATH`. Flat `key = value` lines with -`#` comments, everything optional, and a flag always beats the file. No TOML -dependency, because there is nothing to nest. - -```ini -background = #282828 # the grid's backdrop -foreground = #ebdbb2 # label text -selection = #d79921 # the highlighted tile -selection-text = #282828 # its label -border = #d79921 -border-width = 2px - -max-width = 90ppt # the box the grid may fill -max-height = 90ppt -max-columns = 4 # thumbnails are that box divided by these -max-rows = 4 - -font = monospace -font-size = 13.3 -labels = yes -outputs = no # include whole displays as tiles (default no) -order = mru # mru (default) or tree -alt-tab = auto # auto (default), yes or no -live = all -fps = 12 -format = tsv -timeout = 0 # seconds; 0 means none -``` - -Sizes take sway's units: `600px` is absolute, `90ppt` a percentage — and the -percentage resolves against **the display the grid actually appears on**, every -time it runs. On a mixed setup one file gives 90% of a 1280-wide laptop panel and -90% of a 3840-wide monitor, rather than a pixel count that suits one and looks -wrong on the other. The overlay maps explicitly on that display, at its scale, so -mixed-DPI renders crisply either way. - -All four sizing settings are **caps**: - -- `max-width` and `max-height` bound the overlay. -- `max-columns` and `max-rows` bound the grid inside it. - -A thumbnail is simply that box divided by those caps, which means **its size -never depends on how many windows are open**: one window gets the same -thumbnail as thirty, in a smaller overlay, because the overlay hugs whatever is -actually there. Rows past `max-rows` scroll, with a scrollbar in the right -margin, `PgUp`/`PgDn`, and the selection always kept in view. Tiles scrolled out -of sight are unmapped, so live capture skips them too. - -Turning labels off gives that row back to the thumbnails rather than shrinking -the window, since the box is what you asked for either way. - -## Look - -The defaults come from the rofi theme this replaces: gruvbox dark, a yellow -selection filling the element padding, `ceil(sqrt(n))` columns capped at 4, -`title · app` centred underneath. Padding, gaps and margins are still fixed, in -`src/theme.rs`. - -The label font defaults to the system monospace font — whatever `fc-match -monospace` answers, which is what the rest of the desktop uses. (cosmic-text's -own generic resolves through a built-in preference that is usually not -installed, and then lands on an arbitrary face, so it is asked directly -instead; if fontconfig isn't available, a short list of common distribution -defaults is tried.) `--font` names a family instead, and `--verbose` reports -which family the labels were actually shaped with. - -Naming a family scans your own font directories first because they are small; -the full system scan (~37ms) happens only if it isn't found there. An unknown -family falls back to whatever cosmic-text picks rather than failing. Long titles -are ellipsised to the cell. - -## Requirements - -A wlroots compositor advertising `ext-image-copy-capture-v1`, -`ext-image-capture-source-v1` (with the foreign-toplevel source manager), -`ext-foreign-toplevel-list-v1`, `wlr-layer-shell-unstable-v1` and -`wp_viewporter` — sway 1.11+, and in principle Hyprland, labwc and jay, though -only sway is tested. sway is also the source of truth for the window list, over -its IPC socket, which is the one thing that would need replacing to run -elsewhere (`ext-foreign-toplevel-list-v1` already reports app id and title). - -The socket is found from `SWAYSOCK`/`I3SOCK` when those point at something that -exists, and otherwise by looking for the running sway's socket in -`$XDG_RUNTIME_DIR`. Inheriting a stale path is easy — any process that outlives -the sway that started it hands one to every shell it spawns — and a picker on a -keybinding should not be the thing that notices. - -Known upstream issue: holding per-toplevel capture sessions open makes windows -blurry on **fractionally scaled** outputs -([sway#9113](https://github.com/swaywm/sway/issues/9113)). Integer scales are -unaffected. It matters more once previews are live. - -## Roadmap - -- type-to-filter with fzf-quality fuzzy matching (and the xkb keyboard input it - needs, which would also let virtual-keyboard clients drive the overlay) -- dmabuf capture, so the pixels never leave the GPU at all — and live previews - stop costing a readback per frame - -## Source layout - -``` -main.rs orchestration: list, capture, map, report the pick -cli.rs flags, defaults, and the help text that documents them -sway.rs the window list and display names, over sway's IPC socket -target.rs what a tile stands for, and the three output formats -app.rs the Wayland client state every event dispatches into -capture.rs capture sessions, their buffers, and the live clock -overlay.rs the layer surface, the drawing, and the keyboard -theme.rs colours, grid geometry, aspect fitting -text.rs label shaping on a worker thread -shm.rs memfd allocation and the ARGB painter -``` - -## Building - -``` -cargo build --release -cargo test # grid geometry and hit-testing, ellipsising, output - # formats, glyph output -``` diff --git a/src/app.rs b/src/app.rs index 3759c5d..6e1b599 100644 --- a/src/app.rs +++ b/src/app.rs @@ -46,8 +46,7 @@ use wayland_protocols::wp::viewporter::client::{ }; use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1; -use crate::capture::{Live, Tile}; -use crate::config::AltTabMode; +use crate::capture::Tile; use crate::overlay; use crate::shm; use crate::target::Target; @@ -55,18 +54,14 @@ use crate::text; use crate::theme::{Layout, Theme}; /// What the caller decided before any of this started: the look, and how much -/// live capture to do. Passed as one value because `theme`, `live`, `fps` and -/// `scale` all arrive together and two of them are bare integers. +/// live capture to do. `live` is always on for the alt-tab switcher mode. pub struct Settings { pub theme: Theme, - pub live: Live, pub fps: u32, /// Integer scale of the display the overlay renders on, and its name, so /// the overlay maps there rather than wherever the compositor would put it. pub scale: i32, pub output: String, - #[allow(dead_code)] - pub alt_tab: AltTabMode, } pub struct App { @@ -88,7 +83,6 @@ pub struct App { pub(crate) theme: Theme, pub(crate) layout: Layout, - pub(crate) live: Live, pub(crate) fps: u32, pub(crate) scale: i32, pub(crate) sel: usize, @@ -182,7 +176,6 @@ impl App { ) -> Result> { let Settings { theme, - live, fps, scale, output, @@ -206,7 +199,6 @@ impl App { tiles: targets.into_iter().map(Tile::new).collect(), theme, layout, - live, fps, scale, sel, diff --git a/src/capture.rs b/src/capture.rs index ebe94c4..1384811 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -7,12 +7,14 @@ //! //! The pixels are never mapped into this process. A capture buffer goes straight //! to a subsurface for display, so the compositor writes those pages and samples -//! them again itself. +//! them again itself. Live previews are always on. use std::error::Error; use std::os::fd::AsFd; use std::time::{Duration, Instant}; +use crate::Target; + use wayland_client::protocol::{ wl_buffer::{self, WlBuffer}, wl_callback, wl_output, wl_shm, @@ -30,29 +32,7 @@ use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport; use crate::app::App; use crate::shm; -use crate::target::{Kind, Target}; - -/// Which tiles keep updating after the first frame. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Live { - /// Every tile. - All, - /// Only the selected tile: much cheaper, and still reads as alive. - Current, - /// Nothing: one snapshot each, a picker rather than an expose. - None, -} - -impl Live { - pub fn parse(s: &str) -> Result { - match s.trim() { - "all" => Ok(Live::All), - "current" => Ok(Live::Current), - "none" => Ok(Live::None), - other => Err(format!("{other:?} is not all, current or none")), - } - } -} +use crate::target::Kind; /// One capture buffer. `busy` means the compositor still holds it — either it is /// on screen or a capture is writing into it — so we must not scribble over it. @@ -202,9 +182,8 @@ impl App { tile.settled = true; continue; } - // Only a tile that will be re-captured needs a second buffer, and a - // display's is the size of the whole screen. - let slots = if self.live == Live::None || tile.target.kind == Kind::Output { + // Always 2 buffers for live preview; display gets full-screen buffer. + let slots = if tile.target.kind == Kind::Output { 1 } else { 2 @@ -301,9 +280,6 @@ impl App { /// Ask for the next frame callback. A commit is needed for the compositor to /// schedule one, and an empty commit is enough. pub fn arm_frame_callback(&mut self, qh: &QueueHandle) { - if self.live == Live::None { - return; - } if let Some(surface) = self.surface.clone() { surface.frame(qh, ()); surface.commit(); @@ -314,15 +290,9 @@ impl App { /// the overlay is not being presented. pub fn tick(&mut self, qh: &QueueHandle) { self.stats.ticks += 1; - if self.live == Live::None { - return; - } let interval = Duration::from_secs_f64(1.0 / self.fps.max(1) as f64); let now = Instant::now(); for i in 0..self.tiles.len() { - if self.live == Live::Current && i != self.sel { - continue; - } // A display tile shows this overlay, which shows the display tile: // refreshing it never settles and costs a whole screen per frame. if self.tiles[i].target.kind == Kind::Output { diff --git a/src/cli.rs b/src/cli.rs index fececca..86b1a36 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,8 +4,7 @@ use std::path::PathBuf; use std::time::Duration; use crate::app::Settings; -use crate::capture::Live; -use crate::config::{AltTabMode, Config, Length}; +use crate::config::{Config, Length}; use crate::sway::{Display, Order}; use crate::target::Format; use crate::theme::Theme; @@ -23,7 +22,6 @@ usage: wl-pick [options] --outputs, --no-outputs include whole displays as tiles [no] --labels, --no-labels a label under each thumbnail [yes] --order mru|tree window ordering: mru or layout tree [mru] - --alt-tab, --no-alt-tab alt-tab switcher mode (commit on release) [auto] --focus, --no-focus focus the picked target in sway directly [no] --font FAMILY label font family [the system monospace font] --font-size PX label size in logical px [13.3] @@ -116,11 +114,9 @@ pub struct Args { pub(crate) labels: Option, pub(crate) font: Option, pub(crate) font_size: Option, - pub(crate) live: Option, pub(crate) fps: Option, pub(crate) timeout: Option, pub(crate) order: Option, - pub(crate) alt_tab: Option, pub(crate) focus: Option, } @@ -204,11 +200,9 @@ impl Args { display: (display.width, display.height), settings: Settings { theme, - live: self.live.or(cfg.live).unwrap_or(Live::All), fps: self.fps.or(cfg.fps).unwrap_or(12), scale: display.scale, output: display.name.clone(), - alt_tab: self.alt_tab.or(cfg.alt_tab).unwrap_or(AltTabMode::Auto), }, } } @@ -233,8 +227,6 @@ fn parse(it: impl Iterator) -> Result { let v = it.next().ok_or("--order needs mru|tree")?; args.order = Some(Order::parse(&v)?); } - "--alt-tab" => args.alt_tab = Some(AltTabMode::Yes), - "--no-alt-tab" => args.alt_tab = Some(AltTabMode::No), "--focus" => args.focus = Some(true), "--no-focus" => args.focus = Some(false), "--config" => { @@ -245,10 +237,6 @@ fn parse(it: impl Iterator) -> Result { // --hide-labels was the only spelling before --labels existed, and // is still accepted for whatever it is wired into. "--no-labels" | "--hide-labels" => args.labels = Some(false), - "--live" => { - let v = it.next().ok_or("--live needs all|current|none")?; - args.live = Some(Live::parse(&v)?); - } "--fps" => { let v = it.next().ok_or("--fps needs a number")?; args.fps = Some(v.parse().map_err(|_| format!("bad --fps: {v}"))?); @@ -307,8 +295,6 @@ mod tests { assert_eq!(args(&["--labels"]).labels, Some(true)); assert_eq!(args(&["--no-labels"]).labels, Some(false)); assert_eq!(args(&["--hide-labels"]).labels, Some(false), "old spelling"); - assert_eq!(args(&["--alt-tab"]).alt_tab, Some(AltTabMode::Yes)); - assert_eq!(args(&["--no-alt-tab"]).alt_tab, Some(AltTabMode::No)); // Unset is what lets the file have its say. assert_eq!(args(&[]).outputs, None); assert_eq!(args(&[]).labels, None); diff --git a/src/config.rs b/src/config.rs index f047652..7ee32d6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,30 +14,10 @@ use std::path::{Path, PathBuf}; use std::time::Duration; -use crate::capture::Live; use crate::sway::Order; use crate::target::Format; use crate::theme::Argb; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum AltTabMode { - #[default] - Auto, - Yes, - No, -} - -impl AltTabMode { - pub fn parse(s: &str) -> Result { - match s.trim() { - "auto" => Ok(AltTabMode::Auto), - "yes" | "true" | "on" | "1" => Ok(AltTabMode::Yes), - "no" | "false" | "off" | "0" => Ok(AltTabMode::No), - other => Err(format!("{other:?} is not auto, yes or no")), - } - } -} - /// A size, either absolute or relative to the display it will be shown on. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Length { @@ -114,12 +94,10 @@ pub struct Config { pub font_size: Option, pub labels: Option, pub outputs: Option, - pub live: Option, pub fps: Option, pub format: Option, pub timeout: Option, pub order: Option, - pub alt_tab: Option, pub focus: Option, } @@ -171,7 +149,7 @@ impl Config { "font-size" => self.font_size = Some(number(value)?), "labels" => self.labels = Some(boolean(value)?), "outputs" => self.outputs = Some(boolean(value)?), - "live" => self.live = Some(Live::parse(value)?), + "fps" => self.fps = Some(number(value)?), "format" => self.format = Some(Format::parse(value)?), // Zero is how you say "no timeout"; an immediate deadline would @@ -181,7 +159,6 @@ impl Config { self.timeout = (secs > 0.0).then(|| Duration::from_secs_f64(secs)); } "order" => self.order = Some(Order::parse(value)?), - "alt-tab" => self.alt_tab = Some(AltTabMode::parse(value)?), "focus" => self.focus = Some(boolean(value)?), other => return Err(format!("unknown setting {other:?}")), } @@ -264,12 +241,10 @@ max-width = 70ppt max-columns = 4 max-rows = 3 -live = current fps = 30 labels = no timeout = 0 order = mru -alt-tab = yes ", ) .expect("should parse"); @@ -283,8 +258,6 @@ alt-tab = yes assert_eq!(cfg.labels, Some(false)); assert_eq!(cfg.timeout, None, "zero means no timeout"); assert_eq!(cfg.order, Some(Order::Mru)); - assert_eq!(cfg.alt_tab, Some(AltTabMode::Yes)); - assert!(cfg.live.is_some()); // Untouched settings stay unset, so defaults survive. assert_eq!(cfg.foreground, None); assert_eq!(cfg.max_height, None); @@ -324,16 +297,4 @@ alt-tab = yes unsafe { std::env::set_var("XDG_CONFIG_HOME", "/nonexistent") }; assert!(Config::load(None).is_ok()); } - - #[test] - fn alt_tab_mode_parses() { - assert_eq!(AltTabMode::parse("auto"), Ok(AltTabMode::Auto)); - assert_eq!(AltTabMode::parse("yes"), Ok(AltTabMode::Yes)); - assert_eq!(AltTabMode::parse("true"), Ok(AltTabMode::Yes)); - assert_eq!(AltTabMode::parse("1"), Ok(AltTabMode::Yes)); - assert_eq!(AltTabMode::parse("no"), Ok(AltTabMode::No)); - assert_eq!(AltTabMode::parse("false"), Ok(AltTabMode::No)); - assert_eq!(AltTabMode::parse("0"), Ok(AltTabMode::No)); - assert!(AltTabMode::parse("maybe").is_err()); - } } diff --git a/src/overlay.rs b/src/overlay.rs index 43d4891..38ff454 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -34,11 +34,6 @@ const KEY_TAB: u32 = 15; const KEY_Q: u32 = 16; const KEY_ENTER: u32 = 28; const KEY_LEFTCTRL: u32 = 29; -// hjkl, by physical position: the same keys as vim on a qwerty layout. -const KEY_H: u32 = 35; -const KEY_J: u32 = 36; -const KEY_K: u32 = 37; -const KEY_L: u32 = 38; const KEY_LEFTSHIFT: u32 = 42; const KEY_RIGHTSHIFT: u32 = 54; const KEY_LEFTALT: u32 = 56; @@ -59,12 +54,7 @@ const KEY_RIGHTMETA: u32 = 126; fn is_trigger_modifier(code: u32) -> bool { matches!( code, - KEY_LEFTALT - | KEY_RIGHTALT - | KEY_LEFTMETA - | KEY_RIGHTMETA - | KEY_LEFTCTRL - | KEY_RIGHTCTRL + KEY_LEFTALT | KEY_RIGHTALT | KEY_LEFTMETA | KEY_RIGHTMETA | KEY_LEFTCTRL | KEY_RIGHTCTRL ) } @@ -81,10 +71,6 @@ fn is_repeatable_key(code: u32) -> bool { | KEY_END | KEY_PGUP | KEY_PGDN - | KEY_H - | KEY_J - | KEY_K - | KEY_L ) } @@ -358,10 +344,10 @@ impl App { self.ending = Ending::Picked; } 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_TAB | KEY_RIGHT => self.move_sel(1, qh), + KEY_LEFT => self.move_sel(-1, qh), + KEY_DOWN => self.move_sel(1, qh), + KEY_UP => self.move_sel(-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), @@ -396,10 +382,10 @@ impl App { // 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_TAB | KEY_RIGHT => self.move_sel(1, qh), + KEY_LEFT => self.move_sel(-1, qh), + KEY_DOWN => self.move_sel(1, qh), + KEY_UP => self.move_sel(-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), @@ -415,7 +401,10 @@ impl App { .map(|chunk| u32::from_ne_bytes(chunk.try_into().unwrap())) .collect(); - if held_keys.iter().any(|&k| k == KEY_LEFTSHIFT || k == KEY_RIGHTSHIFT) { + if held_keys + .iter() + .any(|&k| k == KEY_LEFTSHIFT || k == KEY_RIGHTSHIFT) + { self.shift = true; }