A tiny, keyboard-driven tiling window manager that replaces explorer.exe
as the Windows shell. No taskbar, no tray, no desktop icons — just windows,
tiled, driven entirely from the keyboard and configured in Lua.
⚠️ mshell takes over your Windows shell. Read INSTALL.md — especially the Recovery section — before installing.Ctrl+Shift+Esc(Task Manager) is never intercepted and is your always-available escape hatch.
- Seven tiling layouts: master-stack, monocle (true single-window), grid,
spiral (fibonacci), centered-master, bottom-stack, and columns — with
configurable
nmaster(master count) and per-windowcfactsizing.cfactsizes a window within its stack, so it applies to master-stack, bottom-stack, columns and centered-master. Grid and spiral tile on a fixed geometry and ignore it;bsphas its own per-split ratio (layout.split.grow/layout.split.shrink) instead. - Fullscreen in three flavours, because the window and the app's own
content fullscreen (YouTube's button,
F11) are different things:window.fullscreen.windowgives the window the whole monitor,window.fullscreen.contentpins it so the app's fullscreen fills only its tile, andwindow.fullscreen.bothlets the app's fullscreen cover the display.set_fullscreen_policypicks which of the last two an app that fullscreens itself gets by default. - Multi-monitor: each display is tiled independently; move focus and windows
across monitors (
Win+,/Win+.), and send a whole desktop to a display (desktop.to_monitor) whether or not a rule pinned it there. - Force-tiled mode (
set_float_policy("never")) so every window joins the grid and nothing is ever stacked on top of another window. - Nothing minimizes. With no taskbar to bring a window back from, a window that minimizes — its own button, or a game losing focus — is put straight back, and one that opens minimized opens normally.
- Floating windows are centred on their monitor rather than left wherever
the app opened them —
set_float_placementand a per-rulecenterdecide. - Flicker-free placement: a whole layout pass is applied in one
DeferWindowPosbatch, windows already in place are skipped, and geometry is computed against DWM's real visible frame so gaps are pixel-accurate. - Independent inner/outer gaps (
set_gaps) with optional smart gaps. - Directional focus & movement (
hjkl) based on real window geometry, with next/prev cycling as a fallback. - Dynamic virtual desktops via show/hide — no dependency on the Win10 API.
A desktop is its name (
"web", or"1"— a number is just a name): switch to a name nothing is using and that desktop is created, leave one empty and it is destroyed. Nothing to declare, up to 32 alive at once. - Desktop rules (
desktop_rule) — per-desktopapp(auto-launch when you enter it empty),float,layout,master_ratio,nmasterandmonitorpinning, matched by name or wildcard and layered like window rules. Pair each with a key and a bareWin-tapg blands you on a running browser whilem bthrows the focused window at it. - Leader mode + submaps. Point
set_leaderat any submap and a bareWintap enters it, so you reach everything with bare keys (w→ window,r→ resize,g→ go to a desktop,m→ move a window to one, …); theWin+keychords still work too. Submaps are persisting (stay until an exit key) or one-shot (next key drops back to root), expressive enough to spawn programs, switch desktops, and nest — with an optional which-key hint that lists the active submap's keys (delay, placement, maximum size, spacing, font and chrome;set_whichkey). - Lua configuration that reloads when you save it (or on
Win+Shift+R) and is atomic — a broken config keeps the previous one instead of stranding you. - Status bar in two modes.
top_baris a strip on every monitor — the live desktop set with the current one marked, the active layout, the focused window's title, and a clock — reserving its strip from each monitor's work area, so tiled windows sit below it and a fullscreen window still covers it.floatingis instead one panel in the middle of the focused monitor: a large clock, the date, the desktops, and mshell's live notifications listed inline. It reserves nothing, passes clicks through, and follows the focus between displays. Configurable viaset_bar, the module list can be trimmed or turned off entirely, andbar.togglehides it without a reload. - Focus ring around the active window and a solid desktop backdrop (there is no Explorer to paint one).
- Unfocused-window dimming by a percentage
(
mshell.appearance.dim_unfocused(30)), off by default. It is a click-through scrim with the focused window punched out, so no other app's window is ever made layered. - Window rules matching class, process or full install path as wildcard
patterns — float, ignore, strip the frame, or park a window fullscreen over
its monitor. One rule covers a whole game library; tiled windows can't be
dragged loose (they snap back) and can't maximize out of the grid. A rule can
also match what a window is rather than what it's called:
dialog = truecatches every file picker, message box and permission prompt, whichever app raised it, so the default config floats them all in one line. - The display itself, from the config: a
monitor_rulecan state aresolution, arefreshrate, arotation(landscape, portrait, either flipped) and whetherhdris on, alongside that display's tiling habits — because Settings → System → Display is an Explorer-hosted page and there is no Start menu to reach it from. Applied at startup, on reload and to a monitor you plug in, but never on top of a change you made yourself in Windows; validated first, so a resolution the panel cannot show costs a line in the log rather than a black screen.resolutionalways names the panel's unrotated size, so a rule stays right whichever way the screen is turned. Mode changes are session-only and Windows' own stored configuration is left alone, so booting without mshell gives you your normal display back.mshell.exe --displayslists every attached display, its device name, its position, its orientation, its HDR support and every mode it will accept;display.hdr.toggle,display.refresh.cycle,display.portrait.toggleanddisplay.rotation.cycleare bindable, for HDR only while a game is up, 60Hz on battery, or standing a secondary on its end to read. - The arrangement too:
primary = trueandposition = {x, y}state which display Windows treats as primary and where each one sits, so a config can restate a whole multi-monitor desk after a hotplug shuffles it. Positions are relative — mshell slides the arrangement until the primary lands on the origin, which is the only shape Windows accepts — and a change that is refused part-way is rolled back rather than half-applied. Unlike a mode, this one persists: batching displays into a single change is only offered alongside a registry write, so it is stored the wayhdris. - Control it from a script:
mshell.exe --msg "desktop.focus web"runs any action in the running shell, andmshell.exe --queryprints its state as JSON (desktops, monitors, focused window). The pipe is per-session and its DACL admits only the owning user. - Sticky windows, a scratchpad, dwm-style zoom, and mouse drag-to-swap between tiles.
- The things replacing Explorer takes away: lock, log off, reboot, shut
down, sleep and hibernate, because there is no Start menu to pick them from;
volume and media keys, because every
Win+*combo belongs to mshell and a keyboard without dedicated media keys would otherwise have no route to volume at all. Bound in submaps, none of them needs a chord. Pointer speed, acceleration and the left/right button swap come from the same place —set_mouse{speed=, accel=, swap_buttons=}is the Settings page you no longer have. They are saved, exactly as that page would save them: the value goes into your user profile, so it survives quitting mshell, signing out and booting into Explorer. mshell only writes a field that differs from what Windows already has, and a field you don't mention is left alone — deleting a line stops mshell asserting it but does not undo it. Scrolling, cursor size, key repeat, the accessibility keys, light and dark theme and Game Mode come the same way, frommouse.setup,keyboard.setup,theme.setupandgaming.setup. - Nothing is remembered across a restart: every start is the one your
init.luadescribes. A layout or master ratio you change at runtime lasts as long as mshell does, and a config reload keeps it — unless adesktop_rulenames that field, in which case the config states it and the reload puts it back. (set_layoutis a default, not a rule;desktop_rule("*", {layout = ...})is how a config insists.) - An optional privileged helper (
mshelld.exe) so an unelevated mshell can still tile, hide and close windows owned by elevated processes — without yourinit.luaever becoming administrator-level code. No config, no Lua, no scripting: it moves, restacks and closes windows, and nothing else. Installed byinstall.bat, and registered to start at sign-in wheneverinstall.batruns from an administrator prompt (/nohelperskips it). - A built-in launcher.
launchertypes a name and runs a program. If you want modules, Lua configuration and a clipboard/emoji story, mrun is a separate app that does that. See The launcher. - Screenshots and screen recording are separate apps.
mcapture takes a dragged region, a
window, a monitor or an exact rectangle; mrecord
records one to MP4. mshell ships neither and depends on neither — bind them
with
mshell.exec("mcapture.exe", "--select")like any other program. - Single global instance, low-level keyboard hook, out-of-context WinEvent hooks — no DLL injection.
Cross-compiled from Linux with mingw-w64. You need Lua 5.4 source in
vendor/lua/:
mkdir -p vendor/lua && cd vendor/lua
curl -LO https://www.lua.org/ftp/lua-5.4.7.tar.gz
tar xzf lua-5.4.7.tar.gz --strip-components=1
cd ../..
make # produces mshell.exe
make test # runs the host-side unit tests (no Windows needed)
make dist # produces dist/mshell-<version>-win64.zipThe version lives in one place — VERSION in the Makefile — and is baked into
the binary's startup log, its VERSIONINFO resource, and the release zip name.
make test builds the parts with no Windows in them — rule pattern matching and
the tiling arithmetic — with the host compiler and runs them directly, so it
works on the same Linux box you cross-compile from. Everything that needs a real
machine is listed in MANUAL-TESTS.md.
Copy mshell.exe and config/ to the Windows machine and run alongside
Explorer — quitting just exits, it does not log you out:
mshell.exe --test
%LOCALAPPDATA%\mshell\mshell.log (and DebugView) is always written, and is the
first place to look when something doesn't work: it records whether your config
loaded, how many bindings it produced, and any startup program that failed to
launch. If none of your keybinds work, read it — a config error is atomic, so
a single bad line rejects the whole file and leaves you on a six-binding fallback
keymap. Add --verbose for the full per-keystroke trace on top.
Every line is timestamped and carries a level, the file is appended to rather
than truncated (so a crash leaves its evidence behind), and it rotates at 5 MB
keeping two older generations alongside it. mshell.log.level("error" | "warn" | "info" | "debug" | "trace") sets the level from your config — "debug"
is what --verbose gives you, and "info" is the default. The privileged
helper writes mshelld.log beside it.
See INSTALL.md. In short: install.bat copies the program to
C:\mshell, your config to %APPDATA%\mshell\init.lua, and sets the
per-user (HKCU) Winlogon Shell key. It only affects your account and is
reverted by uninstall.bat.
Run it again to upgrade: it replaces the installed exe even while that exe is
your running shell, then restarts mshell so the new build takes over without a
sign-out. Your init.lua is never overwritten.
The release ships one config, config/init.lua. It is small, assumes nothing
is installed but Windows, and opens cmd.exe because that is the one terminal
every machine has. install.bat puts it at %APPDATA%\mshell\init.lua only
when you have no config there yet, and a portable copy with no config falls back
to it beside mshell.exe.
It launches nothing at startup: a default that opened a browser, a chat client and a game would greet most new users with a log full of launch failures. It is a starting point to make your own, not someone else's setup.
The launcher action opens a type-a-name-run-a-program box. It indexes both
Start menus, matches subsequences (fox finds Firefox), and runs whatever you
typed when nothing matches — so it is a Run box as well as a menu.
It is deliberately small and has no configuration. When you want more, mrun is a separate application built for exactly that: a modular launcher with its own Lua config, where app launching is one module and clipboard history, emoji or anything you write yourself are the same shape.
mshell and mrun are independent — neither needs the other. To use mrun, or any other launcher, bind its executable:
mshell.keys.bind({"LWin"}, "Space",
function() mshell.exec("mrun.exe") end, { desc = "run" })Modifier is Win (swallowed entirely; every Win+* combo is mshell's).
Two ways to drive it: hold Win+key for a chord, or tap Win on its own
to enter the leader map and use bare keys — h/j/k/l focus, Space cycles the
layout, t opens a terminal. Tap Win again or press Esc to leave.
Desktops are created by going to them and destroyed when you leave them empty, so none of the numbered desktops has to exist in advance.
| Keys | Action |
|---|---|
Win+h/j/k/l |
Focus left/down/up/right |
Win+Shift+h/j/k/l |
Move the window that way |
Win+1..9 / Win+Shift+1..9 |
Go to desktop 1..9 / send the window there |
Win+` · Win+[ · Win+] |
Last desktop · previous · next |
Win+, . (+Shift) |
Focus / move to the previous or next monitor |
Win+t m g · Win+Space |
Layout tiling / monocle / grid · cycle |
Win+f · Win+Return |
Toggle floating · promote to master |
Win+Ctrl+h/l · Win+Ctrl+j/k |
Master ratio · master count |
Win+Shift+f · Win+Ctrl+f · Win+F11 |
Fullscreen: window · inside the tile · both |
Win+Shift+c · Win+Shift+x |
Close · kill |
Win+Shift+Return |
Terminal |
Win+Shift+r · Win+Shift+q |
Reload config · quit |
Win+Shift+u |
Install the latest GitHub release (mshell restarts) |
Your config lives at %APPDATA%\mshell\init.lua
(C:\Users\<you>\AppData\Roaming\mshell\init.lua) — the standard Windows
location for per-user config, so a reinstall never touches it. If that file is
absent, mshell falls back to config\init.lua beside mshell.exe, which keeps
a portable/unzipped copy working.
An action is a value, not a string. mshell.window.close is the action
itself, so your editor completes it and a typo is a mistake you see as you
write rather than one the config load reports:
mshell.keys.bind({"LWin"}, "h", mshell.window.focus.left)Anything that has to be told something — which desktop, which command — goes inside a function, because that is the only place an argument can live:
mshell.keys.bind({"LWin"}, "3",
function() mshell.desktop.focus("3") end, { desc = "3" })A function has no name, so the which-key panel has nothing to label the key
with unless you give it desc. That is the one thing to remember about the
form: parameterised bindings want a desc.
A string in the action position names a submap to enter:
mshell.keys.bind({"LWin"}, "x", "extra") -- enters the "extra" submapThe same names are callable at runtime, from a binding or an event handler, and every window verb takes an optional window to act on — the focused one when you leave it out:
mshell.window.close() -- the focused window
mshell.window.move({ desktop = "web" })
for _, w in ipairs(mshell.window.list({ process = "firefox.exe" })) do
w:move({ desktop = "web" }) -- windows are objects
endmshell ships the type definitions for its whole API, generated from the same
table the binary dispatches through, so they describe exactly the release you
have installed. install.bat puts them in %APPDATA%\mshell\meta alongside a
.luarc.json that points lua-language-server at them.
Open %APPDATA%\mshell in any editor with that LSP and you get completion over
mshell.*, signatures and documentation on hover, and a diagnostic on anything
mshell does not have — including a removed name, which says what replaced it.
See config/init.lua for the full commented example.
Highlights:
mshell.layout.gaps(6, 6) -- inner gap, outer gap
mshell.layout.smart_gaps(true) -- no gaps when a monitor has one window
mshell.appearance.border(2, 0xffffff) -- focus ring: width, 0xRRGGBB
mshell.appearance.border{ accent = "top" }-- move the focus bar; "none" drops it
mshell.appearance.smart_borders(true) -- no ring when a monitor shows one window
mshell.appearance.background(0x000000) -- desktop backdrop
mshell.appearance.dim_unfocused(30) -- darken unfocused windows by 30%
mshell.desktop.rule("1", { default = true }) -- the desktop you land on
mshell.desktop.rule("web", { app = "firefox.exe" }) -- open it when empty
mshell.desktop.rule("game", { float = true, app = "steam.exe" })
mshell.desktop.rule("chat", { layout = "monocle", monitor = 1 })
mshell.layout.set("tiling") -- tiling|monocle|grid|spiral|centered|bstack|columns
mshell.layout.master.count(1) -- windows in the master area
mshell.desktop.attach("master") -- new windows become master (dwm-style)
mshell.window.policy.float("never") -- force EVERY window into the grid
mshell.window.policy.placement("center") -- floats land mid-monitor ("none" = leave them)
-- persist = false (the default) is one-shot: the next key drops back to root.
mshell.keys.submap("launch", {
Return = { function() mshell.exec("alacritty.exe") end, desc = "alacritty" },
b = { function() mshell.exec("firefox.exe") end, desc = "firefox" },
p = mshell.launcher.open,
})The focus bar. A 2px ring is easy to lose. It is drawn outside the frame,
so the only side with room to grow is the one facing a neighbour — the other
three get clamped into the work area and end up as a hairline against the screen
edge, which reads as a divider rather than as "this window has focus". So the
ring comes with a bar across one edge of the focused window, drawn inside the
frame where nothing can clip it: accent = "bottom" (the default), "top", or
"none" to go back to the bare ring, with accent_width for its thickness. It
shares the ring's colour, so a floating or urgent window tints both together,
and it obeys the same rules — no_ring windows, fullscreen windows and
smart_borders suppress the whole thing.
API. Everything is grouped by what it acts on, and every name is a value
rather than a string: mshell.window.*, mshell.desktop.*, mshell.monitor.*,
mshell.display.*, mshell.layout.*, mshell.bar.*, mshell.appearance.*,
mshell.keys.*, mshell.mouse.*, mshell.keyboard.*, mshell.theme.*,
mshell.gaming.*, mshell.system.*, mshell.media.*, mshell.exec,
mshell.notify, mshell.log, mshell.config.*, mshell.on.
The full list is not reproduced here, because a list in a README goes stale.
It is generated from the same table the binary dispatches through and shipped
as meta/mshell.lua, so your editor can show it to you — see
Editor support below. make meta regenerates it and CI
fails if the committed copy has drifted.
Manual tiling. Alongside the seven dynamic layouts there is bsp: windows
split wherever you were, layout.split.h/layout.split.v decide the direction the next one
takes, and any split can become a tabbed or stacked container showing one
window at a time. The tree does not replace the window list — it is an index
over it — so a desktop moves between bsp and the dynamic layouts freely. Each
display gets its own tree, and Win+Space deliberately does not cycle into
bsp: a layout you build by hand is one you ask for, with mshell.layout.bsp.
A launcher. mshell.launcher.open opens a filter over your Start-menu
shortcuts; anything that matches nothing is run as typed, so it is a Run box too.
It types without ever taking focus, because the keyboard hook hands it keys
directly. Bind it in a one-shot submap rather than on a persisting leader,
because a map you are still in would be swallowing keys the moment the
launcher closed.
Registry tweaks you can undo. mshell.exe --tweaks list shows what is
applied and why; apply records the previous value before writing, so revert
restores exactly what you had rather than Microsoft's default.
Windows' own settings, where they belong. The Settings app needs Explorer, but the settings behind it do not: Windows keeps each page's logic in handler libraries that work without a shell, and mshell drives those same handlers. Each setting is a field on the thing it configures, not a registry path:
mshell.mouse.setup{ scroll_lines = 5, scroll_by = "lines", reverse_scroll = false,
cursor_size = 2, hide_while_typing = true }
mshell.keyboard.setup{ repeat_delay = 0, repeat_rate = 31, sticky_keys_shortcut = false }
mshell.theme.setup{ mode = "dark", transparency = false, animations = false }
mshell.gaming.setup{ game_mode = true }They are saved to your profile exactly as the Settings page would save them, so
they outlive mshell. On every start and reload mshell hands the lot to a
separate mshell.exe in the background, which writes only the ones that differ
and logs each change; a handler that hangs or crashes takes that helper down,
not the shell, and anything that could not be applied raises a notification.
A wrong name or value is a config error like any other. The same names work
from a terminal:
mshell.exe --settings list [pattern] every field, what it takes, what it does
mshell.exe --settings get [name...] current values, all of them by default
mshell.exe --settings set theme.mode light mouse.scroll_by screen
A setting another one disables — trails_length while trails is off, lines
while scrolling by screen — is retried after the rest, so the order you write
them in does not matter. Group policy still wins: a field it locks reports that
instead of changing.
Notifications. There is no Explorer, so there is no toast host and no tray —
mshell paints its own. A config that fails to reload says so on screen with the
Lua error, which matters because the atomic rollback that keeps your previous
config running is otherwise completely silent. mshell.notify("text", "warn")
raises one yourself, and mshell.exe --msg 'notify hello' from a script does the
same. It is deliberately mshell's own messages only.
Other apps' notifications are a different matter, and there are two kinds.
Toasts — what Discord, Chrome, WhatsApp and Outlook send — are still accepted
and stored by Windows when Explorer is not running; it is Explorer that draws
them, so they never appear. Tray balloons are what older tray utilities send,
and they are only missing a Shell_TrayWnd to land in.
mnotify is a separate app that shows
both: it reads new toasts from Windows' notification store, stands in for the
tray, and gives you a menu of tray icons. mshell ships nothing of it and needs
no configuration for it — mshell.exec.startup("mnotify.exe") starts it with
the session. Toasts need Windows notifications left on: ToastEnabled under
HKCU\Software\Microsoft\Windows\CurrentVersion\PushNotifications must not
be 0.
A panic key. The panic action starts
Explorer alongside mshell and stops the hook binding anything, so a shell that is
misbehaving does not need Task Manager to escape. It deliberately does not quit —
exiting as the shell ends the session, which is the thing you were avoiding. Any
reload undoes it (mshell.exe --msg reload, or saving init.lua); no keybinding
can, because not binding keys is the point.
Updating from a keybinding. The update action — Win+Shift+u in the
default config — fetches the latest GitHub release, checks the
download against the SHA-256 the release published, unpacks it and runs the
install.bat inside it. That script is the upgrade path either way: it renames
the running image rather than overwriting it and restarts mshell itself, so the
screen blinks and you come back on the new build. Progress, and every way it can
fail, arrives as a notification.
This is the deliberate opposite of set_update_check, which only ever tells
you a release exists. An updater that swapped out the shell unattended would
turn a bad release into a black screen at sign-in with no desktop left to fix it
from; a key you pressed, while sitting in front of the machine, is a different
proposition. Two things it will not do: run twice at once, and install when the
running mshell is not the registered shell — from a portable copy or --test,
install.bat would not be upgrading anything, it would be taking over your
shell for the first time. In that case it unpacks and tells you where, and you
run install.bat yourself. mshell.exe --msg update works too.
Safe mode. Three starts inside a minute means the previous two did not
survive one, so the next run skips init.lua entirely and comes up on the
built-in keymap with the reason in the log. As the shell, a config that crashes
at startup otherwise loops with no way in.
Auto-reload. mshell watches the folder holding your init.lua and reloads
250 ms after the last write, so saving in your editor applies the config —
Win+Shift+R is still there for an explicit reload. The debounce means a
save-in-progress isn't read half-written, and the atomic-reload rollback means
a syntax error leaves the running config untouched (the error goes to the log).
Extra modules you keep beside init.lua and require() count too. Turn it off
with mshell.config.auto_reload(false) if you'd rather reload by hand.
Window rules & games. mshell.window.rule(match, action, opts) matches on class,
process (the .exe name) and/or path (its full image path). Each criterion is
a case-insensitive wildcard pattern — * any run, ? one character, / and
\ interchangeable — so a pattern without wildcards is an exact match, and one
path pattern covers an entire game library. All the keys you give must match;
rules are tried in order and the first match wins, so a specific rule placed
above a broad one carves an exception out of it. opts takes ring = false
(no focus ring painted over the window), decorate = false (strip the title bar
and add no border — floating windows otherwise keep their own chrome) and
fullscreen = true (park it over the monitor's full bounds, ignoring gaps).
The three together are the game preset:
mshell.window.rule({ path = [[*\steamapps\common\*]] }, "float",
{ ring = false, decorate = false, fullscreen = true })System dialogs. Some windows can't be named: a file picker is the host
app's process wearing a class the OS handed it, so no process or class
pattern separates Firefox's Open box from Firefox. dialog = true matches them
by what they are: a window with a title bar that is also one of the
common-dialog class (#32770: Open, Save As, Select Folder, message boxes,
print and properties sheets), a modal dialog frame without a maximize box (Qt,
WinUI, .NET), or an owned window (GTK, and most app-modal prompts). The title
bar is what keeps menus, dropdowns and tooltips out — those are owned popups
too.
mshell.window.rule({ dialog = true }, "float") -- every picker/prompt
mshell.window.rule({ process = "code.exe", dialog = true }, "manage") -- except this app'sOwned windows are the one kind a dialog rule fully manages that mshell
otherwise only tracks. Every window is adopted in one of two tiers: full
(tiled, decorated, ringed) or tracked — desktop membership and nothing else.
An unmatched owned window lands in the tracked tier: it hides and shows with
the desktop it opened on, is focusable, closable and movable between desktops,
but keeps its own chrome and is never tiled. Matched by a dialog rule it
becomes a real floating window, and Win+f on any tracked window promotes it
to full management the same way. The shipped config uses the one-liner above
plus named rules for consent.exe (UAC, only visible if the secure desktop is
disabled) and CredentialUIBroker.exe (the "Windows Security" PIN/password
box), which are separate processes rather than dialogs of the app that
triggered them. dialog = false is the inverse, for a rule that should skip
dialogs. An ignore rule is the way to leave a window on every desktop at
once — it stays completely untouched.
Naming a window in a rule also rescues it from the "caption-less popup =
menu/tooltip" filter — the exact style a borderless-fullscreen game uses — so
the game stays a real managed window: it hides on desktop switch and
Win+Shift+c can close it. Because games rebuild their window when the
graphics device comes up (and again whenever you change resolution or flip
windowed/borderless in their options), the frame and the fullscreen geometry are
re-asserted whenever the window moves, not just once when it opens.
Force every window to tile. Set mshell.window.policy.float("never"): any
"float" rule is downgraded to "manage" and Win+f becomes a no-op on
managed windows, so nothing is ever stacked on top of a tiled window. (Tracked
windows — owned/modal dialogs and the like — are still only desktop-bound,
not tiled, unless you also set set_manage_owned(true), which is aggressive:
many dialogs are fixed-size and tile poorly.)
A float is an overlay. Floating windows stay above the tiled grid — that is
the default, and it holds across focus changes: focusing a tiled window, with a
keybind or with the mouse, does not bury the float you were looking at, because
activation raising the tiled window is undone on the spot. Floats keep their own
order among themselves, with the focused one on top, and a float that has been
promoted to always-on-top or fullscreen sits above the rest of them. Pass
mshell.window.float_on_top(false) for the old behaviour, where a float is an
ordinary window in the stack and sinks behind whatever you focus next.
Floating windows land in the middle. A float is the window you deliberately
kept out of the grid, so mshell centres it on its monitor's work area rather
than leaving it wherever the app opened it — both a window that opens floating
and one Win+f just untiled. Only the position is decided: the size stays the
app's own, clamped to fit. mshell.window.policy.placement("none") turns it off,
and center = false (or true) in a rule's opts answers for one app — useful
for an overlay that already positions itself well. A rule with an explicit
geometry = {x, y, w, h} or fullscreen = true places the window itself and is
unaffected either way.
Desktops are dynamic, and a desktop is its name. There is no desktop count
to configure and no 1..9: a desktop is a name — a word ("web") or a number
("1"), with no difference between them — and it exists only while something is
on it. Switching to a name nothing is using creates that desktop; leaving one
with no windows on it destroys it. At startup exactly one desktop exists, the
one you land on:
mshell.desktop.rule("term", { default = true }) -- start here; "1" if nothing claims it
-- "web" is created on demand; "3" is a name, not an index
mshell.keys.bind({mod}, "w",
function() mshell.desktop.focus("web") end, { desc = "web" })
mshell.keys.bind({mod, shft}, "w",
function() mshell.window.move.to_desktop("web") end, { desc = "→ web" })
mshell.keys.bind({mod}, "3",
function() mshell.desktop.focus("3") end, { desc = "3" })default is a desktop rule like any other, so where you begin sits beside that
desktop's app, layout and monitor instead of in a setter somewhere else. It needs
a literal name rather than a pattern — mshell has to create exactly one desktop
at startup — and if two rules claim it, the last one wins.
It decides every start, not just the first: mshell keeps no memory of where you were, so a restart puts you back on the desktop your config names. Nothing to clear, nothing to drift.
Names are case-insensitive (the first spelling to create the desktop is the one
displayed), can't contain whitespace, and are capped at 63 characters. Because a
name never has to be declared, desktop.focus "scratch" always works whether or
not scratch appears anywhere in your config — you can invent a desktop at any
time and it costs nothing once you close its last window.
Two consequences worth knowing:
- The desktop you are standing on is never destroyed, however empty it is — closing everything in front of you leaves you somewhere, not nowhere.
desktop.focus.lastremembers a name, so it goes back to a desktop that was destroyed behind you by re-creating it (empty, with its rules applied).
Since the set changes under you, desktop.focus.next / .prev step through
whatever exists at that moment, in name order — numbers first and numerically
(1, 2, 10), then words alphabetically. That's how you get back to a desktop you
made on the fly and never bound a key to.
How a desktop is taken off the screen (set_hide_policy). A desktop you are
not on is a set of windows that have been removed from view, and how they are
removed decides whether they come back looking right. By default mshell
sinks them: each window drops below the desktop backdrop in the z-order, so
Windows still considers it visible and it keeps rendering, but nothing on
screen shows it. A window that can't be sunk — always-on-top, or owned by an
elevated process — is parked off every display instead (through mshelld.exe
for an elevated one), and ShowWindow(SW_HIDE) is the last resort. The policy
is still called "cloak" from when it meant DWM cloaking, which mshell can't
use: DWM only lets a window's owner cloak it, and the shell cloak Windows' own
virtual desktops use lives inside explorer.exe.
The obvious alternative, ShowWindow(SW_HIDE), is what mshell did before 0.13.0
and it has a visible failure mode: clearing a window's visible bit makes DWM
throw away its redirection surface, and every app that renders off the UI thread
— anything Chromium or Electron based (Chrome, VS Code, Discord, Spotify), WPF,
Qt on D3D — also reads it as being occluded and shuts its renderer down. On the
way back there is a fresh, empty surface and nothing has asked the app to draw
into it, so the window comes back black. Because the tiler skips windows that
are already in the right place, no resize arrived to shake them out of it either,
and a whole desktop's worth of apps could come back blank at once.
mshell.window.policy.hide("cloak") -- default
mshell.window.policy.hide("hide") -- pre-0.13.0 ShowWindow(SW_HIDE)Keeping the window visible is not what fixes the blackness on its own,
though — an app can still stop presenting when its own occlusion tracking
decides nobody can see it. What fixes it is that a window coming
back is always asked to draw: RedrawWindow over it and its children, plus a
forced re-placement carrying SWP_FRAMECHANGED and SWP_NOCOPYBITS so the
tiler cannot skip it for already being in the right place. That applies to both
policies, which is why "hide" is a usable escape hatch rather than a way back
to the bug.
Desktop rules — what a desktop does. mshell.desktop.rule(pattern, opts) is
the desktop counterpart of mshell.window.rule. pattern is a desktop name or a
case-insensitive wildcard over names, matched with the same */? syntax window
rules use:
mshell.desktop.rule("web", { app = "firefox.exe" })
mshell.desktop.rule("chat", { app = discord, layout = "monocle" })
mshell.desktop.rule("game-*", { float = true, monitor = 1 })| field | effect |
|---|---|
app |
open this whenever you enter the desktop and it has no windows |
float |
windows opened here start floating instead of tiled |
layout |
this desktop's layout, overriding set_layout |
master_ratio |
master area size for this desktop, 0.2 .. 0.9 |
nmaster |
windows in this desktop's master area |
monitor |
pin the desktop to a display (0-based) |
A pin is also a runtime operation, which is what the rule alone could not be:
desktop.to_monitor as an action (bindable, and reachable as
mshell.exe --msg "desktop.to_monitor chat 1"), or mshell.desktop.to_monitor
from a binding or an event handler. What you set by hand outranks the rule and
survives a reload, an unplug/replug and a restart, until -1 clears it.
Rules layer rather than compete: every rule whose pattern matches is applied
in declaration order, and each overrides only the fields it names. So a "*"
rule sets the house style and a specific one adjusts a field or two:
mshell.desktop.rule("*", { layout = "tiling" })
mshell.desktop.rule("scratch", { float = true }) -- still layout = tilingRules are resolved when a desktop is created and re-applied on every config reload, so editing one takes effect on desktops that already exist — including a layout you changed at runtime, which goes back to what the rule says.
float = true sets what new windows on that desktop start as; window.float.toggle
still works per window, so you can tile one thing on a floating desktop. It is
deliberately checked after set_float_policy("never") vetoes a window rule's
float, because a config that tiles aggressively and then carves out one floating
desktop means it. monitor is not advisory — windows already on the desktop are
moved to that display too, and switching to the desktop takes the focus there. A
pin naming a display that isn't there (unplugged, or monitor = 2 on a one-head
machine) lapses to "wherever it opens" rather than tiling into nothing.
Auto-launch (app). The launch goes through the normal manage path, so any
rule you set for that app still applies. Switching away and back before the
window appears won't spawn a second copy; closing the app and returning re-opens
it. A failed launch (bad command) is retried on your next visit rather than
latched.
Note that app and mshell.exec.startup do not cancel out: at startup the
auto-launch check runs while a spawned app's window still doesn't exist, so the
desktop looks empty and both launches go through. Put an app in one or the
other — spawn for resident, desktop-less things (a launcher, a sync client),
a desktop rule's app for anything that belongs to a desktop.
Drive desktops from one table (go / move). Two submaps sharing one key
per desktop — g takes you there, m sends the focused window there — are
worth generating from a single declaration rather than writing three times:
local desktops = {
{ name = "term", key = "t" }, -- no app: see the note below
{ name = "web", key = "b", rule = { app = "firefox.exe" } },
{ name = "chat", key = "d", rule = { app = discord } }, -- nil if not installed
{ name = "game", key = "v", rule = { app = valorant,
float = true } },
}
local go_keys, move_keys = {}, {}
for _, d in ipairs(desktops) do
local name = d.name
-- A closure is how an action is told WHICH desktop, and desc is how the
-- which-key panel gets a label for it — a function has no name of its own.
go_keys[d.key] = { function() mshell.desktop.focus(name) end,
desc = name }
move_keys[d.key] = { function() mshell.window.move.to_desktop(name) end,
desc = "→ " .. name }
if d.rule then mshell.desktop.rule(d.name, d.rule) end
end
go_keys.Tab = mshell.desktop.focus.last
go_keys["]"] = mshell.desktop.focus.next -- step through whatever exists now
go_keys["["] = mshell.desktop.focus.prev
mshell.keys.submap("go", go_keys) -- one-shot: pick and you're there
mshell.keys.submap("move", move_keys)
-- then, in the leader map: g = "go", m = "move"Adding a desktop is then one row: it gets both leader keys and its rule at once,
and the two maps can't drift apart. app may be nil, so a program you don't
have loses only its auto-launch — the desktop and its keys stay. Note the table
declares nothing: these are just the desktops
worth a key and a rule, and you can still switch to any other name at any time.
Apps whose launcher needs arguments can't be used directly: spawn and a
desktop rule's app both go through ShellExecute with no parameters. Discord
(Update.exe --processStart Discord.exe) and Valorant (Riot Client + Vanguard)
are the common cases — point at the Start-menu .lnk instead, which carries the
arguments itself.
Fullscreen, in three flavours. Two different things can go fullscreen and
mshell keeps them apart: the window (geometry, which mshell owns) and the
app's own content fullscreen — YouTube's fullscreen button, F11 in a
browser, where the app switches its own UI and resizes itself to the display.
Each action is its own toggle, and pressing a different one switches modes
directly:
-- window fills the monitor / app fullscreen stays in the tile / both
mshell.keys.bind({mod, shft}, "f", mshell.window.fullscreen.window)
mshell.keys.bind({mod, ctrl}, "f", mshell.window.fullscreen.content)
mshell.keys.bind({mod, alt}, "f", mshell.window.fullscreen.both)| Action | Window geometry | The app's own fullscreen |
|---|---|---|
window.fullscreen.window |
covers the monitor, edge to edge | untouched — the app is never told |
window.fullscreen.content |
pinned to its tile | renders inside the window |
window.fullscreen.both |
covers the monitor | left alone — it covers the display |
A fullscreen window leaves the layout: the others tile underneath it as if it weren't there, so leaving fullscreen reveals the layout already in place. Only one window per monitor can cover the screen — claiming it releases the previous one — and its focus ring is suppressed (a colored line hugging the screen edges is not what content asking for the whole display wants).
window.fullscreen.content has nothing to do until the app itself goes fullscreen:
mshell can't press YouTube's button for you. What it does is pin the window, so
when you do press it the fullscreen content fills the tile instead of escaping to
the display.
What happens when an app fullscreens itself. For windows you haven't given a
mode, mshell.window.policy.fullscreen("contain" | "monitor") decides.
"contain" (the default, and what mshell has always done) keeps the window in
its tile, so a fullscreen video fills the tile. "monitor" hands it the display
— detected from the window covering its monitor's full bounds — and puts it back
in the layout the moment it leaves fullscreen, so the fullscreen button behaves
the way it does outside a tiling WM. The per-window actions override the policy
either way.
Jump back to the last desktop. The desktop.focus.last action returns to the
desktop you switched away from. Every switch records where it came from, so the
two form a toggle — press it twice and you are back where you started:
mshell.keys.bind({mod}, "`", mshell.desktop.focus.last) -- bounces between twoThe default config also puts it on Tab inside the desktop submap (Win+d
then Tab). It is deliberately not on Win+Tab: Windows picks that combo up
below mshell's keyboard hook, so Task View would open on top of the switch —
bare keys inside a submap never reach the OS. Until you switch desktops at least
once the action does nothing (there is nowhere to go back to). It remembers a
name, not a desktop, so it still works when the desktop you came from was
destroyed behind you for being empty — it is simply re-created.
| File | Responsibility |
|---|---|
main.c |
bootstrap, elevation check, message loop, session window |
keyboard.c |
low-level keyboard hook, submap state machine, action dispatch |
window.c |
adoption tiers (tracked/full), manage/unmanage, decoration stripping, rules |
tiling.c |
7 layout algorithms, per-monitor tiling, batched/frame-accurate placement |
desktop.c |
virtual desktops (show/hide), reconfigure, reapply |
events.c |
WinEvent hooks for window lifecycle tracking |
config.c |
atomic Lua config load/reload + built-in fallback keymap |
lua_api.c |
C functions exposed to the Lua config |
border.c |
focused-window ring and accent bar overlay |
border_math.c |
ring/accent geometry, clamped to the work area (pure) |
background.c |
solid-color desktop backdrop |
The keyboard hook only mutates state and defers heavy work to the message
pump (PostMessage), keeping it well under LowLevelHooksTimeout.
A desktop in mshell is a set of windows, and each monitor tiles that desktop's windows independently. Switching desktops changes what is on all displays at once.
The alternative — dwm's model, where every monitor owns its own tag set and switching affects only the focused one — was considered and deliberately not taken. Two reasons:
- A desktop here is a name you invent, not a slot you own. Desktops are
created by going to them and destroyed when you leave them empty, so "the
tags on monitor 2" would be a second, differently-shaped namespace layered
over a set that is already dynamic.
desktop.focus "web"would have to mean something different depending on which monitor had focus. - It matches how the desktops are actually used. A desktop that is your browser, or your chat app, is a context you move between — and a context spanning your whole desk is usually what you want when you switch into it.
If you want a display to hold one thing permanently, pin a desktop to it with
desktop_rule("name", { monitor = 1 }); its windows tile there and switching to
it moves the focus there.
- Layout is per-desktop, not per-monitor. All monitors on a desktop share
the same layout /
nmaster/ master-ratio; there is no independent per-monitor layout state yet. - Owned/modal dialogs are tracked, not tiled, unless
set_manage_owned(true). This is deliberate: forcing fixed-size dialogs into a tile can make them unusable. They still hide and show with their desktop like any other window. - Directional focus/move uses window-rect centers; unusual custom layouts may not always match intuition (it falls back to cycling).
MIT — see LICENSE. Vendors Lua, also MIT; its notice is in THIRD-PARTY-NOTICES.md.