Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Static component for rendering permanent output #66

Merged
merged 2 commits into from
Sep 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions mosaic-runtime/src/main/kotlin/com/jakewharton/mosaic/Static.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.jakewharton.mosaic

import androidx.compose.runtime.Composable
import androidx.compose.runtime.ComposeNode
import androidx.compose.runtime.DisallowComposableCalls
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect

/**
* Will render each value emitted by [items] as permanent output above the
* regular display.
*/
@Composable
fun <T> Static(
items: Flow<T>,
content: @Composable (T) -> Unit,
) {
class Item(val value: T, var rendered: Boolean)

// Keep list of items which have not yet been rendered.
val pending = remember { mutableStateListOf<Item>() }

LaunchedEffect(items) {
items.collect {
pending.add(Item(it, rendered = false))
}
}

Static(
postRender = {
// Remove any items which have been rendered.
pending.removeIf { it.rendered }
Comment on lines +34 to +35
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't super efficient but it's probably fine. One thing to note is that the trues are contiguous starting at index 0 so we could find the first false index and then do subList(0, firstFalse).clear() for an operation that is likely the most optimal.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good idea. I always forget that modifications on subList are reflected in the full list. Also worth noting that just pending.clear() worked in all of the test samples I created but I wasn't convinced that would work in all cases so I added the wrapper Item class to be safe.

}
) {
for (item in pending) {
Row {
// Render item and mark it as having been included in render.
content(item.value)
item.rendered = true
}
}
}
}

/**
* Renders [content] permanently above the normal canvas. When content has
* actually been written to a [TextCanvas], the [postRender] callback will be
* invoked to allow clearing of content.
*
* @param postRender Callback after rendering to a [TextCanvas] is complete.
* @param content Content which should be rendered permanently above normal
* canvas.
*/
@Composable
internal fun Static(
postRender: () -> Unit = {},
content: @Composable () -> Unit,
) {
ComposeNode<StaticNode, MosaicNodeApplier>(
factory = ::StaticNode,
update = {
set(postRender) {
this.postRender = postRender
}
},
content = content,
)
}

