cellery 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/example.js ADDED
@@ -0,0 +1,258 @@
1
+ const { EdgeInsets, Color, Cellery, BoxDecoration, Border, Alignment, HotKey, keys } = require('.')
2
+ const { Container, Text, Center, Pressable, Scrollable } = require('cellery/components')
3
+ const { CelleryRendererTUI } = require('cellery/renderers')
4
+ const fs = require('fs')
5
+ const repos = [
6
+ 'my-first-repo',
7
+ 'cellery',
8
+ 'git-remote-pear-transport',
9
+ 'pear-desktop',
10
+ 'pear-runtime',
11
+ 'bare-kit',
12
+ 'bare-dev',
13
+ 'hypercore',
14
+ 'hyperswarm',
15
+ 'hyperdht',
16
+ 'hypercore-crypto',
17
+ 'compact-encoding',
18
+ 'protomux',
19
+ 'b4a',
20
+ 'random-access-storage',
21
+ 'random-access-file',
22
+ 'brittle',
23
+ 'quickbit',
24
+ 'safety-catch'
25
+ ]
26
+ // Load file content once
27
+ const fileContent = fs.readFileSync('./example.js', 'utf8')
28
+ const lines = fileContent.split('\n')
29
+
30
+ // Create persistent stateful components outside render function
31
+ let selected = 0
32
+ let currentView = 'list' // 'list' or 'file'
33
+
34
+ // Scrollable for the repo list - maintains its own scroll state
35
+ const listScrollable = new Scrollable({
36
+ width: '100%',
37
+ height: 'calc(100% - 1)',
38
+ scrollOffset: 0,
39
+ child: new Container({
40
+ width: '100%',
41
+ height: '100%',
42
+ alignment: Alignment.Center,
43
+ children: [] // Will be populated in render
44
+ })
45
+ })
46
+
47
+ // Scrollable for file viewer - maintains its own scroll state
48
+ const fileScrollable = new Scrollable({
49
+ width: '100%',
50
+ height: '100%',
51
+ scrollOffset: 0,
52
+ child: new Container({
53
+ width: '100%',
54
+ height: '100%',
55
+ children: lines.map(
56
+ (line) =>
57
+ new Text({
58
+ value: line,
59
+ width: '100%'
60
+ })
61
+ )
62
+ })
63
+ })
64
+
65
+ // Navigation controls for list view
66
+ const listUpControl = new Pressable({
67
+ hotkey: new HotKey({ key: keys.ARROW_UP }),
68
+ onPress: function () {
69
+ selected = selected === 0 ? repos.length - 1 : selected - 1
70
+ updateListView()
71
+ cellery.render()
72
+ }
73
+ })
74
+
75
+ const listDownControl = new Pressable({
76
+ hotkey: new HotKey({ key: keys.ARROW_DOWN }),
77
+ onPress: function () {
78
+ selected = selected === repos.length - 1 ? 0 : selected + 1
79
+ updateListView()
80
+ cellery.render()
81
+ }
82
+ })
83
+
84
+ const listEnterControl = new Pressable({
85
+ hotkey: new HotKey({ key: keys.ENTER }),
86
+ onPress: function () {
87
+ currentView = 'file'
88
+ fileScrollable.scrollOffset = 0
89
+ cellery.update(App())
90
+ }
91
+ })
92
+
93
+ // Navigation controls for file view
94
+ const fileUpControl = new Pressable({
95
+ hotkey: new HotKey({ key: keys.ARROW_UP }),
96
+ onPress: function () {
97
+ if (fileScrollable.scrollOffset > 0) {
98
+ fileScrollable.scrollOffset--
99
+ cellery.render()
100
+ }
101
+ }
102
+ })
103
+
104
+ const fileDownControl = new Pressable({
105
+ hotkey: new HotKey({ key: keys.ARROW_DOWN }),
106
+ onPress: function () {
107
+ const viewport = fileScrollable._renderedViewport
108
+ if (viewport && fileScrollable.scrollOffset + viewport.itemCount < lines.length) {
109
+ fileScrollable.scrollOffset++
110
+ cellery.render()
111
+ }
112
+ }
113
+ })
114
+
115
+ const fileBackControl = new Pressable({
116
+ hotkey: new HotKey({ key: keys.ESC }),
117
+ onPress: function () {
118
+ currentView = 'list'
119
+ cellery.update(App())
120
+ }
121
+ })
122
+
123
+ // Update list view children based on current selection
124
+ function updateListView() {
125
+ // Auto-scroll to keep selection visible
126
+ const viewport = listScrollable._renderedViewport
127
+ if (viewport && viewport.itemCount > 0) {
128
+ const positionInViewport = selected - listScrollable.scrollOffset
129
+ const triggerDistance = 1
130
+ // Scroll down if selection is too close to bottom
131
+ if (positionInViewport >= viewport.itemCount - triggerDistance) {
132
+ listScrollable.scrollOffset = Math.min(
133
+ repos.length - viewport.itemCount,
134
+ selected - viewport.itemCount + triggerDistance + 1
135
+ )
136
+ }
137
+ // Scroll up if selection is too close to top
138
+ if (positionInViewport < triggerDistance) {
139
+ listScrollable.scrollOffset = Math.max(0, selected - triggerDistance)
140
+ }
141
+ }
142
+ // Update list children with current selection highlighting
143
+ listScrollable.child.children = repos.map(
144
+ (name, i) =>
145
+ new Container({
146
+ width: '50%',
147
+ height: 3,
148
+ decoration: new BoxDecoration({
149
+ border: Border.all({
150
+ color: selected === i ? Color.from('#fa0') : Color.from('#bade5b')
151
+ })
152
+ }),
153
+ children: [
154
+ new Text({
155
+ value: name,
156
+ color: Color.from('#fff')
157
+ })
158
+ ]
159
+ })
160
+ )
161
+ }
162
+
163
+ function App() {
164
+ // Initialize list on first render
165
+ if (listScrollable.child.children.length === 0) {
166
+ updateListView()
167
+ }
168
+
169
+ const header = new Container({
170
+ width: '100%',
171
+ height: 3,
172
+ decoration: new BoxDecoration({
173
+ border: Border.all({
174
+ color: Color.from('#bade5b')
175
+ })
176
+ }),
177
+ children: [
178
+ new Center({
179
+ width: '100%',
180
+ height: 3,
181
+ child: new Text({
182
+ value: currentView === 'list' ? 'Pear Git' : `Pear Git - ${repos[selected]}`,
183
+ color: Color.from('#fff')
184
+ })
185
+ })
186
+ ]
187
+ })
188
+
189
+ if (currentView === 'file') {
190
+ return new Container({
191
+ width: '100%',
192
+ height: '100%',
193
+ margin: EdgeInsets.all(2),
194
+ alignment: Alignment.Center,
195
+ decoration: new BoxDecoration({
196
+ border: Border.all()
197
+ }),
198
+ children: [
199
+ fileUpControl,
200
+ fileDownControl,
201
+ fileBackControl,
202
+ header,
203
+ new Text({
204
+ value: 'Use ↑/↓ to scroll, ESC to go back',
205
+ color: Color.from({ red: 200, green: 200, blue: 200 })
206
+ }),
207
+ new Container({
208
+ width: '100%',
209
+ height: 'calc(100% - 5)',
210
+ decoration: new BoxDecoration({
211
+ border: Border.all({
212
+ color: Color.from('#bade5b')
213
+ })
214
+ }),
215
+ children: [fileScrollable]
216
+ })
217
+ ]
218
+ })
219
+ }
220
+
221
+ // List view
222
+ const footer = new Text({
223
+ value: `${selected + 1}/${repos.length} | scroll: ${listScrollable.scrollOffset}`,
224
+ color: Color.from({ red: 100, green: 100, blue: 100 })
225
+ })
226
+
227
+ return new Container({
228
+ width: '100%',
229
+ height: '100%',
230
+ margin: EdgeInsets.all(2),
231
+ alignment: Alignment.Center,
232
+ decoration: new BoxDecoration({
233
+ border: Border.all()
234
+ }),
235
+ children: [
236
+ listUpControl,
237
+ listDownControl,
238
+ listEnterControl,
239
+ header,
240
+ new Text({
241
+ value: 'Use ↑/↓ to navigate, ENTER to view file',
242
+ color: Color.from({ red: 200, green: 200, blue: 200 })
243
+ }),
244
+ new Container({
245
+ width: '100%',
246
+ height: '70%',
247
+ alignment: Alignment.Center,
248
+ children: [listScrollable, footer]
249
+ })
250
+ ]
251
+ })
252
+ }
253
+ const cellery = new Cellery({
254
+ renderer: new CelleryRendererTUI(),
255
+ child: App()
256
+ })
257
+
258
+ cellery.render()
package/lib/base.js ADDED
@@ -0,0 +1,195 @@
1
+ const { EventEmitter } = require('events')
2
+
3
+ const Alignment = {
4
+ Left: 'left',
5
+ Center: 'center',
6
+ Right: 'right'
7
+ }
8
+
9
+ class EdgeInsets {
10
+ left = 0
11
+ top = 0
12
+ right = 0
13
+ bottom = 0
14
+
15
+ constructor(left, top, right, bottom) {
16
+ this.left = left
17
+ this.top = top
18
+ this.right = right
19
+ this.bottom = bottom
20
+ }
21
+
22
+ static all(value) {
23
+ return new EdgeInsets(value, value, value, value)
24
+ }
25
+
26
+ static symmetric({ vertical, horizontal }) {
27
+ return new EdgeInsets(horizontal, vertical, horizontal, vertical)
28
+ }
29
+
30
+ static only({ left, right, top, bottom }) {
31
+ return new EdgeInsets(left, top, right, bottom)
32
+ }
33
+
34
+ toString() {
35
+ return `EdgeInsets(${Object.entries(this).map(([k, v]) => `${k}: ${v}`)})`
36
+ }
37
+ }
38
+
39
+ class Border {
40
+ width = 0
41
+ color = null
42
+
43
+ constructor(opts = {}) {
44
+ this.width = opts.width || 1
45
+ this.color = opts.color
46
+ }
47
+
48
+ static all(opts) {
49
+ return new Border(opts)
50
+ }
51
+
52
+ toString() {
53
+ return `Border(${Object.entries(this).map(([k, v]) => `${k}: ${v}`)})`
54
+ }
55
+ }
56
+
57
+ class BoxDecoration {
58
+ border = null
59
+
60
+ constructor(opts = {}) {
61
+ this.border = opts.border
62
+ }
63
+
64
+ toString() {
65
+ return `BoxDecoration(${Object.entries(this).map(([k, v]) => `${k}: ${v}`)})`
66
+ }
67
+ }
68
+
69
+ class Color {
70
+ red = 0
71
+ green = 0
72
+ blue = 0
73
+ alpha = 0
74
+
75
+ constructor(red, green, blue, alpha) {
76
+ this.red = red || 0
77
+ this.green = green || 0
78
+ this.blue = blue || 0
79
+ this.alpha = alpha || 1
80
+ }
81
+
82
+ toString() {
83
+ return `Color(${Object.entries(this).map(([k, v]) => `${k}: ${v}`)})`
84
+ }
85
+
86
+ toRGBA() {
87
+ return `rgba(${this.red}, ${this.green}, ${this.blue}, ${this.alpha})`
88
+ }
89
+
90
+ toRGB() {
91
+ return `rgba(${this.red}, ${this.green}, ${this.blue})`
92
+ }
93
+
94
+ static from(value, alpha) {
95
+ if (typeof value === 'string' && value.startsWith('#')) {
96
+ return this.#fromHex(value, alpha)
97
+ }
98
+ if (typeof value === 'object') {
99
+ const { red, green, blue, alpha } = value
100
+ return new Color(red, green, blue, alpha)
101
+ }
102
+ }
103
+
104
+ static #fromHex(hex, alpha = 1) {
105
+ if (typeof hex !== 'string') return null
106
+
107
+ hex = hex.trim().replace(/^#/, '').toLowerCase()
108
+
109
+ if (hex.length === 3) {
110
+ const r = hex[0] + hex[0]
111
+ const g = hex[1] + hex[1]
112
+ const b = hex[2] + hex[2]
113
+ hex = r + g + b
114
+ }
115
+
116
+ if (hex.length !== 6 || !/^[0-9a-f]{6}$/.test(hex)) {
117
+ return null
118
+ }
119
+
120
+ const r = parseInt(hex.slice(0, 2), 16)
121
+ const g = parseInt(hex.slice(2, 4), 16)
122
+ const b = parseInt(hex.slice(4, 6), 16)
123
+
124
+ return new Color(r, g, b, alpha)
125
+ }
126
+ }
127
+
128
+ class HotKey {
129
+ constructor(opts = {}) {
130
+ this.key = opts.key
131
+ this.ctrl = opts.ctrl ?? false
132
+ this.shift = opts.shift ?? false
133
+ }
134
+ }
135
+
136
+ class Cell extends EventEmitter {
137
+ constructor() {
138
+ super()
139
+ }
140
+
141
+ setAttribute(key, value) {
142
+ const oldValue = this[key]
143
+ this[key] = value
144
+
145
+ if (this.constructor.observedAttributes && this.constructor.observedAttributes.includes(key)) {
146
+ this.attributeChangedCallback(key, oldValue, value)
147
+ }
148
+ }
149
+
150
+ connectedCallback() {}
151
+ disconnectedCallback() {}
152
+ adoptedCallback() {}
153
+ attributeChangedCallback(name, oldValue, newValue) {}
154
+
155
+ toString() {
156
+ return `${this.constructor.name}(${Object.entries(this)
157
+ .filter(([_, v]) => (Array.isArray(v) ? v.length : v))
158
+ .map(([k, v]) => `${k}: ${Array.isArray(v) ? `[${v.map((vc) => vc.toString())}]` : v}`)
159
+ .join(', ')})`
160
+ }
161
+ }
162
+
163
+ class Cellery {
164
+ #renderer = null
165
+ #child = null
166
+
167
+ constructor(opts = {}) {
168
+ this.#renderer = opts.renderer
169
+ this.#child = opts.child
170
+ }
171
+
172
+ update(child) {
173
+ this.#child = child
174
+ return this.#renderer.render(child)
175
+ }
176
+
177
+ render() {
178
+ if (!this.#renderer) {
179
+ return this.#child.toString()
180
+ }
181
+
182
+ return this.#renderer.render(this.#child)
183
+ }
184
+ }
185
+
186
+ module.exports = {
187
+ Alignment,
188
+ Border,
189
+ BoxDecoration,
190
+ Color,
191
+ EdgeInsets,
192
+ Cell,
193
+ Cellery,
194
+ HotKey
195
+ }
@@ -0,0 +1,165 @@
1
+ const { Cell } = require('../base')
2
+
3
+ class Center extends Cell {
4
+ constructor(opts = {}) {
5
+ super(opts)
6
+
7
+ this.child = opts.child
8
+ }
9
+ }
10
+
11
+ /**
12
+ * Base container class for holding and aligning multiple children
13
+ */
14
+ class Container extends Cell {
15
+ static observedAttributes = ['width', 'height']
16
+
17
+ constructor(opts = {}) {
18
+ super(opts)
19
+ this.width = opts.width
20
+ this.height = opts.height
21
+ this.alignment = opts.alignment
22
+ this.margin = opts.margin
23
+ this.padding = opts.padding
24
+ this.decoration = opts.decoration
25
+ this.color = opts.color
26
+ this.children = opts.children || []
27
+ }
28
+
29
+ attributeChangedCallback(name, oldValue, newValue) {
30
+ // console.log('attributeChangedCallback', name, oldValue, newValue)
31
+ }
32
+ }
33
+
34
+ const TextAlign = {
35
+ Left: 'left',
36
+ Right: 'right',
37
+ Center: 'center'
38
+ }
39
+
40
+ class Text extends Cell {
41
+ constructor(opts = {}) {
42
+ super(opts)
43
+
44
+ this.value = opts.value || ''
45
+ this.color = opts.color
46
+ this.textAlign = opts.textAlign
47
+ }
48
+ }
49
+
50
+ class Pressable extends Cell {
51
+ hotkey = null
52
+ #onPress = null
53
+
54
+ constructor(opts = {}) {
55
+ super(opts)
56
+
57
+ this.child = opts.child
58
+ this.#onPress = opts.onPress
59
+ this.hotkey = opts.hotkey
60
+ }
61
+
62
+ async onPress() {
63
+ if (!this.#onPress) return
64
+
65
+ await this.#onPress()
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Scrollable component - manages viewport and scroll offset
71
+ *
72
+ * The framework keeps it simple: this component just tracks scroll state.
73
+ * Renderers implement their own scroll triggering logic while maintaining
74
+ * consistent UX.
75
+ *
76
+ * Takes a single child (typically a Container with children array)
77
+ */
78
+ class Scrollable extends Cell {
79
+ constructor(opts = {}) {
80
+ super(opts)
81
+
82
+ this.width = opts.width
83
+ this.height = opts.height
84
+ this.child = opts.child // Single child, not children array
85
+
86
+ // Scroll state - managed by the component consumer (e.g., your app logic)
87
+ this.scrollOffset = opts.scrollOffset || 0
88
+
89
+ // Optional: callback when scroll would be useful (renderer can trigger this)
90
+ this.onScrollRequest = opts.onScrollRequest || null
91
+
92
+ // Renderer will populate this after rendering
93
+ this._renderedViewport = null
94
+ }
95
+
96
+ /**
97
+ * Get viewport info from last render
98
+ * Renderer populates this during render
99
+ */
100
+ getViewportInfo() {
101
+ return this._renderedViewport || { itemCount: 0, height: 0 }
102
+ }
103
+
104
+ /**
105
+ * Request scroll by delta
106
+ * This is called by renderers or application logic to update scroll position
107
+ */
108
+ requestScroll(delta) {
109
+ const newOffset = this.scrollOffset + delta
110
+
111
+ if (this.onScrollRequest) {
112
+ this.onScrollRequest(newOffset, delta)
113
+ }
114
+
115
+ return newOffset
116
+ }
117
+
118
+ /**
119
+ * Scroll to make a specific child index visible
120
+ * Returns the new scroll offset
121
+ */
122
+ scrollToIndex(index, viewportHeight) {
123
+ if (index < this.scrollOffset) {
124
+ // Scroll up to show this item
125
+ return index
126
+ } else if (index >= this.scrollOffset + viewportHeight) {
127
+ // Scroll down to show this item
128
+ return index - viewportHeight + 1
129
+ }
130
+
131
+ return this.scrollOffset
132
+ }
133
+
134
+ /**
135
+ * Check if we can scroll in a direction
136
+ * childCount is the number of items in the scrollable content
137
+ */
138
+ canScroll(direction, viewportHeight, childCount) {
139
+ if (direction < 0) {
140
+ return this.scrollOffset > 0
141
+ } else {
142
+ return this.scrollOffset + viewportHeight < childCount
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Get the visible range of children for current scroll position
148
+ * childCount is the total number of items
149
+ */
150
+ getVisibleRange(viewportHeight, childCount) {
151
+ const start = Math.max(0, this.scrollOffset)
152
+ const end = Math.min(childCount, start + viewportHeight)
153
+
154
+ return { start, end }
155
+ }
156
+ }
157
+
158
+ module.exports = {
159
+ Center,
160
+ Container,
161
+ Pressable,
162
+ Scrollable,
163
+ Text,
164
+ TextAlign
165
+ }
@@ -0,0 +1,7 @@
1
+ const base = require('./base')
2
+ const List = require('./list')
3
+
4
+ module.exports = {
5
+ ...base,
6
+ List
7
+ }
@@ -0,0 +1,107 @@
1
+ const { Cell, HotKey, Alignment } = require('../base')
2
+ const keys = require('../keys')
3
+ const { Scrollable, Pressable, Container } = require('./base')
4
+
5
+ class List extends Cell {
6
+ constructor(opts = {}) {
7
+ super(opts)
8
+
9
+ this.children = opts.children || []
10
+ this.selected = opts.selected
11
+ this.scrollOffset = opts.scrollOffset || 0
12
+ this.triggerDistance = opts.triggerDistance || 0
13
+ this.viewportItemCount = opts.viewportItemCount || 0
14
+ }
15
+
16
+ render() {
17
+ // Calculate new scroll offset based on viewport info from last render
18
+ let newScrollOffset = this.scrollOffset
19
+
20
+ if (this.selected >= 0 && this.viewportItemCount) {
21
+ if (this.viewportItemCount > 0) {
22
+ const positionInViewport = this.selected - this.scrollOffset
23
+
24
+ // Scroll down if we're too close to bottom
25
+ if (positionInViewport >= this.viewportItemCount - this.triggerDistance) {
26
+ newScrollOffset = Math.min(
27
+ this.children.length - this.viewportItemCount,
28
+ this.selected - this.viewportItemCount + this.triggerDistance + 1
29
+ )
30
+ }
31
+
32
+ // Scroll up if we're too close to top
33
+ if (positionInViewport < this.triggerDistance) {
34
+ newScrollOffset = Math.max(0, this.selected - this.triggerDistance)
35
+ }
36
+
37
+ // Clamp to valid range
38
+ newScrollOffset = Math.max(
39
+ 0,
40
+ Math.min(Math.max(0, this.children.length - this.viewportItemCount), newScrollOffset)
41
+ )
42
+ }
43
+ }
44
+
45
+ // Navigation controls (invisible, just for hotkeys)
46
+ const upControl = new Pressable({
47
+ hotkey: new HotKey({ key: keys.ARROW_UP }),
48
+ onPress: () => {
49
+ const newSelected = this.selected === 0 ? this.children.length - 1 : this.selected - 1
50
+ this.emit('navigate', {
51
+ selected: newSelected,
52
+ scrollOffset: newScrollOffset,
53
+ viewportItemCount: this._scrollableRef._renderedViewport.itemCount
54
+ })
55
+ }
56
+ })
57
+
58
+ const downControl = new Pressable({
59
+ hotkey: new HotKey({ key: keys.ARROW_DOWN }),
60
+ onPress: () => {
61
+ const newSelected = this.selected === this.children.length - 1 ? 0 : this.selected + 1
62
+ this.emit('navigate', {
63
+ selected: newSelected,
64
+ scrollOffset: newScrollOffset,
65
+ viewportItemCount: this._scrollableRef._renderedViewport.itemCount
66
+ })
67
+ }
68
+ })
69
+
70
+ // Create container with all children and their pressables
71
+ const listContainer = new Container({
72
+ width: '100%',
73
+ height: '100%', // Use full height available from Scrollable
74
+ alignment: Alignment.Center,
75
+ children: this.children.map(
76
+ (child, i) =>
77
+ new Pressable({
78
+ hotkey: i === this.selected ? new HotKey({ key: keys.ENTER }) : null,
79
+ onPress: () => {
80
+ this.emit('select', { selected: this.selected })
81
+ },
82
+ child
83
+ })
84
+ )
85
+ })
86
+
87
+ // Wrap container in scrollable
88
+ const scrollable = new Scrollable({
89
+ width: '100%',
90
+ height: 'calc(100% - 1)', // Reserve 1 row for footer
91
+ scrollOffset: newScrollOffset,
92
+ child: listContainer
93
+ })
94
+
95
+ // Store ref for next render
96
+ this._scrollableRef = scrollable
97
+
98
+ return new Container({
99
+ width: '100%',
100
+ height: '70%',
101
+ alignment: Alignment.Center,
102
+ children: [upControl, downControl, scrollable]
103
+ })
104
+ }
105
+ }
106
+
107
+ module.exports = List
package/lib/index.js ADDED
@@ -0,0 +1,7 @@
1
+ const base = require('./base')
2
+ const keys = require('./keys')
3
+
4
+ module.exports = {
5
+ ...base,
6
+ keys
7
+ }
package/lib/keys.js ADDED
@@ -0,0 +1,9 @@
1
+ module.exports = {
2
+ CTRL_C: '\u0003',
3
+ ESC: '\u001b',
4
+ ENTER: '\r',
5
+ ARROW_UP: '\u001b[A',
6
+ ARROW_DOWN: '\u001b[B',
7
+ ARROW_LEFT: '\u001b[C',
8
+ ARROW_RIGHT: '\u001b[D'
9
+ }
@@ -0,0 +1,133 @@
1
+ function escapeHTML(str) {
2
+ return str
3
+ .replace(/&/g, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;')
7
+ .replace(/'/g, '&#039;')
8
+ }
9
+
10
+ class CelleryRendererHTML {
11
+ components = {
12
+ Container: function () {
13
+ const width = this.width
14
+ const height = this.height
15
+
16
+ // Calculate margins (outside everything)
17
+ const margin = this.margin || { left: 0, top: 0, right: 0, bottom: 0 }
18
+ const marginLeft = margin.left
19
+ const marginTop = margin.top
20
+ const marginRight = margin.right
21
+ const marginBottom = margin.bottom
22
+
23
+ // Check if border exists
24
+ const hasBorder = this.decoration && this.decoration.border
25
+ const borderWidth = hasBorder ? 1 : 0
26
+
27
+ // Calculate padding (inside decoration/border)
28
+ const padding = this.padding || { left: 0, top: 0, right: 0, bottom: 0 }
29
+ const paddingLeft = padding.left
30
+ const paddingTop = padding.top
31
+ const paddingRight = padding.right
32
+ const paddingBottom = padding.bottom
33
+
34
+ // Build styles
35
+ const styles = {
36
+ width: `${width}px`,
37
+ height: `${height}px`,
38
+ margin: `${marginTop}px ${marginRight}px ${marginBottom}px ${marginLeft}px`,
39
+ padding: `${paddingTop}px ${paddingRight}px ${paddingBottom}px ${paddingLeft}px`,
40
+ boxSizing: 'border-box',
41
+ display: 'flex',
42
+ flexDirection: 'column'
43
+ }
44
+
45
+ if (hasBorder) {
46
+ const borderColor = this.decoration.border.color?.toRGBA() || '#000'
47
+ styles.border = `${borderWidth}px solid ${borderColor}`
48
+ }
49
+
50
+ if (this.color) {
51
+ styles.backgroundColor = this.color.toRGBA()
52
+ }
53
+
54
+ // Render children
55
+ let childrenHTML = ''
56
+ if (this.children && this.children.length > 0) {
57
+ for (const child of this.children) {
58
+ childrenHTML += this.renderer._renderComponent(child)
59
+ }
60
+ }
61
+
62
+ const styleStr = Object.entries(styles)
63
+ .map(([k, v]) => `${k.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${v}`)
64
+ .join('; ')
65
+
66
+ return `<div style="${styleStr}">${childrenHTML}</div>`
67
+ },
68
+
69
+ Center: function () {
70
+ const width = this.width
71
+ const height = this.height
72
+
73
+ const styles = {
74
+ width: `${width}px`,
75
+ height: `${height}px`,
76
+ display: 'flex',
77
+ justifyContent: 'center',
78
+ alignItems: 'center'
79
+ }
80
+
81
+ // Render child if exists
82
+ let childHTML = ''
83
+ if (this.child) {
84
+ childHTML = this.renderer._renderComponent(this.child)
85
+ }
86
+
87
+ const styleStr = Object.entries(styles)
88
+ .map(([k, v]) => `${k.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${v}`)
89
+ .join('; ')
90
+
91
+ return `<div style="${styleStr}">${childHTML}</div>`
92
+ },
93
+
94
+ Text: function () {
95
+ const text = String(this.value)
96
+ const color = this.color
97
+ const colorStr = color
98
+ ? `rgba(${color.red}, ${color.green}, ${color.blue}, ${color.alpha || 1})`
99
+ : 'inherit'
100
+
101
+ return `<span style="color: ${colorStr}; textAlign: ${this.textAlign || 'left'}">${escapeHTML(text)}</span>`
102
+ }
103
+ }
104
+
105
+ _renderComponent(component) {
106
+ component.renderer = this
107
+ const rendererFn = this.components[component.constructor.name]
108
+ return rendererFn.call(component)
109
+ }
110
+
111
+ render(component) {
112
+ const html = this._renderComponent(component)
113
+ return `<!DOCTYPE html>
114
+ <html>
115
+ <head>
116
+ <meta charset="UTF-8">
117
+ <style>
118
+ body {
119
+ margin: 0;
120
+ padding: 20px;
121
+ font-family: monospace;
122
+ background: #f0f0f0;
123
+ }
124
+ </style>
125
+ </head>
126
+ <body>
127
+ ${html}
128
+ </body>
129
+ </html>`
130
+ }
131
+ }
132
+
133
+ module.exports = { CelleryRendererHTML }
@@ -0,0 +1,4 @@
1
+ const html = require('./html-renderer')
2
+ const tui = require('./tui-renderer')
3
+
4
+ module.exports = { ...html, ...tui }
@@ -0,0 +1,480 @@
1
+ const process = require('process')
2
+ const { cursorPosition, eraseLine, eraseDisplay } = require('bare-ansi-escapes')
3
+ const { Alignment } = require('../base')
4
+ const goodbye = require('graceful-goodbye')
5
+ const keys = require('../keys')
6
+
7
+ const originalRawMode = process.stdin.isRaw
8
+
9
+ function start() {
10
+ process.stdin.setRawMode(true)
11
+ process.stdin.resume()
12
+ process.stdin.setEncoding('utf8')
13
+
14
+ process.stdout.write('\x1b[?1049h')
15
+ process.stdout.write('\x1b[?25l')
16
+ }
17
+
18
+ function exit() {
19
+ process.stdout.write(cursorPosition(0))
20
+ process.stdout.write(eraseLine)
21
+ process.stdin.setRawMode(originalRawMode)
22
+ process.stdout.write('\x1b[?25h')
23
+ process.stdout.write('\x1b[?1049l')
24
+ }
25
+
26
+ function parseDimension(value, parentValue) {
27
+ if (typeof value === 'number') {
28
+ return value
29
+ } else if (typeof value === 'string') {
30
+ if (value.endsWith('%')) {
31
+ const percent = Number(value.replace('%', '')) / 100
32
+ return parentValue * percent
33
+ } else if (value.startsWith('calc(') && value.endsWith(')')) {
34
+ // Simple calc parser: calc(100% - N) or calc(100% + N)
35
+ const expr = value.slice(5, -1).trim()
36
+ const match = expr.match(/^(\d+)%\s*([+-])\s*(\d+)$/)
37
+ if (match) {
38
+ const percent = Number(match[1]) / 100
39
+ const operator = match[2]
40
+ const offset = Number(match[3])
41
+ const base = parentValue * percent
42
+ return operator === '+' ? base + offset : base - offset
43
+ }
44
+ }
45
+ }
46
+
47
+ return 0
48
+ }
49
+
50
+ function getDimensions() {
51
+ const width = parseDimension(this.width, this.parent?.width)
52
+ const height = parseDimension(this.height, this.parent?.height)
53
+
54
+ return {
55
+ width: Math.floor(width),
56
+ height: Math.floor(height)
57
+ }
58
+ }
59
+
60
+ class CelleryRendererTUI {
61
+ hotkeys = new Map()
62
+
63
+ components = {
64
+ Container: function () {
65
+ const { width, height } = getDimensions.call(this)
66
+
67
+ // Calculate margins (outside everything)
68
+ const margin = this.margin || { left: 0, top: 0, right: 0, bottom: 0 }
69
+ const marginLeft = Math.floor(margin.left)
70
+ const marginTop = Math.floor(margin.top)
71
+ const marginRight = Math.floor(margin.right)
72
+ const marginBottom = Math.floor(margin.bottom)
73
+
74
+ // Check if border exists
75
+ const hasBorder = this.decoration && this.decoration.border
76
+ const borderWidth = hasBorder ? 1 : 0
77
+
78
+ // Calculate padding (inside decoration/border)
79
+ const padding = this.padding || { left: 0, top: 0, right: 0, bottom: 0 }
80
+ const paddingLeft = Math.floor(padding.left)
81
+ const paddingTop = Math.floor(padding.top)
82
+ const paddingRight = Math.floor(padding.right)
83
+ const paddingBottom = Math.floor(padding.bottom)
84
+
85
+ // Get background color
86
+ const backgroundColor = this.decoration?.color || this.color || null
87
+
88
+ // Create grid
89
+ const grid = []
90
+ for (let y = 0; y < height; y++) {
91
+ const row = []
92
+ for (let x = 0; x < width; x++) {
93
+ let char = ' '
94
+ let fgColor = null
95
+ let bgColor = null
96
+
97
+ // Check if we're in the margin area (outside)
98
+ const inMarginX = x < marginLeft || x >= width - marginRight
99
+ const inMarginY = y < marginTop || y >= height - marginBottom
100
+
101
+ // Adjust coordinates relative to decoration area (after margin)
102
+ const decorationX = x - marginLeft
103
+ const decorationY = y - marginTop
104
+ const decorationWidth = width - marginLeft - marginRight
105
+ const decorationHeight = height - marginTop - marginBottom
106
+
107
+ // Check if we're on the border
108
+ const onBorder =
109
+ !inMarginX &&
110
+ !inMarginY &&
111
+ hasBorder &&
112
+ (decorationX === 0 ||
113
+ decorationX === decorationWidth - 1 ||
114
+ decorationY === 0 ||
115
+ decorationY === decorationHeight - 1)
116
+
117
+ // Apply background color if not in margin and not on border
118
+ if (!inMarginX && !inMarginY && !onBorder && backgroundColor) {
119
+ bgColor = backgroundColor
120
+ }
121
+
122
+ // Only render border/decoration if not in margin
123
+ if (!inMarginX && !inMarginY && hasBorder) {
124
+ // Check border positions (using -1 for last index)
125
+ if (decorationX === 0 && decorationY === 0) {
126
+ char = '┌'
127
+ fgColor = this.decoration.border.color
128
+ } else if (decorationX === 0 && decorationY === decorationHeight - 1) {
129
+ char = '└'
130
+ fgColor = this.decoration.border.color
131
+ } else if (decorationX === decorationWidth - 1 && decorationY === 0) {
132
+ char = '┐'
133
+ fgColor = this.decoration.border.color
134
+ } else if (
135
+ decorationX === decorationWidth - 1 &&
136
+ decorationY === decorationHeight - 1
137
+ ) {
138
+ char = '┘'
139
+ fgColor = this.decoration.border.color
140
+ } else if (decorationX === 0 || decorationX === decorationWidth - 1) {
141
+ char = '│'
142
+ fgColor = this.decoration.border.color
143
+ } else if (decorationY === 0 || decorationY === decorationHeight - 1) {
144
+ char = '─'
145
+ fgColor = this.decoration.border.color
146
+ }
147
+ }
148
+
149
+ row.push({ char, fgColor, bgColor })
150
+ }
151
+ grid.push(row)
152
+ }
153
+
154
+ // Render children vertically stacked
155
+ // Children go inside: margin → border → padding
156
+ if (this.children && this.children.length > 0) {
157
+ const childBaseX = marginLeft + borderWidth + paddingLeft
158
+ let childCurrentY = marginTop + borderWidth + paddingTop
159
+
160
+ // Calculate available width and height for children
161
+ const availableWidth =
162
+ width - marginLeft - marginRight - borderWidth * 2 - paddingLeft - paddingRight
163
+
164
+ const availableHeight =
165
+ height - marginTop - marginBottom - borderWidth * 2 - paddingTop - paddingBottom
166
+
167
+ const childParent = {
168
+ width: availableWidth,
169
+ height: availableHeight
170
+ }
171
+
172
+ for (const child of this.children) {
173
+ const childGrid = this.renderer._renderComponent(child, {
174
+ parent: childParent
175
+ })
176
+ if (!childGrid) continue
177
+
178
+ // Calculate horizontal position based on alignment
179
+ let childX = childBaseX
180
+ const childWidth = childGrid[0]?.length || 0
181
+
182
+ if (this.alignment === Alignment.Center) {
183
+ childX = childBaseX + Math.floor((availableWidth - childWidth) / 2)
184
+ } else if (this.alignment === Alignment.Right) {
185
+ childX = childBaseX + (availableWidth - childWidth)
186
+ }
187
+
188
+ this.renderer._mergeIntoBuffer(grid, childGrid, childX, childCurrentY)
189
+
190
+ // Move down by the height of the child
191
+ childCurrentY += childGrid.length
192
+ }
193
+ }
194
+
195
+ return grid
196
+ },
197
+
198
+ Center: function () {
199
+ const { width, height } = getDimensions.call(this.parent)
200
+
201
+ // Create empty grid
202
+ const grid = []
203
+ for (let y = 0; y < height; y++) {
204
+ const row = []
205
+ for (let x = 0; x < width; x++) {
206
+ row.push({ char: ' ', fgColor: null, bgColor: null })
207
+ }
208
+ grid.push(row)
209
+ }
210
+
211
+ // Render child if exists
212
+ if (this.child) {
213
+ const childParent = {
214
+ width: width,
215
+ height: height
216
+ }
217
+
218
+ const childGrid = this.renderer._renderComponent(this.child, {
219
+ parent: childParent
220
+ })
221
+
222
+ // Calculate center position
223
+ const childWidth = childGrid[0]?.length || 0
224
+ const childHeight = childGrid.length
225
+
226
+ const centerX = Math.floor((width - childWidth) / 2)
227
+ const centerY = Math.floor((height - childHeight) / 2)
228
+
229
+ this.renderer._mergeIntoBuffer(grid, childGrid, centerX, centerY)
230
+ }
231
+
232
+ return grid
233
+ },
234
+
235
+ Text: function () {
236
+ const text = String(this.value)
237
+ const grid = [[]]
238
+
239
+ for (let i = 0; i < text.length; i++) {
240
+ grid[0].push({ char: text[i], fgColor: this.color, bgColor: null })
241
+ }
242
+
243
+ return grid
244
+ },
245
+
246
+ Pressable: function () {
247
+ if (this.hotkey) {
248
+ this.renderer.registerHotkey(this.hotkey, () => {
249
+ this.onPress()
250
+ })
251
+ }
252
+
253
+ if (!this.child) return
254
+
255
+ return this.renderer._renderComponent(this.child, {
256
+ parent: this.parent
257
+ })
258
+ },
259
+
260
+ Scrollable: function () {
261
+ const { width, height } = getDimensions.call(this)
262
+
263
+ // Create viewport grid
264
+ const grid = []
265
+ for (let y = 0; y < height; y++) {
266
+ const row = []
267
+ for (let x = 0; x < width; x++) {
268
+ row.push({ char: ' ', fgColor: null, bgColor: null })
269
+ }
270
+ grid.push(row)
271
+ }
272
+
273
+ if (!this.child) {
274
+ // Store empty viewport info
275
+ this._renderedViewport = { itemCount: 0, height, itemHeight: 0 }
276
+ return grid
277
+ }
278
+
279
+ // The child should be a Container with children
280
+ // We need to know how many children it has to calculate viewport
281
+ const childrenCount = this.child.children?.length || 0
282
+
283
+ if (childrenCount === 0) {
284
+ this._renderedViewport = { itemCount: 0, height, itemHeight: 0 }
285
+ return grid
286
+ }
287
+
288
+ // Measure first child to determine item height
289
+ let itemHeight = 1
290
+ if (this.child.children && this.child.children.length > 0) {
291
+ const firstChild = this.child.children[0]
292
+ const firstChildGrid = this.renderer._renderComponent(firstChild, {
293
+ parent: { width, height }
294
+ })
295
+ if (firstChildGrid) {
296
+ itemHeight = firstChildGrid.length
297
+ }
298
+ }
299
+
300
+ const viewportItemCount = Math.floor(height / itemHeight)
301
+
302
+ // Store viewport info for component to use
303
+ this._renderedViewport = {
304
+ itemCount: viewportItemCount,
305
+ height,
306
+ itemHeight
307
+ }
308
+
309
+ // Get visible range based on scroll offset
310
+ const { start, end } = this.getVisibleRange(viewportItemCount, childrenCount)
311
+
312
+ // Store metadata for scroll indicators (optional)
313
+ this._scrollInfo = {
314
+ canScrollUp: start > 0,
315
+ canScrollDown: end < childrenCount,
316
+ visibleCount: end - start,
317
+ totalCount: childrenCount,
318
+ scrollOffset: this.scrollOffset,
319
+ viewportItemCount
320
+ }
321
+
322
+ // Create a modified child with only visible children
323
+ const visibleChildren = this.child.children.slice(start, end)
324
+
325
+ // Clone the child container with only visible children
326
+ const visibleChild = Object.create(Object.getPrototypeOf(this.child))
327
+ Object.assign(visibleChild, this.child)
328
+ visibleChild.children = visibleChildren
329
+
330
+ // Render the child with visible items
331
+ const childGrid = this.renderer._renderComponent(visibleChild, {
332
+ parent: { width, height }
333
+ })
334
+
335
+ if (childGrid) {
336
+ // Clip to viewport height
337
+ const clippedGrid = childGrid.slice(0, height)
338
+ this.renderer._mergeIntoBuffer(grid, clippedGrid, 0, 0)
339
+ }
340
+
341
+ return grid
342
+ }
343
+ }
344
+
345
+ _fgColorToAnsi(color) {
346
+ if (!color) return ''
347
+ const { red, green, blue } = color
348
+ return `\x1b[38;2;${red};${green};${blue}m`
349
+ }
350
+
351
+ _bgColorToAnsi(color) {
352
+ if (!color) return ''
353
+ const { red, green, blue } = color
354
+ return `\x1b[48;2;${red};${green};${blue}m`
355
+ }
356
+
357
+ _resetAnsi() {
358
+ return '\x1b[0m'
359
+ }
360
+
361
+ _mergeIntoBuffer(buffer, componentGrid, offsetX = 0, offsetY = 0) {
362
+ for (let dy = 0; dy < componentGrid.length; dy++) {
363
+ const targetY = offsetY + dy
364
+ if (targetY < 0 || targetY >= buffer.length) continue
365
+
366
+ for (let dx = 0; dx < componentGrid[dy].length; dx++) {
367
+ const targetX = offsetX + dx
368
+ if (targetX < 0 || targetX >= buffer[targetY].length) continue
369
+
370
+ const sourcePixel = componentGrid[dy][dx]
371
+ const targetPixel = buffer[targetY][targetX]
372
+
373
+ // Merge the pixels - preserve background if source doesn't have one
374
+ buffer[targetY][targetX] = {
375
+ char: sourcePixel.char,
376
+ fgColor: sourcePixel.fgColor,
377
+ bgColor: sourcePixel.bgColor !== null ? sourcePixel.bgColor : targetPixel.bgColor
378
+ }
379
+ }
380
+ }
381
+ }
382
+
383
+ _renderComponent(component, opts = {}) {
384
+ if (typeof component.render === 'function') {
385
+ component = component.render()
386
+ }
387
+ component.renderer = this
388
+ component.parent = opts.parent
389
+ const rendererFn = this.components[component.constructor.name]
390
+ return rendererFn.call(component)
391
+ }
392
+
393
+ registerHotkey(hotkey, fn) {
394
+ const key = hotkey.key
395
+ this.hotkeys.set(key, fn)
396
+ }
397
+
398
+ clearHotkeys() {
399
+ this.hotkeys.clear()
400
+ }
401
+
402
+ setup() {
403
+ if (this.isSetup) return
404
+ this.isSetup = true
405
+
406
+ start()
407
+
408
+ if (process.stdin.isTTY) {
409
+ process.stdin.on('data', (data) => {
410
+ const key = data.toString()
411
+
412
+ if (key === keys.CTRL_C || key === keys.ESC || key === 'q') {
413
+ // Ctrl+C
414
+ process.exit(0)
415
+ }
416
+
417
+ // Compare against the key string directly (not hotkey object)
418
+ for (const [hotkeyString, fn] of this.hotkeys.entries()) {
419
+ if (key === hotkeyString) {
420
+ fn()
421
+ return
422
+ }
423
+ }
424
+ })
425
+ }
426
+
427
+ process.on('SIGINT', exit)
428
+ process.on('SIGTERM', exit)
429
+ process.on('exit', exit)
430
+
431
+ process.stdout.on('resize', () => {
432
+ this.render()
433
+ })
434
+
435
+ goodbye(() => {
436
+ exit()
437
+ })
438
+ }
439
+
440
+ draw() {
441
+ this.clearHotkeys()
442
+
443
+ const buffer = this._renderComponent(this.root, {
444
+ parent: this.size
445
+ })
446
+
447
+ process.stdout.write(eraseDisplay)
448
+ process.stdout.write(cursorPosition(0, 0))
449
+
450
+ for (const row of buffer) {
451
+ for (const pixel of row) {
452
+ if (pixel.bgColor) {
453
+ process.stdout.write(this._bgColorToAnsi(pixel.bgColor))
454
+ }
455
+ if (pixel.fgColor) {
456
+ process.stdout.write(this._fgColorToAnsi(pixel.fgColor))
457
+ }
458
+ process.stdout.write(pixel.char)
459
+ if (pixel.fgColor || pixel.bgColor) {
460
+ process.stdout.write(this._resetAnsi())
461
+ }
462
+ }
463
+ process.stdout.write('\n')
464
+ }
465
+ }
466
+
467
+ render(component) {
468
+ if (component) {
469
+ this.root = component
470
+ }
471
+
472
+ this.size = { width: process.stdout.columns, height: process.stdout.rows }
473
+
474
+ this.setup()
475
+
476
+ this.draw()
477
+ }
478
+ }
479
+
480
+ module.exports = { CelleryRendererTUI }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cellery",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "cellery",
5
5
  "exports": {
6
6
  "./package": "./package.json",
@@ -28,7 +28,10 @@
28
28
  "files": [
29
29
  "package.json",
30
30
  "index.js",
31
- "index.d.ts"
31
+ "index.d.ts",
32
+ "lib",
33
+ "demo",
34
+ "example.js"
32
35
  ],
33
36
  "devDependencies": {
34
37
  "bare-fs": "^4.5.2",