termcast 1.4.1 → 1.6.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 +30 -12
- package/dist/build.js.map +1 -1
- package/dist/cli.js +0 -40
- package/dist/cli.js.map +1 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +7 -1
- package/dist/compile.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 +9 -0
- package/dist/components/list.d.ts.map +1 -1
- package/dist/components/list.js +84 -21
- 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/list-detail-height-ratchet.d.ts +2 -0
- package/dist/examples/list-detail-height-ratchet.d.ts.map +1 -0
- package/dist/examples/list-detail-height-ratchet.js +26 -0
- package/dist/examples/list-detail-height-ratchet.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/extensions/dev.d.ts.map +1 -1
- package/dist/extensions/dev.js +1 -0
- package/dist/extensions/dev.js.map +1 -1
- package/dist/globals.js +8 -0
- package/dist/globals.js.map +1 -1
- 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/package-json.d.ts +2 -0
- package/dist/package-json.d.ts.map +1 -1
- package/dist/package-json.js +20 -17
- package/dist/package-json.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/profiler.d.ts +2 -0
- package/dist/profiler.d.ts.map +1 -0
- package/dist/profiler.js +390 -0
- package/dist/profiler.js.map +1 -0
- 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 +18 -21
- package/src/build.tsx +38 -15
- package/src/cli.tsx +3 -40
- package/src/compile.tsx +9 -1
- package/src/compile.vitest.tsx +8 -8
- 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 +112 -26
- package/src/examples/action-shortcut.vitest.tsx +20 -20
- package/src/examples/actions-context.vitest.tsx +2 -2
- package/src/examples/bar-graph-weekly.tsx +2 -2
- package/src/examples/bar-graph-weekly.vitest.tsx +103 -102
- package/src/examples/charts-showcase-bargraph.tsx +103 -0
- package/src/examples/detail-metadata-showcase.vitest.tsx +12 -12
- package/src/examples/form-basic.vitest.tsx +11 -11
- 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 -31
- package/src/examples/graph-bar-chart.vitest.tsx +36 -36
- package/src/examples/graph-multi-series.tsx +1 -1
- package/src/examples/graph-polymarket.vitest.tsx +24 -24
- 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-height-ratchet.tsx +48 -0
- package/src/examples/list-detail-height-ratchet.vitest.tsx +161 -0
- package/src/examples/list-detail-metadata.vitest.tsx +51 -51
- package/src/examples/list-dropdown-default.vitest.tsx +27 -27
- package/src/examples/list-fetch-data.vitest.tsx +3 -3
- package/src/examples/list-loading-empty-view.vitest.tsx +1 -1
- package/src/examples/list-no-actions.vitest.tsx +3 -3
- package/src/examples/list-scrollbox.vitest.tsx +6 -6
- package/src/examples/list-spacing-mode.vitest.tsx +1 -1
- package/src/examples/list-with-detail.vitest.tsx +55 -55
- package/src/examples/list-with-dropdown.vitest.tsx +6 -6
- package/src/examples/list-with-sections.vitest.tsx +20 -20
- package/src/examples/list-with-toast.vitest.tsx +4 -4
- package/src/examples/simple-candle-chart.vitest.tsx +61 -59
- 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-heatmap.vitest.tsx +9 -9
- package/src/examples/simple-histogram.tsx +90 -0
- package/src/examples/simple-navigation.vitest.tsx +25 -25
- package/src/examples/simple-progress-bar.vitest.tsx +7 -7
- package/src/examples/swift-extension.vitest.tsx +5 -5
- package/src/examples/toast-action.vitest.tsx +4 -4
- package/src/extensions/dev.tsx +2 -1
- package/src/extensions/dev.vitest.tsx +17 -17
- package/src/globals.ts +9 -0
- package/src/index.tsx +21 -0
- package/src/package-json.tsx +24 -23
- package/src/platform/node/sqlite.ts +29 -13
- package/src/profiler.tsx +487 -0
- 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,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HorizontalBarGraph renders multi-series horizontal stacked bars.
|
|
3
|
+
*
|
|
4
|
+
* Each row is one category, usually a time bucket. The left chart area grows to
|
|
5
|
+
* fill available space while the right legend uses only the width needed for
|
|
6
|
+
* colored series rows and percentages.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { ReactNode, useMemo } from 'react'
|
|
10
|
+
import { BoxProps } from '@opentui/react'
|
|
11
|
+
import { Color, resolveColor } from 'termcast/src/colors'
|
|
12
|
+
import { getThemePalette, useTheme } from 'termcast/src/theme'
|
|
13
|
+
|
|
14
|
+
export interface HorizontalBarGraphSeriesProps {
|
|
15
|
+
/** One value per row/category position. */
|
|
16
|
+
data: number[]
|
|
17
|
+
/** Series label shown in the legend. */
|
|
18
|
+
title?: string
|
|
19
|
+
/** Override the auto-assigned color. */
|
|
20
|
+
color?: Color.ColorLike
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface HorizontalBarGraphProps extends BoxProps {
|
|
24
|
+
/** Row labels, one per bar position. Usually time buckets. */
|
|
25
|
+
labels?: string[]
|
|
26
|
+
/** Maximum chart rows to render. Defaults to all rows. */
|
|
27
|
+
height?: number
|
|
28
|
+
/** Character used for bar cells. Matches Histogram by default. */
|
|
29
|
+
barCharacter?: string
|
|
30
|
+
/** Show header row. Default true. */
|
|
31
|
+
showHeader?: boolean
|
|
32
|
+
/** Show right-side legend. Default true when any series has a title. */
|
|
33
|
+
showLegend?: boolean
|
|
34
|
+
/** Max display width for labels; longer labels are truncated. Default 16. */
|
|
35
|
+
maxLabelWidth?: number
|
|
36
|
+
/** Header text for the category column. Default "category". */
|
|
37
|
+
categoryTitle?: string
|
|
38
|
+
/** Header text for the bar column. Default "distribution". */
|
|
39
|
+
distributionTitle?: string
|
|
40
|
+
/** Header text for the legend column. Default "legend". */
|
|
41
|
+
legendTitle?: string
|
|
42
|
+
/** HorizontalBarGraph.Series children. */
|
|
43
|
+
children: ReactNode
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface HorizontalBarGraphType {
|
|
47
|
+
(props: HorizontalBarGraphProps): any
|
|
48
|
+
Series: (props: HorizontalBarGraphSeriesProps) => any
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function truncateLabel(label: string, maxLabelWidth: number): string {
|
|
52
|
+
if (label.length <= maxLabelWidth) {
|
|
53
|
+
return label
|
|
54
|
+
}
|
|
55
|
+
return label.slice(0, maxLabelWidth - 1) + '…'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function padRight(value: string, width: number): string {
|
|
59
|
+
if (value.length >= width) {
|
|
60
|
+
return value
|
|
61
|
+
}
|
|
62
|
+
return value + ' '.repeat(width - value.length)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function padLeft(value: string, width: number): string {
|
|
66
|
+
if (value.length >= width) {
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
return ' '.repeat(width - value.length) + value
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatPercentage(value: number, total: number): string {
|
|
73
|
+
if (total <= 0) {
|
|
74
|
+
return '0%'
|
|
75
|
+
}
|
|
76
|
+
return `${Math.round((value / total) * 100)}%`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const HorizontalBarGraphSeries = (_props: HorizontalBarGraphSeriesProps): any => {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const HorizontalBarGraph: HorizontalBarGraphType = (props) => {
|
|
84
|
+
const theme = useTheme()
|
|
85
|
+
const {
|
|
86
|
+
labels = [],
|
|
87
|
+
height,
|
|
88
|
+
barCharacter = '╻',
|
|
89
|
+
showHeader = true,
|
|
90
|
+
showLegend,
|
|
91
|
+
maxLabelWidth = 16,
|
|
92
|
+
categoryTitle = 'category',
|
|
93
|
+
distributionTitle = 'distribution',
|
|
94
|
+
legendTitle = 'legend',
|
|
95
|
+
children,
|
|
96
|
+
...rest
|
|
97
|
+
} = props
|
|
98
|
+
|
|
99
|
+
const palette = getThemePalette(theme)
|
|
100
|
+
|
|
101
|
+
const seriesList = useMemo<Array<{ data: number[]; color: string; title?: string }>>(() => {
|
|
102
|
+
const childArray = React.Children.toArray(children)
|
|
103
|
+
return childArray
|
|
104
|
+
.filter(React.isValidElement)
|
|
105
|
+
.map((child, index) => {
|
|
106
|
+
const childProps = child.props as HorizontalBarGraphSeriesProps
|
|
107
|
+
return {
|
|
108
|
+
data: childProps.data,
|
|
109
|
+
title: childProps.title,
|
|
110
|
+
color: resolveColor(childProps.color) || palette[index % palette.length]!,
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
.filter((series) => {
|
|
114
|
+
return Array.isArray(series.data)
|
|
115
|
+
})
|
|
116
|
+
}, [children, palette])
|
|
117
|
+
|
|
118
|
+
const rowCount = useMemo(() => {
|
|
119
|
+
return Math.max(labels.length, ...seriesList.map((series) => series.data.length), 0)
|
|
120
|
+
}, [labels, seriesList])
|
|
121
|
+
|
|
122
|
+
const rows = useMemo<Array<{ label: string; total: number; values: number[] }>>(() => {
|
|
123
|
+
return Array.from({ length: rowCount }, (_, rowIndex) => {
|
|
124
|
+
const values = seriesList.map((series) => {
|
|
125
|
+
return series.data[rowIndex] || 0
|
|
126
|
+
})
|
|
127
|
+
const total = values.reduce((sum, value) => {
|
|
128
|
+
return sum + value
|
|
129
|
+
}, 0)
|
|
130
|
+
return {
|
|
131
|
+
label: labels[rowIndex] ?? String(rowIndex + 1),
|
|
132
|
+
total,
|
|
133
|
+
values,
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
}, [labels, rowCount, seriesList])
|
|
137
|
+
|
|
138
|
+
const visibleRows = useMemo(() => {
|
|
139
|
+
return rows.slice(0, height ?? rows.length)
|
|
140
|
+
}, [height, rows])
|
|
141
|
+
|
|
142
|
+
const maxTotal = useMemo(() => {
|
|
143
|
+
return Math.max(0, ...rows.map((row) => row.total))
|
|
144
|
+
}, [rows])
|
|
145
|
+
|
|
146
|
+
const legendRows = useMemo<Array<{ title: string; color: string; percentage: string; total: number }>>(() => {
|
|
147
|
+
const grandTotal = rows.reduce((sum, row) => {
|
|
148
|
+
return sum + row.total
|
|
149
|
+
}, 0)
|
|
150
|
+
return seriesList
|
|
151
|
+
.map((series, seriesIndex) => {
|
|
152
|
+
const seriesTotal = series.data.reduce((sum, value) => {
|
|
153
|
+
return sum + value
|
|
154
|
+
}, 0)
|
|
155
|
+
return {
|
|
156
|
+
title: series.title ?? `Series ${seriesIndex + 1}`,
|
|
157
|
+
color: series.color,
|
|
158
|
+
percentage: formatPercentage(seriesTotal, grandTotal),
|
|
159
|
+
total: seriesTotal,
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
.filter((row) => {
|
|
163
|
+
return row.title.length > 0
|
|
164
|
+
})
|
|
165
|
+
.sort((a, b) => {
|
|
166
|
+
return b.total - a.total
|
|
167
|
+
})
|
|
168
|
+
}, [rows, seriesList])
|
|
169
|
+
|
|
170
|
+
const legendVisible = showLegend ?? seriesList.some((series) => {
|
|
171
|
+
return Boolean(series.title)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
if (seriesList.length === 0 || visibleRows.length === 0 || maxTotal === 0) {
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const displayLabels = visibleRows.map((row) => {
|
|
179
|
+
return truncateLabel(row.label, maxLabelWidth)
|
|
180
|
+
})
|
|
181
|
+
const displayCategoryTitle = truncateLabel(categoryTitle, maxLabelWidth)
|
|
182
|
+
const labelWidth = Math.max(5, displayCategoryTitle.length, ...displayLabels.map((label) => label.length))
|
|
183
|
+
|
|
184
|
+
const legendGap = 2
|
|
185
|
+
const legendTitleWidth = Math.max(6, ...legendRows.map((row) => row.title.length))
|
|
186
|
+
const legendPercentageWidth = Math.max(3, ...legendRows.map((row) => row.percentage.length))
|
|
187
|
+
const legendWidth = legendVisible ? legendGap + 2 + legendTitleWidth + 2 + legendPercentageWidth : 0
|
|
188
|
+
const headerHeight = showHeader ? 2 : 0
|
|
189
|
+
const chartHeight = headerHeight + visibleRows.length
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<box flexDirection="column" width="100%" flexShrink={0} {...rest}>
|
|
193
|
+
{showHeader && (
|
|
194
|
+
<>
|
|
195
|
+
<box flexDirection="row" height={1} flexShrink={0}>
|
|
196
|
+
<box width={labelWidth + 2} flexShrink={0} overflow="hidden">
|
|
197
|
+
<text fg={theme.textMuted} wrapMode="none">{padRight(displayCategoryTitle, labelWidth)} </text>
|
|
198
|
+
</box>
|
|
199
|
+
<box flexGrow={1} flexShrink={1} overflow="hidden">
|
|
200
|
+
<text fg={theme.textMuted} wrapMode="none">{distributionTitle}</text>
|
|
201
|
+
</box>
|
|
202
|
+
{legendVisible && (
|
|
203
|
+
<box width={legendWidth} flexShrink={0} overflow="hidden">
|
|
204
|
+
<text fg={theme.textMuted} wrapMode="none">{' '.repeat(legendGap)}{legendTitle}</text>
|
|
205
|
+
</box>
|
|
206
|
+
)}
|
|
207
|
+
</box>
|
|
208
|
+
<box flexDirection="row" height={1} flexShrink={0}>
|
|
209
|
+
<box width={labelWidth + 2} flexShrink={0} overflow="hidden">
|
|
210
|
+
<text fg={theme.borderSubtle} wrapMode="none">{'─'.repeat(labelWidth)} </text>
|
|
211
|
+
</box>
|
|
212
|
+
<box flexGrow={1} flexShrink={1} overflow="hidden">
|
|
213
|
+
<text fg={theme.borderSubtle} wrapMode="none">{'─'.repeat(200)}</text>
|
|
214
|
+
</box>
|
|
215
|
+
{legendVisible && (
|
|
216
|
+
<box width={legendWidth} flexShrink={0} overflow="hidden">
|
|
217
|
+
<text fg={theme.borderSubtle} wrapMode="none">{' '.repeat(legendGap)}{'─'.repeat(legendWidth - legendGap)}</text>
|
|
218
|
+
</box>
|
|
219
|
+
)}
|
|
220
|
+
</box>
|
|
221
|
+
</>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
<box flexDirection="row" height={chartHeight - headerHeight} flexShrink={0}>
|
|
225
|
+
<box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
|
|
226
|
+
{visibleRows.map((row, rowIndex) => {
|
|
227
|
+
return (
|
|
228
|
+
<box key={row.label} flexDirection="row" height={1} flexShrink={0} overflow="hidden">
|
|
229
|
+
<box width={labelWidth + 2} flexShrink={0} overflow="hidden">
|
|
230
|
+
<text fg={theme.text} wrapMode="none">
|
|
231
|
+
{padRight(displayLabels[rowIndex]!, labelWidth)}
|
|
232
|
+
</text>
|
|
233
|
+
</box>
|
|
234
|
+
<box flexDirection="row" flexGrow={1} flexShrink={1} overflow="hidden">
|
|
235
|
+
{row.values.map((value, seriesIndex) => {
|
|
236
|
+
if (value <= 0) {
|
|
237
|
+
return null
|
|
238
|
+
}
|
|
239
|
+
const series = seriesList[seriesIndex]!
|
|
240
|
+
return (
|
|
241
|
+
<box key={seriesIndex} flexGrow={value} flexBasis={0} flexShrink={1} overflow="hidden">
|
|
242
|
+
<box position="absolute" width="100%" height="100%" overflow="hidden">
|
|
243
|
+
<text fg={series.color} wrapMode="none">{barCharacter.repeat(200)}</text>
|
|
244
|
+
</box>
|
|
245
|
+
</box>
|
|
246
|
+
)
|
|
247
|
+
})}
|
|
248
|
+
{maxTotal > row.total && (
|
|
249
|
+
<box flexGrow={maxTotal - row.total} flexBasis={0} flexShrink={1} />
|
|
250
|
+
)}
|
|
251
|
+
</box>
|
|
252
|
+
</box>
|
|
253
|
+
)
|
|
254
|
+
})}
|
|
255
|
+
</box>
|
|
256
|
+
|
|
257
|
+
{legendVisible && (
|
|
258
|
+
<box flexDirection="column" width={legendWidth} flexShrink={0} overflow="hidden">
|
|
259
|
+
{legendRows.map((row) => {
|
|
260
|
+
return (
|
|
261
|
+
<box key={row.title} height={1} flexShrink={0} flexDirection="row" overflow="hidden">
|
|
262
|
+
<box width={legendGap} flexShrink={0} />
|
|
263
|
+
<text fg={row.color} wrapMode="none">● </text>
|
|
264
|
+
<text fg={theme.textMuted} wrapMode="none">
|
|
265
|
+
{padRight(row.title, legendTitleWidth)} {padLeft(row.percentage, legendPercentageWidth)}
|
|
266
|
+
</text>
|
|
267
|
+
</box>
|
|
268
|
+
)
|
|
269
|
+
})}
|
|
270
|
+
</box>
|
|
271
|
+
)}
|
|
272
|
+
</box>
|
|
273
|
+
</box>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
HorizontalBarGraph.Series = HorizontalBarGraphSeries
|
|
278
|
+
|
|
279
|
+
export { HorizontalBarGraph }
|
package/src/components/list.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
TextareaRenderable,
|
|
7
7
|
} from '@opentui/core'
|
|
8
8
|
import type { MouseEvent as OpenTUIMouseEvent } from '@opentui/core'
|
|
9
|
-
import { useKeyboard, flushSync } from '@opentui/react'
|
|
9
|
+
import { useKeyboard, flushSync, useTerminalDimensions } from '@opentui/react'
|
|
10
10
|
import React, {
|
|
11
11
|
ReactElement,
|
|
12
12
|
ReactNode,
|
|
@@ -225,8 +225,17 @@ function CurrentItemDetail(props: {
|
|
|
225
225
|
}): any {
|
|
226
226
|
const theme = useTheme()
|
|
227
227
|
const descendantsMap = useListDescendantsRerender()
|
|
228
|
-
|
|
229
|
-
|
|
228
|
+
const boxRef = React.useRef<BoxRenderable>(null)
|
|
229
|
+
// Grow-only height ratchet: once the detail panel reaches a certain height,
|
|
230
|
+
// it never shrinks below that. This prevents the footer from jumping up
|
|
231
|
+
// when navigating from a tall detail to a short one.
|
|
232
|
+
const maxHeightRef = React.useRef(0)
|
|
233
|
+
|
|
234
|
+
if (!props.isShowingDetail) {
|
|
235
|
+
// Reset ratchet when detail is hidden so next show starts fresh
|
|
236
|
+
maxHeightRef.current = 0
|
|
237
|
+
return null
|
|
238
|
+
}
|
|
230
239
|
|
|
231
240
|
const currentItem = Object.values(descendantsMap)
|
|
232
241
|
.find((item) => item.index === props.selectedIndex)
|
|
@@ -236,6 +245,14 @@ function CurrentItemDetail(props: {
|
|
|
236
245
|
|
|
237
246
|
return (
|
|
238
247
|
<box
|
|
248
|
+
ref={boxRef}
|
|
249
|
+
minHeight={maxHeightRef.current || undefined}
|
|
250
|
+
onSizeChange={() => {
|
|
251
|
+
const h = boxRef.current?.height ?? 0
|
|
252
|
+
if (h > maxHeightRef.current) {
|
|
253
|
+
maxHeightRef.current = h
|
|
254
|
+
}
|
|
255
|
+
}}
|
|
239
256
|
style={{
|
|
240
257
|
width: '50%',
|
|
241
258
|
paddingLeft: 1,
|
|
@@ -374,6 +391,8 @@ export interface DropdownProps extends SearchBarInterface, CommonProps {
|
|
|
374
391
|
storeValue?: boolean
|
|
375
392
|
value?: string
|
|
376
393
|
defaultValue?: string
|
|
394
|
+
/** Override the text shown in the search bar for the active selection. When set, this is displayed instead of the selected item's title. Useful for managed state where the display label doesn't match any dropdown item. */
|
|
395
|
+
displayValue?: string
|
|
377
396
|
onChange?: (newValue: string) => void
|
|
378
397
|
children?: ReactNode
|
|
379
398
|
}
|
|
@@ -404,6 +423,13 @@ export interface ListProps
|
|
|
404
423
|
searchBarPlaceholder?: string
|
|
405
424
|
selectedItemId?: string
|
|
406
425
|
isShowingDetail?: boolean
|
|
426
|
+
/**
|
|
427
|
+
* Minimum terminal width in columns required to show the detail panel.
|
|
428
|
+
* When the terminal is narrower than this value, the detail panel is
|
|
429
|
+
* automatically hidden even if `isShowingDetail` is true.
|
|
430
|
+
* @default 80
|
|
431
|
+
*/
|
|
432
|
+
detailMinWidth?: number
|
|
407
433
|
/**
|
|
408
434
|
* Controls the vertical spacing of list items.
|
|
409
435
|
* - 'default': Single-line items with title and subtitle on same row
|
|
@@ -946,9 +972,9 @@ function ListItemRow(props: {
|
|
|
946
972
|
<text flexShrink={0} fg={active ? theme.background : theme.text} attributes={active ? TextAttributes.BOLD : undefined} selectable={false} wrapMode="none">{active ? '›' : ' '}</text>
|
|
947
973
|
{icon && <text flexShrink={0} fg={active ? theme.background : iconColor || theme.text} selectable={false} wrapMode="none">{getIconEmoji(icon)} </text>}
|
|
948
974
|
<text
|
|
949
|
-
flexShrink={
|
|
975
|
+
flexShrink={1}
|
|
976
|
+
truncate
|
|
950
977
|
fg={active ? theme.background : theme.text}
|
|
951
|
-
|
|
952
978
|
attributes={TextAttributes.BOLD}
|
|
953
979
|
selectable={false}
|
|
954
980
|
wrapMode="none"
|
|
@@ -969,9 +995,10 @@ function ListItemRow(props: {
|
|
|
969
995
|
</box>
|
|
970
996
|
{/* Line 2: subtitle indented to align with title */}
|
|
971
997
|
{subtitle && (
|
|
972
|
-
<box style={{ paddingLeft: subtitleIndent }}>
|
|
998
|
+
<box style={{ paddingLeft: subtitleIndent, overflow: 'hidden' }}>
|
|
973
999
|
<text
|
|
974
|
-
flexShrink={
|
|
1000
|
+
flexShrink={1}
|
|
1001
|
+
truncate
|
|
975
1002
|
fg={active ? theme.background : theme.text}
|
|
976
1003
|
selectable={false}
|
|
977
1004
|
wrapMode="none"
|
|
@@ -1006,11 +1033,12 @@ function ListItemRow(props: {
|
|
|
1006
1033
|
onMouseDown={handleMouseDown}
|
|
1007
1034
|
>
|
|
1008
1035
|
<box style={{ flexDirection: 'row', flexGrow: 1, flexShrink: 1, overflow: 'hidden', gap: 1 }}>
|
|
1009
|
-
<box style={{ flexDirection: 'row', flexShrink:
|
|
1036
|
+
<box style={{ flexDirection: 'row', flexShrink: 1, overflow: 'hidden' }}>
|
|
1010
1037
|
<text flexShrink={0} fg={active ? theme.background : theme.text} attributes={active ? TextAttributes.BOLD : undefined} selectable={false} wrapMode="none">{active ? '›' : ' '}</text>
|
|
1011
1038
|
{icon && <text flexShrink={0} fg={active ? theme.background : iconColor || theme.text} selectable={false} wrapMode="none">{getIconEmoji(icon)} </text>}
|
|
1012
1039
|
<text
|
|
1013
|
-
flexShrink={
|
|
1040
|
+
flexShrink={1}
|
|
1041
|
+
truncate
|
|
1014
1042
|
fg={active ? theme.background : theme.text}
|
|
1015
1043
|
attributes={active ? TextAttributes.BOLD : undefined}
|
|
1016
1044
|
selectable={false}
|
|
@@ -1021,7 +1049,8 @@ function ListItemRow(props: {
|
|
|
1021
1049
|
</box>
|
|
1022
1050
|
{subtitle && (
|
|
1023
1051
|
<text
|
|
1024
|
-
flexShrink={
|
|
1052
|
+
flexShrink={3}
|
|
1053
|
+
truncate
|
|
1025
1054
|
fg={active ? theme.background : theme.textMuted}
|
|
1026
1055
|
selectable={false}
|
|
1027
1056
|
wrapMode="none"
|
|
@@ -1055,6 +1084,7 @@ export const List: ListType = (props) => {
|
|
|
1055
1084
|
isLoading,
|
|
1056
1085
|
navigationTitle,
|
|
1057
1086
|
isShowingDetail,
|
|
1087
|
+
detailMinWidth = 80,
|
|
1058
1088
|
selectedItemId,
|
|
1059
1089
|
searchBarAccessory,
|
|
1060
1090
|
logo,
|
|
@@ -1065,6 +1095,9 @@ export const List: ListType = (props) => {
|
|
|
1065
1095
|
} = props
|
|
1066
1096
|
|
|
1067
1097
|
const theme = useTheme()
|
|
1098
|
+
const { width: terminalWidth } = useTerminalDimensions()
|
|
1099
|
+
const effectiveIsShowingDetail = isShowingDetail && terminalWidth >= detailMinWidth
|
|
1100
|
+
|
|
1068
1101
|
const currentStackSelectedListIndex = useStore((state) => {
|
|
1069
1102
|
const stack = state.navigationStack
|
|
1070
1103
|
const currentItem = stack[stack.length - 1]
|
|
@@ -1257,14 +1290,14 @@ export const List: ListType = (props) => {
|
|
|
1257
1290
|
setSelectedIndex: setSelectedIndexWithPersistence,
|
|
1258
1291
|
searchText,
|
|
1259
1292
|
isFiltering: isFilteringEnabled,
|
|
1260
|
-
isShowingDetail,
|
|
1293
|
+
isShowingDetail: effectiveIsShowingDetail,
|
|
1261
1294
|
customEmptyViewRef,
|
|
1262
1295
|
isLoading,
|
|
1263
1296
|
hasDropdown: !!searchBarAccessory,
|
|
1264
1297
|
spacingMode,
|
|
1265
1298
|
accessoryTagWidths: accessoryTagsLayout,
|
|
1266
1299
|
}),
|
|
1267
|
-
[isDropdownOpen, selectedIndex, searchText, isFilteringEnabled,
|
|
1300
|
+
[isDropdownOpen, selectedIndex, searchText, isFilteringEnabled, effectiveIsShowingDetail, isLoading, searchBarAccessory, spacingMode, accessoryTagsLayout],
|
|
1268
1301
|
)
|
|
1269
1302
|
|
|
1270
1303
|
// Handle selectedItemId prop changes (before paint to avoid flash)
|
|
@@ -1351,6 +1384,46 @@ export const List: ListType = (props) => {
|
|
|
1351
1384
|
}
|
|
1352
1385
|
}
|
|
1353
1386
|
|
|
1387
|
+
// Trigger pagination when the user mouse-scrolls near the bottom of the list.
|
|
1388
|
+
// Only fires on scroll-down so scrolling up near the bottom doesn't spuriously
|
|
1389
|
+
// re-trigger onLoadMore. Uses queueMicrotask because opentui calls onMouseScroll
|
|
1390
|
+
// before ScrollBox.onMouseEvent updates scrollTop in the same call stack;
|
|
1391
|
+
// a microtask runs after the synchronous handler chain finishes.
|
|
1392
|
+
const checkScrollPagination = (event: OpenTUIMouseEvent) => {
|
|
1393
|
+
if (event.scroll?.direction !== 'down') return
|
|
1394
|
+
|
|
1395
|
+
queueMicrotask(() => {
|
|
1396
|
+
const scrollBox = scrollBoxRef.current
|
|
1397
|
+
if (!scrollBox || !props.pagination?.hasMore) return
|
|
1398
|
+
|
|
1399
|
+
// Reset pagination lock when new items arrive (same logic as in move())
|
|
1400
|
+
const items = Object.values(descendantsContext.map.current)
|
|
1401
|
+
.filter((item) => item.index !== -1 && item.props?.visible !== false)
|
|
1402
|
+
if (items.length !== prevItemCountRef.current) {
|
|
1403
|
+
prevItemCountRef.current = items.length
|
|
1404
|
+
paginationCalledRef.current = false
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
if (paginationCalledRef.current) return
|
|
1408
|
+
|
|
1409
|
+
const scrollTop = scrollBox.scrollTop || 0
|
|
1410
|
+
const viewportHeight = scrollBox.viewport?.height || 0
|
|
1411
|
+
const contentHeight = scrollBox.scrollHeight || 0
|
|
1412
|
+
|
|
1413
|
+
// Nothing to paginate if content fits in viewport
|
|
1414
|
+
if (contentHeight <= viewportHeight) return
|
|
1415
|
+
|
|
1416
|
+
// Trigger when within 20% of the bottom (or 3 rows, whichever is larger)
|
|
1417
|
+
const threshold = Math.max(3, Math.floor(viewportHeight * 0.2))
|
|
1418
|
+
const distanceFromBottom = contentHeight - (scrollTop + viewportHeight)
|
|
1419
|
+
|
|
1420
|
+
if (distanceFromBottom <= threshold) {
|
|
1421
|
+
paginationCalledRef.current = true
|
|
1422
|
+
props.pagination.onLoadMore()
|
|
1423
|
+
}
|
|
1424
|
+
})
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1354
1427
|
const move = (direction: -1 | 1) => {
|
|
1355
1428
|
// Get all visible items
|
|
1356
1429
|
const items = Object.values(descendantsContext.map.current)
|
|
@@ -1607,7 +1680,8 @@ export const List: ListType = (props) => {
|
|
|
1607
1680
|
return
|
|
1608
1681
|
}
|
|
1609
1682
|
|
|
1610
|
-
// Ctrl+d
|
|
1683
|
+
// Ctrl+d / Ctrl+u for half-page down/up
|
|
1684
|
+
// Ctrl+f / Ctrl+b for full-page down/up
|
|
1611
1685
|
if (evt.ctrl && evt.name === 'd') {
|
|
1612
1686
|
const viewportHeight = scrollBoxRef.current?.viewport?.height || 20
|
|
1613
1687
|
moveByN(Math.floor(viewportHeight / 2))
|
|
@@ -1620,6 +1694,18 @@ export const List: ListType = (props) => {
|
|
|
1620
1694
|
evt.stopPropagation()
|
|
1621
1695
|
return
|
|
1622
1696
|
}
|
|
1697
|
+
if (evt.ctrl && evt.name === 'f') {
|
|
1698
|
+
const viewportHeight = scrollBoxRef.current?.viewport?.height || 20
|
|
1699
|
+
moveByN(viewportHeight)
|
|
1700
|
+
evt.stopPropagation()
|
|
1701
|
+
return
|
|
1702
|
+
}
|
|
1703
|
+
if (evt.ctrl && evt.name === 'b') {
|
|
1704
|
+
const viewportHeight = scrollBoxRef.current?.viewport?.height || 20
|
|
1705
|
+
moveByN(-viewportHeight)
|
|
1706
|
+
evt.stopPropagation()
|
|
1707
|
+
return
|
|
1708
|
+
}
|
|
1623
1709
|
|
|
1624
1710
|
// / to enter search mode
|
|
1625
1711
|
if (evt.sequence === '/' && !evt.ctrl && !evt.meta) {
|
|
@@ -1794,14 +1880,15 @@ export const List: ListType = (props) => {
|
|
|
1794
1880
|
{/* Main content area with optional detail view */}
|
|
1795
1881
|
<box style={{ flexDirection: 'row', flexGrow: 1, flexShrink: 1 }}>
|
|
1796
1882
|
{/* List content - render children which will register themselves */}
|
|
1797
|
-
<box style={{ width:
|
|
1883
|
+
<box style={{ width: effectiveIsShowingDetail ? '50%' : '100%', flexGrow: 1, flexShrink: 1, flexDirection: 'column' }}>
|
|
1798
1884
|
{/* Scrollable list items */}
|
|
1799
1885
|
<ScrollBox
|
|
1800
1886
|
ref={scrollBoxRef}
|
|
1801
1887
|
focused={false}
|
|
1802
1888
|
flexGrow={1}
|
|
1803
1889
|
flexShrink={1}
|
|
1804
|
-
minHeight={
|
|
1890
|
+
minHeight={10}
|
|
1891
|
+
onMouseScroll={checkScrollPagination}
|
|
1805
1892
|
style={{
|
|
1806
1893
|
rootOptions: {
|
|
1807
1894
|
backgroundColor: undefined,
|
|
@@ -1831,7 +1918,7 @@ export const List: ListType = (props) => {
|
|
|
1831
1918
|
{/* Detail panel on the right */}
|
|
1832
1919
|
<CurrentItemDetail
|
|
1833
1920
|
selectedIndex={selectedIndex}
|
|
1834
|
-
isShowingDetail={
|
|
1921
|
+
isShowingDetail={effectiveIsShowingDetail}
|
|
1835
1922
|
/>
|
|
1836
1923
|
</box>
|
|
1837
1924
|
</box>
|
|
@@ -1985,8 +2072,7 @@ const ListItem: ListItemType = (props) => {
|
|
|
1985
2072
|
}
|
|
1986
2073
|
}
|
|
1987
2074
|
|
|
1988
|
-
|
|
1989
|
-
const showAccessories = !props.detail && props.accessories
|
|
2075
|
+
const showAccessories = Boolean(props.accessories)
|
|
1990
2076
|
|
|
1991
2077
|
// Get icon string and color from props.icon (can be string or object with value/tintColor)
|
|
1992
2078
|
const { iconValue, iconColor } = (() => {
|
|
@@ -2127,9 +2213,9 @@ const ListDropdown: ListDropdownType = (props) => {
|
|
|
2127
2213
|
|
|
2128
2214
|
const { isDropdownOpen, setIsDropdownOpen } = listContext
|
|
2129
2215
|
|
|
2130
|
-
const setDropdownSelection = (
|
|
2131
|
-
setDropdownState({ value:
|
|
2132
|
-
useStore.setState({ dropdownFooterLabel: props.title || 'dropdown' })
|
|
2216
|
+
const setDropdownSelection = (selectionProps: { value: string; title: string }) => {
|
|
2217
|
+
setDropdownState({ value: selectionProps.value, title: selectionProps.title })
|
|
2218
|
+
useStore.setState({ dropdownFooterLabel: props.displayValue ?? (selectionProps.title || 'dropdown') })
|
|
2133
2219
|
}
|
|
2134
2220
|
// Store both value and title together
|
|
2135
2221
|
const [dropdownState, setDropdownState] = useState<{
|
|
@@ -2168,7 +2254,7 @@ const ListDropdown: ListDropdownType = (props) => {
|
|
|
2168
2254
|
|
|
2169
2255
|
if (!valueToUse) {
|
|
2170
2256
|
useStore.setState({
|
|
2171
|
-
dropdownFooterLabel: dropdownState.title || 'dropdown',
|
|
2257
|
+
dropdownFooterLabel: props.displayValue ?? (dropdownState.title || 'dropdown'),
|
|
2172
2258
|
})
|
|
2173
2259
|
return
|
|
2174
2260
|
}
|
|
@@ -2189,8 +2275,8 @@ const ListDropdown: ListDropdownType = (props) => {
|
|
|
2189
2275
|
return
|
|
2190
2276
|
}
|
|
2191
2277
|
|
|
2192
|
-
useStore.setState({ dropdownFooterLabel: title || 'dropdown' })
|
|
2193
|
-
}, [props.value]) // Run when props.value changes and on mount
|
|
2278
|
+
useStore.setState({ dropdownFooterLabel: props.displayValue ?? (title || 'dropdown') })
|
|
2279
|
+
}, [props.value, props.displayValue]) // Run when props.value or displayValue changes and on mount
|
|
2194
2280
|
|
|
2195
2281
|
const dropdownContextValue = useMemo<DropdownContextValue>(
|
|
2196
2282
|
() => ({
|
|
@@ -2242,8 +2328,8 @@ const ListDropdown: ListDropdownType = (props) => {
|
|
|
2242
2328
|
}
|
|
2243
2329
|
}, [isDropdownOpen, props.children])
|
|
2244
2330
|
|
|
2245
|
-
// Display the title from our state
|
|
2246
|
-
const displayValue = dropdownState.title || 'All'
|
|
2331
|
+
// Display the title from our state, or use the caller's override
|
|
2332
|
+
const displayValue = props.displayValue ?? (dropdownState.title || 'All')
|
|
2247
2333
|
const openDropdownIfClosed = () => {
|
|
2248
2334
|
if (!isDropdownOpen) {
|
|
2249
2335
|
listContext.openDropdown()
|