u/uhs-robert

[Hyprland] Oasis Dots 1.0: Vim modes for the whole desktop, a which-key for everything, and retro game themes/login screens

Sure, I occasionally have to reach for the mouse... but that's fine, right?

No. It is not fine.

I wanted a vim-like experience across my entire desktop: fully keyboard driven, mouse completely optional.

So I started a personal pilgrimage to bring vim motions everywhere, one tool and one problem at a time. Now windows, workspaces, the bar and popups, fuzzy finders, screenshots, tmux sessions, settings, GUI apps, and even the login screen all answer to vim modes and motions.

I've been daily-driving it since 2024, and I just tagged 1.0, the first public release. The longer story of how it got here is in the release notes.

Nothing in the video touches the mouse. It's one live take: no cuts, no speed-ups.

What is in the video

  • 0:00 Login: the video uses the Metal Gear Solid 2 login (press start, load game, pick the save, enter the password); there are also Ocarina of Time, Final Fantasy VII, GoldenEye, TIE Fighter and CRT terminal skins.
  • 0:17 Session launcher: SUPER + SHIFT + O opens a configured session, and its apps land on their assigned workspaces/monitors in their assigned positions: Neovim and Yazi, an AI agent, btop and lazygit, and Firefox.
  • 0:21 Resize mode: SUPER + R enters a modal submap to resize windows, all submaps in Hyprland display their own dynamically generated which-key panel, and SHIFT + L widens the editor.
  • 0:26 Palettes: the Settings panel switches color palettes live using my oasis.nvim theme. The bar, borders, kitty, tmux and Neovim all repaint together.
  • 0:39 Live styles: the whole shell (bar, popups, OSD, cursor, sounds) switches style at runtime. The video goes from Neovim to PSX, then through FF7, GoldenEye, Game Boy, Metroid and Oasis. I show the same calendar, weather and volume OSD in each one. There are 16 styles total.
  • 1:31 Agent kickoff: an agent gets a task in tmux and keeps working while the rest of the video plays.
  • 1:36 Workspace overview: every monitor and workspace in one view. Hop across monitors with hjkl, mark two windows and move them to another workspace together, then search for a window by name and jump to it.
  • 1:49 Screenshot tool: region selection with hjkl in 100, 10 and 1 px steps, a zoom loupe for pixel-perfect edge selection, then copy.
  • 2:02 Command prompt: HyprVim's : prompt. :layout, Tab down to select scrolling, and the workspace re-tiles live. Includes all Hyprland commands for one-off changes and some custom shortcuts of my own.
  • 2:10 KeepTabs: a popup that lists the running AI agent sessions with cute little animations to display their state, with a jump straight to the one that finished. See KeepTabs.

What is NOT in the video

One feature I'm proud of that I couldn't fit in the video: the wallpapers follow the world outside. Bring your own art in folders by season, time of day, and weather (like Fall/Night/Rain), and it rotates to match, so a rainy fall evening will most likely get a rainy fall evening scene. The beach in the video is from my Summer Evening set.

Also not in the video:

  • SUPER + C drives the mouse from the keyboard, with vimium-style hint labels to click anything in a couple of keystrokes.
  • Bar popups you walk between with CTRL + h/l, including voice dictation.
  • Screen recording, scrolling capture with OCR, and a color picker.
  • Fuzzy pickers for apps, clipboard and emoji.
  • A vim-style NORMAL and INSERT model on the lock and login screens.
  • Email in Betterbird with vim motions, a which-key, visual selection and a : command line.
  • A screen-share picker, a tmux overview, and keybind search that runs what you pick (SUPER + /).
  • A Settings panel covering everything from displays to the login screen.

Before you ask

  • Is that a real login?
    • Yes. The greeter runs under greetd and reuses the lock screen skins. So, each user is a "Save file" you can select and authenticate with. The video uses a preview mode for recording convenience.
  • Can I use just the shell?
    • Yes. The repo is a set of GNU Stow packages, so you can take the quickshell and hypr packages without the rest. The shell is written for this Hyprland config and talks to it over IPC, and the installer is Arch only.
  • Did you use AI?
    • Yes! I'm a dad running a business, so recreating the entire MGS2 intro by hand was never going to happen. AI agents built a lot of this with me, especially the Quickshell side. The ideas, design and taste are all mine; the agents just got me there without giving up family time or the business.
  • Wallpaper? Audio?
    • See Credits below!

Credits

Resources

