Skip to content

Repository files navigation

Rori

CI Gem Version

Try the live demo → (a desktop browser; it resets every night, so break anything you like)

A niri-style window manager for Rails pages — a "desk" of windows. Every page opens as a window in endlessly scrolling column strips, one strip per workspace; ⌘K and a drop-down terminal (`) run one fuzzy-matched command tree; a keymap with a configurable modifier (⌘ in a native shell) and optional hover keys drive the windows. Ghostty themes and Unsplash wallpapers included.

From an empty desk: ⌘K opens pages as windows, focus moves along the strip, the overview zooms out, and the terminal switches the theme

A Rails engine (not isolated): it wraps the host's own controllers and views.

See it wired into a small app in rori-demo: configuration, window options on pages, a server-side command with a background job, and a Tauri macOS wrapper.

A tour

Windows in strips: columns of any width, windows stacked inside them, the strip running off the edges; one strip per workspace.

A workspace with a users list, two users stacked in one column, and a service running off the right edge

⌘K matches whole paths through nested lists: uthen finds UI › Theme › Nord.

The command palette with "uthen" matching theme entries

The terminal (`) runs the same commands as words, with Tab completion and history. Server-side commands can ask first and report back as notifications.

The drop-down terminal after a confirmed Reindex search, with its notification

Overview (⌥O) zooms out to every workspace.

Overview of three workspaces

Hover keys (opt-in): point at an inactive window and bare keys act on it (W closes, R cycles its width, ⇧1–9 sends it to a workspace) while the cursor stays in the field you're typing in.

A window marked "keys → here" beside a form whose field has focus

Themes: bundled Ghostty palettes, or your own theme files.

The same desk in Rosé Pine Dawn

Every shortcut in one place (⌥?), generated from the live keymap.

The keyboard shortcuts modal

Requirements

Rails 8 with Propshaft, importmap, Turbo and Stimulus.

Your app's views stay whatever they are — ERB, Phlex, anything. Only the engine's own views are written in HAML, so the haml gem comes along as a dependency; you never have to write a line of it.

Why HAML? The desk's views are nothing but nesting, and HAML makes nesting the syntax: no closing tags, no towers of <% end %>. The ERB version had more angle brackets than a Rust signature with lifetimes — fn render<'a, T: View + 'a>(v: &'a T) -> Result<Box<dyn Html + 'a>, Error> — and nobody should have to read that at 2 a.m.

Wiring it into an app

# Gemfile
gem "rori"
bundle install
bin/rails g rori:install

The generator adds config/initializers/rori.rb, includes Rori::Windowed in ApplicationController, draws the desk's routes (and makes the blank desk the root, unless you have one), and registers the Stimulus controllers. Run it again any time; steps already in place are skipped. By hand, that's:

# app/controllers/application_controller.rb — every page renders as a window
class ApplicationController < ActionController::Base
  include Rori::Windowed
end

# config/routes.rb
Rails.application.routes.draw do
  draw :rori                  # ⌘K / terminal command lists
  root "rori/desktops#show"   # the blank desk
end

# config/initializers/rori.rb
Rori.configure do |rori|
  rori.records = %w[ User Project ]   # recent records listed in ⌘K
end
// app/javascript/controllers/index.js
import { registerRori } from "rori"
registerRori(application)

Pages declare how they're shown with window size:, mode:, workspace:, key: (see Rori::WindowHelper); links that should open a window use rori_link_to. A parameterless GET route shows up in ⌘K once it has a label under rori.commands.routes.<controller>.<action> in the app's locale.

CSS

The desk's CSS lives in its own cascade layer, rori (sub-layers rori.reset, rori.tokens, rori.base, rori.layout, rori.components, rori.themes), so it never mixes into the host's layers. Place it among yours with one name:

@layer reset, base, rori, layout, components, utilities;

Unmentioned, it lands after your layers (it loads later).

It needs nothing from the host: its components use only --rori-* tokens, each falling back from a host token when present — --color-canvas, --color-surface, --color-ink, --color-ink-muted, --color-line, --color-primary, --color-on-primary, --color-success, --color-danger, --color-hover, --color-backdrop, --gap, --radius, --radius-sm, --ease, --motion, --font-sans, --font-mono — to a built-in default. Themes set those host tokens, so they re-colour the host's pages as well.

What the host provides

  • Page styles (buttons, forms, tables); the desk styles only its chrome.
  • Head tags (favicons…): override app/views/layouts/rori/_head.html.haml.

Configuration

See lib/rori.rb for everything: app_name, records, commands, keymap, modifier / native_modifier / native_user_agent, hover_keys, terminal_key, wallpapers, theme and wallpaper files and cookies.

Records in ⌘K

Rori.configure do |rori|
  rori.records = %w[ User Project ]
  rori.record_limit = 25   # the default
