Skip to content

Commit b740edc

Browse files
committed
allow building without font file locally, add swipe trail
1 parent bc19c98 commit b740edc

2 files changed

Lines changed: 161 additions & 19 deletions

File tree

ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3Keyboard.kt

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.thelightphone.lp3Keyboard.ui
22

3+
import android.os.SystemClock
34
import androidx.annotation.DrawableRes
5+
import androidx.compose.foundation.Canvas
46
import androidx.compose.foundation.background
57
import androidx.compose.foundation.gestures.awaitEachGesture
68
import androidx.compose.foundation.gestures.awaitFirstDown
@@ -21,18 +23,28 @@ import androidx.compose.foundation.layout.width
2123
import androidx.compose.material.Icon
2224
import androidx.compose.material.Text
2325
import androidx.compose.runtime.Composable
26+
import androidx.compose.runtime.CompositionLocalProvider
2427
import androidx.compose.runtime.LaunchedEffect
2528
import androidx.compose.runtime.getValue
29+
import androidx.compose.runtime.mutableLongStateOf
30+
import androidx.compose.runtime.mutableStateListOf
2631
import androidx.compose.runtime.mutableStateOf
2732
import androidx.compose.runtime.remember
2833
import androidx.compose.runtime.setValue
34+
import androidx.compose.runtime.snapshotFlow
35+
import androidx.compose.runtime.withFrameNanos
2936
import androidx.compose.ui.Alignment
3037
import androidx.compose.ui.BiasAlignment
3138
import androidx.compose.ui.Modifier
3239
import 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
3344
import androidx.compose.ui.graphics.graphicsLayer
3445
import androidx.compose.ui.input.pointer.PointerInputChange
3546
import androidx.compose.ui.input.pointer.pointerInput
47+
import androidx.compose.ui.platform.LocalContext
3648
import androidx.compose.ui.layout.boundsInRoot
3749
import androidx.compose.ui.layout.onGloballyPositioned
3850
import androidx.compose.ui.layout.positionInRoot
@@ -50,6 +62,7 @@ import com.thelightphone.lp3Keyboard.ui.layout.SwipeConfig
5062
import com.thelightphone.lp3Keyboard.ui.layout.UpperCaseLayout
5163
import com.thelightphone.lp3Keyboard.ui.viewmodel.defaultEmojis
5264
import kotlinx.coroutines.flow.first
65+
import kotlinx.coroutines.flow.filter
5366

5467
enum class SpecialKey {
5568
UpCase,
@@ -94,6 +107,10 @@ const val MEDIUM_KEY_WIDTH_DP = STANDARD_KEY_WIDTH_DP + 8
94107
const val STANDARD_ROW_HEIGHT_DP = 44
95108
const val STANDARD_KEY_TEXT_SP = 25
96109
const 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
99116
fun 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,

ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Style.kt

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,60 @@
11
package com.thelightphone.lp3Keyboard.ui
22

3+
import android.content.Context
4+
import android.graphics.fonts.SystemFonts
35
import androidx.compose.runtime.Composable
46
import androidx.compose.runtime.CompositionLocalProvider
57
import androidx.compose.runtime.Immutable
68
import androidx.compose.runtime.staticCompositionLocalOf
79
import androidx.compose.ui.graphics.Color
810
import androidx.compose.ui.text.font.Font
911
import androidx.compose.ui.text.font.FontFamily
12+
import androidx.compose.ui.text.font.FontStyle
1013
import androidx.compose.ui.text.font.FontWeight
1114

12-
val akkuratFamily = FontFamily(
13-
Font(R.font.akkuratll_light, FontWeight.Light),
14-
Font(R.font.akkuratpro_bold, FontWeight.Bold),
15-
Font(R.font.akkuratll_regular, FontWeight.Normal),
16-
)
15+
/**
16+
* Resolves the Akkurat font family at runtime — the .ttf/.otf files are
17+
* license-restricted, so we can't ship them in this library's resources.
18+
* Lookup order:
19+
* 1. System fonts on the host device (LP3 hardware ships with Akkurat).
20+
* 2. A res/font copy in the consumer's app if they have one locally
21+
* (resolved via getIdentifier so a missing copy is a runtime miss,
22+
* not a compile error).
23+
* 3. FontFamily.Default.
24+
*/
25+
fun lightFontFamily(context: Context): FontFamily {
26+
systemAkkuratFonts()?.let { return it }
27+
bundledAkkuratFonts(context)?.let { return it }
28+
return FontFamily.Default
29+
}
30+
31+
private fun systemAkkuratFonts(): FontFamily? {
32+
val fonts = SystemFonts.getAvailableFonts()
33+
.filter { it.file?.name?.startsWith("Akkurat", ignoreCase = true) == true }
34+
.mapNotNull { font ->
35+
val file = font.file ?: return@mapNotNull null
36+
val weight = FontWeight(font.style.weight)
37+
val style = if (font.style.slant != 0) FontStyle.Italic else FontStyle.Normal
38+
Font(file = file, weight = weight, style = style)
39+
}
40+
return if (fonts.isNotEmpty()) FontFamily(fonts) else null
41+
}
42+
43+
private fun bundledAkkuratFonts(context: Context): FontFamily? {
44+
val res = context.resources
45+
val pkg = context.packageName
46+
fun fontId(name: String): Int = res.getIdentifier(name, "font", pkg)
47+
48+
val fonts = buildList {
49+
fontId("akkuratll_light").takeIf { it != 0 }
50+
?.let { add(Font(it, FontWeight.Light)) }
51+
fontId("akkuratll_regular").takeIf { it != 0 }
52+
?.let { add(Font(it, FontWeight.Normal)) }
53+
fontId("akkuratpro_bold").takeIf { it != 0 }
54+
?.let { add(Font(it, FontWeight.Bold)) }
55+
}
56+
return if (fonts.isNotEmpty()) FontFamily(fonts) else null
57+
}
1758

1859
@Immutable
1960
data class Lp3KeyboardColors(
@@ -33,6 +74,13 @@ val LightKeyboardColors = Lp3KeyboardColors(
3374

3475
val LocalKeyboardColors = staticCompositionLocalOf { DarkKeyboardColors }
3576

77+
/**
78+
* Provided by [Lp3Keyboard] after one runtime lookup; key composables read
79+
* from it instead of calling [lightFontFamily] themselves so the system-font
80+
* scan only happens once per keyboard, not once per key.
81+
*/
82+
internal val LocalAkkuratFamily = staticCompositionLocalOf<FontFamily> { FontFamily.Default }
83+
3684
@Composable
3785
fun Lp3KeyboardTheme(
3886
colors: Lp3KeyboardColors = DarkKeyboardColors,
@@ -41,4 +89,4 @@ fun Lp3KeyboardTheme(
4189
CompositionLocalProvider(LocalKeyboardColors provides colors) {
4290
content()
4391
}
44-
}
92+
}

0 commit comments

Comments
 (0)