Build machines from the android app too

453cc82 · Gabriel Morell · 2026-09-16 23:07

26 files +663 -23
Message
{commit_body(@commit)}

Files changed

modified android/app/src/main/java/org/neiam/lambent/app/data/LambentSocket.kt
+46 −1
@@ -67,6 +67,10 @@ object LambentSocket {
67 67 private val _cronosArgs = MutableStateFlow<Map<String, List<Choice>>>(emptyMap())
68 68 val cronosArgs: StateFlow<Map<String, List<Choice>>> = _cronosArgs.asStateFlow()
69 69
70 + /** what the new machine form can build, pushed on join */
71 + private val _machineTypes = MutableStateFlow<List<MachineType>>(emptyList())
72 + val machineTypes: StateFlow<List<MachineType>> = _machineTypes.asStateFlow()
73 +
70 74 /** the machine library, pushed on join: class name to its entries */
71 75 private val _library = MutableStateFlow<Map<String, List<String>>>(emptyMap())
72 76 val library: StateFlow<Map<String, List<String>>> = _library.asStateFlow()
@@ -119,6 +123,7 @@ object LambentSocket {
119 123 _previews.value = emptyMap()
120 124 _placeClamps.value = emptyMap()
121 125 _globalClamp.value = Clamp(null, Clamp.FULL, Clamp.FULL, 0)
126 + _machineTypes.value = emptyList()
122 127 }
123 128
124 129 // ----- joins -----
@@ -161,6 +166,12 @@ object LambentSocket {
161 166 .mapValues { (_, entries) -> entries.asMap().keys.sorted() }
162 167 }
163 168
169 + ch.on("types") { message ->
170 + _machineTypes.value = message.payload["types"].asList().map {
171 + MachineType.from(it.asMap())
172 + }
173 + }
174 +
164 175 ch.on("machine") { message ->
165 176 val machine = Machine.from(message.payload)
166 177 if (machine.id.isNotEmpty()) {
@@ -236,13 +247,19 @@ object LambentSocket {
236 247 // ----- commands -----
237 248
238 249 // Payload is Map<String, Any>, so nothing nullable goes over the wire
239 − private fun push(topic: String, event: String, payload: Map<String, Any>) {
250 + private fun push(
251 + topic: String,
252 + event: String,
253 + payload: Map<String, Any>,
254 + onOk: ((Map<String, Any?>) -> Unit)? = null,
255 + ) {
240 256 val ch = channels[topic]
241 257 if (ch == null) {
242 258 _notices.tryEmit("not connected")
243 259 return
244 260 }
245 261 ch.push(event, payload)
262 + .receive("ok") { message -> onOk?.invoke(message.payload) }
246 263 .receive("error") { message ->
247 264 val why = message.payload["reason"] ?: message.payload["errors"] ?: "rejected"
248 265 _notices.tryEmit("$event: $why")
@@ -264,6 +281,34 @@ object LambentSocket {
264 281 /** the server pushes the library on join; this asks for it again */
265 282 fun refreshLibrary() = push("machines", "library", emptyMap())
266 283
284 + fun refreshMachineTypes() = push("machines", "types", emptyMap())
285 +
286 + /** a catalog entry as the form would hold it, for editing rather than starting */
287 + fun libraryEdit(cls: String, entry: String, onDraft: (MachineDraft) -> Unit) =
288 + push("machines", "library_edit", mapOf("cls" to cls, "entry" to entry)) { payload ->
289 + onDraft(MachineDraft.from(payload["machine"].asMap()))
290 + }
291 +
292 + /** a single pixel over time, for a configuration that has not been started */
293 + fun previewMachine(draft: MachineDraft, onPixels: (List<Int>) -> Unit) =
294 + push("machines", "preview", mapOf("machine" to draftPayload(draft))) { payload ->
295 + onPixels(
296 + payload["pixels"].asList().mapNotNull { pixel ->
297 + val rgb = pixel.asList().mapNotNull { it.asInt() }
298 + if (rgb.size >= 3) (rgb[0] shl 16) or (rgb[1] shl 8) or rgb[2] else null
299 + }
300 + )
301 + }
302 +
303 + fun createMachine(draft: MachineDraft) =
304 + push("machines", "create", mapOf("machine" to draftPayload(draft)))
305 +
306 + private fun draftPayload(draft: MachineDraft): Map<String, Any> = buildMap {
307 + put("name", draft.name)
308 + draft.type?.let { put("type", it) }
309 + put("class", draft.settings)
310 + }
311 +
267 312 // links
268 313 fun linkCommand(cmd: String, name: String) = push("links", cmd, mapOf("id" to name))
269 314
modified android/app/src/main/java/org/neiam/lambent/app/data/Models.kt
+136 −0
@@ -143,6 +143,110 @@ data class Choice(val label: String, val value: String) {
143 143 }
144 144 }
145 145
146 +/**
147 + * A machine type the new-machine form can build, and the settings it carries.
148 + * Both come off the wire so the app draws the same fields the web form does
149 + * rather than keeping its own idea of what a machine needs.
150 + */
151 +data class MachineType(
152 + val type: String,
153 + val label: String,
154 + val fields: List<MachineField>,
155 +) {
156 + /** what the machine cannot run without, and the hue lists, lead the form */
157 + val leading: List<MachineField> get() = fields.filter { it.required || it.kind == "hues" }
158 +
159 + val extra: List<MachineField>
160 + get() = fields.filter { !it.required && it.kind != "hues" && it.name != "h_sel" }
161 +
162 + val picksHues: Boolean get() = fields.any { it.kind == "hues" }
163 +
164 + /** the selector a hue list is built from, when there is one */
165 + val selector: MachineField? get() = fields.firstOrNull { it.name == "h_sel" }
166 +
167 + fun defaults(): Map<String, Any> = buildMap {
168 + fields.forEach { field -> field.default?.let { put(field.name, it) } }
169 + }
170 +
171 + companion object {
172 + fun from(raw: Map<String, Any?>) = MachineType(
173 + type = raw["type"].asStr() ?: "",
174 + label = raw["label"].asStr() ?: "",
175 + fields = raw["fields"].asList().map { MachineField.from(it.asMap()) },
176 + )
177 + }
178 +}
179 +
180 +data class MachineField(
181 + val name: String,
182 + val kind: String,
183 + val label: String,
184 + val required: Boolean,
185 + val default: Any?,
186 +) {
187 + companion object {
188 + fun from(raw: Map<String, Any?>) = MachineField(
189 + name = raw["name"].asStr() ?: "",
190 + kind = raw["kind"].asStr() ?: "number",
191 + label = raw["label"].asStr() ?: "",
192 + required = raw["required"] == true,
193 + default = when (val d = raw["default"]) {
194 + is Number -> d.toInt()
195 + is List<*> -> d.mapNotNull { it.asInt() }
196 + else -> d
197 + },
198 + )
199 + }
200 +}
201 +
202 +/** a machine as the form holds it, before it is started */
203 +data class MachineDraft(
204 + val name: String = "",
205 + val type: String? = null,
206 + val settings: Map<String, Any> = emptyMap(),
207 +) {
208 + fun hues(field: String): List<Int> =
209 + @Suppress("UNCHECKED_CAST") (settings[field] as? List<Int> ?: emptyList())
210 +
211 + fun int(field: String): Int? = settings[field].asInt()
212 +
213 + fun flag(field: String): Boolean = settings[field] == true
214 +
215 + fun with(field: String, value: Any): MachineDraft =
216 + copy(settings = settings + (field to value))
217 +
218 + fun addHue(field: String, hue: Int): MachineDraft =
219 + with(field, hues(field) + hue)
220 +
221 + fun dropHue(field: String, index: Int): MachineDraft =
222 + with(field, hues(field).filterIndexed { idx, _ -> idx != index })
223 +
224 + /** whether everything the machine cannot run without has been set */
225 + fun settled(spec: MachineType?): Boolean {
226 + if (spec == null || name.isBlank()) return false
227 + return spec.fields.filter { it.required }.all { settings[it.name] != null }
228 + }
229 +
230 + companion object {
231 + fun from(raw: Map<String, Any?>): MachineDraft {
232 + val settings = raw["class"].asMap().mapNotNull { (key, value) ->
233 + when (value) {
234 + null -> null
235 + is Number -> key to value.toInt()
236 + is List<*> -> key to value.mapNotNull { it.asInt() }
237 + else -> key to value
238 + }
239 + }.toMap()
240 +
241 + return MachineDraft(
242 + name = raw["name"].asStr() ?: "",
243 + type = raw["type"].asStr(),
244 + settings = settings,
245 + )
246 + }
247 + }
248 +}
249 +
146 250 data class Service(val kind: String, val name: String?, val entry: String) {
147 251 companion object {
148 252 fun from(kind: String, raw: Map<String, Any?>) = Service(
@@ -194,3 +298,35 @@ fun ladderIndex(value: Int, ladder: List<Named>): Int =
194 298 Math.round(ladderPosition(value, ladder))
195 299
196 300 val ORDERS = listOf("rgb", "grb", "rgbww", "rgbnw", "rgbcw", "rgbaw", "rgbxw")
301 +
302 +/**
303 + * A hue as a colour, for a swatch under a slider. The machines work in 0..255
304 + * for all three, the way the firmware does, rather than in degrees.
305 + */
306 +fun hsvToRgb(h: Int, s: Int, v: Int): Int {
307 + val hue = (h.coerceIn(0, 255)) / 255f
308 + val sat = (s.coerceIn(0, 255)) / 255f
309 + val value = (v.coerceIn(0, 255)) / 255f
310 +
311 + if (sat == 0f) {
312 + val grey = (value * 255).toInt()
313 + return (grey shl 16) or (grey shl 8) or grey
314 + }
315 +
316 + val sector = (hue * 6).toInt()
317 + val offset = hue * 6 - sector
318 + val p = value * (1 - sat)
319 + val q = value * (1 - sat * offset)
320 + val t = value * (1 - sat * (1 - offset))
321 +
322 + val (r, g, b) = when (sector % 6) {
323 + 0 -> Triple(value, t, p)
324 + 1 -> Triple(q, value, p)
325 + 2 -> Triple(p, value, t)
326 + 3 -> Triple(p, q, value)
327 + 4 -> Triple(t, p, value)
328 + else -> Triple(value, p, q)
329 + }
330 +
331 + return ((r * 255).toInt() shl 16) or ((g * 255).toInt() shl 8) or (b * 255).toInt()
332 +}
added android/app/src/main/java/org/neiam/lambent/app/ui/MachineFormSheet.kt
+250 −0
@@ -0,0 +1,250 @@
1 +package org.neiam.lambent.app.ui
2 +
3 +import androidx.compose.foundation.layout.Arrangement
4 +import androidx.compose.foundation.layout.Column
5 +import androidx.compose.foundation.layout.ExperimentalLayoutApi
6 +import androidx.compose.foundation.layout.FlowRow
7 +import androidx.compose.foundation.layout.Row
8 +import androidx.compose.foundation.layout.fillMaxWidth
9 +import androidx.compose.foundation.layout.height
10 +import androidx.compose.foundation.layout.padding
11 +import androidx.compose.foundation.rememberScrollState
12 +import androidx.compose.foundation.verticalScroll
13 +import androidx.compose.material.icons.Icons
14 +import androidx.compose.material.icons.filled.Add
15 +import androidx.compose.material3.AssistChip
16 +import androidx.compose.material3.AssistChipDefaults
17 +import androidx.compose.material3.Button
18 +import androidx.compose.material3.Checkbox
19 +import androidx.compose.material3.DropdownMenu
20 +import androidx.compose.material3.DropdownMenuItem
21 +import androidx.compose.material3.ExperimentalMaterial3Api
22 +import androidx.compose.material3.Icon
23 +import androidx.compose.material3.MaterialTheme
24 +import androidx.compose.material3.OutlinedButton
25 +import androidx.compose.material3.OutlinedTextField
26 +import androidx.compose.material3.Slider
27 +import androidx.compose.material3.Text
28 +import androidx.compose.material3.TextButton
29 +import androidx.compose.runtime.Composable
30 +import androidx.compose.runtime.LaunchedEffect
31 +import androidx.compose.runtime.getValue
32 +import androidx.compose.runtime.mutableStateOf
33 +import androidx.compose.runtime.remember
34 +import androidx.compose.runtime.setValue
35 +import androidx.compose.ui.Alignment
36 +import androidx.compose.ui.Modifier
37 +import androidx.compose.ui.graphics.Color
38 +import androidx.compose.ui.unit.dp
39 +import org.neiam.lambent.app.data.LambentSocket
40 +import org.neiam.lambent.app.data.MachineDraft
41 +import org.neiam.lambent.app.data.MachineField
42 +import org.neiam.lambent.app.data.MachineType
43 +import org.neiam.lambent.app.data.hsvToRgb
44 +
45 +/**
46 + * The new machine form, drawn from what the server says each type carries.
47 + *
48 + * Nothing here knows what a chaser or a scape is: the leading settings are the
49 + * ones a machine cannot run without, the rest fold away, and what to draw for
50 + * each comes off the wire with it.
51 + */
52 +@OptIn(ExperimentalMaterial3Api::class)
53 +@Composable
54 +fun MachineFormSheet(
55 + types: List<MachineType>,
56 + initial: MachineDraft,
57 + onDismiss: () -> Unit,
58 +) {
59 + var draft by remember(initial) { mutableStateOf(initial) }
60 + var preview by remember { mutableStateOf(emptyList<Int>()) }
61 + var showExtra by remember { mutableStateOf(false) }
62 +
63 + val spec = types.firstOrNull { it.type == draft.type }
64 +
65 + // the server draws the preview, the same one the web form shows
66 + LaunchedEffect(draft.type, draft.settings) {
67 + if (draft.settled(spec)) {
68 + LambentSocket.previewMachine(draft) { preview = it }
69 + } else {
70 + preview = emptyList()
71 + }
72 + }
73 +
74 + Column(
75 + modifier = Modifier
76 + .padding(16.dp)
77 + .verticalScroll(rememberScrollState()),
78 + verticalArrangement = Arrangement.spacedBy(8.dp),
79 + ) {
80 + Text("New machine", style = MaterialTheme.typography.titleLarge)
81 +
82 + OutlinedTextField(
83 + value = draft.name,
84 + onValueChange = { draft = draft.copy(name = it) },
85 + label = { Text("Name") },
86 + singleLine = true,
87 + modifier = Modifier.fillMaxWidth(),
88 + )
89 +
90 + TypePicker(types = types, selected = spec) { picked ->
91 + // a type change starts from that type's own defaults
92 + draft = draft.copy(type = picked.type, settings = picked.defaults())
93 + }
94 +
95 + if (preview.isNotEmpty()) {
96 + Text("A single pixel over time", style = MaterialTheme.typography.labelSmall)
97 + PreviewStrip(
98 + pixels = preview,
99 + modifier = Modifier
100 + .fillMaxWidth()
101 + .height(24.dp),
102 + )
103 + }
104 +
105 + if (spec == null) {
106 + Text(
107 + "Pick a machine type to configure it.",
108 + style = MaterialTheme.typography.bodyMedium,
109 + )
110 + } else {
111 + spec.leading.forEach { field ->
112 + Setting(field, spec, draft) { draft = it }
113 + }
114 +
115 + if (spec.extra.isNotEmpty()) {
116 + TextButton(onClick = { showExtra = !showExtra }) {
117 + Text(if (showExtra) "Fewer settings" else "Extra configuration values")
118 + }
119 + if (showExtra) {
120 + spec.extra.forEach { field ->
121 + Setting(field, spec, draft) { draft = it }
122 + }
123 + }
124 + }
125 + }
126 +
127 + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
128 + Button(
129 + enabled = draft.settled(spec),
130 + onClick = {
131 + LambentSocket.createMachine(draft)
132 + onDismiss()
133 + },
134 + ) { Text("Create machine") }
135 + OutlinedButton(onClick = onDismiss) { Text("Cancel") }
136 + }
137 + }
138 +}
139 +
140 +@Composable
141 +private fun TypePicker(
142 + types: List<MachineType>,
143 + selected: MachineType?,
144 + onPick: (MachineType) -> Unit,
145 +) {
146 + var open by remember { mutableStateOf(false) }
147 +
148 + Column {
149 + Text("Machine type", style = MaterialTheme.typography.labelSmall)
150 + OutlinedButton(onClick = { open = true }) { Text(selected?.label ?: "Select") }
151 + DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
152 + types.forEach { type ->
153 + DropdownMenuItem(
154 + text = { Text(type.label) },
155 + onClick = {
156 + onPick(type)
157 + open = false
158 + },
159 + )
160 + }
161 + }
162 + }
163 +}
164 +
165 +/** one setting, drawn as whatever kind of thing the server says it is */
166 +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
167 +@Composable
168 +private fun Setting(
169 + field: MachineField,
170 + spec: MachineType,
171 + draft: MachineDraft,
172 + onChange: (MachineDraft) -> Unit,
173 +) {
174 + when (field.kind) {
175 + "hues" -> {
176 + val selector = spec.selector
177 +
178 + Column {
179 + Text(field.label, style = MaterialTheme.typography.labelSmall)
180 + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
181 + draft.hues(field.name).forEachIndexed { index, hue ->
182 + AssistChip(
183 + onClick = { onChange(draft.dropHue(field.name, index)) },
184 + label = { Text("$hue") },
185 + colors = AssistChipDefaults.assistChipColors(
186 + containerColor = Color(hsvToRgb(hue, 255, 255) or 0xFF000000.toInt()),
187 + ),
188 + )
189 + }
190 + AssistChip(
191 + onClick = {
192 + val hue = selector?.let { draft.int(it.name) } ?: 0
193 + onChange(draft.addHue(field.name, hue))
194 + },
195 + label = { Icon(Icons.Filled.Add, contentDescription = "Add this hue") },
196 + )
197 + }
198 + }
199 + }
200 +
201 + "hue", "level" -> {
202 + val value = draft.int(field.name) ?: 0
203 + val label = if (field.name == "h_sel" && spec.picksHues) "Hue (selecting)" else field.label
204 +
205 + Column {
206 + Row {
207 + Text("$label: $value", style = MaterialTheme.typography.labelSmall)
208 + }
209 + Slider(
210 + value = value.toFloat(),
211 + onValueChange = { onChange(draft.with(field.name, it.toInt())) },
212 + valueRange = 0f..255f,
213 + )
214 + if (field.kind == "hue") {
215 + val s = draft.int("s") ?: 255
216 + val v = draft.int("v") ?: 255
217 + Swatch(hsvToRgb(value, s, v))
218 + }
219 + }
220 + }
221 +
222 + "flag" -> Row(verticalAlignment = Alignment.CenterVertically) {
223 + Checkbox(
224 + checked = draft.flag(field.name),
225 + onCheckedChange = { onChange(draft.with(field.name, it)) },
226 + )
227 + Text(field.label)
228 + }
229 +
230 + else -> OutlinedTextField(
231 + value = draft.int(field.name)?.toString() ?: "",
232 + onValueChange = { typed ->
233 + val number = typed.filter { it.isDigit() }.toIntOrNull()
234 + onChange(if (number == null) draft else draft.with(field.name, number))
235 + },
236 + label = { Text(field.label) },
237 + singleLine = true,
238 + )
239 + }
240 +}
241 +
242 +@Composable
243 +private fun Swatch(rgb: Int) {
244 + PreviewStrip(
245 + pixels = listOf(rgb),
246 + modifier = Modifier
247 + .fillMaxWidth()
248 + .height(8.dp),
249 + )
250 +}
modified android/app/src/main/java/org/neiam/lambent/app/ui/MachinesScreen.kt
+74 −18
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
6 6 import androidx.compose.foundation.layout.Column
7 7 import androidx.compose.foundation.layout.PaddingValues
8 8 import androidx.compose.foundation.layout.Row
9 +import androidx.compose.foundation.layout.Spacer
9 10 import androidx.compose.foundation.layout.fillMaxSize
10 11 import androidx.compose.foundation.layout.fillMaxWidth
11 12 import androidx.compose.foundation.layout.height
@@ -15,6 +16,7 @@ import androidx.compose.foundation.lazy.items
15 16 import androidx.compose.material.icons.Icons
16 17 import androidx.compose.material.icons.filled.Add
17 18 import androidx.compose.material.icons.filled.Close
19 +import androidx.compose.material.icons.filled.Edit
18 20 import androidx.compose.material.icons.filled.FastForward
19 21 import androidx.compose.material.icons.filled.BrightnessHigh
20 22 import androidx.compose.material.icons.filled.BrightnessLow
@@ -23,6 +25,7 @@ import androidx.compose.material.icons.filled.SkipNext
23 25 import androidx.compose.material3.Card
24 26 import androidx.compose.material3.ExperimentalMaterial3Api
25 27 import androidx.compose.material3.FloatingActionButton
28 +import androidx.compose.material3.OutlinedButton
26 29 import androidx.compose.material3.Icon
27 30 import androidx.compose.material3.IconButton
28 31 import androidx.compose.material3.MaterialTheme
@@ -42,6 +45,7 @@ import androidx.compose.ui.unit.dp
42 45 import androidx.lifecycle.compose.collectAsStateWithLifecycle
43 46 import org.neiam.lambent.app.data.LambentSocket
44 47 import org.neiam.lambent.app.data.Machine
48 +import org.neiam.lambent.app.data.MachineDraft
45 49 import org.neiam.lambent.app.data.brightnessPercent
46 50 import org.neiam.lambent.app.data.speedLabel
47 51
@@ -51,10 +55,13 @@ fun MachinesScreen() {
51 55 val machines by LambentSocket.machines.collectAsStateWithLifecycle()
52 56 val previews by LambentSocket.previews.collectAsStateWithLifecycle()
53 57 val library by LambentSocket.library.collectAsStateWithLifecycle()
58 + val types by LambentSocket.machineTypes.collectAsStateWithLifecycle()
54 59 var showLibrary by remember { mutableStateOf(false) }
60 + var building by remember { mutableStateOf<MachineDraft?>(null) }
55 61
56 62 LaunchedEffect(showLibrary) {
57 63 if (showLibrary && library.isEmpty()) LambentSocket.refreshLibrary()
64 + if (showLibrary && types.isEmpty()) LambentSocket.refreshMachineTypes()
58 65 }
59 66
60 67 Box(modifier = Modifier.fillMaxSize()) {
@@ -83,10 +90,30 @@ fun MachinesScreen() {
83 90
84 91 if (showLibrary) {
85 92 ModalBottomSheet(onDismissRequest = { showLibrary = false }) {
86 − LibrarySheet(library) { cls, entry ->
87 − LambentSocket.libraryAdd(cls, entry)
88 − showLibrary = false
89 − }
93 + LibrarySheet(
94 + library = library,
95 + onPick = { cls, entry ->
96 + LambentSocket.libraryAdd(cls, entry)
97 + showLibrary = false
98 + },
99 + // the catalog entry, opened in the form rather than started
100 + onEdit = { cls, entry ->
101 + LambentSocket.libraryEdit(cls, entry) { draft ->
102 + building = draft
103 + showLibrary = false
104 + }
105 + },
106 + onBuild = {
107 + building = MachineDraft()
108 + showLibrary = false
109 + },
110 + )
111 + }
112 + }
113 +
114 + building?.let { draft ->
115 + ModalBottomSheet(onDismissRequest = { building = null }) {
116 + MachineFormSheet(types = types, initial = draft, onDismiss = { building = null })
90 117 }
91 118 }
92 119 }
@@ -112,7 +139,14 @@ private fun MachineCard(machine: Machine, preview: List<Int>) {
112 139 style = MaterialTheme.typography.labelSmall,
113 140 )
114 141 }
115 − if (preview.isNotEmpty()) PreviewStrip(preview)
142 + if (preview.isNotEmpty()) {
143 + PreviewStrip(
144 + preview,
145 + modifier = Modifier
146 + .height(48.dp)
147 + .fillMaxWidth(0.35f),
148 + )
149 + }
116 150 }
117 151
118 152 Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
@@ -151,14 +185,10 @@ private fun MachineCard(machine: Machine, preview: List<Int>) {
151 185 }
152 186 }
153 187
154 −/** the machine's own output, as it comes off the firehose */
188 +/** a strip of pixels: a machine's own output, or a preview of one not started */
155 189 @Composable
156 −private fun PreviewStrip(pixels: List<Int>) {
157 − Canvas(
158 − modifier = Modifier
159 − .height(48.dp)
160 − .fillMaxWidth(0.35f)
161 − ) {
190 +fun PreviewStrip(pixels: List<Int>, modifier: Modifier = Modifier) {
191 + Canvas(modifier = modifier) {
162 192 if (pixels.isEmpty()) return@Canvas
163 193 val slice = size.width / pixels.size
164 194 pixels.forEachIndexed { index, rgb ->
@@ -175,19 +205,45 @@ private fun PreviewStrip(pixels: List<Int>) {
175 205 private fun LibrarySheet(
176 206 library: Map<String, List<String>>,
177 207 onPick: (String, String) -> Unit,
208 + onEdit: (String, String) -> Unit,
209 + onBuild: () -> Unit,
178 210 ) {
179 − if (library.isEmpty()) {
180 − Text("Loading…", modifier = Modifier.padding(16.dp))
181 − return
182 − }
183 −
184 211 LazyColumn(contentPadding = PaddingValues(16.dp)) {
212 + item {
213 + Row(
214 + modifier = Modifier.fillMaxWidth(),
215 + verticalAlignment = Alignment.CenterVertically,
216 + ) {
217 + Text("Catalog", style = MaterialTheme.typography.titleLarge)
218 + Spacer(Modifier.weight(1f))
219 + OutlinedButton(onClick = onBuild) { Text("Build one") }
220 + }
221 + }
222 +
223 + if (library.isEmpty()) {
224 + item { Text("Loading…", modifier = Modifier.padding(16.dp)) }
225 + }
226 +
185 227 library.forEach { (cls, entries) ->
186 228 item {
187 229 Text(cls, style = MaterialTheme.typography.titleMedium)
188 230 }
231 + // tapping an entry starts it; the pencil opens it in the form to
232 + // change before starting, the way the web catalog does
189 233 items(entries) { entry ->
190 − TextButton(onClick = { onPick(cls, entry) }) { Text(entry) }
234 + Row(
235 + modifier = Modifier.fillMaxWidth(),
236 + verticalAlignment = Alignment.CenterVertically,
237 + ) {
238 + TextButton(
239 + onClick = { onPick(cls, entry) },
240 + modifier = Modifier.weight(1f),
241 + ) { Text(entry, modifier = Modifier.fillMaxWidth()) }
242 +
243 + IconButton(onClick = { onEdit(cls, entry) }) {
244 + Icon(Icons.Filled.Edit, contentDescription = "Edit before starting")
245 + }
246 + }
191 247 }
192 248 }
193 249 }
modified lib/lambent_ex/machine/preview.ex
+10 −3
@@ -10,7 +10,9 @@ defmodule LambentEx.Machines.Preview do
10 10 @count 64
11 11
12 12 @doc """
13 − `count` pixels of `step` configured with `args`, oldest first, as `[r, g, b]`.
13 + `count` pixels of `step` configured with `args`, oldest first, as `[r, g, b]`
14 + bytes — the steps work in floats, which is not what a wire or a stylesheet
15 + wants.
14 16
15 17 The process is started unlinked and unregistered: a configuration that a step
16 18 cannot start with is a thing the form should say, not a thing that should
@@ -35,7 +37,10 @@ defmodule LambentEx.Machines.Preview do
35 37 end)
36 38
37 39 GenServer.stop(pid)
38 − Enum.filter(pixels, &match?([_r, _g, _b], &1))
40 +
41 + pixels
42 + |> Enum.filter(&match?([_r, _g, _b], &1))
43 + |> Enum.map(fn pixel -> Enum.map(pixel, &byte/1) end)
39 44 catch
40 45 # a half configured step can fail mid-run, and a partial strip is still
41 46 # worth showing
@@ -44,9 +49,11 @@ defmodule LambentEx.Machines.Preview do
44 49 []
45 50 end
46 51
52 + defp byte(value), do: value |> round() |> max(0) |> min(255)
53 +
47 54 @doc "The strip as css colours, for a template to lay out."
48 55 def swatches(step, args, count \\ @count) do
49 56 strip(step, args, count)
50 − |> Enum.map(fn [r, g, b] -> "rgb(#{round(r)}, #{round(g)}, #{round(b)})" end)
57 + |> Enum.map(fn [r, g, b] -> "rgb(#{r}, #{g}, #{b})" end)
51 58 end
52 59 end
modified lib/lambent_ex_web/channels/machines_channel.ex
+74 −1
@@ -8,11 +8,15 @@ defmodule LambentExWeb.MachinesChannel do
8 8 * `"machine"` — one machine's published state, keyed by `id`
9 9 * `"preview"` — `%{name: name, pixels: [[r,g,b], ...]}`, first 32 pixels,
10 10 up to ~4Hz per machine. Join with `%{"previews" => false}` to skip it.
11 + * `"types"` — what the new machine form can build and what each type
12 + carries, so a client draws the same fields the web form does without
13 + keeping its own copy of them.
11 14 """
12 15
13 16 use Phoenix.Channel
14 − require Logger
15 17
18 + alias LambentEx.Machines.Preview
19 + alias LambentEx.Schema.Machines
16 20 alias LambentExWeb.Channels.Payload
17 21
18 22 @pubsub LambentEx.PubSub
@@ -31,6 +35,7 @@ defmodule LambentExWeb.MachinesChannel do
31 35 @impl true
32 36 def handle_info(:after_join, socket) do
33 37 push(socket, "library", %{library: library()})
38 + push(socket, "types", %{types: types()})
34 39 {:noreply, socket}
35 40 end
36 41
@@ -83,6 +88,52 @@ defmodule LambentExWeb.MachinesChannel do
83 88 end
84 89 end
85 90
91 + @impl true
92 + def handle_in("types", _params, socket) do
93 + types = types()
94 + push(socket, "types", %{types: types})
95 + {:reply, {:ok, %{types: types}}, socket}
96 + end
97 +
98 + # what a configuration looks like running, before committing to it: a single
99 + # pixel over time, the same thing the web form and the catalog draw
100 + @impl true
101 + def handle_in("preview", %{"machine" => params}, socket) do
102 + changeset = Machines.change_machine(%Machines{}, params)
103 + type = Ecto.Changeset.get_field(changeset, :type)
104 + class = Ecto.Changeset.get_field(changeset, :class)
105 +
106 + case {Machines.spec(type), class, changeset.valid?} do
107 + {%{step: step}, class, true} when not is_nil(class) ->
108 + pixels = Preview.strip(step, Machines.args(class))
109 + {:reply, {:ok, %{pixels: Payload.wire(pixels)}}, socket}
110 +
111 + _otherwise ->
112 + {:reply, {:ok, %{pixels: []}}, socket}
113 + end
114 + end
115 +
116 + # a catalog entry as the form would hold it, for editing rather than
117 + # starting. entries with no form behind them have nothing to hand over.
118 + @impl true
119 + def handle_in("library_edit", %{"cls" => cls, "entry" => entry}, socket) do
120 + case Machines.from_library(cls, entry) do
121 + nil ->
122 + {:reply, {:error, %{reason: "#{cls}/#{entry} has no form behind it"}}, socket}
123 +
124 + machine ->
125 + {:reply,
126 + {:ok,
127 + %{
128 + machine: %{
129 + name: machine.name,
130 + type: to_string(machine.type),
131 + class: machine.class |> Map.from_struct() |> Payload.wire()
132 + }
133 + }}, socket}
134 + end
135 + end
136 +
86 137 # same params the web form posts: %{"name" => _, "type" => _, "class" => %{...}}
87 138 @impl true
88 139 def handle_in("create", %{"machine" => params}, socket) do
@@ -94,6 +145,28 @@ defmodule LambentExWeb.MachinesChannel do
94 145
95 146 defp library, do: Payload.wire(LambentEx.Machines.Library.machines())
96 147
148 + # the form metadata, in the order the web form offers it
149 + defp types do
150 + Machines.options()
151 + |> Enum.map(fn {label, type} ->
152 + %{
153 + type: to_string(type),
154 + label: label,
155 + fields:
156 + Machines.fields(type)
157 + |> Enum.map(fn field ->
158 + %{
159 + name: to_string(field.name),
160 + kind: to_string(field.kind),
161 + label: field.label,
162 + required: field.required,
163 + default: Map.get(struct(Machines.spec(type).schema), field.name)
164 + }
165 + end)
166 + }
167 + end)
168 + end
169 +
97 170 # the library is keyed by atoms, and a client should not be able to grow the
98 171 # atom table by asking for entries that do not exist
99 172 defp safe_atom(str) do
modified test/lambent_ex_web/channels/channels_test.exs
+73 −0
@@ -51,6 +51,79 @@ defmodule LambentExWeb.ChannelsTest do
51 51 end
52 52 end
53 53
54 + describe "the machine form over the machines channel" do
55 + test "describes what it can build and what each type carries" do
56 + connect_to("machines", %{"previews" => false})
57 +
58 + assert_push "types", %{types: types}
59 + assert {:ok, _json} = Jason.encode(types)
60 +
61 + chaser = Enum.find(types, fn t -> t.type == "gen_chaser_h" end)
62 + assert chaser.label == "Chaser (HSV)"
63 +
64 + hue = Enum.find(chaser.fields, fn f -> f.name == "h" end)
65 + assert hue.kind == "hue"
66 + assert hue.required
67 +
68 + spacing = Enum.find(chaser.fields, fn f -> f.name == "spacing" end)
69 + refute spacing.required
70 + assert spacing.default == 30
71 +
72 + # nothing that has no form behind it
73 + refute Enum.any?(types, fn t -> t.type == "rcv_fft" end)
74 + end
75 +
76 + test "previews a configuration without starting it" do
77 + socket = connect_to("machines", %{"previews" => false})
78 +
79 + ref =
80 + push(socket, "preview", %{
81 + "machine" => %{
82 + "name" => "preview-test",
83 + "type" => "gen_solid_h",
84 + "class" => %{"h" => 84, "s" => 255, "v" => 255}
85 + }
86 + })
87 +
88 + assert_reply ref, :ok, %{pixels: pixels}
89 + assert length(pixels) > 0
90 + assert [6, 255, 0] in pixels
91 +
92 + # and nothing was started
93 + assert Registry.lookup(:lambent_machine, "machine-preview-test") == []
94 + end
95 +
96 + test "a half filled configuration previews as nothing" do
97 + socket = connect_to("machines", %{"previews" => false})
98 +
99 + ref =
100 + push(socket, "preview", %{
101 + "machine" => %{"name" => "preview-empty", "type" => "gen_solid_h"}
102 + })
103 +
104 + assert_reply ref, :ok, %{pixels: []}
105 + end
106 +
107 + test "hands over a catalog entry for editing" do
108 + socket = connect_to("machines", %{"previews" => false})
109 +
110 + ref = push(socket, "library_edit", %{"cls" => "chase", "entry" => "purple"})
111 + assert_reply ref, :ok, %{machine: machine}
112 +
113 + assert machine.name == "purple-edited"
114 + assert machine.type == "gen_chaser_h"
115 + assert machine.class["h"] == 170
116 + assert {:ok, _json} = Jason.encode(machine)
117 + end
118 +
119 + test "refuses an entry with no form behind it" do
120 + socket = connect_to("machines", %{"previews" => false})
121 +
122 + ref = push(socket, "library_edit", %{"cls" => "solidrgb", "entry" => "ruby"})
123 + assert_reply ref, :error, %{reason: _why}
124 + end
125 + end
126 +
54 127 describe "brightness channel" do
55 128 test "pushes the ladders and the clamps on join" do
56 129 connect_to("brightness")

Parents: 6a69b24