Skip to content

Window Management

gdbforge organizes debugger panes through a Workspace containing a recursive split tree, managed at the top level by tabs and a global command line. Each workspace pane shows a per-pane status line at its bottom edge when focused; a global debugger status bar is still planned above the command line.

The split tree, tabs, chrome bands, and per-pane status line are provided by termforge; this document covers how gdbforge uses them and the policy it layers on top. For the framework mechanics see termforge: Window Management.

Companion docs: INPUT.md · ARCHITECTURE.md


Table of contents


Top-level layout

The root UI is a workspace that fills the screen, with the command line pinned to its bottom edge inside the split tree:

Root
├── TabBar      (fixed height)
└── Workspace   (split tree)
    ├── panes   (ratio sized)
    └── CmdLine (pinned leaf, exactly 1 row)
+--------------------------------------------------+
| Tab1 | Tab2 | Tab3                               |
+--------------------------------------------------+
|                                                  |
|                 Workspace                        |
|                                                  |
+--------------------------------------------------+
| : command line                                   |
+--------------------------------------------------+
graph TB
    Root["Root"]
    TabBar["TabBar<br/>(fixed height)"]
    Workspace["Workspace<br/>(split tree, fills the screen)"]
    Panes["panes<br/>(ratio sized)"]
    CmdLine["CmdLine<br/>(pinned leaf, 1 row)"]

    Root --> TabBar
    Root --> Workspace
    Workspace --> Panes
    Workspace --> CmdLine

Source: diagrams/top_level_ui.mermaid

Design decision: the command line is a pinned leaf at the bottom of the tree, not a chrome band:

  • The line above it is that split's own separator, so no layout draws a border outside its own rect.
  • It is still a stable anchor: FixedSecond holds it to one row at the bottom edge whatever the pane ratios do, and CollectLeaves hides it so focus movement, :close, :only and separator drags behave as if it were not in the tree.
  • Transient chrome (wildmenu, help, future message bars) is not in the tree. It goes in the App's floating tier, which reserves no space — no popup compositor.

App chrome is a WidgetsList — the flat Layout at App level, the counterpart of WidgetTree inside the workspace. internal/app/setup.go declares the placement once; nothing assigns rects on resize:

a.AddWidget(a.Widget())                     // TabWidget: fills the screen
a.SetCmdline(a.cmdWidget)                   // paste target in command mode
lay.PinBottom(a.cmdWidget, 1)               // CmdWidget (: line)
a.AddFloatingWidget(bar, completionBarRect) // wildmenu on row H-2

The wildmenu view is chosen by completionAsWindow in setup.go: the bar above, or CompletionPopupWidget centered over the workspace. Both are floating, so the switch changes nothing about the layout.

WidgetsList.BuildLayout gives the fill widget everything the rows leave over; floating widgets are placed by their callback and contribute nothing to the row math. So the workspace spans H rows, with the cmdline on H-1 and its separator on H-2 — the same row the bottom panes paint their status labels onto. App.Draw paints in registration order, so the wildmenu window covers the workspace.

Geometry is rebuilt on every frame and on every UpdateCanvas, so a resize needs no application hook at all — AppApi has none. App.WidgetRect(w) returns the rect a widget was given; the cmdline is the exception, since it lives in the tree — cmdLineRect() reads WidgetTree.PinnedBottomRect().

Extending chrome (no popup layer)

Three placements, and the choice is about lifetime, not looks:

Chrome Placement Example
Transient window or overlaid row AddFloatingWidget at App level wildmenu (CompletionBarWidget on H-2, or CompletionPopupWidget)
Permanent full-width edge pinned tree leaf via PinBottom the : command line
Permanent band outside the workspace AddRowWidget a future tab bar or status bar

For an overlay:

  1. Register it with AddFloatingWidget, last, so it paints over the workspace.
  2. Gate on your own visibility flag in both Draw and HandleEvent — WidgetsList.HandleEvent broadcasts to every registered widget.
  3. Own keys with a platform.Mode (like ModeCompletion), not with tree position.
  4. Paint the frame with SetContent, never DrawHorizontalLocal / DrawVerticalLocal: those run border composition and would fuse the window frame into the pane separators underneath.