internal class StaticNode : ContainerNode() {
// Delegate container column for static content.
private val box = BoxNode().also {
it.isRow = false
}

override val children: MutableList<MosaicNode>
get() = box.children

var postRender: () -> Unit = {}

override fun measure() {
// Not visible.
}

override fun layout() {
// Not visible.
}

override fun renderTo(canvas: TextCanvas) {
// Render contents of static node to a separate display.
val other = box.render()

// Add display canvas to static canvases if it is not empty.
if (other.width > 0 && other.height > 0) {
canvas.static.add(other)
}

// Propagate any static content of the display.
canvas.static.addAll(other.static)
Comment on lines +97 to +102
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is my least favorite part of the change. The fact that a render causes a side-effect to collect these other renders and then something at the call site magically emits that first.

It's hard to argue with the output, but I think I would like to try and find something better for this. I'm not going to block merging here because the output is truly fantastic and it's all implementation detail.

Render (well, renderTo) is a tree walk, and I think perhaps we separate out the static tree walk to its own function which can be less reliant on side-effects + mutation. It also hopefully means the canvas isn't aware of these static renders.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this was definitely a result of not wanting to make a bunch of render things Static specific or introduce multiple render passes. Thankfully as you said it is all implementation detail so can definitely be iterated.

Render (well, renderTo) is a tree walk, and I think perhaps we separate out the static tree walk to its own function which can be less reliant on side-effects + mutation.

Are you thinking something like 2 renderTo passes, one for static and the other for "normal" content? Or find all the Static nodes in the render function and render those to the canvas first somehow? I can try a couple different things - if you want - but maybe a little pseudocode of what you are thinking might help.

It also hopefully means the canvas isn't aware of these static renders.

The tricky part here was that AnsiOutput needs to know the static and non-static parts so it can set the lastHeight to only the non-static part. So whatever gets sent to the output, needs to know that additional detail.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking a whole separate function, like renderTo() and renderStaticTo() or something. Basically just splitting the responsibilities and each node would only realistically implement one of them.


postRender()
}

override fun toString() = box.children.joinToString(prefix = "Static(", postfix = ")")
}
12 changes: 12 additions & 0 deletions mosaic-runtime/src/main/kotlin/com/jakewharton/mosaic/canvas.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ internal interface TextCanvas {
val width: Int
val height: Int

/** Separate canvases for [static][StaticNode] content. */
val static: MutableList<TextCanvas>

operator fun get(row: Int, column: Int): TextCodepoint

operator fun get(row: Int, columns: IntRange) = get(row..row, columns)
Expand All @@ -30,6 +33,10 @@ internal interface TextCanvas {
return ClippedTextCanvas(this, left, top, right, bottom)
}

fun empty(): TextCanvas {
return ClippedTextCanvas(this, 0, 0, -1, -1)
}

fun write(
row: Int,
column: Int,
Expand Down Expand Up @@ -87,6 +94,9 @@ internal class ClippedTextCanvas(
override val width = right - left + 1
override val height = bottom - top + 1

override val static: MutableList<TextCanvas>
get() = delegate.static

override fun get(row: Int, column: Int): TextCodepoint {
require(row in 0 until height) { "Row value out of range [0,$height): $row"}
require(column in 0 until width) { "Column value out of range [0,$width): $column"}
Expand All @@ -113,6 +123,8 @@ internal class TextSurface(
) : TextCanvas {
private val rows = Array(height) { Array(width) { TextCodepoint(' ') } }

override val static = mutableListOf<TextCanvas>()

override operator fun get(row: Int, column: Int) = rows[row][column]

override fun toString() = render()
Expand Down
23 changes: 15 additions & 8 deletions mosaic-runtime/src/main/kotlin/com/jakewharton/mosaic/nodes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ internal sealed class MosaicNode {
abstract fun layout()
abstract fun renderTo(canvas: TextCanvas)

fun render(): String {
fun render(): TextCanvas {
measure()
layout()
val canvas = TextSurface(width, height)
renderTo(canvas)
return canvas.toString()
return canvas
}
}

Expand Down Expand Up @@ -62,8 +62,13 @@ internal class TextNode(initialValue: String = "") : MosaicNode() {
override fun toString() = "Text(\"$value\", x=$x, y=$y, width=$width, height=$height)"
}

internal class BoxNode : MosaicNode() {
val children = mutableListOf<MosaicNode>()
internal sealed class ContainerNode : MosaicNode() {
abstract val children: MutableList<MosaicNode>
}

internal class BoxNode : ContainerNode() {
override val children = mutableListOf<MosaicNode>()

/** If row, otherwise column. */
var isRow = true

Expand Down Expand Up @@ -135,6 +140,8 @@ internal class BoxNode : MosaicNode() {
val right = left + child.width - 1
val bottom = top + child.height - 1
child.renderTo(canvas[top..bottom, left..right])
} else {
child.renderTo(canvas.empty())
}
}
}
Expand All @@ -148,22 +155,22 @@ internal class MosaicNodeApplier(root: BoxNode) : AbstractApplier<MosaicNode>(ro
}

override fun insertBottomUp(index: Int, instance: MosaicNode) {
val boxNode = current as BoxNode
val boxNode = current as ContainerNode
boxNode.children.add(index, instance)
}

override fun remove(index: Int, count: Int) {
val boxNode = current as BoxNode
val boxNode = current as ContainerNode
boxNode.children.remove(index, count)
}

override fun move(from: Int, to: Int, count: Int) {
val boxNode = current as BoxNode
val boxNode = current as ContainerNode
boxNode.children.move(from, to, count)
}

override fun onClear() {
val boxNode = root as BoxNode
val boxNode = root as ContainerNode
boxNode.children.clear()
}
}
20 changes: 12 additions & 8 deletions mosaic-runtime/src/main/kotlin/com/jakewharton/mosaic/output.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import java.nio.charset.StandardCharsets.UTF_8
import java.util.concurrent.TimeUnit.NANOSECONDS

internal interface Output {
fun display(output: String)
fun display(canvas: TextCanvas)
}

internal object DebugOutput : Output {
private var lastRenderNanos = 0L

override fun display(output: String) {
override fun display(canvas: TextCanvas) {
println(buildString {
val renderNanos = System.nanoTime()

Expand All @@ -24,30 +24,34 @@ internal object DebugOutput : Output {
}
lastRenderNanos = renderNanos

appendLine(output)
for (static in canvas.static) {
appendLine(static.render())
}

appendLine(canvas.render())
})
}
}

internal object AnsiOutput : Output {
private var lastHeight = 0

override fun display(output: String) {
override fun display(canvas: TextCanvas) {
val rendered = buildString {
val lines = output.split("\n")

repeat(lastHeight) {
append("\u001B[F") // Cursor up line.
}

for (line in lines) {
val staticLines = canvas.static.flatMap { it.render().split("\n") }
val lines = canvas.render().split("\n")
for (line in staticLines + lines) {
append(line)
append("\u001B[K") // Clear rest of line.
append('\n')
}

// If the new output contains fewer lines than the last output, clear those old lines.
val extraLines = lastHeight - lines.size
val extraLines = lastHeight - (lines.size + staticLines.size)
for (i in 0 until extraLines) {
if (i > 0) {
append('\n')
Expand Down
Loading