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());
}
}