termcast 1.4.0 → 1.5.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.
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +8 -7
- package/dist/build.js.map +1 -1
- package/dist/cli.js +0 -40
- package/dist/cli.js.map +1 -1
- package/dist/components/bar-graph.d.ts +23 -8
- package/dist/components/bar-graph.d.ts.map +1 -1
- package/dist/components/bar-graph.js +84 -40
- package/dist/components/bar-graph.js.map +1 -1
- package/dist/components/dotted-line-graph.d.ts +86 -0
- package/dist/components/dotted-line-graph.d.ts.map +1 -0
- package/dist/components/dotted-line-graph.js +260 -0
- package/dist/components/dotted-line-graph.js.map +1 -0
- package/dist/components/extension-preferences.d.ts.map +1 -1
- package/dist/components/extension-preferences.js +1 -10
- package/dist/components/extension-preferences.js.map +1 -1
- package/dist/components/graph.d.ts.map +1 -1
- package/dist/components/graph.js +7 -1
- package/dist/components/graph.js.map +1 -1
- package/dist/components/histogram.d.ts +42 -0
- package/dist/components/histogram.d.ts.map +1 -0
- package/dist/components/histogram.js +115 -0
- package/dist/components/histogram.js.map +1 -0
- package/dist/components/horizontal-bar-graph.d.ts +47 -0
- package/dist/components/horizontal-bar-graph.d.ts.map +1 -0
- package/dist/components/horizontal-bar-graph.js +137 -0
- package/dist/components/horizontal-bar-graph.js.map +1 -0
- package/dist/components/list.d.ts +2 -0
- package/dist/components/list.d.ts.map +1 -1
- package/dist/components/list.js +10 -10
- package/dist/components/list.js.map +1 -1
- package/dist/examples/bar-graph-weekly.js +2 -2
- package/dist/examples/bar-graph-weekly.js.map +1 -1
- package/dist/examples/charts-showcase-barchart.d.ts +2 -0
- package/dist/examples/charts-showcase-barchart.d.ts.map +1 -0
- package/dist/examples/charts-showcase-barchart.js +10 -0
- package/dist/examples/charts-showcase-barchart.js.map +1 -0
- package/dist/examples/charts-showcase-bargraph.d.ts +2 -0
- package/dist/examples/charts-showcase-bargraph.d.ts.map +1 -0
- package/dist/examples/charts-showcase-bargraph.js +60 -0
- package/dist/examples/charts-showcase-bargraph.js.map +1 -0
- package/dist/examples/charts-showcase-candle.d.ts +2 -0
- package/dist/examples/charts-showcase-candle.d.ts.map +1 -0
- package/dist/examples/charts-showcase-candle.js +30 -0
- package/dist/examples/charts-showcase-candle.js.map +1 -0
- package/dist/examples/charts-showcase-graph.d.ts +2 -0
- package/dist/examples/charts-showcase-graph.d.ts.map +1 -0
- package/dist/examples/charts-showcase-graph.js +33 -0
- package/dist/examples/charts-showcase-graph.js.map +1 -0
- package/dist/examples/charts-showcase-heatmap.d.ts +2 -0
- package/dist/examples/charts-showcase-heatmap.d.ts.map +1 -0
- package/dist/examples/charts-showcase-heatmap.js +36 -0
- package/dist/examples/charts-showcase-heatmap.js.map +1 -0
- package/dist/examples/charts-showcase-mixed.d.ts +2 -0
- package/dist/examples/charts-showcase-mixed.d.ts.map +1 -0
- package/dist/examples/charts-showcase-mixed.js +30 -0
- package/dist/examples/charts-showcase-mixed.js.map +1 -0
- package/dist/examples/charts-showcase-progress.d.ts +2 -0
- package/dist/examples/charts-showcase-progress.d.ts.map +1 -0
- package/dist/examples/charts-showcase-progress.js +10 -0
- package/dist/examples/charts-showcase-progress.js.map +1 -0
- package/dist/examples/graph-multi-series.js +1 -1
- package/dist/examples/graph-multi-series.js.map +1 -1
- package/dist/examples/horizontal-bar-graph-weekly.d.ts +2 -0
- package/dist/examples/horizontal-bar-graph-weekly.d.ts.map +1 -0
- package/dist/examples/horizontal-bar-graph-weekly.js +67 -0
- package/dist/examples/horizontal-bar-graph-weekly.js.map +1 -0
- package/dist/examples/simple-dotted-line-graph.d.ts +2 -0
- package/dist/examples/simple-dotted-line-graph.d.ts.map +1 -0
- package/dist/examples/simple-dotted-line-graph.js +39 -0
- package/dist/examples/simple-dotted-line-graph.js.map +1 -0
- package/dist/examples/simple-histogram.d.ts +2 -0
- package/dist/examples/simple-histogram.d.ts.map +1 -0
- package/dist/examples/simple-histogram.js +47 -0
- package/dist/examples/simple-histogram.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +15 -6
- package/dist/logger.js.map +1 -1
- package/dist/platform/node/sqlite.d.ts +6 -5
- package/dist/platform/node/sqlite.d.ts.map +1 -1
- package/dist/platform/node/sqlite.js +30 -14
- package/dist/platform/node/sqlite.js.map +1 -1
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +11 -9
- package/dist/theme.js.map +1 -1
- package/dist/utils/run-command.d.ts.map +1 -1
- package/dist/utils/run-command.js +8 -19
- package/dist/utils/run-command.js.map +1 -1
- package/dist/utils.d.ts +1 -19
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +1 -100
- package/dist/utils.js.map +1 -1
- package/package.json +14 -16
- package/src/build.tsx +11 -10
- package/src/cli.tsx +3 -40
- package/src/compile.vitest.tsx +3 -3
- package/src/components/bar-graph.tsx +217 -111
- package/src/components/dotted-line-graph.tsx +407 -0
- package/src/components/extension-preferences.tsx +2 -12
- package/src/components/graph.tsx +5 -1
- package/src/components/histogram.tsx +228 -0
- package/src/components/horizontal-bar-graph.tsx +279 -0
- package/src/components/list.tsx +20 -15
- package/src/examples/action-shortcut.vitest.tsx +17 -17
- package/src/examples/bar-graph-weekly.tsx +2 -2
- package/src/examples/bar-graph-weekly.vitest.tsx +63 -62
- package/src/examples/charts-showcase-bargraph.tsx +103 -0
- package/src/examples/detail-metadata-showcase.vitest.tsx +13 -18
- package/src/examples/form-basic.vitest.tsx +35 -35
- package/src/examples/form-dropdown.vitest.tsx +11 -11
- package/src/examples/form-scroll.vitest.tsx +1 -1
- package/src/examples/form-tagpicker.vitest.tsx +11 -11
- package/src/examples/github.vitest.tsx +22 -22
- package/src/examples/graph-bar-chart.vitest.tsx +8 -8
- package/src/examples/graph-multi-series.tsx +1 -1
- package/src/examples/graph-row.vitest.tsx +14 -14
- package/src/examples/graph-styles.vitest.tsx +77 -77
- package/src/examples/horizontal-bar-graph-weekly.tsx +138 -0
- package/src/examples/horizontal-bar-graph-weekly.vitest.tsx +164 -0
- package/src/examples/list-detail-metadata.vitest.tsx +4 -4
- package/src/examples/list-with-detail.vitest.tsx +46 -46
- package/src/examples/simple-candle-chart.vitest.tsx +8 -8
- package/src/examples/simple-dotted-line-graph.tsx +53 -0
- package/src/examples/simple-dotted-line-graph.vitest.tsx +62 -0
- package/src/examples/simple-grid.vitest.tsx +4 -4
- package/src/examples/simple-histogram.tsx +90 -0
- package/src/examples/simple-navigation.vitest.tsx +4 -4
- package/src/examples/swift-extension.vitest.tsx +3 -3
- package/src/examples/toast-variations.vitest.tsx +5 -5
- package/src/extensions/dev.vitest.tsx +8 -8
- package/src/index.tsx +21 -0
- package/src/logger.tsx +16 -6
- package/src/platform/node/sqlite.ts +29 -13
- package/src/theme.tsx +11 -10
- package/src/utils/run-command.tsx +10 -19
- package/src/utils.tsx +0 -163
- package/src/examples/store.tsx +0 -4
- package/src/examples/store.vitest.tsx +0 -78
- package/src/extensions/home.tsx +0 -227
- package/src/extensions/store.tsx +0 -375
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DottedLineGraph renders metric-style dotted line charts with braille subcells.
|
|
3
|
+
*
|
|
4
|
+
* Each terminal cell contains a 2×4 braille grid, so diagonal and step changes
|
|
5
|
+
* can move by half columns and quarter rows instead of snapping to full cells.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { ReactNode, useMemo } from 'react'
|
|
9
|
+
import { Renderable, RGBA } from '@opentui/core'
|
|
10
|
+
import type { OptimizedBuffer, RenderableOptions, RenderContext } from '@opentui/core'
|
|
11
|
+
import { extend } from '@opentui/react'
|
|
12
|
+
import { Color, resolveColor } from 'termcast/src/colors'
|
|
13
|
+
import { getThemePalette, useTheme } from 'termcast/src/theme'
|
|
14
|
+
|
|
15
|
+
const BRAILLE_BITS: number[][] = [
|
|
16
|
+
[1, 8],
|
|
17
|
+
[2, 16],
|
|
18
|
+
[4, 32],
|
|
19
|
+
[64, 128],
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
export interface DottedLineGraphSeriesData {
|
|
23
|
+
data: number[]
|
|
24
|
+
color: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DottedLineGraphPlotOptions extends RenderableOptions {
|
|
28
|
+
series?: DottedLineGraphSeriesData[]
|
|
29
|
+
xLabels?: string[]
|
|
30
|
+
yMin?: number
|
|
31
|
+
yMax?: number
|
|
32
|
+
yTicks?: number
|
|
33
|
+
yFormat?: (value: number) => string
|
|
34
|
+
axisColor?: string
|
|
35
|
+
dotSpacing?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class DottedLineGraphPlotRenderable extends Renderable {
|
|
39
|
+
private _series: DottedLineGraphSeriesData[] = []
|
|
40
|
+
private _xLabels: string[] = []
|
|
41
|
+
private _yMin = 0
|
|
42
|
+
private _yMax = 100
|
|
43
|
+
private _yTicks = 5
|
|
44
|
+
private _yFormat: (value: number) => string = (value) => {
|
|
45
|
+
return value >= 1000 ? value.toFixed(0) : value.toFixed(1)
|
|
46
|
+
}
|
|
47
|
+
private _axisColor = '#666666'
|
|
48
|
+
private _dotSpacing = 2
|
|
49
|
+
|
|
50
|
+
constructor(ctx: RenderContext, options: DottedLineGraphPlotOptions) {
|
|
51
|
+
super(ctx, options)
|
|
52
|
+
if (options.series) this._series = options.series
|
|
53
|
+
if (options.xLabels) this._xLabels = options.xLabels
|
|
54
|
+
if (options.yMin !== undefined) this._yMin = options.yMin
|
|
55
|
+
if (options.yMax !== undefined) this._yMax = options.yMax
|
|
56
|
+
if (options.yTicks !== undefined) this._yTicks = options.yTicks
|
|
57
|
+
if (options.yFormat) this._yFormat = options.yFormat
|
|
58
|
+
if (options.axisColor) this._axisColor = options.axisColor
|
|
59
|
+
if (options.dotSpacing !== undefined) this._dotSpacing = options.dotSpacing
|
|
60
|
+
|
|
61
|
+
this.computeLayout = this.computeLayout.bind(this)
|
|
62
|
+
this.drawAxes = this.drawAxes.bind(this)
|
|
63
|
+
this.drawSeries = this.drawSeries.bind(this)
|
|
64
|
+
this.setDot = this.setDot.bind(this)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
set series(value: DottedLineGraphSeriesData[]) { this._series = value; this.requestRender() }
|
|
68
|
+
set xLabels(value: string[]) { this._xLabels = value; this.requestRender() }
|
|
69
|
+
set yMin(value: number) { this._yMin = value; this.requestRender() }
|
|
70
|
+
set yMax(value: number) { this._yMax = value; this.requestRender() }
|
|
71
|
+
set yTicks(value: number) { this._yTicks = value; this.requestRender() }
|
|
72
|
+
set yFormat(value: (value: number) => string) { this._yFormat = value; this.requestRender() }
|
|
73
|
+
set axisColor(value: string) { this._axisColor = value; this.requestRender() }
|
|
74
|
+
set dotSpacing(value: number) { this._dotSpacing = value; this.requestRender() }
|
|
75
|
+
|
|
76
|
+
private computeLayout(): {
|
|
77
|
+
plotX: number
|
|
78
|
+
plotY: number
|
|
79
|
+
plotW: number
|
|
80
|
+
plotH: number
|
|
81
|
+
yAxisWidth: number
|
|
82
|
+
yLabels: string[]
|
|
83
|
+
} | null {
|
|
84
|
+
if (this.width <= 0 || this.height <= 0) return null
|
|
85
|
+
|
|
86
|
+
const safeTicks = Math.max(2, Math.floor(this._yTicks))
|
|
87
|
+
const yLabels = Array.from({ length: safeTicks }, (_, index) => {
|
|
88
|
+
const value = this._yMin + (this._yMax - this._yMin) * (1 - index / (safeTicks - 1))
|
|
89
|
+
return this._yFormat(value)
|
|
90
|
+
})
|
|
91
|
+
const yAxisWidth = Math.max(...yLabels.map((label) => {
|
|
92
|
+
return label.length
|
|
93
|
+
}))
|
|
94
|
+
const plotX = this.x + yAxisWidth + 1
|
|
95
|
+
const plotY = this.y
|
|
96
|
+
const plotW = this.width - yAxisWidth - 1
|
|
97
|
+
const plotH = this.height - 1
|
|
98
|
+
|
|
99
|
+
if (plotW <= 0 || plotH <= 0) return null
|
|
100
|
+
return { plotX, plotY, plotW, plotH, yAxisWidth, yLabels }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private drawAxes(buffer: OptimizedBuffer, layout: {
|
|
104
|
+
plotX: number
|
|
105
|
+
plotY: number
|
|
106
|
+
plotW: number
|
|
107
|
+
plotH: number
|
|
108
|
+
yAxisWidth: number
|
|
109
|
+
yLabels: string[]
|
|
110
|
+
}): void {
|
|
111
|
+
const { plotX, plotY, plotW, plotH, yAxisWidth, yLabels } = layout
|
|
112
|
+
const axisColor = RGBA.fromHex(this._axisColor)
|
|
113
|
+
const labelRows = new Set<number>()
|
|
114
|
+
|
|
115
|
+
yLabels.forEach((label, index) => {
|
|
116
|
+
const row = Math.round(plotY + (index / Math.max(1, yLabels.length - 1)) * (plotH - 1))
|
|
117
|
+
labelRows.add(row)
|
|
118
|
+
buffer.drawText(label, this.x + yAxisWidth - label.length, row, axisColor)
|
|
119
|
+
buffer.drawText('│', this.x + yAxisWidth, row, axisColor)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
for (let row = plotY; row < plotY + plotH; row++) {
|
|
123
|
+
if (!labelRows.has(row)) {
|
|
124
|
+
buffer.drawText('│', this.x + yAxisWidth, row, axisColor)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// X-axis labels (skip labels that would overlap the previous one)
|
|
129
|
+
let occupiedEnd = -1
|
|
130
|
+
this._xLabels.forEach((label, index) => {
|
|
131
|
+
if (!label) return
|
|
132
|
+
const labelX = plotX + Math.round((index / Math.max(1, this._xLabels.length - 1)) * (plotW - 1))
|
|
133
|
+
const centeredX = Math.max(plotX, Math.min(labelX - Math.floor(label.length / 2), plotX + plotW - label.length))
|
|
134
|
+
if (centeredX <= occupiedEnd) return
|
|
135
|
+
buffer.drawText(label, centeredX, plotY + plotH, axisColor)
|
|
136
|
+
occupiedEnd = centeredX + label.length
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private setDot({
|
|
141
|
+
px,
|
|
142
|
+
py,
|
|
143
|
+
plotW,
|
|
144
|
+
plotH,
|
|
145
|
+
cellBits,
|
|
146
|
+
cellColors,
|
|
147
|
+
color,
|
|
148
|
+
}: {
|
|
149
|
+
px: number
|
|
150
|
+
py: number
|
|
151
|
+
plotW: number
|
|
152
|
+
plotH: number
|
|
153
|
+
cellBits: Uint8Array
|
|
154
|
+
cellColors: RGBA[]
|
|
155
|
+
color: RGBA
|
|
156
|
+
}): void {
|
|
157
|
+
if (px < 0 || py < 0 || px >= plotW * 2 || py >= plotH * 4) return
|
|
158
|
+
|
|
159
|
+
const cellX = Math.floor(px / 2)
|
|
160
|
+
const cellY = Math.floor(py / 4)
|
|
161
|
+
const cellIndex = cellY * plotW + cellX
|
|
162
|
+
cellBits[cellIndex] = cellBits[cellIndex]! | BRAILLE_BITS[py % 4]![px % 2]!
|
|
163
|
+
cellColors[cellIndex] = color
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private drawSeries({
|
|
167
|
+
series,
|
|
168
|
+
plotW,
|
|
169
|
+
plotH,
|
|
170
|
+
cellBits,
|
|
171
|
+
cellColors,
|
|
172
|
+
}: {
|
|
173
|
+
series: DottedLineGraphSeriesData
|
|
174
|
+
plotW: number
|
|
175
|
+
plotH: number
|
|
176
|
+
cellBits: Uint8Array
|
|
177
|
+
cellColors: RGBA[]
|
|
178
|
+
}): void {
|
|
179
|
+
const yRange = this._yMax - this._yMin
|
|
180
|
+
if (series.data.length === 0 || yRange === 0) return
|
|
181
|
+
|
|
182
|
+
const color = RGBA.fromHex(series.color)
|
|
183
|
+
const virtualW = plotW * 2
|
|
184
|
+
const virtualH = plotH * 4
|
|
185
|
+
const dotSpacing = Math.max(1, Math.floor(this._dotSpacing))
|
|
186
|
+
const toPoint = (value: number, index: number) => {
|
|
187
|
+
const normalized = (value - this._yMin) / yRange
|
|
188
|
+
return {
|
|
189
|
+
x: Math.round((index / Math.max(1, series.data.length - 1)) * (virtualW - 1)),
|
|
190
|
+
y: Math.round((1 - normalized) * (virtualH - 1)),
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (series.data.length === 1) {
|
|
195
|
+
const point = toPoint(series.data[0]!, 0)
|
|
196
|
+
this.setDot({ px: point.x, py: point.y, plotW, plotH, cellBits, cellColors, color })
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
series.data.slice(0, -1).forEach((value, index) => {
|
|
201
|
+
const start = toPoint(value, index)
|
|
202
|
+
const end = toPoint(series.data[index + 1]!, index + 1)
|
|
203
|
+
const dx = Math.abs(end.x - start.x)
|
|
204
|
+
const dy = Math.abs(end.y - start.y)
|
|
205
|
+
const sx = start.x < end.x ? 1 : -1
|
|
206
|
+
const sy = start.y < end.y ? 1 : -1
|
|
207
|
+
let error = dx - dy
|
|
208
|
+
let x = start.x
|
|
209
|
+
let y = start.y
|
|
210
|
+
let step = index === 0 ? 0 : 1
|
|
211
|
+
|
|
212
|
+
while (true) {
|
|
213
|
+
if (step % dotSpacing === 0 || (x === end.x && y === end.y)) {
|
|
214
|
+
this.setDot({ px: x, py: y, plotW, plotH, cellBits, cellColors, color })
|
|
215
|
+
}
|
|
216
|
+
if (x === end.x && y === end.y) break
|
|
217
|
+
|
|
218
|
+
const error2 = error * 2
|
|
219
|
+
if (error2 > -dy) { error -= dy; x += sx }
|
|
220
|
+
if (error2 < dx) { error += dx; y += sy }
|
|
221
|
+
step++
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
protected renderSelf(buffer: OptimizedBuffer): void {
|
|
227
|
+
const layout = this.computeLayout()
|
|
228
|
+
if (!layout) return
|
|
229
|
+
|
|
230
|
+
this.drawAxes(buffer, layout)
|
|
231
|
+
if (this._series.length === 0 || this._yMax === this._yMin) return
|
|
232
|
+
|
|
233
|
+
const transparent = RGBA.fromValues(0, 0, 0, 0)
|
|
234
|
+
const cellCount = layout.plotW * layout.plotH
|
|
235
|
+
const cellBits = new Uint8Array(cellCount)
|
|
236
|
+
const cellColors = Array.from({ length: cellCount }, () => {
|
|
237
|
+
return transparent
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
this._series.forEach((series) => {
|
|
241
|
+
this.drawSeries({ series, plotW: layout.plotW, plotH: layout.plotH, cellBits, cellColors })
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
for (let y = 0; y < layout.plotH; y++) {
|
|
245
|
+
for (let x = 0; x < layout.plotW; x++) {
|
|
246
|
+
const cellIndex = y * layout.plotW + x
|
|
247
|
+
const bits = cellBits[cellIndex]!
|
|
248
|
+
if (bits === 0) continue
|
|
249
|
+
buffer.setCell(layout.plotX + x, layout.plotY + y, String.fromCharCode(0x2800 + bits), cellColors[cellIndex]!, transparent)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
extend({ 'dotted-line-graph-plot': DottedLineGraphPlotRenderable })
|
|
256
|
+
|
|
257
|
+
declare module '@opentui/react' {
|
|
258
|
+
interface OpenTUIComponents {
|
|
259
|
+
'dotted-line-graph-plot': typeof DottedLineGraphPlotRenderable
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface DottedLineGraphSeriesProps {
|
|
264
|
+
/** Y-values for this metric line */
|
|
265
|
+
data: number[]
|
|
266
|
+
/** Override the auto-assigned color */
|
|
267
|
+
color?: Color.ColorLike
|
|
268
|
+
/** Series label shown in the legend */
|
|
269
|
+
title?: string
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface DottedLineGraphProps {
|
|
273
|
+
/** Height of the plot area in terminal rows (default: 10) */
|
|
274
|
+
height?: number
|
|
275
|
+
/** X-axis labels */
|
|
276
|
+
xLabels?: string[]
|
|
277
|
+
/** Manual Y-axis range [min, max] (default: auto from data) */
|
|
278
|
+
yRange?: [number, number]
|
|
279
|
+
/** Number of Y-axis tick labels (default: 4) */
|
|
280
|
+
yTicks?: number
|
|
281
|
+
/** Custom Y-axis label formatter */
|
|
282
|
+
yFormat?: (value: number) => string
|
|
283
|
+
/** Gap between dots in virtual braille pixels (default: 2) */
|
|
284
|
+
dotSpacing?: number
|
|
285
|
+
/** Show compact legend (default: true when any series has a title) */
|
|
286
|
+
showLegend?: boolean
|
|
287
|
+
/** DottedLineGraph.Series children */
|
|
288
|
+
children: ReactNode
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
interface DottedLineGraphType {
|
|
292
|
+
(props: DottedLineGraphProps): any
|
|
293
|
+
Series: (props: DottedLineGraphSeriesProps) => any
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const DottedLineGraphSeries = (_props: DottedLineGraphSeriesProps): any => {
|
|
297
|
+
return null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function formatDefaultTick(value: number): string {
|
|
301
|
+
return value >= 1000 ? value.toFixed(0) : value.toFixed(1)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const DottedLineGraph: DottedLineGraphType = (props) => {
|
|
305
|
+
const theme = useTheme()
|
|
306
|
+
const {
|
|
307
|
+
height = 10,
|
|
308
|
+
xLabels = [],
|
|
309
|
+
yRange,
|
|
310
|
+
yTicks = 4,
|
|
311
|
+
yFormat,
|
|
312
|
+
dotSpacing = 2,
|
|
313
|
+
showLegend,
|
|
314
|
+
children,
|
|
315
|
+
} = props
|
|
316
|
+
const palette = getThemePalette(theme)
|
|
317
|
+
|
|
318
|
+
const series = useMemo<Array<DottedLineGraphSeriesData & { title?: string }>>(() => {
|
|
319
|
+
return React.Children.toArray(children)
|
|
320
|
+
.filter(React.isValidElement)
|
|
321
|
+
.map((child, index) => {
|
|
322
|
+
const childProps = child.props as DottedLineGraphSeriesProps
|
|
323
|
+
return {
|
|
324
|
+
data: childProps.data,
|
|
325
|
+
color: resolveColor(childProps.color) || palette[index % palette.length]!,
|
|
326
|
+
title: childProps.title,
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
.filter((item) => {
|
|
330
|
+
return Array.isArray(item.data)
|
|
331
|
+
})
|
|
332
|
+
}, [children, palette])
|
|
333
|
+
|
|
334
|
+
const computedYRange = useMemo<[number, number]>(() => {
|
|
335
|
+
if (yRange) return yRange
|
|
336
|
+
|
|
337
|
+
const values = series.flatMap((item) => {
|
|
338
|
+
return item.data
|
|
339
|
+
})
|
|
340
|
+
if (values.length === 0) return [0, 100]
|
|
341
|
+
|
|
342
|
+
const min = Math.min(...values)
|
|
343
|
+
const max = Math.max(...values)
|
|
344
|
+
const padding = max === min ? Math.max(1, Math.abs(max) * 0.1) : (max - min) * 0.08
|
|
345
|
+
return [min - padding, max + padding]
|
|
346
|
+
}, [series, yRange])
|
|
347
|
+
|
|
348
|
+
const legendRows = series.filter((item) => {
|
|
349
|
+
return Boolean(item.title)
|
|
350
|
+
})
|
|
351
|
+
const legendVisible = showLegend ?? legendRows.length > 0
|
|
352
|
+
const plotSeries = series.map((item) => {
|
|
353
|
+
return { data: item.data, color: item.color }
|
|
354
|
+
})
|
|
355
|
+
const legendPaddingLeft = useMemo(() => {
|
|
356
|
+
const safeTicks = Math.max(2, Math.floor(yTicks))
|
|
357
|
+
const tickFormat = yFormat || formatDefaultTick
|
|
358
|
+
const yLabels = Array.from({ length: safeTicks }, (_, index) => {
|
|
359
|
+
const value = computedYRange[0] + (computedYRange[1] - computedYRange[0]) * (1 - index / (safeTicks - 1))
|
|
360
|
+
return tickFormat(value)
|
|
361
|
+
})
|
|
362
|
+
return Math.max(...yLabels.map((label) => {
|
|
363
|
+
return label.length
|
|
364
|
+
})) + 1
|
|
365
|
+
}, [computedYRange, yFormat, yTicks])
|
|
366
|
+
|
|
367
|
+
if (series.length === 0) return null
|
|
368
|
+
|
|
369
|
+
return (
|
|
370
|
+
<box flexDirection="column" width="100%" flexShrink={0}>
|
|
371
|
+
<dotted-line-graph-plot
|
|
372
|
+
width="100%"
|
|
373
|
+
height={height + (xLabels.length > 0 ? 1 : 0)}
|
|
374
|
+
series={plotSeries}
|
|
375
|
+
xLabels={xLabels}
|
|
376
|
+
yMin={computedYRange[0]}
|
|
377
|
+
yMax={computedYRange[1]}
|
|
378
|
+
yTicks={yTicks}
|
|
379
|
+
yFormat={yFormat}
|
|
380
|
+
axisColor={theme.textMuted}
|
|
381
|
+
dotSpacing={dotSpacing}
|
|
382
|
+
/>
|
|
383
|
+
{legendVisible ? (
|
|
384
|
+
<box height={1} width="100%" flexShrink={0} overflow="hidden" flexDirection="row">
|
|
385
|
+
<box width={legendPaddingLeft} flexShrink={0} />
|
|
386
|
+
<box flexGrow={1} flexShrink={1} overflow="hidden">
|
|
387
|
+
<text wrapMode="none">
|
|
388
|
+
{legendRows.map((item, index) => {
|
|
389
|
+
const separator = index < legendRows.length - 1 ? ' ' : ''
|
|
390
|
+
return (
|
|
391
|
+
<React.Fragment key={index}>
|
|
392
|
+
<span fg={item.color}>●</span>
|
|
393
|
+
<span fg={theme.textMuted}> {item.title}{separator}</span>
|
|
394
|
+
</React.Fragment>
|
|
395
|
+
)
|
|
396
|
+
})}
|
|
397
|
+
</text>
|
|
398
|
+
</box>
|
|
399
|
+
</box>
|
|
400
|
+
) : null}
|
|
401
|
+
</box>
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
DottedLineGraph.Series = DottedLineGraphSeries
|
|
406
|
+
|
|
407
|
+
export { DottedLineGraph }
|
|
@@ -7,7 +7,7 @@ import { ActionPanel, Action } from './actions'
|
|
|
7
7
|
import { LocalStorage } from 'termcast/src/apis/localstorage'
|
|
8
8
|
import { useNavigation } from 'termcast/src/internal/navigation'
|
|
9
9
|
import { logger } from 'termcast/src/logger'
|
|
10
|
-
|
|
10
|
+
|
|
11
11
|
import { useStore } from 'termcast/src/state'
|
|
12
12
|
import type { RaycastPackageJson } from 'termcast/src/package-json'
|
|
13
13
|
|
|
@@ -46,7 +46,6 @@ export function ExtensionPreferences({
|
|
|
46
46
|
queryKey: ['extension-preferences', extensionName, commandName],
|
|
47
47
|
queryFn: async () => {
|
|
48
48
|
try {
|
|
49
|
-
// First check extensionPath from state (dev mode), then fall back to store directory
|
|
50
49
|
const { extensionPath, extensionPackageJson } = useStore.getState()
|
|
51
50
|
|
|
52
51
|
let packageJson: RaycastPackageJson
|
|
@@ -58,16 +57,7 @@ export function ExtensionPreferences({
|
|
|
58
57
|
// Dev mode with extensionPath - read from disk
|
|
59
58
|
packageJson = JSON.parse(readFileSync(joinPath(extensionPath, 'package.json')))
|
|
60
59
|
} else {
|
|
61
|
-
|
|
62
|
-
const storeDir = getStoreDirectory()
|
|
63
|
-
const extensionDir = joinPath(storeDir, extensionName)
|
|
64
|
-
const packageJsonPath = joinPath(extensionDir, 'package.json')
|
|
65
|
-
|
|
66
|
-
if (!fileExists(packageJsonPath)) {
|
|
67
|
-
throw new Error(`Extension ${extensionName} not found`)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
packageJson = JSON.parse(readFileSync(packageJsonPath))
|
|
60
|
+
throw new Error(`Extension ${extensionName} not found`)
|
|
71
61
|
}
|
|
72
62
|
|
|
73
63
|
let prefsToUse: PreferenceManifest[] = []
|
package/src/components/graph.tsx
CHANGED
|
@@ -171,15 +171,19 @@ export class GraphPlotRenderable extends Renderable {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
// X-axis labels
|
|
174
|
+
// X-axis labels (skip labels that would overlap the previous one)
|
|
175
175
|
if (this._xLabels.length > 0) {
|
|
176
176
|
const xAxisRow = plotY + plotH
|
|
177
177
|
const labelCount = this._xLabels.length
|
|
178
|
+
let occupiedEnd = -1
|
|
178
179
|
for (let i = 0; i < labelCount; i++) {
|
|
179
180
|
const label = this._xLabels[i]!
|
|
181
|
+
if (!label) continue
|
|
180
182
|
const labelX = plotX + Math.round((i / Math.max(1, labelCount - 1)) * (plotW - 1))
|
|
181
183
|
const centeredX = Math.max(plotX, Math.min(labelX - Math.floor(label.length / 2), plotX + plotW - label.length))
|
|
184
|
+
if (centeredX <= occupiedEnd) continue
|
|
182
185
|
buffer.drawText(label, centeredX, xAxisRow, axisRgba)
|
|
186
|
+
occupiedEnd = centeredX + label.length
|
|
183
187
|
}
|
|
184
188
|
}
|
|
185
189
|
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Histogram component for rendering horizontal distribution tables in the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Each row displays a colored dot, label, count, percentage, and a horizontal
|
|
5
|
+
* bar made of repeated characters. Includes optional header and totals footer.
|
|
6
|
+
*
|
|
7
|
+
* Colors can be set per-item via props, or auto-assigned from the theme palette
|
|
8
|
+
* in row order (cycling when there are more items than palette colors).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React, { ReactNode, useMemo } from 'react'
|
|
12
|
+
import { useTheme, getThemePalette } from 'termcast/src/theme'
|
|
13
|
+
import { Color, resolveColor } from 'termcast/src/colors'
|
|
14
|
+
|
|
15
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface HistogramItemProps {
|
|
18
|
+
/** Row label (e.g. "user", "bash", "edit") */
|
|
19
|
+
label: string
|
|
20
|
+
/** Numeric count for this row */
|
|
21
|
+
value: number
|
|
22
|
+
/** Override the auto-assigned color for the dot and bar */
|
|
23
|
+
color?: Color.ColorLike
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface HistogramProps {
|
|
27
|
+
/** Max columns for the distribution bar (default: 30) */
|
|
28
|
+
maxBarWidth?: number
|
|
29
|
+
/** Character used for bar segments (default: "│") */
|
|
30
|
+
barCharacter?: string
|
|
31
|
+
/** Show column header row (default: true) */
|
|
32
|
+
showHeader?: boolean
|
|
33
|
+
/** Show totals footer row (default: true) */
|
|
34
|
+
showTotal?: boolean
|
|
35
|
+
/** Show the percentage column (default: true) */
|
|
36
|
+
showPercentage?: boolean
|
|
37
|
+
/** Max display width for labels; longer labels are truncated (default: 24) */
|
|
38
|
+
maxLabelWidth?: number
|
|
39
|
+
/** Histogram.Item children */
|
|
40
|
+
children: ReactNode
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface HistogramType {
|
|
44
|
+
(props: HistogramProps): any
|
|
45
|
+
Item: (props: HistogramItemProps) => any
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Internal data ────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
interface ItemData {
|
|
51
|
+
label: string
|
|
52
|
+
/** Display label (possibly truncated) */
|
|
53
|
+
displayLabel: string
|
|
54
|
+
value: number
|
|
55
|
+
color?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Collect children recursively (handles fragments) ─────────────────
|
|
59
|
+
|
|
60
|
+
function collectItems(children: ReactNode, maxLabelWidth: number): ItemData[] {
|
|
61
|
+
const result: ItemData[] = []
|
|
62
|
+
const flatten = (node: ReactNode) => {
|
|
63
|
+
React.Children.forEach(node, (child) => {
|
|
64
|
+
if (!React.isValidElement(child)) return
|
|
65
|
+
// Traverse fragments
|
|
66
|
+
if (child.type === React.Fragment) {
|
|
67
|
+
const fragmentProps = child.props as { children?: ReactNode }
|
|
68
|
+
flatten(fragmentProps.children)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
const p = child.props as HistogramItemProps
|
|
72
|
+
if (p.label === undefined || p.value === undefined) return
|
|
73
|
+
const displayLabel = p.label.length > maxLabelWidth
|
|
74
|
+
? p.label.slice(0, maxLabelWidth - 1) + '…'
|
|
75
|
+
: p.label
|
|
76
|
+
result.push({
|
|
77
|
+
label: p.label,
|
|
78
|
+
displayLabel,
|
|
79
|
+
value: p.value,
|
|
80
|
+
color: resolveColor(p.color),
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
flatten(children)
|
|
85
|
+
return result
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Histogram.Item (data-only, renders null like BarChart.Segment) ───
|
|
89
|
+
|
|
90
|
+
const HistogramItem = (_props: HistogramItemProps): any => {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Main Histogram component ─────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
const Histogram: HistogramType = (props) => {
|
|
97
|
+
const theme = useTheme()
|
|
98
|
+
const {
|
|
99
|
+
maxBarWidth = 30,
|
|
100
|
+
barCharacter = '╻',
|
|
101
|
+
showHeader = true,
|
|
102
|
+
showTotal = true,
|
|
103
|
+
showPercentage = true,
|
|
104
|
+
maxLabelWidth = 24,
|
|
105
|
+
children,
|
|
106
|
+
} = props
|
|
107
|
+
|
|
108
|
+
const palette = getThemePalette(theme)
|
|
109
|
+
|
|
110
|
+
// Collect item data from children (traverses fragments)
|
|
111
|
+
const items = useMemo<ItemData[]>(() => {
|
|
112
|
+
return collectItems(children, maxLabelWidth)
|
|
113
|
+
}, [children, maxLabelWidth])
|
|
114
|
+
|
|
115
|
+
// Resolve colors: explicit prop > row-order palette assignment
|
|
116
|
+
const coloredItems = useMemo(() => {
|
|
117
|
+
return items.map((item, index) => ({
|
|
118
|
+
...item,
|
|
119
|
+
resolvedColor: item.color || palette[index % palette.length]!,
|
|
120
|
+
}))
|
|
121
|
+
}, [items, palette])
|
|
122
|
+
|
|
123
|
+
const total = useMemo(() => {
|
|
124
|
+
return coloredItems.reduce((sum, item) => sum + item.value, 0)
|
|
125
|
+
}, [coloredItems])
|
|
126
|
+
|
|
127
|
+
const maxValue = useMemo(() => {
|
|
128
|
+
return Math.max(...coloredItems.map((item) => item.value))
|
|
129
|
+
}, [coloredItems])
|
|
130
|
+
|
|
131
|
+
if (coloredItems.length === 0 || total === 0) return null
|
|
132
|
+
|
|
133
|
+
// Compute column widths from data
|
|
134
|
+
const labelWidth = Math.max(...coloredItems.map((item) => item.displayLabel.length), 5)
|
|
135
|
+
const countWidth = Math.max(
|
|
136
|
+
...coloredItems.map((item) => String(item.value).length),
|
|
137
|
+
String(total).length,
|
|
138
|
+
5,
|
|
139
|
+
)
|
|
140
|
+
const pctWidth = 4 // "100%" = 4 chars
|
|
141
|
+
|
|
142
|
+
// ── Formatting helpers ───────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
function padLeft(str: string, width: number): string {
|
|
145
|
+
return str.length >= width ? str : ' '.repeat(width - str.length) + str
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function padRight(str: string, width: number): string {
|
|
149
|
+
return str.length >= width ? str : str + ' '.repeat(width - str.length)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function formatPct(value: number): string {
|
|
153
|
+
const pct = Math.round((value / total) * 100)
|
|
154
|
+
return `${pct}%`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function makeBar(value: number): string {
|
|
158
|
+
if (value <= 0 || maxValue <= 0) return ''
|
|
159
|
+
const barLen = Math.max(1, Math.round((value / maxValue) * maxBarWidth))
|
|
160
|
+
return barCharacter.repeat(barLen)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Separator line ───────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
const percentageWidth = showPercentage ? 2 + pctWidth : 0
|
|
166
|
+
const separatorWidth = 2 + labelWidth + 2 + countWidth + percentageWidth + 2 + maxBarWidth
|
|
167
|
+
const separator = '─'.repeat(separatorWidth)
|
|
168
|
+
|
|
169
|
+
// Build header text as a single string
|
|
170
|
+
const headerText = ` ${padRight('category', labelWidth)} ${padLeft('count', countWidth)}${showPercentage ? ` ${padLeft('%', pctWidth)}` : ''} distribution`
|
|
171
|
+
|
|
172
|
+
// Build total text as a single string
|
|
173
|
+
const totalText = ` ${padRight('total', labelWidth)} ${padLeft(String(total), countWidth)}${showPercentage ? ` ${padLeft('100%', pctWidth)}` : ''}`
|
|
174
|
+
|
|
175
|
+
// Build each data row as a single line string (without dot and bar)
|
|
176
|
+
function buildRowMiddle(item: ItemData & { resolvedColor: string }): string {
|
|
177
|
+
return `${padRight(item.displayLabel, labelWidth)} ${padLeft(String(item.value), countWidth)}${showPercentage ? ` ${padLeft(formatPct(item.value), pctWidth)}` : ''} `
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<box flexDirection="column" flexShrink={0}>
|
|
182
|
+
{/* Header row */}
|
|
183
|
+
{showHeader && (
|
|
184
|
+
<>
|
|
185
|
+
<box height={1} flexShrink={0}>
|
|
186
|
+
<text fg={theme.textMuted} wrapMode="none">{headerText}</text>
|
|
187
|
+
</box>
|
|
188
|
+
<box height={1} flexShrink={0}>
|
|
189
|
+
<text fg={theme.borderSubtle} wrapMode="none">{separator}</text>
|
|
190
|
+
</box>
|
|
191
|
+
</>
|
|
192
|
+
)}
|
|
193
|
+
|
|
194
|
+
{/* Data rows: each row is a single <box> with fixed-width prefix + colored bar */}
|
|
195
|
+
{coloredItems.map((item, i) => {
|
|
196
|
+
const middle = buildRowMiddle(item)
|
|
197
|
+
const bar = makeBar(item.value)
|
|
198
|
+
// Prefix width: dot(2) + middle text length
|
|
199
|
+
const prefixWidth = 2 + middle.length
|
|
200
|
+
return (
|
|
201
|
+
<box key={i} flexDirection="row" height={1} flexShrink={0}>
|
|
202
|
+
<box width={prefixWidth} flexShrink={0} flexDirection="row">
|
|
203
|
+
<text fg={item.resolvedColor} wrapMode="none">{'● '}</text>
|
|
204
|
+
<text fg={theme.text} wrapMode="none">{middle}</text>
|
|
205
|
+
</box>
|
|
206
|
+
{bar && <text fg={item.resolvedColor} wrapMode="none">{bar}</text>}
|
|
207
|
+
</box>
|
|
208
|
+
)
|
|
209
|
+
})}
|
|
210
|
+
|
|
211
|
+
{/* Footer: separator + total */}
|
|
212
|
+
{showTotal && (
|
|
213
|
+
<>
|
|
214
|
+
<box height={1} flexShrink={0}>
|
|
215
|
+
<text fg={theme.borderSubtle} wrapMode="none">{separator}</text>
|
|
216
|
+
</box>
|
|
217
|
+
<box height={1} flexShrink={0}>
|
|
218
|
+
<text fg={theme.textMuted} wrapMode="none">{totalText}</text>
|
|
219
|
+
</box>
|
|
220
|
+
</>
|
|
221
|
+
)}
|
|
222
|
+
</box>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
Histogram.Item = HistogramItem
|
|
227
|
+
|
|
228
|
+
export { Histogram }
|