end

Each time ⌘K opens (and when the terminal loads its commands), the desk lists the record_limit most recently updated records of every model named here, so typing “ada” jumps straight to that user. An entry is labelled with record.to_s, grouped by Model.model_name.human, and opens polymorphic_path(record) in a window. Each model therefore needs:

  • an updated_at column (the list is ordered by it);
  • a show route, e.g. resources :users (polymorphic_path raises without one);
  • a meaningful to_s, or the label reads #<User:0x…>.

It's a shortcut to recent work, not a search: older records don't appear, and each model costs one query per open. Leaving a model out only drops its records from ⌘K; its pages still open from links, and its index can still be listed through a route label (rori.commands.routes.<controller>.index).

Server-side commands and notifications

Rori.configure do |rori|
  rori.command :reindex_search, confirm: true do
    SearchReindexJob.perform_later
    I18n.t("search.reindex_started")   # optional: the notification's text (default "Done")
  end
end
en:
  rori:
    commands:
      custom:
        reindex_search: Reindex search

The command shows up in ⌘K and the terminal under Run. Picking it POSTs to /rori/commands/runs, runs the block and shows the result as a corner notification (errors stay until dismissed). confirm: true asks first: ↵ twice in ⌘K, y in the terminal. The block runs inside the request, so hand slow work to a job, which can report back from anywhere:

Rori.notify "Search reindexed", "1,204 records", kind: :success   # :info, :success, :error

Rori.notify broadcasts over Action Cable to Rori.notifications_stream, which every open desk subscribes to — all users see it, so keep it to single-user or admin desks.

Your own themes and wallpapers

Rori.configure do |rori|
  rori.themes_folder = "config/themes"   # Ghostty theme files (relative to Rails.root)
  rori.wallpapers = true
  rori.wallpapers_folder = "wallpapers"  # app/assets/images/wallpapers/*.{jpg,png,webp,avif}
end
  • Themes: any Ghostty theme file (e.g. from iTerm2-Color-Schemes/ghostty) dropped into the folder shows up in UI › Theme next to the bundled ones; a file with a bundled theme's name replaces it. No build step — their CSS is rendered into the page.
  • Wallpapers: the folder is a path inside the app's asset load path, so images are fingerprinted and served by Propshaft. When set, its images replace the bundled Unsplash photos.
  • UI › Wallpaper › Next wallpaper swaps the photo in place; Pin wallpaper keeps the current one across launches (toggle).

The bundled themes are compiled into the gem's themes.css; after changing Rori.themes_directory, run bin/rails rori:themes:build.

Customising the look

The desk's chrome reads only its own --rori-* tokens, and each one defaults to your token of the same meaning. So there are three levels, from broad to precise:

  1. Your design tokens — set them as usual; the desk follows:
    :root { --radius: 4px; --radius-sm: 2px; --gap: 8px; --font-sans: "Inter", sans-serif; }
    Supported: --color-canvas|surface|ink|ink-muted|line|primary|on-primary|success|danger|hover|backdrop, --gap, --radius, --radius-sm, --ease, --motion, --font-sans, --font-mono.
  2. Desk-only tokens — change the desk without touching your pages:
    @layer overrides {        /* any layer AFTER `rori`, or unlayered */
      :root { --rori-radius: 0; --rori-gap: 16px; }
    }
  3. Components — every chrome element has a rori- class (.rori-win, .rori-win__bar, .rori-menubar, .rori-minimap, .rori-palette, .rori-terminal, .rori-col…):
    @layer overrides {
      .rori-win { border-radius: 0; box-shadow: none; }
      .rori-win__bar { height: 26px; }
    }

The one rule: overrides must come after the rori layer. Cascade layers beat specificity, so a rule in a layer before rori loses even with a more specific selector — and so does a --rori-* token set there, since rori.tokens defines them on :root too. Put overrides in a later layer (components, utilities, a dedicated overrides) or leave them unlayered. Overriding your own tokens (level 1) works from any layer, because the desk only reads them.

Themes set the --color-* tokens, so a theme picked in UI › Theme wins over level-1 colours; use level 2 (--rori-ink, --rori-surface…) for colours a theme mustn't change.

Development

bundle install
bundle exec rake test     # models, controllers, the generator, against test/dummy
bundle exec rubocop
bin/rails server          # the dummy app, for poking at the desk

To work on the gem inside a real app, use the GitHub source in the app's Gemfile (gem "rori", github: "varyform/rori", branch: "main") and point Bundler at your checkout: bundle config set --local local.rori /path/to/rori. Releases: see RELEASING.md.

About

A niri-style tiling window manager for Rails apps: every page opens as a window, driven by ⌘K, a terminal and the keyboard. Rails engine on Turbo + Stimulus.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages