neiam
/lambent_ex
Lambent Aether 5
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Build machines from the android app too
453cc82 · Gabriel Morell · 2026-09-16 23:07
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