Skip to content

Multi-window — Window menu, per-window theme, persistence, scope toggle

A tmnl window is the unit of state. Each one owns its own tab strip, its own split tree, its own theme, and — as of the Phase 6 multi-window arc — its own slice of the config. You can have a window painted in kanagawa running mnml on a client repo, a window painted in catppuccin running a stack of shells against your home lab, and a window with prompt_position = "bottom" for note-taking, all in the same tmnl process. Quit, relaunch, and every one of them comes back where you left it.

That’s the deliverable. This page walks the three phases that ship it — the Window menu (6a), per-window theme (6b), per-window cfg override + Apply-to scope toggle (6c) — and then how N windows persist across restarts. It closes with the small list of knobs that are deliberately NOT per-window yet, so you know what to expect when the Settings overlay seems to ignore your edits.

The first piece is a discoverable path between open windows. The native macOS menu bar gains a Window submenu with two new items at the top:

| Menu item | Chord | What it does | | --- | --- | --- | | Window > Next Window | Cmd+\`` | Focus the next background window. No-op when only one window is open. | | Window > Previous Window|Cmd+Shift+“ | Focus the LAST background window (reverse cycle). |

`Cmd+“ is the standard macOS application-window-cycle chord, so the muscle memory matches every other Mac app. Both menu items stay enabled even when only one window exists — the dispatch is a no-op rather than a greyed-out item, which keeps the menu shape stable.

The pre-existing palette command window.cycle_next keeps its Cmd+Alt+\`` chord too. The menu chords are the discoverable path; the palette chord is for muscle memory. They both call the same focus_background_window` helper under the hood.

Dynamic window list at the bottom of the menu

Section titled “Dynamic window list at the bottom of the menu”

The bottom of the Window menu carries a live, dynamic list of every open window — the same Safari / Finder convention. The focused window is -marked; background windows are prefixed with two spaces so the bullet column aligns. Clicking a background window swaps focus to it (focus_background_window under the hood); clicking the bulleted focused row dismisses the menu (standard macOS pattern — no-op on the focused window’s own entry).

Window
Minimize ⌘M
Next Tab ⌘⇧]
Previous Tab ⌘⇧[
Next Window ⌘`
Previous Window ⌘⇧`
Focus Pane Left ⌘⌥←
Focus Pane Right ⌘⌥→
Focus Pane Up ⌘⌥↑
Focus Pane Down ⌘⌥↓
─ ← dynamic section below
✓ tmnl — mnml ~/Projects/foo
tmnl — shell
tmnl — mixr

The list rebuilds each tick from (focused_label, background_labels). A String fingerprint guards the rebuild — when nothing has changed, the per-tick sync is a single hash compare. Opening or closing a window live-updates the list; renaming a tab does too (since tab labels feed the window label).

IDs on the dynamic items are window_focus_<idx> (idx = position in App.background_windows) for background entries and window_focus_main for the focused one; the menu dispatcher parses the suffix to route into focus_background_window. The dynamic-list bookkeeping lives in AppMenu::sync_window_list (src/menu.rs).

The application menu (top-level tmnl) also gains two per-window cfg verbs for managing overrides without spelunking into the Settings overlay:

| Item | Effect | | --- | --- | | Reset Window Settings | Clears the focused window’s cfg_override so it falls back to the global cfg. No-op when no override exists. | | Apply Window Settings to All Windows | Copies the focused window’s cfg_override into the global cfg, then clears every window’s override (focused + background). Persists. |

These pair with the Settings overlay’s Apply to scope toggle (Phase 6c/2) — once you’ve experimented with a window-scoped override and decide you like it everywhere, Apply Window Settings to All Windows promotes it without re-opening Settings.

Window > Move Tab to New Window pops the focused tab out of the current window and into a fresh one. No-op when the focused window has only one tab (the move would leave it empty). The achievable substitute for full drag-out-of-window-into-new-window; Safari has the same convention with no accelerator.

The blocker that made per-window themes hard is that tmnl’s palette is a process-global OnceLock<RwLock<Palette>> read directly by hundreds of render-path call sites. Swapping it on focus change would leak the focused window’s palette into background-window paint that races on the same RwLock at the same time.

Phase 6b solves it with a thread-local override layer.

Each window’s redraw runs on a single thread. The paint entry point sets a thread-local Cell<Option<Palette>> to that window’s chosen palette, then drops the guard at end-of-function:

// src/app.rs — handle_redraw_requested
let _g = self.win.theme_palette.map(PaletteScope::push);
// … entire paint runs with the per-window palette pushed …
// _g drops here, restoring the previous thread-local override.

palette() reads the thread-local first and only falls back to the global RwLock when the slot is empty. Background windows on other threads (or the same thread between paints) see the global. Single-window users with no override see the global. The override is opt-in, the cost is one read-thru on every palette() call, and nesting is LIFO-safe — overlays painted inside a per-window paint nest cleanly.

The Settings overlay (open with Cmd+, or tmnl > Settings…) gains a Window theme row. Its choices are (global) plus every theme file discovered under:

  • ~/.config/mnml/themes/
  • ~/Local/share/mnml/themes/
  • ~/Projects/mnml/themes/

(global) means “inherit the process palette” — the row’s default. Any other choice is this window’s override. / cycle through the choices with wrap-around; r resets to (global); Enter saves; Esc reverts to the snapshot taken when the overlay opened.

Live preview on nudge is not wired today — the row shows the new name but doesn’t repaint chrome until you press Enter. That’s a deliberate v1 cut: live preview would either nest a PaletteScope around every Settings paint frame (cheap but fiddly) or call set_theme on every arrow press (expensive — one TOML read per nudge). If you want to see the change, press Enter to commit.

A per-window theme retints every paint path that reads through palette() — cell colours, the chrome strip, chip backgrounds, the cursor, the gradient inside the bufferline cluster, all of it. The other window on your other monitor doesn’t move.

Theme is one knob. Phase 6c generalises the same pattern to the whole Config struct.

WindowState gains a cfg_override: Option<Config> slot. None (the default) means “inherit the App’s global cfg.” Some(c) means “paint this window with c.” A new accessor returns the effective config:

src/window.rs
pub fn effective_cfg<'a>(&'a self, global: &'a Config) -> &'a Config {
self.cfg_override.as_ref().unwrap_or(global)
}

Render-path knobs that should vary per window read through self.win.effective_cfg(&self.cfg) instead of self.cfg directly. The override is a full-Config overlay, not a sparse per-knob bag — that keeps the accessor branch trivial and lets the Settings overlay write the dialog’s edited Config struct verbatim.

Flipping the Settings overlay's Apply-to row from [global] to [window] scopes the edit to the focused window only — the on-disk cfg is untouched, other windows unaffected

The Settings overlay’s first row is Apply to, with two choices:

▸ Apply to: [global] / window
Editing globally. Enter writes to ~/.config/tmnl/config.toml and
applies process-wide. Switch to "window" to scope changes to this
window only.

| Scope | What Enter does | | --- | --- | | [global] (default) | Writes the cfg to disk. Applies process-wide. Clears any in-flight cfg_override on the focused window so it doesn’t mask the globally-saved value. | | [window] | Calls set_cfg_override(Some(edited)) on the focused window. On-disk cfg untouched. Other windows unaffected. The focused window’s GPU resizes immediately. |

r (reset row) and R (reset all) return Scope to global. The * modified marker lights when Scope is window. The help text spells out exactly what each scope does, so you don’t accidentally write a one-off window experiment to disk.

When you re-open Settings on a window that already has an override, the overlay reads effective_cfg so the rows show the override’s values, and Scope starts on window — so re-opening Settings on a windowed override doesn’t silently flip to Global on Enter.

Infrastructure plus a toggle is half the story. The other half is migrating every render-path read site from self.cfg.<knob> to self.win.effective_cfg(&self.cfg).<knob> so the override actually takes effect on paint.

Phase 6c/3 swept the following knobs across four stages:

| Stage | Knobs migrated | | --- | --- | | 1 | prompt_position, focused_pane_border_color (snapshot-before-gpu-borrow refactor). | | 2 | selection_invert_fg_bg, cursor_opacity, cursor_blink_animation, cursor_blink_ms, cursor_thickness, bell_flash_color, padding_color, background_opacity (+ _active / _inactive), image_storage_gpu_limit, tab_bar_position, window_padding_balance. | | 3 | tab_layout (horizontal vs vertical), launcher_position, prompt_position, macOS macos_native_tabs, show_tab_kind_icons, shell_context_chip. | | 4 | launcher_icons (count for strip-width math + per-icon glyph/colour clone), macOS trackpad_swipe_cycles_tabs, themed_prompt (4 shell-spawn sites). |

Once the sweep landed, a window-scoped Settings save of (say) tab_layout = "vertical" reshapes the focused window’s chrome — sidebar appears, body grid shifts right — without touching the other window where the horizontal strip stays the way you had it.

The pattern in every migrated site is snapshot before the GPU mutable-borrow — you can’t call self.win.effective_cfg(&self.cfg) inside if let Some(gpu) = &mut self.win.gpu { … } because both come from self.win. The fix is mechanical: read the knobs into a small RenderKnobs struct at the top of the paint, then read from the struct inside the borrow. Single-window users see no behaviour change; the snapshot resolves to &self.cfg.

Three knobs are deliberately process-global and ignore the scope toggle:

  • font_family
  • font_size
  • bold_is_bright

These are consumed at GPU atlas init time — Gpu::new rasterises every glyph in the font at the chosen size into a texture, and changing them mid-window requires rebuilding the atlas, reflowing the grid to the new cell dimensions, and re-rasterising every glyph. Each Gpu carries its own atlas instance, but they all read from process-global font setup.

Window-scoped font isn’t a user-asked-for shape anyway — most users want one font process-wide — so the per-window-atlas rebuild work is deferred until somebody files a feature ask. If you flip these in the Settings overlay under [window] scope today, the override is recorded but doesn’t take effect; the row is honest about which scope it really honours.

Same shape applies to command and cwd at shell spawn: they remain on self.cfg, because they’re spawn-time overrides typically set globally per session (tmnl --command vim etc.), not per window.

Persistence — N windows survive a restart

Section titled “Persistence — N windows survive a restart”

The earlier per-window state file (~/.config/tmnl/window_state.toml) was one struct. Quit with three windows open, relaunch, and only the focused one came back. Phase 6 persistence v3 fixes that with a forward-compatible schema bump.

~/.config/tmnl/window_state.toml
# Legacy flat fields — mirrors windows[0] so older tmnl binaries
# can still read the focused window's geometry.
position = [120.0, 80.0]
size = [1400.0, 900.0]
active = 0
theme_name = "kanagawa"
cfg_override_toml = """
prompt_position = "bottom"
tab_layout = "vertical"
"""
[[windows]]
position = [120.0, 80.0]
size = [1400.0, 900.0]
active = 0
theme_name = "kanagawa"
cfg_override_toml = """
prompt_position = "bottom"
tab_layout = "vertical"
"""
# … tabs, etc.
[[windows]]
position = [1700.0, 80.0]
size = [1400.0, 900.0]
active = 2
theme_name = "catppuccin"
# … tabs, etc.
[[windows]]
position = [200.0, 600.0]
size = [1200.0, 600.0]
active = 0
# inherits global theme + cfg

Each [[windows]] entry carries position, size, active tab index, theme override, cfg override (as a TOML-serialized string — see below), and the per-tab list. The legacy flat fields at the top mirror windows[0] so a tmnl binary that predates v3 can still restore the focused window’s geometry from the same file. New tmnl reads [[windows]] and ignores the flat duplicate.

Why cfg_override_toml is a TOML string instead of a nested Config: Config { last_window_cfg: Option<Config> } doesn’t terminate. The string-boxing sidesteps the infinite-type cycle, and the parse cost is amortised over one read per launch.

persist_window_state walks self.background_windows after saving the focused window and produces a Vec<WindowState>, one entry per open window. Save happens on every move + resize event, so a force-quit, panic, or power loss still leaves a recent snapshot.

Order in the vec is focused first, then backgrounds in their stored order. The next launch restores windows[0] as the focused window.

App::resumed reads load_all(), consumes the first entry as the focused window’s snapshot (current behaviour from v2), and queues the rest into a new pending_restore_windows: Vec<WindowState> field.

App::tick drains one pending entry per tick. Per-tick instead of all-at-once keeps the first-frame paint snappy and avoids back-to-back wgpu surface creation stutter (visible on 3+ saved windows). Each restored background window gets its saved theme + cfg override + position

  • size applied, then restore_tabs_from_state walks its tabs vec and calls the matching new_shell_tab / new_native_tab / browser helpers. Custom tab names re-apply via the helper’s existing logic.

The originally-focused window keeps focus after the queue drains — the restored windows show up as additional backgrounds you can `Cmd+“ cycle to.

