11package com.thelightphone.lp3Keyboard.ui
22
3+ import android.os.SystemClock
34import androidx.annotation.DrawableRes
5+ import androidx.compose.foundation.Canvas
46import androidx.compose.foundation.background
57import androidx.compose.foundation.gestures.awaitEachGesture
68import androidx.compose.foundation.gestures.awaitFirstDown
@@ -21,18 +23,28 @@ import androidx.compose.foundation.layout.width
2123import androidx.compose.material.Icon
2224import androidx.compose.material.Text
2325import androidx.compose.runtime.Composable
26+ import androidx.compose.runtime.CompositionLocalProvider
2427import androidx.compose.runtime.LaunchedEffect
2528import androidx.compose.runtime.getValue
29+ import androidx.compose.runtime.mutableLongStateOf
30+ import androidx.compose.runtime.mutableStateListOf
2631import androidx.compose.runtime.mutableStateOf
2732import androidx.compose.runtime.remember
2833import androidx.compose.runtime.setValue
34+ import androidx.compose.runtime.snapshotFlow
35+ import androidx.compose.runtime.withFrameNanos
2936import androidx.compose.ui.Alignment
3037import androidx.compose.ui.BiasAlignment
3138import androidx.compose.ui.Modifier
3239import androidx.compose.ui.geometry.Offset
40+ import androidx.compose.ui.graphics.Path
41+ import androidx.compose.ui.graphics.StrokeCap
42+ import androidx.compose.ui.graphics.StrokeJoin
43+ import androidx.compose.ui.graphics.drawscope.Stroke
3344import androidx.compose.ui.graphics.graphicsLayer
3445import androidx.compose.ui.input.pointer.PointerInputChange
3546import androidx.compose.ui.input.pointer.pointerInput
47+ import androidx.compose.ui.platform.LocalContext
3648import androidx.compose.ui.layout.boundsInRoot
3749import androidx.compose.ui.layout.onGloballyPositioned
3850import androidx.compose.ui.layout.positionInRoot
@@ -50,6 +62,7 @@ import com.thelightphone.lp3Keyboard.ui.layout.SwipeConfig
5062import com.thelightphone.lp3Keyboard.ui.layout.UpperCaseLayout
5163import com.thelightphone.lp3Keyboard.ui.viewmodel.defaultEmojis
5264import kotlinx.coroutines.flow.first
65+ import kotlinx.coroutines.flow.filter
5366
5467enum class SpecialKey {
5568 UpCase ,
@@ -94,6 +107,10 @@ const val MEDIUM_KEY_WIDTH_DP = STANDARD_KEY_WIDTH_DP + 8
94107const val STANDARD_ROW_HEIGHT_DP = 44
95108const val STANDARD_KEY_TEXT_SP = 25
96109const val MINIMUM_SWIPE_DP = 40
110+ private const val SWIPE_TRAIL_FADE_MS = 350L
111+ private const val SWIPE_TRAIL_WIDTH_DP = 6
112+
113+ private data class TrailPoint (val x : Float , val y : Float , val timeMs : Long )
97114
98115@Composable
99116fun Lp3Keyboard (
@@ -106,9 +123,39 @@ fun Lp3Keyboard(
106123 // Pointer positions inside the swipe gesture are local to this Box, but the
107124 // letter bounds reported via onGloballyPositioned/boundsInRoot are in the
108125 // composition root's coordinate space. Track the Box's own root offset so the
109- // swipe handler can reconcile them — needed whenever the keyboard isn't
110- // pinned at the root origin (e.g. embedded above other UI).
126+ // swipe handler can reconcile them.
111127 val boxRootOffset = remember { mutableStateOf(Offset .Zero ) }
128+ // Live swipe trail. Points carry the uptime they were sampled at, so the
129+ // Canvas can fade each segment independently. Points are pruned after the
130+ // fade window elapses; the frame ticker idles when the list is empty.
131+ val trailPoints = remember { mutableStateListOf<TrailPoint >() }
132+ var nowMs by remember { mutableLongStateOf(0L ) }
133+ val trailColor = LocalKeyboardColors .current.foreground
134+ // Reused across draws — rewind() is cheap, allocating a new Path/SkPath
135+ // every frame is not.
136+ val swipePath = remember { Path () }
137+ // Resolve the Akkurat family once and hand it to keys through a
138+ // CompositionLocal. lightFontFamily scans SystemFonts.getAvailableFonts(),
139+ // which we don't want to run per-key.
140+ val context = LocalContext .current
141+ val akkurat = remember(context) { lightFontFamily(context) }
142+
143+ LaunchedEffect (Unit ) {
144+ while (true ) {
145+ // Idle until a gesture starts recording points.
146+ snapshotFlow { trailPoints.isNotEmpty() }.filter { it }.first()
147+ while (trailPoints.isNotEmpty()) {
148+ withFrameNanos { /* tick the frame clock so we recompose */ }
149+ nowMs = SystemClock .uptimeMillis()
150+ // Clear the whole trail once the newest point has fully faded.
151+ // While the gesture is active the newest point is constantly
152+ // refreshed so this never trips; once the finger lifts, the
153+ // trail fades together and disappears as a unit.
154+ val newestAge = nowMs - trailPoints.last().timeMs
155+ if (newestAge > SWIPE_TRAIL_FADE_MS ) trailPoints.clear()
156+ }
157+ }
158+ }
112159 Box (
113160 Modifier
114161 .fillMaxWidth()
@@ -125,9 +172,22 @@ fun Lp3Keyboard(
125172 val xs = ArrayList <Float >()
126173 val ys = ArrayList <Float >()
127174 val ts = ArrayList <Float >()
175+ // Pointer events on Android are already on
176+ // SystemClock.uptimeMillis, which is the same clock
177+ // the fade ticker reads — so we can store
178+ // change.uptimeMillis directly for the trail.
179+ val pointTimes = ArrayList <Long >()
128180 xs.add(down.position.x)
129181 ys.add(down.position.y)
130182 ts.add(0f )
183+ pointTimes.add(startTime)
184+ // Clear any leftover trail from the previous gesture.
185+ // Do NOT seed it yet — taps jitter a few pixels and
186+ // would render as a dot. We hold the trail back
187+ // until displacement crosses the swipe threshold,
188+ // then backfill so the drawn line starts at the
189+ // touch-down position.
190+ trailPoints.clear()
131191 var minX = down.position.x
132192 var maxX = down.position.x
133193 var minY = down.position.y
@@ -141,16 +201,25 @@ fun Lp3Keyboard(
141201 val p = change.position
142202 xs.add(p.x); ys.add(p.y)
143203 ts.add((change.uptimeMillis - startTime).toFloat())
204+ pointTimes.add(change.uptimeMillis)
144205 if (p.x < minX) minX = p.x
145206 if (p.x > maxX) maxX = p.x
146207 if (p.y < minY) minY = p.y
147208 if (p.y > maxY) maxY = p.y
148- if (swipeCallback != null && ! swipeStarted) {
209+ if (! swipeStarted) {
149210 val displacementPx = maxOf(maxX - minX, maxY - minY)
150211 if (displacementPx >= minSwipePx) {
151- swipeCallback.onSwipeStarted()
212+ swipeCallback? .onSwipeStarted()
152213 swipeStarted = true
214+ // Backfill the trail with everything collected so far
215+ // because we only want to start drawing the trail when we're
216+ // definitely in a swipe
217+ for (i in xs.indices) {
218+ trailPoints.add(TrailPoint (xs[i], ys[i], pointTimes[i]))
219+ }
153220 }
221+ } else {
222+ trailPoints.add(TrailPoint (p.x, p.y, change.uptimeMillis))
154223 }
155224 if (! change.pressed) break
156225 }
@@ -175,14 +244,39 @@ fun Lp3Keyboard(
175244 )
176245 ) {
177246 Column (Modifier .fillMaxSize().padding(top = 4 .dp).align(Alignment .Center )) {
178- with (layout) { Render (options, callback) }
247+ CompositionLocalProvider (LocalAkkuratFamily provides akkurat) {
248+ with (layout) { Render (options, callback) }
249+ }
179250 }
180- }
181- if (swipeConfig != null ) {
182- LaunchedEffect (swipeConfig) {
183- swipeConfig.boundsFlow.first()
184- swipeConfig.deriveLayout()?.let { (letters, cx, cy) ->
185- swipeCallback?.onSwipeLayoutReady(letters, cx, cy)
251+ if (swipeConfig != null ) {
252+ Canvas (Modifier .fillMaxSize()) {
253+ if (trailPoints.size < 2 ) return @Canvas
254+ // Whole-trail alpha keyed to the newest point's age
255+ // tried "comet" effect but overlapping butts looked like dots
256+ val newestAge = (nowMs - trailPoints.last().timeMs).coerceAtLeast(0L )
257+ val alpha = (1f - newestAge.toFloat() / SWIPE_TRAIL_FADE_MS ).coerceIn(0f , 1f )
258+ if (alpha <= 0f ) return @Canvas
259+ swipePath.rewind()
260+ swipePath.moveTo(trailPoints[0 ].x, trailPoints[0 ].y)
261+ for (i in 1 until trailPoints.size) {
262+ swipePath.lineTo(trailPoints[i].x, trailPoints[i].y)
263+ }
264+ drawPath(
265+ path = swipePath,
266+ color = trailColor.copy(alpha = alpha),
267+ style = Stroke (
268+ width = SWIPE_TRAIL_WIDTH_DP .dp.toPx(),
269+ cap = StrokeCap .Round ,
270+ join = StrokeJoin .Round
271+ )
272+ )
273+ }
274+
275+ LaunchedEffect (swipeConfig) {
276+ swipeConfig.boundsFlow.first()
277+ swipeConfig.deriveLayout()?.let { (letters, cx, cy) ->
278+ swipeCallback?.onSwipeLayoutReady(letters, cx, cy)
279+ }
186280 }
187281 }
188282 }
@@ -360,7 +454,7 @@ fun RowScope.Key(
360454 Text (
361455 text = buildString { appendCodePoint(code) },
362456 color = LocalKeyboardColors .current.foreground,
363- fontFamily = akkuratFamily ,
457+ fontFamily = LocalAkkuratFamily .current ,
364458 fontWeight = FontWeight .Normal ,
365459 fontSize = STANDARD_KEY_TEXT_SP .sp,
366460 modifier = Modifier .then(
@@ -403,7 +497,7 @@ fun RowScope.MultiLabelKey(
403497 Text (
404498 text = labelText,
405499 color = LocalKeyboardColors .current.foreground,
406- fontFamily = akkuratFamily ,
500+ fontFamily = LocalAkkuratFamily .current ,
407501 fontWeight = FontWeight .Normal ,
408502 letterSpacing = 2 .sp,
409503 fontSize = 16 .sp,
0 commit comments