main.rs raw

//! liftoff — a classically Unix-ish launcher.
//!
//! Presents a centered Wayland layer-shell overlay that prefix-completes
//! executables found on `$PATH`. Typing filters the matches; space commits the
//! highlighted match and moves on to typing arguments; enter runs it; escape
//! dismisses. The display-independent behavior lives in the
//! `liftoff` library crate; this binary is the SCTK/cosmic-text UI around it.

mod render;

use std::num::NonZeroU32;

use smithay_client_toolkit::reexports::client::{
    Connection, QueueHandle,
    globals::registry_queue_init,
    protocol::{wl_keyboard, wl_output, wl_seat, wl_shm, wl_surface},
};
use smithay_client_toolkit::{
    compositor::{CompositorHandler, CompositorState, Region},
    delegate_compositor, delegate_keyboard, delegate_layer, delegate_output, delegate_registry,
    delegate_seat, delegate_shm,
    output::{OutputHandler, OutputState},
    reexports::calloop::{EventLoop, LoopHandle, channel},
    reexports::calloop_wayland_source::WaylandSource,
    registry::{ProvidesRegistryState, RegistryState},
    registry_handlers,
    seat::{
        Capability, SeatHandler, SeatState,
        keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers},
    },
    shell::{
        WaylandSurface,
        wlr_layer::{
            Anchor, KeyboardInteractivity, Layer, LayerShell, LayerShellHandler, LayerSurface,
            LayerSurfaceConfigure,
        },
    },
    shm::{Shm, ShmHandler, slot::SlotPool},
};

use liftoff::{Executables, InstanceLock, Phase, Query, default_lock_path};
use render::{Renderer, View};

/// Distance from the top of the screen to the overlay. Anchoring to the top
/// (rather than centering) keeps the overlay's position fixed as it grows and
/// shrinks with the match count, instead of jittering as it re-centers.
const TOP_MARGIN: i32 = 240;

fn main() {
    // Refuse to stack a second overlay: if another liftoff is already running it
    // holds the runtime lock, so quit immediately and leave that one in focus.
    // The lock is held for the life of this process (via `_instance`) and
    // released by the kernel when we exit. If the lock file cannot even be
    // opened we warn and carry on — better a possible double overlay than
    // refusing to launch at all.
    let _instance: Option<InstanceLock> = match InstanceLock::acquire(&default_lock_path()) {
        Ok(Some(lock)) => Some(lock),
        Ok(None) => return,
        Err(err) => {
            eprintln!("liftoff: could not check for a running instance: {err}");
            None
        }
    };

    let conn = Connection::connect_to_env().expect("failed to connect to a Wayland compositor");
    let (globals, event_queue) = registry_queue_init(&conn).expect("failed to init registry");
    let qh: QueueHandle<App> = event_queue.handle();

    let mut event_loop: EventLoop<App> =
        EventLoop::try_new().expect("failed to create the event loop");
    WaylandSource::new(conn.clone(), event_queue)
        .insert(event_loop.handle())
        .expect("failed to insert the Wayland source into the event loop");

    let compositor = CompositorState::bind(&globals, &qh).expect("wl_compositor unavailable");
    let layer_shell = LayerShell::bind(&globals, &qh).expect("wlr layer shell unavailable");
    let shm = Shm::bind(&globals, &qh).expect("wl_shm unavailable");

    let surface = compositor.create_surface(&qh);
    let layer =
        layer_shell.create_layer_surface(&qh, surface, Layer::Overlay, Some("liftoff"), None);
    layer.set_anchor(Anchor::TOP);
    layer.set_margin(TOP_MARGIN, 0, 0, 0);
    layer.set_keyboard_interactivity(KeyboardInteractivity::Exclusive);

    // The surface is created at a fixed size, tall enough for a full match list,
    // and never resized — only the drawn box grows and shrinks within it. This
    // keeps compositors from replaying their layer-open animation on every
    // keystroke that changes the match count.
    let surface_height = render::surface_height();
    layer.set_size(render::WIDTH, surface_height);
    layer.commit();

    // Get that commit to the compositor before doing anything else. Until the
    // surface is mapped we do not hold keyboard focus, and every key struck in
    // the meantime is delivered to whatever window we are about to cover — typed
    // into someone else's input, not ours. Everything expensive therefore
    // happens after this point, never in front of it.
    conn.flush()
        .expect("failed to flush the initial surface commit");

    // Scanning `$PATH` means a `stat` for every file in every directory on it.
    // Warm, that is milliseconds; on a cold cache — a spinning disk, a fresh
    // boot — it is seconds, which is far too long to leave the launcher
    // unfocusable. So it runs on a worker thread and arrives through the event
    // loop, while the overlay maps and starts accepting input immediately.
    let (scan_tx, scan_rx) = channel::channel();
    std::thread::spawn(move || {
        // The receiver is gone only if the launcher has already exited, in which
        // case there is nobody left to tell.
        let _ = scan_tx.send(Executables::from_path());
    });
    event_loop
        .handle()
        .insert_source(scan_rx, |event, _, app: &mut App| {
            if let channel::Event::Msg(executables) = event {
                app.catalog_ready(executables);
            }
        })
        .expect("failed to insert the $PATH scan into the event loop");

    let pool_bytes = (render::WIDTH * surface_height * 4) as usize;
    let pool = SlotPool::new(pool_bytes, &shm).expect("failed to create the shm pool");

    let mut app = App {
        conn: conn.clone(),
        registry_state: RegistryState::new(&globals),
        seat_state: SeatState::new(&globals, &qh),
        output_state: OutputState::new(&globals, &qh),
        shm,
        compositor,
        layer,
        pool,
        loop_handle: event_loop.handle(),
        keyboard: None,
        width: render::WIDTH,
        height: surface_height,
        exit: false,
        catalog: Catalog::Loading {
            deferred: Vec::new(),
        },
        query: Query::new(),
        renderer: Renderer::new(),
        scratch: Vec::new(),
    };

    loop {
        if let Err(err) = event_loop.dispatch(None, &mut app) {
            // A dispatch error generally means the compositor went away (broken
            // pipe). That is a normal way for the session to end, so exit
            // quietly instead of panicking.
            eprintln!("liftoff: event loop ended: {err}");
            break;
        }
        // Flush any commits produced outside the Wayland source's own dispatch
        // (for instance from a key-repeat timer callback).
        let _ = conn.flush();
        if app.exit {
            break;
        }
    }
}

