Torchform is the desktop environment for Project Minerva β a handheld device with two DSI displays and a custom controller layout. It is built entirely in Rust.
ββββββββββββββββββββββββββββββββββββββββββ
β Upper Display 1920Γ1080 β β apps, shell overlays (DSI-1)
ββββββββββββββββββββββββββββββββββββββββββ€
β Lower Display 640Γ480 β β status, virtual keyboard (DSI-2)
ββββββββββββββββββββββββββββββββββββββββββ
torchform-shell Slint UI process β shell overlays + built-in stub apps
torchform-compositor Smithay Wayland compositor β two outputs, XDG tiling
torchform-inputd Input daemon β Cirque SPI trackpad, USB HID gamepad,
uinput virtual device, Unix socket to shell
torchform-actions Shared lib β ShellAction enum + InputMap keybinds (no Slint)
torchform-config Shared lib β TorchformConfig + settings schema (no Slint)
torchform-settings Standalone Settings app (Slint window, same UI as stub)
torchform-files Standalone File Browser app (Slint window, same UI as stub)
torchform-terminal Terminal launcher β writes themed config then exec()s terminal
torchform-run Universal app launcher β sets Wayland env, then exec()s app
In production all processes run separately. In development, torchform-shell runs standalone (no compositor needed) and reads gamepad/keyboard directly via gilrs + Slint FocusScope.
All physical inputs are mapped to semantic ShellAction variants (defined in torchform-actions) before reaching the shell's event handlers. Raw button/axis names are never hardcoded in shell logic.
The mapping is controlled by ~/.config/torchform/keybinds.toml (or /etc/torchform/keybinds.toml, or config/keybinds.toml in the repo). If no file is found, built-in defaults are used. See config/keybinds.toml for the full default table.
Key ShellAction variants:
| Variant | Meaning |
|---|---|
Confirm / Cancel |
Accept / dismiss |
NavUp / NavDown / NavLeft / NavRight |
D-pad direction |
OpenPalette / OpenSwitcher |
Toggle overlays |
RadialHold { held } |
Radial menu open (true) / close (false) |
StickMoved { x, y } |
Analog stick (bypasses map) |
WorkspacePrev / WorkspaceNext |
Cycle workspaces |
BrightnessUp/Down, VolumeUp/Down, WifiToggle, β¦ |
System actions |
| Role | Intended binary | Fallback / stub |
|---|---|---|
| Terminal | alacritty |
kitty (configured via config.toml [apps] terminal) |
| File Manager | torchform-files (native Slint app) |
in-shell stub |
| Settings | torchform-settings (native Slint app) |
in-shell stub |
| Web Browser | Servo (embedded) | chromium --kiosk --ozone-platform=wayland |
| Media Player | mpv --player-operation-mode=pseudo-gui |
in-shell stub |
Native (recommended if you have Rust installed):
# Rust β install from https://rustup.rs if not present
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# System libs needed for the full workspace
# Debian/Ubuntu:
sudo apt-get install \
libseat-dev libinput-dev libgbm-dev libdrm-dev \
libudev-dev libxkbcommon-dev libwayland-dev \
libx11-dev libx11-xcb-dev libevdev-dev \
pkg-config build-essentialAlpine Linux (on-device / aarch64):
# One-time setup β install build deps
apk add --no-cache \
rust \
clang \ #or gcc
libalsa-dev \
eudev-dev \
libinput-dev \
mesa-dev \
libdrm-dev \
wayland-dev \
libx11-dev \
libxcb-dev \
libevdev-dev \
libseat-dev
# Then build normally
cargo build --release -p torchform-shellNote: Alpine ships
eudev(not systemd'slibudev). The packageeudev-devprovideslibudev.pcand satisfies thelibudev-syscrate. Ifapkreportslibseat-devnot found, tryseatd-devinstead.
Docker (no host libs needed):
make dev-build # build the dev image (one-time, ~5 min)
make dev # enter interactive container shell
# then run any cargo / make command from insideThe emulator is a single window that shows both displays inside a hardware-frame β like a DS emulator. This is the primary way to develop and test.
make run-emulator # default β both screens, palette open
make run-shell-radial # start with radial menu open
make run-shell-switcher # start with app switcher open
make run-shell-idle # start with lower screen in idle/status modeOr with cargo directly:
cargo run -p torchform-shell
cargo run -p torchform-shell -- --demo radial
cargo run -p torchform-shell -- --demo switcher
cargo run -p torchform-shell -- --demo idle| Key | Maps to | Action |
|---|---|---|
Space |
Select | Toggle command palette |
Enter |
Start | Open app switcher |
Tab (hold) |
L2 (hold) | Open radial menu |
Tab release |
L2 release | Activate radial selection / exit |
Esc |
B | Dismiss overlay / close app |
A |
A (confirm) | Select focused item |
| Arrow keys | D-pad | Navigate UI elements / app rows |
I / K |
Stick North/South | Point radial menu up/down |
J / L |
Stick West/East | Point radial menu left/right |
The stick keys set a fixed deflection of 0.8 magnitude. Releasing any of them sends a zero vector (stick-release), which, when L2 is held, dismisses the radial without activating anything.
make run-standalone
cargo run -p torchform-shell -- --standalone
cargo run -p torchform-shell -- --standalone --demo radialmake check # fast type-check β torchform-shell only (no hardware libs)
make build # debug build β torchform-shell only
make build-release # release build β torchform-shell only
make build-all # full workspace (needs libseat/libinput/libgbm/libdrm)
make check-all # full workspace type-checkOn a system where Torchform is the compositor (Raspberry Pi CM5, or nested in another compositor via Winit):
# Needs /dev/uinput write access β add user to 'input' group or run as root
torchform-inputdIt will:
- Open
/dev/spidev0.0for the Cirque trackpad (skips gracefully if absent) - Scan
/dev/input/event*for the USB HID gamepad - Create a uinput virtual gamepad
- Listen on
/run/torchform/inputd.sockfor the shell to connect
# Development / QEMU β runs inside an X11 or Wayland window
TORCHFORM_BACKEND=winit torchform-compositor
# Production β DRM/KMS direct to hardware (stub, full integration pending)
TORCHFORM_BACKEND=udev torchform-compositortorchform-shellThe shell connects to $WAYLAND_DISPLAY as a Wayland client and to /run/torchform/inputd.sock for decoded input events.
Torchform/
βββ Cargo.toml workspace (9 crates)
βββ Makefile dev targets
βββ config/
β βββ torchform.toml default DE config
β βββ keybinds.toml default input β action bindings
βββ scripts/
β βββ send-input.py mock gamepad event sender
β βββ dev.sh dev helpers
βββ crates/
βββ torchform-actions/ β shared lib, NO Slint dependency
β βββ src/
β βββ lib.rs re-exports
β βββ action.rs ShellAction enum (serde Serialize/Deserialize)
β βββ input_map.rs InputMap β RawInput β ShellAction via keybinds.toml
βββ torchform-config/ β shared lib, NO Slint dependency
β βββ src/
β βββ lib.rs re-exports
β βββ config.rs TorchformConfig β full TOML config tree
β βββ settings.rs Settings schema (SCHEMA), SettingsRowData,
β apply_activation, apply_adjustment, focus helpers
βββ torchform-shell/
β βββ build.rs slint_build β compiles ui/main.slint
β βββ src/
β β βββ main.rs emulator + standalone modes; uses ShellAction
β β βββ apps.rs try_launch_external β spawns Wayland apps on real DE
β β βββ palette.rs command palette state machine + registry
β β βββ radial.rs radial menu state machine + stick navigation
β β βββ settings.rs re-exports from torchform_config
β β βββ config.rs re-exports from torchform_config + parse_color
β β βββ workspace.rs workspace / tiling config
β βββ ui/
β βββ main.slint single compile entry point
β βββ tokens.slint design system (colours, typography)
β βββ shell.slint upper display overlay (standalone mode)
β βββ lower_screen.slint lower companion display (standalone mode)
β βββ emulator.slint combined DS-frame window (default mode)
β βββ radial_menu.slint radial overlay component
β βββ command_palette.slint command palette overlay
β βββ app_switcher.slint app switcher overlay
β βββ app_settings.slint Settings panel (shared by shell + torchform-settings)
β βββ app_files.slint Files panel (shared by shell + torchform-files)
βββ torchform-settings/ β standalone Settings Slint app
β βββ build.rs
β βββ ui/main.slint SettingsWindow β embeds AppSettings from shell/ui
β βββ src/main.rs full nav + activation callbacks
βββ torchform-files/ β standalone File Browser Slint app
β βββ build.rs
β βββ ui/main.slint FilesWindow β embeds AppFiles from shell/ui
β βββ src/main.rs filesystem helpers + callbacks
βββ torchform-terminal/ β terminal launcher
β βββ src/main.rs writes themed alacritty.toml or kitty.conf,
β then exec()s the terminal binary
β NOTE: Intended terminal: Alacritty (https://alacritty.org)
β Fallback terminal: Kitty (https://sw.kovidgoyal.net/kitty/)
β Configured via: config.toml [apps] terminal = "alacritty"
βββ torchform-run/ β universal app launcher
β βββ src/main.rs sets Wayland env vars (SDL, Qt, GDK, MOZ, EGL)
β then exec()s the target binary
βββ torchform-compositor/
β βββ src/
β βββ main.rs event loop, Winit + UDev backends
β βββ compositor.rs TorchState β all Smithay delegate impls
β βββ display.rs TorchOutput β dual output management
β βββ input.rs InputAction, ChordTracker, evdev mappings
βββ torchform-inputd/
βββ src/
βββ main.rs input loop; translates chord::Action β ShellAction
β via InputMap, publishes JSON over Unix socket
βββ chord.rs ChordDetector, low-level Action, D-pad repeat
βββ cirque.rs Cirque SPI driver + kernel evdev fallback
βββ uinput.rs VirtualGamepad (uinput axes + buttons)
| Token | Value | Usage |
|---|---|---|
bg-base |
#0d0f14 |
All backgrounds |
bg-surface |
#1a1d24 |
Cards, overlays |
bg-elevated |
#23273a |
Highlighted rows |
accent |
#00d4ff |
Focus rings, active items |
accent-dim |
#007a99 |
Secondary accent |
text-primary |
#e8eaf0 |
Body text |
text-secondary |
#8892a4 |
Labels, hints |
All physical inputs are mapped through InputMap (loaded from keybinds.toml) to ShellAction variants before any shell logic sees them. To remap a button, edit ~/.config/torchform/keybinds.toml.
| Raw input name | ShellAction |
Meaning |
|---|---|---|
button_a |
Confirm |
Select focused item |
button_b |
Cancel |
Back / dismiss / close app |
button_select |
OpenPalette |
Toggle command palette |
button_start |
OpenSwitcher |
Toggle app switcher |
l2_hold_true |
RadialHold { held: true } |
Open radial menu |
l2_hold_false |
RadialHold { held: false } |
Close radial (commit if stick active) |
r2_hold_true |
RadialHold { held: true } |
Same as L2 |
r2_hold_false |
RadialHold { held: false } |
Same as L2 |
dpad_up |
NavUp |
Move focus up |
dpad_down |
NavDown |
Move focus down |
dpad_left |
NavLeft |
Move focus left / decrease slider |
dpad_right |
NavRight |
Move focus right / increase slider |
l1 |
WorkspacePrev |
Previous workspace |
r1 |
WorkspaceNext |
Next workspace |
select_long |
Sleep |
Suspend device |
| (analog axes) | StickMoved { x, y } |
Steer radial menu (bypass map) |
| (Cirque pad) | PadMoved { x, y } |
Trackpad position (bypass map) |
There are two ways to add a program: external Wayland apps and built-in stub panels.
An external app is a normal Wayland client (GTK, Qt, Slint, terminal, etc.) that the compositor tiles on the upper display. This is how production apps ship.
When the user selects a command from the palette, main.rs calls apps::try_launch_external(command_id). If the binary is found and spawned successfully, control returns to the shell. The child process inherits WAYLAND_DISPLAY from the compositor, so it connects automatically.
On the emulator / Docker where the binary is not present, spawn() returns Err and try_launch_external returns false. The shell then falls through to show a built-in stub panel instead (see Option B).
- Register a palette entry in
palette.rs::default_commands():
PaletteEntry {
id: "app.myapp".into(),
label: "My App".into(),
description: "What it does".into(),
category: "App".into(),
icon: "π§".into(),
shortcut: "".into(),
},- Add a spawn arm in
apps.rs::try_launch_external():
"app.myapp" => {
std::process::Command::new("my-wayland-binary")
.spawn()
.is_ok()
}The binary only needs to exist on the real device. On the emulator spawn() fails and false is returned, which triggers the built-in stub (step 3) if you write one.
- Optionally add a built-in stub (see Option B below) for emulator testing.
A built-in app is a Slint Rectangle component that renders directly inside the upper-display area of the shell window. Use this for:
- Apps that need to work in the emulator / Docker (no Wayland compositor)
- Simple data views (settings, file browser, status) that don't need a separate process
- Prototyping before writing the full Wayland app
1. Create the Slint component as a new file, e.g. ui/app_myapp.slint:
import { Tokens } from "tokens.slint";
// IMPORTANT: inherits Rectangle, not Window.
// Window creates a separate OS window; Rectangle embeds inside the shell frame.
export component AppMyApp inherits Rectangle {
background: Tokens.bg-base;
// Row navigation β set by the shell when D-pad is pressed
in property <int> focused-row: 0;
// B button / close button fires this callback
callback close-requested();
// Your UI here
Text {
text: "My App";
color: Tokens.text-primary;
font-size: Tokens.text-lg;
horizontal-alignment: center;
vertical-alignment: center;
}
}2. Import it in emulator.slint (and shell.slint for standalone mode):
import { AppMyApp } from "app_myapp.slint";Add properties and a close callback:
in property <bool> app-myapp-visible: false;
in property <int> app-myapp-focused-row: 0;
callback app-closed(); // already present β reuse thisAdd the conditional panel inside the upper display Rectangle (before the overlay layers):
if app-myapp-visible: AppMyApp {
x: 0; y: 0;
width: parent.width; height: parent.height;
focused-row: app-myapp-focused-row;
close-requested => { root.app-closed(); }
}3. Add an ActiveApp variant in main.rs:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ActiveApp { Settings, Files, MyApp }4. Add state fields to ShellApp if you need row tracking:
myapp_row: i32,5. Wire the palette command in emu_handle_event's ButtonA handler:
"app.myapp" => {
app.active_app = Some(ActiveApp::MyApp);
app.myapp_row = 0;
}6. Update emu_apply_apps to set/clear the visible property:
Some(ActiveApp::MyApp) => {
emu.set_app_myapp_visible(true);
emu.set_app_myapp_focused_row(app.myapp_row);
emu.set_app_settings_visible(false);
emu.set_app_files_visible(false);
}Also clear it in the None arm.
7. Add D-pad navigation in DpadUp/DpadDown match arms:
Some(ActiveApp::MyApp) => {
app.myapp_row = (app.myapp_row + delta).clamp(0, MAX_ROWS);
emu.set_app_myapp_focused_row(app.myapp_row);
}8. Repeat steps 2β7 for shell.slint and sa_handle_event / sa_apply_apps if you want the app to work in standalone mode too.
File: ui/app_settings.slint
Command ID: app.settings (also matches settings, open-settings)
External binary: gnome-control-center (tried first on real DE; falls back to stub)
A stub settings panel with a scrollable list of placeholder rows. Each row is navigable with D-pad Up/Down. B closes the panel.
State: settings_row: i32 β currently focused row index, clamped to β₯ 0.
File: ui/app_files.slint
Command ID: app.files (also matches file-manager, open-files)
External binary: thunar (tried first; falls back to stub)
A stub file browser showing the current path and a list of entries. Selecting a directory entry fires the navigate(name) callback, which appends the name to the path. D-pad Up/Down moves the row highlight. B closes the panel.
State:
files_row: i32β focused row indexfiles_path: Stringβ current directory path (starts at/home)
Navigation: path is built by appending /{name} to the current path. There is no .. parent navigation yet (see Known Bugs).
All commands registered in palette.rs::default_commands():
| ID | Label | Category | External binary |
|---|---|---|---|
app.settings |
Open Settings | App | gnome-control-center |
app.files |
File Manager | App | thunar |
app.editor |
Text Editor | App | (stub only) |
app.browser |
Web Browser | App | (stub only) |
app.network |
Network Manager | App | (stub only) |
app.gpio |
GPIO Manager | App | (stub only) |
sys.brightness.up |
Brightness Up | System | (not implemented) |
sys.brightness.down |
Brightness Down | System | (not implemented) |
sys.volume.up |
Volume Up | System | (not implemented) |
sys.volume.down |
Volume Down | System | (not implemented) |
sys.wifi.toggle |
Toggle WiFi | System | (not implemented) |
sys.bt.toggle |
Toggle Bluetooth | System | (not implemented) |
sys.sleep |
Sleep | System | (not implemented) |
sys.split.toggle |
Toggle Split Screen | System | (not implemented) |
| Component | Status |
|---|---|
| Shell emulator window | Working β run make run-emulator |
| Keyboard shortcuts | Working (includes IJKL stick simulation) |
| HID gamepad (gilrs) | Working in emulator mode |
| Input virtualization | Working β ShellAction + InputMap + keybinds.toml |
| Radial menu UI | Working β stick steers, release activates/dismisses |
| Command palette UI | Working β D-pad + virtual keyboard, scrolls correctly |
| App switcher UI | Working (display only, no close/switch logic) |
| Settings app | Working β schema-driven, sliders/toggles/selects, scrolls |
| Files app | Working β directory navigation, icons, file sizes |
| torchform-terminal | Builds β writes Alacritty/Kitty theme config, exec()s terminal |
| torchform-settings | Builds β standalone Slint window |
| torchform-files | Builds β standalone Slint window |
| torchform-actions lib | Working β InputMap + ShellAction, 4 unit tests |
| torchform-config lib | Working β full settings schema, mutation helpers |
| Wayland compositor | Builds; Winit backend functional |
| DRM/KMS backend | Stub β pending hardware integration |
| Input daemon | Builds; translates chord::Action β ShellAction via InputMap |
| Cirque SPI driver | Written; requires CM5 hardware |
Location: main.rs (on_app_files_navigate), app_files.slint
The path is built by always appending /{name} to the current path. There is no mechanism to navigate to the parent directory (no .. entry, no backspace). Selecting a file does nothing different from selecting a directory.
Fix needed: Add a .. entry at the top of the file list in app_files.slint. In main.rs, detect .. in the navigate handler and strip the last path component with std::path::PathBuf.
Location: main.rs DpadDown handler, both emu_handle_event and sa_handle_event
The row index is incremented without knowing the number of rows in the Slint component. The focused highlight will appear past the last visible row if D-pad Down is pressed too many times. (The lower bound is correctly clamped at 0.)
Fix needed: Either query the row count from the Slint model or define a MAX_SETTINGS_ROWS / MAX_FILES_ROWS constant and clamp on the way down.
Location: main.rs run_standalone()
on_app_closed and on_app_files_navigate are wired in run_emulator but not in run_standalone. In standalone mode the B button inside an app panel will not close it (the Slint close callback fires into nothing) and folder navigation does not update the path.
Fix needed: Mirror the same two callback registrations from run_emulator into run_standalone, using shell.as_weak() instead of emu.as_weak().
Location: main.rs ButtonA and L2Held(false) handlers
When the user activates a radial slot (via stick-release or A-button), the item label is logged with info!() but no action is taken. System commands (brightness, volume, wifi) have no backend implementation.
Fix needed: Implement a handle_radial_action(item_id: &str) function that dispatches system commands (initially as stubs that log "TODO").
Location: main.rs on_switcher_app_activated, on_switcher_app_closed
The app switcher overlay renders correctly but the switcher-app-activated and switcher-app-closed callbacks are not wired in either run mode. Selecting or closing a card does nothing.
Fix needed: Wire on_switcher_app_activated and on_switcher_app_closed to update WorkspaceManager and dismiss the switcher.
Location: apps.rs, main.rs
These four palette entries have no external binary match in try_launch_external and no ActiveApp variant, so activating them closes the palette and opens nothing.
Fix needed: For each, either add a spawn() call to apps.rs (real DE) or add a stub panel (emulator). At minimum add a try_launch_external arm that attempts to launch a sensible default binary.