@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.
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/anim.d.ts +4 -0
- package/dist/anim.d.ts.map +1 -0
- package/dist/anim.js +5 -0
- package/dist/anim.js.map +1 -0
- package/dist/ansi.d.ts +69 -0
- package/dist/ansi.d.ts.map +1 -0
- package/dist/ansi.js +72 -0
- package/dist/ansi.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +18 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +247 -0
- package/dist/keys.js.map +1 -0
- package/dist/layout/linearStack.d.ts +17 -0
- package/dist/layout/linearStack.d.ts.map +1 -0
- package/dist/layout/linearStack.js +86 -0
- package/dist/layout/linearStack.js.map +1 -0
- package/dist/motion-value.d.ts +58 -0
- package/dist/motion-value.d.ts.map +1 -0
- package/dist/motion-value.js +250 -0
- package/dist/motion-value.js.map +1 -0
- package/dist/present/display.d.ts +58 -0
- package/dist/present/display.d.ts.map +1 -0
- package/dist/present/display.js +168 -0
- package/dist/present/display.js.map +1 -0
- package/dist/present/writers/fullscreen.d.ts +19 -0
- package/dist/present/writers/fullscreen.d.ts.map +1 -0
- package/dist/present/writers/fullscreen.js +55 -0
- package/dist/present/writers/fullscreen.js.map +1 -0
- package/dist/present/writers/inline.d.ts +20 -0
- package/dist/present/writers/inline.d.ts.map +1 -0
- package/dist/present/writers/inline.js +92 -0
- package/dist/present/writers/inline.js.map +1 -0
- package/dist/render/buffer.d.ts +31 -0
- package/dist/render/buffer.d.ts.map +1 -0
- package/dist/render/buffer.js +183 -0
- package/dist/render/buffer.js.map +1 -0
- package/dist/render/color-utils.d.ts +18 -0
- package/dist/render/color-utils.d.ts.map +1 -0
- package/dist/render/color-utils.js +58 -0
- package/dist/render/color-utils.js.map +1 -0
- package/dist/render/diff.d.ts +30 -0
- package/dist/render/diff.d.ts.map +1 -0
- package/dist/render/diff.js +83 -0
- package/dist/render/diff.js.map +1 -0
- package/dist/render/measure.d.ts +15 -0
- package/dist/render/measure.d.ts.map +1 -0
- package/dist/render/measure.js +65 -0
- package/dist/render/measure.js.map +1 -0
- package/dist/render/palette.d.ts +46 -0
- package/dist/render/palette.d.ts.map +1 -0
- package/dist/render/palette.js +108 -0
- package/dist/render/palette.js.map +1 -0
- package/dist/render/surface.d.ts +77 -0
- package/dist/render/surface.d.ts.map +1 -0
- package/dist/render/surface.js +198 -0
- package/dist/render/surface.js.map +1 -0
- package/dist/runtime/backend_node.d.ts +36 -0
- package/dist/runtime/backend_node.d.ts.map +1 -0
- package/dist/runtime/backend_node.js +66 -0
- package/dist/runtime/backend_node.js.map +1 -0
- package/dist/spring-physics.d.ts +36 -0
- package/dist/spring-physics.d.ts.map +1 -0
- package/dist/spring-physics.js +113 -0
- package/dist/spring-physics.js.map +1 -0
- package/dist/spring.d.ts +73 -0
- package/dist/spring.d.ts.map +1 -0
- package/dist/spring.js +136 -0
- package/dist/spring.js.map +1 -0
- package/dist/ui/containers/canvas.d.ts +13 -0
- package/dist/ui/containers/canvas.d.ts.map +1 -0
- package/dist/ui/containers/canvas.js +16 -0
- package/dist/ui/containers/canvas.js.map +1 -0
- package/dist/ui/containers/geometry-reader.d.ts +17 -0
- package/dist/ui/containers/geometry-reader.d.ts.map +1 -0
- package/dist/ui/containers/geometry-reader.js +24 -0
- package/dist/ui/containers/geometry-reader.js.map +1 -0
- package/dist/ui/containers/hstack.d.ts +12 -0
- package/dist/ui/containers/hstack.d.ts.map +1 -0
- package/dist/ui/containers/hstack.js +28 -0
- package/dist/ui/containers/hstack.js.map +1 -0
- package/dist/ui/containers/scroll.d.ts +28 -0
- package/dist/ui/containers/scroll.d.ts.map +1 -0
- package/dist/ui/containers/scroll.js +97 -0
- package/dist/ui/containers/scroll.js.map +1 -0
- package/dist/ui/containers/shared.d.ts +12 -0
- package/dist/ui/containers/shared.d.ts.map +1 -0
- package/dist/ui/containers/shared.js +19 -0
- package/dist/ui/containers/shared.js.map +1 -0
- package/dist/ui/containers/vstack.d.ts +12 -0
- package/dist/ui/containers/vstack.d.ts.map +1 -0
- package/dist/ui/containers/vstack.js +28 -0
- package/dist/ui/containers/vstack.js.map +1 -0
- package/dist/ui/containers/zstack.d.ts +14 -0
- package/dist/ui/containers/zstack.d.ts.map +1 -0
- package/dist/ui/containers/zstack.js +36 -0
- package/dist/ui/containers/zstack.js.map +1 -0
- package/dist/ui/core/geometry-store.d.ts +22 -0
- package/dist/ui/core/geometry-store.d.ts.map +1 -0
- package/dist/ui/core/geometry-store.js +29 -0
- package/dist/ui/core/geometry-store.js.map +1 -0
- package/dist/ui/core/geometry.d.ts +34 -0
- package/dist/ui/core/geometry.d.ts.map +1 -0
- package/dist/ui/core/geometry.js +14 -0
- package/dist/ui/core/geometry.js.map +1 -0
- package/dist/ui/core/view.d.ts +25 -0
- package/dist/ui/core/view.d.ts.map +1 -0
- package/dist/ui/core/view.js +34 -0
- package/dist/ui/core/view.js.map +1 -0
- package/dist/ui/index.d.ts +44 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +39 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/inlinetext.d.ts +24 -0
- package/dist/ui/inlinetext.d.ts.map +1 -0
- package/dist/ui/inlinetext.js +131 -0
- package/dist/ui/inlinetext.js.map +1 -0
- package/dist/ui/install.d.ts +22 -0
- package/dist/ui/install.d.ts.map +1 -0
- package/dist/ui/install.js +66 -0
- package/dist/ui/install.js.map +1 -0
- package/dist/ui/markdown.d.ts +40 -0
- package/dist/ui/markdown.d.ts.map +1 -0
- package/dist/ui/markdown.js +351 -0
- package/dist/ui/markdown.js.map +1 -0
- package/dist/ui/modifiers/border.d.ts +33 -0
- package/dist/ui/modifiers/border.d.ts.map +1 -0
- package/dist/ui/modifiers/border.js +82 -0
- package/dist/ui/modifiers/border.js.map +1 -0
- package/dist/ui/modifiers/fill.d.ts +14 -0
- package/dist/ui/modifiers/fill.d.ts.map +1 -0
- package/dist/ui/modifiers/fill.js +25 -0
- package/dist/ui/modifiers/fill.js.map +1 -0
- package/dist/ui/modifiers/frame.d.ts +23 -0
- package/dist/ui/modifiers/frame.d.ts.map +1 -0
- package/dist/ui/modifiers/frame.js +54 -0
- package/dist/ui/modifiers/frame.js.map +1 -0
- package/dist/ui/modifiers/offset.d.ts +15 -0
- package/dist/ui/modifiers/offset.d.ts.map +1 -0
- package/dist/ui/modifiers/offset.js +21 -0
- package/dist/ui/modifiers/offset.js.map +1 -0
- package/dist/ui/modifiers/opacity.d.ts +15 -0
- package/dist/ui/modifiers/opacity.d.ts.map +1 -0
- package/dist/ui/modifiers/opacity.js +95 -0
- package/dist/ui/modifiers/opacity.js.map +1 -0
- package/dist/ui/modifiers/padding.d.ts +20 -0
- package/dist/ui/modifiers/padding.d.ts.map +1 -0
- package/dist/ui/modifiers/padding.js +36 -0
- package/dist/ui/modifiers/padding.js.map +1 -0
- package/dist/ui/modifiers/styled.d.ts +14 -0
- package/dist/ui/modifiers/styled.d.ts.map +1 -0
- package/dist/ui/modifiers/styled.js +26 -0
- package/dist/ui/modifiers/styled.js.map +1 -0
- package/dist/ui/primitives/rectangle.d.ts +15 -0
- package/dist/ui/primitives/rectangle.d.ts.map +1 -0
- package/dist/ui/primitives/rectangle.js +23 -0
- package/dist/ui/primitives/rectangle.js.map +1 -0
- package/dist/ui/primitives/spacer.d.ts +13 -0
- package/dist/ui/primitives/spacer.d.ts.map +1 -0
- package/dist/ui/primitives/spacer.js +16 -0
- package/dist/ui/primitives/spacer.js.map +1 -0
- package/dist/ui/primitives/text.d.ts +15 -0
- package/dist/ui/primitives/text.d.ts.map +1 -0
- package/dist/ui/primitives/text.js +79 -0
- package/dist/ui/primitives/text.js.map +1 -0
- package/dist/ui/primitives/wrapped-text.d.ts +30 -0
- package/dist/ui/primitives/wrapped-text.d.ts.map +1 -0
- package/dist/ui/primitives/wrapped-text.js +117 -0
- package/dist/ui/primitives/wrapped-text.js.map +1 -0
- package/dist/ui/shinytext.d.ts +66 -0
- package/dist/ui/shinytext.d.ts.map +1 -0
- package/dist/ui/shinytext.js +99 -0
- package/dist/ui/shinytext.js.map +1 -0
- package/dist/ui/text/layout.d.ts +35 -0
- package/dist/ui/text/layout.d.ts.map +1 -0
- package/dist/ui/text/layout.js +102 -0
- package/dist/ui/text/layout.js.map +1 -0
- package/dist/ui/textinput.d.ts +140 -0
- package/dist/ui/textinput.d.ts.map +1 -0
- package/dist/ui/textinput.js +402 -0
- package/dist/ui/textinput.js.map +1 -0
- package/dist/ui/view-constructors.d.ts +72 -0
- package/dist/ui/view-constructors.d.ts.map +1 -0
- package/dist/ui/view-constructors.js +74 -0
- package/dist/ui/view-constructors.js.map +1 -0
- package/package.json +57 -0
- package/src/anim.ts +5 -0
- package/src/ansi.ts +83 -0
- package/src/index.ts +21 -0
- package/src/keys.ts +302 -0
- package/src/layout/linearStack.ts +115 -0
- package/src/motion-value.ts +335 -0
- package/src/present/display.ts +206 -0
- package/src/present/writers/fullscreen.ts +58 -0
- package/src/present/writers/inline.ts +101 -0
- package/src/render/buffer.ts +200 -0
- package/src/render/color-utils.ts +60 -0
- package/src/render/diff.ts +95 -0
- package/src/render/measure.ts +74 -0
- package/src/render/palette.ts +113 -0
- package/src/render/surface.ts +238 -0
- package/src/runtime/backend_node.ts +80 -0
- package/src/spring-physics.ts +151 -0
- package/src/spring.ts +234 -0
- package/src/ui/__snapshots__/wrappedtext.test.ts.snap +57 -0
- package/src/ui/containers/canvas.ts +18 -0
- package/src/ui/containers/geometry-reader.ts +32 -0
- package/src/ui/containers/hstack.ts +33 -0
- package/src/ui/containers/scroll.ts +106 -0
- package/src/ui/containers/shared.ts +27 -0
- package/src/ui/containers/vstack.ts +34 -0
- package/src/ui/containers/zstack.ts +37 -0
- package/src/ui/core/geometry-store.ts +42 -0
- package/src/ui/core/geometry.ts +30 -0
- package/src/ui/core/view.ts +49 -0
- package/src/ui/index.ts +84 -0
- package/src/ui/inlinetext.ts +135 -0
- package/src/ui/install.ts +110 -0
- package/src/ui/markdown.test.ts +74 -0
- package/src/ui/markdown.ts +388 -0
- package/src/ui/modifiers/border.ts +100 -0
- package/src/ui/modifiers/fill.ts +28 -0
- package/src/ui/modifiers/frame.ts +74 -0
- package/src/ui/modifiers/offset.ts +23 -0
- package/src/ui/modifiers/opacity.ts +93 -0
- package/src/ui/modifiers/padding.ts +53 -0
- package/src/ui/modifiers/styled.ts +31 -0
- package/src/ui/primitives/rectangle.ts +25 -0
- package/src/ui/primitives/spacer.ts +18 -0
- package/src/ui/primitives/text.ts +85 -0
- package/src/ui/primitives/wrapped-text.ts +131 -0
- package/src/ui/shinytext.ts +159 -0
- package/src/ui/text/layout.ts +119 -0
- package/src/ui/textinput.ts +496 -0
- package/src/ui/view-constructors.ts +96 -0
- 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
|
+
}
|