@effect-tui/core 0.1.0-alpha.1

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.
Files changed (241) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -0
  3. package/dist/anim.d.ts +4 -0
  4. package/dist/anim.d.ts.map +1 -0
  5. package/dist/anim.js +5 -0
  6. package/dist/anim.js.map +1 -0
  7. package/dist/ansi.d.ts +69 -0
  8. package/dist/ansi.d.ts.map +1 -0
  9. package/dist/ansi.js +72 -0
  10. package/dist/ansi.js.map +1 -0
  11. package/dist/index.d.ts +16 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +16 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/keys.d.ts +18 -0
  16. package/dist/keys.d.ts.map +1 -0
  17. package/dist/keys.js +247 -0
  18. package/dist/keys.js.map +1 -0
  19. package/dist/layout/linearStack.d.ts +17 -0
  20. package/dist/layout/linearStack.d.ts.map +1 -0
  21. package/dist/layout/linearStack.js +86 -0
  22. package/dist/layout/linearStack.js.map +1 -0
  23. package/dist/motion-value.d.ts +58 -0
  24. package/dist/motion-value.d.ts.map +1 -0
  25. package/dist/motion-value.js +250 -0
  26. package/dist/motion-value.js.map +1 -0
  27. package/dist/present/display.d.ts +58 -0
  28. package/dist/present/display.d.ts.map +1 -0
  29. package/dist/present/display.js +168 -0
  30. package/dist/present/display.js.map +1 -0
  31. package/dist/present/writers/fullscreen.d.ts +19 -0
  32. package/dist/present/writers/fullscreen.d.ts.map +1 -0
  33. package/dist/present/writers/fullscreen.js +55 -0
  34. package/dist/present/writers/fullscreen.js.map +1 -0
  35. package/dist/present/writers/inline.d.ts +20 -0
  36. package/dist/present/writers/inline.d.ts.map +1 -0
  37. package/dist/present/writers/inline.js +92 -0
  38. package/dist/present/writers/inline.js.map +1 -0
  39. package/dist/render/buffer.d.ts +31 -0
  40. package/dist/render/buffer.d.ts.map +1 -0
  41. package/dist/render/buffer.js +183 -0
  42. package/dist/render/buffer.js.map +1 -0
  43. package/dist/render/color-utils.d.ts +18 -0
  44. package/dist/render/color-utils.d.ts.map +1 -0
  45. package/dist/render/color-utils.js +58 -0
  46. package/dist/render/color-utils.js.map +1 -0
  47. package/dist/render/diff.d.ts +30 -0
  48. package/dist/render/diff.d.ts.map +1 -0
  49. package/dist/render/diff.js +83 -0
  50. package/dist/render/diff.js.map +1 -0
  51. package/dist/render/measure.d.ts +15 -0
  52. package/dist/render/measure.d.ts.map +1 -0
  53. package/dist/render/measure.js +65 -0
  54. package/dist/render/measure.js.map +1 -0
  55. package/dist/render/palette.d.ts +46 -0
  56. package/dist/render/palette.d.ts.map +1 -0
  57. package/dist/render/palette.js +108 -0
  58. package/dist/render/palette.js.map +1 -0
  59. package/dist/render/surface.d.ts +77 -0
  60. package/dist/render/surface.d.ts.map +1 -0
  61. package/dist/render/surface.js +198 -0
  62. package/dist/render/surface.js.map +1 -0
  63. package/dist/runtime/backend_node.d.ts +36 -0
  64. package/dist/runtime/backend_node.d.ts.map +1 -0
  65. package/dist/runtime/backend_node.js +66 -0
  66. package/dist/runtime/backend_node.js.map +1 -0
  67. package/dist/spring-physics.d.ts +36 -0
  68. package/dist/spring-physics.d.ts.map +1 -0
  69. package/dist/spring-physics.js +113 -0
  70. package/dist/spring-physics.js.map +1 -0
  71. package/dist/spring.d.ts +73 -0
  72. package/dist/spring.d.ts.map +1 -0
  73. package/dist/spring.js +136 -0
  74. package/dist/spring.js.map +1 -0
  75. package/dist/ui/containers/canvas.d.ts +13 -0
  76. package/dist/ui/containers/canvas.d.ts.map +1 -0
  77. package/dist/ui/containers/canvas.js +16 -0
  78. package/dist/ui/containers/canvas.js.map +1 -0
  79. package/dist/ui/containers/geometry-reader.d.ts +17 -0
  80. package/dist/ui/containers/geometry-reader.d.ts.map +1 -0
  81. package/dist/ui/containers/geometry-reader.js +24 -0
  82. package/dist/ui/containers/geometry-reader.js.map +1 -0
  83. package/dist/ui/containers/hstack.d.ts +12 -0
  84. package/dist/ui/containers/hstack.d.ts.map +1 -0
  85. package/dist/ui/containers/hstack.js +28 -0
  86. package/dist/ui/containers/hstack.js.map +1 -0
  87. package/dist/ui/containers/scroll.d.ts +28 -0
  88. package/dist/ui/containers/scroll.d.ts.map +1 -0
  89. package/dist/ui/containers/scroll.js +97 -0
  90. package/dist/ui/containers/scroll.js.map +1 -0
  91. package/dist/ui/containers/shared.d.ts +12 -0
  92. package/dist/ui/containers/shared.d.ts.map +1 -0
  93. package/dist/ui/containers/shared.js +19 -0
  94. package/dist/ui/containers/shared.js.map +1 -0
  95. package/dist/ui/containers/vstack.d.ts +12 -0
  96. package/dist/ui/containers/vstack.d.ts.map +1 -0
  97. package/dist/ui/containers/vstack.js +28 -0
  98. package/dist/ui/containers/vstack.js.map +1 -0
  99. package/dist/ui/containers/zstack.d.ts +14 -0
  100. package/dist/ui/containers/zstack.d.ts.map +1 -0
  101. package/dist/ui/containers/zstack.js +36 -0
  102. package/dist/ui/containers/zstack.js.map +1 -0
  103. package/dist/ui/core/geometry-store.d.ts +22 -0
  104. package/dist/ui/core/geometry-store.d.ts.map +1 -0
  105. package/dist/ui/core/geometry-store.js +29 -0
  106. package/dist/ui/core/geometry-store.js.map +1 -0
  107. package/dist/ui/core/geometry.d.ts +34 -0
  108. package/dist/ui/core/geometry.d.ts.map +1 -0
  109. package/dist/ui/core/geometry.js +14 -0
  110. package/dist/ui/core/geometry.js.map +1 -0
  111. package/dist/ui/core/view.d.ts +25 -0
  112. package/dist/ui/core/view.d.ts.map +1 -0
  113. package/dist/ui/core/view.js +34 -0
  114. package/dist/ui/core/view.js.map +1 -0
  115. package/dist/ui/index.d.ts +44 -0
  116. package/dist/ui/index.d.ts.map +1 -0
  117. package/dist/ui/index.js +39 -0
  118. package/dist/ui/index.js.map +1 -0
  119. package/dist/ui/inlinetext.d.ts +24 -0
  120. package/dist/ui/inlinetext.d.ts.map +1 -0
  121. package/dist/ui/inlinetext.js +131 -0
  122. package/dist/ui/inlinetext.js.map +1 -0
  123. package/dist/ui/install.d.ts +22 -0
  124. package/dist/ui/install.d.ts.map +1 -0
  125. package/dist/ui/install.js +66 -0
  126. package/dist/ui/install.js.map +1 -0
  127. package/dist/ui/markdown.d.ts +40 -0
  128. package/dist/ui/markdown.d.ts.map +1 -0
  129. package/dist/ui/markdown.js +351 -0
  130. package/dist/ui/markdown.js.map +1 -0
  131. package/dist/ui/modifiers/border.d.ts +33 -0
  132. package/dist/ui/modifiers/border.d.ts.map +1 -0
  133. package/dist/ui/modifiers/border.js +82 -0
  134. package/dist/ui/modifiers/border.js.map +1 -0
  135. package/dist/ui/modifiers/fill.d.ts +14 -0
  136. package/dist/ui/modifiers/fill.d.ts.map +1 -0
  137. package/dist/ui/modifiers/fill.js +25 -0
  138. package/dist/ui/modifiers/fill.js.map +1 -0
  139. package/dist/ui/modifiers/frame.d.ts +23 -0
  140. package/dist/ui/modifiers/frame.d.ts.map +1 -0
  141. package/dist/ui/modifiers/frame.js +54 -0
  142. package/dist/ui/modifiers/frame.js.map +1 -0
  143. package/dist/ui/modifiers/offset.d.ts +15 -0
  144. package/dist/ui/modifiers/offset.d.ts.map +1 -0
  145. package/dist/ui/modifiers/offset.js +21 -0
  146. package/dist/ui/modifiers/offset.js.map +1 -0
  147. package/dist/ui/modifiers/opacity.d.ts +15 -0
  148. package/dist/ui/modifiers/opacity.d.ts.map +1 -0
  149. package/dist/ui/modifiers/opacity.js +95 -0
  150. package/dist/ui/modifiers/opacity.js.map +1 -0
  151. package/dist/ui/modifiers/padding.d.ts +20 -0
  152. package/dist/ui/modifiers/padding.d.ts.map +1 -0
  153. package/dist/ui/modifiers/padding.js +36 -0
  154. package/dist/ui/modifiers/padding.js.map +1 -0
  155. package/dist/ui/modifiers/styled.d.ts +14 -0
  156. package/dist/ui/modifiers/styled.d.ts.map +1 -0
  157. package/dist/ui/modifiers/styled.js +26 -0
  158. package/dist/ui/modifiers/styled.js.map +1 -0
  159. package/dist/ui/primitives/rectangle.d.ts +15 -0
  160. package/dist/ui/primitives/rectangle.d.ts.map +1 -0
  161. package/dist/ui/primitives/rectangle.js +23 -0
  162. package/dist/ui/primitives/rectangle.js.map +1 -0
  163. package/dist/ui/primitives/spacer.d.ts +13 -0
  164. package/dist/ui/primitives/spacer.d.ts.map +1 -0
  165. package/dist/ui/primitives/spacer.js +16 -0
  166. package/dist/ui/primitives/spacer.js.map +1 -0
  167. package/dist/ui/primitives/text.d.ts +15 -0
  168. package/dist/ui/primitives/text.d.ts.map +1 -0
  169. package/dist/ui/primitives/text.js +79 -0
  170. package/dist/ui/primitives/text.js.map +1 -0
  171. package/dist/ui/primitives/wrapped-text.d.ts +30 -0
  172. package/dist/ui/primitives/wrapped-text.d.ts.map +1 -0
  173. package/dist/ui/primitives/wrapped-text.js +117 -0
  174. package/dist/ui/primitives/wrapped-text.js.map +1 -0
  175. package/dist/ui/shinytext.d.ts +66 -0
  176. package/dist/ui/shinytext.d.ts.map +1 -0
  177. package/dist/ui/shinytext.js +99 -0
  178. package/dist/ui/shinytext.js.map +1 -0
  179. package/dist/ui/text/layout.d.ts +35 -0
  180. package/dist/ui/text/layout.d.ts.map +1 -0
  181. package/dist/ui/text/layout.js +102 -0
  182. package/dist/ui/text/layout.js.map +1 -0
  183. package/dist/ui/textinput.d.ts +140 -0
  184. package/dist/ui/textinput.d.ts.map +1 -0
  185. package/dist/ui/textinput.js +402 -0
  186. package/dist/ui/textinput.js.map +1 -0
  187. package/dist/ui/view-constructors.d.ts +72 -0
  188. package/dist/ui/view-constructors.d.ts.map +1 -0
  189. package/dist/ui/view-constructors.js +74 -0
  190. package/dist/ui/view-constructors.js.map +1 -0
  191. package/package.json +57 -0
  192. package/src/anim.ts +5 -0
  193. package/src/ansi.ts +83 -0
  194. package/src/index.ts +21 -0
  195. package/src/keys.ts +302 -0
  196. package/src/layout/linearStack.ts +115 -0
  197. package/src/motion-value.ts +335 -0
  198. package/src/present/display.ts +206 -0
  199. package/src/present/writers/fullscreen.ts +58 -0
  200. package/src/present/writers/inline.ts +101 -0
  201. package/src/render/buffer.ts +200 -0
  202. package/src/render/color-utils.ts +60 -0
  203. package/src/render/diff.ts +95 -0
  204. package/src/render/measure.ts +74 -0
  205. package/src/render/palette.ts +113 -0
  206. package/src/render/surface.ts +238 -0
  207. package/src/runtime/backend_node.ts +80 -0
  208. package/src/spring-physics.ts +151 -0
  209. package/src/spring.ts +234 -0
  210. package/src/ui/__snapshots__/wrappedtext.test.ts.snap +57 -0
  211. package/src/ui/containers/canvas.ts +18 -0
  212. package/src/ui/containers/geometry-reader.ts +32 -0
  213. package/src/ui/containers/hstack.ts +33 -0
  214. package/src/ui/containers/scroll.ts +106 -0
  215. package/src/ui/containers/shared.ts +27 -0
  216. package/src/ui/containers/vstack.ts +34 -0
  217. package/src/ui/containers/zstack.ts +37 -0
  218. package/src/ui/core/geometry-store.ts +42 -0
  219. package/src/ui/core/geometry.ts +30 -0
  220. package/src/ui/core/view.ts +49 -0
  221. package/src/ui/index.ts +84 -0
  222. package/src/ui/inlinetext.ts +135 -0
  223. package/src/ui/install.ts +110 -0
  224. package/src/ui/markdown.test.ts +74 -0
  225. package/src/ui/markdown.ts +388 -0
  226. package/src/ui/modifiers/border.ts +100 -0
  227. package/src/ui/modifiers/fill.ts +28 -0
  228. package/src/ui/modifiers/frame.ts +74 -0
  229. package/src/ui/modifiers/offset.ts +23 -0
  230. package/src/ui/modifiers/opacity.ts +93 -0
  231. package/src/ui/modifiers/padding.ts +53 -0
  232. package/src/ui/modifiers/styled.ts +31 -0
  233. package/src/ui/primitives/rectangle.ts +25 -0
  234. package/src/ui/primitives/spacer.ts +18 -0
  235. package/src/ui/primitives/text.ts +85 -0
  236. package/src/ui/primitives/wrapped-text.ts +131 -0
  237. package/src/ui/shinytext.ts +159 -0
  238. package/src/ui/text/layout.ts +119 -0
  239. package/src/ui/textinput.ts +496 -0
  240. package/src/ui/view-constructors.ts +96 -0
  241. package/src/ui/wrappedtext.test.ts +138 -0
