Skip to content

Commit 95c4f47

Browse files
authored
Merge pull request #2841 from switchifyapp/feature/scan-highlight-visuals-2840
Animate scan highlights and add countdown and spotlight styles
2 parents 371440c + 34ba47c commit 95c4f47

27 files changed

Lines changed: 1098 additions & 196 deletions

app/src/main/java/com/enaboapps/switchify/backend/preferences/PreferenceManager.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ class PreferenceManager(context: Context) {
5050
const val PREFERENCE_KEY_GESTURE_REPEAT_INITIAL_DELAY = "gesture_repeat_initial_delay"
5151
const val PREFERENCE_KEY_GESTURE_REPEAT_DELAY = "gesture_repeat_delay"
5252
const val PREFERENCE_KEY_SCAN_COLOR_SET = "scan_color_set"
53+
const val PREFERENCE_KEY_SCAN_HIGHLIGHT_LEGACY_TYPE = "scan_highlight_legacy_type"
54+
const val PREFERENCE_KEY_SCAN_HIGHLIGHT_MOVEMENT = "scan_highlight_movement"
55+
const val PREFERENCE_KEY_SCAN_HIGHLIGHT_COUNTDOWN = "scan_highlight_countdown"
5356
const val PREFERENCE_KEY_SCAN_HIGHLIGHT_TYPE = "scan_highlight_type"
5457
const val PREFERENCE_KEY_MENU_TRANSPARENCY = "menu_transparency"
5558
const val PREFERENCE_KEY_MENU_SIZE_SCALE = "menu_size_scale"

app/src/main/java/com/enaboapps/switchify/screens/settings/techniques/ItemScanSettingsView.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,29 @@ fun ItemScanSettingsView() {
5555
itemToString = { scanHighlightStyle.getName(it) },
5656
itemDescription = { scanHighlightStyle.getDescription(it) }
5757
)
58+
59+
val movementEnabled = remember { mutableStateOf(scanHighlightStyle.isMovementEnabled()) }
60+
PreferenceSwitch(
61+
titleResId = R.string.preference_title_scan_highlight_movement,
62+
summaryResId = R.string.preference_summary_scan_highlight_movement,
63+
checked = movementEnabled.value,
64+
onCheckedChange = {
65+
movementEnabled.value = it
66+
preferenceManager.setBooleanValue(PreferenceManager.PREFERENCE_KEY_SCAN_HIGHLIGHT_MOVEMENT, it)
67+
}
68+
)
69+
70+
val countdownEnabled = remember { mutableStateOf(scanHighlightStyle.isCountdownEnabled()) }
71+
PreferenceSwitch(
72+
titleResId = R.string.preference_title_scan_highlight_countdown,
73+
summaryResId = R.string.preference_summary_scan_highlight_countdown,
74+
checked = countdownEnabled.value,
75+
onCheckedChange = {
76+
countdownEnabled.value = it
77+
preferenceManager.setBooleanValue(PreferenceManager.PREFERENCE_KEY_SCAN_HIGHLIGHT_COUNTDOWN, it)
78+
}
79+
)
80+
5881
}
5982