/// Pick the slice of `matches` to display so the highlighted row stays visible,
/// and translate `selected` into an index within that slice. The window holds at
/// most [`render::MAX_ROWS`] rows and keeps the selection at the bottom edge
/// once it scrolls past the initial page.
fn window_rows(matches: &[&str], selected: usize) -> (Vec<String>, usize) {
    let max = render::MAX_ROWS;
    if matches.is_empty() {
        return (Vec::new(), 0);
    }
    let start = if selected < max {
        0
    } else {
        selected + 1 - max
    };
    let end = (start + max).min(matches.len());
    (
        matches[start..end].iter().map(|s| s.to_string()).collect(),
        selected - start,
    )
}

/// What a keystroke means, worked out before we know whether the command list
/// is available yet. Resolving the meaning up front is what lets a key struck
/// during the `$PATH` scan be held and replayed later exactly as typed.
enum Action {
    /// Dismiss without running anything.
    Dismiss,
    /// Launch the highlighted command and exit.
    Run,
    /// Delete a character, or step back out of argument entry.
    Backspace,
    /// Move the highlight up the match list.
    SelectPrevious,
    /// Move the highlight down the match list.
    SelectNext,
    /// The space/tab key: commits the highlighted match while completing a
    /// command name, and is a literal separator while typing arguments.
    Space,
    /// Type these characters into the input line.
    Insert(String),
    /// Nothing to do — a modifier, or a key with no printable text.
    Ignore,
}

impl Action {
    /// Interpret a key press. Only the keysym and the text the compositor
    /// resolved for it matter, so this needs no launcher state.
    fn of(event: &KeyEvent) -> Self {
        match event.keysym {
            Keysym::Escape => Action::Dismiss,
            Keysym::Return | Keysym::KP_Enter => Action::Run,
            Keysym::BackSpace => Action::Backspace,
            Keysym::Up => Action::SelectPrevious,
            Keysym::Down => Action::SelectNext,
            Keysym::Tab | Keysym::space => Action::Space,
            _ => {
                let text: String = event
                    .utf8
                    .iter()
                    .flat_map(|text| text.chars())
                    .filter(|c| !c.is_control())
                    .collect();
                if text.is_empty() {
                    Action::Ignore
                } else {
                    Action::Insert(text)
                }
            }
        }
    }