Do not introduce a separate popup/z-order system. Registration order is the z-order, and a floating widget already costs no layout space.


Workspace concept

The Workspace is everything below the TabBar, cmdline row included. It is the only place where recursive splits exist. gdbforge also has a LayoutShell type (internal/app/workspace*.go) that owns pane policy above the layout — see LayoutShell (gdbforge) vs Tab.

Workspace panes are widgets — views bound to application models owned by *Ctl controllers. Typical models and their views:

Model Widget (view) Purpose
Source buffer (per file, bufferCtl) CodeWidget File, PC ━━▶, BP gutters (BreakGutter)
AssemblyList (asmCtl) AssemblyWidget Disassembly; addr BPs; autoAsm when source missing
BreakpointList (breakCtl) BreakpointWidget Breakpoint list; host intents
Session via consoleCtl GDBWidget Debugger console paint + OnSubmit
Inferior ptyx.TTY (inferiorIOCtl) OutputWidget Program stdin/stdout
ThreadList (debugInfoCtl) ThreadWidget Thread list on stop
CallStack (debugInfoCtl) CallStackWidget Stack frames on stop
AppState.SourceFiles FileListWidget :edit project picker
ExecClient (via controller) ExecWidget :! shell / SSH
Logger sink LoggerWidget Application log
DebuggerApp (composition root)
├── backend.Backend          →  Session (GDB or Delve)
├── breakCtl.list            →  BreakpointWidget + Code/Asm gutters
├── asmCtl                   →  AssemblyWidget
├── debugInfoCtl             →  ThreadWidget / CallStackWidget
├── bufferCtl.files          →  CodeWidget(s)
├── consoleCtl               →  GDBWidget
└── Workspace                →  TabWidget (geometry + focus)

Each pane is a leaf widget in the split tree. The Workspace band does not draw content itself — it delegates geometry to WidgetTree.BuildLayout. Models exist whether or not a widget is currently displaying them.


Split tree architecture

The split tree is a full binary tree where internal nodes are splits and leaves are widgets.

graph TB
    WS["Workspace"]
    VS["VerticalSplit"]
    SRC["Source View"]
    HS["HorizontalSplit"]
    BP["Breakpoints"]
    CON["Console"]

    WS --> VS
    VS --> SRC
    VS --> HS
    HS --> BP
    HS --> CON

Source: diagrams/split_tree.mermaid

Example ASCII layout matching the diagram above:

Workspace
└── VerticalSplit
    ├── Source
    └── HorizontalSplit
        ├── Breakpoints
        └── Console

Two rules carry the whole model, and they are the easiest thing to miss:

Every visible separator on screen originates from an internal split node. Every actual pane/widget is a leaf node.

A split node holds no widget. It owns a direction, a Ratio, and two children; it takes the rectangle handed to it, keeps 1 cell for the separator it draws, and gives the rest to First and Second. So counting split nodes tells you exactly how many separator lines appear. In the Mermaid diagrams below, hexagons are split nodes (the separators) and rectangles are leaf panes.

The node kinds, and the two simple build-up cases (one separator, then a nested separator) are walked through step by step in termforge: Split tree architecture. What follows is the larger tree gdbforge actually ships.

The shipped default layout

Exactly as built by BuildDefault (internal/gdbforge/layout/default.go), with the shipped ratios from AppState.DefaultLayoutRatios (Left 2/3, Output 1/2, BottomFirst 1/3). 5 split nodes and 6 leaves, so 5 separators on screen.

|  SPLIT vertical, ratio 2/3                     <- root
├── -  SPLIT horizontal, ratio 1/2
│   ├── Code                       LEAF
│   └── GDB                        LEAF
└── -  SPLIT horizontal, ratio 1/2
    ├── Output                     LEAF
    └── -  SPLIT horizontal, ratio 1/3
        ├── Breakpoints            LEAF
        └── -  SPLIT horizontal, ratio 1/2
            ├── Threads            LEAF
            └── Call stack         LEAF

