tuix
Version:
A performant TUI framework for Bun with JSX and reactive state management
646 lines (560 loc) • 17.5 kB
text/typescript
/**
* Viewport Component - Scrollable content area with scrollbars
*
* Features:
* - Vertical and horizontal scrolling
* - Scroll indicators/scrollbars
* - Mouse wheel support
* - Keyboard navigation (arrow keys, page up/down)
* - Content that can be larger than the viewport
* - Smooth scrolling behavior
*/
import { Effect } from "effect"
import type { Component, View, Cmd, AppServices, KeyEvent, MouseEvent } from "@/core/types.ts"
import { text, vstack, hstack, box } from "@/core/view.ts"
import { style, Colors, Borders } from "@/styling/index.ts"
import { stringWidth } from "@/utils/string-width.ts"
// =============================================================================
// Types
// =============================================================================
export interface ViewportConfig {
readonly width: number
readonly height: number
readonly showScrollbars?: boolean
readonly smoothScroll?: boolean
readonly scrollStep?: number
readonly pageSize?: number
}
export interface ViewportModel {
readonly config: ViewportConfig
readonly scrollX: number
readonly scrollY: number
readonly contentWidth: number
readonly contentHeight: number
readonly content: string[] // Array of lines
readonly isFocused: boolean
}
export type ViewportMsg =
| { readonly _tag: "ScrollUp"; readonly amount?: number }
| { readonly _tag: "ScrollDown"; readonly amount?: number }
| { readonly _tag: "ScrollLeft"; readonly amount?: number }
| { readonly _tag: "ScrollRight"; readonly amount?: number }
| { readonly _tag: "ScrollToTop" }
| { readonly _tag: "ScrollToBottom" }
| { readonly _tag: "ScrollToPosition"; readonly x: number; readonly y: number }
| { readonly _tag: "PageUp" }
| { readonly _tag: "PageDown" }
| { readonly _tag: "SetContent"; readonly content: string[] }
| { readonly _tag: "Focus" }
| { readonly _tag: "Blur" }
| { readonly _tag: "MouseWheel"; readonly deltaX: number; readonly deltaY: number }
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Calculate content dimensions from lines
*/
const calculateContentDimensions = (content: string[]): { width: number; height: number } => {
const height = content.length
const width = content.reduce((max, line) => Math.max(max, stringWidth(line)), 0)
return { width, height }
}
/**
* Clamp scroll position to valid bounds
*/
const clampScroll = (
scrollX: number,
scrollY: number,
contentWidth: number,
contentHeight: number,
viewportWidth: number,
viewportHeight: number
): { scrollX: number; scrollY: number } => {
const maxScrollX = Math.max(0, contentWidth - viewportWidth)
const maxScrollY = Math.max(0, contentHeight - viewportHeight)
return {
scrollX: Math.max(0, Math.min(scrollX, maxScrollX)),
scrollY: Math.max(0, Math.min(scrollY, maxScrollY))
}
}
/**
* Extract visible portion of content
*/
const getVisibleContent = (
content: string[],
scrollX: number,
scrollY: number,
viewportWidth: number,
viewportHeight: number
): string[] => {
const visibleLines = content.slice(scrollY, scrollY + viewportHeight)
return visibleLines.map(line => {
if (scrollX >= stringWidth(line)) {
return ''
}
// Handle multibyte characters properly
let charCount = 0
let bytePos = 0
// Skip to scrollX position
while (bytePos < line.length && charCount < scrollX) {
const char = line[bytePos]
charCount += stringWidth(char)
bytePos++
}
const startPos = bytePos
// Take viewportWidth characters
let visibleWidth = 0
let endPos = startPos
while (endPos < line.length && visibleWidth < viewportWidth) {
const char = line[endPos]
const charWidth = stringWidth(char)
if (visibleWidth + charWidth > viewportWidth) {
break
}
visibleWidth += charWidth
endPos++
}
const visiblePart = line.substring(startPos, endPos)
// Pad to full viewport width if needed
const paddingNeeded = viewportWidth - stringWidth(visiblePart)
return visiblePart + ' '.repeat(Math.max(0, paddingNeeded))
})
}
/**
* Create vertical scrollbar
*/
const createVerticalScrollbar = (
scrollY: number,
contentHeight: number,
viewportHeight: number
): string[] => {
if (contentHeight <= viewportHeight) {
return Array(viewportHeight).fill('│')
}
const scrollbarHeight = viewportHeight
const thumbHeight = Math.max(1, Math.floor((viewportHeight / contentHeight) * scrollbarHeight))
const thumbPosition = Math.floor((scrollY / (contentHeight - viewportHeight)) * (scrollbarHeight - thumbHeight))
const scrollbar: string[] = []
for (let i = 0; i < scrollbarHeight; i++) {
if (i >= thumbPosition && i < thumbPosition + thumbHeight) {
scrollbar.push('█') // Thumb
} else {
scrollbar.push('│') // Track
}
}
return scrollbar
}
/**
* Create horizontal scrollbar
*/
const createHorizontalScrollbar = (
scrollX: number,
contentWidth: number,
viewportWidth: number
): string => {
if (contentWidth <= viewportWidth) {
return '─'.repeat(viewportWidth)
}
const scrollbarWidth = viewportWidth
const thumbWidth = Math.max(1, Math.floor((viewportWidth / contentWidth) * scrollbarWidth))
const thumbPosition = Math.floor((scrollX / (contentWidth - viewportWidth)) * (scrollbarWidth - thumbWidth))
let scrollbar = ''
for (let i = 0; i < scrollbarWidth; i++) {
if (i >= thumbPosition && i < thumbPosition + thumbWidth) {
scrollbar += '█' // Thumb
} else {
scrollbar += '─' // Track
}
}
return scrollbar
}
// =============================================================================
// Component
// =============================================================================
export const viewport = (config: ViewportConfig): Component<ViewportModel, ViewportMsg> => ({
init: Effect.succeed([
{
config: {
showScrollbars: true,
smoothScroll: false,
scrollStep: 1,
pageSize: Math.floor(config.height * 0.8),
...config
},
scrollX: 0,
scrollY: 0,
contentWidth: 0,
contentHeight: 0,
content: [],
isFocused: false
},
[]
]),
update(msg: ViewportMsg, model: ViewportModel) {
const { config } = model
const actualViewportWidth = config.showScrollbars ? config.width - 1 : config.width
const actualViewportHeight = config.showScrollbars ? config.height - 1 : config.height
switch (msg._tag) {
case "ScrollUp": {
const amount = msg.amount ?? config.scrollStep ?? 1
const newScrollY = model.scrollY - amount
const { scrollX, scrollY } = clampScroll(
model.scrollX,
newScrollY,
model.contentWidth,
model.contentHeight,
actualViewportWidth,
actualViewportHeight
)
return Effect.succeed([
{ ...model, scrollX, scrollY },
[]
])
}
case "ScrollDown": {
const amount = msg.amount ?? config.scrollStep ?? 1
const newScrollY = model.scrollY + amount
const { scrollX, scrollY } = clampScroll(
model.scrollX,
newScrollY,
model.contentWidth,
model.contentHeight,
actualViewportWidth,
actualViewportHeight
)
return Effect.succeed([
{ ...model, scrollX, scrollY },
[]
])
}
case "ScrollLeft": {
const amount = msg.amount ?? config.scrollStep ?? 1
const newScrollX = model.scrollX - amount
const { scrollX, scrollY } = clampScroll(
newScrollX,
model.scrollY,
model.contentWidth,
model.contentHeight,
actualViewportWidth,
actualViewportHeight
)
return Effect.succeed([
{ ...model, scrollX, scrollY },
[]
])
}
case "ScrollRight": {
const amount = msg.amount ?? config.scrollStep ?? 1
const newScrollX = model.scrollX + amount
const { scrollX, scrollY } = clampScroll(
newScrollX,
model.scrollY,
model.contentWidth,
model.contentHeight,
actualViewportWidth,
actualViewportHeight
)
return Effect.succeed([
{ ...model, scrollX, scrollY },
[]
])
}
case "ScrollToTop": {
const { scrollX, scrollY } = clampScroll(
model.scrollX,
0,
model.contentWidth,
model.contentHeight,
actualViewportWidth,
actualViewportHeight
)
return Effect.succeed([
{ ...model, scrollX, scrollY },
[]
])
}
case "ScrollToBottom": {
const { scrollX, scrollY } = clampScroll(
model.scrollX,
model.contentHeight,
model.contentWidth,
model.contentHeight,
actualViewportWidth,
actualViewportHeight
)
return Effect.succeed([
{ ...model, scrollX, scrollY },
[]
])
}
case "ScrollToPosition": {
const { scrollX, scrollY } = clampScroll(
msg.x,
msg.y,
model.contentWidth,
model.contentHeight,
actualViewportWidth,
actualViewportHeight
)
return Effect.succeed([
{ ...model, scrollX, scrollY },
[]
])
}
case "PageUp": {
const amount = config.pageSize ?? Math.floor(actualViewportHeight * 0.8)
const newScrollY = model.scrollY - amount
const { scrollX, scrollY } = clampScroll(
model.scrollX,
newScrollY,
model.contentWidth,
model.contentHeight,
actualViewportWidth,
actualViewportHeight
)
return Effect.succeed([
{ ...model, scrollX, scrollY },
[]
])
}
case "PageDown": {
const amount = config.pageSize ?? Math.floor(actualViewportHeight * 0.8)
const newScrollY = model.scrollY + amount
const { scrollX, scrollY } = clampScroll(
model.scrollX,
newScrollY,
model.contentWidth,
model.contentHeight,
actualViewportWidth,
actualViewportHeight
)
return Effect.succeed([
{ ...model, scrollX, scrollY },
[]
])
}
case "SetContent": {
const { width: contentWidth, height: contentHeight } = calculateContentDimensions(msg.content)
const { scrollX, scrollY } = clampScroll(
model.scrollX,
model.scrollY,
contentWidth,
contentHeight,
actualViewportWidth,
actualViewportHeight
)
return Effect.succeed([
{
...model,
content: msg.content,
contentWidth,
contentHeight,
scrollX,
scrollY
},
[]
])
}
case "Focus": {
return Effect.succeed([
{ ...model, isFocused: true },
[]
])
}
case "Blur": {
return Effect.succeed([
{ ...model, isFocused: false },
[]
])
}
case "MouseWheel": {
// Scroll based on wheel delta
const scrollAmount = config.scrollStep ?? 1
const newScrollX = model.scrollX + (msg.deltaX * scrollAmount)
const newScrollY = model.scrollY + (msg.deltaY * scrollAmount)
const { scrollX, scrollY } = clampScroll(
newScrollX,
newScrollY,
model.contentWidth,
model.contentHeight,
actualViewportWidth,
actualViewportHeight
)
return Effect.succeed([
{ ...model, scrollX, scrollY },
[]
])
}
}
},
view(model: ViewportModel): View {
const { config } = model
const actualViewportWidth = config.showScrollbars ? config.width - 1 : config.width
const actualViewportHeight = config.showScrollbars ? config.height - 1 : config.height
// Get visible content
const visibleContent = getVisibleContent(
model.content,
model.scrollX,
model.scrollY,
actualViewportWidth,
actualViewportHeight
)
// Pad content to fill viewport height
while (visibleContent.length < actualViewportHeight) {
visibleContent.push(' '.repeat(actualViewportWidth))
}
// Create content views
const contentViews = visibleContent.map(line =>
text(line, style())
)
if (!config.showScrollbars) {
// Return content without scrollbars
return vstack(...contentViews)
}
// Create scrollbars
const verticalScrollbar = createVerticalScrollbar(
model.scrollY,
model.contentHeight,
actualViewportHeight
)
const horizontalScrollbar = createHorizontalScrollbar(
model.scrollX,
model.contentWidth,
actualViewportWidth
)
// Create scrollbar views
const scrollbarViews = verticalScrollbar.map(char =>
text(char, style(Colors.Gray))
)
// Combine content with vertical scrollbar
const contentWithVerticalScrollbar = contentViews.map((contentView, index) =>
hstack(
contentView,
scrollbarViews[index] || text('│', style(Colors.Gray))
)
)
// Add horizontal scrollbar at bottom
const horizontalScrollbarView = hstack(
text(horizontalScrollbar, style(Colors.Gray)),
text('┘', style(Colors.Gray)) // Corner piece
)
// Add focus indicator if focused
const borderStyle = model.isFocused
? style(Colors.BrightBlue)
: style(Colors.Gray)
const viewport = vstack(
...contentWithVerticalScrollbar,
horizontalScrollbarView
)
return box(viewport, { border: Borders.Rounded, style: borderStyle })
},
// Handle keyboard input when focused
handleKey(key: KeyEvent, model: ViewportModel): ViewportMsg | null {
if (!model.isFocused) {
return null
}
switch (key.key) {
case 'up':
case 'k':
return { _tag: "ScrollUp" }
case 'down':
case 'j':
return { _tag: "ScrollDown" }
case 'left':
case 'h':
return { _tag: "ScrollLeft" }
case 'right':
case 'l':
return { _tag: "ScrollRight" }
case 'pageup':
return { _tag: "PageUp" }
case 'pagedown':
return { _tag: "PageDown" }
case 'home':
return { _tag: "ScrollToTop" }
case 'end':
return { _tag: "ScrollToBottom" }
default:
return null
}
}
})
// =============================================================================
// Helper Functions for Creating Content
// =============================================================================
/**
* Create text content from a string with line wrapping
*/
export const createTextContent = (
text: string,
maxWidth?: number
): string[] => {
const lines = text.split('\n')
if (!maxWidth) {
return lines
}
const wrappedLines: string[] = []
for (const line of lines) {
if (stringWidth(line) <= maxWidth) {
wrappedLines.push(line)
} else {
// Wrap long lines
let currentLine = ''
const words = line.split(' ')
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word
if (stringWidth(testLine) <= maxWidth) {
currentLine = testLine
} else {
if (currentLine) {
wrappedLines.push(currentLine)
currentLine = word
} else {
// Word is longer than maxWidth, break it
wrappedLines.push(word.substring(0, maxWidth))
currentLine = word.substring(maxWidth)
}
}
}
if (currentLine) {
wrappedLines.push(currentLine)
}
}
}
return wrappedLines
}
/**
* Create grid content (useful for tables, logs, etc.)
*/
export const createGridContent = (
data: string[][],
columnWidths: number[],
separator: string = ' | '
): string[] => {
return data.map(row => {
const paddedCells = row.map((cell, index) => {
const width = columnWidths[index] || 10
const cellWidth = stringWidth(cell)
if (cellWidth >= width) {
return cell.substring(0, width)
} else {
return cell + ' '.repeat(width - cellWidth)
}
})
return paddedCells.join(separator)
})
}
/**
* Create numbered line content (useful for code/logs)
*/
export const createNumberedContent = (
lines: string[],
startNumber: number = 1,
numberWidth: number = 4
): string[] => {
return lines.map((line, index) => {
const lineNumber = startNumber + index
const paddedNumber = lineNumber.toString().padStart(numberWidth, ' ')
return `${paddedNumber}: ${line}`
})
}