    /// Whether carrying this out needs the command list. These are exactly the
    /// actions that read or act on the match list, and so the ones held back
    /// while the `$PATH` scan is still running. Editing the input line is not
    /// among them: typing works from the first moment the overlay is focused.
    fn needs_catalog(&self) -> bool {
        match self {
            Action::Run | Action::SelectPrevious | Action::SelectNext | Action::Space => true,
            Action::Dismiss | Action::Backspace | Action::Insert(_) | Action::Ignore => false,
        }
    }
}

/// The set of runnable commands, which arrives partway through the launcher's
/// life rather than being there at startup — see the scan thread in `main`.
enum Catalog {
    /// The scan is still running. `deferred` holds, in the order they were
    /// typed, the actions that could not be carried out without the list.
    Loading { deferred: Vec<Action> },
    /// The scan finished and this is what it found.
    Ready(Executables),
}

impl Catalog {
    /// The command list, or `None` while the scan is still running.
    fn executables(&self) -> Option<&Executables> {
        match self {
            Catalog::Ready(executables) => Some(executables),
            Catalog::Loading { .. } => None,
        }
    }
}

/// The launcher's whole live state: Wayland/SCTK plumbing plus the editing model
/// and renderer.
struct App {
    /// Kept so a finished frame can be pushed to the compositor the moment it is
    /// committed, rather than whenever the event loop next comes around.
    conn: Connection,
    registry_state: RegistryState,
    seat_state: SeatState,
    output_state: OutputState,
    shm: Shm,
    compositor: CompositorState,
    layer: LayerSurface,
    pool: SlotPool,
    loop_handle: LoopHandle<'static, App>,
    keyboard: Option<wl_keyboard::WlKeyboard>,
    width: u32,
    height: u32,
    exit: bool,
    catalog: Catalog,
    query: Query,
    renderer: Renderer,
    /// Reused u32 scratch buffer the renderer paints into before we copy it to
    /// the shared-memory buffer.
    scratch: Vec<u32>,
}

impl App {
    /// Handle a key press or repeat. Shared by the initial-press handler and the
    /// key-repeat timer callback so held keys behave identically to taps.
    ///
    /// While the `$PATH` scan is still running, anything that needs the command
    /// list is set aside for [`App::catalog_ready`] to replay. Once something has
    /// been set aside, everything after it is too, so the held keys are applied
    /// in the order they were typed rather than overtaking each other.
    fn on_key(&mut self, event: KeyEvent) {
        let action = Action::of(&event);
        if let Catalog::Loading { deferred } = &mut self.catalog {
            // Dismissal is never held: escape must work whether or not the scan
            // has finished.
            let dismissing = matches!(action, Action::Dismiss);
            if !dismissing && (action.needs_catalog() || !deferred.is_empty()) {
                deferred.push(action);
                return;
            }
        }
        if self.apply(action) {
            self.refresh();
        }
    }

    /// Carry out `action`, returning whether it changed what is on screen.
    fn apply(&mut self, action: Action) -> bool {
        match action {
            Action::Dismiss => {
                self.exit = true;
                false
            }
            Action::Run => {
                self.run();
                false
            }
            Action::Ignore => false,
            Action::Backspace => {
                self.query.backspace();
                true
            }
            Action::SelectPrevious => {
                self.query.select_previous();
                true
            }
            Action::SelectNext => {
                if let Some(executables) = self.catalog.executables() {
                    self.query.select_next(executables);
                }
                true
            }
            Action::Space => {
                if let Some(executables) = self.catalog.executables() {
                    self.query.space(executables);
                }
                true
            }
            Action::Insert(text) => {
                for c in text.chars() {
                    self.query.insert_char(c);
                }
                true
            }
        }
    }

    /// Adopt the finished `$PATH` scan and replay whatever the user typed that
    /// could not be interpreted without it.
    fn catalog_ready(&mut self, executables: Executables) {
        let previous = std::mem::replace(&mut self.catalog, Catalog::Ready(executables));
        let Catalog::Loading { deferred } = previous else {
            // The scan reports exactly once, so this cannot happen.
            return;
        };
        for action in deferred {
            self.apply(action);
            // A held enter launches on replay; there is nothing left to draw.
            if self.exit {
                return;
            }
        }
        // The match list exists now even if nothing was held back.
        self.refresh();
    }

