This time I decided to use Slint for something a bit different than Oxide: a keyboard-first app launcher for Linux written in Rust and Slint. I already knew my way around Slint basics, but a launcher has a very different set of problems: global-ish feel, super low latency, and a bunch of tiny UX details that are surprisingly annoying to get right.

The idea is simple: hit a hotkey, type a few letters, press Enter, done. No heavy frameworks, no multi‑second startup. Just a small native binary that gets out of the way.

Wiring up Slint and Rust

Since I’d already gone through the setting up of the build script with Oxide, this time I just copied over the build.rs script.

// build.rs
use std::{env, fs};

fn main() {
    let mut config = slint_build::CompilerConfiguration::new().with_style("qt".into());
    match std::env::consts::OS {
        "windows" => {
            config = config.with_style("fluent".into());
        }

        "linux" => {
            config = config.with_style("cosmic".into());
        }

        "macos" => {
            config = config.with_style("cupertino".into());
        }

        _ => {
            config = config.with_style("qt".into());
        }
    }

    slint_build::compile_with_config("ui/app-window.slint", config).expect("Slint build failed");
}

I will be using the Cosmic style for this project as well, as I think it's the best looking theme as of now.

The UI in Slint started as a very barebones window: a search box and a list of actions. Since this is a launcher, I went for a frameless window so it looks more like an overlay than a regular app.

I still need to make the background transparent, and render the "window" as a Rectangle, so I can add some kind of corner roundness, but I can't be bothered yet.

import { VerticalBox, LineEdit, ScrollView, Text } from "std-widgets.slint";

export struct ActionItem {
    name: string,
    description: string,
}

export component LauncherWindow inherits Window {
    width: 600px;
    height: 400px;
    no-frame: true;

    in-out property <[ActionItem]> actions;

    VerticalBox {
        padding: 16px;

        LineEdit {
            placeholder-text: "Type to search...";
        }

        ScrollView {
            for action[i] in root.actions : Text {
                text: action.name;
            }
        }
    }
}

Nothing fancy, but it was enough to start hooking Rust into it.

Passing actions from Rust into Slint

One of the mildly annoying parts of Slint is remembering the right pattern to pass dynamic data from Rust into the UI. I already used VecModel and ModelRc for Oxide, but I still have to re‑remember the exact boilerplate every time.

For the launcher, each action is basically a name + command:

slint::include_modules!();
use slint::{ModelRc, VecModel};
use std::rc::Rc;

fn main() -> Result<(), slint::PlatformError> {
    let ui = LauncherWindow::new()?;

    let actions = Rc::new(VecModel::from(vec![
        ActionItem {
            name: "Terminal".into(),
            description: "alacritty".into(),
        },
        ActionItem {
            name: "Browser".into(),
            description: "firefox".into(),
        },
    ]));

    ui.set_actions(ModelRc::from(actions.clone()));

    let window = ui.window();
    window.set_decorated(false);

    ui.run()
}

The Rc<VecModel<…>>ModelRc::from() combo is still one of those “I know this is the pattern, but I don’t fully love it” parts. It works, though, and once wired, updating the list is easy.

Spawning commands without freezing the launcher

Launching commands is the whole point, so this part had to feel instant. The obvious way is std::process::Command, but doing it directly from the main thread is a good way to block the UI when launching heavier apps.

The first version was the classic blocking call and, unsurprisingly, it could make the launcher feel sticky. So the fix was just to push it into a separate thread:

use std::process::Command;

fn main() {
    ui.on_linefinished(move |app| {
        std::thread::spawn(move || {
            let _ = Command::new("sh")
                .arg("-c")
                .arg(&cmd)
                .spawn();
        });

        std::process::exit(0);
    }
}

After I tried this, I noticed in GNOME after doing std::process::exit(0), the program didn't actually quit, so I ended up doing something different.

use std::process::Command;

fn main() {
    ui.on_linefinished(move |app| {
        Command::new(app).spawn().unwrap();

        slint::quit_event_loop();

        std::process::exit(0);
    });
}

I instead .spawn() the program, and quit with slint::quit_event_loop().

Nothing fancy, but it keeps Slint’s event loop happy and the UI responsive while the app starts.

The next step is wiring this into a callback from Slint so that when an action is activated (click / Enter), Rust receives the index and runs the corresponding command. That part is still not done, but the core is there.

Config file and “variables everywhere”

I wanted the launcher to be configurable via a TOML file, not hardcoded in Rust. The idea is something like this:

hotkey = "Super+Space"
editor = "nvim"
projects_dir = "/home/user/dev"

[[actions]]
name = "Open dotfiles"
cmd = "$editor ~/dotfiles"

[[actions]]
name = "Open projects folder"
cmd = "thunar $projects_dir"

I still havent gotten to it, but I believe its a good idea to set the grounds from the start to have a fixed way on how it works.

Where Swift Launcher is heading

Right now Swift Launcher is minimal but usable: read config, show actions, run commands, and feel snappy. The stuff on the roadmap that I’m most interested in:[2]

  • Fuzzy search over actions instead of plain substring matching.
  • Reading .desktop files so it can discover system apps automatically. (Already starting this, but not even close to being done)
  • Global hotkey support (this will be “fun” with Wayland). -- Tho this will probably be done through the DE, so I don't think I 'have' to do anything, no idea yet
  • Flatpak packaging maybe at some point, I know launchers in flatpak are kinda fucky, but doesn't hurt to try(?
  • Maybe some small polish like theming and tray integration.

It’s a pretty nice project to glue together things I already know, Rust and Slint, and push them a bit further. I've already thrown it on GitHub at pezfisk/swift-launcher, so if you are reading this, you could contribute :)

Pretty happy with how it’s shaping up so far, and it’s definitely scratching that “daily‑use app” itch.