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 +258 -0
- package/lib/base.js +195 -0
- package/lib/components/base.js +165 -0
- package/lib/components/index.js +7 -0
- package/lib/components/list.js +107 -0
- package/lib/index.js +7 -0
- package/lib/keys.js +9 -0
- package/lib/renderers/html-renderer.js +133 -0
- package/lib/renderers/index.js +4 -0
- package/lib/renderers/tui-renderer.js +480 -0
- package/package.json +5 -2
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,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
package/lib/keys.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
function escapeHTML(str) {
|
|
2
|
+
return str
|
|
3
|
+
.replace(/&/g, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
.replace(/"/g, '"')
|
|
7
|
+
.replace(/'/g, ''')
|
|
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,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.
|
|
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",
|