    /// Resolve the current input and launch it, dismissing on success. A query
    /// that matches nothing is a no-op (the launcher stays open).
    fn run(&mut self) {
        let Some(executables) = self.catalog.executables() else {
            return;
        };
        if let Some(invocation) = self.query.resolve(executables) {
            match liftoff::launch(&invocation) {
                Ok(()) => self.exit = true,
                Err(err) => {
                    eprintln!("liftoff: failed to launch {}: {err}", invocation.command);
                }
            }
        }
    }

    /// Build the render view-model from the current query state.
    fn view(&self) -> View {
        match self.query.phase() {
            Phase::Command => {
                // While the scan is still running there is nothing to complete
                // against yet, so the input line shows the typed text alone.
                let Some(executables) = self.catalog.executables() else {
                    return View {
                        typed: self.query.command().to_string(),
                        ghost: String::new(),
                        rows: Vec::new(),
                        selected: 0,
                    };
                };
                let matches = self.query.matches(executables);
                let (rows, selected) = window_rows(&matches, self.query.selected_index());
                View {
                    typed: self.query.command().to_string(),
                    ghost: self.query.completion_suffix(executables),
                    rows,
                    selected,
                }
            }
            Phase::Arguments => View {
                typed: format!("{} {}", self.query.command(), self.query.args()),
                ghost: String::new(),
                rows: Vec::new(),
                selected: 0,
            },
        }
    }

    /// React to a query change by repainting. The surface is a fixed size, so
    /// this never resizes it — only the drawn content changes.
    fn refresh(&mut self) {
        self.draw();
    }

    /// Paint the current state into a fresh shared-memory buffer and present it.
    fn draw(&mut self) {
        let width = self.width;
        let height = self.height;
        let stride = width as i32 * 4;

        let view = self.view();
        // The opaque box is only this tall; the rest of the surface is transparent.
        let content_height = render::height_for(view.rows.len());
        self.scratch.resize((width * height) as usize, 0);

        let (buffer, canvas) = self
            .pool
            .create_buffer(
                width as i32,
                height as i32,
                stride,
                wl_shm::Format::Argb8888,
            )
            .expect("failed to create a buffer");

        self.renderer
            .render(&mut self.scratch, width, height, &view);
        for (chunk, pixel) in canvas.chunks_exact_mut(4).zip(self.scratch.iter()) {
            chunk.copy_from_slice(&pixel.to_le_bytes());
        }

        let surface = self.layer.wl_surface();
        // Restrict pointer input to the visible box so clicks in the transparent
        // region below it reach whatever is beneath the overlay.
        if let Ok(region) = Region::new(&self.compositor) {
            region.add(0, 0, width as i32, content_height as i32);
            surface.set_input_region(Some(region.wl_region()));
        }
        surface.damage_buffer(0, 0, width as i32, height as i32);
        buffer
            .attach_to(surface)
            .expect("failed to attach the buffer");
        self.layer.commit();
        // Push the frame out now. Nothing slow may sit between committing a
        // buffer and the compositor seeing it: for the very first frame that
        // delay is time spent unmapped, and so unfocused, with the user's
        // keystrokes going to the window underneath.
        let _ = self.conn.flush();
    }
}

impl CompositorHandler for App {
    fn scale_factor_changed(
        &mut self,
        _conn: &Connection,
        _qh: &QueueHandle<Self>,
        _surface: &wl_surface::WlSurface,
        _new_factor: i32,
    ) {
    }

    fn transform_changed(
        &mut self,
        _conn: &Connection,
        _qh: &QueueHandle<Self>,
        _surface: &wl_surface::WlSurface,
        _new_transform: wl_output::Transform,
    ) {
    }

    fn frame(
        &mut self,
        _conn: &Connection,
        _qh: &QueueHandle<Self>,
        _surface: &wl_surface::WlSurface,
        _time: u32,
    ) {
    }

    fn surface_enter(
        &mut self,
        _conn: &Connection,
        _qh: &QueueHandle<Self>,
        _surface: &wl_surface::WlSurface,
        _output: &wl_output::WlOutput,
    ) {
    }