window_state.toml is the per-window store described above. There’s also two process-global fallbacks in ~/.config/tmnl/config.toml:

# Most-recently-saved window theme. Read by `spawn_in_process_window`
# (Cmd+N) so a freshly spawned window inherits the family default.
last_window_theme = "kanagawa"
# Most-recently-saved window cfg override (TOML-serialized Config).
# Same role as last_window_theme for the cfg path.
last_window_cfg = "prompt_position = \"bottom\"\n"

window_state.toml always wins when both are present. The last_window_* knobs serve a different role — they’re the “next launch default for a freshly spawned window in this session,” read by spawn_in_process_window when Cmd+N produces a new window. The window_state store can’t serve there because the new window has no window_state entry yet.

A practical session that exercises every piece:

  1. Launch tmnl. One window opens with the global theme and cfg.
  2. Hit Cmd+N. A second window spawns in-process, inheriting the global theme + the last_window_* knobs if any.
  3. In the second window, open Settings (Cmd+,), set Apply to: [window], change Window theme: kanagawa, flip tab_layout: vertical. Press Enter.
  4. The second window retints to kanagawa and grows a left sidebar. The first window is untouched.
  5. `Cmd+“ cycles you between them. Each paints with its own palette and its own chrome layout.
  6. Move both windows around. Quit (Cmd+Q).
  7. Relaunch. Both windows come back, same positions, same themes, same tab_layout choices, same tabs.