Resulting panes for a 120x40 workspace band:

 x=0                                x=79 x=80                       x=119
  +-----------------------------------+--------------------------------+ y=0
  |                                   |                                |
  |  Code                             |  Output                        |
  |  79 x 19                          |  40 x 19                       |
  |                                   |                                |
  +-----------------------------------+--------------------------------+ y=19
  |                                   |  Breakpoints        40 x 6     |
  |  GDB                              +--------------------------------+ y=26
  |  79 x 20                          |  Threads            40 x 6     |
  |                                   +--------------------------------+ y=33
  |                                   |  Call stack         40 x 6     |
  +-----------------------------------+--------------------------------+ y=39
                                      ^
                            the root "|" split node

Each separator maps back to exactly one split node:

Separator on screen Split node Ratio
vertical at x=79, full height root \| 2/3
horizontal at y=19, x=0..78 - Code / GDB 1/2
horizontal at y=19, x=80..119 - Output / lists 1/2
horizontal at y=26, x=80..119 - Breakpoints / rest 1/3
horizontal at y=33, x=80..119 - Threads / Call stack 1/2

The two separators at y=19 are different split nodes that happen to line up, because both columns are 40 rows tall and both use ratio 1/2. Widen only the left pane and they stay aligned; drag either one and only that node's Ratio changes.

graph TB
    R{{"SPLIT (vertical)<br/>ratio 2/3"}}
    LC{{"SPLIT (horizontal)<br/>ratio 1/2"}}
    CODE["LEAF: Code"]
    GDB["LEAF: GDB"]
    RC{{"SPLIT (horizontal)<br/>ratio 1/2"}}
    OUT["LEAF: Output"]
    B1{{"SPLIT (horizontal)<br/>ratio 1/3"}}
    BP["LEAF: Breakpoints"]
    B2{{"SPLIT (horizontal)<br/>ratio 1/2"}}
    TH["LEAF: Threads"]
    CS["LEAF: Call stack"]

    R -->|First / left| LC
    R -->|Second / right| RC
    LC -->|First / top| CODE
    LC -->|Second / bottom| GDB
    RC -->|First / top| OUT
    RC -->|Second / bottom| B1
    B1 -->|First / top| BP
    B1 -->|Second / bottom| B2
    B2 -->|First / top| TH
    B2 -->|Second / bottom| CS

applyDefaultRatios sets only the root, the right column, and the two nested list splits. The Code / GDB ratio of 1/2 comes from ComputeRatios, which runs on every Split while the builder has equalalways on.


Splitting at runtime

tree := NewWidgetTree(initialWidget)
tree.Split(Vertical, newWidget)  // splits focused pane

What happens:

  1. The focused leaf becomes a split node.
  2. First retains the original widget; Second gets the new widget.
  3. Focus moves to First (original pane).
  4. Ratio is set to 0.5.

Design rationale: splitting at focus matches cgdb/emacs user expectations. Alternative designs (split always right, pick target pane first) may be added as commands (:vsplit, :hsplit) later.

NewTabTwoHozSplitWins builds a tree with an initial horizontal split of the two widgets. Debugger workspaces live in internal/gdbforge/layout and are applied with :layout <name>:

Layout Tree
panels (startup) Left Code/GDB 2/3; right IO 1/2; bottom half = (Threads | Callstack) 2/3 over Breakpoints 1/3
default Left Code/GDB 2/3; right IO / Breakpoints / Threads / Call stack (DefaultLayoutRatios)
classic Full-width Code over GDB (original cgdb)

Per-layout normal-mode key policy is registered in internal/app/layout_behavior.go (not in termforge Tab).


LayoutShell (gdbforge) vs Tab (termforge)

gdbforge owns a LayoutShell layer (internal/app/workspace*.go) above termforge.TabWidget:

Layer Owns
LayoutShell Pane marks (code / gdb / asm / last), Code/GDB activation, placement (placeCodeInSlot, sticky GDB swap), layout apply — split-tree policy
TabWidget Tab list chrome; hands the active tab's Layout its canvas and events
DebuggerApp / *Ctl Debugger domain (breakpoints, stops, threads, buffers, …)

LayoutShell is workspace policy, not debugger policy. Split-tree ops go straight to the layout via LayoutShell.Layout() / DebuggerApp.Layout(), which returns *termforge.WidgetTree concretely — no forwarding through Tab.

The generic side of this — Tab as a Layout container, the interface, and why Tab carries no forwarding methods — is documented in termforge: Tab is a generic Layout container.


Tab management

