Skip to content

Commit ed31b95

Browse files
committed
feat(opentui): add TypeScript types, JS wrapper, and integration tests
- lib/native.d.ts: Full type definitions for all 248 node-api bindings - lib/index.mjs: Platform-detecting loader with RGBA class and enums - lib/index.d.ts: Type declarations for the wrapper module - test/bindings.test.mjs: 25 integration tests covering module loading, renderer, buffer, text buffer, edit buffer, editor view, syntax style, and links - Updated package.json exports with proper entry points - Fixed vitest config to only run package tests
1 parent 7fb3ccd commit ed31b95

6 files changed

Lines changed: 844 additions & 1 deletion

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type OpenTUIBindings from './native.js'
2+
3+
export type { NativePointer } from './native.js'
4+
5+
export declare const native: OpenTUIBindings
6+
7+
export declare class RGBA {
8+
buffer: Float32Array
9+
r: number
10+
g: number
11+
b: number
12+
a: number
13+
constructor(r: number, g: number, b: number, a?: number)
14+
static fromValues(r: number, g: number, b: number, a?: number): RGBA
15+
static fromInts(r: number, g: number, b: number, a?: number): RGBA
16+
static fromHex(hex: string): RGBA
17+
static fromArray(array: Float32Array | number[]): RGBA
18+
toInts(): [number, number, number, number]
19+
toHex(): string
20+
equals(other: RGBA | undefined): boolean
21+
toString(): string
22+
}
23+
24+
export declare const WidthMethod: {
25+
readonly WCWIDTH: 0
26+
readonly UNICODE: 1
27+
readonly NO_ZWJ: 2
28+
}
29+
30+
export declare const WrapMode: {
31+
readonly NONE: 0
32+
readonly CHAR: 1
33+
readonly WORD: 2
34+
}
35+
36+
export declare const TextAttributes: {
37+
readonly NONE: 0
38+
readonly BOLD: 1
39+
readonly DIM: 2
40+
readonly ITALIC: 4
41+
readonly UNDERLINE: 8
42+
readonly BLINK: 16
43+
readonly INVERSE: 32
44+
readonly HIDDEN: 64
45+
readonly STRIKETHROUGH: 128
46+
}
47+
48+
export declare const ATTRIBUTE_BASE_BITS: 8
49+
export declare const ATTRIBUTE_BASE_MASK: 0xff
50+
51+
export declare const DebugOverlayCorner: {
52+
readonly TOP_LEFT: 0
53+
readonly TOP_RIGHT: 1
54+
readonly BOTTOM_LEFT: 2
55+
readonly BOTTOM_RIGHT: 3
56+
}
57+
58+
export declare const TargetChannel: {
59+
readonly FG: 1
60+
readonly BG: 2
61+
readonly BOTH: 3
62+
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { existsSync } from 'node:fs'
2+
import { createRequire } from 'node:module'
3+
import path from 'node:path'
4+
import process from 'node:process'
5+
import { fileURLToPath } from 'node:url'
6+
7+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
8+
const esmRequire = createRequire(import.meta.url)
9+
10+
const PLATFORM_MAP = {
11+
__proto__: null,
12+
darwin: { __proto__: null, arm64: 'aarch64-macos', x64: 'x86_64-macos' },
13+
linux: {
14+
__proto__: null,
15+
arm64: 'aarch64-linux-gnu',
16+
arm64_musl: 'aarch64-linux-musl',
17+
x64: 'x86_64-linux-gnu',
18+
x64_musl: 'x86_64-linux-musl',
19+
},
20+
win32: { __proto__: null, arm64: 'aarch64-windows-gnu', x64: 'x86_64-windows-gnu' },
21+
}
22+
23+
const EXT_MAP = { __proto__: null, darwin: 'dylib', linux: 'so', win32: 'dll' }
24+
const PREFIX_MAP = { __proto__: null, darwin: 'lib', linux: 'lib', win32: '' }
25+
26+
const PLATFORM_ARCH_MAP = {
27+
__proto__: null,
28+
darwin: 'darwin',
29+
linux: 'linux',
30+
win32: 'win',
31+
}
32+
33+
function detectMusl() {
34+
if (process.platform !== 'linux') return false
35+
try {
36+
// Node reports musl libc via process.report
37+
const report = process.report?.getReport()
38+
if (typeof report === 'object' && report !== undefined) {
39+
const header = report.header
40+
if (header && typeof header.glibcVersionRuntime === 'string') {
41+
return false
42+
}
43+
}
44+
} catch {}
45+
// Fallback: check if ldd is musl-based
46+
try {
47+
const { execFileSync } = require('node:child_process')
48+
const lddOutput = execFileSync('ldd', ['--version'], {
49+
encoding: 'utf8',
50+
stdio: ['pipe', 'pipe', 'pipe'],
51+
})
52+
if (/musl/i.test(lddOutput)) return true
53+
} catch (e) {
54+
if (e && typeof e.stderr === 'string' && /musl/i.test(e.stderr)) return true
55+
}
56+
return false
57+
}
58+
59+
function loadNativeModule() {
60+
const { platform, arch } = process
61+
const platformMap = PLATFORM_MAP[platform]
62+
if (!platformMap) {
63+
throw new Error(`Unsupported platform: ${platform}`)
64+
}
65+
66+
const isMusl = detectMusl()
67+
const archKey = isMusl ? `${arch}_musl` : arch
68+
const zigTarget = platformMap[archKey] ?? platformMap[arch]
69+
if (!zigTarget) {
70+
throw new Error(`Unsupported architecture: ${platform}-${arch}`)
71+
}
72+
73+
const osPart = PLATFORM_ARCH_MAP[platform]
74+
const platformArch = `${osPart}-${arch}`
75+
76+
const candidates = [
77+
path.join(__dirname, '..', 'build', 'dev', platformArch, 'out', platformArch, 'opentui.node'),
78+
path.join(__dirname, '..', 'build', 'prod', platformArch, 'out', platformArch, 'opentui.node'),
79+
]
80+
81+
for (const candidate of candidates) {
82+
if (existsSync(candidate)) {
83+
return esmRequire(candidate)
84+
}
85+
}
86+
87+
// Raw shared library via dlopen (lib/ directory contains .dylib/.so/.dll
88+
// built with napi symbols, loadable directly)
89+
const ext = EXT_MAP[platform]
90+
const prefix = PREFIX_MAP[platform]
91+
const libPath = path.join(__dirname, zigTarget, `${prefix}opentui.${ext}`)
92+
if (existsSync(libPath)) {
93+
const mod = { __proto__: null, exports: { __proto__: null } }
94+
process.dlopen(mod, libPath)
95+
return mod.exports
96+
}
97+
98+
throw new Error(
99+
`OpenTUI native module not found. Searched:\n${[...candidates, libPath].join('\n')}\nRun "pnpm --filter opentui-builder build" to compile.`,
100+
)
101+
}
102+
103+
export const native = loadNativeModule()
104+
105+
export const WidthMethod = { __proto__: null, WCWIDTH: 0, UNICODE: 1, NO_ZWJ: 2 }
106+
107+
export const WrapMode = { __proto__: null, NONE: 0, CHAR: 1, WORD: 2 }
108+
109+
export const TextAttributes = {
110+
__proto__: null,
111+
NONE: 0,
112+
BOLD: 1,
113+
DIM: 2,
114+
ITALIC: 4,
115+
UNDERLINE: 8,
116+
BLINK: 16,
117+
INVERSE: 32,
118+
HIDDEN: 64,
119+
STRIKETHROUGH: 128,
120+
}
121+
122+
export const ATTRIBUTE_BASE_BITS = 8
123+
export const ATTRIBUTE_BASE_MASK = 0xff
124+
125+
export class RGBA {
126+
constructor(r, g, b, a = 1) {
127+
this.buffer = new Float32Array([r, g, b, a])
128+
}
129+
130+
get r() { return this.buffer[0] }
131+
set r(v) { this.buffer[0] = v }
132+
133+
get g() { return this.buffer[1] }
134+
set g(v) { this.buffer[1] = v }
135+
136+
get b() { return this.buffer[2] }
137+
set b(v) { this.buffer[2] = v }
138+
139+
get a() { return this.buffer[3] }
140+
set a(v) { this.buffer[3] = v }
141+
142+
static fromValues(r, g, b, a = 1) {
143+
return new RGBA(r, g, b, a)
144+
}
145+
146+
static fromInts(r, g, b, a = 255) {
147+
return new RGBA(r / 255, g / 255, b / 255, a / 255)
148+
}
149+
150+
static fromHex(hex) {
151+
hex = hex.replace(/^#/, '')
152+
if (hex.length === 3) {
153+
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
154+
} else if (hex.length === 4) {
155+
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
156+
}
157+
if (!/^[0-9A-Fa-f]{6}$/.test(hex) && !/^[0-9A-Fa-f]{8}$/.test(hex)) {
158+
return new RGBA(1, 0, 1, 1)
159+
}
160+
const r = parseInt(hex.substring(0, 2), 16) / 255
161+
const g = parseInt(hex.substring(2, 4), 16) / 255
162+
const b = parseInt(hex.substring(4, 6), 16) / 255
163+
const a = hex.length === 8 ? parseInt(hex.substring(6, 8), 16) / 255 : 1
164+
return new RGBA(r, g, b, a)
165+
}
166+
167+
static fromArray(array) {
168+
const rgba = new RGBA(0, 0, 0, 1)
169+
rgba.buffer = array instanceof Float32Array ? array : new Float32Array(array)
170+
return rgba
171+
}
172+
173+
toInts() {
174+
return [
175+
Math.round(this.r * 255),
176+
Math.round(this.g * 255),
177+
Math.round(this.b * 255),
178+
Math.round(this.a * 255),
179+
]
180+
}
181+
182+
toHex() {
183+
const components = this.a === 1
184+
? [this.r, this.g, this.b]
185+
: [this.r, this.g, this.b, this.a]
186+
return (
187+
'#' +
188+
components
189+
.map(x => {
190+
const h = Math.floor(Math.max(0, Math.min(1, x) * 255)).toString(16)
191+
return h.length === 1 ? '0' + h : h
192+
})
193+
.join('')
194+
)
195+
}
196+
197+
equals(other) {
198+
if (!other) return false
199+
return this.r === other.r && this.g === other.g && this.b === other.b && this.a === other.a
200+
}
201+
202+
toString() {
203+
return `rgba(${this.r.toFixed(2)}, ${this.g.toFixed(2)}, ${this.b.toFixed(2)}, ${this.a.toFixed(2)})`
204+
}
205+
}
206+
207+
export const DebugOverlayCorner = {
208+
__proto__: null,
209+
TOP_LEFT: 0,
210+
TOP_RIGHT: 1,
211+
BOTTOM_LEFT: 2,
212+
BOTTOM_RIGHT: 3,
213+
}
214+
215+
export const TargetChannel = {
216+
__proto__: null,
217+
FG: 1,
218+
BG: 2,
219+
BOTH: 3,
220+
}

0 commit comments

Comments
 (0)