+9
| 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 |
+116
| 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" | ||
| } | ||
| } |
+68
-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. |
-258
| 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() |
-195
| 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, '&') | ||
| .replace(/</g, '<') | ||
| .replace(/>/g, '>') | ||
| .replace(/"/g, '"') | ||
| .replace(/'/g, ''') | ||
| } | ||
| 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 } |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
1
-75%4
-20%1
-50%80
344.44%0
-100%21010
-57.97%8
-42.86%255
-78.02%+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed