TUI Architecture¶
This document describes shelfctl's terminal user interface architecture — a unified single-program design using the Bubble Tea framework.
Overview¶
When launched with no arguments (shelfctl), a single persistent Bubble Tea program runs for the entire session. All views (hub, browse, edit, shelve, etc.) are internal state transitions — no screen flicker, no program restarts.
shelfctl (no args)
↓
Single tea.NewProgram starts
↓
Hub view → user selects "Browse" → Browse view (instant, no flicker)
↓
Browse view → user presses 'q' → Hub view (instant, no flicker)
↓
... continues until user quits
Direct commands (shelfctl browse, shelfctl shelve book.pdf, etc.) still run standalone as before.
Core Components¶
Orchestrator (internal/unified/model.go)¶
The central coordinator. Holds all view models and routes messages/rendering to the active view.
type Model struct {
currentView string // "hub", "browse", "edit-book", etc.
hub HubModel
browse BrowseModel
editBook EditBookModel
shelve ShelveModel
// ... other view models
width, height int
}
Update()routes messages to the current view, except navigation messages which switch viewsView()delegates rendering to the current view- Single
tea.NewProgram()call ininternal/app/root.go
Navigation Messages (internal/unified/messages.go)¶
Views communicate via typed messages instead of return values:
// Switch to another view
type NavigateMsg struct {
Target string // "hub", "browse", "shelve", etc.
Data interface{} // Optional context
BookItem *tui.BookItem // Optional single book for direct-edit flows
}
// Exit the application
type QuitAppMsg struct{}
// Request an operation that suspends the TUI
type ActionRequestMsg struct {
Action tui.BrowserAction
BookItem *tui.BookItem
ReturnTo string
}
// Request a non-TUI command (shelves, index, cache-info, etc.)
type CommandRequestMsg struct {
Command string
ReturnTo string
}
View Models (internal/unified/*.go)¶
Each view is a self-contained model that:
- Handles its own key bindings and rendering
- Emits NavigateMsg to switch views (never calls tea.Quit)
- Emits ActionRequestMsg or CommandRequestMsg for operations needing TUI suspension
Fully integrated views (zero flicker):
- hub.go — Main menu with scrollable details panel and command palette (ctrl+p)
- browse.go — Book browser with multi-select and actions
- shelve.go — File picker and metadata form
- edit_book.go — Book picker and edit form
- move_book.go — Multi-select picker and destination selector
- delete_book.go — Multi-select picker with confirmation
- cache_clear.go — Multi-select picker for cache operations
- create_shelf.go — Shelf creation form
Suspend-resume operations (brief screen clear, then returns): - View shelves, generate index, cache info — print output to terminal - Import repository, delete shelf — run interactive workflows
Shared TUI Utilities (internal/tui/)¶
Footer Highlight (footer.go)¶
Shared across all views. When a shortcut key is pressed, the corresponding footer label highlights for 500ms.
// Set highlight and return a 500ms clear timer
func SetActiveCmd(activeCmd *string, key string) tea.Cmd
// Render footer bar with optional highlight
func RenderFooterBar(shortcuts []ShortcutEntry, activeCmd string) string
Each view has an activeCmd string field, handles ClearActiveCmdMsg, and calls RenderFooterBar in its View().
Reusable Components¶
list_browser.go— Browse list with details panel, multi-select, download progressbook_picker.go— Single and multi-select book pickersshelf_picker.go— Shelf selection pickerfile_picker.go— Miller columns file browser with multi-selectedit_form.go— Metadata edit form with text inputsshelve_form.go— Add book form with metadata + cache checkboxshelf_create_form.go— New shelf creation formprogress.go— Download/upload progress bar
All pickers use picker.Base from bubbletea-picker for consistent key handling, window resize, and border rendering.
Suspend-Resume Pattern¶
For operations that need normal terminal output (tables, external commands):
Hub view (in TUI)
↓ User selects "View Shelves"
↓
CommandRequestMsg{Command: "shelves", ReturnTo: "hub"}
↓
Orchestrator suspends TUI (exits alt screen)
↓
Command runs in normal terminal, prints output
↓
"Press Enter to return..."
↓
TUI resumes, navigates back to hub
This causes one screen clear (unavoidable when exiting alt screen), but only for operations that require terminal output.
File Structure¶
internal/
├── app/
│ └── root.go # Entry point, launches unified program
├── tui/
│ ├── footer.go # Shared footer highlight utilities
│ ├── list_browser.go # Browse view (model + renderer)
│ ├── hub.go # Hub menu (standalone mode)
│ ├── book_picker.go # Book selection pickers
│ ├── shelf_picker.go # Shelf selection picker
│ ├── file_picker.go # Miller columns file browser
│ ├── edit_form.go # Metadata edit form
│ ├── shelve_form.go # Add book form
│ ├── shelf_create_form.go # Shelf creation form
│ ├── progress.go # Progress bar
│ ├── styles.go # Shared lipgloss styles
│ ├── keys.go # Standard key bindings
│ └── delegate/ # List delegate base component
└── unified/
├── model.go # Orchestrator (~850 lines)
├── messages.go # Navigation message types
├── hub.go # Hub view (wraps tui.HubModel)
├── browse.go # Browse view adapter
├── edit_book.go # Edit book workflow
├── shelve.go # Shelve workflow
├── move_book.go # Move book workflow
├── delete_book.go # Delete book workflow
├── cache_clear.go # Cache clear workflow
└── create_shelf.go # Create shelf form
Message Flow Example¶
User browses library, edits a book, returns to hub:
1. Hub renders menu → user presses Enter on "Browse Library"
2. Hub emits NavigateMsg{Target: "browse"}
3. Orchestrator sets currentView = "browse", initializes browse model
4. Browse renders instantly (no flicker)
5. User presses 'e' on a book
6. Browse emits NavigateMsg{Target: "edit-book-single", BookItem: selected}
7. Orchestrator sets currentView = "edit-book", initializes with single book
8. Edit form renders instantly (no flicker)
9. User edits metadata, presses Ctrl+S to save
10. Edit view commits changes, emits NavigateMsg{Target: "browse"}
11. Browse re-renders with updated metadata (no flicker)
12. User presses 'q' → NavigateMsg{Target: "hub"} → hub renders instantly
CLI Compatibility¶
The unified TUI only activates when running shelfctl with no arguments. All direct commands work exactly as before:
# Direct commands (no unified TUI)
shelfctl browse --shelf programming
shelfctl shelve book.pdf --shelf prog --title "..."
shelfctl edit-book book-id --title "New Title"
# Interactive hub (unified TUI)
shelfctl
Detection in root.go:
Adding a New View¶
- Create
internal/unified/new_view.gowith a model struct implementingInit(),Update(),View() - Add
activeCmd stringfield andClearActiveCmdMsghandler for footer highlights - Emit
NavigateMsgto navigate away (nevertea.Quit) - Add model field to
Modelstruct inmodel.go - Add routing cases in
Update(),View(), andinitView() - Add menu item in
hub.goif accessible from hub
Performance¶
- Startup: ~50ms (single program launch)
- View transitions: <1ms (internal state change)
- Memory: ~10-15 MB (all view models in memory)
- Terminal: Alt screen entered once, persists until quit