    fn surface_leave(
        &mut self,
        _conn: &Connection,
        _qh: &QueueHandle<Self>,
        _surface: &wl_surface::WlSurface,
        _output: &wl_output::WlOutput,
    ) {
    }
}

impl OutputHandler for App {
    fn output_state(&mut self) -> &mut OutputState {
        &mut self.output_state
    }

    fn new_output(
        &mut self,
        _conn: &Connection,
        _qh: &QueueHandle<Self>,
        _output: wl_output::WlOutput,
    ) {
    }

    fn update_output(
        &mut self,
        _conn: &Connection,
        _qh: &QueueHandle<Self>,
        _output: wl_output::WlOutput,
    ) {
    }

    fn output_destroyed(
        &mut self,
        _conn: &Connection,
        _qh: &QueueHandle<Self>,
        _output: wl_output::WlOutput,
    ) {
    }
}

impl LayerShellHandler for App {
    fn closed(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _layer: &LayerSurface) {
        self.exit = true;
    }

    fn configure(
        &mut self,
        _conn: &Connection,
        _qh: &QueueHandle<Self>,
        _layer: &LayerSurface,
        configure: LayerSurfaceConfigure,
        _serial: u32,
    ) {
        // A zero dimension means "you choose"; keep our current value there.
        self.width = NonZeroU32::new(configure.new_size.0).map_or(self.width, NonZeroU32::get);
        self.height = NonZeroU32::new(configure.new_size.1).map_or(self.height, NonZeroU32::get);
        self.draw();
        // That frame is on the wire, so the compositor can map us and hand over
        // keyboard focus. Loading fonts is the other expensive piece of startup;
        // now is the moment for it — after the commit, and while the user is
        // still reacting to the overlay appearing. The opening frame is an empty
        // input line, which needs no fonts at all.
        self.renderer.load_fonts();
    }
}

impl SeatHandler for App {
    fn seat_state(&mut self) -> &mut SeatState {
        &mut self.seat_state
    }

    fn new_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}

    fn new_capability(
        &mut self,
        _conn: &Connection,
        qh: &QueueHandle<Self>,
        seat: wl_seat::WlSeat,
        capability: Capability,
    ) {
        if capability == Capability::Keyboard && self.keyboard.is_none() {
            let keyboard = self
                .seat_state
                .get_keyboard_with_repeat(
                    qh,
                    &seat,
                    None,
                    self.loop_handle.clone(),
                    Box::new(|state: &mut App, _kbd, event| state.on_key(event)),
                )
                .expect("failed to create a keyboard with repeat");
            self.keyboard = Some(keyboard);
        }
    }

    fn remove_capability(
        &mut self,
        _conn: &Connection,
        _: &QueueHandle<Self>,
        _: wl_seat::WlSeat,
        capability: Capability,
    ) {
        if capability == Capability::Keyboard {
            if let Some(keyboard) = self.keyboard.take() {
                keyboard.release();
            }
        }
    }

    fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
}

impl KeyboardHandler for App {
    fn enter(
        &mut self,
        _: &Connection,
        _: &QueueHandle<Self>,
        _: &wl_keyboard::WlKeyboard,
        _surface: &wl_surface::WlSurface,
        _: u32,
        _: &[u32],
        _keysyms: &[Keysym],
    ) {
    }

    fn leave(
        &mut self,
        _: &Connection,
        _: &QueueHandle<Self>,
        _: &wl_keyboard::WlKeyboard,
        _surface: &wl_surface::WlSurface,
        _: u32,
    ) {
    }

    fn press_key(
        &mut self,
        _conn: &Connection,
        _qh: &QueueHandle<Self>,
        _: &wl_keyboard::WlKeyboard,
        _: u32,
        event: KeyEvent,
    ) {
        self.on_key(event);
    }

    fn release_key(
        &mut self,
        _: &Connection,
        _: &QueueHandle<Self>,
        _: &wl_keyboard::WlKeyboard,
        _: u32,
        _event: KeyEvent,
    ) {
    }

    // Repeats are delivered through the callback registered with
    // `get_keyboard_with_repeat`, not through this method.
    fn repeat_key(
        &mut self,
        _: &Connection,
        _: &QueueHandle<Self>,
        _: &wl_keyboard::WlKeyboard,
        _: u32,
        _event: KeyEvent,
    ) {
    }

