@rlabs-inc/tui 0.3.2 → 0.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.
@@ -72,6 +72,15 @@ export {
72
72
  export * from './scroll'
73
73
  export * from './cursor'
74
74
 
75
+ // Drawn cursor - for input components (style, blink, colors)
76
+ export {
77
+ createCursor,
78
+ disposeCursor,
79
+ getCursorCharCode,
80
+ hasCursor,
81
+ } from './drawnCursor'
82
+ export type { DrawnCursorStyle, DrawnCursorConfig, DrawnCursor } from './drawnCursor'
83
+
75
84
  // Global keys - all shortcuts wired together
76
85
  export { globalKeys } from './global-keys'
77
86
 
@@ -66,8 +66,13 @@ const focusedHandlers = new Map<number, Set<KeyHandler>>()
66
66
  * Returns true if any handler consumed the event.
67
67
  */
68
68
  export function dispatch(event: KeyboardEvent): boolean {
69
+ // Always update reactive state (for monitoring)
69
70
  lastEvent.value = event
70
71
 
72
+ // Only dispatch press events to handlers
73
+ // (Kitty protocol sends press/repeat/release - we only want press)
74
+ if (event.state !== 'press') return false
75
+
71
76
  // Dispatch to key-specific handlers
72
77
  const handlers = keyHandlers.get(event.key)
73
78
  if (handlers) {
@@ -90,6 +95,7 @@ export function dispatch(event: KeyboardEvent): boolean {
90
95
  */
91
96
  export function dispatchFocused(focusedIndex: number, event: KeyboardEvent): boolean {
92
97
  if (focusedIndex < 0) return false
98
+ if (event.state !== 'press') return false
93
99
 
94
100
  const handlers = focusedHandlers.get(focusedIndex)
95
101
  if (!handlers) return false
@@ -17,7 +17,13 @@
17
17
 
18
18
  import { state, derived } from '@rlabs-inc/signals'
19
19
  import type { RGBA } from '../types'
20
- import { parseColor, TERMINAL_DEFAULT, ansiColor } from '../types/color'
20
+ import {
21
+ parseColor,
22
+ TERMINAL_DEFAULT,
23
+ ansiColor,
24
+ isAnsiColor,
25
+ adjustLightnessForContrast,
26
+ } from '../types/color'
21
27
 
22
28
  // =============================================================================
23
29
  // THEME COLOR TYPE
@@ -270,6 +276,222 @@ export const themes = {
270
276
  border: 0x586e75,
271
277
  borderFocus: 0x268bd2,
272
278
  },
279
+
280
+ /**
281
+ * Catppuccin Mocha - soothing pastel theme (most popular variant).
282
+ */
283
+ catppuccin: {
284
+ name: 'catppuccin',
285
+ description: 'Catppuccin Mocha theme',
286
+ primary: 0x89b4fa, // blue
287
+ secondary: 0xcba6f7, // mauve
288
+ tertiary: 0x94e2d5, // teal
289
+ accent: 0xf9e2af, // yellow
290
+ success: 0xa6e3a1, // green
291
+ warning: 0xf9e2af, // yellow
292
+ error: 0xf38ba8, // red
293
+ info: 0x89dceb, // sky
294
+ text: 0xcdd6f4, // text
295
+ textMuted: 0x6c7086, // overlay0
296
+ textDim: 0x585b70, // surface2
297
+ textDisabled: 0x45475a, // surface0
298
+ textBright: 0xffffff,
299
+ background: 0x1e1e2e, // base
300
+ backgroundMuted: 0x313244, // surface0
301
+ surface: 0x45475a, // surface1
302
+ overlay: 0x181825, // mantle
303
+ border: 0x6c7086, // overlay0
304
+ borderFocus: 0x89b4fa, // blue
305
+ },
306
+
307
+ /**
308
+ * Gruvbox Dark - retro groove color scheme.
309
+ */
310
+ gruvbox: {
311
+ name: 'gruvbox',
312
+ description: 'Gruvbox Dark theme',
313
+ primary: 0x458588, // blue
314
+ secondary: 0xb16286, // purple
315
+ tertiary: 0x689d6a, // aqua
316
+ accent: 0xd79921, // yellow
317
+ success: 0x98971a, // green
318
+ warning: 0xd79921, // yellow
319
+ error: 0xcc241d, // red
320
+ info: 0x458588, // blue
321
+ text: 0xebdbb2, // fg
322
+ textMuted: 0xa89984, // gray
323
+ textDim: 0x928374, // gray
324
+ textDisabled: 0x665c54, // bg3
325
+ textBright: 0xfbf1c7, // fg0
326
+ background: 0x282828, // bg
327
+ backgroundMuted: 0x3c3836, // bg1
328
+ surface: 0x504945, // bg2
329
+ overlay: 0x1d2021, // bg0_h
330
+ border: 0x665c54, // bg3
331
+ borderFocus: 0xfe8019, // orange
332
+ },
333
+
334
+ /**
335
+ * Tokyo Night - clean, dark theme inspired by Tokyo city lights.
336
+ */
337
+ tokyoNight: {
338
+ name: 'tokyoNight',
339
+ description: 'Tokyo Night theme',
340
+ primary: 0x7aa2f7, // blue
341
+ secondary: 0xbb9af7, // purple
342
+ tertiary: 0x7dcfff, // cyan
343
+ accent: 0xe0af68, // yellow
344
+ success: 0x9ece6a, // green
345
+ warning: 0xe0af68, // yellow
346
+ error: 0xf7768e, // red
347
+ info: 0x7dcfff, // cyan
348
+ text: 0xa9b1d6, // fg
349
+ textMuted: 0x565f89, // comment
350
+ textDim: 0x414868, // dark3
351
+ textDisabled: 0x3b4261, // dark2
352
+ textBright: 0xc0caf5, // fg_bright
353
+ background: 0x1a1b26, // bg
354
+ backgroundMuted: 0x24283b, // bg_highlight
355
+ surface: 0x414868, // dark3
356
+ overlay: 0x16161e, // bg_dark
357
+ border: 0x414868, // dark3
358
+ borderFocus: 0x7aa2f7, // blue
359
+ },
360
+
361
+ /**
362
+ * One Dark - Atom's iconic dark theme.
363
+ */
364
+ oneDark: {
365
+ name: 'oneDark',
366
+ description: 'One Dark (Atom) theme',
367
+ primary: 0x61afef, // blue
368
+ secondary: 0xc678dd, // purple
369
+ tertiary: 0x56b6c2, // cyan
370
+ accent: 0xe5c07b, // yellow
371
+ success: 0x98c379, // green
372
+ warning: 0xe5c07b, // yellow
373
+ error: 0xe06c75, // red
374
+ info: 0x56b6c2, // cyan
375
+ text: 0xabb2bf, // fg
376
+ textMuted: 0x5c6370, // comment
377
+ textDim: 0x4b5263, // gutter
378
+ textDisabled: 0x3e4451, // guide
379
+ textBright: 0xffffff,
380
+ background: 0x282c34, // bg
381
+ backgroundMuted: 0x21252b, // bg_dark
382
+ surface: 0x3e4451, // guide
383
+ overlay: 0x1e2127, // bg_darker
384
+ border: 0x3e4451, // guide
385
+ borderFocus: 0x61afef, // blue
386
+ },
387
+
388
+ /**
389
+ * Rosé Pine - all natural pine, faux fur and a bit of soho vibes.
390
+ */
391
+ rosePine: {
392
+ name: 'rosePine',
393
+ description: 'Rosé Pine theme',
394
+ primary: 0x9ccfd8, // foam
395
+ secondary: 0xc4a7e7, // iris
396
+ tertiary: 0x31748f, // pine
397
+ accent: 0xf6c177, // gold
398
+ success: 0x31748f, // pine
399
+ warning: 0xf6c177, // gold
400
+ error: 0xeb6f92, // love
401
+ info: 0x9ccfd8, // foam
402
+ text: 0xe0def4, // text
403
+ textMuted: 0x908caa, // subtle
404
+ textDim: 0x6e6a86, // muted
405
+ textDisabled: 0x524f67, // highlight_med
406
+ textBright: 0xffffff,
407
+ background: 0x191724, // base
408
+ backgroundMuted: 0x1f1d2e, // surface
409
+ surface: 0x26233a, // overlay
410
+ overlay: 0x16141f, // nc
411
+ border: 0x524f67, // highlight_med
412
+ borderFocus: 0xebbcba, // rose
413
+ },
414
+
415
+ /**
416
+ * Kanagawa - theme inspired by Katsushika Hokusai's famous wave painting.
417
+ */
418
+ kanagawa: {
419
+ name: 'kanagawa',
420
+ description: 'Kanagawa wave theme',
421
+ primary: 0x7e9cd8, // crystalBlue
422
+ secondary: 0x957fb8, // oniViolet
423
+ tertiary: 0x7aa89f, // waveAqua2
424
+ accent: 0xdca561, // carpYellow
425
+ success: 0x98bb6c, // springGreen
426
+ warning: 0xdca561, // carpYellow
427
+ error: 0xc34043, // autumnRed
428
+ info: 0x7fb4ca, // springBlue
429
+ text: 0xdcd7ba, // fujiWhite
430
+ textMuted: 0x727169, // fujiGray
431
+ textDim: 0x54546d, // sumiInk4
432
+ textDisabled: 0x363646, // sumiInk3
433
+ textBright: 0xffffff,
434
+ background: 0x1f1f28, // sumiInk1
435
+ backgroundMuted: 0x2a2a37, // sumiInk2
436
+ surface: 0x363646, // sumiInk3
437
+ overlay: 0x16161d, // sumiInk0
438
+ border: 0x54546d, // sumiInk4
439
+ borderFocus: 0x7e9cd8, // crystalBlue
440
+ },
441
+
442
+ /**
443
+ * Everforest - comfortable green-tinted theme.
444
+ */
445
+ everforest: {
446
+ name: 'everforest',
447
+ description: 'Everforest theme',
448
+ primary: 0x7fbbb3, // aqua
449
+ secondary: 0xd699b6, // purple
450
+ tertiary: 0x83c092, // green
451
+ accent: 0xdbbc7f, // yellow
452
+ success: 0xa7c080, // green
453
+ warning: 0xdbbc7f, // yellow
454
+ error: 0xe67e80, // red
455
+ info: 0x7fbbb3, // aqua
456
+ text: 0xd3c6aa, // fg
457
+ textMuted: 0x9da9a0, // grey1
458
+ textDim: 0x859289, // grey0
459
+ textDisabled: 0x5c6a72, // bg5
460
+ textBright: 0xfdf6e3,
461
+ background: 0x2d353b, // bg_dim
462
+ backgroundMuted: 0x343f44, // bg1
463
+ surface: 0x3d484d, // bg3
464
+ overlay: 0x272e33, // bg0
465
+ border: 0x5c6a72, // bg5
466
+ borderFocus: 0xa7c080, // green
467
+ },
468
+
469
+ /**
470
+ * Night Owl - designed with accessibility in mind.
471
+ */
472
+ nightOwl: {
473
+ name: 'nightOwl',
474
+ description: 'Night Owl theme',
475
+ primary: 0x82aaff, // blue
476
+ secondary: 0xc792ea, // purple
477
+ tertiary: 0x7fdbca, // cyan
478
+ accent: 0xffcb6b, // yellow
479
+ success: 0xaddb67, // green
480
+ warning: 0xffcb6b, // yellow
481
+ error: 0xef5350, // red
482
+ info: 0x7fdbca, // cyan
483
+ text: 0xd6deeb, // fg
484
+ textMuted: 0x637777, // comment
485
+ textDim: 0x5f7e97, // lineHighlight
486
+ textDisabled: 0x3b4252, // guide
487
+ textBright: 0xffffff,
488
+ background: 0x011627, // bg
489
+ backgroundMuted: 0x0b2942, // bg_light
490
+ surface: 0x1d3b53, // selection
491
+ overlay: 0x010e1a, // bg_dark
492
+ border: 0x5f7e97, // lineHighlight
493
+ borderFocus: 0x82aaff, // blue
494
+ },
273
495
  }
274
496
 
275
497
  // =============================================================================
@@ -568,9 +790,37 @@ export interface VariantStyle {
568
790
  borderFocus: RGBA
569
791
  }
570
792
 
793
+ /**
794
+ * Get a foreground color with proper contrast against the background.
795
+ *
796
+ * For ANSI colors (terminal theme): Returns the desired fg as-is, trusting
797
+ * the standard ANSI color pairings and terminal's contrast handling.
798
+ *
799
+ * For RGB colors (custom themes): Adjusts the fg lightness using OKLCH
800
+ * to ensure WCAG AA contrast (4.5:1 ratio).
801
+ */
802
+ function getContrastFg(desiredFg: RGBA, bg: RGBA): RGBA {
803
+ // If background is ANSI, trust terminal's color handling
804
+ if (isAnsiColor(bg)) {
805
+ return desiredFg
806
+ }
807
+
808
+ // If foreground is ANSI but background is RGB, also trust it
809
+ // (mixed case, probably intentional)
810
+ if (isAnsiColor(desiredFg)) {
811
+ return desiredFg
812
+ }
813
+
814
+ // Both are RGB - ensure proper contrast using OKLCH
815
+ return adjustLightnessForContrast(desiredFg, bg, 4.5)
816
+ }
817
+
571
818
  /**
572
819
  * Get variant styles resolved to RGBA.
573
820
  * Returns colors based on variant name and current theme.
821
+ *
822
+ * For terminal theme (ANSI colors): Uses standard ANSI pairings.
823
+ * For custom themes (RGB colors): Calculates proper OKLCH contrast.
574
824
  */
575
825
  export function getVariantStyle(variant: Variant): VariantStyle {
576
826
  const resolved = resolvedTheme.value
@@ -578,56 +828,56 @@ export function getVariantStyle(variant: Variant): VariantStyle {
578
828
  switch (variant) {
579
829
  case 'primary':
580
830
  return {
581
- fg: resolved.textBright,
831
+ fg: getContrastFg(resolved.textBright, resolved.primary),
582
832
  bg: resolved.primary,
583
833
  border: resolved.primary,
584
834
  borderFocus: resolved.accent,
585
835
  }
586
836
  case 'secondary':
587
837
  return {
588
- fg: resolved.textBright,
838
+ fg: getContrastFg(resolved.textBright, resolved.secondary),
589
839
  bg: resolved.secondary,
590
840
  border: resolved.secondary,
591
841
  borderFocus: resolved.accent,
592
842
  }
593
843
  case 'tertiary':
594
844
  return {
595
- fg: resolved.textBright,
845
+ fg: getContrastFg(resolved.textBright, resolved.tertiary),
596
846
  bg: resolved.tertiary,
597
847
  border: resolved.tertiary,
598
848
  borderFocus: resolved.accent,
599
849
  }
600
850
  case 'accent':
601
851
  return {
602
- fg: { r: 0, g: 0, b: 0, a: 255 }, // Dark text on accent (usually bright)
852
+ fg: getContrastFg({ r: 0, g: 0, b: 0, a: 255 }, resolved.accent),
603
853
  bg: resolved.accent,
604
854
  border: resolved.accent,
605
855
  borderFocus: resolved.primary,
606
856
  }
607
857
  case 'success':
608
858
  return {
609
- fg: resolved.textBright,
859
+ fg: getContrastFg(resolved.textBright, resolved.success),
610
860
  bg: resolved.success,
611
861
  border: resolved.success,
612
862
  borderFocus: resolved.accent,
613
863
  }
614
864
  case 'warning':
615
865
  return {
616
- fg: { r: 0, g: 0, b: 0, a: 255 }, // Dark text on warning
866
+ fg: getContrastFg({ r: 0, g: 0, b: 0, a: 255 }, resolved.warning),
617
867
  bg: resolved.warning,
618
868
  border: resolved.warning,
619
869
  borderFocus: resolved.accent,
620
870
  }
621
871
  case 'error':
622
872
  return {
623
- fg: resolved.textBright,
873
+ fg: getContrastFg(resolved.textBright, resolved.error),
624
874
  bg: resolved.error,
625
875
  border: resolved.error,
626
876
  borderFocus: resolved.accent,
627
877
  }
628
878
  case 'info':
629
879
  return {
630
- fg: resolved.textBright,
880
+ fg: getContrastFg(resolved.textBright, resolved.info),
631
881
  bg: resolved.info,
632
882
  border: resolved.info,
633
883
  borderFocus: resolved.accent,
@@ -648,7 +898,7 @@ export function getVariantStyle(variant: Variant): VariantStyle {
648
898
  }
649
899
  case 'elevated':
650
900
  return {
651
- fg: resolved.textBright,
901
+ fg: getContrastFg(resolved.textBright, resolved.surface),
652
902
  bg: resolved.surface,
653
903
  border: resolved.primary,
654
904
  borderFocus: resolved.borderFocus,
@@ -232,6 +232,121 @@ function oklchToRgb(l: number, c: number, h: number): { r: number; g: number; b:
232
232
  }
233
233
  }
234
234
 
235
+ /**
236
+ * Convert RGB to OKLCH.
237
+ * Inverse of oklchToRgb. Used for color manipulation while preserving perceptual uniformity.
238
+ *
239
+ * @returns { l: lightness (0-1), c: chroma (0-~0.4), h: hue (0-360) }
240
+ */
241
+ export function rgbToOklch(color: RGBA): { l: number; c: number; h: number } {
242
+ // sRGB to linear sRGB (inverse gamma)
243
+ const toLinear = (x: number) => {
244
+ const s = x / 255
245
+ return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
246
+ }
247
+
248
+ const rLinear = toLinear(color.r)
249
+ const gLinear = toLinear(color.g)
250
+ const bLinear = toLinear(color.b)
251
+
252
+ // Linear sRGB to LMS (cone responses)
253
+ const l = 0.4122214708 * rLinear + 0.5363325363 * gLinear + 0.0514459929 * bLinear
254
+ const m = 0.2119034982 * rLinear + 0.6806995451 * gLinear + 0.1073969566 * bLinear
255
+ const s = 0.0883024619 * rLinear + 0.2817188376 * gLinear + 0.6299787005 * bLinear
256
+
257
+ // LMS to OKLab (cube root for perceptual linearity)
258
+ const l_ = Math.cbrt(l)
259
+ const m_ = Math.cbrt(m)
260
+ const s_ = Math.cbrt(s)
261
+
262
+ const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_
263
+ const a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_
264
+ const b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
265
+
266
+ // OKLab to OKLCH (polar coordinates)
267
+ const c = Math.sqrt(a * a + b * b)
268
+ let h = Math.atan2(b, a) * (180 / Math.PI)
269
+ if (h < 0) h += 360
270
+
271
+ return { l: L, c, h }
272
+ }
273
+
274
+ /**
275
+ * Adjust OKLCH lightness to achieve minimum contrast ratio against a background.
276
+ * Preserves hue and chroma while adjusting only lightness.
277
+ *
278
+ * @param fg Foreground color to adjust
279
+ * @param bg Background color
280
+ * @param minRatio Minimum contrast ratio (WCAG AA = 4.5, AAA = 7)
281
+ * @returns Adjusted foreground color as RGBA
282
+ */
283
+ export function adjustLightnessForContrast(fg: RGBA, bg: RGBA, minRatio: number = 4.5): RGBA {
284
+ const bgOklch = rgbToOklch(bg)
285
+ let fgOklch = rgbToOklch(fg)
286
+
287
+ // Calculate current contrast
288
+ const getContrast = (fgL: number): number => {
289
+ const fgRgb = oklchToRgb(fgL, fgOklch.c, fgOklch.h)
290
+ const fgRgba = { ...fgRgb, a: 255 }
291
+ return contrastRatio(fgRgba, bg)
292
+ }
293
+
294
+ let currentRatio = getContrast(fgOklch.l)
295
+
296
+ // If already sufficient, return original
297
+ if (currentRatio >= minRatio) {
298
+ return fg
299
+ }
300
+
301
+ // Determine direction: if bg is dark, go lighter; if bg is light, go darker
302
+ const direction = bgOklch.l > 0.5 ? -1 : 1
303
+ let newL = fgOklch.l
304
+
305
+ // Binary search for the right lightness
306
+ let step = 0.25
307
+ for (let i = 0; i < 20 && currentRatio < minRatio; i++) {
308
+ newL = Math.max(0, Math.min(1, newL + direction * step))
309
+ currentRatio = getContrast(newL)
310
+
311
+ // If we overshot, reverse and halve step
312
+ if (currentRatio >= minRatio) {
313
+ // Found a valid lightness, but let's fine-tune to not over-adjust
314
+ const testL = newL - direction * step * 0.5
315
+ if (getContrast(testL) >= minRatio) {
316
+ newL = testL
317
+ currentRatio = getContrast(newL)
318
+ }
319
+ }
320
+ step *= 0.6
321
+ }
322
+
323
+ // Convert back to RGB
324
+ const adjusted = oklchToRgb(newL, fgOklch.c, fgOklch.h)
325
+ return { ...adjusted, a: fg.a }
326
+ }
327
+
328
+ /**
329
+ * Calculate contrast ratio between two colors (WCAG formula).
330
+ */
331
+ function contrastRatio(fg: RGBA, bg: RGBA): number {
332
+ const lumFg = relativeLuminance(fg)
333
+ const lumBg = relativeLuminance(bg)
334
+ const lighter = Math.max(lumFg, lumBg)
335
+ const darker = Math.min(lumFg, lumBg)
336
+ return (lighter + 0.05) / (darker + 0.05)
337
+ }
338
+
339
+ /**
340
+ * Calculate relative luminance per WCAG 2.1.
341
+ */
342
+ function relativeLuminance(color: RGBA): number {
343
+ const toLinear = (c: number) => {
344
+ const s = c / 255
345
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
346
+ }
347
+ return 0.2126 * toLinear(color.r) + 0.7152 * toLinear(color.g) + 0.0722 * toLinear(color.b)
348
+ }
349
+
235
350
  // =============================================================================
236
351
  // Color Comparison
237
352
  // =============================================================================