opentui-markdown-render 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +27 -0
- package/README.md +223 -2
- package/bin/otmd.tsx +99 -0
- package/native/Cargo.lock +3708 -0
- package/native/Cargo.toml +28 -0
- package/native/build.rs +3 -0
- package/native/src/lib.rs +880 -0
- package/package.json +67 -4
- package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
- package/src/app.tsx +180 -0
- package/src/cli.ts +63 -0
- package/src/config/index.ts +46 -0
- package/src/config/load.ts +337 -0
- package/src/config/resolve.ts +36 -0
- package/src/config/theme.ts +110 -0
- package/src/config/types.ts +105 -0
- package/src/core/document.ts +44 -0
- package/src/core/frame.ts +144 -0
- package/src/core/image.ts +9 -0
- package/src/core/index.ts +6 -0
- package/src/core/layout.ts +27 -0
- package/src/core/math.ts +170 -0
- package/src/core/parse.ts +208 -0
- package/src/core/tables.ts +58 -0
- package/src/graphics.ts +85 -0
- package/src/headings.ts +77 -0
- package/src/index.ts +107 -0
- package/src/math/index.ts +86 -0
- package/src/math/native.ts +39 -0
- package/src/math/renderer.ts +84 -0
- package/src/math/validate.ts +44 -0
- package/src/media.tsx +267 -0
- package/src/merman/class/dagre-layout.ts +171 -0
- package/src/merman/class/diagram.ts +8 -0
- package/src/merman/class/drawing.ts +343 -0
- package/src/merman/class/labels.ts +38 -0
- package/src/merman/class/layout.ts +531 -0
- package/src/merman/class/parser.ts +308 -0
- package/src/merman/class/render-grid.ts +18 -0
- package/src/merman/class/style.ts +27 -0
- package/src/merman/class/types.ts +57 -0
- package/src/merman/core/canvas.ts +228 -0
- package/src/merman/core/color/style.ts +56 -0
- package/src/merman/core/drawing.ts +307 -0
- package/src/merman/core/geometry.ts +347 -0
- package/src/merman/core/mermaid.ts +38 -0
- package/src/merman/core/render-grid.ts +51 -0
- package/src/merman/core/spatial.ts +243 -0
- package/src/merman/core/text-lines.ts +32 -0
- package/src/merman/core/text.ts +44 -0
- package/src/merman/detect.ts +19 -0
- package/src/merman/diagnostics.ts +16 -0
- package/src/merman/diagram.ts +268 -0
- package/src/merman/flowchart/drawing.ts +433 -0
- package/src/merman/flowchart/labels.ts +144 -0
- package/src/merman/flowchart/layout.ts +1057 -0
- package/src/merman/flowchart/options.ts +12 -0
- package/src/merman/flowchart/parser.ts +477 -0
- package/src/merman/flowchart/render.ts +10 -0
- package/src/merman/flowchart/routing.ts +1429 -0
- package/src/merman/flowchart/style.ts +79 -0
- package/src/merman/flowchart/types.ts +63 -0
- package/src/merman/gantt/diagram.ts +8 -0
- package/src/merman/gantt/drawing.ts +193 -0
- package/src/merman/gantt/parser.ts +198 -0
- package/src/merman/gantt/render-grid.ts +17 -0
- package/src/merman/gantt/style.ts +36 -0
- package/src/merman/gantt/types.ts +45 -0
- package/src/merman/gitgraph/diagram.ts +8 -0
- package/src/merman/gitgraph/drawing.ts +234 -0
- package/src/merman/gitgraph/parser.ts +234 -0
- package/src/merman/gitgraph/render-grid.ts +17 -0
- package/src/merman/gitgraph/style.ts +37 -0
- package/src/merman/gitgraph/types.ts +36 -0
- package/src/merman/palette.ts +60 -0
- package/src/merman/sequence/diagram.ts +8 -0
- package/src/merman/sequence/drawing.ts +288 -0
- package/src/merman/sequence/options.ts +8 -0
- package/src/merman/sequence/parser.ts +303 -0
- package/src/merman/sequence/placement.ts +692 -0
- package/src/merman/sequence/render-grid.ts +22 -0
- package/src/merman/sequence/style.ts +81 -0
- package/src/merman/sequence/types.ts +74 -0
- package/src/merman/state/diagram.ts +8 -0
- package/src/merman/state/drawing.ts +363 -0
- package/src/merman/state/endpoint.ts +15 -0
- package/src/merman/state/layout.ts +1134 -0
- package/src/merman/state/note.ts +59 -0
- package/src/merman/state/options.ts +11 -0
- package/src/merman/state/parser.ts +245 -0
- package/src/merman/state/render-grid.ts +22 -0
- package/src/merman/state/routing.ts +1522 -0
- package/src/merman/state/search.ts +103 -0
- package/src/merman/state/style.ts +52 -0
- package/src/merman/state/types.ts +63 -0
- package/src/merman/state/visible-model.ts +77 -0
- package/src/merman/timeline/diagram.ts +8 -0
- package/src/merman/timeline/drawing.ts +109 -0
- package/src/merman/timeline/parser.ts +119 -0
- package/src/merman/timeline/render-grid.ts +17 -0
- package/src/merman/timeline/style.ts +36 -0
- package/src/merman/timeline/types.ts +31 -0
- package/src/merman.ts +3 -0
- package/src/statusline.tsx +183 -0
- package/src/theme/colors.ts +11 -0
- package/src/theme/defaults.ts +190 -0
- package/src/theme/index.ts +7 -0
- package/src/theme/load.ts +39 -0
- package/src/theme/resolve.ts +167 -0
- package/src/theme/syntax.ts +96 -0
- package/src/theme/types.ts +151 -0
- package/src/view/code.tsx +27 -0
- package/src/view/document-slots.ts +111 -0
- package/src/view/document-table.tsx +98 -0
- package/src/view/document.tsx +195 -0
- package/src/view/frame.tsx +83 -0
- package/src/view/inline-flow.tsx +113 -0
- package/src/view/inline-layout.ts +149 -0
- package/src/view/inline-math.ts +42 -0
- package/src/view/inline-style.ts +103 -0
- package/src/view/math.tsx +85 -0
- package/src/view/mermaid.tsx +128 -0
- package/src/view/table.ts +100 -0
- package/themes/dark.json +32 -0
- package/themes/example.jsonc +98 -0
- package/themes/light.json +32 -0
- package/themes/solarized-dark.json +32 -0
- package/themes/solarized-light.json +32 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { For, Show } from "solid-js"
|
|
2
|
+
import { TextAttributes, type RGBA } from "@opentui/core"
|
|
3
|
+
import { statusLineFields } from "./config/types"
|
|
4
|
+
import { deriveStatusLineTheme } from "./theme/resolve"
|
|
5
|
+
import type { Theme } from "./theme/types"
|
|
6
|
+
import type { StatusLineBorder, StatusLineField, StatusLineStyle } from "./config/types"
|
|
7
|
+
|
|
8
|
+
// The host owns the values; the library owns the layout and the colours.
|
|
9
|
+
export type StatusLineState = {
|
|
10
|
+
readonly title?: string
|
|
11
|
+
readonly mode?: string
|
|
12
|
+
readonly protocol?: string
|
|
13
|
+
readonly cell?: string
|
|
14
|
+
readonly mathScale?: number
|
|
15
|
+
readonly position?: string
|
|
16
|
+
readonly hint?: string
|
|
17
|
+
readonly text?: readonly string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type StatusLineSegment = {
|
|
21
|
+
readonly text: string
|
|
22
|
+
readonly emphasis: "strong" | "muted"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The colours the row is drawn with. `plain` keeps the line in the page
|
|
26
|
+
// colours, `bar` gives it its own background and an accent border.
|
|
27
|
+
export type StatusLinePalette = {
|
|
28
|
+
readonly background: RGBA
|
|
29
|
+
readonly foreground: RGBA
|
|
30
|
+
readonly title: RGBA
|
|
31
|
+
readonly muted: RGBA
|
|
32
|
+
readonly border: RGBA
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// `text` is a list field, so it is only rendered when a host asks for it.
|
|
36
|
+
export const defaultStatusLineFields: readonly StatusLineField[] = statusLineFields.filter(
|
|
37
|
+
(field) => field !== "text",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
// A field without a value is skipped, so a host only has to provide what it
|
|
41
|
+
// knows. `text` expands to every entry of the state's text list.
|
|
42
|
+
export function statusLineSegments(
|
|
43
|
+
state: StatusLineState,
|
|
44
|
+
fields: readonly StatusLineField[] = defaultStatusLineFields,
|
|
45
|
+
highlight: StatusLineField = "title",
|
|
46
|
+
): StatusLineSegment[] {
|
|
47
|
+
const segments: StatusLineSegment[] = []
|
|
48
|
+
for (const field of fields) {
|
|
49
|
+
if (field === "text") {
|
|
50
|
+
for (const entry of state.text ?? []) {
|
|
51
|
+
if (entry) segments.push({ text: entry, emphasis: "muted" })
|
|
52
|
+
}
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
const value = statusLineValue(state, field)
|
|
56
|
+
if (value === undefined) continue
|
|
57
|
+
segments.push({ text: value, emphasis: field === highlight ? "strong" : "muted" })
|
|
58
|
+
}
|
|
59
|
+
return segments
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatStatusLine(
|
|
63
|
+
state: StatusLineState,
|
|
64
|
+
fields: readonly StatusLineField[] = defaultStatusLineFields,
|
|
65
|
+
separator = " · ",
|
|
66
|
+
): string {
|
|
67
|
+
return statusLineSegments(state, fields)
|
|
68
|
+
.map((segment) => segment.text)
|
|
69
|
+
.join(separator)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The effective colours of the row. The bar style uses the theme's status line
|
|
73
|
+
// section, falling back to colours derived from the accent colour.
|
|
74
|
+
export function statusLinePalette(theme: Theme, style: StatusLineStyle = "bar"): StatusLinePalette {
|
|
75
|
+
if (style === "plain") {
|
|
76
|
+
return {
|
|
77
|
+
background: theme.background.base,
|
|
78
|
+
foreground: theme.text.muted,
|
|
79
|
+
title: theme.text.base,
|
|
80
|
+
muted: theme.text.muted,
|
|
81
|
+
border: theme.border.base,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return theme.statusline ?? deriveStatusLineTheme(theme)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function statusLineValue(state: StatusLineState, field: StatusLineField): string | undefined {
|
|
88
|
+
switch (field) {
|
|
89
|
+
case "title":
|
|
90
|
+
return state.title || undefined
|
|
91
|
+
case "mode":
|
|
92
|
+
return state.mode || undefined
|
|
93
|
+
case "protocol":
|
|
94
|
+
return state.protocol ? `img:${state.protocol}` : undefined
|
|
95
|
+
case "cell":
|
|
96
|
+
return state.cell
|
|
97
|
+
case "mathScale":
|
|
98
|
+
return state.mathScale === undefined ? undefined : `${state.mathScale.toFixed(1)}x`
|
|
99
|
+
case "position":
|
|
100
|
+
return state.position
|
|
101
|
+
case "hint":
|
|
102
|
+
return state.hint
|
|
103
|
+
case "text":
|
|
104
|
+
return undefined
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type StatusLinePart = {
|
|
109
|
+
readonly text: string
|
|
110
|
+
readonly kind: "value" | "separator"
|
|
111
|
+
readonly emphasis: "strong" | "muted"
|
|
112
|
+
// The separator that follows the highlighted field is drawn in the accent
|
|
113
|
+
// colour, so the highlighted field is cut off from the rest of the row.
|
|
114
|
+
readonly divider: boolean
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function partColor(part: StatusLinePart, palette: StatusLinePalette): RGBA {
|
|
118
|
+
if (part.kind === "separator") return part.divider ? palette.border : palette.muted
|
|
119
|
+
return part.emphasis === "strong" ? palette.title : palette.foreground
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function StatusLine(props: {
|
|
123
|
+
readonly theme: Theme
|
|
124
|
+
readonly state: () => StatusLineState
|
|
125
|
+
readonly fields?: readonly StatusLineField[]
|
|
126
|
+
readonly separator?: string
|
|
127
|
+
readonly border?: StatusLineBorder
|
|
128
|
+
readonly style?: StatusLineStyle
|
|
129
|
+
readonly highlight?: StatusLineField
|
|
130
|
+
}) {
|
|
131
|
+
const separator = () => props.separator ?? " · "
|
|
132
|
+
const style = () => props.style ?? "bar"
|
|
133
|
+
const highlight = () => props.highlight ?? "title"
|
|
134
|
+
const palette = () => statusLinePalette(props.theme, style())
|
|
135
|
+
const parts = (): StatusLinePart[] => {
|
|
136
|
+
const segments = statusLineSegments(props.state(), props.fields, highlight())
|
|
137
|
+
const result: StatusLinePart[] = []
|
|
138
|
+
let previousStrong = false
|
|
139
|
+
for (const segment of segments) {
|
|
140
|
+
if (result.length) {
|
|
141
|
+
result.push({ text: separator(), kind: "separator", emphasis: "muted", divider: previousStrong })
|
|
142
|
+
}
|
|
143
|
+
result.push({ text: segment.text, kind: "value", emphasis: segment.emphasis, divider: false })
|
|
144
|
+
previousStrong = segment.emphasis === "strong"
|
|
145
|
+
}
|
|
146
|
+
return result
|
|
147
|
+
}
|
|
148
|
+
const border = () => {
|
|
149
|
+
if (props.border === "top") return ["top"] as ["top"]
|
|
150
|
+
if (props.border === "bottom") return ["bottom"] as ["bottom"]
|
|
151
|
+
// An empty side list keeps the border off. Passing `false` together with
|
|
152
|
+
// a border colour makes OpenTUI draw a full frame instead.
|
|
153
|
+
return [] as []
|
|
154
|
+
}
|
|
155
|
+
return (
|
|
156
|
+
<box
|
|
157
|
+
width="100%"
|
|
158
|
+
flexShrink={0}
|
|
159
|
+
flexDirection="row"
|
|
160
|
+
alignItems="center"
|
|
161
|
+
paddingLeft={1}
|
|
162
|
+
paddingRight={1}
|
|
163
|
+
border={border()}
|
|
164
|
+
borderColor={palette().border}
|
|
165
|
+
backgroundColor={palette().background}
|
|
166
|
+
>
|
|
167
|
+
<Show when={parts().length}>
|
|
168
|
+
<For each={parts()}>
|
|
169
|
+
{(part) => (
|
|
170
|
+
<text
|
|
171
|
+
fg={partColor(part, palette())}
|
|
172
|
+
attributes={part.kind === "value" && part.emphasis === "strong" ? TextAttributes.BOLD : TextAttributes.NONE}
|
|
173
|
+
flexShrink={0}
|
|
174
|
+
wrapMode="none"
|
|
175
|
+
>
|
|
176
|
+
{part.text}
|
|
177
|
+
</text>
|
|
178
|
+
)}
|
|
179
|
+
</For>
|
|
180
|
+
</Show>
|
|
181
|
+
</box>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { RGBA } from "@opentui/core"
|
|
2
|
+
|
|
3
|
+
// Mixes `over` into `base` with the given weight. The result is opaque, so a
|
|
4
|
+
// tint computed this way can be used as a solid background.
|
|
5
|
+
export function mixColors(over: RGBA, base: RGBA, ratio: number): RGBA {
|
|
6
|
+
const weight = Math.min(Math.max(ratio, 0), 1)
|
|
7
|
+
const [overR, overG, overB] = over.toInts()
|
|
8
|
+
const [baseR, baseG, baseB] = base.toInts()
|
|
9
|
+
const channel = (from: number, to: number) => Math.round(to + (from - to) * weight)
|
|
10
|
+
return RGBA.fromInts(channel(overR, baseR), channel(overG, baseG), channel(overB, baseB), 255)
|
|
11
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { RGBA } from "@opentui/core"
|
|
2
|
+
import type { Theme, ThemePreset } from "./types"
|
|
3
|
+
|
|
4
|
+
const h = (hex: string) => RGBA.fromHex(hex)
|
|
5
|
+
|
|
6
|
+
// The canonical Solarized base tones and accents (8 + 8), without interpolated
|
|
7
|
+
// shades. Both presets share these swatches and differ only in how they use them.
|
|
8
|
+
const solarizedPalette = [
|
|
9
|
+
"#002b36", "#073642", "#586e75", "#657b83", "#839496", "#93a1a1", "#eee8d5", "#fdf6e3",
|
|
10
|
+
"#b58900", "#cb4b16", "#dc322f", "#d33682", "#6c71c4", "#268bd2", "#2aa198", "#859900",
|
|
11
|
+
].map(h)
|
|
12
|
+
|
|
13
|
+
const light: Theme = {
|
|
14
|
+
mode: "light",
|
|
15
|
+
accent: h("#0969da"),
|
|
16
|
+
categorical: [h("#0969da"), h("#8250df"), h("#1a7f37"), h("#9a6700"), h("#cf222e"), h("#bc4c00")],
|
|
17
|
+
text: {
|
|
18
|
+
base: h("#1f2328"),
|
|
19
|
+
muted: h("#59636e"),
|
|
20
|
+
feedback: {
|
|
21
|
+
error: h("#cf222e"),
|
|
22
|
+
warning: h("#9a6700"),
|
|
23
|
+
success: h("#1a7f37"),
|
|
24
|
+
info: h("#0969da"),
|
|
25
|
+
},
|
|
26
|
+
action: { primary: { focused: h("#0969da") } },
|
|
27
|
+
},
|
|
28
|
+
background: { base: h("#ffffff") },
|
|
29
|
+
border: { base: h("#8c959f") },
|
|
30
|
+
frame: { border: h("#8c959f"), title: h("#59636e") },
|
|
31
|
+
markdown: {
|
|
32
|
+
text: h("#1f2328"),
|
|
33
|
+
heading: h("#8250df"),
|
|
34
|
+
// The ramp of the heading levels: the top level takes the accent, the
|
|
35
|
+
// middle levels the heading colour, the lowest ones a readable text colour.
|
|
36
|
+
headingLevels: [h("#0969da"), h("#8250df"), h("#8250df"), h("#6f42c1"), h("#3d444d"), h("#3d444d")],
|
|
37
|
+
link: h("#0969da"),
|
|
38
|
+
linkText: h("#0969da"),
|
|
39
|
+
code: h("#953800"),
|
|
40
|
+
blockQuote: h("#59636e"),
|
|
41
|
+
emphasis: h("#1f2328"),
|
|
42
|
+
strong: h("#1f2328"),
|
|
43
|
+
horizontalRule: h("#d1d9e0"),
|
|
44
|
+
listItem: h("#1f2328"),
|
|
45
|
+
listEnumeration: h("#1f2328"),
|
|
46
|
+
image: h("#0969da"),
|
|
47
|
+
imageText: h("#0969da"),
|
|
48
|
+
codeBlock: h("#1f2328"),
|
|
49
|
+
},
|
|
50
|
+
syntax: {
|
|
51
|
+
comment: h("#59636e"),
|
|
52
|
+
keyword: h("#cf222e"),
|
|
53
|
+
function: h("#8250df"),
|
|
54
|
+
variable: h("#1f2328"),
|
|
55
|
+
string: h("#0a3069"),
|
|
56
|
+
number: h("#0550ae"),
|
|
57
|
+
type: h("#0550ae"),
|
|
58
|
+
operator: h("#0550ae"),
|
|
59
|
+
punctuation: h("#1f2328"),
|
|
60
|
+
},
|
|
61
|
+
diff: {
|
|
62
|
+
text: { added: h("#1a7f37"), removed: h("#cf222e"), context: h("#59636e") },
|
|
63
|
+
background: { added: h("#dafbe1"), removed: h("#ffebe9"), context: h("#f6f8fa") },
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const dark: Theme = {
|
|
68
|
+
mode: "dark",
|
|
69
|
+
accent: h("#58a6ff"),
|
|
70
|
+
categorical: [h("#58a6ff"), h("#d2a8ff"), h("#3fb950"), h("#d29922"), h("#f85149"), h("#ffa657")],
|
|
71
|
+
text: {
|
|
72
|
+
base: h("#e6edf3"),
|
|
73
|
+
muted: h("#8b949e"),
|
|
74
|
+
feedback: {
|
|
75
|
+
error: h("#f85149"),
|
|
76
|
+
warning: h("#d29922"),
|
|
77
|
+
success: h("#3fb950"),
|
|
78
|
+
info: h("#58a6ff"),
|
|
79
|
+
},
|
|
80
|
+
action: { primary: { focused: h("#58a6ff") } },
|
|
81
|
+
},
|
|
82
|
+
background: { base: h("#0d1117") },
|
|
83
|
+
border: { base: h("#30363d") },
|
|
84
|
+
frame: { border: h("#30363d"), title: h("#8b949e") },
|
|
85
|
+
markdown: {
|
|
86
|
+
text: h("#e6edf3"),
|
|
87
|
+
heading: h("#d2a8ff"),
|
|
88
|
+
headingLevels: [h("#58a6ff"), h("#d2a8ff"), h("#d2a8ff"), h("#8b949e"), h("#8b949e"), h("#8b949e")],
|
|
89
|
+
link: h("#58a6ff"),
|
|
90
|
+
linkText: h("#58a6ff"),
|
|
91
|
+
code: h("#ffa657"),
|
|
92
|
+
blockQuote: h("#8b949e"),
|
|
93
|
+
emphasis: h("#e6edf3"),
|
|
94
|
+
strong: h("#e6edf3"),
|
|
95
|
+
horizontalRule: h("#30363d"),
|
|
96
|
+
listItem: h("#e6edf3"),
|
|
97
|
+
listEnumeration: h("#e6edf3"),
|
|
98
|
+
image: h("#58a6ff"),
|
|
99
|
+
imageText: h("#58a6ff"),
|
|
100
|
+
codeBlock: h("#e6edf3"),
|
|
101
|
+
},
|
|
102
|
+
syntax: {
|
|
103
|
+
comment: h("#8b949e"),
|
|
104
|
+
keyword: h("#ff7b72"),
|
|
105
|
+
function: h("#d2a8ff"),
|
|
106
|
+
variable: h("#ffa657"),
|
|
107
|
+
string: h("#a5d6ff"),
|
|
108
|
+
number: h("#79c0ff"),
|
|
109
|
+
type: h("#ffa657"),
|
|
110
|
+
operator: h("#ff7b72"),
|
|
111
|
+
punctuation: h("#c9d1d9"),
|
|
112
|
+
},
|
|
113
|
+
diff: {
|
|
114
|
+
text: { added: h("#3fb950"), removed: h("#f85149"), context: h("#8b949e") },
|
|
115
|
+
background: { added: h("#12261e"), removed: h("#25171c"), context: h("#161b22") },
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const solarizedLight: Theme = {
|
|
120
|
+
mode: "light",
|
|
121
|
+
palette: solarizedPalette,
|
|
122
|
+
accent: h("#268bd2"),
|
|
123
|
+
categorical: [h("#268bd2"), h("#6c71c4"), h("#859900"), h("#b58900"), h("#dc322f"), h("#cb4b16")],
|
|
124
|
+
text: {
|
|
125
|
+
base: h("#586e75"), muted: h("#657b83"),
|
|
126
|
+
feedback: { error: h("#dc322f"), warning: h("#b58900"), success: h("#859900"), info: h("#268bd2") },
|
|
127
|
+
action: { primary: { focused: h("#268bd2") } },
|
|
128
|
+
},
|
|
129
|
+
background: { base: h("#fdf6e3") },
|
|
130
|
+
border: { base: h("#657b83") },
|
|
131
|
+
frame: { border: h("#657b83"), title: h("#586e75") },
|
|
132
|
+
markdown: {
|
|
133
|
+
text: h("#586e75"), heading: h("#268bd2"),
|
|
134
|
+
headingLevels: [h("#268bd2"), h("#268bd2"), h("#268bd2"), h("#268bd2"), h("#268bd2"), h("#268bd2")],
|
|
135
|
+
link: h("#268bd2"), linkText: h("#268bd2"), code: h("#cb4b16"), blockQuote: h("#657b83"),
|
|
136
|
+
emphasis: h("#586e75"), strong: h("#586e75"), horizontalRule: h("#657b83"),
|
|
137
|
+
listItem: h("#586e75"), listEnumeration: h("#586e75"), image: h("#268bd2"),
|
|
138
|
+
imageText: h("#268bd2"), codeBlock: h("#586e75"),
|
|
139
|
+
},
|
|
140
|
+
syntax: {
|
|
141
|
+
comment: h("#657b83"), keyword: h("#dc322f"), function: h("#6c71c4"), variable: h("#586e75"),
|
|
142
|
+
string: h("#2aa198"), number: h("#d33682"), type: h("#b58900"), operator: h("#268bd2"),
|
|
143
|
+
punctuation: h("#586e75"),
|
|
144
|
+
},
|
|
145
|
+
diff: {
|
|
146
|
+
text: { added: h("#859900"), removed: h("#dc322f"), context: h("#657b83") },
|
|
147
|
+
background: { added: h("#eee8d5"), removed: h("#eee8d5"), context: h("#eee8d5") },
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const solarizedDark: Theme = {
|
|
152
|
+
mode: "dark",
|
|
153
|
+
palette: solarizedPalette,
|
|
154
|
+
accent: h("#268bd2"),
|
|
155
|
+
categorical: [h("#268bd2"), h("#6c71c4"), h("#859900"), h("#b58900"), h("#dc322f"), h("#cb4b16")],
|
|
156
|
+
text: {
|
|
157
|
+
base: h("#93a1a1"), muted: h("#839496"),
|
|
158
|
+
feedback: { error: h("#dc322f"), warning: h("#b58900"), success: h("#859900"), info: h("#268bd2") },
|
|
159
|
+
action: { primary: { focused: h("#268bd2") } },
|
|
160
|
+
},
|
|
161
|
+
background: { base: h("#002b36") },
|
|
162
|
+
border: { base: h("#586e75") },
|
|
163
|
+
frame: { border: h("#586e75"), title: h("#93a1a1") },
|
|
164
|
+
markdown: {
|
|
165
|
+
text: h("#93a1a1"), heading: h("#268bd2"),
|
|
166
|
+
headingLevels: [h("#268bd2"), h("#268bd2"), h("#268bd2"), h("#268bd2"), h("#268bd2"), h("#268bd2")],
|
|
167
|
+
link: h("#268bd2"), linkText: h("#268bd2"), code: h("#cb4b16"), blockQuote: h("#839496"),
|
|
168
|
+
emphasis: h("#93a1a1"), strong: h("#93a1a1"), horizontalRule: h("#586e75"),
|
|
169
|
+
listItem: h("#93a1a1"), listEnumeration: h("#93a1a1"), image: h("#268bd2"),
|
|
170
|
+
imageText: h("#268bd2"), codeBlock: h("#93a1a1"),
|
|
171
|
+
},
|
|
172
|
+
syntax: {
|
|
173
|
+
comment: h("#839496"), keyword: h("#dc322f"), function: h("#6c71c4"), variable: h("#93a1a1"),
|
|
174
|
+
string: h("#2aa198"), number: h("#d33682"), type: h("#b58900"), operator: h("#268bd2"),
|
|
175
|
+
punctuation: h("#93a1a1"),
|
|
176
|
+
},
|
|
177
|
+
diff: {
|
|
178
|
+
text: { added: h("#859900"), removed: h("#dc322f"), context: h("#839496") },
|
|
179
|
+
background: { added: h("#073642"), removed: h("#073642"), context: h("#073642") },
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function defaultTheme(preset: ThemePreset): Theme {
|
|
184
|
+
switch (preset) {
|
|
185
|
+
case "light": return light
|
|
186
|
+
case "dark": return dark
|
|
187
|
+
case "solarized-light": return solarizedLight
|
|
188
|
+
case "solarized-dark": return solarizedDark
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { defaultTheme } from "./defaults"
|
|
2
|
+
export { loadTheme, bundledThemeNames, themeFileNames } from "./load"
|
|
3
|
+
export type { ThemeEnvironment } from "./load"
|
|
4
|
+
export { generateSyntax } from "./syntax"
|
|
5
|
+
export { resolveTheme, mergeTheme, deriveStatusLineTheme } from "./resolve"
|
|
6
|
+
export { mixColors } from "./colors"
|
|
7
|
+
export type { Theme, ThemeMode, ThemePreset, ThemeOverride, StatusLineTheme, StatusLineThemeOverride, FrameTheme, FrameThemeOverride, MarkdownThemeOverride, HeadingLevel } from "./types"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { parseJsonc } from "../config/load"
|
|
5
|
+
import { validateTheme } from "../config/theme"
|
|
6
|
+
import type { ThemeOverride } from "./types"
|
|
7
|
+
|
|
8
|
+
export const bundledThemeNames = ["light", "dark", "solarized-light", "solarized-dark"] as const
|
|
9
|
+
export const themeFileNames = [".jsonc", ".json"] as const
|
|
10
|
+
|
|
11
|
+
export type ThemeEnvironment = {
|
|
12
|
+
readonly XDG_CONFIG_HOME?: string
|
|
13
|
+
readonly HOME?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function loadTheme(
|
|
17
|
+
name: string,
|
|
18
|
+
environment: ThemeEnvironment = { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, HOME: process.env.HOME },
|
|
19
|
+
): ThemeOverride | undefined {
|
|
20
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) return undefined
|
|
21
|
+
const configured = environment.XDG_CONFIG_HOME
|
|
22
|
+
const configHome = configured && path.isAbsolute(configured)
|
|
23
|
+
? configured
|
|
24
|
+
: path.join(environment.HOME || os.homedir(), ".config")
|
|
25
|
+
const directories = [
|
|
26
|
+
path.join(configHome, "otmd", "themes"),
|
|
27
|
+
path.resolve(import.meta.dir, "..", "..", "themes"),
|
|
28
|
+
]
|
|
29
|
+
for (const directory of directories) {
|
|
30
|
+
for (const suffix of themeFileNames) {
|
|
31
|
+
const file = path.join(directory, `${name}${suffix}`)
|
|
32
|
+
if (!existsSync(file) || !statSync(file).isFile()) continue
|
|
33
|
+
const parsed = parseJsonc(readFileSync(file, "utf8"), file)
|
|
34
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined
|
|
35
|
+
return validateTheme(parsed as Record<string, unknown>, file)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { parseColor, type ColorInput, type RGBA } from "@opentui/core"
|
|
2
|
+
import { defaultTheme } from "./defaults"
|
|
3
|
+
import { mixColors } from "./colors"
|
|
4
|
+
import type { StatusLineTheme, StatusLineThemeOverride, Theme, ThemeMode, ThemePreset, ThemeOverride, MarkdownThemeOverride } from "./types"
|
|
5
|
+
|
|
6
|
+
export function resolveTheme(options: {
|
|
7
|
+
mode: ThemePreset | null | undefined
|
|
8
|
+
override?: ThemeOverride
|
|
9
|
+
}): Theme {
|
|
10
|
+
const override = options.override
|
|
11
|
+
const mode = override?.mode ?? options.mode ?? "light"
|
|
12
|
+
return mergeTheme(defaultTheme(mode), override)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The bar background is the accent colour toned down over the page background,
|
|
16
|
+
// so the strip separates from the page without a loud block of colour.
|
|
17
|
+
export function deriveStatusLineTheme(theme: {
|
|
18
|
+
readonly mode: ThemeMode
|
|
19
|
+
readonly palette?: readonly RGBA[]
|
|
20
|
+
readonly accent: RGBA
|
|
21
|
+
readonly background: { readonly base: RGBA }
|
|
22
|
+
readonly text: { readonly base: RGBA; readonly muted: RGBA }
|
|
23
|
+
}): StatusLineTheme {
|
|
24
|
+
// The same weight in both modes: a soft tint in light mode and a slightly
|
|
25
|
+
// stronger one in dark mode.
|
|
26
|
+
const ratio = theme.mode === "light" ? 0.12 : 0.14
|
|
27
|
+
return {
|
|
28
|
+
background: theme.palette?.[theme.mode === "light" ? 6 : 1] ?? mixColors(theme.accent, theme.background.base, ratio),
|
|
29
|
+
foreground: theme.text.base,
|
|
30
|
+
title: theme.accent,
|
|
31
|
+
muted: theme.text.muted,
|
|
32
|
+
border: theme.accent,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function mergeTheme(base: Theme, override: ThemeOverride | undefined): Theme {
|
|
37
|
+
if (!override) return base
|
|
38
|
+
const color = (value: ColorInput | undefined, fallback: RGBA) => (value === undefined ? fallback : parseColor(value))
|
|
39
|
+
const accent = color(override.accent, base.accent)
|
|
40
|
+
const palette = override.palette ? override.palette.map((entry) => parseColor(entry)) : base.palette
|
|
41
|
+
const background = { base: color(override.background?.base, base.background.base) }
|
|
42
|
+
const text = {
|
|
43
|
+
base: color(override.text?.base, base.text.base),
|
|
44
|
+
muted: color(override.text?.muted, base.text.muted),
|
|
45
|
+
}
|
|
46
|
+
const border = { base: color(override.border?.base, base.border.base) }
|
|
47
|
+
// An explicit border or muted-text override moves the frame with it, unless
|
|
48
|
+
// the frame names its own colour. A hand-built theme keeps its frame colours.
|
|
49
|
+
const frame = {
|
|
50
|
+
border: color(
|
|
51
|
+
override.frame?.border,
|
|
52
|
+
override.border?.base !== undefined ? border.base : (base.frame?.border ?? border.base),
|
|
53
|
+
),
|
|
54
|
+
title: color(
|
|
55
|
+
override.frame?.title,
|
|
56
|
+
override.text?.muted !== undefined ? text.muted : (base.frame?.title ?? text.muted),
|
|
57
|
+
),
|
|
58
|
+
}
|
|
59
|
+
const mode = override.mode ? (override.mode.endsWith("light") ? "light" : "dark") : base.mode
|
|
60
|
+
const derived = deriveStatusLineTheme({ mode, accent, background, text, palette })
|
|
61
|
+
return {
|
|
62
|
+
mode,
|
|
63
|
+
palette,
|
|
64
|
+
accent,
|
|
65
|
+
categorical: override.categorical ? override.categorical.map((entry) => parseColor(entry)) : base.categorical,
|
|
66
|
+
text: {
|
|
67
|
+
base: text.base,
|
|
68
|
+
muted: text.muted,
|
|
69
|
+
feedback: {
|
|
70
|
+
error: color(override.text?.feedback?.error, base.text.feedback.error),
|
|
71
|
+
warning: color(override.text?.feedback?.warning, base.text.feedback.warning),
|
|
72
|
+
success: color(override.text?.feedback?.success, base.text.feedback.success),
|
|
73
|
+
info: color(override.text?.feedback?.info, base.text.feedback.info),
|
|
74
|
+
},
|
|
75
|
+
action: {
|
|
76
|
+
primary: { focused: color(override.text?.action?.primary?.focused, base.text.action.primary.focused) },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
background,
|
|
80
|
+
border,
|
|
81
|
+
frame,
|
|
82
|
+
// The bar colours follow the merged accent, so overriding the accent in
|
|
83
|
+
// the config file moves the bar with it. A theme that already carries
|
|
84
|
+
// explicit bar colours keeps them.
|
|
85
|
+
statusline: mergeStatusLineTheme(derived, base.statusline, override.statusline),
|
|
86
|
+
markdown: mergeMarkdownTheme(base.markdown, override.markdown),
|
|
87
|
+
syntax: mergeColors(base.syntax, override.syntax),
|
|
88
|
+
diff: {
|
|
89
|
+
text: {
|
|
90
|
+
added: color(override.diff?.text?.added, base.diff.text.added),
|
|
91
|
+
removed: color(override.diff?.text?.removed, base.diff.text.removed),
|
|
92
|
+
context: color(override.diff?.text?.context, base.diff.text.context),
|
|
93
|
+
},
|
|
94
|
+
background: {
|
|
95
|
+
added: color(override.diff?.background?.added, base.diff.background.added),
|
|
96
|
+
removed: color(override.diff?.background?.removed, base.diff.background.removed),
|
|
97
|
+
context: color(override.diff?.background?.context, base.diff.background.context),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function mergeStatusLineTheme(
|
|
104
|
+
derived: StatusLineTheme,
|
|
105
|
+
base: StatusLineTheme | undefined,
|
|
106
|
+
override: StatusLineThemeOverride | undefined,
|
|
107
|
+
): StatusLineTheme {
|
|
108
|
+
const color = (value: ColorInput | undefined, fallback: RGBA) => (value === undefined ? fallback : parseColor(value))
|
|
109
|
+
const pick = (key: keyof StatusLineTheme) => color(override?.[key], base?.[key] ?? derived[key])
|
|
110
|
+
return {
|
|
111
|
+
background: pick("background"),
|
|
112
|
+
foreground: pick("foreground"),
|
|
113
|
+
title: pick("title"),
|
|
114
|
+
muted: pick("muted"),
|
|
115
|
+
border: pick("border"),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function mergeColors<T extends Record<string, RGBA>>(
|
|
120
|
+
base: T,
|
|
121
|
+
override: Partial<Record<keyof T, ColorInput>> | undefined,
|
|
122
|
+
): T {
|
|
123
|
+
if (!override) return base
|
|
124
|
+
const merged = { ...base } as Record<string, RGBA>
|
|
125
|
+
for (const key of Object.keys(base) as (keyof T)[]) {
|
|
126
|
+
const value = override[key]
|
|
127
|
+
if (value !== undefined) merged[key as string] = parseColor(value)
|
|
128
|
+
}
|
|
129
|
+
return merged as T
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The heading colours are resolved on their own: a colour for a single level
|
|
133
|
+
// only replaces that level, while a base heading colour replaces the whole
|
|
134
|
+
// ramp, which is how the theme behaved before the levels existed.
|
|
135
|
+
function mergeMarkdownTheme(
|
|
136
|
+
base: Theme["markdown"],
|
|
137
|
+
override: MarkdownThemeOverride | undefined,
|
|
138
|
+
): Theme["markdown"] {
|
|
139
|
+
const levels = (override ?? {}) as Record<string, ColorInput | undefined>
|
|
140
|
+
const pick = (level: number, fallback: RGBA) => {
|
|
141
|
+
const color = levels[`heading${level}`]
|
|
142
|
+
if (color !== undefined) return parseColor(color)
|
|
143
|
+
if (levels.heading !== undefined) return parseColor(levels.heading)
|
|
144
|
+
return fallback
|
|
145
|
+
}
|
|
146
|
+
// A theme that was built by hand may not carry the ramp; every level then
|
|
147
|
+
// takes the base heading colour.
|
|
148
|
+
const [one, two, three, four, five, six] = base.headingLevels ?? [
|
|
149
|
+
base.heading,
|
|
150
|
+
base.heading,
|
|
151
|
+
base.heading,
|
|
152
|
+
base.heading,
|
|
153
|
+
base.heading,
|
|
154
|
+
base.heading,
|
|
155
|
+
]
|
|
156
|
+
const headingLevels: Theme["markdown"]["headingLevels"] = [
|
|
157
|
+
pick(1, one),
|
|
158
|
+
pick(2, two),
|
|
159
|
+
pick(3, three),
|
|
160
|
+
pick(4, four),
|
|
161
|
+
pick(5, five),
|
|
162
|
+
pick(6, six),
|
|
163
|
+
]
|
|
164
|
+
const { headingLevels: _levels, ...colors } = base
|
|
165
|
+
const merged = mergeColors(colors, override)
|
|
166
|
+
return { ...merged, headingLevels }
|
|
167
|
+
}
|