Terminal User Interfaces (TUIs) have come a long way from simple menus and forms. Modern TUI applications increasingly need sophisticated UI paradigms—multiple panels, popups, dialogs, and floating windows. However, building a robust window management system from scratch presents unique challenges in the terminal environment.
In this article, I'll walk through the architecture, design decisions, and challenges faced while building a complete window management system for TUI applications. The full source lives on GitHub at subhasundardass/tuix, if you'd rather read the real code than just the snippets below. The code we'll discuss provides a production-ready window manager with features like:
- Floating windows with absolute positioning
- Modal windows that block background interaction
- Z-order management (window stacking)
- Focus management with keyboard navigation
- Fluent API for easy window configuration
- Thread-safe operations
Why Build a Window System for TUIs?
I didn't set out to build a window manager. I set out to add a "confirm delete" dialog to a CLI tool, and about three days later I had a hundred-line rabbit hole on my hands.
Here's the thing nobody tells you when you start writing TUIs: the terminal was never designed for overlapping surfaces. A GUI toolkit gives you compositing, z-buffers, and a windowing system for free — the OS handles it. In a terminal, you get a grid of cells and a cursor. That's it. If you want one panel to visually sit "on top of" another, you have to figure out what that even means when both are just characters being written to the same 2D buffer.
Most TUI libraries sidestep this by giving you a single, fixed layout — a tree of panes that split the screen and never overlap. That's fine until your app actually needs a popup. A confirmation dialog. A command palette. An autocomplete dropdown that has to float above whatever's underneath it without destroying that content. At that point, you're not laying out boxes anymore — you're managing a window system, whether you meant to or not.
So that's what I built. Not because it was the fun weekend project I was planning, but because every alternative I tried (special-casing "just this one popup," hacking a second screen buffer, praying) fell apart the second I needed a second popup on top of the first one.
The Core Problem: Everything Shares One Buffer
In a real windowing system, each window typically owns its own surface, and the compositor merges them at the end. Terminals don't give you that. Every widget, every panel, every floating dialog is ultimately writing characters into the same flat buffer that gets flushed to the screen.
That means a "window manager" for a TUI isn't really managing windows in the OS sense — it's managing draw order and input routing against a shared canvas. Two problems fall out of that immediately:
- Rendering order matters enormously. If window B is on top of window A and they overlap, B has to be drawn after A, cell by cell, or you get visual garbage — characters from the wrong window bleeding through.
- Input has to be routed, not broadcast. A keypress can only go to one place at a time (or, for modals, be deliberately blocked from going anywhere else). Figure out who "owns" the terminal at any given moment, and you've solved half the problem.
Everything else in the system — z-ordering, focus, modality — is really just infrastructure built to answer those two questions cleanly.
Architecture: Windows as a Managed Stack
The design I landed on treats every floating surface as a Window struct with a handful of properties: position, size, content, a z-index, and some behavioral flags (is it modal, is it focusable, is it currently visible). The WindowManager owns a slice of these, sorted by z-index, and it's the single source of truth for "what's currently on screen and in what order."
type Window struct {
ID string
Title string
X, Y int
Width int
Height int
ZIndex int
Modal bool
Focusable bool
Visible bool
Content Drawable
}
type WindowManager struct {
mu sync.RWMutex
windows []*Window
focused string
nextZ int
}
The mu sync.RWMutex up there isn't decoration. TUI apps that pull data from network calls or background jobs will very often want to pop up a "loading" window or update content from a goroutine, and the moment you have concurrent writers touching the window stack, you need real locking — not a "well it's probably fine" shrug. More on that later.
Every operation that changes what's visible — opening a window, closing one, bringing one to front — goes through the manager rather than mutating window state directly. That single choke point is what makes the rest of the system tractable.
Floating Windows and Absolute Positioning
The base layout system in most TUI frameworks works in relative terms: split the screen, size things as a fraction of their container, let the framework do the arithmetic. Floating windows break that model on purpose — they need to say "put me at row 10, column 20, and I don't care what else is on screen."
func (wm *WindowManager) Open(w *Window) {
wm.mu.Lock()
defer wm.mu.Unlock()
w.ZIndex = wm.nextZ
wm.nextZ++
w.Visible = true
wm.windows = append(wm.windows, w)
wm.focused = w.ID
}
Positioning itself is simple math — clamp X/Y so the window doesn't render off the edge of the terminal, clamp width/height so it doesn't exceed the available rows and columns. The part that's not simple is what happens when two floating windows overlap, which is really a rendering problem more than a positioning one.
Rendering: Painter's Algorithm, Terminal Edition
Once windows are sorted by z-index, rendering is conceptually just the painter's algorithm from computer graphics: draw the bottom-most window first, then paint each subsequent window on top, letting later windows overwrite earlier ones cell by cell.
func (wm *WindowManager) Render(screen *Buffer) {
wm.mu.RLock()
defer wm.mu.RUnlock()
sorted := make([]*Window, len(wm.windows))
copy(sorted, wm.windows)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].ZIndex < sorted[j].ZIndex
})
for _, w := range sorted {
if !w.Visible {
continue
}
w.Content.DrawInto(screen, w.X, w.Y, w.Width, w.Height)
}
}
The subtlety is in DrawInto. It's not enough to write a window's characters into the buffer — you also have to write its background, including a drop shadow or border if the window has one, so it visually reads as "in front of" whatever it's covering rather than looking like it's been stamped through the layer below. I ended up drawing a one-cell shadow offset to the bottom-right of every floating window; it's a small touch, but it's the single biggest thing that made the UI feel like actual overlapping windows instead of just "text with holes in it."
Modal Windows: Blocking on Purpose
Modals are conceptually simple — a window that owns input exclusively until it's dismissed — but they expose a design question that's easy to get wrong: does "modal" live on the window, or on the manager?
I put it on the manager, computed from the window stack, rather than as a standalone flag anywhere else in the app:
func (wm *WindowManager) TopModal() *Window {
wm.mu.RLock()
defer wm.mu.RUnlock()
for i := len(wm.windows) - 1; i >= 0; i-- {
if wm.windows[i].Modal && wm.windows[i].Visible {
return wm.windows[i]
}
}
return nil
}
func (wm *WindowManager) HandleInput(ev InputEvent) {
if modal := wm.TopModal(); modal != nil {
modal.Content.HandleInput(ev)
return
}
if focused := wm.FocusedWindow(); focused != nil {
focused.Content.HandleInput(ev)
}
}
That "top-most modal wins, full stop" rule matters because you'll eventually stack modals — a confirmation dialog spawned from within another dialog is a completely normal thing for users to trigger, and if your modal logic isn't stack-aware, the second dialog either won't block correctly or will block the wrong thing. Deriving modality from the stack, instead of tracking "is anything modal right now" as separate boolean state, means it's correct by construction no matter how deep the stack gets.
Focus Management and Keyboard Navigation
Mouse support in terminals is inconsistent enough across emulators that keyboard navigation isn't optional — it's the primary interface for a meaningful chunk of your users. That makes focus tracking one of the more important pieces of the whole system, even though it looks like the most boring one.
The rule I settled on: focus always follows the top of the z-stack unless the user explicitly moves it (Tab / Shift+Tab between focusable windows, or clicking a window if the terminal supports mouse events). Opening a new window focuses it automatically; closing a window returns focus to whatever is now on top.
func (wm *WindowManager) CycleFocus(reverse bool) {
wm.mu.Lock()
defer wm.mu.Unlock()
focusable := wm.focusableWindows()
if len(focusable) == 0 {
return
}
idx := indexOf(focusable, wm.focused)
if reverse {
idx = (idx - 1 + len(focusable)) % len(focusable)
} else {
idx = (idx + 1) % len(focusable)
}
wm.focused = focusable[idx].ID
wm.BringToFront(focusable[idx].ID)
}
One detail that took a couple of iterations to get right: bringing a window to front on focus is usually what you want, except for background utility windows — a status bar, a log panel — that shouldn't jump on top of a modal just because they briefly held focus. I ended up adding a Sticky flag for exactly that case, which excludes a window from z-reordering even while letting it stay focusable.
The Fluent API
None of the above is much fun to configure by hand every time you want a dialog. So the actual thing most consumers of this library touch is a builder:
window := NewWindow("confirm-delete").
Title("Confirm Delete").
Size(40, 8).
Position(Center, Center).
Modal(true).
Content(confirmDialog).
OnClose(func() {
log.Println("dialog dismissed")
}).
Build()
wm.Open(window)
Position(Center, Center) deserves a mention — it's resolved lazily, against the current terminal dimensions, at the moment the window is opened rather than when it's built. That matters because terminals resize, and a window built once but reused across multiple opens (a settings dialog, say) needs to re-center correctly every time, not just the first.
The fluent style isn't just sugar. It front-loads validation — width/height bounds, ID collisions, invalid position combos — into the builder, so Open() can assume it's always handed a well-formed window instead of defensively checking everything at render time.
Thread Safety: The Part That Bites You Later
I'll be honest — the first version of this didn't have a mutex anywhere. It worked fine, right up until I wired in an async data fetch that wanted to close a "loading" window and open a "results" window from a goroutine while the main loop was mid-render. The result was a window stack that occasionally rendered with a window half-removed — not a crash, just silent visual corruption, which is a much worse bug to track down.
The fix is the sync.RWMutex from earlier, applied consistently: every mutation takes the write lock, every read (including the render pass) takes the read lock. The one rule that took discipline to enforce was making sure Content.HandleInput and Content.DrawInto — the callbacks into user-supplied window content — never get called while the manager's lock is held, since a badly behaved callback that tries to open another window from inside its own draw call would deadlock instantly. Snapshotting the sorted window list before iterating, as in the Render function above, solves that cleanly: the lock only needs to cover the bookkeeping, not the actual drawing.
What I'd Do Differently
A few things I'd change if I were starting over:
- Dirty-rect tracking. Right now every render redraws the full stack from scratch. It's fast enough for the window counts a typical TUI app actually has, but it's wasteful, and a diff-based redraw would matter a lot for anything with animation.
- Resize events per-window. Terminal resizes currently trigger a full re-layout pass across every open window. Most windows don't need to know or care that the terminal resized unless they're centered or edge-anchored; a subscription model would cut a lot of unnecessary work.
- Better shadow/border theming earlier. I treated visual polish as a late-stage concern and regretted it — the shadow-offset trick that made floating windows read as floating was worth doing on day one, not day twelve.
Closing Thoughts
The terminal is a genuinely constrained environment, and it's easy to underestimate how much of "real" windowing — layering, focus, modality — you end up reimplementing the moment your TUI needs more than a single static layout. None of the individual pieces here are exotic; it's mostly careful bookkeeping around a shared buffer and a very literal notion of "on top." But that bookkeeping is exactly the part that's tedious to get right under deadline pressure, which is usually when people reach for a shortcut and end up with popups that flicker, dialogs that don't block correctly, or focus that silently gets lost. Getting the primitives right once, and building everything else on top of them, turned out to be worth the three days it wasn't supposed to take.
If you want to dig into the actual implementation, run it yourself, or file an issue about something I got wrong, the repo is at github.com/subhasundardass/tuix. Stars, forks, and PRs are all welcome — and if you build something with it, I'd love to hear about it.
Top comments (0)