Tab is chrome: a title plus a Layout. For the tiling layout, focus and named leaf marks live on the WidgetTree itself. Mark names and focus policy are app-private on LayoutShell. termforge itself stays free of debugger roles so other apps can reuse it.

flowchart LR
    TabBar["TabBar"]
    T1["Tab 1 · content"]
    T2["Tab 2 · content"]
    T3["Tab 3 · content"]

    TabBar --> T1
    TabBar --> T2
    TabBar --> T3

Current TabWidget implementation:

type Tab struct {
    Title   string
    Content Layout
}

type TabWidget struct {
    tabs   []Tab
    active int
}

Its whole surface is Layout(), SetLayout(), Draw, HandleEvent and the two constructors.

Feature Status
Single tab container Implemented
Hand events/draw to active tab's Layout Implemented
Named leaf marks on WidgetTree Implemented
Generic non-tree tab content Implemented (Layout interface); no second implementation yet
Nested layout inside a leaf Structurally supported; focus arbitration not implemented
Tab header rendering Not implemented
Tab switching Not implemented
Tab close / new tab Not implemented
Persist layout per tab Not implemented

Design decision: gdbforge tabs are workspace presets, not separate debugger sessions (initially). A tab might represent "source + console" vs "registers + memory". Multi-session tabs may come later with backend association per tab.

Named layout builders (internal/gdbforge/layout) return a *termforge.WidgetTree; LayoutShell.ApplyLayout mounts it via TabWidget.SetLayout and then re-applies the startup wiring (status clipboard, resize hook, equalalways, marks) that a freshly built layout does not carry.


Command line

The CmdLine is a top-level band for Vim-style : commands, distinct from the GDB (gdb) prompt inside a console pane.

+--------------------------------------------------+
| : break main                                     |
+--------------------------------------------------+

CmdWidget (cmd_widget.go) provides:

  • Vim-style : activation and drawing on the bottom line (row H-1 of the terminal).
  • Command history (termforge.History) — Up/Down navigation.
  • Tab completion (termforge.AutoCompleter) — command name only.
  • SubmitMsg on the event bus — resolved CommandID + args; cmdCtl handles it via platform.Subscribe.

Command mode is entered by DebuggerApp (: → ModeCommand, CmdWidget.Activate()), not by CmdWidget alone. Esc returns to normal mode at the app layer.

Current state: :quit / :q exits the debug session (same as Ctrl-D). :close removes the focused pane/split. Split commands (:vs, :split) partially wired. Unknown commands emit termforge.CmdUnknown.

Design decision: separate CmdLine from GDB console because:

  • GDB console speaks MI/cli dialect; CmdLine speaks UI commands (:split, :focus, :close, :quit).
  • Users can run UI operations without sending spurious input to GDB.
  • Completion vocabularies differ (UI vs debugger).

Planned flow details: see INPUT.md and ARCHITECTURE.md.


Buffer command

Implemented today: Vim-like :b name switches among builtins (help, about, logger, gdb, breakpoint, threads, callstack, output, exec, asm) and open file CodeWidgets; :edit / :edit file opens the project picker or a per-file source buffer (:e is the unique prefix). Workspace trees: :layout default|panels|classic|wide with optional asm (internal/gdbforge/layout).

The layout "gdb" leaf is a fixed slot: :b / :edit / :help / :! / Ctrl-O refuse to replace GDBWidget there. Focus another pane first to open a different view. :b gdb / i still focus (and restore) GDB on that leaf.

The longer-term :buffer idea selects which application model to display — it does not open a text file (except via the :e path above).

:help
:b help
:b about
:b logger
:b gdb
:b breakpoint
:b threads
:b callstack
:b io
:b output
:layout default
:layout panels
:layout classic
:set clearoutput
:set noclearoutput
:edit
:edit main.c
:b main.c
:set markcolor darkblue
:set breakcolor red
:set breakdisabledcolor yellow

Future model names (aspirational):

:buffer registers
:buffer memory

Related window commands (same model-binding semantics):

Command Action
:buffer <name> Display model in focused pane
:split / :vsplit Split focused pane; new pane also bound via subsequent :buffer or default
:tab Open model in a new tab workspace

The architecture does not use :attach <name>. All models exist from startup; the user only chooses which to display. See ARCHITECTURE.md.

