tuix
Version:
A performant TUI framework for Bun with JSX and reactive state management
261 lines (226 loc) • 6.97 kB
text/typescript
/**
* Component Base - Common interfaces and utilities for all components
*
* Based on patterns from the Bubbletea ecosystem, this module provides
* standard interfaces that all interactive components should implement.
*/
import { Effect } from "effect"
import type { View, Cmd, AppServices, KeyEvent, MouseEvent } from "@/core/types.ts"
import { Style, style, Colors } from "@/styling/index.ts"
// =============================================================================
// Component Interfaces
// =============================================================================
/**
* Base interface for all interactive components
* Extends the standard Component interface with common UI functionality
*/
export interface UIComponent<Model, Msg> {
// Standard MVU methods
init(): Effect.Effect<[Model, ReadonlyArray<Cmd<Msg>>], never, AppServices>
update(msg: Msg, model: Model): Effect.Effect<[Model, ReadonlyArray<Cmd<Msg>>], never, AppServices>
view(model: Model): View
// Focus management
focus(): Effect.Effect<Cmd<Msg>, never, never>
blur(): Effect.Effect<Cmd<Msg>, never, never>
focused(model: Model): boolean
// Size management
setSize(width: number, height?: number): Effect.Effect<void, never, never>
getSize(model: Model): { width: number; height?: number }
// Key handling
handleKey?: (key: KeyEvent, model: Model) => Msg | null
// Mouse handling
handleMouse?: (mouse: MouseEvent, model: Model) => Msg | null
// Component ID for hit testing
readonly id: string
// Styling
styles?: ComponentStyles
}
/**
* Standard component styles structure
* Provides consistent styling across all components
*/
export interface ComponentStyles {
// Base styles
readonly base: Style
readonly focused: Style
readonly disabled: Style
// Component-specific styles
[key: string]: Style
}
/**
* Focus state mixin for component models
*/
export interface Focusable {
readonly focused: boolean
}
/**
* Size constraint mixin for component models
*/
export interface Sized {
readonly width: number
readonly height?: number
readonly minWidth?: number
readonly maxWidth?: number
readonly minHeight?: number
readonly maxHeight?: number
}
/**
* Disabled state mixin for component models
*/
export interface Disableable {
readonly disabled: boolean
}
// =============================================================================
// Key Binding Helpers
// =============================================================================
/**
* Key binding definition for components
*/
export interface KeyBinding<Msg> {
readonly keys: string[]
readonly help: { key: string; desc: string }
readonly msg: Msg
readonly disabled?: boolean
}
/**
* Create a key binding
*/
export const keyBinding = <Msg>(
keys: string[],
help: [string, string],
msg: Msg,
disabled = false
): KeyBinding<Msg> => ({
keys,
help: { key: help[0], desc: help[1] },
msg,
disabled
})
/**
* Standard key map interface for components
*/
export interface KeyMap<Msg> {
// Navigation
readonly up?: KeyBinding<Msg>
readonly down?: KeyBinding<Msg>
readonly left?: KeyBinding<Msg>
readonly right?: KeyBinding<Msg>
readonly home?: KeyBinding<Msg>
readonly end?: KeyBinding<Msg>
readonly pageUp?: KeyBinding<Msg>
readonly pageDown?: KeyBinding<Msg>
// Actions
readonly select?: KeyBinding<Msg>
readonly cancel?: KeyBinding<Msg>
readonly delete?: KeyBinding<Msg>
readonly clear?: KeyBinding<Msg>
// Component-specific bindings
[key: string]: KeyBinding<Msg> | undefined
}
/**
* Check if a key event matches any binding in the key map
*/
export const matchKeyBinding = <Msg>(
key: KeyEvent,
keyMap: KeyMap<Msg>
): Msg | null => {
for (const binding of Object.values(keyMap)) {
if (!binding || binding.disabled) continue
for (const k of binding.keys) {
// Check exact key match
if (key.key === k) {
return binding.msg
}
// Check runes match (for single character keys)
if (key.runes && key.runes === k) {
return binding.msg
}
// Check composite key match (e.g., ctrl+s matches "s" with ctrl=true)
if (k.includes('+')) {
const parts = k.split('+')
const mainKey = parts[parts.length - 1]
const hasCtrl = parts.includes('ctrl')
const hasAlt = parts.includes('alt')
const hasShift = parts.includes('shift')
const hasMeta = parts.includes('meta')
if ((key.key === mainKey || key.runes === mainKey) &&
key.ctrl === hasCtrl &&
key.alt === hasAlt &&
key.shift === hasShift &&
key.meta === hasMeta) {
return binding.msg
}
}
}
}
return null
}
// =============================================================================
// Component ID Management
// =============================================================================
let componentIdCounter = 0
/**
* Generate a unique component ID
*/
export const generateComponentId = (prefix: string): string => {
return `${prefix}-${++componentIdCounter}`
}
// =============================================================================
// Common Component Messages
// =============================================================================
/**
* Base message types that many components share
*/
export type CommonMsg =
| { _tag: "Focus" }
| { _tag: "Blur" }
| { _tag: "SetSize"; width: number; height?: number }
| { _tag: "SetDisabled"; disabled: boolean }
// =============================================================================
// Component Factory Helpers
// =============================================================================
/**
* Options for creating components
*/
export interface ComponentOptions {
readonly id?: string
readonly width?: number
readonly height?: number
readonly disabled?: boolean
readonly styles?: Partial<ComponentStyles>
}
/**
* Create default component styles
*/
export const createDefaultStyles = (overrides?: Partial<ComponentStyles>): ComponentStyles => {
const defaults: ComponentStyles = {
base: style(),
focused: style()
.bold()
.foreground(Colors.white)
.background(Colors.blue),
disabled: style().faint(),
...overrides
}
return defaults
}
/**
* Merge multiple component styles
*/
export const mergeStyles = (...styles: (ComponentStyles | undefined)[]): ComponentStyles => {
return styles.reduce((merged, style) => ({
...merged,
...style
}), createDefaultStyles())
}
/**
* Create a key map from an array of key bindings
*/
export const createKeyMap = <Msg>(bindings: KeyBinding<Msg>[]): KeyMap<Msg> => {
const map: KeyMap<Msg> = {}
bindings?.forEach(binding => {
// Use help.key as the map key since that's the primary identifier
map[binding.help.key.toLowerCase()] = binding
})
return map
}