//! 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 = 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 = event_queue.handle(); let mut event_loop: EventLoop = 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, 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 }, /// 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, 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, } 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, _surface: &wl_surface::WlSurface, _new_factor: i32, ) { } fn transform_changed( &mut self, _conn: &Connection, _qh: &QueueHandle, _surface: &wl_surface::WlSurface, _new_transform: wl_output::Transform, ) { } fn frame( &mut self, _conn: &Connection, _qh: &QueueHandle, _surface: &wl_surface::WlSurface, _time: u32, ) { } fn surface_enter( &mut self, _conn: &Connection, _qh: &QueueHandle, _surface: &wl_surface::WlSurface, _output: &wl_output::WlOutput, ) { } fn surface_leave( &mut self, _conn: &Connection, _qh: &QueueHandle, _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, _output: wl_output::WlOutput, ) { } fn update_output( &mut self, _conn: &Connection, _qh: &QueueHandle, _output: wl_output::WlOutput, ) { } fn output_destroyed( &mut self, _conn: &Connection, _qh: &QueueHandle, _output: wl_output::WlOutput, ) { } } impl LayerShellHandler for App { fn closed(&mut self, _conn: &Connection, _qh: &QueueHandle, _layer: &LayerSurface) { self.exit = true; } fn configure( &mut self, _conn: &Connection, _qh: &QueueHandle, _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, _: wl_seat::WlSeat) {} fn new_capability( &mut self, _conn: &Connection, qh: &QueueHandle, 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, _: 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, _: wl_seat::WlSeat) {} } impl KeyboardHandler for App { fn enter( &mut self, _: &Connection, _: &QueueHandle, _: &wl_keyboard::WlKeyboard, _surface: &wl_surface::WlSurface, _: u32, _: &[u32], _keysyms: &[Keysym], ) { } fn leave( &mut self, _: &Connection, _: &QueueHandle, _: &wl_keyboard::WlKeyboard, _surface: &wl_surface::WlSurface, _: u32, ) { } fn press_key( &mut self, _conn: &Connection, _qh: &QueueHandle, _: &wl_keyboard::WlKeyboard, _: u32, event: KeyEvent, ) { self.on_key(event); } fn release_key( &mut self, _: &Connection, _: &QueueHandle, _: &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, _: &wl_keyboard::WlKeyboard, _: u32, _event: KeyEvent, ) { } fn update_modifiers( &mut self, _: &Connection, _: &QueueHandle, _: &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()); } }