Skip to content

Latest commit

Β 

History

History
613 lines (465 loc) Β· 26.9 KB

File metadata and controls

613 lines (465 loc) Β· 26.9 KB

Torchform DE

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)
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Architecture

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.

Input virtualization

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

Intended external programs

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

Quick start β€” desktop testing (no hardware required)

Prerequisites

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-essential

Alpine 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-shell

Note: Alpine ships eudev (not systemd's libudev). The package eudev-dev provides libudev.pc and satisfies the libudev-sys crate. If apk reports libseat-dev not found, try seatd-dev instead.

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 inside

Run the emulator

The 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 mode

Or 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

Keyboard shortcuts in the emulator window

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.

Run with two separate windows (mirrors physical layout)

make run-standalone
cargo run -p torchform-shell -- --standalone
cargo run -p torchform-shell -- --standalone --demo radial

Build targets

make 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-check

Running as a real DE (target device or QEMU)

On a system where Torchform is the compositor (Raspberry Pi CM5, or nested in another compositor via Winit):

1 β€” Start the input daemon

# Needs /dev/uinput write access β€” add user to 'input' group or run as root
torchform-inputd

It will:

  • Open /dev/spidev0.0 for 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.sock for the shell to connect

2 β€” Start the compositor

# 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-compositor

3 β€” Start the shell

torchform-shell

The shell connects to $WAYLAND_DISPLAY as a Wayland client and to /run/torchform/inputd.sock for decoded input events.


Project layout

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)

Design system

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

Input grammar

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.

Default bindings

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)

Making programs for Torchform

There are two ways to add a program: external Wayland apps and built-in stub panels.

Option A β€” External Wayland app

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.

How it works

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).

Steps to add an external app

  1. 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(),
},
  1. 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.

  1. Optionally add a built-in stub (see Option B below) for emulator testing.

Option B β€” Built-in stub panel

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

Steps to add a built-in stub

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 this

Add 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.


Built-in programs

Settings (app.settings)

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 Manager (app.files)

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 index
  • files_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).


Command palette registry

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)

Status

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

Known bugs

BUG-001 β€” Files stub: no parent-directory navigation

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.


BUG-002 β€” settings_row and files_row have no upper bound

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.


BUG-003 β€” Standalone mode missing on_app_closed and on_app_files_navigate callbacks

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().


BUG-004 β€” Radial menu item activation is logged but never acted upon

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").


BUG-005 β€” App switcher has no close or switch logic

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.


BUG-006 β€” app.editor, app.browser, app.network, app.gpio palette commands do nothing

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.