Current state: :b / :edit bind views to models owned by *Ctl controllers (models.*, AppState). See ARCHITECTURE.md — MVC.


Global status bar (planned)

A global status bar is still planned between Workspace and CmdLine (or integrated into CmdLine's opposite edge):

+--------------------------------------------------+
| Workspace …                                      |
+--------------------------------------------------+
| Normal | main.c:42 | stopped | thread 1          |  ← StatusBar
+--------------------------------------------------+
| :                                                |
+--------------------------------------------------+

Planned contents:

Segment Source
Mode Normal / Focus / Command
Location Current file:line from debugger
Target state running / stopped from MI *stopped
Thread Active thread ID
Backend GDB / OpenOCD indicator

Design decision: the global status bar is read-only and outside the split tree — it never steals focus. Updates arrive as typed messages on platform.EventBus from debugger backends, not from widget polling. This is separate from the per-pane focus indicator described above.


Global application state

App.State() returns *platform.AppState — process-global state for the running session:

Field Purpose
Mode Input mode: Normal / Insert / Command
PTYOwner Who holds exclusive PTY write intent (none / ui / mcp / app)
EqualAlways Vim-like: when true, split ratios rebalance to equal after Split / close (not every paint). :set equalalways also rebalances immediately.
EscToCode Esc restores last non-Code/non-GDB pane if any, else CodeWidget (:set esctocode / :set noesctocode; default on)
BreakMain Insert break main on GDB session start (:set breakmain / :set nobreakmain; default on; skipped on YAML restore or -x/-ex)
GdbListenPrint Paint App/MCP replies in the GDB console (:set gdblistenprint / :set nogdblistenprint; default on)
DefaultLayoutRatios Presets for :layout default: Left 2/3, Output 1/2 (right IO column), BottomFirst 1/3 (Breakpoints share of bottom half)
LayoutLeftRatio Alias for DefaultLayoutRatios.Left
SourceFiles Paths from -file-list-exec-source-files (App query on stop / :edit)
MarkColor Focused list selection background (:set markcolor; default blue)
MarkDimColor Unfocused list selection background (:set markdimcolor; default gray)
BreakColor Enabled breakpoint background (:set breakcolor; default red)
BreakDisabledColor Disabled breakpoint background (:set breakdisabledcolor; default yellow)
PCColor Code ━━▶ row background (:set pccolor; default darkslategray)
StackBreakColor Stop-PC highlight on BP / stack #0 / thread (:set stackbreakcolor; default green)
CodeSelColor Code browse cursor (:set codeselcolor; default darkblue)
MutedColor Empty-list / dim text (:set mutedcolor; default gray)
StopFile / StopLine Real PC from *stopped (━━▶); not moved by BP list browse
CurrentFile / CurrentLine Browse / selected frame location for CodeWidget
st := app.State()
st.PTYOwner()           // platform.PTYOwnerApp during silent file-list Query
st.SetEqualAlways(true) // :set equalalways
st.StopFile()           // after *stopped (━━▶)

PTY exclusivity is still enforced by ptyx.WithWrite; PTYOwner is the status so the UI can suppress console paint for App/MCP traffic when listen-print is off (:set nogdblistenprint; default paints). Layout: :set equalalways / :set noequalalways; :layout default|panels|classic. IO: :b io (alias :b output), :set clearoutput / :set noclearoutput. Source: :edit name opens a per-file CodeWidget (PaneName = basename); :edit opens the project file picker (:e = unique prefix); :b filename switches to an already-open buffer; stops show ━━▶ on the PC line. Breakpoints: :b breakpoint, CodeWidget Space, sync + YAML persist in DEBUGGER_INTEGRATION.md / breakpoint persistence.


Planned window operations

Command Action Status
:split / :vsplit Split focused pane horizontally / vertically Done (:vs / :split)
:close Close focused pane (collapse split) Partial
:focus left/right/up/down Move focus Done (:window / Ctrl-W)
:tabnew / :tabclose / :tabn Tab management Planned
:only Collapse to single pane Done (:only / Ctrl-W o)
:resize +N/-N Adjust split ratio Planned

Remaining items call into WidgetTree / TabWidget APIs from the command router (INPUT.md).