Krolyxon
Commit 606eee1

Branches

Refs →

budctlandroidappsrcmainkotliniogithubkrolyxonbudctlDots.kt

232 lines · 9.3 KB · Kotlin

1package io.github.krolyxon.budctl
2
3import androidx.compose.animation.core.Animatable
4import androidx.compose.animation.core.RepeatMode
5import androidx.compose.animation.core.animateFloat
6import androidx.compose.animation.core.animateFloatAsState
7import androidx.compose.animation.core.infiniteRepeatable
8import androidx.compose.animation.core.keyframes
9import androidx.compose.animation.core.rememberInfiniteTransition
10import androidx.compose.animation.core.tween
11import androidx.compose.foundation.Canvas
12import androidx.compose.foundation.gestures.detectHorizontalDragGestures
13import androidx.compose.foundation.gestures.detectTapGestures
14import androidx.compose.foundation.gestures.detectVerticalDragGestures
15import androidx.compose.foundation.layout.Box
16import androidx.compose.foundation.layout.BoxWithConstraints
17import androidx.compose.foundation.layout.aspectRatio
18import androidx.compose.foundation.layout.fillMaxWidth
19import androidx.compose.foundation.layout.height
20import androidx.compose.foundation.layout.size
21import androidx.compose.foundation.layout.width
22import androidx.compose.foundation.text.BasicText
23import androidx.compose.runtime.Composable
24import androidx.compose.runtime.LaunchedEffect
25import androidx.compose.runtime.getValue
26import androidx.compose.runtime.mutableStateOf
27import androidx.compose.runtime.remember
28import androidx.compose.runtime.rememberUpdatedState
29import androidx.compose.runtime.setValue
30import androidx.compose.ui.Alignment
31import androidx.compose.ui.Modifier
32import androidx.compose.ui.geometry.CornerRadius
33import androidx.compose.ui.geometry.Offset
34import androidx.compose.ui.geometry.Size
35import androidx.compose.ui.graphics.Color
36import androidx.compose.ui.graphics.lerp
37import androidx.compose.ui.input.pointer.PointerInputChange
38import androidx.compose.ui.input.pointer.pointerInput
39import androidx.compose.ui.text.TextStyle
40import androidx.compose.ui.unit.Density
41import androidx.compose.ui.unit.Dp
42import androidx.compose.ui.unit.IntSize
43import androidx.compose.ui.unit.dp
44import androidx.compose.ui.unit.sp
45import androidx.compose.ui.util.lerp
46import kotlin.math.PI
47import kotlin.math.cos
48import kotlin.math.max
49import kotlin.math.roundToInt
50import kotlin.math.sin
51
52/** The dots in a battery ring. */
53private const val RING_DOTS = 44
54
55/** How many of [n] dots the fraction [f] lights. */
56private fun litCount(f: Float, n: Int) = (f.coerceIn(0f, 1f) * n).roundToInt().coerceAtMost(n)
57
58/**
59 * A battery ring as wide as it is given: a ring of dots, lit clockwise from the top up to the
60 * [level] (a percentage), with the level in LED dots in the middle; "--" and no lit dot when it
61 * is unknown. The dots light one after the other, like the Glyph lights, when the ring first
62 * shows and when the level changes.
63 */
64@Composable
65fun Ring(level: Int?, modifier: Modifier = Modifier) {
66 val look = LocalLook.current
67 val p = look.p
68 val target = level?.let { litCount(it / 100f, RING_DOTS) } ?: 0
69 val lit = remember { Animatable(0f) }
70 LaunchedEffect(target, look.motion) {
71 if (look.motion) lit.animateTo(target.toFloat(), tween(Motion.SWEEP, easing = Motion.EASE))
72 else lit.snapTo(target.toFloat())
73 }
74 BoxWithConstraints(modifier.aspectRatio(1f), contentAlignment = Alignment.Center) {
75 Canvas(Modifier.size(maxWidth)) {
76 val dot = max(size.width * 0.045f, 3.dp.toPx())
77 val radius = size.width / 2 - dot / 2 - 1
78 val on = lit.value.roundToInt()
79 for (n in 0 until RING_DOTS) {
80 val angle = n.toFloat() / RING_DOTS * 2 * PI.toFloat() - PI.toFloat() / 2
81 val at = Offset(center.x + radius * cos(angle), center.y + radius * sin(angle))
82 drawCircle(if (n < on) p.fill else p.s3, dot / 2, at)
83 }
84 }
85 // Doto is never set under 26 sp.
86 val figure = max(26f, maxWidth.value * 0.28f).sp
87 BasicText(
88 level?.toString() ?: "--",
89 style = TextStyle(fontFamily = look.fonts.dot, fontSize = figure, color = if (level != null) p.fg else p.mute),
90 )
91 }
92}
93
94/**
95 * An on/off switch: off, a dark pill with a dim knob on the left; on, the fill runs to the right
96 * end, with a dot in it. Turned, the knob stretches into the fill (or back). It only shows the
97 * state: the row around it takes the touch.
98 */
99@Composable
100fun Toggle(on: Boolean) {
101 val look = LocalLook.current
102 val p = look.p
103 val t by animateFloatAsState(if (on) 1f else 0f, Motion.eased(Motion.STATE, look.motion), label = "switch")
104 Canvas(Modifier.size(60.dp, 32.dp)) {
105 drawRoundRect(p.s2, cornerRadius = CornerRadius(size.height / 2))
106 val pad = 4.dp.toPx()
107 val height = size.height - 2 * pad
108 val width = lerp(height, size.width - 2 * pad, t)
109 drawRoundRect(lerp(p.mute, p.fill, t), Offset(pad, pad), Size(width, height), CornerRadius(height / 2))
110 if (t > 0f) {
111 val r = 4.5.dp.toPx()
112 drawCircle(p.fillFg.copy(alpha = p.fillFg.alpha * t), r, Offset(pad + width - 7.dp.toPx() - r, size.height / 2))
113 }
114 }
115}
116
117/** A dotted slider's dots: the value's, and the others. */
118private val DOT = 5.dp
119private val DOT_BIG = 11.dp
120
121/**
122 * A dotted slider over [low]..[high], one dot per step, left to right or, [vertical], bottom to
123 * top: the dots from 0 to the value are lit, and the value's own dot is larger. A touch or a drag
124 * calls [change]; lifting the finger calls [release], which is when a front end writes.
125 */
126@Composable
127fun DotSlider(
128 low: Int,
129 high: Int,
130 value: Int,
131 change: (Int) -> Unit,
132 release: () -> Unit,
133 modifier: Modifier = Modifier,
134 vertical: Boolean = false,
135) {
136 val look = LocalLook.current
137 val p = look.p
138 val onChange by rememberUpdatedState(change)
139 val onRelease by rememberUpdatedState(release)
140 // The value's dot grows; the one it left shrinks back.
141 var left by remember { mutableStateOf<Int?>(null) }
142 var shown by remember { mutableStateOf(value) }
143 val grow = remember { Animatable(1f) }
144 LaunchedEffect(value) {
145 if (value != shown) {
146 left = shown
147 shown = value
148 grow.snapTo(0f)
149 grow.animateTo(1f, Motion.eased(Motion.HOVER, look.motion))
150 }
151 }
152 val steps = max(1, high - low)
153 val sized = if (vertical) modifier.width(DOT_BIG + 22.dp).height(190.dp) else modifier.fillMaxWidth().height(DOT_BIG + 22.dp)
154
155 /** 0 to 1 along the slider for a point on it. */
156 fun Density.along(at: Offset, size: IntSize): Float {
157 val pad = DOT_BIG.toPx()
158 val along = if (vertical) (size.height - pad - at.y) / max(1f, size.height - 2 * pad) else (at.x - pad) / max(1f, size.width - 2 * pad)
159 return along.coerceIn(0f, 1f)
160 }
161 Canvas(
162 sized
163 .pointerInput(low, high, vertical) {
164 detectTapGestures { at ->
165 onChange(low + (along(at, size) * steps).roundToInt())
166 onRelease()
167 }
168 }
169 .pointerInput(low, high, vertical) {
170 val drag = { change: PointerInputChange ->
171 change.consume()
172 onChange(low + (along(change.position, size) * steps).roundToInt())
173 }
174 if (vertical) {
175 detectVerticalDragGestures(onDragEnd = { onRelease() }, onDragCancel = { onRelease() }) { change, _ -> drag(change) }
176 } else {
177 detectHorizontalDragGestures(onDragEnd = { onRelease() }, onDragCancel = { onRelease() }) { change, _ -> drag(change) }
178 }
179 },
180 ) {
181 val pad = DOT_BIG.toPx()
182 val base = low.coerceAtLeast(0).coerceAtMost(high)
183 val from = minOf(base, value)
184 val to = maxOf(base, value)
185 val g = grow.value
186 for (v in low..high) {
187 val along = (v - low).toFloat() / steps
188 val at = if (vertical) {
189 Offset(size.width / 2, size.height - pad - along * (size.height - 2 * pad))
190 } else {
191 Offset(pad + along * (size.width - 2 * pad), size.height / 2)
192 }
193 val diameter = when (v) {
194 value -> lerp(DOT.toPx(), DOT_BIG.toPx(), g)
195 left -> lerp(DOT_BIG.toPx(), DOT.toPx(), g)
196 else -> DOT.toPx()
197 }
198 drawCircle(if (v in from..to) p.fill else p.s3, diameter / 2, at)
199 }
200 }
201}
202
203/** A round light [size] across; [blinking], it is lit and dark in turn, like a recording light. */
204@Composable
205fun Light(color: Color, size: Dp, blinking: Boolean = false) {
206 val look = LocalLook.current
207 val alpha = if (blinking && look.motion) {
208 val blink = rememberInfiniteTransition(label = "blink")
209 // Hard steps, no fade: lit for one beat, dark for the next.
210 val a by blink.animateFloat(
211 1f,
212 0f,
213 infiniteRepeatable(
214 keyframes {
215 durationMillis = 2 * Motion.BLINK
216 1f at 0
217 1f at Motion.BLINK - 1
218 0f at Motion.BLINK
219 0f at 2 * Motion.BLINK - 1
220 },
221 RepeatMode.Restart,
222 ),
223 label = "blink",
224 )
225 a
226 } else {
227 1f
228 }
229 Box(Modifier.size(size)) {
230 Canvas(Modifier.size(size)) { drawCircle(color.copy(alpha = color.alpha * alpha)) }
231 }
232}