neiam
/lambent_ex
Lambent Aether 5
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Let the brightness sliders stand up as a bank of faders
949a2ee · Gabriel Morell · 2026-09-18 04:04
Message
{commit_body(@commit)}
Files changed
modified
android/README.md
+2
−0
@@ -35,11 +35,13 @@ android/
| 35 | 35 | LambentSocket.kt # the socket, four channels, all commands |
| 36 | 36 | Models.kt # Device / Machine / Link / Service + parsing |
| 37 | 37 | BaseUrlStore.kt # where the box lives |
| 38 | + UiPrefs.kt # layout the person picked, e.g. upright faders | |
| 38 | 39 | ui/ |
| 39 | 40 | ConnectScreen.kt # QR scan or type an address |
| 40 | 41 | DevicesScreen.kt # rename / place / order / poke / find-by-IP |
| 41 | 42 | MachinesScreen.kt # speed, brightness, persist, quit, previews, library |
| 42 | 43 | LinksScreen.kt # toggle / persist / quit, create with channel |
| 44 | + BrightnessScreen.kt # global + per-place clamps, rows or upright faders | |
| 43 | 45 | ServicesScreen.kt # list + delete, and forget-this-box |
| 44 | 46 | theme/ # the afterdark palette and TF2 Build |
| 45 | 47 | res/font/tf2build.ttf # converted from assets/css/fonts/TF2Build.woff |
modified
android/app/src/main/java/org/neiam/lambent/app/MainActivity.kt
+5
−3
@@ -37,6 +37,7 @@ import androidx.compose.runtime.LaunchedEffect
| 37 | 37 | import org.neiam.lambent.app.data.BaseUrlStore |
| 38 | 38 | import org.neiam.lambent.app.data.ConnectionState |
| 39 | 39 | import org.neiam.lambent.app.data.LambentSocket |
| 40 | +import org.neiam.lambent.app.data.UiPrefs | |
| 40 | 41 | import org.neiam.lambent.app.ui.BrightnessScreen |
| 41 | 42 | import org.neiam.lambent.app.ui.ConnectScreen |
| 42 | 43 | import org.neiam.lambent.app.ui.DevicesScreen |
@@ -51,11 +52,12 @@ class MainActivity : ComponentActivity() {
| 51 | 52 | enableEdgeToEdge() |
| 52 | 53 | |
| 53 | 54 | val store = BaseUrlStore(this) |
| 55 | + val prefs = UiPrefs(this) | |
| 54 | 56 | |
| 55 | 57 | setContent { |
| 56 | 58 | LambentTheme { |
| 57 | 59 | Surface(modifier = Modifier.fillMaxSize()) { |
| 58 | − App(store) | |
| 60 | + App(store, prefs) | |
| 59 | 61 | } |
| 60 | 62 | } |
| 61 | 63 | } |
@@ -77,7 +79,7 @@ private enum class Tab(val label: String, val icon: ImageVector) {
| 77 | 79 | |
| 78 | 80 | @OptIn(ExperimentalMaterial3Api::class) |
| 79 | 81 | @Composable |
| 80 | −private fun App(store: BaseUrlStore) { | |
| 82 | +private fun App(store: BaseUrlStore, prefs: UiPrefs) { | |
| 81 | 83 | var baseUrl by remember { mutableStateOf(store.baseUrl) } |
| 82 | 84 | val state by LambentSocket.state.collectAsStateWithLifecycle() |
| 83 | 85 | val snackbar = remember { SnackbarHostState() } |
@@ -147,7 +149,7 @@ private fun App(store: BaseUrlStore) {
| 147 | 149 | Tab.Devices -> DevicesScreen() |
| 148 | 150 | Tab.Machines -> MachinesScreen() |
| 149 | 151 | Tab.Links -> LinksScreen() |
| 150 | − Tab.Brightness -> BrightnessScreen() | |
| 152 | + Tab.Brightness -> BrightnessScreen(prefs) | |
| 151 | 153 | Tab.Services -> ServicesScreen( |
| 152 | 154 | baseUrl = baseUrl.orEmpty(), |
| 153 | 155 | onForget = { |
added
android/app/src/main/java/org/neiam/lambent/app/data/UiPrefs.kt
+20
−0
@@ -0,0 +1,20 @@
| 1 | +package org.neiam.lambent.app.data | |
| 2 | + | |
| 3 | +import android.content.Context | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Small bits of layout the person chose and expects to still be there next | |
| 7 | + * time. Same prefs file as [BaseUrlStore]; none of it is worth protecting. | |
| 8 | + */ | |
| 9 | +class UiPrefs(context: Context) { | |
| 10 | + private val prefs = context.getSharedPreferences("lambent_prefs", Context.MODE_PRIVATE) | |
| 11 | + | |
| 12 | + /** brightness sliders as a row of upright faders rather than stacked rows */ | |
| 13 | + var verticalClamps: Boolean | |
| 14 | + get() = prefs.getBoolean(KEY_VERTICAL_CLAMPS, false) | |
| 15 | + set(value) = prefs.edit().putBoolean(KEY_VERTICAL_CLAMPS, value).apply() | |
| 16 | + | |
| 17 | + companion object { | |
| 18 | + private const val KEY_VERTICAL_CLAMPS = "brightness_vertical" | |
| 19 | + } | |
| 20 | +} |
modified
android/app/src/main/java/org/neiam/lambent/app/ui/BrightnessScreen.kt
+242
−48
@@ -1,21 +1,28 @@
| 1 | 1 | package org.neiam.lambent.app.ui |
| 2 | 2 | |
| 3 | 3 | import androidx.compose.foundation.layout.Arrangement |
| 4 | +import androidx.compose.foundation.layout.Box | |
| 4 | 5 | import androidx.compose.foundation.layout.Column |
| 5 | 6 | import androidx.compose.foundation.layout.PaddingValues |
| 6 | 7 | import androidx.compose.foundation.layout.Row |
| 8 | +import androidx.compose.foundation.layout.fillMaxSize | |
| 7 | 9 | import androidx.compose.foundation.layout.fillMaxWidth |
| 10 | +import androidx.compose.foundation.layout.height | |
| 8 | 11 | import androidx.compose.foundation.layout.padding |
| 12 | +import androidx.compose.foundation.layout.width | |
| 9 | 13 | import androidx.compose.foundation.lazy.LazyColumn |
| 14 | +import androidx.compose.foundation.lazy.LazyRow | |
| 10 | 15 | import androidx.compose.foundation.lazy.items |
| 11 | 16 | import androidx.compose.material.icons.Icons |
| 12 | 17 | import androidx.compose.material.icons.filled.Close |
| 13 | 18 | import androidx.compose.material.icons.filled.LightMode |
| 19 | +import androidx.compose.material.icons.filled.SwapVert | |
| 14 | 20 | import androidx.compose.material.icons.outlined.LightMode |
| 15 | 21 | import androidx.compose.material3.AssistChip |
| 16 | 22 | import androidx.compose.material3.Card |
| 17 | 23 | import androidx.compose.material3.DropdownMenu |
| 18 | 24 | import androidx.compose.material3.DropdownMenuItem |
| 25 | +import androidx.compose.material3.FilterChip | |
| 19 | 26 | import androidx.compose.material3.Icon |
| 20 | 27 | import androidx.compose.material3.IconButton |
| 21 | 28 | import androidx.compose.material3.LinearProgressIndicator |
@@ -30,29 +37,48 @@ import androidx.compose.runtime.remember
| 30 | 37 | import androidx.compose.runtime.setValue |
| 31 | 38 | import androidx.compose.ui.Alignment |
| 32 | 39 | import androidx.compose.ui.Modifier |
| 40 | +import androidx.compose.ui.draw.rotate | |
| 41 | +import androidx.compose.ui.layout.layout | |
| 42 | +import androidx.compose.ui.text.style.TextAlign | |
| 43 | +import androidx.compose.ui.text.style.TextOverflow | |
| 44 | +import androidx.compose.ui.unit.Constraints | |
| 45 | +import androidx.compose.ui.unit.Dp | |
| 33 | 46 | import androidx.compose.ui.unit.dp |
| 34 | 47 | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
| 35 | 48 | import org.neiam.lambent.app.data.Clamp |
| 36 | 49 | import org.neiam.lambent.app.data.LambentSocket |
| 37 | 50 | import org.neiam.lambent.app.data.Named |
| 51 | +import org.neiam.lambent.app.data.UiPrefs | |
| 38 | 52 | import org.neiam.lambent.app.data.brightnessPercent |
| 39 | 53 | import org.neiam.lambent.app.data.ladderIndex |
| 40 | 54 | import org.neiam.lambent.app.data.ladderPosition |
| 41 | 55 | |
| 56 | +/** how tall an upright fader's travel is */ | |
| 57 | +private val FADER_LENGTH = 220.dp | |
| 58 | + | |
| 59 | +/** narrow enough that a handful of places fit on a phone at once */ | |
| 60 | +private val FADER_WIDTH = 104.dp | |
| 61 | + | |
| 42 | 62 | /** |
| 43 | 63 | * The global clamp and one row per place, mirroring /cfg/brightness. A clamp |
| 44 | 64 | * is a ceiling over what the machines produce, not a level, and it walks to |
| 45 | 65 | * its target rather than snapping — so each row shows where it is headed and |
| 46 | 66 | * where it has actually got to. |
| 67 | + * | |
| 68 | + * The toggle swaps the sliders between stacked horizontal rows and a | |
| 69 | + * side-scrolling bank of upright faders, which is how a lighting desk puts | |
| 70 | + * them and the only way to reach several at once with a thumb. | |
| 47 | 71 | */ |
| 48 | 72 | @Composable |
| 49 | −fun BrightnessScreen() { | |
| 73 | +fun BrightnessScreen(prefs: UiPrefs) { | |
| 50 | 74 | val global by LambentSocket.globalClamp.collectAsStateWithLifecycle() |
| 51 | 75 | val clamps by LambentSocket.placeClamps.collectAsStateWithLifecycle() |
| 52 | 76 | val devices by LambentSocket.devices.collectAsStateWithLifecycle() |
| 53 | 77 | val ladder by LambentSocket.ladder.collectAsStateWithLifecycle() |
| 54 | 78 | val fades by LambentSocket.fades.collectAsStateWithLifecycle() |
| 55 | 79 | |
| 80 | + var vertical by remember { mutableStateOf(prefs.verticalClamps) } | |
| 81 | + | |
| 56 | 82 | // every place a device claims, plus any carrying a clamp with no device |
| 57 | 83 | // answering right now |
| 58 | 84 | val places = remember(devices, clamps) { |
@@ -61,51 +87,107 @@ fun BrightnessScreen() {
| 61 | 87 | |
| 62 | 88 | val defaultRate = remember(fades) { fades.firstOrNull { it.name == "TWOMINS" }?.value ?: 0 } |
| 63 | 89 | |
| 64 | − LazyColumn( | |
| 65 | − contentPadding = PaddingValues(12.dp), | |
| 66 | − verticalArrangement = Arrangement.spacedBy(8.dp), | |
| 67 | − ) { | |
| 68 | − item { | |
| 90 | + fun clampFor(place: String) = clamps[place] ?: Clamp.unclamped(place, defaultRate) | |
| 91 | + | |
| 92 | + fun clearFor(place: String): (() -> Unit)? = | |
| 93 | + if (clamps.containsKey(place)) { | |
| 94 | + { LambentSocket.clearClamp(place) } | |
| 95 | + } else { | |
| 96 | + null | |
| 97 | + } | |
| 98 | + | |
| 99 | + Column(modifier = Modifier.fillMaxSize()) { | |
| 100 | + Row( | |
| 101 | + modifier = Modifier.fillMaxWidth().padding(start = 12.dp, top = 12.dp, end = 12.dp), | |
| 102 | + verticalAlignment = Alignment.CenterVertically, | |
| 103 | + ) { | |
| 69 | 104 | Text( |
| 70 | 105 | "A clamp is a ceiling over everything underneath it. The global one scales " + |
| 71 | 106 | "every machine; a place clamp scales whatever reaches a device in that place.", |
| 72 | 107 | style = MaterialTheme.typography.labelSmall, |
| 108 | + modifier = Modifier.weight(1f), | |
| 73 | 109 | ) |
| 74 | − } | |
| 75 | − | |
| 76 | − item { | |
| 77 | − ClampCard( | |
| 78 | − title = "Global", | |
| 79 | − subtitle = "${devices.size} devices", | |
| 80 | − clamp = global, | |
| 81 | − ladder = ladder, | |
| 82 | − fades = fades, | |
| 110 | + FilterChip( | |
| 111 | + selected = vertical, | |
| 112 | + onClick = { | |
| 113 | + vertical = !vertical | |
| 114 | + prefs.verticalClamps = vertical | |
| 115 | + }, | |
| 116 | + label = { Text(if (vertical) "Faders" else "Rows") }, | |
| 117 | + leadingIcon = { Icon(Icons.Filled.SwapVert, contentDescription = null) }, | |
| 118 | + modifier = Modifier.padding(start = 8.dp), | |
| 83 | 119 | ) |
| 84 | 120 | } |
| 85 | 121 | |
| 86 | − if (places.isEmpty()) { | |
| 87 | − item { | |
| 122 | + if (vertical) { | |
| 123 | + if (places.isEmpty()) { | |
| 88 | 124 | Text( |
| 89 | 125 | "No places yet. Give a device a place and it shows up here.", |
| 90 | 126 | style = MaterialTheme.typography.bodyMedium, |
| 127 | + modifier = Modifier.padding(horizontal = 12.dp), | |
| 91 | 128 | ) |
| 92 | 129 | } |
| 93 | − } | |
| 94 | 130 | |
| 95 | − items(places, key = { it }) { place -> | |
| 96 | − val clamp = clamps[place] ?: Clamp.unclamped(place, defaultRate) | |
| 97 | − ClampCard( | |
| 98 | − title = place, | |
| 99 | − subtitle = "${devices.values.count { it.place == place }} devices", | |
| 100 | − clamp = clamp, | |
| 101 | − ladder = ladder, | |
| 102 | − fades = fades, | |
| 103 | − onClear = if (clamps.containsKey(place)) { | |
| 104 | − { LambentSocket.clearClamp(place) } | |
| 105 | − } else { | |
| 106 | − null | |
| 107 | − }, | |
| 108 | − ) | |
| 131 | + LazyRow( | |
| 132 | + contentPadding = PaddingValues(12.dp), | |
| 133 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 134 | + ) { | |
| 135 | + item { | |
| 136 | + FaderCard( | |
| 137 | + title = "Global", | |
| 138 | + subtitle = "${devices.size} devices", | |
| 139 | + clamp = global, | |
| 140 | + ladder = ladder, | |
| 141 | + fades = fades, | |
| 142 | + ) | |
| 143 | + } | |
| 144 | + | |
| 145 | + items(places, key = { it }) { place -> | |
| 146 | + FaderCard( | |
| 147 | + title = place, | |
| 148 | + subtitle = "${devices.values.count { it.place == place }} devices", | |
| 149 | + clamp = clampFor(place), | |
| 150 | + ladder = ladder, | |
| 151 | + fades = fades, | |
| 152 | + onClear = clearFor(place), | |
| 153 | + ) | |
| 154 | + } | |
| 155 | + } | |
| 156 | + } else { | |
| 157 | + LazyColumn( | |
| 158 | + contentPadding = PaddingValues(12.dp), | |
| 159 | + verticalArrangement = Arrangement.spacedBy(8.dp), | |
| 160 | + ) { | |
| 161 | + item { | |
| 162 | + ClampCard( | |
| 163 | + title = "Global", | |
| 164 | + subtitle = "${devices.size} devices", | |
| 165 | + clamp = global, | |
| 166 | + ladder = ladder, | |
| 167 | + fades = fades, | |
| 168 | + ) | |
| 169 | + } | |
| 170 | + | |
| 171 | + if (places.isEmpty()) { | |
| 172 | + item { | |
| 173 | + Text( | |
| 174 | + "No places yet. Give a device a place and it shows up here.", | |
| 175 | + style = MaterialTheme.typography.bodyMedium, | |
| 176 | + ) | |
| 177 | + } | |
| 178 | + } | |
| 179 | + | |
| 180 | + items(places, key = { it }) { place -> | |
| 181 | + ClampCard( | |
| 182 | + title = place, | |
| 183 | + subtitle = "${devices.values.count { it.place == place }} devices", | |
| 184 | + clamp = clampFor(place), | |
| 185 | + ladder = ladder, | |
| 186 | + fades = fades, | |
| 187 | + onClear = clearFor(place), | |
| 188 | + ) | |
| 189 | + } | |
| 190 | + } | |
| 109 | 191 | } |
| 110 | 192 | } |
| 111 | 193 | } |
@@ -127,15 +209,7 @@ private fun ClampCard(
| 127 | 209 | Row(verticalAlignment = Alignment.CenterVertically) { |
| 128 | 210 | Column(modifier = Modifier.weight(1f)) { |
| 129 | 211 | Text(title, style = MaterialTheme.typography.titleMedium) |
| 130 | − Text( | |
| 131 | − if (clamp.walking) { | |
| 132 | − "${brightnessPercent(clamp.current)}% → " + | |
| 133 | − "${brightnessPercent(clamp.target)}% · $subtitle" | |
| 134 | − } else { | |
| 135 | − "${brightnessPercent(clamp.current)}% · $subtitle" | |
| 136 | − }, | |
| 137 | − style = MaterialTheme.typography.labelSmall, | |
| 138 | − ) | |
| 212 | + Text(walkLabel(clamp, subtitle), style = MaterialTheme.typography.labelSmall) | |
| 139 | 213 | } |
| 140 | 214 | |
| 141 | 215 | IconButton(onClick = { LambentSocket.clampStep(clamp.place, up = false) }) { |
@@ -161,9 +235,7 @@ private fun ClampCard(
| 161 | 235 | // where the walk has actually got to, on the same scale as the |
| 162 | 236 | // slider above it |
| 163 | 237 | LinearProgressIndicator( |
| 164 | − progress = { | |
| 165 | − ladderPosition(clamp.current, ladder) / (ladder.size - 1) | |
| 166 | − }, | |
| 238 | + progress = { ladderProgress(clamp, ladder) }, | |
| 167 | 239 | modifier = Modifier.fillMaxWidth(), |
| 168 | 240 | ) |
| 169 | 241 | } |
@@ -173,13 +245,92 @@ private fun ClampCard(
| 173 | 245 | } |
| 174 | 246 | } |
| 175 | 247 | |
| 248 | +/** | |
| 249 | + * The same controls stood on end: brighter at the top, the name at the bottom | |
| 250 | + * where a desk labels its channels, and the whole card narrow enough that | |
| 251 | + * several sit side by side under a thumb. | |
| 252 | + */ | |
| 253 | +@Composable | |
| 254 | +private fun FaderCard( | |
| 255 | + title: String, | |
| 256 | + subtitle: String, | |
| 257 | + clamp: Clamp, | |
| 258 | + ladder: List<Named>, | |
| 259 | + fades: List<Named>, | |
| 260 | + onClear: (() -> Unit)? = null, | |
| 261 | +) { | |
| 262 | + Card(modifier = Modifier.width(FADER_WIDTH)) { | |
| 263 | + Column( | |
| 264 | + modifier = Modifier.padding(8.dp).fillMaxWidth(), | |
| 265 | + horizontalAlignment = Alignment.CenterHorizontally, | |
| 266 | + verticalArrangement = Arrangement.spacedBy(4.dp), | |
| 267 | + ) { | |
| 268 | + IconButton(onClick = { LambentSocket.clampStep(clamp.place, up = true) }) { | |
| 269 | + Icon(Icons.Filled.LightMode, contentDescription = "Brighter") | |
| 270 | + } | |
| 271 | + | |
| 272 | + if (ladder.size > 1) { | |
| 273 | + Row( | |
| 274 | + modifier = Modifier.height(FADER_LENGTH), | |
| 275 | + verticalAlignment = Alignment.CenterVertically, | |
| 276 | + horizontalArrangement = Arrangement.spacedBy(4.dp), | |
| 277 | + ) { | |
| 278 | + RungSlider( | |
| 279 | + clamp = clamp, | |
| 280 | + ladder = ladder, | |
| 281 | + modifier = Modifier.upright(FADER_LENGTH), | |
| 282 | + ) | |
| 283 | + | |
| 284 | + // the walk's actual position, on the fader's own scale | |
| 285 | + LinearProgressIndicator( | |
| 286 | + progress = { ladderProgress(clamp, ladder) }, | |
| 287 | + modifier = Modifier.upright(FADER_LENGTH), | |
| 288 | + ) | |
| 289 | + } | |
| 290 | + } else { | |
| 291 | + Box(modifier = Modifier.height(FADER_LENGTH)) | |
| 292 | + } | |
| 293 | + | |
| 294 | + IconButton(onClick = { LambentSocket.clampStep(clamp.place, up = false) }) { | |
| 295 | + Icon(Icons.Outlined.LightMode, contentDescription = "Dimmer") | |
| 296 | + } | |
| 297 | + | |
| 298 | + FadeChip(clamp = clamp, fades = fades, compact = true) | |
| 299 | + | |
| 300 | + Text( | |
| 301 | + title, | |
| 302 | + style = MaterialTheme.typography.titleSmall, | |
| 303 | + maxLines = 1, | |
| 304 | + overflow = TextOverflow.Ellipsis, | |
| 305 | + textAlign = TextAlign.Center, | |
| 306 | + ) | |
| 307 | + Text( | |
| 308 | + walkLabel(clamp, subtitle), | |
| 309 | + style = MaterialTheme.typography.labelSmall, | |
| 310 | + maxLines = 2, | |
| 311 | + textAlign = TextAlign.Center, | |
| 312 | + ) | |
| 313 | + | |
| 314 | + if (onClear != null) { | |
| 315 | + IconButton(onClick = onClear) { | |
| 316 | + Icon( | |
| 317 | + Icons.Filled.Close, | |
| 318 | + contentDescription = "Drop this clamp", | |
| 319 | + tint = MaterialTheme.colorScheme.error, | |
| 320 | + ) | |
| 321 | + } | |
| 322 | + } | |
| 323 | + } | |
| 324 | + } | |
| 325 | +} | |
| 326 | + | |
| 176 | 327 | /** |
| 177 | 328 | * The target, which can only land on one of the named rungs. The rungs are |
| 178 | 329 | * bunched into the bottom of 0..255, so the slider runs over their positions |
| 179 | 330 | * and gives them even spacing. |
| 180 | 331 | */ |
| 181 | 332 | @Composable |
| 182 | −private fun RungSlider(clamp: Clamp, ladder: List<Named>) { | |
| 333 | +private fun RungSlider(clamp: Clamp, ladder: List<Named>, modifier: Modifier = Modifier) { | |
| 183 | 334 | val last = ladder.size - 1 |
| 184 | 335 | var position by remember { mutableStateOf(ladderIndex(clamp.target, ladder).toFloat()) } |
| 185 | 336 |
@@ -197,16 +348,31 @@ private fun RungSlider(clamp: Clamp, ladder: List<Named>) {
| 197 | 348 | }, |
| 198 | 349 | valueRange = 0f..last.toFloat(), |
| 199 | 350 | steps = (last - 1).coerceAtLeast(0), |
| 351 | + modifier = modifier, | |
| 200 | 352 | ) |
| 201 | 353 | } |
| 202 | 354 | |
| 203 | 355 | @Composable |
| 204 | −private fun FadeChip(clamp: Clamp, fades: List<Named>) { | |
| 356 | +private fun FadeChip(clamp: Clamp, fades: List<Named>, compact: Boolean = false) { | |
| 205 | 357 | var open by remember { mutableStateOf(false) } |
| 206 | − val label = fades.firstOrNull { it.value == clamp.rate }?.name ?: "${clamp.rate}ms" | |
| 358 | + val name = fades.firstOrNull { it.value == clamp.rate }?.name ?: "${clamp.rate}ms" | |
| 207 | 359 | |
| 208 | 360 | Column { |
| 209 | − AssistChip(onClick = { open = true }, label = { Text("fade $label") }) | |
| 361 | + AssistChip( | |
| 362 | + onClick = { open = true }, | |
| 363 | + label = { | |
| 364 | + Text( | |
| 365 | + if (compact) name else "fade $name", | |
| 366 | + maxLines = 1, | |
| 367 | + overflow = TextOverflow.Ellipsis, | |
| 368 | + style = if (compact) { | |
| 369 | + MaterialTheme.typography.labelSmall | |
| 370 | + } else { | |
| 371 | + MaterialTheme.typography.labelLarge | |
| 372 | + }, | |
| 373 | + ) | |
| 374 | + }, | |
| 375 | + ) | |
| 210 | 376 | DropdownMenu(expanded = open, onDismissRequest = { open = false }) { |
| 211 | 377 | fades.forEach { fade -> |
| 212 | 378 | DropdownMenuItem( |
@@ -220,3 +386,31 @@ private fun FadeChip(clamp: Clamp, fades: List<Named>) {
| 220 | 386 | } |
| 221 | 387 | } |
| 222 | 388 | } |
| 389 | + | |
| 390 | +private fun walkLabel(clamp: Clamp, subtitle: String): String = | |
| 391 | + if (clamp.walking) { | |
| 392 | + "${brightnessPercent(clamp.current)}% → ${brightnessPercent(clamp.target)}% · $subtitle" | |
| 393 | + } else { | |
| 394 | + "${brightnessPercent(clamp.current)}% · $subtitle" | |
| 395 | + } | |
| 396 | + | |
| 397 | +private fun ladderProgress(clamp: Clamp, ladder: List<Named>): Float = | |
| 398 | + ladderPosition(clamp.current, ladder) / (ladder.size - 1) | |
| 399 | + | |
| 400 | +/** | |
| 401 | + * Stands a horizontal control on end: it is measured at [length] wide as it | |
| 402 | + * would be lying down, reports itself to the parent with those dimensions | |
| 403 | + * swapped, and is drawn — and dragged — rotated so low sits at the bottom. | |
| 404 | + */ | |
| 405 | +private fun Modifier.upright(length: Dp): Modifier = this | |
| 406 | + .layout { measurable, _ -> | |
| 407 | + val px = length.roundToPx() | |
| 408 | + val placeable = measurable.measure(Constraints(minWidth = px, maxWidth = px)) | |
| 409 | + layout(placeable.height, placeable.width) { | |
| 410 | + placeable.place( | |
| 411 | + x = -(placeable.width / 2 - placeable.height / 2), | |
| 412 | + y = -(placeable.height / 2 - placeable.width / 2), | |
| 413 | + ) | |
| 414 | + } | |
| 415 | + } | |
| 416 | + .rotate(-90f) |
Parents: 6478ec8