tuix
Version:
A performant TUI framework for Bun with JSX and reactive state management
589 lines (532 loc) • 17.9 kB
text/typescript
/**
* Help Component - Display keybindings and help information
*
* Features:
* - Keyboard shortcut display with descriptions
* - Organized sections (General, Navigation, etc.)
* - Modal overlay or inline display modes
* - Customizable styling and layout
* - Search/filter functionality
* - Contextual help based on current mode
*/
import { Effect } from "effect"
import type { View, Cmd, AppServices, KeyEvent } from "../core/types.ts"
import { text, vstack, hstack, styledText } from "../core/view.ts"
import { style, Colors, Borders, type Style } from "../styling/index.ts"
import { styledBox } from "../layout/box.ts"
import { stringWidth } from "../utils/string-width.ts"
// =============================================================================
// Types
// =============================================================================
export interface KeyBinding {
readonly key: string
readonly description: string
readonly category?: string
readonly context?: string
}
export interface HelpSection {
readonly title: string
readonly bindings: KeyBinding[]
readonly description?: string
}
export interface HelpConfig {
readonly title?: string
readonly width?: number
readonly height?: number
readonly showAsModal?: boolean
readonly showCategories?: boolean
readonly showSearch?: boolean
readonly maxKeyWidth?: number
readonly style?: Style
readonly headerStyle?: Style
readonly keyStyle?: Style
readonly descriptionStyle?: Style
readonly categoryStyle?: Style
}
export interface HelpModel {
readonly config: HelpConfig
readonly sections: HelpSection[]
readonly isOpen: boolean
readonly searchQuery: string
readonly filteredSections: HelpSection[]
readonly selectedIndex: number
readonly showSearch: boolean
readonly terminalWidth: number
readonly terminalHeight: number
}
export type HelpMsg =
| { readonly _tag: "Open" }
| { readonly _tag: "Close" }
| { readonly _tag: "ToggleSearch" }
| { readonly _tag: "SetSearchQuery"; readonly query: string }
| { readonly _tag: "ClearSearch" }
| { readonly _tag: "NavigateUp" }
| { readonly _tag: "NavigateDown" }
| { readonly _tag: "NavigatePageUp" }
| { readonly _tag: "NavigatePageDown" }
| { readonly _tag: "SetSections"; readonly sections: HelpSection[] }
| { readonly _tag: "SetTerminalSize"; readonly width: number; readonly height: number }
// =============================================================================
// Default Configuration
// =============================================================================
const defaultConfig: HelpConfig = {
title: "Help & Keyboard Shortcuts",
width: 70,
height: 25,
showAsModal: true,
showCategories: true,
showSearch: true,
maxKeyWidth: 15,
style: style().background(Colors.black).foreground(Colors.white),
headerStyle: style().foreground(Colors.brightBlue).bold(),
keyStyle: style().foreground(Colors.yellow).bold(),
descriptionStyle: style().foreground(Colors.white),
categoryStyle: style().foreground(Colors.brightCyan).bold()
}
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Filter sections based on search query
*/
const filterSections = (sections: HelpSection[], query: string): HelpSection[] => {
if (!query.trim()) {
return sections
}
const lowerQuery = query.toLowerCase()
return sections
.map(section => ({
...section,
bindings: section.bindings.filter(binding =>
binding.key.toLowerCase().includes(lowerQuery) ||
binding.description.toLowerCase().includes(lowerQuery) ||
(binding.category && binding.category.toLowerCase().includes(lowerQuery))
)
}))
.filter(section => section.bindings.length > 0)
}
/**
* Format a key binding for display
*/
const formatKeyBinding = (
binding: KeyBinding,
maxKeyWidth: number,
keyStyle: Style,
descriptionStyle: Style
): View => {
const keyPart = binding.key.padEnd(maxKeyWidth)
const separator = " • "
return hstack(
styledText(keyPart, keyStyle),
styledText(separator, style().foreground(Colors.gray)),
styledText(binding.description, descriptionStyle)
)
}
/**
* Create section header
*/
const createSectionHeader = (
title: string,
description: string | undefined,
categoryStyle: Style
): View[] => {
const views: View[] = [
text(""),
styledText(`▶ ${title}`, categoryStyle)
]
if (description) {
views.push(styledText(` ${description}`, style().foreground(Colors.gray)))
}
views.push(text(""))
return views
}
/**
* Create search input display
*/
const createSearchInput = (
query: string,
width: number,
active: boolean
): View => {
const prefix = "Search: "
const maxQueryWidth = width - stringWidth(prefix) - 4
let displayQuery = query
if (stringWidth(query) > maxQueryWidth) {
displayQuery = query.substring(query.length - maxQueryWidth)
}
const cursor = active ? "│" : ""
const searchStyle = active
? style().background(Colors.blue).foreground(Colors.white)
: style().foreground(Colors.gray)
return styledBox(
hstack(
styledText(prefix, style().foreground(Colors.yellow)),
styledText(displayQuery + cursor, searchStyle)
),
{
border: Borders.Rounded,
padding: { top: 0, right: 1, bottom: 0, left: 1 },
style: style().foreground(Colors.gray)
}
)
}
/**
* Get commonly used keybindings for default help
*/
export const getDefaultKeybindings = (): HelpSection[] => [
{
title: "General",
description: "Basic application controls",
bindings: [
{ key: "q", description: "Quit application", category: "general" },
{ key: "Ctrl+C", description: "Force quit", category: "general" },
{ key: "Escape", description: "Close dialog/cancel", category: "general" },
{ key: "?", description: "Show/hide help", category: "general" },
{ key: "F1", description: "Show help", category: "general" }
]
},
{
title: "Navigation",
description: "Moving around the interface",
bindings: [
{ key: "↑/k", description: "Move up", category: "navigation" },
{ key: "↓/j", description: "Move down", category: "navigation" },
{ key: "←/h", description: "Move left", category: "navigation" },
{ key: "→/l", description: "Move right", category: "navigation" },
{ key: "Home", description: "Go to first item", category: "navigation" },
{ key: "End", description: "Go to last item", category: "navigation" },
{ key: "Page Up", description: "Page up", category: "navigation" },
{ key: "Page Down", description: "Page down", category: "navigation" }
]
},
{
title: "Selection",
description: "Selecting and activating items",
bindings: [
{ key: "Enter", description: "Select/activate item", category: "selection" },
{ key: "Space", description: "Toggle selection", category: "selection" },
{ key: "Tab", description: "Next selectable item", category: "selection" },
{ key: "Shift+Tab", description: "Previous selectable item", category: "selection" },
{ key: "Ctrl+A", description: "Select all", category: "selection" }
]
},
{
title: "Editing",
description: "Text editing and manipulation",
bindings: [
{ key: "Ctrl+C", description: "Copy", category: "editing" },
{ key: "Ctrl+V", description: "Paste", category: "editing" },
{ key: "Ctrl+X", description: "Cut", category: "editing" },
{ key: "Ctrl+Z", description: "Undo", category: "editing" },
{ key: "Ctrl+Y", description: "Redo", category: "editing" },
{ key: "Delete", description: "Delete character", category: "editing" },
{ key: "Backspace", description: "Delete previous character", category: "editing" }
]
}
]
// =============================================================================
// Component
// =============================================================================
export const help = (config: Partial<HelpConfig> = {}, sections?: HelpSection[]): {
init: Effect.Effect<[HelpModel, Cmd<HelpMsg>[]], never, AppServices>
update: (msg: HelpMsg, model: HelpModel) => Effect.Effect<[HelpModel, Cmd<HelpMsg>[]], never, AppServices>
view: (model: HelpModel) => View
handleKey?: (key: KeyEvent, model: HelpModel) => HelpMsg | null
} => {
const finalConfig = { ...defaultConfig, ...config }
const defaultSections = sections ?? getDefaultKeybindings()
return {
init: Effect.succeed([
{
config: finalConfig,
sections: defaultSections,
isOpen: false,
searchQuery: "",
filteredSections: defaultSections,
selectedIndex: 0,
showSearch: false,
terminalWidth: 80,
terminalHeight: 24
},
[]
]),
update(msg: HelpMsg, model: HelpModel) {
switch (msg._tag) {
case "Open":
return Effect.succeed([
{
...model,
isOpen: true,
filteredSections: filterSections(model.sections, model.searchQuery)
},
[]
])
case "Close":
return Effect.succeed([
{
...model,
isOpen: false,
showSearch: false,
searchQuery: "",
filteredSections: model.sections
},
[]
])
case "ToggleSearch":
const newShowSearch = !model.showSearch
return Effect.succeed([
{
...model,
showSearch: newShowSearch,
searchQuery: newShowSearch ? model.searchQuery : "",
filteredSections: newShowSearch
? filterSections(model.sections, model.searchQuery)
: model.sections
},
[]
])
case "SetSearchQuery":
const filtered = filterSections(model.sections, msg.query)
return Effect.succeed([
{
...model,
searchQuery: msg.query,
filteredSections: filtered,
selectedIndex: 0
},
[]
])
case "ClearSearch":
return Effect.succeed([
{
...model,
searchQuery: "",
filteredSections: model.sections,
selectedIndex: 0
},
[]
])
case "NavigateUp":
return Effect.succeed([
{
...model,
selectedIndex: Math.max(0, model.selectedIndex - 1)
},
[]
])
case "NavigateDown":
const totalBindings = model.filteredSections.reduce((sum, section) => sum + section.bindings.length, 0)
return Effect.succeed([
{
...model,
selectedIndex: Math.min(totalBindings - 1, model.selectedIndex + 1)
},
[]
])
case "NavigatePageUp":
const pageSize = Math.floor((model.config.height ?? defaultConfig.height!) / 4)
return Effect.succeed([
{
...model,
selectedIndex: Math.max(0, model.selectedIndex - pageSize)
},
[]
])
case "NavigatePageDown":
const totalBindingsDown = model.filteredSections.reduce((sum, section) => sum + section.bindings.length, 0)
const pageSizeDown = Math.floor((model.config.height ?? defaultConfig.height!) / 4)
return Effect.succeed([
{
...model,
selectedIndex: Math.min(totalBindingsDown - 1, model.selectedIndex + pageSizeDown)
},
[]
])
case "SetSections":
const newFiltered = filterSections(msg.sections, model.searchQuery)
return Effect.succeed([
{
...model,
sections: msg.sections,
filteredSections: newFiltered,
selectedIndex: Math.min(model.selectedIndex, newFiltered.reduce((sum, section) => sum + section.bindings.length, 0) - 1)
},
[]
])
case "SetTerminalSize":
return Effect.succeed([
{
...model,
terminalWidth: msg.width,
terminalHeight: msg.height
},
[]
])
}
},
view(model: HelpModel): View {
if (!model.isOpen) {
return text("") // Empty view when help is closed
}
const { config } = model
const width = config.width ?? defaultConfig.width!
const height = config.height ?? defaultConfig.height!
const maxKeyWidth = config.maxKeyWidth ?? defaultConfig.maxKeyWidth!
const headerStyle = config.headerStyle ?? defaultConfig.headerStyle!
const keyStyle = config.keyStyle ?? defaultConfig.keyStyle!
const descriptionStyle = config.descriptionStyle ?? defaultConfig.descriptionStyle!
const categoryStyle = config.categoryStyle ?? defaultConfig.categoryStyle!
const mainStyle = config.style ?? defaultConfig.style!
const content: View[] = []
// Title
if (config.title) {
content.push(styledText(config.title, headerStyle))
content.push(text(""))
}
// Search input
if (config.showSearch && model.showSearch) {
content.push(createSearchInput(model.searchQuery, width - 4, true))
content.push(text(""))
}
// Help sections
for (const section of model.filteredSections) {
if (config.showCategories) {
content.push(...createSectionHeader(section.title, section.description, categoryStyle))
}
for (const binding of section.bindings) {
content.push(
hstack(
text(" "), // Indentation
formatKeyBinding(binding, maxKeyWidth, keyStyle, descriptionStyle)
)
)
}
}
// Help instructions
content.push(text(""))
content.push(text(""))
content.push(styledText("Controls:", style().foreground(Colors.yellow).bold()))
content.push(styledText(" ↑↓: Navigate • /: Search • Escape: Close", style().foreground(Colors.gray)))
if (config.showAsModal) {
return styledBox(
vstack(...content),
{
border: Borders.Rounded,
padding: { top: 1, right: 2, bottom: 1, left: 2 },
minWidth: width,
minHeight: height,
style: mainStyle
}
)
} else {
return vstack(...content)
}
},
handleKey(key: KeyEvent, model: HelpModel): HelpMsg | null {
if (!model.isOpen) {
return null
}
if (model.showSearch) {
// Search mode key handling
switch (key.key) {
case 'escape':
return { _tag: "ToggleSearch" }
case 'enter':
return { _tag: "ToggleSearch" }
case 'backspace':
if (model.searchQuery.length > 0) {
return { _tag: "SetSearchQuery", query: model.searchQuery.slice(0, -1) }
}
break
default:
if (key.key.length === 1 && !key.ctrl && !key.alt) {
return { _tag: "SetSearchQuery", query: model.searchQuery + key.key }
}
}
} else {
// Navigation mode key handling
switch (key.key) {
case 'escape':
return { _tag: "Close" }
case 'up':
case 'k':
return { _tag: "NavigateUp" }
case 'down':
case 'j':
return { _tag: "NavigateDown" }
case 'pageup':
return { _tag: "NavigatePageUp" }
case 'pagedown':
return { _tag: "NavigatePageDown" }
case '/':
if (model.config.showSearch) {
return { _tag: "ToggleSearch" }
}
break
case 'c':
if (key.ctrl) {
return { _tag: "ClearSearch" }
}
break
}
}
return null
}
}
}
// =============================================================================
// Helper Functions for Common Use Cases
// =============================================================================
/**
* Create a modal help dialog with default keybindings
*/
export const createHelpModal = (
title?: string,
customSections?: HelpSection[]
): {
component: ReturnType<typeof help>
openEffect: Effect.Effect<HelpMsg, never, never>
} => {
const component = help(
{
title: title ?? "Help & Keyboard Shortcuts",
showAsModal: true,
showSearch: true,
width: 75,
height: 30
},
customSections
)
const openEffect = Effect.succeed({ _tag: "Open" as const })
return { component, openEffect }
}
/**
* Create an inline help panel (non-modal)
*/
export const createHelpPanel = (
sections?: HelpSection[],
config?: Partial<HelpConfig>
): ReturnType<typeof help> => {
return help(
{
showAsModal: false,
showCategories: true,
showSearch: false,
...config
},
sections
)
}
/**
* Create context-sensitive help for specific components
*/
export const createContextHelp = (
context: string,
bindings: KeyBinding[]
): HelpSection => {
return {
title: `${context} Controls`,
description: `Keyboard shortcuts for ${context.toLowerCase()}`,
bindings: bindings.map(binding => ({ ...binding, context }))
}
}