cellery 0.0.4 → 1.0.0

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.
@@ -1,480 +0,0 @@
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 }