@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,135 @@
1
+ import type { Palette, Surface, StyleSpec } from "../render/surface.js"
2
+ import { View } from "./core/view.js"
3
+ import type { Rect } from "./core/geometry.js"
4
+ import type { WrappingOptions } from "./primitives/wrapped-text.js"
5
+
6
+ export type TextFragment = {
7
+ text: string
8
+ style?: StyleSpec
9
+ }
10
+
11
+ type Token = { text: string; style?: StyleSpec; kind: "word" | "space" }
12
+
13
+ function toTokens(fragments: TextFragment[]): Token[] {
14
+ const tokens: Token[] = []
15
+ for (const frag of fragments) {
16
+ const parts = frag.text.split(/(\s+)/)
17
+ for (const p of parts) {
18
+ if (p.length === 0) continue
19
+ if (/^\s+$/.test(p)) tokens.push({ text: p.replace(/\s+/g, " "), style: frag.style, kind: "space" })
20
+ else tokens.push({ text: p, style: frag.style, kind: "word" })
21
+ }
22
+ }
23
+ return tokens
24
+ }
25
+
26
+ function strlen(s: string): number {
27
+ // Best-effort: count code points. Rendering will use wcwidth-aware clipping.
28
+ return [...s].length
29
+ }
30
+
31
+ export class InlineText extends View {
32
+ constructor(
33
+ readonly fragments: TextFragment[],
34
+ readonly options: WrappingOptions = {},
35
+ ) {
36
+ super()
37
+ }
38
+
39
+ private layout(maxW: number): Array<Array<Token>> {
40
+ const { wordWrap = true, breakWords = true } = this.options
41
+ const toks = toTokens(this.fragments)
42
+ const lines: Array<Array<Token>> = []
43
+ let line: Token[] = []
44
+ let used = 0
45
+ const flush = () => {
46
+ if (line.length === 0) return
47
+ lines.push(line)
48
+ line = []
49
+ used = 0
50
+ }
51
+ for (const t of toks) {
52
+ if (t.kind === "space") {
53
+ if (used === 0) continue // skip leading spaces
54
+ const w = 1
55
+ if (used + w <= maxW) {
56
+ line.push({ ...t, text: " " })
57
+ used += w
58
+ } else {
59
+ flush()
60
+ }
61
+ continue
62
+ }
63
+ const w = strlen(t.text)
64
+ if (!wordWrap) {
65
+ // No wrapping: push to current line; if overflow, start new line first
66
+ if (used + w > maxW && used > 0) flush()
67
+ line.push(t)
68
+ used = Math.min(maxW, used + w)
69
+ continue
70
+ }
71
+ if (w > maxW) {
72
+ if (breakWords) {
73
+ const remaining = [...t.text]
74
+ while (remaining.length > 0) {
75
+ const room = Math.max(0, maxW - used)
76
+ if (room === 0) flush()
77
+ const take = Math.min(remaining.length, Math.max(1, maxW - used))
78
+ const chunk = remaining.splice(0, take).join("")
79
+ line.push({ ...t, text: chunk })
80
+ used += strlen(chunk)
81
+ if (remaining.length > 0) flush()
82
+ }
83
+ } else {
84
+ // Place as its own line (will clip visually)
85
+ if (used > 0) flush()
86
+ line.push(t)
87
+ flush()
88
+ }
89
+ } else if (used + w <= maxW) {
90
+ line.push(t)
91
+ used += w
92
+ } else {
93
+ flush()
94
+ line.push(t)
95
+ used = w
96
+ }
97
+ }
98
+ flush()
99
+ if (lines.length === 0) lines.push([])
100
+ return lines
101
+ }
102
+
103
+ protected measureContent(maxW: number, maxH: number) {
104
+ const lines = this.layout(maxW)
105
+ const h = Math.min(maxH, Math.max(1, lines.length))
106
+ return { w: maxW, h }
107
+ }
108
+
109
+ protected renderContent(s: Surface, pal: Palette, rect: Rect) {
110
+ const lines = this.layout(rect.w)
111
+ const h = Math.min(rect.h, lines.length)
112
+ for (let y = 0; y < h; y++) {
113
+ let x = 0
114
+ for (const seg of lines[y]) {
115
+ if (x >= rect.w) break
116
+ const id = pal.id(seg.style)
117
+ const max = Math.max(0, rect.w - x)
118
+ if (max <= 0) break
119
+ s.drawText(rect.x + x, rect.y + y, seg.text, id, max)
120
+ x += strlen(seg.text)
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ // Builder contribution (optional if needed later)
127
+ export type ViewInlineTextExt = {
128
+ inline(fragments: TextFragment[], opts?: WrappingOptions): InlineText
129
+ }
130
+
131
+ export const viewInlineText: ViewInlineTextExt = {
132
+ inline(fragments: TextFragment[], opts?: WrappingOptions): InlineText {
133
+ return new InlineText(fragments, opts)
134
+ },
135
+ }
@@ -0,0 +1,110 @@
1
+ /* install.ts — Installer for View modifier chainers
2
+ *
3
+ * This module installs chainable modifier methods on View.prototype using
4
+ * a clean installer pattern with TypeScript declaration merging.
5
+ */
6
+
7
+ import { View } from "./core/view.js"
8
+ import type { BorderOptions } from "./modifiers/border.js"
9
+ import type { FrameSpec } from "./modifiers/frame.js"
10
+ import type { StyleSpec, ColorLike } from "../render/surface.js"
11
+ import { parseColor } from "../render/surface.js"
12
+
13
+ // Import all modifier classes
14
+ import { Border } from "./modifiers/border.js"
15
+ import { Offset } from "./modifiers/offset.js"
16
+ import { Fill } from "./modifiers/fill.js"
17
+ import { Opacity } from "./modifiers/opacity.js"
18
+ import { Padding } from "./modifiers/padding.js"
19
+ import { Frame } from "./modifiers/frame.js"
20
+ import { Styled } from "./modifiers/styled.js"
21
+
22
+ // Define modifier methods with proper typing
23
+ const modifierMethods = {
24
+ // Layout modifiers
25
+ border(this: View, opts?: BorderOptions): View {
26
+ return new Border(this, opts)
27
+ },
28
+
29
+ offset(this: View, dx = 0, dy = 0): View {
30
+ return new Offset(this, dx, dy)
31
+ },
32
+
33
+ fillWidth(this: View): View {
34
+ return new Fill(this, "horizontal")
35
+ },
36
+
37
+ fillHeight(this: View): View {
38
+ return new Fill(this, "vertical")
39
+ },
40
+
41
+ padding(this: View, t: number, r?: number, b?: number, l?: number): View {
42
+ return new Padding(this, t, r, b, l)
43
+ },
44
+
45
+ frame(this: View, opts: FrameSpec): View {
46
+ return new Frame(this, opts)
47
+ },
48
+
49
+ // Style modifiers
50
+ style(this: View, spec?: StyleSpec): View {
51
+ return spec ? new Styled(this, spec) : this
52
+ },
53
+
54
+ fg(this: View, color: ColorLike): View {
55
+ return new Styled(this, { fg: parseColor(color) })
56
+ },
57
+
58
+ bg(this: View, color: ColorLike): View {
59
+ return new Styled(this, { bg: parseColor(color) })
60
+ },
61
+
62
+ bold(this: View, on = true): View {
63
+ return new Styled(this, { bold: on })
64
+ },
65
+
66
+ italic(this: View, on = true): View {
67
+ return new Styled(this, { italic: on })
68
+ },
69
+
70
+ underline(this: View, on = true): View {
71
+ return new Styled(this, { underline: on })
72
+ },
73
+
74
+ inverse(this: View, on = true): View {
75
+ return new Styled(this, { inverse: on })
76
+ },
77
+
78
+ // Visual effect modifiers
79
+ opacity(this: View, alpha: number): View {
80
+ return new Opacity(this, alpha)
81
+ },
82
+ }
83
+
84
+ // Install methods on View prototype
85
+ Object.assign(View.prototype, modifierMethods)
86
+
87
+ // TypeScript declaration merging to add method signatures
88
+ declare module "./core/view.js" {
89
+ interface View {
90
+ // Layout modifiers
91
+ border(opts?: BorderOptions): View
92
+ offset(dx?: number, dy?: number): View
93
+ fillWidth(): View
94
+ fillHeight(): View
95
+ padding(t: number, r?: number, b?: number, l?: number): View
96
+ frame(opts: FrameSpec): View
97
+
98
+ // Style modifiers
99
+ style(spec?: StyleSpec): View
100
+ fg(color: ColorLike): View
101
+ bg(color: ColorLike): View
102
+ bold(on?: boolean): View
103
+ italic(on?: boolean): View
104
+ underline(on?: boolean): View
105
+ inverse(on?: boolean): View
106
+
107
+ // Visual effect modifiers
108
+ opacity(alpha: number): View
109
+ }
110
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { Surface, Palette } from "../render/surface"
3
+ import { Markdown } from "./markdown"
4
+
5
+ describe("Markdown", () => {
6
+ it("renders paragraph with inline formatting", () => {
7
+ const md = new Markdown("This is **bold** and *italic*, plus `code` and ~~strike~~ and [link](https://ex.com).")
8
+ const palette = new Palette()
9
+ const m = md.measure(120, 10)
10
+ const surface = new Surface(120, m.h)
11
+ md.render(surface, palette, { x: 0, y: 0, w: 120, h: m.h })
12
+ const output = surface.toString()
13
+ expect(output).toBe("This is bold and italic, plus code and strike and link (https://ex.com).")
14
+ })
15
+
16
+ it("renders heading and paragraph with spacing", () => {
17
+ const text = "# Title\n\nBody with *italic* and **bold**."
18
+ const md = new Markdown(text)
19
+ const palette = new Palette()
20
+ const m = md.measure(80, 10)
21
+ const surface = new Surface(80, m.h)
22
+ md.render(surface, palette, { x: 0, y: 0, w: 80, h: m.h })
23
+ const output = surface.toString({ trimEnd: true })
24
+ expect(output).toBe("Title\n\nBody with italic and bold.")
25
+ })
26
+
27
+ it("renders unordered and ordered lists with inline", () => {
28
+ const text = ["- first **item**", "- second with [link](u)", "", "1. one", "2. two and *italic*"].join("\n")
29
+ const md = new Markdown(text)
30
+ const palette = new Palette()
31
+ const m = md.measure(80, 20)
32
+ const surface = new Surface(80, m.h)
33
+ md.render(surface, palette, { x: 0, y: 0, w: 80, h: m.h })
34
+ const output = surface.toString({ trimEnd: true })
35
+ const expected = ["• first item", "", "• second with link (u)", "", "1. one", "", "2. two and italic"].join("\n")
36
+ expect(output).toBe(expected)
37
+ })
38
+
39
+ it("supports • bullets and 1) ordered markers", () => {
40
+ const text = ["• apple", "• banana", "", "1) first", "2) second"].join("\n")
41
+ const md = new Markdown(text)
42
+ const palette = new Palette()
43
+ const m = md.measure(80, 20)
44
+ const surface = new Surface(80, m.h)
45
+ md.render(surface, palette, { x: 0, y: 0, w: 80, h: m.h })
46
+ const output = surface.toString({ trimEnd: true })
47
+ const expected = ["• apple", "", "• banana", "", "1. first", "", "2. second"].join("\n")
48
+ expect(output).toBe(expected)
49
+ })
50
+
51
+ it("renders blockquote with prefix", () => {
52
+ const text = ["> quoted *text* with [ref](x)", "> and more ~~stuff~~"].join("\n")
53
+ const md = new Markdown(text)
54
+ const palette = new Palette()
55
+ const m = md.measure(80, 10)
56
+ const surface = new Surface(80, m.h)
57
+ md.render(surface, palette, { x: 0, y: 0, w: 80, h: m.h })
58
+ const output = surface.toString({ trimEnd: true })
59
+ const expected = ["│ quoted text with ref (x)", "│ and more stuff"].join("\n")
60
+ expect(output).toBe(expected)
61
+ })
62
+
63
+ it("renders fenced code blocks (no inline parsing inside)", () => {
64
+ const text = ["```ts", "const x = 1 // **not bold**", "console.log(x) // [link](u)", "```"].join("\n")
65
+ const md = new Markdown(text)
66
+ const palette = new Palette()
67
+ const m = md.measure(80, 10)
68
+ const surface = new Surface(80, m.h)
69
+ md.render(surface, palette, { x: 0, y: 0, w: 80, h: m.h })
70
+ const output = surface.toString({ trimEnd: true })
71
+ const expected = ["const x = 1 // **not bold**", "console.log(x) // [link](u)"].join("\n")
72
+ expect(output).toBe(expected)
73
+ })
74
+ })
@@ -0,0 +1,388 @@
1
+ import type { Palette, Surface, StyleSpec, ColorLike } from "../render/surface.js"
2
+ import { Colors, mergeStyle } from "../render/surface.js"
3
+ import { View } from "./core/view.js"
4
+ import type { Rect } from "./core/geometry.js"
5
+ import { VStack } from "./containers/vstack.js"
6
+ import { WrappedText } from "./primitives/wrapped-text.js"
7
+ import { InlineText, type TextFragment } from "./inlinetext.js"
8
+
9
+ export type MarkdownOptions = {
10
+ spacing?: number // vertical space between blocks (lines)
11
+ styles?: {
12
+ heading?: Partial<Record<1 | 2 | 3 | 4 | 5 | 6, StyleSpec>>
13
+ paragraph?: StyleSpec
14
+ code?: StyleSpec
15
+ codeBorder?: {
16
+ kind?: "none" | "rounded" | "square" | "ascii"
17
+ color?: ColorLike
18
+ padding?: number | { x?: number; y?: number }
19
+ }
20
+ quote?: StyleSpec
21
+ listBullet?: StyleSpec
22
+ }
23
+ }
24
+
25
+ type Block =
26
+ | { type: "heading"; level: number; text: string }
27
+ | { type: "paragraph"; text: string }
28
+ | { type: "code"; lang?: string; text: string }
29
+ | { type: "list"; ordered: boolean; items: string[] }
30
+ | { type: "quote"; text: string }
31
+
32
+ /** Minimal Markdown renderer for terminal UI: block-level only. */
33
+ export class Markdown extends View {
34
+ constructor(
35
+ readonly text: string,
36
+ readonly opts: MarkdownOptions = {},
37
+ ) {
38
+ super()
39
+ }
40
+
41
+ private parseBlocks(): Block[] {
42
+ const lines = this.text.replace(/\r\n?/g, "\n").split("\n")
43
+ const blocks: Block[] = []
44
+ let i = 0
45
+ const isBlank = (s: string) => /^\s*$/.test(s)
46
+
47
+ while (i < lines.length) {
48
+ const line = lines[i]
49
+ if (isBlank(line)) {
50
+ i++
51
+ continue
52
+ }
53
+
54
+ // Code fence
55
+ const fence = line.match(/^```(?:(\w+))?\s*$/)
56
+ if (fence) {
57
+ const lang = fence[1]
58
+ const buf: string[] = []
59
+ i++
60
+ while (i < lines.length && !/^```\s*$/.test(lines[i])) {
61
+ buf.push(lines[i])
62
+ i++
63
+ }
64
+ // skip closing fence if present
65
+ if (i < lines.length && /^```\s*$/.test(lines[i])) i++
66
+ blocks.push({ type: "code", lang, text: buf.join("\n") })
67
+ continue
68
+ }
69
+
70
+ // Heading
71
+ const h = line.match(/^(#{1,6})\s+(.*)$/)
72
+ if (h) {
73
+ blocks.push({ type: "heading", level: h[1].length, text: h[2] })
74
+ i++
75
+ continue
76
+ }
77
+
78
+ // Blockquote (consume contiguous ">" lines)
79
+ if (/^>\s?/.test(line)) {
80
+ const buf: string[] = []
81
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
82
+ buf.push(lines[i].replace(/^>\s?/, ""))
83
+ i++
84
+ }
85
+ blocks.push({ type: "quote", text: buf.join("\n") })
86
+ continue
87
+ }
88
+
89
+ // List (unordered or ordered) — support -,*,+, • and 1., 1)
90
+ let m = line.match(/^\s*([-*+]|•)\s+(.*)$/)
91
+ let ordered = false
92
+ if (!m) {
93
+ const om = line.match(/^\s*(\d+)[.)]\s+(.*)$/)
94
+ if (om) {
95
+ ordered = true
96
+ m = [om[0], om[1], om[2]] as any
97
+ }
98
+ }
99
+ if (m) {
100
+ const items: string[] = []
101
+ while (i < lines.length) {
102
+ const l = lines[i]
103
+ const um = l.match(/^\s*([-*+]|•)\s+(.*)$/)
104
+ const om = l.match(/^\s*(\d+)[.)]\s+(.*)$/)
105
+ if ((ordered && om) || (!ordered && um)) {
106
+ items.push((ordered ? om?.[2] : um?.[2])?.trimEnd() ?? "")
107
+ i++
108
+ } else if (isBlank(l)) {
109
+ i++
110
+ break
111
+ } else {
112
+ break
113
+ }
114
+ }
115
+ blocks.push({ type: "list", ordered, items })
116
+ continue
117
+ }
118
+
119
+ // Paragraph — consume until blank or next block
120
+ const buf: string[] = [line]
121
+ i++
122
+ while (i < lines.length) {
123
+ const l = lines[i]
124
+ if (isBlank(l)) {
125
+ i++
126
+ break
127
+ }
128
+ if (
129
+ /^```/.test(l) ||
130
+ /^(#{1,6})\s+/.test(l) ||
131
+ /^>\s?/.test(l) ||
132
+ /^\s*([-*+])\s+/.test(l) ||
133
+ /^\s*\d+\.\s+/.test(l)
134
+ ) {
135
+ break
136
+ }
137
+ buf.push(l)
138
+ i++
139
+ }
140
+ blocks.push({ type: "paragraph", text: buf.join("\n") })
141
+ }
142
+
143
+ return blocks
144
+ }
145
+
146
+ private parseInline(text: string, base?: StyleSpec): TextFragment[] {
147
+ // Recursive descent for bold/italic/strike/link; code spans take precedence and are not parsed inside.
148
+ const frags: TextFragment[] = []
149
+ const push = (t: string, style?: StyleSpec) => {
150
+ if (t.length === 0) return
151
+ frags.push({ text: t, style })
152
+ }
153
+ const walk = (s: string, inherited?: StyleSpec) => {
154
+ let i = 0
155
+ while (i < s.length) {
156
+ // Find nearest marker
157
+ const idxs: Array<{ i: number; kind: string }> = []
158
+ const find = (re: RegExp, kind: string) => {
159
+ const m = re.exec(s.slice(i))
160
+ if (m && m.index >= 0) idxs.push({ i: i + m.index, kind })
161
+ }
162
+ // Prioritize code and link first
163
+ find(/`/, "code")
164
+ find(/\[/, "link")
165
+ find(/\*\*|__/, "bold")
166
+ find(/\*|_/, "italic")
167
+ find(/~~/, "strike")
168
+ if (idxs.length === 0) {
169
+ push(s.slice(i), inherited)
170
+ break
171
+ }
172
+ idxs.sort((a, b) => a.i - b.i)
173
+ const next = idxs[0]
174
+ if (next.i > i) push(s.slice(i, next.i), inherited)
175
+ i = next.i
176
+ switch (next.kind) {
177
+ case "code": {
178
+ const j = s.indexOf("`", i + 1)
179
+ if (j === -1) {
180
+ push(s.slice(i), inherited)
181
+ i = s.length
182
+ } else {
183
+ const inner = s.slice(i + 1, j)
184
+ push(inner, mergeStyle(inherited, { bg: Colors.gray(2), fg: Colors.gray(15) }))
185
+ i = j + 1
186
+ }
187
+ break
188
+ }
189
+ case "link": {
190
+ const close = s.indexOf("]", i + 1)
191
+ if (close !== -1 && s[close + 1] === "(") {
192
+ const end = s.indexOf(")", close + 2)
193
+ if (end !== -1) {
194
+ const label = s.slice(i + 1, close)
195
+ const url = s.slice(close + 2, end)
196
+ // Style label as link; append a thin gray url in parentheses
197
+ const linkStyle = mergeStyle(inherited, { fg: Colors.brightBlue, underline: true })
198
+ walk(label, linkStyle)
199
+ push(` (${url})`, mergeStyle(inherited, { fg: Colors.gray(12), underline: false }))
200
+ i = end + 1
201
+ break
202
+ }
203
+ }
204
+ // Fallback: treat '[' as literal
205
+ push("[", inherited)
206
+ i += 1
207
+ break
208
+ }
209
+ case "bold": {
210
+ const marker = s.slice(i, i + 2)
211
+ const j = s.indexOf(marker, i + 2)
212
+ if (j === -1) {
213
+ push(s.slice(i), inherited)
214
+ i = s.length
215
+ } else {
216
+ const inner = s.slice(i + 2, j)
217
+ walk(inner, mergeStyle(inherited, { bold: true }))
218
+ i = j + 2
219
+ }
220
+ break
221
+ }
222
+ case "strike": {
223
+ const j = s.indexOf("~~", i + 2)
224
+ if (j === -1) {
225
+ push(s.slice(i), inherited)
226
+ i = s.length
227
+ } else {
228
+ const inner = s.slice(i + 2, j)
229
+ walk(inner, mergeStyle(inherited, { fg: Colors.gray(10) }))
230
+ i = j + 2
231
+ }
232
+ break
233
+ }
234
+ case "italic": {
235
+ const marker = s[i]
236
+ const j = s.indexOf(marker, i + 1)
237
+ if (j === -1) {
238
+ push(s.slice(i), inherited)
239
+ i = s.length
240
+ } else {
241
+ const inner = s.slice(i + 1, j)
242
+ walk(inner, mergeStyle(inherited, { italic: true }))
243
+ i = j + 1
244
+ }
245
+ break
246
+ }
247
+ }
248
+ }
249
+ }
250
+ walk(text, base)
251
+ return frags
252
+ }
253
+
254
+ private blockViews(_maxW: number): View[] {
255
+ const spacing = this.opts.spacing ?? 1
256
+ const nodes: View[] = []
257
+ const blocks = this.parseBlocks()
258
+
259
+ const styles = this.opts.styles ?? {}
260
+ const headingDefault: Partial<Record<number, StyleSpec>> = {
261
+ 1: { fg: Colors.brightCyan, bold: true },
262
+ 2: { fg: Colors.brightBlue, bold: true },
263
+ 3: { fg: Colors.brightMagenta, bold: true },
264
+ }
265
+
266
+ for (const b of blocks) {
267
+ switch (b.type) {
268
+ case "heading": {
269
+ const style =
270
+ styles.heading?.[b.level as 1 | 2 | 3 | 4 | 5 | 6] ?? (headingDefault[b.level] as StyleSpec | undefined)
271
+ const fr = this.parseInline(b.text, style)
272
+ nodes.push(new InlineText(fr, { wordWrap: true, breakWords: true }))
273
+ break
274
+ }
275
+ case "paragraph": {
276
+ const fr = this.parseInline(b.text, styles.paragraph)
277
+ nodes.push(new InlineText(fr, { wordWrap: true, breakWords: true }))
278
+ break
279
+ }
280
+ case "code": {
281
+ let node: View = new WrappedText(b.text, { wordWrap: false }).style(
282
+ styles.code ?? { fg: Colors.gray(15), bg: Colors.gray(2) },
283
+ )
284
+ // Use border if requested
285
+ if (this.opts.styles?.codeBorder) {
286
+ node = node.border({
287
+ kind: this.opts.styles.codeBorder.kind ?? "rounded",
288
+ color: this.opts.styles.codeBorder.color ?? Colors.gray(8),
289
+ padding: this.opts.styles.codeBorder.padding ?? { x: 1, y: 0 },
290
+ })
291
+ }
292
+ nodes.push(node)
293
+ break
294
+ }
295
+ case "quote": {
296
+ // Custom node draws a left bar and renders wrapped lines with a 2-col indent.
297
+ class QuoteBlock extends View {
298
+ constructor(readonly child: View) {
299
+ super()
300
+ }
301
+ protected measureContent(maxW: number, maxH: number) {
302
+ const innerW = Math.max(0, maxW - 2)
303
+ const m = this.child.measure(innerW, maxH)
304
+ return { w: Math.min(maxW, m.w + 2), h: m.h }
305
+ }
306
+ protected renderContent(s: Surface, pal: Palette, rect: Rect) {
307
+ const idBar = pal.id({ fg: Colors.gray(10) })
308
+ for (let yy = 0; yy < rect.h; yy++) s.drawCP(rect.x, rect.y + yy, "│".codePointAt(0)!, idBar)
309
+ const r = { x: rect.x + 2, y: rect.y, w: Math.max(0, rect.w - 2), h: rect.h }
310
+ this.child.render(s, pal, r)
311
+ }
312
+ }
313
+ const quoteStyle = styles.quote ?? { fg: Colors.gray(14) }
314
+ const rows = b.text.split("\n").map(
315
+ (line) =>
316
+ new InlineText(this.parseInline(line, quoteStyle), {
317
+ wordWrap: true,
318
+ breakWords: true,
319
+ }),
320
+ )
321
+ const body = new VStack(rows, 0)
322
+ nodes.push(new QuoteBlock(body))
323
+ break
324
+ }
325
+ case "list": {
326
+ const bulletStyle = styles.listBullet ?? { fg: Colors.gray(12), bold: true }
327
+ b.items.forEach((item, idx) => {
328
+ const mark = b.ordered ? `${idx + 1}. ` : "• "
329
+ const fragments: TextFragment[] = [
330
+ { text: mark, style: bulletStyle },
331
+ ...this.parseInline(item, { fg: Colors.white }),
332
+ ]
333
+ const row = new InlineText(fragments, { wordWrap: true, breakWords: true })
334
+ nodes.push(row)
335
+ // Add explicit spacer between list items to keep things readable
336
+ if (idx < b.items.length - 1 && spacing > 0) nodes.push(new WrappedText("", { wordWrap: false }))
337
+ })
338
+ break
339
+ }
340
+ }
341
+ // Vertical spacing between blocks
342
+ if (spacing > 0) nodes.push(new WrappedText("", { wordWrap: false }))
343
+ }
344
+
345
+ // Trim trailing spacer
346
+ if (nodes.length > 0) {
347
+ // Heuristic: empty WrappedText measures height 1 and no content; leave one blank line at most
348
+ nodes.pop()
349
+ }
350
+
351
+ return nodes
352
+ }
353
+
354
+ protected measureContent(maxW: number, maxH: number) {
355
+ const nodes = this.blockViews(maxW)
356
+ let w = 0
357
+ let h = 0
358
+ for (const n of nodes) {
359
+ const m = n.measure(maxW, Math.max(0, maxH - h))
360
+ w = Math.max(w, m.w)
361
+ h = Math.min(maxH, h + m.h)
362
+ if (h >= maxH) break
363
+ }
364
+ return { w: Math.min(maxW, w), h }
365
+ }
366
+
367
+ protected renderContent(s: Surface, pal: Palette, rect: Rect) {
368
+ const nodes = this.blockViews(rect.w)
369
+ let y = rect.y
370
+ for (const n of nodes) {
371
+ if (y >= rect.y + rect.h) break
372
+ const m = n.measure(rect.w, Math.max(0, rect.y + rect.h - y))
373
+ n.render(s, pal, { x: rect.x, y, w: Math.min(rect.w, m.w), h: Math.min(rect.h, m.h) })
374
+ y += m.h
375
+ }
376
+ }
377
+ }
378
+
379
+ // Builder contribution for the View object
380
+ export type ViewMarkdownExt = {
381
+ markdown(text: string, opts?: MarkdownOptions): View
382
+ }
383
+
384
+ export const viewMarkdown: ViewMarkdownExt = {
385
+ markdown(text: string, opts?: MarkdownOptions): View {
386
+ return new Markdown(text, opts)
387
+ },
388
+ }