Building StareBrain, Part 18: Auto-Collapse — The Overlay That Knows When to Hide
Part 17 covered drag-to-reposition. Today we tackle the other half of staying out of the way: auto-collapse.
The overlay has three states — Idle, Running, AwaitingConfirmation. What we hadn't solved was when to transition between them based on what the user is doing, not just what the task is doing. If a step completes while the user is typing in another app, expanding the overlay mid-keystroke is the wrong call.
Auto-collapse means the overlay shrinks to a dot when the user is actively interacting with another app, and expands only when something actually needs their attention.
The Signal: AccessibilityEvent
We already have an AccessibilityService running. It fires events on every interaction — focus changes, window changes, text input. We use that stream to detect active interaction.
class StareBrainService : AccessibilityService() {
private val interactionTracker = UserInteractionTracker()
override fun onAccessibilityEvent(event: AccessibilityEvent) {
when (event.eventType) {
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED,
AccessibilityEvent.TYPE_VIEW_CLICKED,
AccessibilityEvent.TYPE_VIEW_FOCUSED -> {
interactionTracker.recordInteraction()
}
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
interactionTracker.recordAppSwitch(event.packageName?.toString())
}
}
}
}
UserInteractionTracker
Tracks the last interaction timestamp and whether the user is considered "active":
class UserInteractionTracker {
private var lastInteractionAt: Long = 0L
private var lastPackage: String? = null
fun recordInteraction() {
lastInteractionAt = System.currentTimeMillis()
}
fun recordAppSwitch(packageName: String?) {
lastPackage = packageName
lastInteractionAt = System.currentTimeMillis()
}
fun isUserActive(thresholdMs: Long = 3_000): Boolean {
return System.currentTimeMillis() - lastInteractionAt < thresholdMs
}
fun currentPackage(): String? = lastPackage
}
3 seconds of no interaction → not active. This is intentionally short — we want the overlay to re-expand quickly when a step completes, not wait for a long idle period.
CollapseController
Decides whether the overlay should be collapsed based on task state and user activity:
class CollapseController(
private val interactionTracker: UserInteractionTracker
) {
sealed class CollapseDecision {
object Collapse : CollapseDecision()
object Expand : CollapseDecision()
object ExpandUrgent : CollapseDecision() // confirmation required
}
fun evaluate(taskState: TaskStatus?): CollapseDecision {
return when (taskState) {
TaskStatus.AWAITING_CONFIRMATION -> CollapseDecision.ExpandUrgent
TaskStatus.IN_PROGRESS -> {
if (interactionTracker.isUserActive()) CollapseDecision.Collapse
else CollapseDecision.Expand
}
else -> CollapseDecision.Collapse
}
}
}
ExpandUrgent bypasses the activity check — a confirmation always surfaces, even mid-keystroke. Everything else respects whether the user is currently active.
Applying the Decision
We evaluate CollapseDecision on a ticker — every 500ms — rather than reacting to every individual event:
private fun startCollapseLoop() {
lifecycleScope.launch {
while (isActive) {
val task = taskExecutor.taskState.value
val decision = collapseController.evaluate(task?.status)
when (decision) {
CollapseDecision.Collapse -> overlayManager.collapse()
CollapseDecision.Expand -> overlayManager.expand()
CollapseDecision.ExpandUrgent -> overlayManager.expandUrgent()
}
delay(500)
}
}
}
500ms polling avoids the jitter of reacting to every AccessibilityEvent while still feeling responsive.
Collapse Animation
The overlay animates between collapsed (dot) and expanded states:
fun collapse() {
if (currentState == OverlayDisplayState.COLLAPSED) return
currentState = OverlayDisplayState.COLLAPSED
animate()
.scaleX(0.15f)
.scaleY(0.15f)
.alpha(0.4f)
.setDuration(200)
.start()
}
fun expand() {
if (currentState == OverlayDisplayState.EXPANDED) return
currentState = OverlayDisplayState.EXPANDED
animate()
.scaleX(1f)
.scaleY(1f)
.alpha(1f)
.setDuration(200)
.start()
}
fun expandUrgent() {
expand()
// Brief pulse to draw attention without being aggressive
postDelayed({
animate().scaleX(1.05f).scaleY(1.05f).setDuration(80).withEndAction {
animate().scaleX(1f).scaleY(1f).setDuration(80).start()
}.start()
}, 200)
}
The guard check (if (currentState == ...) return) prevents redundant animations on every 500ms tick.
What's Next
Part 19 will cover the voice input layer — how StareBrain accepts spoken instructions while another app is in the foreground, without requiring the user to switch back to StareBrain first.
Top comments (0)