A lightweight, elegant Terminal UI (TUI) framework for Odin, inspired by the Elm Architecture.
- Elm Architecture Pattern: Clean separation of Model, Update, and View
- Cross-Platform: Works on Linux, macOS, and Windows
- Event-Driven Rendering: Only redraws when state changes
- Window Resize Detection: Automatically detects and responds to terminal resize events
- Rich Color Support: 16 ANSI colors with bright variants
- Memory Safe: Built-in allocator support with debug-time memory tracking
- Zero Dependencies: Uses only Odin core libraries
Clone the repository and import it into your Odin project:
git clone https://github.com/amjadjibon/munin.gitHere's a minimal counter example:
package main
import munin "../../munin"
import "core:fmt"
import "core:strings"
// 1. Define your Model
Model :: struct {
counter: int,
}
init :: proc() -> Model {
return Model{counter = 0}
}
// 2. Define your Messages
Increment :: struct {}
Decrement :: struct {}
Quit :: struct {}
Msg :: union {
Increment,
Decrement,
Quit,
}
// 3. Define your Update function
update :: proc(msg: Msg, model: Model) -> (Model, bool) {
new_model := model
should_quit := false
switch m in msg {
case Increment:
new_model.counter += 1
case Decrement:
new_model.counter -= 1
case Quit:
should_quit = true
}
return new_model, should_quit
}
// 4. Define your View function
view :: proc(model: Model, buf: ^strings.Builder) {
munin.clear_screen(buf)
munin.print_at(buf, {2, 2}, fmt.tprintf("Counter: "), .BrightGreen)
munin.print_at(buf, {11, 2}, fmt.tprintf("%d", model.counter), .BrightRed)
munin.print_at(buf, {2, 4}, "Press space to increment, d to decrement, q to quit", .White)
}
// 5. Define your Input handler
input_handler :: proc() -> Maybe(Msg) {
if event, ok := munin.read_key().?; ok {
if event.key == .Char {
switch event.char {
case ' ':
return Increment{}
case 'd':
return Decrement{}
case 'q', 'Q', 3:
// q, Q, or Ctrl+C
return Quit{}
}
}
}
return nil
}
// 6. Run your program
main :: proc() {
program := munin.make_program(init, update, view)
munin.run(&program, input_handler)
}# Build the example
odin build example/counter -out:counter
# Run the example
./counterMunin follows the Elm Architecture pattern with three main components:
- Model: Your application state
- Update: Pure function that transforms the model based on messages
- View: Pure function that renders the model to the terminal
// Create a program without subscriptions
program := munin.make_program(init, update, view)
// Or create a program with subscriptions (for time-based events)
program := munin.make_program(init, update, view, subscriptions)
// Run the program
munin.run(&program, input_handler, target_fps = 60)// Without subscriptions
make_program :: proc(
init: proc() -> Model,
update: proc(msg: Msg, model: Model) -> (Model, bool),
view: proc(model: Model, buf: ^strings.Builder),
allocator := context.allocator,
) -> Program(Model, Msg)
// With subscriptions
make_program :: proc(
init: proc() -> Model,
update: proc(msg: Msg, model: Model) -> (Model, bool),
view: proc(model: Model, buf: ^strings.Builder),
subscriptions: proc(Model) -> Maybe(Msg),
allocator := context.allocator,
) -> Program(Model, Msg)run :: proc(
program: ^Program(Model, Msg),
input_handler: proc() -> Maybe(Msg),
target_fps: i64 = 60,
)clear_screen :: proc(buf: ^strings.Builder)
move_cursor :: proc(buf: ^strings.Builder, pos: Vec2i)
hide_cursor :: proc(buf: ^strings.Builder)
show_cursor :: proc(buf: ^strings.Builder)// Draw a box at position with width and height
draw_box :: proc(buf: ^strings.Builder, pos: Vec2i, width, height: int, color: Color = .Reset)
// Print text at position
print_at :: proc(buf: ^strings.Builder, pos: Vec2i, text: string, color: Color = .Reset)
// Print formatted text at position
printf_at :: proc(buf: ^strings.Builder, pos: Vec2i, color: Color, format: string, args: ..any)
// Draw centered title
draw_title :: proc(
buf: ^strings.Builder,
pos: Vec2i,
width: int,
title: string,
color: Color = .Reset,
bold := false,
)set_color :: proc(buf: ^strings.Builder, color: Color)
set_bg_color :: proc(buf: ^strings.Builder, color: Color)
set_bold :: proc(buf: ^strings.Builder)
set_dim :: proc(buf: ^strings.Builder)
set_underline :: proc(buf: ^strings.Builder)
set_blink :: proc(buf: ^strings.Builder)
set_reverse :: proc(buf: ^strings.Builder)
reset_style :: proc(buf: ^strings.Builder)// Get current terminal size
get_window_size :: proc() -> (width, height: int, ok: bool)
// Set terminal window title
set_window_title :: proc(buf: ^strings.Builder, title: string)// Read keyboard input (non-blocking)
read_key :: proc() -> Maybe(Key_Event)2D integer vector for positions:
Vec2i :: [2]int
// Usage
pos := Vec2i{x, y}
munin.print_at(buf, {10, 5}, "Hello", .Green)Available colors:
Color :: enum {
Reset,
// Standard colors
Black, Red, Green, Yellow, Blue, Magenta, Cyan, White,
// Bright colors
BrightBlack, BrightRed, BrightGreen, BrightYellow,
BrightBlue, BrightMagenta, BrightCyan, BrightWhite,
// Aliases
Gray, // Same as BrightBlack
}Key_Event :: struct {
key: Key_Type,
char: rune,
}
Key_Type :: enum {
Char,
Up,
Down,
Left,
Right,
Enter,
Backspace,
Delete,
Escape,
Tab,
}Munin supports custom allocators and memory tracking:
main :: proc() {
// Debug-time memory tracking
when ODIN_DEBUG {
track: mem.Tracking_Allocator
mem.tracking_allocator_init(&track, context.allocator)
context.allocator = mem.tracking_allocator(&track)
defer {
if len(track.allocation_map) > 0 {
fmt.eprintf("=== %v allocations not freed: ===\n", len(track.allocation_map))
for _, entry in track.allocation_map {
fmt.eprintf("- %v bytes @ %v\n", entry.size, entry.location)
}
}
mem.tracking_allocator_destroy(&track)
}
}
// Create program with custom allocator
program := munin.make_program(init, update, view, allocator = context.allocator)
munin.run(&program, input_handler)
}Subscriptions allow you to handle time-based or external events:
subscriptions :: proc(model: Model) -> Maybe(Msg) {
// Check for tick every frame
if model.should_tick {
return Tick{}
}
return nil
}
main :: proc() {
program := munin.make_program(init, update, view, subscriptions)
munin.run(&program, input_handler)
}Munin automatically detects window resizes. Get the current size in your view:
view :: proc(model: Model, buf: ^strings.Builder) {
munin.clear_screen(buf)
width, height, ok := munin.get_window_size()
if !ok {
width = 80
height = 24
}
// Center content based on terminal size
center_x := width / 2
center_y := height / 2
munin.print_at(buf, {center_x - 5, center_y}, "Centered!", .Green)
}Control the rendering frame rate:
main :: proc() {
program := munin.make_program(init, update, view)
munin.run(&program, input_handler, target_fps = 30) // 30 FPS
}Check out the example/counter directory for a complete, feature-rich example that demonstrates:
- Responsive layout that adapts to terminal size
- Colored box drawing
- Text styling and formatting
- Keyboard input handling
- Memory tracking in debug builds
-
Event-Driven Rendering: Munin only redraws when state changes. Avoid setting
needs_redrawunnecessarily. -
String Builder Efficiency: The framework uses a pre-allocated string builder (4KB default) to minimize allocations.
-
Frame Rate Control: Set an appropriate
target_fpsbased on your needs. 60 FPS is the default, but 30 FPS is often sufficient. -
Minimize View Complexity: Keep your view function efficient since it's called every frame when redrawing.
- Linux: Full support
- macOS: Full support
- Windows: Not supported
This project is open source. See LICENSE file for details.
Contributions are welcome! Please feel free to submit issues and pull requests.
Inspired by:
- The Elm Architecture
- Bubble Tea (Go TUI framework)
- Termbox and other terminal libraries