Dotfiles: <https://github.com/uhs-robert/oasis-dots>

Discord: <https://discord.gg/b7y5CGVGTB>

Is my pilgrimage over? I don't think it ever will be. If there's a vim feature you think is missing, file an issue or tell me about it on Discord. Happy to answer any questions.

I hope you enjoy the fruits of my labor, and welcome to the Oasis.

u/uhs-robert — 8 days ago
▲ 143 r/hyprland

HyprVim v2.0: Ported to Lua with Vim modes + which-key + registers + excommands for Hyprland

HyprVim brings Vim-style modal keybindings to your Hyprland desktop: NORMAL, INSERT, VISUAL, and OPERATORS for text editing and Hyprland window management with a which-key HUD, registers, count multipliers, and the ability to run excommands.

This is a huge update as porting to Lua required a complete rewrite. But it also opened a lot of doors for additional functionality, user customization, and performance enhancements that simply weren't possible before. Keybinds used to shell out on every keystroke, but now they all run in-process inside Hyprland's Lua runtime.

Link to Repo: <https://github.com/uhs-robert/hyprvim>

What's new

Performance

  • No more per-keystroke hyprctl/jq subprocess spawns, logic now runs natively in Hyprland's Lua runtime
  • Leaner which-key HUD: monitor geometry passed directly, no jq for register rendering
  • Fewer shell round-trips across motions, operators, and submap transitions

New Features

  • Auto-updater with stable, nightly, and pinned channels (AUR coming soon once registration opens up again)
  • Built-in : excommands + user-defined: define your own or use 10+ built-in excommands (opacity, resize, gaps, move-pixel, etc.). Type :help in NORMAL mode to see what is available (includes tab completion and ! prefix to run any shell command)
  • Vim Registers with live previews in the which-key HUD, numbered-register cycling on yank, count multipliers for paste
  • User keybind overrides via keymaps config
  • Public API exposed from setup() for programmatic use
  • Per-submap delay_ms and instant HUD display with slide animation

Breaking changes

This is a breaking release. Your Hyprland config must be migrated to Lua.

  • Install path changed: hypr config dir -> $XDG_DATA_HOME
  • User themes/overrides: moved to $XDG_CONFIG_HOME

Not ready for Lua? No problem, the legacy-conf branch keeps the old .conf version alive but it has been deprecated in favor of Lua so no new features.

Migration guide: docs/guide


Feedback and issues are welcome. This took me a long time to port so I hope that others will find it as useful as I do during their everyday workflows.

As far as I know, this is the first Lua-based plugin for Hyprland so no precedent exists yet for install location or plugin management. I choose to make the install live outside your config dir with a shim to import cleanly as a plugin, I'm excited to watch how the Lua-based plugin ecosystem will grow.

u/uhs-robert — 4 months ago
▲ 163 r/hyprland

Hyprland Lua: Beyond Basic API Calls

I’ve been spending the past week experimenting with the new Hyprland Lua config system and reading through other people’s setups on here. As someone who writes Lua plugins for NeoVim and Yazi, and maintains HyprVim, I think there’s still a lot of unexplored potential with this new system.

A lot of setups I’m seeing are basically straight API calls split across multiple files. They tend to look very verbose like this:

