@rlabs-inc/tui 0.1.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/README.md +141 -0
- package/index.ts +45 -0
- package/package.json +59 -0
- package/src/api/index.ts +7 -0
- package/src/api/mount.ts +230 -0
- package/src/engine/arrays/core.ts +60 -0
- package/src/engine/arrays/dimensions.ts +68 -0
- package/src/engine/arrays/index.ts +166 -0
- package/src/engine/arrays/interaction.ts +112 -0
- package/src/engine/arrays/layout.ts +175 -0
- package/src/engine/arrays/spacing.ts +100 -0
- package/src/engine/arrays/text.ts +55 -0
- package/src/engine/arrays/visual.ts +140 -0
- package/src/engine/index.ts +25 -0
- package/src/engine/inheritance.ts +138 -0
- package/src/engine/registry.ts +180 -0
- package/src/pipeline/frameBuffer.ts +473 -0
- package/src/pipeline/layout/index.ts +105 -0
- package/src/pipeline/layout/titan-engine.ts +798 -0
- package/src/pipeline/layout/types.ts +194 -0
- package/src/pipeline/layout/utils/hierarchy.ts +202 -0
- package/src/pipeline/layout/utils/math.ts +134 -0
- package/src/pipeline/layout/utils/text-measure.ts +160 -0
- package/src/pipeline/layout.ts +30 -0
- package/src/primitives/box.ts +312 -0
- package/src/primitives/index.ts +12 -0
- package/src/primitives/text.ts +199 -0
- package/src/primitives/types.ts +222 -0
- package/src/primitives/utils.ts +37 -0
- package/src/renderer/ansi.ts +625 -0
- package/src/renderer/buffer.ts +667 -0
- package/src/renderer/index.ts +40 -0
- package/src/renderer/input.ts +518 -0
- package/src/renderer/output.ts +451 -0
- package/src/state/cursor.ts +176 -0
- package/src/state/focus.ts +241 -0
- package/src/state/index.ts +43 -0
- package/src/state/keyboard.ts +771 -0
- package/src/state/mouse.ts +524 -0
- package/src/state/scroll.ts +341 -0
- package/src/state/theme.ts +687 -0
- package/src/types/color.ts +401 -0
- package/src/types/index.ts +316 -0
- package/src/utils/text.ts +471 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Layout Engine Types
|
|
3
|
+
*
|
|
4
|
+
* THE terminal layout system. Not just Flexbox - everything.
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Full Flexbox (6-phase algorithm with freeze loop)
|
|
8
|
+
* - Grid layout (coming soon)
|
|
9
|
+
* - Fixed/Absolute/Relative positioning
|
|
10
|
+
* - Animation & transitions support (built into the architecture)
|
|
11
|
+
*
|
|
12
|
+
* Pure parallel arrays - NO objects for working data.
|
|
13
|
+
* Every array is indexed by component index.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// OUTPUT TYPES (same interface as current Yoga integration)
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
export interface ComputedLayout {
|
|
21
|
+
x: number[]
|
|
22
|
+
y: number[]
|
|
23
|
+
width: number[]
|
|
24
|
+
height: number[]
|
|
25
|
+
scrollable: number[]
|
|
26
|
+
maxScrollY: number[]
|
|
27
|
+
maxScrollX: number[]
|
|
28
|
+
// Content bounds (max extent of all components)
|
|
29
|
+
contentWidth: number // Max x + width across all components
|
|
30
|
+
contentHeight: number // Max y + height across all components
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// =============================================================================
|
|
34
|
+
// FLEX DIRECTION AND WRAP ENUMS
|
|
35
|
+
// =============================================================================
|
|
36
|
+
|
|
37
|
+
export const FlexDirection = {
|
|
38
|
+
COLUMN: 0,
|
|
39
|
+
ROW: 1,
|
|
40
|
+
COLUMN_REVERSE: 2,
|
|
41
|
+
ROW_REVERSE: 3,
|
|
42
|
+
} as const
|
|
43
|
+
|
|
44
|
+
export const FlexWrap = {
|
|
45
|
+
NO_WRAP: 0,
|
|
46
|
+
WRAP: 1,
|
|
47
|
+
WRAP_REVERSE: 2,
|
|
48
|
+
} as const
|
|
49
|
+
|
|
50
|
+
export const JustifyContent = {
|
|
51
|
+
FLEX_START: 0,
|
|
52
|
+
CENTER: 1,
|
|
53
|
+
FLEX_END: 2,
|
|
54
|
+
SPACE_BETWEEN: 3,
|
|
55
|
+
SPACE_AROUND: 4,
|
|
56
|
+
SPACE_EVENLY: 5,
|
|
57
|
+
} as const
|
|
58
|
+
|
|
59
|
+
export const AlignItems = {
|
|
60
|
+
STRETCH: 0,
|
|
61
|
+
FLEX_START: 1,
|
|
62
|
+
CENTER: 2,
|
|
63
|
+
FLEX_END: 3,
|
|
64
|
+
BASELINE: 4,
|
|
65
|
+
} as const
|
|
66
|
+
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// POSITIONING
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
export const Position = {
|
|
72
|
+
RELATIVE: 0, // Normal flow, offsets relative to normal position
|
|
73
|
+
ABSOLUTE: 1, // Out of flow, positioned relative to nearest positioned ancestor
|
|
74
|
+
FIXED: 2, // Out of flow, positioned relative to terminal viewport
|
|
75
|
+
STICKY: 3, // Hybrid - relative until scroll threshold, then fixed
|
|
76
|
+
} as const
|
|
77
|
+
|
|
78
|
+
// =============================================================================
|
|
79
|
+
// DISPLAY MODE (for future Grid support)
|
|
80
|
+
// =============================================================================
|
|
81
|
+
|
|
82
|
+
export const Display = {
|
|
83
|
+
FLEX: 0, // Flexbox layout
|
|
84
|
+
GRID: 1, // Grid layout (future)
|
|
85
|
+
NONE: 2, // Hidden, not in layout
|
|
86
|
+
} as const
|
|
87
|
+
|
|
88
|
+
// =============================================================================
|
|
89
|
+
// OVERFLOW (for scroll containers)
|
|
90
|
+
// =============================================================================
|
|
91
|
+
|
|
92
|
+
export const Overflow = {
|
|
93
|
+
VISIBLE: 0, // Content can overflow
|
|
94
|
+
HIDDEN: 1, // Content clipped
|
|
95
|
+
SCROLL: 2, // Scrollable
|
|
96
|
+
AUTO: 3, // Scroll only if needed
|
|
97
|
+
} as const
|
|
98
|
+
|
|
99
|
+
// =============================================================================
|
|
100
|
+
// WORKING DATA - Pure Parallel Arrays
|
|
101
|
+
// =============================================================================
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* All working data for layout computation.
|
|
105
|
+
* Every array is indexed by component index.
|
|
106
|
+
* Pre-allocated for performance, reset each layout pass.
|
|
107
|
+
*/
|
|
108
|
+
export interface WorkingData {
|
|
109
|
+
// Hierarchy (computed once per layout)
|
|
110
|
+
depth: number[] // Nesting depth (root = 0)
|
|
111
|
+
childStart: number[] // Index into childrenOrdered
|
|
112
|
+
childCount: number[] // Number of direct children
|
|
113
|
+
|
|
114
|
+
// Flex working data
|
|
115
|
+
flexBaseSize: number[] // Initial size before flex
|
|
116
|
+
hypotheticalMainSize: number[] // Clamped by min/max
|
|
117
|
+
targetMainSize: number[] // Size after flex distribution
|
|
118
|
+
targetCrossSize: number[] // Cross axis size
|
|
119
|
+
frozen: Uint8Array // 0 = unfrozen, 1 = frozen
|
|
120
|
+
|
|
121
|
+
// Final computed values
|
|
122
|
+
computedX: number[]
|
|
123
|
+
computedY: number[]
|
|
124
|
+
computedWidth: number[]
|
|
125
|
+
computedHeight: number[]
|
|
126
|
+
|
|
127
|
+
// Scroll detection
|
|
128
|
+
contentWidth: number[] // Total content width
|
|
129
|
+
contentHeight: number[] // Total content height
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Line data for flex wrap.
|
|
134
|
+
* Stored separately since number of lines varies per container.
|
|
135
|
+
*/
|
|
136
|
+
export interface LineData {
|
|
137
|
+
// Per-container line info
|
|
138
|
+
lineStart: number[] // Index into items array where lines begin
|
|
139
|
+
lineCount: number[] // Number of lines in this container
|
|
140
|
+
|
|
141
|
+
// Flattened line items (all lines concatenated)
|
|
142
|
+
items: number[][] // items[lineIndex] = array of component indices
|
|
143
|
+
|
|
144
|
+
// Per-line computed data
|
|
145
|
+
lineCrossSize: number[] // Cross size of each line
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// =============================================================================
|
|
149
|
+
// HELPER TYPES
|
|
150
|
+
// =============================================================================
|
|
151
|
+
|
|
152
|
+
export type FlexDirectionValue = typeof FlexDirection[keyof typeof FlexDirection]
|
|
153
|
+
export type FlexWrapValue = typeof FlexWrap[keyof typeof FlexWrap]
|
|
154
|
+
export type JustifyContentValue = typeof JustifyContent[keyof typeof JustifyContent]
|
|
155
|
+
export type AlignItemsValue = typeof AlignItems[keyof typeof AlignItems]
|
|
156
|
+
export type PositionValue = typeof Position[keyof typeof Position]
|
|
157
|
+
export type DisplayValue = typeof Display[keyof typeof Display]
|
|
158
|
+
export type OverflowValue = typeof Overflow[keyof typeof Overflow]
|
|
159
|
+
|
|
160
|
+
// =============================================================================
|
|
161
|
+
// ANIMATION TYPES (built into architecture from day 1)
|
|
162
|
+
// =============================================================================
|
|
163
|
+
|
|
164
|
+
export interface TransitionConfig {
|
|
165
|
+
property: string // Which property to animate
|
|
166
|
+
duration: number // Duration in ms
|
|
167
|
+
easing: EasingFunction // Easing function
|
|
168
|
+
delay: number // Delay before start
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type EasingFunction =
|
|
172
|
+
| 'linear'
|
|
173
|
+
| 'ease'
|
|
174
|
+
| 'ease-in'
|
|
175
|
+
| 'ease-out'
|
|
176
|
+
| 'ease-in-out'
|
|
177
|
+
| 'step-start'
|
|
178
|
+
| 'step-end'
|
|
179
|
+
| ((t: number) => number) // Custom easing function
|
|
180
|
+
|
|
181
|
+
// Animation state per component (for future use)
|
|
182
|
+
export interface AnimationState {
|
|
183
|
+
startValue: number
|
|
184
|
+
endValue: number
|
|
185
|
+
startTime: number
|
|
186
|
+
duration: number
|
|
187
|
+
easing: EasingFunction
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// =============================================================================
|
|
191
|
+
// CONSTANTS
|
|
192
|
+
// =============================================================================
|
|
193
|
+
|
|
194
|
+
export const INFINITY = 999999 // Terminal has finite size, use large number
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Hierarchy Utilities
|
|
3
|
+
*
|
|
4
|
+
* Builds hierarchy data from parallel arrays for efficient iteration.
|
|
5
|
+
* NO recursion - just flat loops over arrays.
|
|
6
|
+
*
|
|
7
|
+
* Key insight: Instead of walking a tree, we:
|
|
8
|
+
* 1. Calculate depth for each component (one loop)
|
|
9
|
+
* 2. Group children by parent (one loop)
|
|
10
|
+
* 3. Build ordered children array (one loop)
|
|
11
|
+
*
|
|
12
|
+
* Result: O(n) regardless of nesting depth.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { unwrap } from '@rlabs-inc/signals'
|
|
16
|
+
import * as core from '../../../engine/arrays/core'
|
|
17
|
+
import * as layout from '../../../engine/arrays/layout'
|
|
18
|
+
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// HIERARCHY DATA (reused across layouts)
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
// Depth of each component (root = 0)
|
|
24
|
+
export const depth: number[] = []
|
|
25
|
+
|
|
26
|
+
// Children organization
|
|
27
|
+
export const childStart: number[] = [] // Index into childrenOrdered
|
|
28
|
+
export const childCount: number[] = [] // Number of direct children
|
|
29
|
+
|
|
30
|
+
// Flat array of all children, ordered by parent
|
|
31
|
+
export const childrenOrdered: number[] = []
|
|
32
|
+
|
|
33
|
+
// Root components (parentIndex === -1)
|
|
34
|
+
export const roots: number[] = []
|
|
35
|
+
|
|
36
|
+
// Components sorted by depth (for bottom-up/top-down iteration)
|
|
37
|
+
export const sortedByDepth: number[] = []
|
|
38
|
+
|
|
39
|
+
// Maximum depth in the tree
|
|
40
|
+
export let maxDepth = 0
|
|
41
|
+
|
|
42
|
+
// =============================================================================
|
|
43
|
+
// HIERARCHY PREPARATION
|
|
44
|
+
// =============================================================================
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Prepare hierarchy data for layout.
|
|
48
|
+
* Called once at the start of each layout pass.
|
|
49
|
+
*
|
|
50
|
+
* This replaces recursive tree traversal with flat array iteration.
|
|
51
|
+
*/
|
|
52
|
+
export function prepareHierarchy(indices: Set<number>): void {
|
|
53
|
+
// Reset arrays
|
|
54
|
+
roots.length = 0
|
|
55
|
+
childrenOrdered.length = 0
|
|
56
|
+
sortedByDepth.length = 0
|
|
57
|
+
maxDepth = 0
|
|
58
|
+
|
|
59
|
+
if (indices.size === 0) return
|
|
60
|
+
|
|
61
|
+
// Step 1: Calculate depth for each component
|
|
62
|
+
// This is the only "recursive-like" part, but we do it iteratively
|
|
63
|
+
for (const i of indices) {
|
|
64
|
+
let d = 0
|
|
65
|
+
let p = unwrap(core.parentIndex[i]) ?? -1
|
|
66
|
+
|
|
67
|
+
// Walk up to root, counting depth
|
|
68
|
+
while (p >= 0 && indices.has(p)) {
|
|
69
|
+
d++
|
|
70
|
+
p = unwrap(core.parentIndex[p]) ?? -1
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
depth[i] = d
|
|
74
|
+
if (d > maxDepth) maxDepth = d
|
|
75
|
+
|
|
76
|
+
// Track roots
|
|
77
|
+
if (d === 0) {
|
|
78
|
+
roots.push(i)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Step 2: Group children by parent
|
|
83
|
+
const childrenByParent = new Map<number, number[]>()
|
|
84
|
+
|
|
85
|
+
for (const i of indices) {
|
|
86
|
+
const parent = unwrap(core.parentIndex[i]) ?? -1
|
|
87
|
+
|
|
88
|
+
if (parent >= 0) {
|
|
89
|
+
let children = childrenByParent.get(parent)
|
|
90
|
+
if (!children) {
|
|
91
|
+
children = []
|
|
92
|
+
childrenByParent.set(parent, children)
|
|
93
|
+
}
|
|
94
|
+
children.push(i)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Step 3: Sort children within each parent by order property
|
|
99
|
+
for (const children of childrenByParent.values()) {
|
|
100
|
+
children.sort((a, b) => {
|
|
101
|
+
const orderA = unwrap(layout.order[a]) ?? 0
|
|
102
|
+
const orderB = unwrap(layout.order[b]) ?? 0
|
|
103
|
+
return orderA - orderB
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Step 4: Build childrenOrdered array and set childStart/childCount
|
|
108
|
+
let offset = 0
|
|
109
|
+
|
|
110
|
+
for (const i of indices) {
|
|
111
|
+
const children = childrenByParent.get(i)
|
|
112
|
+
|
|
113
|
+
if (children && children.length > 0) {
|
|
114
|
+
childStart[i] = offset
|
|
115
|
+
childCount[i] = children.length
|
|
116
|
+
|
|
117
|
+
for (const child of children) {
|
|
118
|
+
childrenOrdered[offset++] = child
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
childStart[i] = 0
|
|
122
|
+
childCount[i] = 0
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Step 5: Sort all indices by depth
|
|
127
|
+
for (const i of indices) {
|
|
128
|
+
sortedByDepth.push(i)
|
|
129
|
+
}
|
|
130
|
+
sortedByDepth.sort((a, b) => depth[a]! - depth[b]!)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get children of a component.
|
|
135
|
+
* Returns array slice from childrenOrdered.
|
|
136
|
+
*/
|
|
137
|
+
export function getChildren(index: number): number[] {
|
|
138
|
+
const start = childStart[index] ?? 0
|
|
139
|
+
const count = childCount[index] ?? 0
|
|
140
|
+
|
|
141
|
+
if (count === 0) return []
|
|
142
|
+
|
|
143
|
+
const result: number[] = []
|
|
144
|
+
for (let i = 0; i < count; i++) {
|
|
145
|
+
result.push(childrenOrdered[start + i]!)
|
|
146
|
+
}
|
|
147
|
+
return result
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Check if component has children.
|
|
152
|
+
*/
|
|
153
|
+
export function hasChildren(index: number): boolean {
|
|
154
|
+
return (childCount[index] ?? 0) > 0
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get depth of a component.
|
|
159
|
+
*/
|
|
160
|
+
export function getDepth(index: number): number {
|
|
161
|
+
return depth[index] ?? 0
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Iterate components in depth order (roots first, then children).
|
|
166
|
+
* Useful for top-down traversal.
|
|
167
|
+
*/
|
|
168
|
+
export function* iterateTopDown(): Generator<number> {
|
|
169
|
+
for (const i of sortedByDepth) {
|
|
170
|
+
yield i
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Iterate components in reverse depth order (deepest first).
|
|
176
|
+
* Useful for bottom-up traversal (content sizing).
|
|
177
|
+
*/
|
|
178
|
+
export function* iterateBottomUp(): Generator<number> {
|
|
179
|
+
for (let i = sortedByDepth.length - 1; i >= 0; i--) {
|
|
180
|
+
yield sortedByDepth[i]!
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get the root components.
|
|
186
|
+
*/
|
|
187
|
+
export function getRoots(): number[] {
|
|
188
|
+
return roots
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Reset hierarchy data (called when clearing layout).
|
|
193
|
+
*/
|
|
194
|
+
export function resetHierarchy(): void {
|
|
195
|
+
depth.length = 0
|
|
196
|
+
childStart.length = 0
|
|
197
|
+
childCount.length = 0
|
|
198
|
+
childrenOrdered.length = 0
|
|
199
|
+
roots.length = 0
|
|
200
|
+
sortedByDepth.length = 0
|
|
201
|
+
maxDepth = 0
|
|
202
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Layout Math Utilities
|
|
3
|
+
*
|
|
4
|
+
* Integer-only math for terminal cells.
|
|
5
|
+
* No sub-pixel nonsense - terminals work in discrete cells.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Clamp a value between min and max.
|
|
10
|
+
* Returns integer.
|
|
11
|
+
*/
|
|
12
|
+
export function clamp(min: number, value: number, max: number): number {
|
|
13
|
+
if (value < min) return min
|
|
14
|
+
if (value > max) return max
|
|
15
|
+
return value
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Clamp and round to integer.
|
|
20
|
+
* Use at final step when writing to computed arrays.
|
|
21
|
+
*/
|
|
22
|
+
export function clampInt(min: number, value: number, max: number): number {
|
|
23
|
+
return Math.round(clamp(min, value, max))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Round to nearest integer.
|
|
28
|
+
* Always round at the end, not during intermediate calculations.
|
|
29
|
+
*/
|
|
30
|
+
export function round(value: number): number {
|
|
31
|
+
return Math.round(value)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Floor to integer.
|
|
36
|
+
* Use for flex grow to avoid overflow.
|
|
37
|
+
*/
|
|
38
|
+
export function floor(value: number): number {
|
|
39
|
+
return Math.floor(value)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Ceiling to integer.
|
|
44
|
+
* Use for flex shrink to ensure minimum coverage.
|
|
45
|
+
*/
|
|
46
|
+
export function ceil(value: number): number {
|
|
47
|
+
return Math.ceil(value)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if a number is effectively zero (within epsilon).
|
|
52
|
+
*/
|
|
53
|
+
export function isZero(value: number, epsilon = 0.001): boolean {
|
|
54
|
+
return Math.abs(value) < epsilon
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Distribute a total amount across N items proportionally.
|
|
59
|
+
* Returns array of integer amounts that sum to exactly `total`.
|
|
60
|
+
*
|
|
61
|
+
* Uses largest remainder method to ensure exact distribution.
|
|
62
|
+
*/
|
|
63
|
+
export function distributeProportionally(
|
|
64
|
+
total: number,
|
|
65
|
+
weights: number[]
|
|
66
|
+
): number[] {
|
|
67
|
+
if (weights.length === 0) return []
|
|
68
|
+
|
|
69
|
+
const totalWeight = weights.reduce((a, b) => a + b, 0)
|
|
70
|
+
if (totalWeight === 0) {
|
|
71
|
+
// Equal distribution if no weights
|
|
72
|
+
const base = Math.floor(total / weights.length)
|
|
73
|
+
const remainder = total - base * weights.length
|
|
74
|
+
return weights.map((_, i) => base + (i < remainder ? 1 : 0))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Calculate exact (floating) shares
|
|
78
|
+
const exact = weights.map(w => (total * w) / totalWeight)
|
|
79
|
+
|
|
80
|
+
// Floor each share
|
|
81
|
+
const floored = exact.map(Math.floor)
|
|
82
|
+
|
|
83
|
+
// Calculate remainders
|
|
84
|
+
const remainders = exact.map((e, i) => ({
|
|
85
|
+
index: i,
|
|
86
|
+
remainder: e - floored[i]!,
|
|
87
|
+
}))
|
|
88
|
+
|
|
89
|
+
// Sort by remainder descending
|
|
90
|
+
remainders.sort((a, b) => b.remainder - a.remainder)
|
|
91
|
+
|
|
92
|
+
// Distribute leftover to items with largest remainders
|
|
93
|
+
const distributed = total - floored.reduce((a, b) => a + b, 0)
|
|
94
|
+
for (let i = 0; i < distributed; i++) {
|
|
95
|
+
floored[remainders[i]!.index]!++
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return floored
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Sum an array of numbers.
|
|
103
|
+
*/
|
|
104
|
+
export function sum(arr: number[]): number {
|
|
105
|
+
let total = 0
|
|
106
|
+
for (let i = 0; i < arr.length; i++) {
|
|
107
|
+
total += arr[i]!
|
|
108
|
+
}
|
|
109
|
+
return total
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Find maximum in an array.
|
|
114
|
+
*/
|
|
115
|
+
export function max(arr: number[]): number {
|
|
116
|
+
if (arr.length === 0) return 0
|
|
117
|
+
let m = arr[0]!
|
|
118
|
+
for (let i = 1; i < arr.length; i++) {
|
|
119
|
+
if (arr[i]! > m) m = arr[i]!
|
|
120
|
+
}
|
|
121
|
+
return m
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Find minimum in an array.
|
|
126
|
+
*/
|
|
127
|
+
export function min(arr: number[]): number {
|
|
128
|
+
if (arr.length === 0) return 0
|
|
129
|
+
let m = arr[0]!
|
|
130
|
+
for (let i = 1; i < arr.length; i++) {
|
|
131
|
+
if (arr[i]! < m) m = arr[i]!
|
|
132
|
+
}
|
|
133
|
+
return m
|
|
134
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Text Measurement
|
|
3
|
+
*
|
|
4
|
+
* Terminal text measurement using Bun.stringWidth.
|
|
5
|
+
* Handles Unicode, emoji, CJK wide characters correctly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { stringWidth } from '../../../utils/text'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Measure the width of a string in terminal cells.
|
|
12
|
+
* Handles:
|
|
13
|
+
* - Regular ASCII (1 cell each)
|
|
14
|
+
* - Emoji (usually 2 cells)
|
|
15
|
+
* - CJK characters (2 cells)
|
|
16
|
+
* - Control characters (0 cells)
|
|
17
|
+
*/
|
|
18
|
+
export function measureTextWidth(content: string): number {
|
|
19
|
+
return stringWidth(content)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Measure text height when wrapped to a maximum width.
|
|
24
|
+
* Returns number of lines.
|
|
25
|
+
*/
|
|
26
|
+
export function measureTextHeight(content: string, maxWidth: number): number {
|
|
27
|
+
if (!content || maxWidth <= 0) return 0
|
|
28
|
+
|
|
29
|
+
// Simple word wrap - count lines
|
|
30
|
+
let lines = 1
|
|
31
|
+
let currentLineWidth = 0
|
|
32
|
+
|
|
33
|
+
// Split by existing newlines first
|
|
34
|
+
const paragraphs = content.split('\n')
|
|
35
|
+
|
|
36
|
+
for (const paragraph of paragraphs) {
|
|
37
|
+
if (paragraph === '') {
|
|
38
|
+
lines++
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Process each character
|
|
43
|
+
for (const char of paragraph) {
|
|
44
|
+
const charWidth = stringWidth(char)
|
|
45
|
+
|
|
46
|
+
if (currentLineWidth + charWidth > maxWidth && currentLineWidth > 0) {
|
|
47
|
+
lines++
|
|
48
|
+
currentLineWidth = charWidth
|
|
49
|
+
} else {
|
|
50
|
+
currentLineWidth += charWidth
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Reset for next paragraph
|
|
55
|
+
currentLineWidth = 0
|
|
56
|
+
if (paragraphs.indexOf(paragraph) < paragraphs.length - 1) {
|
|
57
|
+
lines++
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return lines
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Measure text dimensions for layout.
|
|
66
|
+
* Returns both width (longest line) and height (number of lines).
|
|
67
|
+
*/
|
|
68
|
+
export function measureText(
|
|
69
|
+
content: string,
|
|
70
|
+
maxWidth: number
|
|
71
|
+
): { width: number; height: number } {
|
|
72
|
+
if (!content) return { width: 0, height: 0 }
|
|
73
|
+
|
|
74
|
+
const lines = wrapTextLines(content, maxWidth)
|
|
75
|
+
let maxLineWidth = 0
|
|
76
|
+
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
const lineWidth = stringWidth(line)
|
|
79
|
+
if (lineWidth > maxLineWidth) {
|
|
80
|
+
maxLineWidth = lineWidth
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
width: maxLineWidth,
|
|
86
|
+
height: lines.length,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Wrap text to a maximum width, returning array of lines.
|
|
92
|
+
* Simple word-boundary wrapping with fallback to character wrap.
|
|
93
|
+
*/
|
|
94
|
+
export function wrapTextLines(content: string, maxWidth: number): string[] {
|
|
95
|
+
if (!content) return []
|
|
96
|
+
if (maxWidth <= 0) return [content]
|
|
97
|
+
|
|
98
|
+
const result: string[] = []
|
|
99
|
+
const paragraphs = content.split('\n')
|
|
100
|
+
|
|
101
|
+
for (const paragraph of paragraphs) {
|
|
102
|
+
if (paragraph === '') {
|
|
103
|
+
result.push('')
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let currentLine = ''
|
|
108
|
+
let currentWidth = 0
|
|
109
|
+
const words = paragraph.split(' ')
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < words.length; i++) {
|
|
112
|
+
const word = words[i]!
|
|
113
|
+
const wordWidth = stringWidth(word)
|
|
114
|
+
|
|
115
|
+
// Check if word fits on current line
|
|
116
|
+
const needsSpace = currentLine.length > 0
|
|
117
|
+
const spaceWidth = needsSpace ? 1 : 0
|
|
118
|
+
|
|
119
|
+
if (currentWidth + spaceWidth + wordWidth <= maxWidth) {
|
|
120
|
+
// Word fits
|
|
121
|
+
if (needsSpace) {
|
|
122
|
+
currentLine += ' '
|
|
123
|
+
currentWidth += 1
|
|
124
|
+
}
|
|
125
|
+
currentLine += word
|
|
126
|
+
currentWidth += wordWidth
|
|
127
|
+
} else if (currentLine.length === 0) {
|
|
128
|
+
// Word is too long for any line - character wrap
|
|
129
|
+
let remaining = word
|
|
130
|
+
while (remaining.length > 0) {
|
|
131
|
+
let chunk = ''
|
|
132
|
+
let chunkWidth = 0
|
|
133
|
+
for (const char of remaining) {
|
|
134
|
+
const charWidth = stringWidth(char)
|
|
135
|
+
if (chunkWidth + charWidth > maxWidth && chunk.length > 0) {
|
|
136
|
+
break
|
|
137
|
+
}
|
|
138
|
+
chunk += char
|
|
139
|
+
chunkWidth += charWidth
|
|
140
|
+
}
|
|
141
|
+
result.push(chunk)
|
|
142
|
+
remaining = remaining.slice(chunk.length)
|
|
143
|
+
}
|
|
144
|
+
currentLine = ''
|
|
145
|
+
currentWidth = 0
|
|
146
|
+
} else {
|
|
147
|
+
// Start new line
|
|
148
|
+
result.push(currentLine)
|
|
149
|
+
currentLine = word
|
|
150
|
+
currentWidth = wordWidth
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (currentLine.length > 0) {
|
|
155
|
+
result.push(currentLine)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return result.length > 0 ? result : ['']
|
|
160
|
+
}
|