Latest Threat Research:SANDWORM_MODE: Shai-Hulud-Style npm Worm Hijacks CI Workflows and Poisons AI Toolchains.Details
Socket
Book a DemoInstallSign in
Socket

cellery

Package Overview
Dependencies
Maintainers
2
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

cellery - npm Package Compare versions

Comparing version
0.0.2
to
0.0.4
+258
example.js
const { EdgeInsets, Color, Cellery, BoxDecoration, Border, Alignment, HotKey, keys } = require('.')
const { Container, Text, Center, Pressable, Scrollable } = require('cellery/components')
const { CelleryRendererTUI } = require('cellery/renderers')
const fs = require('fs')
const repos = [
'my-first-repo',
'cellery',
'git-remote-pear-transport',
'pear-desktop',
'pear-runtime',
'bare-kit',
'bare-dev',
'hypercore',
'hyperswarm',
'hyperdht',
'hypercore-crypto',
'compact-encoding',
'protomux',
'b4a',
'random-access-storage',
'random-access-file',
'brittle',
'quickbit',
'safety-catch'
]
// Load file content once
const fileContent = fs.readFileSync('./example.js', 'utf8')
const lines = fileContent.split('\n')
// Create persistent stateful components outside render function
let selected = 0
let currentView = 'list' // 'list' or 'file'
// Scrollable for the repo list - maintains its own scroll state
const listScrollable = new Scrollable({
width: '100%',
height: 'calc(100% - 1)',
scrollOffset: 0,
child: new Container({
width: '100%',
height: '100%',
alignment: Alignment.Center,
children: [] // Will be populated in render
})
})
// Scrollable for file viewer - maintains its own scroll state
const fileScrollable = new Scrollable({
width: '100%',
height: '100%',
scrollOffset: 0,
child: new Container({
width: '100%',
height: '100%',
children: lines.map(
(line) =>
new Text({
value: line,
width: '100%'
})
)
})
})
// Navigation controls for list view
const listUpControl = new Pressable({
hotkey: new HotKey({ key: keys.ARROW_UP }),
onPress: function () {
selected = selected === 0 ? repos.length - 1 : selected - 1
updateListView()
cellery.render()
}
})
const listDownControl = new Pressable({
hotkey: new HotKey({ key: keys.ARROW_DOWN }),
onPress: function () {
selected = selected === repos.length - 1 ? 0 : selected + 1
updateListView()
cellery.render()
}
})
const listEnterControl = new Pressable({
hotkey: new HotKey({ key: keys.ENTER }),
onPress: function () {
currentView = 'file'
fileScrollable.scrollOffset = 0
cellery.update(App())
}
})
// Navigation controls for file view
const fileUpControl = new Pressable({
hotkey: new HotKey({ key: keys.ARROW_UP }),
onPress: function () {
if (fileScrollable.scrollOffset > 0) {
fileScrollable.scrollOffset--
cellery.render()
}
}
})
const fileDownControl = new Pressable({
hotkey: new HotKey({ key: keys.ARROW_DOWN }),
onPress: function () {
const viewport = fileScrollable._renderedViewport
if (viewport && fileScrollable.scrollOffset + viewport.itemCount < lines.length) {
fileScrollable.scrollOffset++
cellery.render()
}
}
})
const fileBackControl = new Pressable({
hotkey: new HotKey({ key: keys.ESC }),
onPress: function () {
currentView = 'list'
cellery.update(App())
}
})
// Update list view children based on current selection
function updateListView() {
// Auto-scroll to keep selection visible
const viewport = listScrollable._renderedViewport
if (viewport && viewport.itemCount > 0) {
const positionInViewport = selected - listScrollable.scrollOffset
const triggerDistance = 1
// Scroll down if selection is too close to bottom
if (positionInViewport >= viewport.itemCount - triggerDistance) {
listScrollable.scrollOffset = Math.min(
repos.length - viewport.itemCount,
selected - viewport.itemCount + triggerDistance + 1
)
}
// Scroll up if selection is too close to top
if (positionInViewport < triggerDistance) {
listScrollable.scrollOffset = Math.max(0, selected - triggerDistance)
}
}
// Update list children with current selection highlighting
listScrollable.child.children = repos.map(
(name, i) =>
new Container({
width: '50%',
height: 3,
decoration: new BoxDecoration({
border: Border.all({
color: selected === i ? Color.from('#fa0') : Color.from('#bade5b')
})
}),
children: [
new Text({
value: name,
color: Color.from('#fff')
})
]
})
)
}
function App() {
// Initialize list on first render
if (listScrollable.child.children.length === 0) {
updateListView()
}
const header = new Container({
width: '100%',
height: 3,
decoration: new BoxDecoration({
border: Border.all({
color: Color.from('#bade5b')
})
}),
children: [
new Center({
width: '100%',
height: 3,
child: new Text({
value: currentView === 'list' ? 'Pear Git' : `Pear Git - ${repos[selected]}`,
color: Color.from('#fff')
})
})
]
})
if (currentView === 'file') {
return new Container({
width: '100%',
height: '100%',
margin: EdgeInsets.all(2),
alignment: Alignment.Center,
decoration: new BoxDecoration({
border: Border.all()
}),
children: [
fileUpControl,
fileDownControl,
fileBackControl,
header,
new Text({
value: 'Use ↑/↓ to scroll, ESC to go back',
color: Color.from({ red: 200, green: 200, blue: 200 })
}),
new Container({
width: '100%',
height: 'calc(100% - 5)',
decoration: new BoxDecoration({
border: Border.all({
color: Color.from('#bade5b')
})
}),
children: [fileScrollable]
})
]
})
}
// List view
const footer = new Text({
value: `${selected + 1}/${repos.length} | scroll: ${listScrollable.scrollOffset}`,
color: Color.from({ red: 100, green: 100, blue: 100 })
})
return new Container({
width: '100%',
height: '100%',
margin: EdgeInsets.all(2),
alignment: Alignment.Center,
decoration: new BoxDecoration({
border: Border.all()
}),
children: [
listUpControl,
listDownControl,
listEnterControl,
header,
new Text({
value: 'Use ↑/↓ to navigate, ENTER to view file',
color: Color.from({ red: 200, green: 200, blue: 200 })
}),
new Container({
width: '100%',
height: '70%',
alignment: Alignment.Center,
children: [listScrollable, footer]
})
]
})
}
const cellery = new Cellery({
renderer: new CelleryRendererTUI(),
child: App()
})
cellery.render()
const { EventEmitter } = require('events')
const Alignment = {
Left: 'left',
Center: 'center',
Right: 'right'
}
class EdgeInsets {
left = 0
top = 0
right = 0
bottom = 0
constructor(left, top, right, bottom) {
this.left = left
this.top = top
this.right = right
this.bottom = bottom
}
static all(value) {
return new EdgeInsets(value, value, value, value)
}
static symmetric({ vertical, horizontal }) {
return new EdgeInsets(horizontal, vertical, horizontal, vertical)
}
static only({ left, right, top, bottom }) {
return new EdgeInsets(left, top, right, bottom)
}
toString() {
return `EdgeInsets(${Object.entries(this).map(([k, v]) => `${k}: ${v}`)})`
}
}
class Border {
width = 0
color = null
constructor(opts = {}) {
this.width = opts.width || 1
this.color = opts.color
}
static all(opts) {
return new Border(opts)
}
toString() {
return `Border(${Object.entries(this).map(([k, v]) => `${k}: ${v}`)})`
}
}
class BoxDecoration {
border = null
constructor(opts = {}) {
this.border = opts.border
}
toString() {
return `BoxDecoration(${Object.entries(this).map(([k, v]) => `${k}: ${v}`)})`
}
}
class Color {
red = 0
green = 0
blue = 0
alpha = 0
constructor(red, green, blue, alpha) {
this.red = red || 0
this.green = green || 0
this.blue = blue || 0
this.alpha = alpha || 1
}
toString() {
return `Color(${Object.entries(this).map(([k, v]) => `${k}: ${v}`)})`
}
toRGBA() {
return `rgba(${this.red}, ${this.green}, ${this.blue}, ${this.alpha})`
}
toRGB() {
return `rgba(${this.red}, ${this.green}, ${this.blue})`
}
static from(value, alpha) {
if (typeof value === 'string' && value.startsWith('#')) {
return this.#fromHex(value, alpha)
}
if (typeof value === 'object') {
const { red, green, blue, alpha } = value
return new Color(red, green, blue, alpha)
}
}
static #fromHex(hex, alpha = 1) {
if (typeof hex !== 'string') return null
hex = hex.trim().replace(/^#/, '').toLowerCase()
if (hex.length === 3) {
const r = hex[0] + hex[0]
const g = hex[1] + hex[1]
const b = hex[2] + hex[2]
hex = r + g + b
}
if (hex.length !== 6 || !/^[0-9a-f]{6}$/.test(hex)) {
return null
}
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
return new Color(r, g, b, alpha)
}
}
class HotKey {
constructor(opts = {}) {
this.key = opts.key
this.ctrl = opts.ctrl ?? false
this.shift = opts.shift ?? false
}
}
class Cell extends EventEmitter {
constructor() {
super()
}
setAttribute(key, value) {
const oldValue = this[key]
this[key] = value
if (this.constructor.observedAttributes && this.constructor.observedAttributes.includes(key)) {
this.attributeChangedCallback(key, oldValue, value)
}
}
connectedCallback() {}
disconnectedCallback() {}
adoptedCallback() {}
attributeChangedCallback(name, oldValue, newValue) {}
toString() {
return `${this.constructor.name}(${Object.entries(this)
.filter(([_, v]) => (Array.isArray(v) ? v.length : v))
.map(([k, v]) => `${k}: ${Array.isArray(v) ? `[${v.map((vc) => vc.toString())}]` : v}`)
.join(', ')})`
}
}
class Cellery {
#renderer = null
#child = null
constructor(opts = {}) {
this.#renderer = opts.renderer
this.#child = opts.child
}
update(child) {
this.#child = child
return this.#renderer.render(child)
}
render() {
if (!this.#renderer) {
return this.#child.toString()
}
return this.#renderer.render(this.#child)
}
}
module.exports = {
Alignment,
Border,
BoxDecoration,
Color,
EdgeInsets,
Cell,
Cellery,
HotKey
}
const { Cell } = require('../base')
class Center extends Cell {
constructor(opts = {}) {
super(opts)
this.child = opts.child
}
}
/**
* Base container class for holding and aligning multiple children
*/
class Container extends Cell {
static observedAttributes = ['width', 'height']
constructor(opts = {}) {
super(opts)
this.width = opts.width
this.height = opts.height
this.alignment = opts.alignment
this.margin = opts.margin
this.padding = opts.padding
this.decoration = opts.decoration
this.color = opts.color
this.children = opts.children || []
}
attributeChangedCallback(name, oldValue, newValue) {
// console.log('attributeChangedCallback', name, oldValue, newValue)
}
}
const TextAlign = {
Left: 'left',
Right: 'right',
Center: 'center'
}
class Text extends Cell {
constructor(opts = {}) {
super(opts)
this.value = opts.value || ''
this.color = opts.color
this.textAlign = opts.textAlign
}
}
class Pressable extends Cell {
hotkey = null
#onPress = null
constructor(opts = {}) {
super(opts)
this.child = opts.child
this.#onPress = opts.onPress
this.hotkey = opts.hotkey
}
async onPress() {
if (!this.#onPress) return
await this.#onPress()
}
}
/**
* Scrollable component - manages viewport and scroll offset
*
* The framework keeps it simple: this component just tracks scroll state.
* Renderers implement their own scroll triggering logic while maintaining
* consistent UX.
*
* Takes a single child (typically a Container with children array)
*/
class Scrollable extends Cell {
constructor(opts = {}) {
super(opts)
this.width = opts.width
this.height = opts.height
this.child = opts.child // Single child, not children array
// Scroll state - managed by the component consumer (e.g., your app logic)
this.scrollOffset = opts.scrollOffset || 0
// Optional: callback when scroll would be useful (renderer can trigger this)
this.onScrollRequest = opts.onScrollRequest || null
// Renderer will populate this after rendering
this._renderedViewport = null
}
/**
* Get viewport info from last render
* Renderer populates this during render
*/
getViewportInfo() {
return this._renderedViewport || { itemCount: 0, height: 0 }
}
/**
* Request scroll by delta
* This is called by renderers or application logic to update scroll position
*/
requestScroll(delta) {
const newOffset = this.scrollOffset + delta
if (this.onScrollRequest) {
this.onScrollRequest(newOffset, delta)
}
return newOffset
}
/**
* Scroll to make a specific child index visible
* Returns the new scroll offset
*/
scrollToIndex(index, viewportHeight) {
if (index < this.scrollOffset) {
// Scroll up to show this item
return index
} else if (index >= this.scrollOffset + viewportHeight) {
// Scroll down to show this item
return index - viewportHeight + 1
}
return this.scrollOffset
}
/**
* Check if we can scroll in a direction
* childCount is the number of items in the scrollable content
*/
canScroll(direction, viewportHeight, childCount) {
if (direction < 0) {
return this.scrollOffset > 0
} else {
return this.scrollOffset + viewportHeight < childCount
}
}
/**
* Get the visible range of children for current scroll position
* childCount is the total number of items
*/
getVisibleRange(viewportHeight, childCount) {
const start = Math.max(0, this.scrollOffset)
const end = Math.min(childCount, start + viewportHeight)
return { start, end }
}
}
module.exports = {
Center,
Container,
Pressable,
Scrollable,
Text,
TextAlign
}
const base = require('./base')
const List = require('./list')
module.exports = {
...base,
List
}
const { Cell, HotKey, Alignment } = require('../base')
const keys = require('../keys')
const { Scrollable, Pressable, Container } = require('./base')
class List extends Cell {
constructor(opts = {}) {
super(opts)
this.children = opts.children || []
this.selected = opts.selected
this.scrollOffset = opts.scrollOffset || 0
this.triggerDistance = opts.triggerDistance || 0
this.viewportItemCount = opts.viewportItemCount || 0
}
render() {
// Calculate new scroll offset based on viewport info from last render
let newScrollOffset = this.scrollOffset
if (this.selected >= 0 && this.viewportItemCount) {
if (this.viewportItemCount > 0) {
const positionInViewport = this.selected - this.scrollOffset
// Scroll down if we're too close to bottom
if (positionInViewport >= this.viewportItemCount - this.triggerDistance) {
newScrollOffset = Math.min(
this.children.length - this.viewportItemCount,
this.selected - this.viewportItemCount + this.triggerDistance + 1
)
}
// Scroll up if we're too close to top
if (positionInViewport < this.triggerDistance) {
newScrollOffset = Math.max(0, this.selected - this.triggerDistance)
}
// Clamp to valid range
newScrollOffset = Math.max(
0,
Math.min(Math.max(0, this.children.length - this.viewportItemCount), newScrollOffset)
)
}
}
// Navigation controls (invisible, just for hotkeys)
const upControl = new Pressable({
hotkey: new HotKey({ key: keys.ARROW_UP }),
onPress: () => {
const newSelected = this.selected === 0 ? this.children.length - 1 : this.selected - 1
this.emit('navigate', {
selected: newSelected,
scrollOffset: newScrollOffset,
viewportItemCount: this._scrollableRef._renderedViewport.itemCount
})
}
})
const downControl = new Pressable({
hotkey: new HotKey({ key: keys.ARROW_DOWN }),
onPress: () => {
const newSelected = this.selected === this.children.length - 1 ? 0 : this.selected + 1
this.emit('navigate', {
selected: newSelected,
scrollOffset: newScrollOffset,
viewportItemCount: this._scrollableRef._renderedViewport.itemCount
})
}
})
// Create container with all children and their pressables
const listContainer = new Container({
width: '100%',
height: '100%', // Use full height available from Scrollable
alignment: Alignment.Center,
children: this.children.map(
(child, i) =>
new Pressable({
hotkey: i === this.selected ? new HotKey({ key: keys.ENTER }) : null,
onPress: () => {
this.emit('select', { selected: this.selected })
},
child
})
)
})
// Wrap container in scrollable
const scrollable = new Scrollable({
width: '100%',
height: 'calc(100% - 1)', // Reserve 1 row for footer
scrollOffset: newScrollOffset,
child: listContainer
})
// Store ref for next render
this._scrollableRef = scrollable
return new Container({
width: '100%',
height: '70%',
alignment: Alignment.Center,
children: [upControl, downControl, scrollable]
})
}
}
module.exports = List
const base = require('./base')
const keys = require('./keys')
module.exports = {
...base,
keys
}
module.exports = {
CTRL_C: '\u0003',
ESC: '\u001b',
ENTER: '\r',
ARROW_UP: '\u001b[A',
ARROW_DOWN: '\u001b[B',
ARROW_LEFT: '\u001b[C',
ARROW_RIGHT: '\u001b[D'
}
function escapeHTML(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
class CelleryRendererHTML {
components = {
Container: function () {
const width = this.width
const height = this.height
// Calculate margins (outside everything)
const margin = this.margin || { left: 0, top: 0, right: 0, bottom: 0 }
const marginLeft = margin.left
const marginTop = margin.top
const marginRight = margin.right
const marginBottom = margin.bottom
// Check if border exists
const hasBorder = this.decoration && this.decoration.border
const borderWidth = hasBorder ? 1 : 0
// Calculate padding (inside decoration/border)
const padding = this.padding || { left: 0, top: 0, right: 0, bottom: 0 }
const paddingLeft = padding.left
const paddingTop = padding.top
const paddingRight = padding.right
const paddingBottom = padding.bottom
// Build styles
const styles = {
width: `${width}px`,
height: `${height}px`,
margin: `${marginTop}px ${marginRight}px ${marginBottom}px ${marginLeft}px`,
padding: `${paddingTop}px ${paddingRight}px ${paddingBottom}px ${paddingLeft}px`,
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column'
}
if (hasBorder) {
const borderColor = this.decoration.border.color?.toRGBA() || '#000'
styles.border = `${borderWidth}px solid ${borderColor}`
}
if (this.color) {
styles.backgroundColor = this.color.toRGBA()
}
// Render children
let childrenHTML = ''
if (this.children && this.children.length > 0) {
for (const child of this.children) {
childrenHTML += this.renderer._renderComponent(child)
}
}
const styleStr = Object.entries(styles)
.map(([k, v]) => `${k.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${v}`)
.join('; ')
return `<div style="${styleStr}">${childrenHTML}</div>`
},
Center: function () {
const width = this.width
const height = this.height
const styles = {
width: `${width}px`,
height: `${height}px`,
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}
// Render child if exists
let childHTML = ''
if (this.child) {
childHTML = this.renderer._renderComponent(this.child)
}
const styleStr = Object.entries(styles)
.map(([k, v]) => `${k.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${v}`)
.join('; ')
return `<div style="${styleStr}">${childHTML}</div>`
},
Text: function () {
const text = String(this.value)
const color = this.color
const colorStr = color
? `rgba(${color.red}, ${color.green}, ${color.blue}, ${color.alpha || 1})`
: 'inherit'
return `<span style="color: ${colorStr}; textAlign: ${this.textAlign || 'left'}">${escapeHTML(text)}</span>`
}
}
_renderComponent(component) {
component.renderer = this
const rendererFn = this.components[component.constructor.name]
return rendererFn.call(component)
}
render(component) {
const html = this._renderComponent(component)
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
margin: 0;
padding: 20px;
font-family: monospace;
background: #f0f0f0;
}
</style>
</head>
<body>
${html}
</body>
</html>`
}
}
module.exports = { CelleryRendererHTML }
const html = require('./html-renderer')
const tui = require('./tui-renderer')
module.exports = { ...html, ...tui }
const process = require('process')
const { cursorPosition, eraseLine, eraseDisplay } = require('bare-ansi-escapes')
const { Alignment } = require('../base')
const goodbye = require('graceful-goodbye')
const keys = require('../keys')
const originalRawMode = process.stdin.isRaw
function start() {
process.stdin.setRawMode(true)
process.stdin.resume()
process.stdin.setEncoding('utf8')
process.stdout.write('\x1b[?1049h')
process.stdout.write('\x1b[?25l')
}
function exit() {
process.stdout.write(cursorPosition(0))
process.stdout.write(eraseLine)
process.stdin.setRawMode(originalRawMode)
process.stdout.write('\x1b[?25h')
process.stdout.write('\x1b[?1049l')
}
function parseDimension(value, parentValue) {
if (typeof value === 'number') {
return value
} else if (typeof value === 'string') {
if (value.endsWith('%')) {
const percent = Number(value.replace('%', '')) / 100
return parentValue * percent
} else if (value.startsWith('calc(') && value.endsWith(')')) {
// Simple calc parser: calc(100% - N) or calc(100% + N)
const expr = value.slice(5, -1).trim()
const match = expr.match(/^(\d+)%\s*([+-])\s*(\d+)$/)
if (match) {
const percent = Number(match[1]) / 100
const operator = match[2]
const offset = Number(match[3])
const base = parentValue * percent
return operator === '+' ? base + offset : base - offset
}
}
}
return 0
}
function getDimensions() {
const width = parseDimension(this.width, this.parent?.width)
const height = parseDimension(this.height, this.parent?.height)
return {
width: Math.floor(width),
height: Math.floor(height)
}
}
class CelleryRendererTUI {
hotkeys = new Map()
components = {
Container: function () {
const { width, height } = getDimensions.call(this)
// Calculate margins (outside everything)
const margin = this.margin || { left: 0, top: 0, right: 0, bottom: 0 }
const marginLeft = Math.floor(margin.left)
const marginTop = Math.floor(margin.top)
const marginRight = Math.floor(margin.right)
const marginBottom = Math.floor(margin.bottom)
// Check if border exists
const hasBorder = this.decoration && this.decoration.border
const borderWidth = hasBorder ? 1 : 0
// Calculate padding (inside decoration/border)
const padding = this.padding || { left: 0, top: 0, right: 0, bottom: 0 }
const paddingLeft = Math.floor(padding.left)
const paddingTop = Math.floor(padding.top)
const paddingRight = Math.floor(padding.right)
const paddingBottom = Math.floor(padding.bottom)
// Get background color
const backgroundColor = this.decoration?.color || this.color || null
// Create grid
const grid = []
for (let y = 0; y < height; y++) {
const row = []
for (let x = 0; x < width; x++) {
let char = ' '
let fgColor = null
let bgColor = null
// Check if we're in the margin area (outside)
const inMarginX = x < marginLeft || x >= width - marginRight
const inMarginY = y < marginTop || y >= height - marginBottom
// Adjust coordinates relative to decoration area (after margin)
const decorationX = x - marginLeft
const decorationY = y - marginTop
const decorationWidth = width - marginLeft - marginRight
const decorationHeight = height - marginTop - marginBottom
// Check if we're on the border
const onBorder =
!inMarginX &&
!inMarginY &&
hasBorder &&
(decorationX === 0 ||
decorationX === decorationWidth - 1 ||
decorationY === 0 ||
decorationY === decorationHeight - 1)
// Apply background color if not in margin and not on border
if (!inMarginX && !inMarginY && !onBorder && backgroundColor) {
bgColor = backgroundColor
}
// Only render border/decoration if not in margin
if (!inMarginX && !inMarginY && hasBorder) {
// Check border positions (using -1 for last index)
if (decorationX === 0 && decorationY === 0) {
char = '┌'
fgColor = this.decoration.border.color
} else if (decorationX === 0 && decorationY === decorationHeight - 1) {
char = '└'
fgColor = this.decoration.border.color
} else if (decorationX === decorationWidth - 1 && decorationY === 0) {
char = '┐'
fgColor = this.decoration.border.color
} else if (
decorationX === decorationWidth - 1 &&
decorationY === decorationHeight - 1
) {
char = '┘'
fgColor = this.decoration.border.color
} else if (decorationX === 0 || decorationX === decorationWidth - 1) {
char = '│'
fgColor = this.decoration.border.color
} else if (decorationY === 0 || decorationY === decorationHeight - 1) {
char = '─'
fgColor = this.decoration.border.color
}
}
row.push({ char, fgColor, bgColor })
}
grid.push(row)
}
// Render children vertically stacked
// Children go inside: margin → border → padding
if (this.children && this.children.length > 0) {
const childBaseX = marginLeft + borderWidth + paddingLeft
let childCurrentY = marginTop + borderWidth + paddingTop
// Calculate available width and height for children
const availableWidth =
width - marginLeft - marginRight - borderWidth * 2 - paddingLeft - paddingRight
const availableHeight =
height - marginTop - marginBottom - borderWidth * 2 - paddingTop - paddingBottom
const childParent = {
width: availableWidth,
height: availableHeight
}
for (const child of this.children) {
const childGrid = this.renderer._renderComponent(child, {
parent: childParent
})
if (!childGrid) continue
// Calculate horizontal position based on alignment
let childX = childBaseX
const childWidth = childGrid[0]?.length || 0
if (this.alignment === Alignment.Center) {
childX = childBaseX + Math.floor((availableWidth - childWidth) / 2)
} else if (this.alignment === Alignment.Right) {
childX = childBaseX + (availableWidth - childWidth)
}
this.renderer._mergeIntoBuffer(grid, childGrid, childX, childCurrentY)
// Move down by the height of the child
childCurrentY += childGrid.length
}
}
return grid
},
Center: function () {
const { width, height } = getDimensions.call(this.parent)
// Create empty grid
const grid = []
for (let y = 0; y < height; y++) {
const row = []
for (let x = 0; x < width; x++) {
row.push({ char: ' ', fgColor: null, bgColor: null })
}
grid.push(row)
}
// Render child if exists
if (this.child) {
const childParent = {
width: width,
height: height
}
const childGrid = this.renderer._renderComponent(this.child, {
parent: childParent
})
// Calculate center position
const childWidth = childGrid[0]?.length || 0
const childHeight = childGrid.length
const centerX = Math.floor((width - childWidth) / 2)
const centerY = Math.floor((height - childHeight) / 2)
this.renderer._mergeIntoBuffer(grid, childGrid, centerX, centerY)
}
return grid
},
Text: function () {
const text = String(this.value)
const grid = [[]]
for (let i = 0; i < text.length; i++) {
grid[0].push({ char: text[i], fgColor: this.color, bgColor: null })
}
return grid
},
Pressable: function () {
if (this.hotkey) {
this.renderer.registerHotkey(this.hotkey, () => {
this.onPress()
})
}
if (!this.child) return
return this.renderer._renderComponent(this.child, {
parent: this.parent
})
},
Scrollable: function () {
const { width, height } = getDimensions.call(this)
// Create viewport grid
const grid = []
for (let y = 0; y < height; y++) {
const row = []
for (let x = 0; x < width; x++) {
row.push({ char: ' ', fgColor: null, bgColor: null })
}
grid.push(row)
}
if (!this.child) {
// Store empty viewport info
this._renderedViewport = { itemCount: 0, height, itemHeight: 0 }
return grid
}
// The child should be a Container with children
// We need to know how many children it has to calculate viewport
const childrenCount = this.child.children?.length || 0
if (childrenCount === 0) {
this._renderedViewport = { itemCount: 0, height, itemHeight: 0 }
return grid
}
// Measure first child to determine item height
let itemHeight = 1
if (this.child.children && this.child.children.length > 0) {
const firstChild = this.child.children[0]
const firstChildGrid = this.renderer._renderComponent(firstChild, {
parent: { width, height }
})
if (firstChildGrid) {
itemHeight = firstChildGrid.length
}
}
const viewportItemCount = Math.floor(height / itemHeight)
// Store viewport info for component to use
this._renderedViewport = {
itemCount: viewportItemCount,
height,
itemHeight
}
// Get visible range based on scroll offset
const { start, end } = this.getVisibleRange(viewportItemCount, childrenCount)
// Store metadata for scroll indicators (optional)
this._scrollInfo = {
canScrollUp: start > 0,
canScrollDown: end < childrenCount,
visibleCount: end - start,
totalCount: childrenCount,
scrollOffset: this.scrollOffset,
viewportItemCount
}
// Create a modified child with only visible children
const visibleChildren = this.child.children.slice(start, end)
// Clone the child container with only visible children
const visibleChild = Object.create(Object.getPrototypeOf(this.child))
Object.assign(visibleChild, this.child)
visibleChild.children = visibleChildren
// Render the child with visible items
const childGrid = this.renderer._renderComponent(visibleChild, {
parent: { width, height }
})
if (childGrid) {
// Clip to viewport height
const clippedGrid = childGrid.slice(0, height)
this.renderer._mergeIntoBuffer(grid, clippedGrid, 0, 0)
}
return grid
}
}
_fgColorToAnsi(color) {
if (!color) return ''
const { red, green, blue } = color
return `\x1b[38;2;${red};${green};${blue}m`
}
_bgColorToAnsi(color) {
if (!color) return ''
const { red, green, blue } = color
return `\x1b[48;2;${red};${green};${blue}m`
}
_resetAnsi() {
return '\x1b[0m'
}
_mergeIntoBuffer(buffer, componentGrid, offsetX = 0, offsetY = 0) {
for (let dy = 0; dy < componentGrid.length; dy++) {
const targetY = offsetY + dy
if (targetY < 0 || targetY >= buffer.length) continue
for (let dx = 0; dx < componentGrid[dy].length; dx++) {
const targetX = offsetX + dx
if (targetX < 0 || targetX >= buffer[targetY].length) continue
const sourcePixel = componentGrid[dy][dx]
const targetPixel = buffer[targetY][targetX]
// Merge the pixels - preserve background if source doesn't have one
buffer[targetY][targetX] = {
char: sourcePixel.char,
fgColor: sourcePixel.fgColor,
bgColor: sourcePixel.bgColor !== null ? sourcePixel.bgColor : targetPixel.bgColor
}
}
}
}
_renderComponent(component, opts = {}) {
if (typeof component.render === 'function') {
component = component.render()
}
component.renderer = this
component.parent = opts.parent
const rendererFn = this.components[component.constructor.name]
return rendererFn.call(component)
}
registerHotkey(hotkey, fn) {
const key = hotkey.key
this.hotkeys.set(key, fn)
}
clearHotkeys() {
this.hotkeys.clear()
}
setup() {
if (this.isSetup) return
this.isSetup = true
start()
if (process.stdin.isTTY) {
process.stdin.on('data', (data) => {
const key = data.toString()
if (key === keys.CTRL_C || key === keys.ESC || key === 'q') {
// Ctrl+C
process.exit(0)
}
// Compare against the key string directly (not hotkey object)
for (const [hotkeyString, fn] of this.hotkeys.entries()) {
if (key === hotkeyString) {
fn()
return
}
}
})
}
process.on('SIGINT', exit)
process.on('SIGTERM', exit)
process.on('exit', exit)
process.stdout.on('resize', () => {
this.render()
})
goodbye(() => {
exit()
})
}
draw() {
this.clearHotkeys()
const buffer = this._renderComponent(this.root, {
parent: this.size
})
process.stdout.write(eraseDisplay)
process.stdout.write(cursorPosition(0, 0))
for (const row of buffer) {
for (const pixel of row) {
if (pixel.bgColor) {
process.stdout.write(this._bgColorToAnsi(pixel.bgColor))
}
if (pixel.fgColor) {
process.stdout.write(this._fgColorToAnsi(pixel.fgColor))
}
process.stdout.write(pixel.char)
if (pixel.fgColor || pixel.bgColor) {
process.stdout.write(this._resetAnsi())
}
}
process.stdout.write('\n')
}
}
render(component) {
if (component) {
this.root = component
}
this.size = { width: process.stdout.columns, height: process.stdout.rows }
this.setup()
this.draw()
}
}
module.exports = { CelleryRendererTUI }
+5
-2
{
"name": "cellery",
"version": "0.0.2",
"version": "0.0.4",
"description": "cellery",

@@ -31,3 +31,6 @@ "exports": {

"index.js",
"index.d.ts"
"index.d.ts",
"lib",
"demo",
"example.js"
],

@@ -34,0 +37,0 @@ "devDependencies": {