hl.bind("SUPER + ALT + H", hl.dsp.exec_cmd("playerctl previous"), { desc= "Previous Track", submap_universal = true, locked = true })
hl.bind("SUPER + ALT + J", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { desc = "Volume Down", repeating = true, submap_universal = true, locked = true })
hl.bind("SUPER + ALT + K", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"), { desc = "Volume Up", repeating = true, submap_universal = true, locked = true })
hl.bind("SUPER + ALT + L", hl.dsp.exec_cmd("playerctl next"), { "Next Track", submap_universal = true, locked = true })

Which is fine, but I think the real value starts showing up once you stop treating it like a static config and start treating it more like an actual runtime with reusable modules and dynamic configuration. That shift also makes configs dramatically more portable and shareable, since setups can expose configuration entrypoints instead of forcing users to manually rewrite hardcoded values. My take on the above would be:

Bind.leader_key("ALT + H", Media.prev(),        "Previous Track", OPTS.oneshot)
Bind.leader_key("ALT + J", Media.volume_down(), "Volume Down",    OPTS.repeating)
Bind.leader_key("ALT + K", Media.volume_up(),   "Volume Up",      OPTS.repeating)
Bind.leader_key("ALT + L", Media.next(),        "Next Track",     OPTS.oneshot)

I’ve also been building other abstractions around things like:

  • Domain action libraries (Media.volume_up(), Workspace.cycle_next(), etc.)
  • Submap bind helpers with lifecycle hooks
  • Shareable config merging (like a NeoVim plugin)
  • Native split-monitor workspaces
  • Lua theme generators
  • Standalone Lua subprocesses bridging back into hl

Declarative submap definitions

I wrapped submaps into a Submap.define() abstraction with lifecycle hooks, catchall behavior, and automatic exit handling.

Submap.define({
  name     = "Applications",
  enter    = Config.leader .. " + A",
  escape   = "reset",
  catchall = "reset",
  on_enter = function(ctx) ... end,
  on_exit  = function(ctx) ... end,
  binds    = {
    { "F", Apps.open("firefox"),          "Firefox" },
    { "E", Apps.open(TERM .. " -e nvim"), "Editor"  },
  },
}).setup()

Native split-monitor workspaces

The new Lua runtime makes it possible to replicate the split-monitor-workspaces plugin natively with less than 50 lines of code using persistent workspace rules and monitor event hooks:

local monitors = hl.get_monitors()
for i, entry in ipairs(MONITOR_ORDER) do
  local output = get_monitor_output(entry, monitors)
  if output then
    local start_ws = (i - 1) * WS_PER_MONITOR + 1
    for n = start_ws, start_ws + WS_PER_MONITOR - 1 do
      hl.workspace_rule({ workspace = tostring(n), monitor = output, persistent = true })
    end
  end
end

hl.on("monitor.added",   apply_layout)
hl.on("monitor.removed", on_monitor_removed)

Standalone Lua scripts bridging back into the hl runtime

Some workflows block the event loop (rofi pickers, interactive scripts, etc.), so I’ve been spawning standalone Lua subprocesses and bridging back into the live runtime via hyprctl eval.

My session launcher uses this to schedule hl.timer polling after launching apps:

-- subprocess context: no hl global, but hyprctl eval reaches back in
local function eval(code) os.execute("hyprctl eval " .. shell_quote(code)) end

eval(string.format([[
  local timer
  timer = hl.timer(function()
    for _, w in ipairs(hl.get_windows()) do
      if w.class == %s then
        timer:set_enabled(false)
        hl.dispatch(hl.dsp.window.resize({ window = "address:" .. w.address, x = -200, relative = true }))
      end
    end
  end, { timeout = 500, type = "repeat" })
]], lua_string(app.class)))

Shareable config with deep merge and derived fields

I’m also treating the config more like a reusable Lua application by exposing a Config.setup() entrypoint with deep-merged defaults and derived fields:

-- hyprland.lua has only machine-specific values; everything else falls back to defaults
Config.setup({
  nvidia   = { enable = true },
  monitors = function(is_laptop)
    return is_laptop and MONITORS_LAPTOP or MONITORS_DESKTOP
  end,
})

-- config/init.lua — defaults + derived fields
Config.defaults = {
  leader = "SUPER",
  app    = { menu = "rofi", menu_cmd = nil },  -- menu_cmd derived below
}

local function derive(cfg)
  cfg.is_laptop    = cfg.is_laptop or detect_is_laptop()
  cfg.app.menu_cmd = cfg.app.menu_cmd or (cfg.app.menu .. " -name rofiMenu")
  cfg.monitors     = type(cfg.monitors) == "function"
    and cfg.monitors(cfg.is_laptop) or cfg.monitors
end

One thing I’ve found especially interesting is that Lua makes Hyprland setups dramatically more portable and shareable. Treating the config more like a NeoVim plugin or reusable application means users can override behavior through a single Config.setup() entrypoint instead of copying and rewriting large sections of someone else’s dotfiles.

Domain action libraries

Most actions in my setup are grouped into domain modules in (./hypr/lib/actions), this allows:

Bind.keys({
  { "XF86AudioRaiseVolume", Media.volume_up(),                 "Volume up"      },
  { "SUPER + S",            Apps.focus_or_launch(APP.slack),   "Go to Slack"    },
  { "SUPER + H",            Window.focus_dir("l"),             "Focus left"     },
  { "SUPER + TAB",          Workspace.focus_last(),            "Last workspace" },
})

Link to my dotfiles if anyone wants to dig through the implementation: https://github.com/uhs-robert/dotfiles/tree/main/home/hypr/.config/hypr

u/uhs-robert — 5 months ago