A modern, declarative Terminal UI framework for Swift, inspired by SwiftUI and Bubble Tea.
import SwifTeaUI
@main
struct TinyCounterApp: TUIApp {
var body: some TUIScene { TinyCounterScene() }
}
struct TinyCounterScene: TUIScene {
typealias Model = ModelState
typealias Action = Model.Action
var model = ModelState()
mutating func update(action: Action) { model.update(action: action) }
func view(model: Model) -> some TUIView { CounterView(count: model.count) }
func mapKeyToAction(_ key: KeyEvent) -> Action? { model.mapKeyToAction(key) }
func shouldExit(for action: Action) -> Bool { model.shouldExit(for: action) }
}
struct ModelState {
enum Action { case increment, decrement, quit }
@State private var count = 0
mutating func update(action: Action) {
switch action {
case .increment: count += 1
case .decrement: count -= 1
case .quit: break
}
}
func mapKeyToAction(_ key: KeyEvent) -> Action? {
switch key {
case .rightArrow, .char("+"): return .increment
case .leftArrow, .char("-"): return .decrement
case .char("q"), .escape: return .quit
default: return nil
}
}
func shouldExit(for action: Action) -> Bool { action == .quit }
}
struct CounterView: TUIView {
let count: Int
var body: some TUIView {
VStack(spacing: 1, alignment: .leading) {
Text("Tiny Counter").foregroundColor(.yellow).bold()
Text("Use ←/→ or +/- to change, q to quit.").foregroundColor(.cyan)
Text("Count: \(count)").foregroundColor(.green)
}
.padding(1)
}
}For a fuller walkthrough (setup, model/view split, patterns), see docs/QUICKSTART.md.
✅ SwiftUI-like declarative syntax
✅ POSIX & ANSI abstractions handled for you
✅ Async actions, effects, and key event routing
✅ Cross-platform (macOS + Linux)
✅ Clean, composable view system
- Dispatch reducer actions from anywhere—call
SwifTea.dispatch(Action.someCase)to enqueue work on the runtime thread without reaching for global state. - Kick off background work with
SwifTea.dispatch(Effect<Action>.run { send in try await Task.sleep(...) ; send(.completed) }, id: "network", cancelExisting: true); useidto cancel or replace in-flight effects. - Need a timer?
Effect<Action>.timer(every: 0.5) { .tick }emits.tickevery 500 ms until you cancel it (either explicitly viaSwifTea.cancelEffects(withID:)or when the runtime shuts down). - Scenes can override
initializeEffects()to seed timers or long-lived work as soon as the runtime boots, keepinghandleFramefree for animation-heavy cases only.
-
Use
@Statefor local data and pass$propertytoTextField/TextEditor. These views now mirror SwiftUI naming: call.foregroundColor(_),.bold(),.focused(_:),.blinkingCursor(), and.focusRingStyle(_)to customise appearance and focus behaviour. -
Reach for
@StateObject/@ObservedObjectwhen you need shared reference models (e.g., a long-lived view model or async fetcher). SwifTeaUI keeps those objects alive across re-renders, mirroring SwiftUI’s ownership semantics. -
Declare
@FocusStatefor whichever enum identifies focusable elements.$focused.isFocused(.tag)returns aBinding<Bool>that plugs straight into.focused(_:), while$focused.moveForward(in:)/.moveBackward(in:)walk aFocusRing. -
Wrap related fields in a
FocusScopeso Tab and Shift+Tab navigation can stay inside that group before falling back to a global ring. Terminal Shift+Tab arrives as.backTabinKeyEvent. -
Typical pattern:
enum Field: Hashable { case controls, title, body } @FocusState private var focused: Field? private let globalRing = FocusRing<Field>([.controls, .title, .body]) private let noteScope = FocusScope<Field>([.title, .body], wraps: false) mutating func update(action: Action) { switch action { case .focusNext: if !$focused.moveForward(in: noteScope), let next = globalRing.move(from: focused, direction: .forward) { focused = next } case .focusPrevious: if !$focused.moveBackward(in: noteScope), let prev = globalRing.move(from: focused, direction: .backward) { focused = prev } default: break } }
swift run SwifTeaGalleryExamplenow launches focused demos: counter, form & focus, list/search, list selection, table snapshot, and overlays. Jump with[1]…[6]orTab/Shift+Tabbetween sections without quitting; hit[T]to cycle themes (truecolor Lumen Glass or the basic ANSI palette).
- For a concise, model/view-separated walkthrough (setup, gallery controls, minimal app skeleton, common patterns), see
docs/QUICKSTART.md.
VStackandHStackacceptspacingand alignment arguments that mirror SwiftUI. Need a fixed height? Call.frame(height:alignment:)on the stack rather than passing a custom parameter.- Call
.padding(_:)on any view to inset the rendered output with ANSI-aware spacing. - Wrap any view in
.foregroundColor(_:)or.backgroundColor(_:)to tint entire containers (Stacks, Borders, custom composites) with ANSI colors without re-styling each child manually. - Need curated palettes?
SwifTeaThemeships with a truecolorlumenGlasspalette and abasicANSI preset so demos (like Counter) can apply consistent accent/success/info colors and let users toggle between them. ScrollView(axis:viewport:offset:)clamps tall content (vertical) or wide buffers (horizontal) without re-rendering children. BindcontentLengthto capture the total rows or columns, call.followingActiveLine(_:)(optionally with an enable binding) to auto-scroll caret positions, flip on.scrollIndicators(.automatic)for arrow chrome when content overflows, and use.scrollDisabled(true)whenever reducers need to freeze scroll state manually.HStack(spacing:horizontalAlignment:verticalAlignment:)measures ANSI widths accurately so mixed-color content still lines up.AdaptiveStack(breakpoint:expanded:collapsed:)switches entire layouts based on terminal width—use it to collapse dual-column panes into a stacked presentation without re-implementing breakpoint checks.ZStackoverlays multiple views in z-order so badges/tooltips/overlays can be layered without touching the base content.- All
TUIViewconformers exposevar body: some TUIView; returnVStack/HStack(or any other view) and the runtime callsrender()for you—no manual.render()needed.
Table brings SwiftUI-style column definitions to the terminal. Pick from .fixed, .fitContent, or .flex(min:max:) widths, opt into headers/footers, and let SwifTeaUI handle ANSI-aware measurement for multi-line cells. Row styling is opt-in per index so you can emphasize focus, selections, or zebra striping:
@State private var selectedProcessIDs = Set<Process.ID>()
@FocusState private var focusedProcess: Process.ID?
Table(
processes,
divider: .line(color: .brightBlack, isBold: true),
selection: .multiple($selectedProcessIDs, focused: $focusedProcess),
rowStyle: TableRowStyle.stripedRows(
evenStyle: TableRowStyle.stripe(backgroundColor: .brightBlack),
oddStyle: TableRowStyle.focused(accent: .cyan)
),
columns: {
TableColumn("Name", value: \Process.name, width: .flex(min: 12)) { name in
name.uppercased()
}
TableColumn("State", value: \Process.state, width: .fixed(12), alignment: .trailing)
TableColumn("Duration", value: \Process.duration) { duration in
"\(duration)s"
}
}
)TableRowStyle now exposes underline/dim/reverse toggles plus optional borders (▌ row ▐) so focused rows read clearly without building ad-hoc view wrappers. Divider lines accept foreground/background colors (or a fully custom renderer) whenever you need to match a theme instead of relying on plain ASCII separators. TableColumn(value:) mirrors SwiftUI’s key-path sugar and pipes values through a formatter closure to avoid rewriting boilerplate Text views, and selection: .single/.multiple bindings highlight whichever IDs your reducer keeps in state (with customizable focus vs. selection styles).
- Wrap any view in
MinimumTerminalSize(columns:rows:fallback:)to display a friendly message when the window is too small. Counter and Task Runner both demonstrate this pattern so users aren’t stuck staring at broken layouts. - Call
TerminalMetrics.current()to read the live terminal size plus derived size classes (regular/compact). Recompute inside views or store the value in state whenhandleTerminalResize(from:to:)fires. - Scenes can override
handleTerminalResizeto react to live window changes—update layout modes, kick off reflows, or enqueue actions whenever the runtime detects a terminal resize event (the runtime handles detection and calls the hook automatically).
- Builders:
@TUIBuildernow supportsif/if let/switch/loops viabuildOptional,buildEither, andbuildArray, plusGroup { ... }andForEach(data,id:)for conditional & data-driven repetition just like@ViewBuilder. - Text:
Text.foregroundColor(_:)andText.bold()match SwiftUI naming; the old.foreground/.bolded()methods remain as deprecated shims. - Inputs:
TextEditoris the multiline field (withtypealias TextAreafor back-compat). BothTextFieldandTextEditorsupport.focused(_:),.focusRingStyle(_:),.foregroundColor(_:), and.blinkingCursor(), andTextEditorexposes.cursorPosition(_:)/.cursorLine(_:)so reducers can drive caret placement while scroll views keep the caret visible. - Focus:
.focused(_:)mirrors SwiftUI’s modifier, while focus ring visuals come from.focusRingStyle(_:)andFocusRingBorder.
Spinnerrenders an animated activity indicator that follows the runtime clock—use it inline with other views or embed its output inside components likeStatusBarwhen background work is in flight. Built-in styles include.ascii,.braille,.dots, and.line; prefer.asciior.dotswhen targeting monochrome terminals so glyphs stay legible without color cues.ProgressMeterdraws lightweight[########----] 75%bars sized for status strips, making it easy to surface coarse task progress without leaving the status area.- TaskRunner demonstrates a tiny status message queue so transient updates (step started/completed) cycle through the status bar instead of scrolling the primary layout—handy for longer running workflows.
OverlayPresenterplusOverlayHostturns transient notifications and blocking modals into declarative components. Register toasts viapresentToast(duration:style:content:), tick them in your scene’shandleFrame(deltaTime:), and wrap the root view inOverlayHostso the presenter draws toast stacks and modal dialogs automatically.