Skip to content

Commit 354e7b5

Browse files
committed
feat: a model of every screen, drawn from the app itself
docs/model holds each screen, what it is for, the states it can be in, and a picture of every state upright and wide, light and dark. make-renders.sh paints the real composables off-screen on the JVM with a written practice history and seeded questions, so a picture comes from the code the app runs and two runs draw the same thing. Compose draws one scene at a time in a process, so the work is dealt out to as many processes as there are cores: six minutes for 144 renders rather than fifty. Served by viewbook.
1 parent 9d8ae4e commit 354e7b5

438 files changed

Lines changed: 2149 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 16 additions & 0 deletions

composeApp/src/jvmMain/kotlin/it/bosler/numeracy/gallery/Gallery.kt

Lines changed: 361 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package it.bosler.numeracy.gallery
2+
3+
import androidx.compose.foundation.layout.Box
4+
import androidx.compose.foundation.layout.fillMaxSize
5+
import androidx.compose.material3.MaterialTheme
6+
import androidx.compose.material3.Surface
7+
import androidx.compose.runtime.Composable
8+
import androidx.compose.ui.ImageComposeScene
9+
import androidx.compose.ui.Modifier
10+
import androidx.compose.ui.unit.Density
11+
import it.bosler.numeracy.ui.theme.NumeracyTheme
12+
import org.jetbrains.skia.Image
13+
import org.jetbrains.skia.Rect
14+
import org.jetbrains.skia.SamplingMode
15+
import org.jetbrains.skia.Surface as SkiaSurface
16+
import java.io.File
17+
18+
/**
19+
* Draws a composable to a PNG with no display, so every screen of the app can be looked at as a
20+
* picture that came from the code the app runs. Runs on the JVM target.
21+
*/
22+
fun renderToPng(
23+
name: String,
24+
widthDp: Int,
25+
heightDp: Int,
26+
dark: Boolean,
27+
outDir: File,
28+
// Pixels per dp. A phone render is read at its own size and wants two; a wide one is only ever
29+
// looked at beside the phone, at half a window, and at two it costs four times the pixels for
30+
// no more to read.
31+
scale: Float,
32+
content: @Composable () -> Unit,
33+
) {
34+
val density = Density(scale)
35+
val scene = ImageComposeScene(
36+
width = (widthDp * density.density).toInt(),
37+
height = (heightDp * density.density).toInt(),
38+
density = density,
39+
) {
40+
NumeracyTheme(darkTheme = dark) {
41+
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) {
42+
Box(Modifier.fillMaxSize()) { content() }
43+
}
44+
}
45+
}
46+
try {
47+
// A screen answers a tap with an animation and loads its photos off the composition, so the
48+
// first frame is a screen mid-blink: colours not yet arrived at, cards still grey. Frames
49+
// are drawn until those have settled, and the last one is the picture.
50+
var last: Image? = null
51+
var time = 0L
52+
repeat(SETTLING_FRAMES) {
53+
last = scene.render(time)
54+
time += FRAME_NANOS
55+
Thread.sleep(2)
56+
}
57+
val image = last!!
58+
outDir.mkdirs()
59+
File(outDir, "$name.png").writeBytes(image.encodeToData()!!.bytes)
60+
// The book asks for a small copy where it shows a picture small, and a card is the top of a
61+
// screen at a size that can be read. Without them every thumbnail on the index is a
62+
// quarter-megabyte photograph of a phone.
63+
writePart(image, File(outDir, "small"), name, SMALL_SCALE, 1f)
64+
writePart(image, File(outDir, "card"), name, CARD_SCALE, CARD_TOP)
65+
} finally {
66+
scene.close()
67+
}
68+
}
69+
70+
/** A copy of a render: [scale] of its size, and the top [top] of its height. */
71+
private fun writePart(image: Image, dir: File, name: String, scale: Float, top: Float) {
72+
val width = (image.width * scale).toInt().coerceAtLeast(1)
73+
val height = (image.height * top * scale).toInt().coerceAtLeast(1)
74+
val surface = SkiaSurface.makeRasterN32Premul(width, height)
75+
try {
76+
surface.canvas.drawImageRect(
77+
image,
78+
Rect.makeWH(image.width.toFloat(), image.height * top),
79+
Rect.makeWH(width.toFloat(), height.toFloat()),
80+
SamplingMode.LINEAR,
81+
null,
82+
true,
83+
)
84+
dir.mkdirs()
85+
File(dir, "$name.png").writeBytes(surface.makeImageSnapshot().encodeToData()!!.bytes)
86+
} finally {
87+
surface.close()
88+
}
89+
}
90+
91+
private const val SMALL_SCALE = 0.5f
92+
private const val CARD_SCALE = 0.55f
93+
private const val CARD_TOP = 0.45f
94+
95+
private const val FRAME_NANOS = 16_000_000L
96+
private val SETTLING_FRAMES = (System.getProperty("gallery.frames") ?: "45").toInt()
141 KB
141 KB
152 KB
153 KB
200 KB
202 KB
246 KB

0 commit comments

Comments
 (0)