opentui-markdown-render 0.2.2 → 0.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentui-markdown-render",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "OpenTUI Markdown renderer with optional native LaTeX math",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/app.tsx CHANGED
@@ -1,5 +1,5 @@
1
- import type { CliRenderer, SyntaxStyle } from "@opentui/core"
2
- import { createMemo, createSignal, onCleanup } from "solid-js"
1
+ import type { CliRenderer } from "@opentui/core"
2
+ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
3
3
  import { useCanvasGraphics } from "./graphics"
4
4
  import { resolveHeadingStyles } from "./headings"
5
5
  import { rgbaToHex } from "./math"
@@ -155,14 +155,14 @@ export function MarkdownRenderer(props: MarkdownRendererProps) {
155
155
  // The look of the heading levels is a configuration of its own: the colours
156
156
  // come from the theme, the attributes and the spacing from the config file.
157
157
  const headings = createMemo(() => resolveHeadingStyles(config().headings))
158
- let currentSyntax: SyntaxStyle | undefined
159
158
  const syntaxStyle = createMemo(() => {
160
- currentSyntax?.destroy()
161
159
  const nativeScene = (renderer as CliRenderer & { readonly nativeScene?: object }).nativeScene
162
- currentSyntax = generateSyntax(theme(), headings(), theme().markdown.codeBlock, nativeScene)
163
- return currentSyntax
160
+ return generateSyntax(theme(), headings(), theme().markdown.codeBlock, nativeScene)
161
+ })
162
+ createEffect(() => {
163
+ const style = syntaxStyle()
164
+ onCleanup(() => void renderer.idle().then(() => style.destroy()))
164
165
  })
165
- onCleanup(() => currentSyntax?.destroy())
166
166
  return (
167
167
  <box
168
168
  width="100%"
package/src/media.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { NativeImage, imageInfo, parseColor, type ColorInput, type RGBA } from "@opentui/core"
2
- import { decompressFrames, parseGIF } from "gifuct-js"
2
+ import { useRenderer } from "@opentui/solid"
3
+ import { decompressFrame, parseGIF, type ParsedGif } from "gifuct-js"
3
4
  import path from "node:path"
4
5
  import { createEffect, createResource, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
5
6
 
@@ -44,8 +45,16 @@ export function rgbToHex(color: Rgb) {
44
45
  return `#${channel(color[0])}${channel(color[1])}${channel(color[2])}`
45
46
  }
46
47
 
47
- type AnimatedFrame = { readonly image: NativeImage; readonly delay: number }
48
- type Media = { readonly box: { columns: number; rows: number }; readonly image?: NativeImage; readonly frames?: readonly AnimatedFrame[] }
48
+ type GifFrame = Parameters<typeof decompressFrame>[0]
49
+ type GifAnimation = {
50
+ readonly width: number
51
+ readonly height: number
52
+ readonly frames: readonly GifFrame[]
53
+ readonly colorTable: ParsedGif["gct"]
54
+ readonly target: { width: number; height: number }
55
+ readonly background: Rgb
56
+ }
57
+ type Media = { readonly box: { columns: number; rows: number }; readonly image?: NativeImage; readonly animation?: GifAnimation }
49
58
 
50
59
  export { standaloneImage, type MarkdownImage } from "./core/image"
51
60
 
@@ -88,6 +97,7 @@ export function LocalImage(props: {
88
97
  readonly textColor?: () => RGBA
89
98
  readonly limits?: ImageLimits
90
99
  }) {
100
+ const renderer = useRenderer()
91
101
  const [failure, setFailure] = createSignal<string>()
92
102
  // The cell size is part of the key so the image is reloaded and re-fitted once
93
103
  // the terminal reports its pixel resolution (kitty queries it after startup).
@@ -145,26 +155,40 @@ export function LocalImage(props: {
145
155
 
146
156
  createEffect(() => {
147
157
  const value = media()
148
- if (!value?.frames?.length) {
158
+ if (!value?.animation) {
149
159
  setFrame(value?.image)
150
160
  return
151
161
  }
162
+ const animation = value.animation
163
+ const canvas = new Uint8Array(animation.width * animation.height * 4)
152
164
  let index = 0
165
+ let current: NativeImage | undefined
153
166
  let timer: ReturnType<typeof setTimeout> | undefined
154
167
  const next = () => {
155
- const current = value.frames![index]!
156
- setFrame(current.image)
157
- index = (index + 1) % value.frames!.length
158
- timer = setTimeout(next, current.delay)
168
+ const decoded = decompressFrame(animation.frames[index]!, animation.colorTable, true)
169
+ const before = decoded.disposalType === 3 ? canvas.slice() : undefined
170
+ drawPatch(canvas, animation.width, animation.height, decoded.patch, decoded.dims.left, decoded.dims.top, decoded.dims.width, decoded.dims.height)
171
+ const image = prepareImage(NativeImage.fromRgba(canvas.slice(), animation.width, animation.height), animation.target, animation.background)
172
+ const previous = current
173
+ current = image
174
+ setFrame(image)
175
+ if (previous) void renderer.idle().then(() => previous.dispose())
176
+ if (decoded.disposalType === 2) clearPatch(canvas, animation.width, decoded.dims.left, decoded.dims.top, decoded.dims.width, decoded.dims.height)
177
+ if (before) canvas.set(before)
178
+ index = (index + 1) % animation.frames.length
179
+ timer = setTimeout(next, Math.max(50, decoded.delay || 100))
159
180
  }
160
181
  next()
161
182
  onCleanup(() => {
162
183
  if (timer) clearTimeout(timer)
184
+ const image = current
185
+ if (image) void renderer.idle().then(() => image.dispose())
186
+ setFrame()
163
187
  })
164
188
  })
165
189
  createEffect(() => {
166
190
  const value = media()
167
- onCleanup(() => disposeMedia(value))
191
+ onCleanup(() => void renderer.idle().then(() => disposeMedia(value)))
168
192
  })
169
193
 
170
194
  return (
@@ -197,21 +221,13 @@ export function LocalImage(props: {
197
221
  )
198
222
  }
199
223
 
200
- function loadGif(bytes: Uint8Array, box: { columns: number; rows: number }, target: { width: number; height: number }, background: Rgb) {
224
+ export function loadGif(bytes: Uint8Array, box: { columns: number; rows: number }, target: { width: number; height: number }, background: Rgb) {
201
225
  const gif = parseGIF(bytes.slice().buffer as ArrayBuffer)
202
226
  const width = gif.lsd.width
203
227
  const height = gif.lsd.height
204
- const canvas = new Uint8Array(width * height * 4)
205
- const frames = decompressFrames(gif, true).map((frame) => {
206
- const before = canvas.slice()
207
- drawPatch(canvas, width, height, frame.patch, frame.dims.left, frame.dims.top, frame.dims.width, frame.dims.height)
208
- const image = prepareImage(NativeImage.fromRgba(canvas.slice(), width, height), target, background)
209
- if (frame.disposalType === 2) clearPatch(canvas, width, frame.dims.left, frame.dims.top, frame.dims.width, frame.dims.height)
210
- if (frame.disposalType === 3) canvas.set(before)
211
- return { image, delay: Math.max(50, frame.delay || 100) }
212
- })
228
+ const frames = gif.frames.filter((frame): frame is GifFrame => "image" in frame)
213
229
  if (!frames.length) throw new Error("GIF contains no frames")
214
- return { box, frames }
230
+ return { box, animation: { width, height, frames, colorTable: gif.gct, target, background } }
215
231
  }
216
232
 
217
233
  function drawPatch(canvas: Uint8Array, width: number, height: number, patch: Uint8ClampedArray, left: number, top: number, patchWidth: number, patchHeight: number) {
@@ -263,5 +279,4 @@ function prepareImage(image: NativeImage, target: { width: number; height: numbe
263
279
 
264
280
  function disposeMedia(media: Media | undefined) {
265
281
  media?.image?.dispose()
266
- media?.frames?.forEach((frame) => frame.image.dispose())
267
282
  }
package/src/view/code.tsx CHANGED
@@ -13,15 +13,22 @@ export type CodeBlockProps = {
13
13
  syntaxStyle?: SyntaxStyle
14
14
  }
15
15
 
16
+ export function resolveCodeFiletype(language: string | null) {
17
+ const token = language?.trim().split(/\s+/, 1)[0]?.toLowerCase() ?? ""
18
+ return token === "jsonc" ? "json" : infoStringToFiletype(language ?? "")
19
+ }
20
+
16
21
  export function CodeBlock(props: CodeBlockProps) {
17
22
  const renderer = useRenderer()
18
23
  const nativeScene = (renderer as typeof renderer & { readonly nativeScene?: object }).nativeScene
19
24
  const owned = props.syntaxStyle ? undefined : generateSyntax(props.theme, props.headings, props.theme.markdown.codeBlock, nativeScene)
20
- onCleanup(() => owned?.destroy())
25
+ onCleanup(() => {
26
+ if (owned) void renderer.idle().then(() => owned.destroy())
27
+ })
21
28
  return (
22
29
  <code
23
30
  content={props.content}
24
- filetype={infoStringToFiletype(props.language ?? "")}
31
+ filetype={resolveCodeFiletype(props.language)}
25
32
  syntaxStyle={props.syntaxStyle ?? owned!}
26
33
  fg={props.theme.markdown.codeBlock}
27
34
  bg={props.theme.background.base}
@@ -67,7 +67,7 @@ export function DocumentView(props: DocumentViewProps) {
67
67
  if (input.block().type === "mermaid") {
68
68
  return <MermaidDiagramView source={(input.block() as Extract<Block, { type: "mermaid" }>).content}
69
69
  theme={props.theme} columns={input.columns} mermaidOptions={props.mermaidOptions}
70
- headings={props.headings} onDiagnostic={props.onDiagnostic} />
70
+ headings={props.headings} syntaxStyle={props.syntaxStyle} onDiagnostic={props.onDiagnostic} />
71
71
  }
72
72
  const view = createMemo(() => {
73
73
  const block = input.block()
@@ -25,24 +25,34 @@ export type InlineFlowProps = {
25
25
 
26
26
  function PrefixSegment(props: { chunks: () => TextChunk[]; width: number }) {
27
27
  let ref: TextRenderable | undefined
28
- createEffect(() => {
29
- if (ref) ref.content = new StyledText(props.chunks())
30
- })
31
- return <text ref={ref} wrapMode="none" width={props.width} flexShrink={0} />
28
+ const update = () => {
29
+ const content = new StyledText(props.chunks())
30
+ if (ref) ref.content = content
31
+ return content
32
+ }
33
+ createEffect(update)
34
+ return <text ref={(renderable) => {
35
+ ref = renderable
36
+ renderable.content = update()
37
+ }} wrapMode="none" width={props.width} flexShrink={0} />
32
38
  }
33
39
 
34
40
  function StyledSegment(props: { entry: LayoutRun; textRow: number }) {
35
41
  let ref: TextRenderable | undefined
36
- createEffect(() => {
42
+ const update = () => {
37
43
  const atom = props.entry.atom
38
- if (ref && atom.type === "text") {
39
- ref.content = new StyledText([{
40
- __isChunk: true, text: atom.text, fg: atom.fg, attributes: atom.attributes,
41
- ...(atom.href ? { link: { url: atom.href } } : {}),
42
- }])
43
- }
44
- })
45
- return <text ref={ref} width={props.entry.columns} flexShrink={0} wrapMode="none" marginTop={props.textRow} />
44
+ const content = atom.type === "text" ? new StyledText([{
45
+ __isChunk: true, text: atom.text, fg: atom.fg, attributes: atom.attributes,
46
+ ...(atom.href ? { link: { url: atom.href } } : {}),
47
+ }]) : new StyledText([])
48
+ if (ref) ref.content = content
49
+ return content
50
+ }
51
+ createEffect(update)
52
+ return <text ref={(renderable) => {
53
+ ref = renderable
54
+ renderable.content = update()
55
+ }} width={props.entry.columns} flexShrink={0} wrapMode="none" marginTop={props.textRow} />
46
56
  }
47
57
 
48
58
  export function InlineFlow(props: InlineFlowProps) {