You're Invited:Meet the Socket Team at RSAC and BSidesSF 2026, March 23–26.RSVP
Socket
Book a DemoSign 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.4
to
1.0.0
+9
index.js
const Cellery = require('./lib/cellery')
const cells = require('./lib/cells')
const decoration = require('./lib/decorations')
module.exports = {
Cellery,
...cells,
...decoration
}
const Iambus = require('iambus')
class Cellery extends Iambus {
constructor(app, adapter) {
super()
this.app = app
this.adapter = adapter
this.app.register(this)
}
// TODO: pipe compat...
write(data) {
this.pub(data)
}
on(type, cb) {}
emit(type, stream) {}
render() {
this.app.render()
}
}
module.exports = Cellery
class Cell {
constructor(opts = {}) {
this.id = opts.id
this.children = opts.children || []
this.cellery = opts.cellery
this.padding = opts.padding
this.margin = opts.margin
this.color = opts.color
this.alignment = opts.alignment
this.decoration = opts.decoration
this.size = opts.size
}
sub(pattern, cb) {
this.cellery.sub(pattern).on('data', (d) => cb(this, d))
}
render(opts = {}) {
this.cellery.pub({
event: 'render',
id: this.id,
content: this.cellery.adapter.render(this),
...opts
})
}
destroy() {
this.cellery.pub({
event: 'render',
id: this.id,
destroy: true
})
}
register(cellery) {
this.cellery = cellery
for (const c of this.children) {
c.register(cellery)
}
}
}
class MultiCell {
constructor(opts = {}) {
this.id = opts.id
this.cellery = opts.cellery
}
sub(pattern, cb) {
this.cellery.sub(pattern).on('data', (d) => cb(this, d))
}
_render() {
// impl
}
render(opts = {}) {
const cell = this._render()
cell.register(this.cellery)
cell.render(opts)
}
}
class Container extends Cell {
// TODO: replace with classes
static ScrollAll = 'all'
static ScrollVertical = 'vertical'
static ScrollHorizontal = 'horizontal'
static ScrollNone = 'none'
static FlexAuto = 'auto'
static FlexNone = 'none'
constructor(opts = {}) {
super(opts)
this.scroll = opts.scroll || Container.ScrollNone
this.flex = opts.flex || Container.FlexNone
}
}
class App extends Cell {
constructor(opts = {}) {
super({ ...opts, id: 'app' })
}
}
class Text extends Cell {
constructor(opts = {}) {
super(opts)
this.value = opts.value || ''
}
}
class Paragraph extends Cell {
constructor(opts = {}) {
super(opts)
}
}
class Input extends Cell {
constructor(opts = {}) {
super(opts)
this.multiline = !!opts.multiline
this.placeholder = opts.placeholder
this.type = opts.type || 'text'
}
}
module.exports = {
Cell,
MultiCell,
Container,
App,
Text,
Paragraph,
Input
}
class Alignment {
direction = ''
justify = ''
items = ''
constructor(direction, justify, items) {
this.direction = direction
this.justify = justify
this.items = items
}
static Horizontal({ justify, items }) {
return new Alignment('horizontal', justify, items)
}
static Vertical({ justify, items }) {
return new Alignment('vertical', justify, items)
}
}
class Spacing {
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 Spacing(value, value, value, value)
}
static symmetric({ vertical, horizontal }) {
return new Spacing(horizontal, vertical, horizontal, vertical)
}
static only({ left, right, top, bottom }) {
return new Spacing(left, top, right, bottom)
}
toString() {
return `Spacing(${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 Border {
width = 0
color = null
constructor(opts = {}) {
this.width = opts.width || 1
this.color = opts.color
}
static all(opts) {
return new Border(opts)
}
// todo: support trlb
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}`)})`
}
}
const Size = {
XS: 'xs',
S: 's',
M: 'm',
L: 'l',
XL: 'xl'
}
module.exports = {
Alignment,
BoxDecoration,
Border,
Color,
Spacing,
Size
}
+5
-26
{
"name": "cellery",
"version": "0.0.4",
"version": "1.0.0",
"description": "cellery",
"exports": {
"./package": "./package.json",
"./renderers": "./lib/renderers/index.js",
"./components": "./lib/components/index.js",
".": {
"types": "./index.d.ts",
"default": "./lib/index.js"
"default": "./index.js"
}
},
"imports": {
"process": {
"bare": "bare-process",
"default": "process"
},
"fs": {
"bare": "bare-fs",
"default": "fs"
},
"events": {
"bare": "bare-events",
"default": "events"
}
},
"type": "commonjs",
"files": [

@@ -32,8 +17,5 @@ "package.json",

"index.d.ts",
"lib",
"demo",
"example.js"
"lib"
],
"devDependencies": {
"bare-fs": "^4.5.2",
"brittle": "^3.19.0",

@@ -60,7 +42,4 @@ "lunte": "^1.2.0",

"dependencies": {
"bare-ansi-escapes": "^2.2.3",
"bare-events": "^2.8.2",
"bare-process": "^4.2.2",
"graceful-goodbye": "^1.3.3"
"iambus": "^2.0.6"
}
}
# Cellery
> **WIP** - A deliberately simple cross-platform UI framework
> **WIP** - A stream-driven cross-platform UI framework
Cellery is a minimalist framework for cross-platform user interfaces. It keeps component internals simple while allowing renderers flexibility in how they display content. The framework provides essential building blocks called "Cells" - stateless, minimal components that renderers implement to allow complex applications to be built through composition.
Cellery is a minimal UI framework built around streams. Components called **Cells** subscribe to events and emit render instructions — no virtual DOM, no framework lock-in. Any Adapter can consume the output: HTML, TUI, native, mobile, whatever fits your use case.
Core philosophy: cells are stateless and minimal, renderers handle display, applications control behavior.
## How it works
## Why?
Cellery sits at the end of a pipeline. Writers (state machines, database change feeds, RPC streams) push events in, and Cells react by emitting render instructions out.
GUI, TUI, mobile, browser - all have unique needs. Rather than trying to solve all of these, `Cellery` lets you build functional components, similar to Flutter. Renderers can then choose to implement as much or as little as needed for their use case.
This publishes events to `Cellery` which you can subscribe to.
Want to render to eInk? Native components rather than React Native? TUI? Implement the `Cells` as you need for your use case while targetting a consistent UX across devices.
```js
const { Cellery } = require('cellery')
pipeline(
myWriter,
new Transform({
transform(status, cb) {
this.push({ event: 'something-happened', status })
cb()
}
}),
cellery
)
```
Cells subscribe to events and re-render reactively:
```js
const welcome = new Message({ id: 'welcome', value: 'Welcome', cellery })
welcome.sub({ event: 'login' }, (cell, { user }) => {
cell.value = `Welcome back ${user.displayName}`
cell.render({ id: 'messages', insert: 'beforeend' })
})
```
Or you render Cells at will, passing details how they should render:
```js
const msg = new Message({ value: 'Use /join <invite> to join a room', cellery })
msg.render({ id: 'messages', insert: 'beforeend' })
```
Rendering is handled by `Adapters`. Currently these are simply called by `Cellery` to render components when components manually choose to be re-rendered. By default they pass along meta so your `adapter` can figure out what to do with them. But there's no rules, no lifecycles. Just streams of content and events for your to react to as you need.
## Cells
We're trying to keep Cells simple. A super basic set of low level components to see how much can be achieved with little.
| Cell | Description |
|-------------|------------------------------------------|
| `Cell` | Base class for all components |
| `MultiCell` | Composes multiple cells into one render |
| `Container` | Layout wrapper with scroll and flex opts |
| `App` | Root cell, id is always `'app'` |
| `Text` | Inline text content |
| `Paragraph` | Block text content |
| `Input` | Text input, single or multiline |
## Decorations
Style primitives passed to cells — `Adapters` decide how to apply them. Just more meta for you to render with.
- `Color` — RGBA color, construct from hex or object
- `Spacing` — padding/margin with `all`, `symmetric`, or `only`
- `Border` — border width and color
- `BoxDecoration` — wraps border (and future decoration props)
- `Alignment` — horizontal or vertical layout direction with justify/items
- `Size` — named size tokens: `xs`, `s`, `m`, `l`, `xl`
## Renderers
`Adapters` must implement a single `render(cell)` method and return content in whatever format they need. The framework makes no assumptions about output format.
## Status
Work in progress. API subject to change.
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 }