-
Notifications
You must be signed in to change notification settings - Fork 32
/
Copy pathMarkdownComponent.kt
284 lines (238 loc) · 8.88 KB
/
MarkdownComponent.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
package gg.essential.elementa.markdown
import gg.essential.elementa.UIComponent
import gg.essential.elementa.components.MarkdownNode
import gg.essential.elementa.components.TreeListComponent
import gg.essential.elementa.components.TreeNode
import gg.essential.elementa.components.Window
import gg.essential.elementa.constraints.HeightConstraint
import gg.essential.elementa.dsl.pixels
import gg.essential.elementa.events.UIEvent
import gg.essential.elementa.markdown.selection.Cursor
import gg.essential.elementa.markdown.selection.Selection
import gg.essential.elementa.state.BasicState
import gg.essential.elementa.state.State
import gg.essential.elementa.font.ElementaFonts
import gg.essential.elementa.font.FontProvider
import gg.essential.elementa.markdown.drawables.*
import gg.essential.elementa.utils.elementaDebug
import gg.essential.universal.UDesktop
import gg.essential.universal.UKeyboard
import gg.essential.universal.UMatrixStack
/**
* Component that parses a string as Markdown and renders it.
*
* This component's width and height must be non-child-related
* constraints. This is because the actual text rendering is
* done with direct render calls instead of through the component
* hierarchy.
*/
class MarkdownComponent(
text: String,
config: MarkdownConfig = MarkdownConfig(),
private val codeFontPointSize: Float = 10f,
private val codeFontRenderer: FontProvider = ElementaFonts.JETBRAINS_MONO,
private val disableSelection: Boolean = false,
) : UIComponent() {
@JvmOverloads
constructor(
text: String,
config: MarkdownConfig = MarkdownConfig(),
codeFontPointSize: Float = 10f,
codeFontRenderer: FontProvider = ElementaFonts.JETBRAINS_MONO,
) : this(text, config, codeFontPointSize, codeFontRenderer, false)
private val configState = BasicState(config)
val config: MarkdownConfig
get() = configState.get()
private var textState: State<String> = BasicState(text)
private var removeListener = textState.onSetValue {
reparse()
layout()
}
val drawables = DrawableList(this, emptyList())
var sectionOffsets: Map<String, Float> = emptyMap()
private set
private var baseX: Float = -1f
private var baseY: Float = -1f
private lateinit var lastValues: ConstraintValues
private var maxHeight: HeightConstraint = Int.MAX_VALUE.pixels()
private var cursor: Cursor<*>? = null
private var selection: Selection? = null
private var canDrag = false
private var needsInitialLayout = true
private val linkClickListeners = mutableListOf<MarkdownComponent.(LinkClickEvent) -> Unit>()
var maxTextLineWidth = 0f
private set
init {
onMouseClick {
val xShift = getLeft() - baseX
val yShift = getTop() - baseY
cursor =
drawables.cursorAt(it.absoluteX - xShift, it.absoluteY - yShift, dragged = false, it.mouseButton)
selection?.remove()
selection = null
releaseWindowFocus()
}
if (!disableSelection) {
onMouseClick {
canDrag = true
}
onMouseRelease {
canDrag = false
}
onMouseDrag { mouseX, mouseY, mouseButton ->
if (mouseButton != 0 || !canDrag)
return@onMouseDrag
val x = baseX + mouseX.coerceIn(0f, getWidth())
val y = baseY + mouseY.coerceIn(0f, getHeight())
val otherEnd = drawables.cursorAt(x, y, dragged = true, mouseButton)
if (cursor == otherEnd)
return@onMouseDrag
selection?.remove()
selection = Selection.fromCursors(cursor!!, otherEnd)
grabWindowFocus()
}
onKeyType { _, keyCode ->
if (selection != null && keyCode == UKeyboard.KEY_C && UKeyboard.isCtrlKeyDown()) {
UDesktop.setClipboardString(drawables.selectedText(UKeyboard.isShiftKeyDown()))
}
}
}
configState.onSetValue {
reparse()
layout()
}
}
fun bindText(state: State<String>) = apply {
removeListener()
textState = state
reparse()
layout()
removeListener = textState.onSetValue {
reparse()
layout()
}
}
fun setMaxHeight(maxHeight: HeightConstraint) = apply {
this.maxHeight = maxHeight
}
/**
* Parses the text into a markdown tree. This is called everytime
* that the text of this component changes, and is always followed
* by a call to layout().
*/
private fun reparse() {
drawables.setDrawables(MarkdownRenderer(textState.get(), this, config).render())
}
/**
* This method is responsible for laying out the markdown tree.
*
* @see Drawable.layout
*/
fun layout() {
baseX = getLeft()
baseY = getTop()
var currY = baseY
val width = getWidth()
drawables.forEach {
currY += it.layout(baseX, currY, width).height
}
sectionOffsets = drawables.filterIsInstance<HeaderDrawable>().associate { it.id to it.y }
setHeight((currY - baseY).coerceAtMost(maxHeight.getHeight(this)).pixels())
maxTextLineWidth = drawables.maxOfOrNull { drawable ->
when (drawable) {
is ParagraphDrawable -> drawable.maxTextLineWidth
is HeaderDrawable -> drawable.children.filterIsInstance<ParagraphDrawable>().maxOfOrNull { it.maxTextLineWidth } ?: 0f
is ListDrawable -> drawable.maxTextLineWidth
is BlockquoteDrawable -> drawable.maxTextLineWidth
else -> 0f
}
} ?: 0f
}
override fun animationFrame() {
super.animationFrame()
if (needsInitialLayout) {
needsInitialLayout = false
reparse()
layout()
lastValues = constraintValues()
}
// Re-layout if important constraint values have changed
val currentValues = constraintValues()
if (currentValues != lastValues)
layout()
lastValues = currentValues
}
/**
* Updates the MarkdownConfig this component uses.
*/
fun updateConfig(config: MarkdownConfig) {
configState.set(config)
}
/**
* Returns a [TreeListComponent] that contains the markdown tree.
*/
internal fun createLayoutTree(): TreeListComponent {
val nodes = mutableListOf<TreeNode>()
drawables.forEach {
nodes.add(MarkdownNode(it))
}
return TreeListComponent(nodes)
}
override fun draw(matrixStack: UMatrixStack) {
if (needsInitialLayout) {
animationFrame()
}
beforeDraw(matrixStack)
val drawState = DrawState(getLeft() - baseX, getTop() - baseY)
val parentWindow = Window.of(this)
drawables.forEach { it.beforeDraw(drawState) }
drawables.forEach {
if (!parentWindow.isAreaVisible(
it.layout.left.toDouble() + drawState.xShift, it.layout.top.toDouble() + drawState.yShift,
it.layout.right.toDouble() + drawState.xShift, it.layout.bottom.toDouble() + drawState.yShift
)) return@forEach
if (elementaDebug) {
drawDebugOutline(
matrixStack,
it.layout.left.toDouble() + drawState.xShift,
it.layout.top.toDouble() + drawState.yShift,
it.layout.right.toDouble() + drawState.xShift,
it.layout.bottom.toDouble() + drawState.yShift,
this
)
}
it.draw(matrixStack, drawState)
}
if (!disableSelection)
selection?.draw(matrixStack, drawState) ?: cursor?.draw(matrixStack, drawState)
super.draw(matrixStack)
}
private fun constraintValues() = ConstraintValues(
getWidth(),
getTextScale(),
)
fun onLinkClicked(block: MarkdownComponent.(LinkClickEvent) -> Unit) {
linkClickListeners.add(block)
}
internal fun fireLinkClickEvent(event: LinkClickEvent): Boolean {
for (listener in linkClickListeners) {
this.listener(event)
if (event.propagationStoppedImmediately) return false
}
return !event.propagationStopped
}
class LinkClickEvent internal constructor(val url: String) : UIEvent()
/**
* This class stores the values of the important constraints of this
* component. If these values change between frames, we need to do a
* complete re-layout of the entire markdown tree.
*/
data class ConstraintValues(
val width: Float,
val textScale: Float,
)
companion object {
// TODO: Remove
const val DEBUG = false
}
}