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
- Workspace concept
- Split tree architecture
- Splitting at runtime
- LayoutShell vs Tab
- Tab management
- Command line
- Buffer command
- Global status bar (planned)
- Global application state
- Planned window operations
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:
FixedSecondholds it to one row at the bottom edge whatever the pane ratios do, andCollectLeaveshides it so focus movement,:close,:onlyand 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:
- Register it with
AddFloatingWidget, last, so it paints over the workspace. - Gate on your own visibility flag in both
DrawandHandleEvent—WidgetsList.HandleEventbroadcasts to every registered widget. - Own keys with a
platform.Mode(likeModeCompletion), not with tree position. - Paint the frame with
SetContent, neverDrawHorizontalLocal/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:
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¶
What happens:
- The focused leaf becomes a split node.
Firstretains the original widget;Secondgets the new widget.- Focus moves to
First(original pane). Ratiois set to0.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:
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 (rowH-1of the terminal). - Command history (
termforge.History) — Up/Down navigation. - Tab completion (
termforge.AutoCompleter) — command name only. SubmitMsgon the event bus — resolvedCommandID+ args;cmdCtlhandles it viaplatform.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):
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).
Related documentation¶
- termforge: UI Architecture — layout engine internals
- INPUT.md — focus and command mode
- termforge: Rendering — split border drawing