This document describes the architecture and design decisions of Excel TUI.
- Overview
- Design Principles
- Architecture Layers
- Data Flow
- Component Details
- Design Patterns
- Performance Considerations
- Extension Points
Excel TUI is a terminal-based spreadsheet viewer built using the Elm Architecture (via Bubble Tea). It follows a Model-View-Update pattern with clear separation between data, logic, and presentation.
- Framework: Bubble Tea (TUI framework)
- Styling: Lipgloss (terminal styling)
- Components: Bubbles (UI components)
- Excel Parsing: Excelize (Excel file handling)
- Clipboard: clipboard (cross-platform clipboard)
Each package has a single, well-defined responsibility:
app- Application logic and state managementloader- File I/O operationstheme- Visual theme managementui- Reusable UI utilitiesmodels- Data structures
main.go
↓
internal/app (depends on ↓)
↓
internal/loader, internal/theme, internal/ui
↓
pkg/models (no dependencies)
Small, focused interfaces rather than large monolithic ones.
State changes are explicit and controlled through the Update function.
Responsibilities:
- Parse command-line arguments
- Validate input
- Initialize application
- Start Bubble Tea program
Dependencies: app, loader
Responsibilities:
- State management (Model)
- Event handling (Update)
- View rendering (View)
- Keyboard bindings
Key Files:
model.go- Application stateupdate.go- Event handlersview.go- Rendering logickeys.go- Keybindings
Responsibilities:
- File parsing (Excel, CSV)
- Data export (CSV, JSON)
- Search operations
Key Functions:
LoadFile(filename) ([]Sheet, error)ExportToCSV(sheet, filename) errorExportToJSON(sheet, filename) errorSearchSheet(sheet, term) []Cell
Responsibilities:
- Theme definitions
- Theme switching
- Color management
Key Functions:
GetThemeNames() []stringSetTheme(name) boolGetCurrentTheme() Theme
Responsibilities:
- Style initialization
- Helper functions
- Rendering utilities
Key Functions:
InitStyles() *StylesColIndexToLetter(index) stringTruncateToWidth(s, width) stringRenderModal(width, height, modal) string
Responsibilities:
- Data structures
- Constants
- Types
Key Types:
Cell- Single spreadsheet cellSheet- Worksheet with dataMode- Application mode enumStatusMsg- Status message
1. main.go
↓
2. Parse arguments & validate file
↓
3. loader.LoadFile() → []Sheet
↓
4. app.NewModel() → Model
↓
5. tea.NewProgram() → Start
1. User Input (keyboard)
↓
2. tea.Msg delivered to Update()
↓
3. Model state changes
↓
4. View() called with new state
↓
5. Rendered output to terminal
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch m.mode {
case ModeNormal: return m.updateNormal(msg)
case ModeSearch: return m.updateSearch(msg)
case ModeDetail: return m.updateDetail(msg)
case ModeJump: return m.updateJump(msg)
case ModeExport: return m.updateExport(msg)
case ModeTheme: return m.updateTheme(msg)
}
}
return m, nil
}type Model struct {
// Data
sheets []models.Sheet
currentSheet int
// Cursor and viewport
cursorRow int
cursorCol int
offsetRow int
offsetCol int
// UI state
width int
height int
mode models.Mode
// Search state
searchQuery string
searchResults []models.Cell
searchIndex int
// UI components
searchInput textinput.Model
jumpInput textinput.Model
exportInput textinput.Model
// Settings
showFormulas bool
themeName string
// Metadata
status models.StatusMsg
help help.Model
keys KeyMap
filename string
styles *ui.Styles
}The viewport system ensures the cursor is always visible:
func (m *Model) adjustViewport() {
visibleRows := max(1, m.height-9)
visibleCols := max(1, (m.width-8)/(MinCellWidth+2))
// Vertical adjustment
if m.cursorRow < m.offsetRow {
m.offsetRow = m.cursorRow
} else if m.cursorRow >= m.offsetRow+visibleRows {
m.offsetRow = m.cursorRow - visibleRows + 1
}
// Horizontal adjustment
// ...
}Themes are defined as color collections:
type Theme struct {
Name string
Primary lipgloss.Color
Secondary lipgloss.Color
Accent lipgloss.Color
// ... more colors
}Styles are generated from themes:
func InitStyles() *Styles {
t := theme.GetCurrentTheme()
return &Styles{
Title: lipgloss.NewStyle().
Foreground(t.Primary).
Bold(true),
// ... more styles
}
}Model: Application state
Update: Pure function: (Model, Msg) → (Model, Cmd)
View: Pure function: Model → String
Different themes are strategies for coloring the UI. Themes can be swapped at runtime without changing application logic.
func NewModel(filename, sheets, themeName) Model {
// Complex initialization
// Returns fully configured model
}Actions that produce side effects are represented as commands:
return m, textinput.Blink // Command to blink cursorBubble Tea's message passing is an implementation of the observer pattern.
Only visible cells are rendered:
visibleRows := max(1, m.height-9)
endRow := min(m.offsetRow+visibleRows, sheet.MaxRows)
for row := m.offsetRow; row < endRow {
// Render only visible rows
}Use strings.Builder for concatenation:
var b strings.Builder
b.WriteString(header)
b.WriteString(content)
return b.String()sheets := make([]models.Sheet, 0, len(sheetList))
cellRow := make([]models.Cell, 0, len(row))reader := csv.NewReader(file)
reader.ReuseRecord = true // Reuse memoryCache viewport dimensions to avoid recalculation:
visibleRows := max(1, m.height-9)
visibleCols := max(1, (m.width-8)/(MinCellWidth+2))- Define theme in
internal/theme/theme.go:
themes["mytheme"] = Theme{
Name: "My Theme",
// ... colors
}- Add to
GetThemeNames()
- Add mode constant to
pkg/models/models.go:
const (
// ...
ModeCustom Mode = iota + 6
)- Add update handler in
internal/app/update.go:
case models.ModeCustom:
return m.updateCustom(msg)- Add view renderer in
internal/app/view.go:
case models.ModeCustom:
return m.renderCustom()- Add loader in
internal/loader/loader.go:
func loadXML(filename string) ([]models.Sheet, error) {
// Implementation
}- Update
LoadFile()switch statement:
case ".xml":
return loadXML(filename)Similar to file formats, add to loader.go:
func ExportToXML(sheet models.Sheet, filename string) error {
// Implementation
}Test individual functions in isolation:
func TestColIndexToLetter(t *testing.T) {
// Test helper functions
}
func TestLoadCSV(t *testing.T) {
// Test file loading
}Test component interaction:
func TestSearchAndJump(t *testing.T) {
// Test search → jump workflow
}Test complete user workflows:
func TestOpenFileAndExport(t *testing.T) {
// Test file → view → export
}-
Fatal Errors: Exit program with error message
- File not found
- Invalid file format
- Permission denied
-
Recoverable Errors: Show status message
- Search no results
- Invalid jump reference
- Export failed
-
Warnings: Log but continue
- Sheet read error (skip sheet)
- File close error
Use fmt.Errorf with %w for error wrapping:
if err != nil {
return nil, fmt.Errorf("failed to load Excel file: %w", err)
}- File path validation
- Cell reference validation
- Search term sanitization
- Read-only access by default
- Proper file closing
- No arbitrary code execution
- Formulas displayed but never executed
- No eval() or similar operations
- Lazy loading of cell data
- Viewport-based rendering
- Efficient data structures
- Stream processing for CSV
- Chunked loading for Excel
- Progressive rendering
- Plugin System: Load custom themes/exporters
- Configuration File: User preferences persistence
- Macro System: Record/replay actions
- Async Operations: Background file loading
- Database Backend: For very large files
- Remote Files: Load from URLs
- Collaboration: Real-time multi-user
- Web Interface: Browser-based UI
The architecture is designed for:
- Simplicity: Easy to understand and modify
- Maintainability: Clear structure and documentation
- Extensibility: Easy to add features
- Performance: Efficient for large files
- Testability: Modular and mockable
For questions or suggestions, please open an issue or pull request.