@@ -0,0 +1,159 @@
1
+ /* shinytext.ts — reusable shining text component */
2
+
3
+ import type { Palette, Surface } from "../render/surface.js"
4
+ import { Colors } from "../render/surface.js"
5
+ import type { Rect } from "./core/geometry.js"
6
+ import { Step, type Stepper } from "../anim.js"
7
+ import { View } from "./core/view.js"
8
+
9
+ export type ShineTextOptions = {
10
+ /**
11
+ * Center position of the shine in character coordinates (can be fractional).
12
+ * 0 = before first char, length-1 = last char. Values outside are fine for overscan.
13
+ */
14
+ center?: number
15
+ /**
16
+ * Alternative to center: normalized progress 0..1 across the text.
17
+ * If provided, takes precedence and maps to center internally.
18
+ */
19
+ progress?: number
20
+ /** Gaussian falloff spread; larger = wider shine. Default 2.25 */
21
+ spread?: number
22
+ /** Base gray level (0..23). Default 12 */
23
+ baseGray?: number
24
+ /** Max additional gray levels added at peak (0..23). Default 8 */
25
+ boost?: number
26
+ /** Bold threshold on intensity (0..1). Default 0.75 */
27
+ boldThreshold?: number
28
+ }
29
+
30
+ export class ShinyText extends View {
31
+ constructor(
32
+ readonly text: string,
33
+ readonly opts?: ShineTextOptions,
34
+ ) {
35
+ super()
36
+ }
37
+
38
+ private getChars(): string[] {
39
+ return [...this.text]
40
+ }
41
+
42
+ protected measureContent(maxW: number, _maxH: number) {
43
+ const w = Math.min(maxW, this.getChars().length)
44
+ return { w, h: 1 }
45
+ }
46
+
47
+ protected renderContent(s: Surface, pal: Palette, rect: Rect) {
48
+ const chars = this.getChars()
49
+ const n = Math.min(rect.w, chars.length)
50
+ if (rect.h <= 0 || n <= 0) return
51
+
52
+ const spread = this.opts?.spread ?? 2.25
53
+ const base = Math.max(0, Math.min(23, (this.opts?.baseGray ?? 12) | 0))
54
+ const boost = Math.max(0, Math.min(23, (this.opts?.boost ?? 8) | 0))
55
+ const boldTh = this.opts?.boldThreshold ?? 0.75
56
+
57
+ const progress = this.opts?.progress
58
+ const center = progress != null ? progress * (chars.length - 1) : (this.opts?.center ?? 0)
59
+
60
+ const styleFor = (i: number) => {
61
+ const d = (i - center) / Math.max(1e-3, spread)
62
+ const intensity = Math.exp(-d * d)
63
+ const level = Math.max(0, Math.min(23, base + Math.round(intensity * boost)))
64
+ const bold = intensity > boldTh
65
+ return pal.id({ fg: Colors.gray(level), bold })
66
+ }
67
+
68
+ // Emit minimal runs per style id
69
+ const y = rect.y
70
+ let x = rect.x
71
+ let runStyle = -1
72
+ let runStart = x
73
+ let runText = ""
74
+ const flush = () => {
75
+ if (runText.length > 0) {
76
+ const maxW = Math.max(0, rect.x + rect.w - runStart)
77
+ if (maxW > 0) s.drawText(runStart, y, runText, runStyle, maxW)
78
+ runText = ""
79
+ }
80
+ }
81
+ for (let i = 0; i < n; i++) {
82
+ const ch = chars[i]
83
+ const id = styleFor(i)
84
+ if (id !== runStyle) {
85
+ flush()
86
+ runStyle = id
87
+ runStart = x
88
+ }
89
+ runText += ch
90
+ x++
91
+ }
92
+ flush()
93
+ }
94
+ }
95
+
96
+ // Optional builder contribution for the View object
97
+ export type ViewShinyTextExt = {
98
+ shineText(text: string, opts?: ShineTextOptions): View
99
+ }
100
+ export const viewShinyText: ViewShinyTextExt = {
101
+ shineText(text: string, opts?: ShineTextOptions): View {
102
+ return new ShinyText(text, opts)
103
+ },
104
+ }
105
+
106
+ // ----------------------------------------------------------------------------
107
+ // Stateful convenience API (init/tick/setSpeed + node factory)
108
+ // ----------------------------------------------------------------------------
109
+
110
+ export type ShinyTextModel = {
111
+ text: string
112
+ step: Stepper
113
+ speedMs: number
114
+ overscan: number
115
+ shine?: Omit<ShineTextOptions, "center" | "progress">
116
+ }
117
+
118
+ export type InitShinyOptions = {
119
+ speedMs?: number
120
+ overscan?: number
121
+ shine?: Omit<ShineTextOptions, "center" | "progress">
122
+ }
123
+
124
+ export function initShinyText(text: string, opts?: InitShinyOptions): ShinyTextModel {
125
+ const speedMs = Math.max(1, (opts?.speedMs ?? 40) | 0)
126
+ const overscan = Math.max(0, (opts?.overscan ?? 6) | 0)
127
+ const width = [...text].length
128
+ const step = Step.init(width + overscan * 2, speedMs, 0)
129
+ return { text, step, speedMs, overscan, shine: opts?.shine }
130
+ }
131
+
132
+ export function tickShinyText(state: ShinyTextModel, nowMs: number): ShinyTextModel {
133
+ const step = Step.tick(state.step, nowMs)
134
+ return { ...state, step }
135
+ }
136
+
137
+ export function setShinySpeed(state: ShinyTextModel, speedMs: number): ShinyTextModel {
138
+ const ms = Math.max(1, speedMs | 0)
139
+ const step = { ...state.step, intervalMs: ms }
140
+ return { ...state, speedMs: ms, step }
141
+ }
142
+
143
+ /** Create a ShinyText node from state. Use with chainers like .frame/.padding. */
144
+ export function shinyText(state: ShinyTextModel): View {
145
+ const center = state.step.index - state.overscan
146
+ return new ShinyText(state.text, { ...state.shine, center })
147
+ }
148
+
149
+ // Merged namespace to expose a cohesive API: ShinyText.make(), ShinyText.tick(), ShinyText.setSpeed(), ShinyText.view()
150
+ export namespace ShinyText {
151
+ export type Model = ShinyTextModel
152
+ export type Options = ShineTextOptions
153
+ export type MakeOptions = InitShinyOptions
154
+
155
+ export const make = initShinyText
156
+ export const tick = tickShinyText
157
+ export const setSpeed = setShinySpeed
158
+ export const view = shinyText
159
+ }
@@ -0,0 +1,119 @@
1
+ /* text/layout.ts — Shared text layout utilities for wrapping and caret mapping
2
+ *
3
+ * Single source of truth for:
4
+ * - Computing wrapped visual lines (first-line width vs subsequent)
5
+ * - Mapping between grapheme cursor index and visual caret coordinates
6
+ * - Mapping from visual cell X back to cursor index (for vertical nav)
7
+ *
8
+ * These helpers mirror renderer behavior by delegating to render/measure.ts
9
+ * (displayWidth, graphemes, sliceByWidth), ensuring edit-time and render-time
10
+ * wrapping boundaries are identical.
11
+ */
12
+
13
+ import { displayWidth, graphemes, sliceByWidth } from "../../render/measure.js"
14
+
15
+ export type WrappedLine = { text: string; graphemes: string[]; start: number; width: number }
16
+ export type Wrapped = { lines: Array<WrappedLine> }
17
+
18
+ export type WrapOptions = {
19
+ widthFirst: number
20
+ widthOther: number
21
+ wordWrap: boolean
22
+ breakWords: boolean
23
+ }
24
+
25
+ /** Compute wrapped lines with widths and grapheme start offsets (soft + hard breaks). */
26
+ export function wrapText(value: string, opts: WrapOptions): Wrapped {
27
+ const lines: Array<{ graphemes: string[]; start: number; width: number }> = []
28
+ const widthFor = (isFirstVisual: boolean) => Math.max(0, isFirstVisual ? opts.widthFirst : opts.widthOther)
29
+
30
+ let gOffset = 0 // grapheme index from start of whole value
31
+ let isFirstVisual = true
32
+
33
+ // Split logical lines by explicit newlines
34
+ const logicals = value.split("\n")
35
+ for (let li = 0; li < logicals.length; li++) {
36
+ const logical = logicals[li]
37
+ let rest = logical
38
+ // Even for empty logical lines, emit one visual line (empty)
39
+ if (rest.length === 0) {
40
+ lines.push({ graphemes: [], start: gOffset, width: 0 })
41
+ if (li < logicals.length - 1) gOffset += 1 // the "\n" grapheme
42
+ isFirstVisual = false
43
+ continue
44
+ }
45
+ while (rest.length > 0) {
46
+ const maxW = widthFor(isFirstVisual)
47
+ const { text } = sliceByWidth(rest, maxW)
48
+ const used = text.length > 0 ? text : rest.slice(0, 1) // ensure progress even when maxW == 0
49
+ const usedGs = graphemes(used)
50
+ const w = displayWidth(used)
51
+ lines.push({ graphemes: usedGs, start: gOffset, width: w })
52
+ gOffset += usedGs.length
53
+ rest = rest.slice(used.length)
54
+ isFirstVisual = false
55
+ if (used.length === 0) break
56
+ }
57
+ if (li < logicals.length - 1) gOffset += 1 // account for newline between logical segments
58
+ }
59
+ return { lines: lines.map((l) => ({ ...l, text: l.graphemes.join("") })) }
60
+ }
61
+
62
+ /** Locate the visual line and column within that line for a grapheme cursor. */
63
+ export function findVisualPos(wrap: Wrapped, cursor: number): { lineIdx: number; colInLine: number } {
64
+ for (let i = 0; i < wrap.lines.length; i++) {
65
+ const ln = wrap.lines[i]
66
+ const start = ln.start
67
+ // Use next line's start as upper bound (accounts for newline gaps)
68
+ const nextStart = i + 1 < wrap.lines.length ? wrap.lines[i + 1].start : Infinity
69
+
70
+ // Cursor in range [start, nextStart) belongs to this line
71
+ if (cursor >= start && cursor < nextStart) {
72
+ // For empty lines, cursor must be exactly at start
73
+ if (ln.graphemes.length === 0) {
74
+ return { lineIdx: i, colInLine: 0 }
75
+ }
76
+ const col = Math.min(cursor - start, ln.graphemes.length)
77
+ return { lineIdx: i, colInLine: col }
78
+ }
79
+ }
80
+ // Fallback: end of last line
81
+ const last = wrap.lines[wrap.lines.length - 1]
82
+ return { lineIdx: wrap.lines.length - 1, colInLine: last.graphemes.length }
83
+ }
84
+
85
+ /** Sum display widths of graphemes up to a column to get visual cell X. */
86
+ export function cellXWithinLine(wrap: Wrapped, lineIdx: number, colInLine: number): number {
87
+ const ln = wrap.lines[lineIdx]
88
+ const gs = ln.graphemes
89
+ let x = 0
90
+ for (let i = 0; i < Math.min(colInLine, gs.length); i++) x += displayWidth(gs[i])
91
+ return x
92
+ }
93
+
94
+ /** Map a visual cell X back to a cursor index on a given line (nearest-left by centers). */
95
+ export function cursorFromCellX(wrap: Wrapped, lineIdx: number, x: number): number {
96
+ const ln = wrap.lines[lineIdx]
97
+ const gs = ln.graphemes
98
+ let acc = 0
99
+ for (let i = 0; i < gs.length; i++) {
100
+ const w = displayWidth(gs[i])
101
+ const center = acc + w / 2
102
+ if (x < center) return ln.start + i
103
+ acc += w
104
+ }
105
+ return ln.start + gs.length
106
+ }
107
+
108
+ /** Map cursor index to absolute caret coordinates using provided anchors. */
109
+ export function caretXYFromCursor(
110
+ wrap: Wrapped,
111
+ cursor: number,
112
+ anchors: { xFirst: number; xOther: number },
113
+ ): { x: number; y: number } {
114
+ const { lineIdx, colInLine } = findVisualPos(wrap, cursor)
115
+ const baseX = lineIdx === 0 ? anchors.xFirst : anchors.xOther
116
+ const x = baseX + cellXWithinLine(wrap, lineIdx, colInLine)
117
+ const y = lineIdx
118
+ return { x, y }
119
+ }