6083
Section(titleResId = R.string.section_title_scan_pattern) {

app/src/main/java/com/enaboapps/switchify/service/menu/MenuView.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ interface MenuViewListener {
3434
class MenuView(val context: Context, private val menu: BaseMenu) {
3535
val menuId: String? get() = menu.menuId
3636
var menuViewListener: MenuViewListener? = null
37-
val scanTree = ScanTree(context)
37+
val scanTree = ScanTree(context, visualEffectsEnabled = true)
3838
private val preferenceManager = PreferenceManager(context)
3939
private var baseLayout = LinearLayout(context)
4040
private var currentPage = 0

app/src/main/java/com/enaboapps/switchify/service/scanning/ScanHighlightDrawable.kt

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,25 @@ class ScanHighlightDrawable(
3131
// Pick a halo tone that contrasts with the main color so the highlight
3232
// remains visible when it overlaps a same-colored background.
3333
private fun haloColor(mainColor: Int): Int {
34-
val r = Color.red(mainColor)
35-
val g = Color.green(mainColor)
36-
val b = Color.blue(mainColor)
34+
val tone = contrastTone(mainColor)
35+
return Color.argb(
36+
ScanVisualConstants.HALO_ALPHA,
37+
Color.red(tone),
38+
Color.green(tone),
39+
Color.blue(tone)
40+
)
41+
}
42+
43+
/**
44+
* Opaque black or white, whichever contrasts with [color]. Shared by
45+
* the halo and the countdown ring so both pick the same tone.
46+
*/
47+
fun contrastTone(color: Int): Int {
48+
val r = Color.red(color)
49+
val g = Color.green(color)
50+
val b = Color.blue(color)
3751
val luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
38-
return if (luminance > 0.5) {
39-
Color.argb(ScanVisualConstants.HALO_ALPHA, 0, 0, 0)
40-
} else {
41-
Color.argb(ScanVisualConstants.HALO_ALPHA, 255, 255, 255)
42-
}
52+
return if (luminance > 0.5) Color.BLACK else Color.WHITE
4353
}
4454

4555
private fun createLayers(

app/src/main/java/com/enaboapps/switchify/service/scanning/ScanHighlightStyle.kt

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ class ScanHighlightStyle(context: Context) {
1111
companion object {
1212
private val BORDER = Type("border")
1313
private val FILL = Type("fill")
14-
val ALL = listOf(BORDER, FILL)
14+
private val SPOTLIGHT = Type("spotlight")
15+
val ALL = listOf(BORDER, FILL, SPOTLIGHT)
16+
17+
internal fun legacyTypeToSave(current: Type, next: Type): Type? =
18+
(if (next == SPOTLIGHT) current else next).takeIf { it == BORDER || it == FILL }
1519
}
1620

1721
data class Type(val id: String)
@@ -25,12 +29,28 @@ class ScanHighlightStyle(context: Context) {
2529
}
2630

2731
fun setType(type: Type) {
32+
val legacyType = legacyTypeToSave(getType(), type)
33+
if (legacyType != null) {
34+
preferenceManager.setStringValue(PreferenceManager.PREFERENCE_KEY_SCAN_HIGHLIGHT_LEGACY_TYPE, legacyType.id)
35+
}
2836
preferenceManager.setStringValue(
2937
PreferenceManager.Keys.PREFERENCE_KEY_SCAN_HIGHLIGHT_TYPE,
3038
type.id
3139
)
3240
}
3341

42+
fun isSpotlight(): Boolean = getType() == SPOTLIGHT
43+
44+
fun isMovementEnabled(): Boolean = preferenceManager.getBooleanValue(
45+
PreferenceManager.PREFERENCE_KEY_SCAN_HIGHLIGHT_MOVEMENT, true)
46+
47+
fun isCountdownEnabled(): Boolean = preferenceManager.getBooleanValue(
48+
PreferenceManager.PREFERENCE_KEY_SCAN_HIGHLIGHT_COUNTDOWN, true)
49+
50+
fun isLegacyFill(): Boolean = if (isSpotlight()) {
51+
preferenceManager.getStringValue(PreferenceManager.PREFERENCE_KEY_SCAN_HIGHLIGHT_LEGACY_TYPE, BORDER.id) == FILL.id
52+
} else isFill()
53+
3454
fun isBorder(): Boolean {
3555
return getType() == BORDER
3656
}
@@ -42,6 +62,7 @@ class ScanHighlightStyle(context: Context) {
4262
fun getName(type: Type): String {
4363
return when (type) {
4464
BORDER -> Resources.getString(R.string.scan_highlight_type_border)
65+
SPOTLIGHT -> Resources.getString(R.string.scan_highlight_type_spotlight)
4566
FILL -> Resources.getString(R.string.scan_highlight_type_fill)
4667
else -> ""
4768
}
@@ -50,8 +71,9 @@ class ScanHighlightStyle(context: Context) {
5071
fun getDescription(type: Type): String {
5172
return when (type) {
5273
BORDER -> Resources.getString(R.string.scan_highlight_type_border_desc)
74+
SPOTLIGHT -> Resources.getString(R.string.scan_highlight_type_spotlight_desc)
5375
FILL -> Resources.getString(R.string.scan_highlight_type_fill_desc)
5476
else -> ""
5577
}
5678
}
57-
}
79+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.enaboapps.switchify.service.scanning
2+
3+
data class ScanInterval(val startedAtMillis: Long, val durationMillis: Long) {
4+
fun remainingFraction(nowMillis: Long): Float = if (durationMillis <= 0L) 0f else
5+
(1f - (nowMillis - startedAtMillis).coerceAtLeast(0L).toFloat() / durationMillis)
6+
.coerceIn(0f, 1f)
7+
}
8+
9+
data class ScanIntervalEvent(val owner: String, val generation: Long, val interval: ScanInterval?)
10+
11+
internal class ScanIntervalStore {
12+
private data class Entry(val event: ScanIntervalEvent, val sequence: Long)
13+
private val entries = linkedMapOf<String, Entry>()
14+
15+
fun record(event: ScanIntervalEvent, sequence: Long) {
16+
val previous = entries[event.owner]
17+
if (previous != null && (previous.event.generation > event.generation ||
18+
previous.sequence > sequence)) return
19+
entries.remove(event.owner)
20+
entries[event.owner] = Entry(event, sequence)
21+
if (entries.size > 8) entries.remove(entries.keys.first())
22+
}
23+
24+
fun intervalFor(owner: String?, afterSequence: Long): ScanInterval? =
25+
entries[owner]?.takeIf { it.sequence > afterSequence }?.event?.interval
26+
27+
fun clear() = entries.clear()
28+
}

app/src/main/java/com/enaboapps/switchify/service/scanning/ScanVisualConstants.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ object ScanVisualConstants {
5353
/** Alpha for structural overlays — grid, screen outline (~63%). */
5454
const val STRUCTURAL_ALPHA = 160
5555

56+
// ---- Countdown ring (dp) ----
57+
58+
/** Gap between the highlight stroke (plus halo) and the countdown ring. */
59+
const val COUNTDOWN_INSET_DP = 5
60+
61+
/** Coloured countdown ring stroke. */
62+
const val COUNTDOWN_STROKE_DP = 2
63+
64+
/** Contrast outline drawn under the countdown ring. */
65+
const val COUNTDOWN_HALO_STROKE_DP = 4
66+
5667
// ---- Alphas (0-1) ----
5768

5869
/** Alpha for radar swept-line and indicator circle (70%). */
@@ -66,6 +77,8 @@ object ScanVisualConstants {
6677

6778
// ---- Animation ----
6879

80+
const val SPOTLIGHT_ALPHA = 89
81+
6982
const val SHOW_DURATION_MS = 120L
7083
const val HIDE_DURATION_MS = 80L
7184
const val INITIAL_SCALE = 0.96f

app/src/main/java/com/enaboapps/switchify/service/scanning/ScanningManager.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ class ScanningManager(
153153
}
154154

155155
internal fun applyPreferenceUpdate(plan: ScanPreferenceUpdatePlan) {
156+
if (plan.contains(ScanPreferenceEffect.REFRESH_HIGHLIGHT)) {
157+
NodeScannerUI.instance.refreshPreferences()
158+
}
156159
if (plan.contains(ScanPreferenceEffect.RESET_SCAN_MODE)) {
157160
activeScanMethod.resetForScanModeChange()
158161
return

app/src/main/java/com/enaboapps/switchify/service/scanning/ScanningScheduler.kt

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
package com.enaboapps.switchify.service.scanning
22

33
import android.content.Context
4+
import android.os.SystemClock
5+
import java.util.UUID
6+
import java.util.concurrent.atomic.AtomicBoolean
7+
import java.util.concurrent.atomic.AtomicLong
8+
import java.util.concurrent.atomic.AtomicReference
9+
import kotlinx.coroutines.CancellationException
410
import kotlinx.coroutines.CoroutineName
511
import kotlinx.coroutines.CoroutineScope
612
import kotlinx.coroutines.Dispatchers
@@ -10,9 +16,6 @@ import kotlinx.coroutines.delay
1016
import kotlinx.coroutines.isActive
1117
import kotlinx.coroutines.launch
1218
import kotlinx.coroutines.withContext
13-
import java.util.UUID
14-
import java.util.concurrent.atomic.AtomicBoolean
15-
import java.util.concurrent.atomic.AtomicReference
1619

1720
/**
1821
* ScanningScheduler is a class that manages the scheduling of scanning tasks.
@@ -26,14 +29,29 @@ class ScanningScheduler internal constructor(
2629
private val onScan: suspend () -> Unit,
2730
private val scanRateProvider: () -> Long,
2831
private val firstItemPauseProvider: () -> Long,
29-
private val coroutineScope: CoroutineScope
32+
private val coroutineScope: CoroutineScope,
33+
private val intervalOwner: String = UUID.randomUUID().toString(),
34+
private val clock: () -> Long = { System.nanoTime() / 1_000_000L },
35+
private val onInterval: (ScanIntervalEvent) -> Unit = {}
3036
) {
3137

3238
constructor(context: Context, onScan: suspend () -> Unit) : this(
39+
context, UUID.randomUUID().toString(), {}, onScan
40+
)
41+
42+
constructor(
43+
context: Context,
44+
intervalOwner: String = UUID.randomUUID().toString(),
45+
onInterval: (ScanIntervalEvent) -> Unit = {},
46+
onScan: suspend () -> Unit
47+
) : this(
3348
onScan = { withContext(Dispatchers.Main.immediate) { onScan() } },
3449
scanRateProvider = ScanSettings(context)::getScanRate,
3550
firstItemPauseProvider = ScanSettings(context)::getPauseOnFirstItemDelay,
36-
coroutineScope = CoroutineScope(Dispatchers.IO + CoroutineName(UUID.randomUUID().toString()))
51+
coroutineScope = CoroutineScope(Dispatchers.IO + CoroutineName(UUID.randomUUID().toString())),
52+
intervalOwner = intervalOwner,
53+
clock = SystemClock::uptimeMillis,
54+
onInterval = onInterval
3755
)
3856

3957
/**
@@ -48,6 +66,7 @@ class ScanningScheduler internal constructor(
4866
* The Job representing the currently running scanning task.
4967
*/
5068
private var scanningJob: Job? = null
69+
private val intervalGeneration = AtomicLong()
5170

5271
/**
5372
* A flag indicating whether a scanning task is currently executing.
@@ -103,26 +122,55 @@ class ScanningScheduler internal constructor(
103122
}
104123

105124
private fun launchScanningJob(delayMillis: Long) {
125+
val generation = intervalGeneration.incrementAndGet()
106126
scanningJob?.cancel()
107127
scanningJob = coroutineScope.launch {
108-
println("[$uniqueId] Starting scanning job")
109-
delay(delayMillis)
110-
while (isActive) {
111-
if (isExecuting.compareAndSet(false, true)) {
112-
try {
113-
onScan()
114-
} catch (e: Exception) {
115-
println("[$uniqueId] Error during scan: ${e.message}")
116-
e.printStackTrace()
117-
} finally {
118-
isExecuting.set(false)
128+
try {
129+
println("[$uniqueId] Starting scanning job")
130+
publishInterval(generation, delayMillis)
131+
delay(delayMillis)
132+
while (isActive) {
133+
if (isExecuting.compareAndSet(false, true)) {
134+
try {
135+
onScan()
136+
} catch (cancelled: CancellationException) {
137+
// Only our own cancellation ends the loop. A foreign
138+
// cancellation (e.g. a timeout inside a step) is an
139+
// ordinary step failure; ending the loop here would
140+
// leave scanState at SCANNING with no job behind it.
141+
if (!isActive) throw cancelled
142+
println("[$uniqueId] Scan step cancelled: ${cancelled.message}")
143+
} catch (e: Exception) {
144+
println("[$uniqueId] Error during scan: ${e.message}")
145+
e.printStackTrace()
146+
} finally {
147+
isExecuting.set(false)
148+
}
119149
}
150+
if (isActive && generation == intervalGeneration.get()) {
151+
publishInterval(generation, period)
152+
}
153+
delay(period)
154+
}
155+
} finally {
156+
if (intervalGeneration.compareAndSet(generation, generation + 1)) {
157+
onInterval(ScanIntervalEvent(intervalOwner, generation + 1, null))
120158
}
121-
delay(period)
122159
}
123160
}
124161
}
125162

163+
private fun publishInterval(generation: Long, durationMillis: Long) {
164+
if (generation == intervalGeneration.get()) {
165+
onInterval(ScanIntervalEvent(intervalOwner, generation,
166+
ScanInterval(clock(), durationMillis.coerceAtLeast(0L))))
167+
}
168+
}
169+
170+
private fun clearInterval() {
171+
onInterval(ScanIntervalEvent(intervalOwner, intervalGeneration.incrementAndGet(), null))
172+
}
173+
126174
/**
127175
* Checks if the scanner is currently scanning.
128176
*
@@ -152,6 +200,7 @@ class ScanningScheduler internal constructor(
152200
try {
153201
if (scanState.get() == ScanState.SCANNING || scanState.get() == ScanState.PAUSED) {
154202
scanState.set(ScanState.STOPPED)
203+
clearInterval()
155204
scanningJob?.cancel()
156205
}
157206
} catch (e: Exception) {
@@ -167,6 +216,7 @@ class ScanningScheduler internal constructor(
167216
println("[$uniqueId] Attempting to pause scanning... $scanState")
168217
try {
169218
if (scanState.compareAndSet(ScanState.SCANNING, ScanState.PAUSED)) {
219+
clearInterval()
170220
scanningJob?.cancel()
171221
}
172222
} catch (e: Exception) {
@@ -196,6 +246,8 @@ class ScanningScheduler internal constructor(
196246
fun shutdown() {
197247
println("[$uniqueId] Shutting down scope")
198248
try {
249+
scanState.set(ScanState.STOPPED)
250+
clearInterval()
199251
coroutineScope.cancel() // Cancel all coroutines started by this scope
200252
} catch (e: Exception) {
201253
println("[$uniqueId] Error while shutting down: ${e.message}")

app/src/main/java/com/enaboapps/switchify/service/scanning/preferences/ScanPreferencePolicy.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ internal enum class ScanPreferenceEffect {
1010
REFRESH_POINT_STRUCTURE,
1111
REFRESH_POINT_TIMING,
1212
RESET_RADAR_ORIGIN,
13+
REFRESH_HIGHLIGHT,
1314
LIVE_READ
1415
}
1516

@@ -47,8 +48,10 @@ internal object ScanPreferencePolicy {
4748
PreferenceManager.PREFERENCE_KEY_MOVE_REPEAT to ScanPreferenceEffect.LIVE_READ,
4849
PreferenceManager.PREFERENCE_KEY_MOVE_REPEAT_DELAY to ScanPreferenceEffect.LIVE_READ,
4950
PreferenceManager.PREFERENCE_KEY_ITEM_SCAN_SPEECH to ScanPreferenceEffect.LIVE_READ,
50-
PreferenceManager.PREFERENCE_KEY_SCAN_HIGHLIGHT_TYPE to ScanPreferenceEffect.LIVE_READ,
51-
PreferenceManager.PREFERENCE_KEY_SCAN_COLOR_SET to ScanPreferenceEffect.LIVE_READ
51+
PreferenceManager.PREFERENCE_KEY_SCAN_HIGHLIGHT_TYPE to ScanPreferenceEffect.REFRESH_HIGHLIGHT,
52+
PreferenceManager.PREFERENCE_KEY_SCAN_HIGHLIGHT_MOVEMENT to ScanPreferenceEffect.REFRESH_HIGHLIGHT,
53+
PreferenceManager.PREFERENCE_KEY_SCAN_HIGHLIGHT_COUNTDOWN to ScanPreferenceEffect.REFRESH_HIGHLIGHT,
54+
PreferenceManager.PREFERENCE_KEY_SCAN_COLOR_SET to ScanPreferenceEffect.REFRESH_HIGHLIGHT
5255
)
5356

5457
val supportedKeys: Set<String> = effectsByKey.keys

0 commit comments

Comments
 (0)