    fn update_modifiers(
        &mut self,
        _: &Connection,
        _: &QueueHandle<Self>,
        _: &wl_keyboard::WlKeyboard,
        _serial: u32,
        _modifiers: Modifiers,
        _raw_modifiers: RawModifiers,
        _layout: u32,
    ) {
    }
}

impl ShmHandler for App {
    fn shm_state(&mut self) -> &mut Shm {
        &mut self.shm
    }
}

delegate_compositor!(App);
delegate_output!(App);
delegate_shm!(App);
delegate_seat!(App);
delegate_keyboard!(App);
delegate_layer!(App);
delegate_registry!(App);

impl ProvidesRegistryState for App {
    fn registry(&mut self) -> &mut RegistryState {
        &mut self.registry_state
    }
    registry_handlers![OutputState, SeatState];
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A key press as the compositor would report it: the keysym plus whatever
    /// text it resolved to (empty for keys that produce none, such as modifiers).
    fn press(keysym: Keysym, utf8: &str) -> KeyEvent {
        KeyEvent {
            time: 0,
            raw_code: 0,
            keysym,
            utf8: Some(utf8.to_string()),
        }
    }

    /// Typing is interpreted from the text the compositor resolved, so a shifted
    /// key arrives as its capital and goes in as one.
    #[test]
    fn a_printable_key_inserts_its_text() {
        assert!(matches!(
            Action::of(&press(Keysym::F, "F")),
            Action::Insert(text) if text == "F"
        ));
    }

    /// Keys that produce no text — modifiers above all — do nothing rather than
    /// inserting an empty string and forcing a repaint.
    #[test]
    fn a_modifier_press_is_ignored() {
        assert!(matches!(
            Action::of(&press(Keysym::Shift_L, "")),
            Action::Ignore
        ));
    }

    /// Control characters are not text to type: a stray one must not reach the
    /// input line.
    #[test]
    fn control_characters_are_not_inserted() {
        assert!(matches!(
            Action::of(&press(Keysym::Linefeed, "\n")),
            Action::Ignore
        ));
    }

    /// The keys with dedicated meanings keep them.
    #[test]
    fn named_keys_map_to_their_actions() {
        assert!(matches!(
            Action::of(&press(Keysym::Escape, "")),
            Action::Dismiss
        ));
        assert!(matches!(
            Action::of(&press(Keysym::Return, "")),
            Action::Run
        ));
        assert!(matches!(
            Action::of(&press(Keysym::KP_Enter, "")),
            Action::Run
        ));
        assert!(matches!(
            Action::of(&press(Keysym::BackSpace, "")),
            Action::Backspace
        ));
        assert!(matches!(
            Action::of(&press(Keysym::Up, "")),
            Action::SelectPrevious
        ));
        assert!(matches!(
            Action::of(&press(Keysym::Down, "")),
            Action::SelectNext
        ));
        assert!(matches!(Action::of(&press(Keysym::Tab, "")), Action::Space));
        assert!(matches!(
            Action::of(&press(Keysym::space, " ")),
            Action::Space
        ));
    }

    /// Editing the input line never waits on the `$PATH` scan: whatever the user
    /// types while it runs is applied and shown straight away. Dismissal must not
    /// wait either — escape works from the moment the overlay is focused.
    #[test]
    fn editing_and_dismissal_do_not_need_the_command_list() {
        assert!(!Action::of(&press(Keysym::F, "F")).needs_catalog());
        assert!(!Action::of(&press(Keysym::BackSpace, "")).needs_catalog());
        assert!(!Action::of(&press(Keysym::Escape, "")).needs_catalog());
        assert!(!Action::of(&press(Keysym::Shift_L, "")).needs_catalog());
    }

    /// Everything that reads the match list has to wait for it, and so is held
    /// and replayed rather than being acted on against an empty list.
    #[test]
    fn match_list_actions_need_the_command_list() {
        assert!(Action::of(&press(Keysym::Return, "")).needs_catalog());
        assert!(Action::of(&press(Keysym::Up, "")).needs_catalog());
        assert!(Action::of(&press(Keysym::Down, "")).needs_catalog());
        assert!(Action::of(&press(Keysym::Tab, "")).needs_catalog());
        assert!(Action::of(&press(Keysym::space, " ")).needs_catalog());
    }
}