feat(dual-screen): bottom-screen HUD, Line strip, strip under every bottom tab, Bottom screen HUD settings (#1982)

- In-game stats shows a bottom-screen HUD while no game sends dashboard data: 11 styles, tap to change; it reads the game's frames and the device itself, so it works with the top-screen HUD off.
- Line strip: one line like the top HUD's Strip (API + FPS, frame-time graph, 1 / 0.1 / 0.01 % lows, GPU, CPU with temperature, VRAM, RAM, clock); default strip above a game's dashboard; numbers that do not fit are left out by importance.
- The same strip under the tabs on every other bottom page during a session.
- Bottom screen HUD settings, shown even with the top HUD off: one switch for the whole bottom HUD, strip on every tab, and which numbers the Line strip shows.
- The bottom HUD redraws about 10 times a second (was about 3) and reads device stats twice a second.
- GPU load, clock and VRAM readers also run while the bottom HUD shows them (BottomHudPresence).
- Night style stays dim instead of flickering with the FPS.
- Review fixes: slow-frame ring 2048, tab strip redraw without gamescope frames, Proton name per game, Graphs' duration from the kept readings, Fahrenheit in Tiles, Rings and Cores.
This commit is contained in:
The412Banner authored and GitHub committed 2026-11-01 19:23:31 -05:00
1 parent 6a30a9bb4b
commit e1d241d723
22 files changed
+1393 -92

No files matched your search

@@ -3131,6 +3131,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onPage = { com.droiddeck.launcher.ui.SessionBottomTab.pick(SessionState.sessionId, it) },
onClose = { selectSecondScreenMode(SecondScreenMode.NONE) },
onMenu = if (dockDrawer()) ({ drawerOpen = true }) else null,
strip = { pageId ->
com.droiddeck.launcher.ui.SessionBottomStrip(pageId) { if (::hud.isInitialized) hud.liveFps else null }
},
)
}
}),
@@ -0,0 +1,15 @@
package com.droiddeck.launcher.hud
import android.os.SystemClock
internal object BottomHudPresence {
private const val STALE_MS = 2_500L
@Volatile private var seenAt = 0L
fun beat() {
seenAt = SystemClock.elapsedRealtime()
}
val showing: Boolean get() = seenAt != 0L && SystemClock.elapsedRealtime() - seenAt < STALE_MS
}
@@ -19,6 +19,10 @@ internal class FrameStats {
private var sessionStartNs = 0L
private var sessionFrames = 0L
private val slowTimes = LongArray(SLOW_FRAMES)
private var slowHead = 0
private var slowSize = 0
@Volatile var fps = 0f
private set
@@ -51,6 +55,11 @@ internal class FrameStats {
if (lowsSize < LOWS_FRAMES) lowsSize++
if (sessionStartNs == 0L) sessionStartNs = timeNs - frametimeNs
sessionFrames++
if (frametimeNs > SLOW_NS) {
slowTimes[slowHead] = timeNs
slowHead = (slowHead + 1) % SLOW_FRAMES
if (slowSize < SLOW_FRAMES) slowSize++
}
}
count++
}
@@ -68,6 +77,8 @@ internal class FrameStats {
cachedLowsNs = 0L
sessionStartNs = 0L
sessionFrames = 0L
slowHead = 0
slowSize = 0
fps = 0f
count++
}
@@ -94,6 +105,16 @@ internal class FrameStats {
@Synchronized
fun sessionSeconds(nowNs: Long): Float = if (sessionStartNs == 0L) 0f else (nowNs - sessionStartNs) / 1e9f
@Synchronized
fun slowFrames(nowNs: Long, windowNs: Long): Int {
var n = 0
for (i in 0 until slowSize) {
if (nowNs - slowTimes[(slowHead - 1 - i + SLOW_FRAMES) % SLOW_FRAMES] > windowNs) break
n++
}
return n
}
class Lows(val low1Fps: Float, val low01Fps: Float, val low001Fps: Float) {
companion object {
val EMPTY = Lows(0f, 0f, 0f)
@@ -128,6 +149,8 @@ internal class FrameStats {
const val FPS_PERIOD_NS = 500_000_000L
const val LOWS_FRAMES = 8192
private const val PAUSE_NS = 1_000_000_000L
const val SLOW_NS = 33_333_334L
private const val SLOW_FRAMES = 2048
private const val LOWS_RECOMPUTE_NS = 250_000_000L
}
}
@@ -158,6 +181,8 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra
@Volatile var refreshMilliHz = 0
private set
val live: Boolean get() = fromGamescope
fun poll(nowNs: Long): Boolean {
val compositorAdded = compositorFrames != seenCompositorFrames
seenCompositorFrames = compositorFrames
@@ -21,7 +21,8 @@ class GpuMemComponent(private val file: File, private val root: File) : SessionP
thread = Thread({
while (running) {
try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break }
if (running && (OverlayShown.check(root) || com.droiddeck.launcher.hud.DroidDeckHudHost.showing)) sample()?.let { write(it) }
if (running && (OverlayShown.check(root) || com.droiddeck.launcher.hud.DroidDeckHudHost.showing ||
com.droiddeck.launcher.hud.BottomHudPresence.showing)) sample()?.let { write(it) }
}
}, "gpu-mem").apply { isDaemon = true; start() }
}
@@ -133,7 +133,8 @@ class GpuStatsComponent(val dir: File, private val root: File) : SessionPart() {
sources = ""
}
private fun hudShown(): Boolean = OverlayShown.check(root) || com.droiddeck.launcher.hud.DroidDeckHudHost.showing
private fun hudShown(): Boolean = OverlayShown.check(root) || com.droiddeck.launcher.hud.DroidDeckHudHost.showing ||
com.droiddeck.launcher.hud.BottomHudPresence.showing
// Android hides the load (kgsl sysfs blocked): the kgsl memstore gives the whole GPU's busy share. The in-game
// Vulkan layer and the compositor time their own GPU work until the memstore timestamps are seen moving, and
@@ -108,5 +108,9 @@ class HwmonComponent(val dir: File, private val root: File) : SessionPart() {
private fun readNumber(path: String): Long? =
runCatching { File(path).readText().trim().toLong() }.getOrNull()?.takeIf { it >= 0 }
fun fanSource(): String? = FAN_SOURCES().firstOrNull { readNumber(it) != null }
fun readRpm(path: String): Long? = readNumber(path)
}
}
@@ -113,6 +113,31 @@ object SessionPrefs {
prefs(context).edit().putString("statsStrip", id).apply()
}
fun bottomStripMetrics(context: Context): Set<StripMetric> = StripMetric.parse(prefs(context).getString("bottomStripMetrics", null))
fun setBottomStripMetric(context: Context, metric: StripMetric, on: Boolean) {
val now = bottomStripMetrics(context).let { if (on) it + metric else it - metric }
prefs(context).edit().putString("bottomStripMetrics", StripMetric.encode(now)).apply()
}
fun bottomHud(context: Context): Boolean = prefs(context).getBoolean("bottomHud", true)
fun setBottomHud(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("bottomHud", on).apply()
}
fun bottomStripOnTabs(context: Context): Boolean = prefs(context).getBoolean("bottomStripTabs", true)
fun setBottomStripOnTabs(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("bottomStripTabs", on).apply()
}
fun statsHud(context: Context): String? = prefs(context).getString("statsHud", null)
fun setStatsHud(context: Context, id: String) {
prefs(context).edit().putString("statsHud", id).apply()
}
fun statsSpeedUnit(context: Context): String? = prefs(context).getString("statsSpeedUnit", null)
fun setStatsSpeedUnit(context: Context, id: String) {
@@ -26,7 +26,7 @@ object SettingsReset {
"upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias",
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds",
"bootMovieApplyNext", "bootMovieSteamSeen",
"questDefaultsV1", "telemetryAuto", "telemetryOff", "statsStrip", "statsDash", "statsSpeedUnit",
"questDefaultsV1", "telemetryAuto", "telemetryOff", "statsStrip", "statsHud", "bottomHud", "bottomStripMetrics", "bottomStripTabs", "statsDash", "statsSpeedUnit",
)
internal val SESSION_PREFIXES = listOf(
@@ -1,12 +1,38 @@
package com.droiddeck.launcher.session
enum class StatsStrip(val id: String) {
PANEL("panel"), TILES("tiles"), RINGS("rings");
LINE("line"), PANEL("panel"), TILES("tiles"), RINGS("rings");
fun next(): StatsStrip = entries[(ordinal + 1) % entries.size]
companion object {
fun from(id: String?): StatsStrip = entries.firstOrNull { it.id == id } ?: PANEL
fun from(id: String?): StatsStrip = entries.firstOrNull { it.id == id } ?: LINE
}
}
enum class StripMetric(val id: String, val defaultOn: Boolean = true) {
ENGINE("engine"), GRAPH("graph"), LOWS("lows"), LOW001("low001"), GPU("gpu"), GPU_TEMP("gputemp", false),
CPU("cpu"), CPU_TEMP("cputemp"), VRAM("vram"), RAM("ram"), CLOCK("clock");
companion object {
val defaults: Set<StripMetric> = entries.filter { it.defaultOn }.toSet()
fun parse(saved: String?): Set<StripMetric> =
if (saved == null) defaults
else saved.split(',').mapNotNull { id -> entries.firstOrNull { it.id == id.trim() } }.toSet()
fun encode(on: Set<StripMetric>): String = entries.filter { it in on }.joinToString(",") { it.id }
}
}
enum class StatsHud(val id: String) {
BIG_FPS("bigfps"), FULL("full"), TILES("tiles"), RINGS("rings"), GRAPHS("graphs"), CORES("cores"),
HEAT("heat"), GAME("game"), PACING("pacing"), NIGHT("night"), MEGA("mega");
fun next(): StatsHud = entries[(ordinal + 1) % entries.size]
companion object {
fun from(id: String?): StatsHud = entries.firstOrNull { it.id == id } ?: BIG_FPS
}
}
@@ -87,6 +87,7 @@ fun BottomDashboard(
onClose: () -> Unit,
onMenu: (() -> Unit)?,
gameStarts: Int = 0,
strip: (@Composable (String?) -> Unit)? = null,
) {
val colors = MaterialTheme.colorScheme
var current by remember { mutableStateOf(pickBottomPage(remembered, pages.map { it.id })) }
@@ -110,6 +111,7 @@ fun BottomDashboard(
}
if (onMenu != null) BarButton(Icons.Outlined.Menu, stringResource(R.string.touch_droiddeck_menu), "bottom-menu", onMenu)
}
strip?.invoke(page?.id)
Box(Modifier.weight(1f).fillMaxWidth()) {
if (page != null) page.content()
else Text(stringResource(R.string.bottom_dashboard_empty), fontSize = 15.sp, color = colors.onSurfaceVariant,
@@ -15,8 +15,10 @@ class BottomPageActions(
val runningGame: () -> String? = { null },
)
internal const val BOTTOM_PAGE_TELEMETRY = "telemetry"
fun sessionBottomPages(actions: BottomPageActions): List<BottomPage> = listOf(
BottomPage("telemetry", R.string.bottom_page_telemetry, Icons.Outlined.Speed) { BottomTelemetryPage(actions.fps, actions.runningGame) },
BottomPage(BOTTOM_PAGE_TELEMETRY, R.string.bottom_page_telemetry, Icons.Outlined.Speed) { BottomTelemetryPage(actions.fps, actions.runningGame) },
BottomPage("downloads", R.string.bottom_page_downloads, Icons.Outlined.Download) { BottomDownloadsPage() },
BottomPage("notifications", R.string.bottom_page_notifications, Icons.Outlined.Notifications) { BottomNotificationsPage(actions.openSettings) },
BottomPage("screenshots", R.string.bottom_page_screenshots, Icons.Outlined.PhotoLibrary) { BottomScreenshotsPage(actions.takeScreenshot) },
@@ -0,0 +1,85 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import com.droiddeck.launcher.hud.DroidDeckHudConfig
import com.droiddeck.launcher.hud.FrameStats
import com.droiddeck.launcher.hud.GameFrameFeed
import com.droiddeck.launcher.hud.HudFrames
import com.droiddeck.launcher.hud.HudMetrics
import com.droiddeck.launcher.hud.KeyValueSet
import com.droiddeck.launcher.hud.MangoReadings
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.StripMetric
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
private const val STRIP_FRAME_MS = 100L
private const val STRIP_METRICS_MS = 500L
private const val STRIP_TEMP_PLAIN = 0xFFF2F5F9.toInt()
@Composable
internal fun SessionBottomStrip(pageId: String?, fps: () -> Float?) {
val context = LocalContext.current
val frames = remember { GameFrameFeed(File(LinuxRuntime.rootDir(context), HudFrames.PATH)) }
DisposableEffect(frames) { onDispose { frames.close() } }
val page by rememberUpdatedState(pageId)
var frameTick by remember { mutableIntStateOf(0) }
var device by remember { mutableStateOf<HudMetrics.Snapshot?>(null) }
var clock by remember { mutableStateOf("") }
var onTabs by remember { mutableStateOf(SessionPrefs.bottomHud(context) && SessionPrefs.bottomStripOnTabs(context)) }
var metrics by remember { mutableStateOf(SessionPrefs.bottomStripMetrics(context)) }
var thresholds by remember { mutableStateOf<Map<HudMetrics.TempSensor, HudMetrics.Thresholds>>(emptyMap()) }
val tempDisplay = remember { HudMetrics.TempDisplay.from(KeyValueSet(DroidDeckHudConfig.encode(DroidDeckHudConfig.load(context)))) }
LaunchedEffect(frames) {
while (isActive) {
if (frames.poll(System.nanoTime()) || !frames.live) frameTick++
delay(STRIP_FRAME_MS)
}
}
LaunchedEffect(Unit) {
val reader = HudMetrics(context).apply { setMangoReadings(MangoReadings(File(context.filesDir, "session"), LinuxRuntime.rootDir(context))) }
thresholds = withContext(Dispatchers.IO) {
HudMetrics.TempSensor.values().mapNotNull { sensor -> runCatching { sensor to reader.resolveThresholds(sensor, tempDisplay) }.getOrNull() }.toMap()
}
while (isActive) {
onTabs = SessionPrefs.bottomHud(context) && SessionPrefs.bottomStripOnTabs(context)
metrics = SessionPrefs.bottomStripMetrics(context)
if (onTabs && page != BOTTOM_PAGE_TELEMETRY) {
if (StripMetric.GPU in metrics || StripMetric.VRAM in metrics) com.droiddeck.launcher.hud.BottomHudPresence.beat()
device = withContext(Dispatchers.IO) { runCatching { reader.snapshot() }.getOrNull() }
clock = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date())
}
delay(STRIP_METRICS_MS)
}
}
if (!onTabs || pageId == BOTTOM_PAGE_TELEMETRY) return
frameTick.let {
val live = frames.live
val stats = frames.stats
val graph = FloatArray(FrameStats.GRAPH_FRAMES).also { if (live) stats.graph(it) }
LineStrip(StripData(
fps = if (live) stats.fps else fps(), device = device,
fpsHistory = emptyList(), cpuHistory = emptyList(), gpuHistory = emptyList(), wattsHistory = emptyList(), clock = clock,
engine = if (live && !frames.steamFocused) frames.engine else "",
lows = if (live) stats.lows(System.nanoTime()) else null, graph = graph, metrics = metrics,
fahrenheit = tempDisplay.fahrenheit,
tempColor = { sensor, c ->
thresholds[sensor]?.let { Color(HudMetrics.tempColor(c.toFloat(), it, tempDisplay, STRIP_TEMP_PLAIN)) } ?: Color(STRIP_TEMP_PLAIN)
},
))
}
}
@@ -1,9 +1,13 @@
package com.droiddeck.launcher.ui
import android.content.Context
import android.content.res.Resources
import android.system.Os
import android.system.OsConstants
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
@@ -22,6 +26,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -41,18 +46,30 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.CpuCores
import com.droiddeck.launcher.frontend.AddedGameArt
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.frontend.SteamGameArt
import com.droiddeck.launcher.gpu.GpuInfo
import com.droiddeck.launcher.hud.DroidDeckHudConfig
import com.droiddeck.launcher.hud.DroidDeckHudHost
import com.droiddeck.launcher.hud.FrameStats
import com.droiddeck.launcher.hud.GameFrameFeed
import com.droiddeck.launcher.hud.HudFrames
import com.droiddeck.launcher.hud.HudMetrics
import com.droiddeck.launcher.hud.KeyValueSet
import com.droiddeck.launcher.hud.MangoReadings
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.GameStatusFiles
import com.droiddeck.launcher.session.GameTelemetry
import com.droiddeck.launcher.session.HwmonComponent
import com.droiddeck.launcher.input.SteamTouchConfig
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.session.SpeedUnit
import com.droiddeck.launcher.session.StatsDash
import com.droiddeck.launcher.session.StatsHud
import com.droiddeck.launcher.session.StatsStrip
import com.droiddeck.launcher.session.Telemetry
import com.droiddeck.launcher.session.TelemetryOutput
@@ -72,13 +89,16 @@ import java.util.concurrent.atomic.AtomicReference
private const val DRAW_MS = 66L
private const val TELEMETRY_STALE_MS = 3_000L
private const val METRICS_MS = 1_000L
private const val METRICS_MS = 500L
private const val THUNDER_MS = 500L
private const val THUNDER_RETRY_MS = 3_000L
private const val ELITE_MS = 1_000L
private const val ELITE_SEARCH_MS = 10_000L
private const val OUTPUT_SCAN_MS = 3_000L
private const val PERF_N = 30
private const val PERF_N = 60
private const val FRAME_MS = 100L
private const val MINUTE_NS = 60_000_000_000L
private const val TEMP_PLAIN = 0xFFF2F5F9.toInt()
private class PortTraffic {
@Volatile var packets = 0L
@@ -124,6 +144,21 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null }
var perfTick by remember { mutableIntStateOf(0) }
var traceTick by remember { mutableIntStateOf(0) }
var clock by remember { mutableStateOf("") }
val frames = remember { GameFrameFeed(File(LinuxRuntime.rootDir(context), HudFrames.PATH)) }
DisposableEffect(frames) { onDispose { frames.close() } }
val currentFps = { if (frames.live) frames.stats.fps else fps() }
var hud by remember { mutableStateOf(StatsHud.from(SessionPrefs.statsHud(context))) }
var hudOn by remember { mutableStateOf(SessionPrefs.bottomHud(context)) }
var hudTick by remember { mutableIntStateOf(0) }
var fanRpm by remember { mutableStateOf<Long?>(null) }
var gpuName by remember { mutableStateOf("") }
var proton by remember { mutableStateOf<String?>(null) }
var playedSeconds by remember { mutableStateOf<Long?>(null) }
var cover by remember { mutableStateOf<File?>(null) }
var coreGroups by remember { mutableStateOf<List<CoreGroup>>(emptyList()) }
var thresholds by remember { mutableStateOf<Map<HudMetrics.TempSensor, HudMetrics.Thresholds>>(emptyMap()) }
val tempDisplay = remember { HudMetrics.TempDisplay.from(KeyValueSet(DroidDeckHudConfig.encode(DroidDeckHudConfig.load(context)))) }
val gameCores = remember { CpuCores.parse(SessionPrefs.gameCpus(context)) }
LaunchedEffect(Unit) {
for (port in GameTelemetry.PORTS) launch(Dispatchers.IO) {
@@ -167,22 +202,49 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null }
speedTrace.add(r.speedKmh ?: 0f)
traceTick++
}
fpsNow = fps()
fpsNow = currentFps()
unknown = traffic.filter { (_, t) -> t.packets > 0 && !t.readable }.map { (port, t) -> UnknownTraffic(port, t.packets, t.length) }
delay(DRAW_MS)
}
}
LaunchedEffect(frames) {
while (isActive) {
if (frames.poll(System.nanoTime())) hudTick++
delay(FRAME_MS)
}
}
LaunchedEffect(Unit) {
val metrics = HudMetrics(context).apply { setMangoReadings(MangoReadings(File(context.filesDir, "session"), LinuxRuntime.rootDir(context))) }
val fanPath = withContext(Dispatchers.IO) { runCatching { HwmonComponent.fanSource() }.getOrNull() }
gpuName = withContext(Dispatchers.IO) { runCatching { GpuInfo.detect().displayName(context) }.getOrDefault("") }
coreGroups = withContext(Dispatchers.IO) { coreGroups() }
thresholds = withContext(Dispatchers.IO) {
HudMetrics.TempSensor.values().mapNotNull { sensor -> runCatching { sensor to metrics.resolveThresholds(sensor, tempDisplay) }.getOrNull() }.toMap()
}
var protonPid = 0
while (isActive) {
device = withContext(Dispatchers.IO) { runCatching { metrics.snapshot() }.getOrNull() }
fps()?.takeIf { it > 0f }?.let { fpsHistory.add(it) }
fanRpm = fanPath?.let { path -> withContext(Dispatchers.IO) { HwmonComponent.readRpm(path) } }
val pid = if (frames.live && !frames.steamFocused) frames.pid else 0
if (pid == 0) {
protonPid = 0
if (!frames.live) proton = null
} else if (pid != protonPid) {
val name = withContext(Dispatchers.IO) { DroidDeckHudHost.protonName(pid) }
proton = name
if (name != null) protonPid = pid
}
playedSeconds = if (pid > 0 && running != null) withContext(Dispatchers.IO) { processSeconds(pid) } else null
val sampledAt = android.os.SystemClock.elapsedRealtime()
currentFps()?.takeIf { it > 0f }?.let { fpsHistory.add(it, sampledAt) }
device?.let { s ->
s.cpuPercent?.let { cpuHistory.add(it.toFloat()) }
s.gpuPercent?.let { gpuHistory.add(it.toFloat()) }
s.cpuPercent?.let { cpuHistory.add(it.toFloat(), sampledAt) }
s.gpuPercent?.let { gpuHistory.add(it.toFloat(), sampledAt) }
s.battery?.watts?.takeIf { it > 0f }?.let { wattsHistory.add(it) }
}
clock = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date())
hudOn = SessionPrefs.bottomHud(context)
if (hudOn) com.droiddeck.launcher.hud.BottomHudPresence.beat()
perfTick++
delay(METRICS_MS)
}
@@ -221,6 +283,7 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null }
val id = running?.toLongOrNull()
gameName = if (id == null) null
else withContext(Dispatchers.IO) { runCatching { SteamTouchConfig.gameName(context, id.toInt()) }.getOrNull() }
cover = if (id == null) null else withContext(Dispatchers.IO) { runCatching { gameCover(context, id.toInt()) }.getOrNull() }
}
LaunchedEffect(rescan) {
while (isActive) {
@@ -230,96 +293,167 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null }
}
}
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
perfTick.let {
StatsStripView(strip, StripData(fpsNow, device, fpsHistory.snapshot(), cpuHistory.snapshot(), gpuHistory.snapshot(), wattsHistory.snapshot(), clock,
if (running != null) gameName else steamClient?.let { stringResource(steamClientLabel(it)) })) {
strip = strip.next()
SessionPrefs.setStatsStrip(context, strip.id)
}
}
race?.let { r ->
val shown = dash.resolve(r.source)
traceTick.let {
RaceDashView(shown, r, TraceData(throttleTrace.snapshot(), brakeTrace.snapshot(), speedTrace.snapshot()), shownUnit) {
dash = StatsDash.tapped(shown)
SessionPrefs.setStatsDash(context, dash.id)
val title = if (running != null) gameName else steamClient?.let { stringResource(steamClientLabel(it)) }
val noData = race == null && thunder.isEmpty() && elite == null
BoxWithConstraints(Modifier.fillMaxSize()) {
val viewport = maxHeight
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (!hudOn) Unit
else if (noData) hudTick.let {
perfTick.let {
val now = System.nanoTime()
val live = frames.live
val stats = frames.stats
val graph = FloatArray(FrameStats.GRAPH_FRAMES).also { if (live) stats.graph(it) }
val hz = (frames.refreshMilliHz + 499) / 1000
val data = HudData(
fps = fpsNow, avgFps = if (live) stats.averageFps() else null, lows = if (live) stats.lows(now) else FrameStats.Lows.EMPTY,
graph = graph, slowPerMinute = if (live) stats.slowFrames(now, MINUTE_NS) else null,
engine = if (live && !frames.steamFocused) frames.engine else "",
resolution = if (frames.outputWidth > 0 && frames.outputHeight > 0) {
"${frames.outputWidth}x${frames.outputHeight}" + (if (hz > 0) " @ ${hz}Hz" else "")
} else "",
refreshHz = hz, proton = proton, device = device, gpuName = gpuName, fanRpm = fanRpm,
title = title, cover = cover, played = playedSeconds?.let(::playedText), clock = clock,
fpsHistory = fpsHistory.snapshot(), cpuHistory = cpuHistory.snapshot(), gpuHistory = gpuHistory.snapshot(),
historySeconds = maxOf(fpsHistory.spanSeconds(), cpuHistory.spanSeconds(), gpuHistory.spanSeconds()),
coreGroups = coreGroups, gameCores = gameCores, fahrenheit = tempDisplay.fahrenheit,
tempColor = { sensor, c ->
thresholds[sensor]?.let { Color(HudMetrics.tempColor(c.toFloat(), it, tempDisplay, TEMP_PLAIN)) } ?: Color(TEMP_PLAIN)
},
redC = { thresholds[it]?.redC },
)
StatsHudView(hud, data, Modifier.fillMaxWidth().height(viewport)) {
hud = hud.next()
SessionPrefs.setStatsHud(context, hud.id)
}
}
}
}
if (thunder.isNotEmpty()) {
if (FlightLabels.drawable(thunder)) FlightDash(thunder)
else TelemetryCard(stringResource(R.string.bottom_telemetry_war_thunder)) { ValueGrid(thunder.take(GameStatusFiles.WAR_THUNDER_ROWS)) }
}
elite?.let { e ->
if (e.pips != null || e.fuelMain != null) ShipDash(e)
else TelemetryCard(stringResource(R.string.bottom_telemetry_elite)) {
ValueGrid(listOfNotNull(
e.fuelMain?.let { stringResource(R.string.bottom_telemetry_fuel) to "%.1f t".format(it) },
e.cargo?.let { stringResource(R.string.bottom_telemetry_cargo) to "%.0f t".format(it) },
e.pips?.takeIf { it.size == 3 }?.let { p ->
stringResource(R.string.bottom_telemetry_pips) to p.joinToString(" · ") { "%.1f".format(it / 2f) }
},
e.legal?.let { stringResource(R.string.bottom_telemetry_legal) to it },
e.balance?.let { stringResource(R.string.bottom_telemetry_balance) to "%,d CR".format(it) },
))
}
}
if (race == null && thunder.isEmpty() && elite == null) {
Text(stringResource(R.string.bottom_telemetry_help),
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp))
}
if (outputs.isNotEmpty() || menuGames.isNotEmpty()) TelemetryCard(stringResource(R.string.bottom_telemetry_send_title)) {
if (outputs.isNotEmpty()) SwitchRow(stringResource(R.string.bottom_telemetry_send_auto), stringResource(R.string.bottom_telemetry_send_auto_hint), auto, true) { want ->
scope.launch {
withContext(Dispatchers.IO) {
SessionPrefs.setTelemetryAuto(context, want)
if (want) TelemetryOutput.applyAutomatic(context, running)
else TelemetryOutput.restoreTargets(outputs, running).forEach { TelemetryOutput.set(it, false) }
} else hudTick.let {
perfTick.let {
val live = frames.live
val graph = FloatArray(FrameStats.GRAPH_FRAMES).also { if (live) frames.stats.graph(it) }
val data = StripData(
fpsNow, device, fpsHistory.snapshot(), cpuHistory.snapshot(), gpuHistory.snapshot(), wattsHistory.snapshot(), clock, title,
engine = if (live && !frames.steamFocused) frames.engine else "",
lows = if (live) frames.stats.lows(System.nanoTime()) else null, graph = graph,
metrics = SessionPrefs.bottomStripMetrics(context), fahrenheit = tempDisplay.fahrenheit,
tempColor = { sensor, c ->
thresholds[sensor]?.let { Color(HudMetrics.tempColor(c.toFloat(), it, tempDisplay, TEMP_PLAIN)) } ?: Color(TEMP_PLAIN)
},
)
StatsStripView(strip, data) {
strip = strip.next()
SessionPrefs.setStatsStrip(context, strip.id)
}
auto = want
rescan++
}
}
for (game in outputs) {
val locked = TelemetryOutput.isRunning(game.prefixId, running)
val hint = if (locked) stringResource(R.string.bottom_telemetry_send_close, game.name) else null
SwitchRow(game.name, hint, game.on, !locked) { want ->
race?.let { r ->
val shown = dash.resolve(r.source)
traceTick.let {
RaceDashView(shown, r, TraceData(throttleTrace.snapshot(), brakeTrace.snapshot(), speedTrace.snapshot()), shownUnit) {
dash = StatsDash.tapped(shown)
SessionPrefs.setStatsDash(context, dash.id)
}
}
}
if (thunder.isNotEmpty()) {
if (FlightLabels.drawable(thunder)) FlightDash(thunder)
else TelemetryCard(stringResource(R.string.bottom_telemetry_war_thunder)) { ValueGrid(thunder.take(GameStatusFiles.WAR_THUNDER_ROWS)) }
}
elite?.let { e ->
if (e.pips != null || e.fuelMain != null) ShipDash(e)
else TelemetryCard(stringResource(R.string.bottom_telemetry_elite)) {
ValueGrid(listOfNotNull(
e.fuelMain?.let { stringResource(R.string.bottom_telemetry_fuel) to "%.1f t".format(it) },
e.cargo?.let { stringResource(R.string.bottom_telemetry_cargo) to "%.0f t".format(it) },
e.pips?.takeIf { it.size == 3 }?.let { p ->
stringResource(R.string.bottom_telemetry_pips) to p.joinToString(" · ") { "%.1f".format(it / 2f) }
},
e.legal?.let { stringResource(R.string.bottom_telemetry_legal) to it },
e.balance?.let { stringResource(R.string.bottom_telemetry_balance) to "%,d CR".format(it) },
))
}
}
if (race == null && thunder.isEmpty() && elite == null && running == null) {
Text(stringResource(R.string.bottom_telemetry_help),
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp))
}
if (outputs.isNotEmpty() || menuGames.isNotEmpty()) TelemetryCard(stringResource(R.string.bottom_telemetry_send_title)) {
if (outputs.isNotEmpty()) SwitchRow(stringResource(R.string.bottom_telemetry_send_auto), stringResource(R.string.bottom_telemetry_send_auto_hint), auto, true) { want ->
scope.launch {
val ok = withContext(Dispatchers.IO) {
val key = TelemetryOutput.key(game)
val off = SessionPrefs.telemetryOff(context)
SessionPrefs.setTelemetryOff(context, if (want) off - key else off + key)
TelemetryOutput.set(game, want)
withContext(Dispatchers.IO) {
SessionPrefs.setTelemetryAuto(context, want)
if (want) TelemetryOutput.applyAutomatic(context, running)
else TelemetryOutput.restoreTargets(outputs, running).forEach { TelemetryOutput.set(it, false) }
}
outputFailed = if (ok) null else game.name
auto = want
rescan++
}
}
for (game in outputs) {
val locked = TelemetryOutput.isRunning(game.prefixId, running)
val hint = if (locked) stringResource(R.string.bottom_telemetry_send_close, game.name) else null
SwitchRow(game.name, hint, game.on, !locked) { want ->
scope.launch {
val ok = withContext(Dispatchers.IO) {
val key = TelemetryOutput.key(game)
val off = SessionPrefs.telemetryOff(context)
SessionPrefs.setTelemetryOff(context, if (want) off - key else off + key)
TelemetryOutput.set(game, want)
}
outputFailed = if (ok) null else game.name
rescan++
}
}
}
for (game in menuGames) {
Text(stringResource(R.string.bottom_telemetry_send_menu, game.name, game.setting, game.port), fontSize = 13.sp, color = colors.onSurfaceVariant)
}
if (outputs.isNotEmpty()) Text(stringResource(R.string.bottom_telemetry_send_note), fontSize = 12.sp, color = colors.onSurfaceVariant)
outputFailed?.let { Text(stringResource(R.string.bottom_telemetry_send_failed, it), fontSize = 12.sp, color = colors.error) }
}
for (game in menuGames) {
Text(stringResource(R.string.bottom_telemetry_send_menu, game.name, game.setting, game.port), fontSize = 13.sp, color = colors.onSurfaceVariant)
for (u in unknown) {
Text(stringResource(R.string.bottom_telemetry_unknown_format, u.port, u.packets, u.length), fontSize = 13.sp,
color = colors.error, modifier = Modifier.padding(horizontal = 4.dp))
}
if (outputs.isNotEmpty()) Text(stringResource(R.string.bottom_telemetry_send_note), fontSize = 12.sp, color = colors.onSurfaceVariant)
outputFailed?.let { Text(stringResource(R.string.bottom_telemetry_send_failed, it), fontSize = 12.sp, color = colors.error) }
if (busyPorts.isNotEmpty()) {
Text(stringResource(R.string.bottom_telemetry_port_busy, busyPorts.sorted().joinToString()), fontSize = 13.sp,
color = colors.error, modifier = Modifier.padding(horizontal = 4.dp))
}
ChoiceRow(stringResource(R.string.stats_speed_units),
if (speedUnit == SpeedUnit.AUTO) stringResource(R.string.stats_speed_units_auto, shownUnit.label) else speedUnit.label) {
speedUnit = speedUnit.next()
SessionPrefs.setStatsSpeedUnit(context, speedUnit.id)
}
Text(stringResource(R.string.stats_tap_hint), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp))
}
for (u in unknown) {
Text(stringResource(R.string.bottom_telemetry_unknown_format, u.port, u.packets, u.length), fontSize = 13.sp,
color = colors.error, modifier = Modifier.padding(horizontal = 4.dp))
}
if (busyPorts.isNotEmpty()) {
Text(stringResource(R.string.bottom_telemetry_port_busy, busyPorts.sorted().joinToString()), fontSize = 13.sp,
color = colors.error, modifier = Modifier.padding(horizontal = 4.dp))
}
ChoiceRow(stringResource(R.string.stats_speed_units),
if (speedUnit == SpeedUnit.AUTO) stringResource(R.string.stats_speed_units_auto, shownUnit.label) else speedUnit.label) {
speedUnit = speedUnit.next()
SessionPrefs.setStatsSpeedUnit(context, speedUnit.id)
}
Text(stringResource(R.string.stats_tap_hint), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp))
}
}
private fun processSeconds(pid: Int): Long? = runCatching {
val fields = File("/proc/$pid/stat").readText().substringAfterLast(')').trim().split(' ')
val startTicks = fields[19].toLong()
val uptime = File("/proc/uptime").readText().substringBefore(' ').toDouble()
(uptime - startTicks.toDouble() / Os.sysconf(OsConstants._SC_CLK_TCK)).toLong()
}.getOrNull()?.takeIf { it >= 0 }
private fun playedText(seconds: Long): String =
if (seconds >= 3600) "%d:%02d:%02d".format(seconds / 3600, seconds / 60 % 60, seconds % 60) else "%d:%02d".format(seconds / 60, seconds % 60)
private fun gameCover(context: Context, appId: Int): File? =
SteamGameArt.chosenFile(context, appId, AddedGameArt.Slot.COVER)
?: Library.steamCacheImage(File(SessionPrefs.activeSteamRoot(context), "appcache/librarycache"), appId, listOf("library_capsule.jpg", "library_600x900.jpg"))
private fun coreGroups(): List<CoreGroup> {
val out = mutableListOf<CoreGroup>()
for (core in CpuCores.all) {
val ghz = CpuCores.maxGhz(core)
val last = out.lastOrNull()
if (last != null && last.maxGhz == ghz) out[out.lastIndex] = CoreGroup(last.cores + core, ghz) else out += CoreGroup(listOf(core), ghz)
}
return out
}
private fun httpGet(url: String): String {
val c = URL(url).openConnection() as HttpURLConnection
c.connectTimeout = 300
@@ -1,10 +1,17 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.hud.DroidDeckHudSize
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.StripMetric
@Composable
internal fun DroidDeckHudSettings(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
@@ -31,6 +38,7 @@ internal fun DroidDeckHudSettings(host: MenuHost, a: DrawerActions, track: (Stri
ToggleRow(host, "hud-snap", stringResource(R.string.hud_snap), stringResource(R.string.hud_snap_hint), on("hudSnap"),
chipModifier = track("hud-snap")) { a.onHudSetting("hudSnap", flag(it)) }
}
if (a.dashboardAvailable && a.secondScreenDisplays.isNotEmpty()) BottomStripSettings(host, track)
if (!a.hudOn) return
SettingsGroup(stringResource(R.string.hud_group_look)) {
@@ -96,6 +104,47 @@ internal fun DroidDeckHudSettings(host: MenuHost, a: DrawerActions, track: (Stri
private fun flag(on: Boolean) = if (on) "1" else "0"
@Composable
private fun BottomStripSettings(host: MenuHost, track: (String) -> Modifier) {
val context = LocalContext.current
var hudOn by remember { mutableStateOf(SessionPrefs.bottomHud(context)) }
var onTabs by remember { mutableStateOf(SessionPrefs.bottomStripOnTabs(context)) }
var shown by remember { mutableStateOf(SessionPrefs.bottomStripMetrics(context)) }
SettingsGroup(stringResource(R.string.bottom_hud_group), detail = stringResource(R.string.bottom_hud_group_detail)) {
ToggleRow(host, "bottom-hud", stringResource(R.string.bottom_hud_group), stringResource(R.string.bottom_hud_hint), hudOn,
chipModifier = track("bottom-hud")) { on ->
hudOn = on
SessionPrefs.setBottomHud(context, on)
}
ToggleRow(host, "bottom-strip-tabs", stringResource(R.string.bottom_strip_tabs), stringResource(R.string.bottom_strip_tabs_hint), onTabs,
enabled = hudOn, chipModifier = track("bottom-strip-tabs")) { on ->
onTabs = on
SessionPrefs.setBottomStripOnTabs(context, on)
}
for ((metric, label) in BOTTOM_STRIP_ROWS) {
ToggleRow(host, "bottom-strip-${metric.id}", stringResource(label), null, metric in shown,
enabled = hudOn, chipModifier = track("bottom-strip-${metric.id}")) { on ->
shown = if (on) shown + metric else shown - metric
SessionPrefs.setBottomStripMetric(context, metric, on)
}
}
}
}
private val BOTTOM_STRIP_ROWS = listOf(
StripMetric.ENGINE to R.string.hud_metric_engine,
StripMetric.GRAPH to R.string.hud_metric_graph,
StripMetric.LOWS to R.string.bottom_strip_lows,
StripMetric.LOW001 to R.string.hud_metric_low001,
StripMetric.GPU to R.string.hud_metric_gpu,
StripMetric.GPU_TEMP to R.string.hud_metric_gpu_temp,
StripMetric.CPU to R.string.hud_metric_cpu,
StripMetric.CPU_TEMP to R.string.bottom_strip_cpu_temp,
StripMetric.VRAM to R.string.hud_metric_vram,
StripMetric.RAM to R.string.hud_metric_ram,
StripMetric.CLOCK to R.string.hud_metric_clock,
)
private val EXTRA = mapOf(DroidDeckHudSize.PILL to setOf("Temp", "Power"))
private class HudMetric(val chip: String, val key: String, val label: Int)
@@ -0,0 +1,667 @@
package com.droiddeck.launcher.ui
import android.graphics.BlurMaskFilter
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.hud.DroidDeckMark
import com.droiddeck.launcher.hud.FrameStats
import com.droiddeck.launcher.hud.HudMetrics
import com.droiddeck.launcher.session.StatsHud
import java.io.File
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
internal class CoreGroup(val cores: List<Int>, val maxGhz: Double?)
internal class HudData(
val fps: Float?,
val avgFps: Float?,
val lows: FrameStats.Lows,
val graph: FloatArray,
val slowPerMinute: Int?,
val engine: String,
val resolution: String,
val refreshHz: Int,
val proton: String?,
val device: HudMetrics.Snapshot?,
val gpuName: String,
val fanRpm: Long?,
val title: String?,
val cover: File?,
val played: String?,
val clock: String,
val fpsHistory: List<Float>,
val cpuHistory: List<Float>,
val gpuHistory: List<Float>,
val coreGroups: List<CoreGroup>,
val gameCores: Set<Int>,
val fahrenheit: Boolean,
val tempColor: (HudMetrics.TempSensor, Int) -> Color,
val redC: (HudMetrics.TempSensor) -> Int?,
val historySeconds: Int = 0,
) {
val frametimeMs: Float? get() = fps?.takeIf { it > 0f }?.let { 1000f / it }
val hz: Int get() = if (refreshHz > 0) refreshHz else 60
}
private object HudInk {
val value = Color(0xFFF2F5F9)
val dim = Color(0xFF9AA4B2)
val low = Color(0xFFE4E8EE)
val vram = Color(0xFFC98BFF)
val sep = Color(0xFF5D6876)
val header = Color(0xFFEAF4FF)
val amber = Color(0xFFFFB74D)
val tile = Color(0x0EFFFFFF)
}
private val Mono = FontFamily.Monospace
private val HEADER = 40.dp
private const val UNIT = 0.62f
@Composable
internal fun StatsHudView(style: StatsHud, d: HudData, modifier: Modifier, onTap: () -> Unit) {
val accent = LocalPalette.current.primary
val night = style == StatsHud.NIGHT
Box(modifier.clickable(onClick = onTap)) {
HudPanel(d.clock, if (style == StatsHud.GRAPHS) stringResource(R.string.stats_hud_last_seconds, d.historySeconds) else null, night, accent) {
when (style) {
StatsHud.BIG_FPS -> BigFps(d, accent)
StatsHud.FULL -> Full(d, accent)
StatsHud.TILES -> Tiles(d, accent)
StatsHud.RINGS -> Rings(d, accent)
StatsHud.GRAPHS -> Graphs(d, accent)
StatsHud.CORES -> Cores(d, accent)
StatsHud.HEAT -> Heat(d)
StatsHud.GAME -> GameCard(d, accent)
StatsHud.PACING -> Pacing(d, accent)
StatsHud.NIGHT -> Night(d, night)
StatsHud.MEGA -> Mega(d, accent)
}
}
}
}
@Composable
private fun HudPanel(clock: String, right: String?, dim: Boolean, accent: Color, content: @Composable ColumnScope.() -> Unit) {
val headerInk = if (dim) Color(0xFF7D8794) else HudInk.header
Column(
Modifier.fillMaxSize()
.drawWithCache {
val w = size.width
val h = size.height
val stroke = 2.dp.toPx()
val corner = 7.dp.toPx()
val radius = min(h / 2f, 64.dp.toPx())
val headerPx = HEADER.toPx()
val a = accent.toArgb()
val body = dPath(0f, 0f, w, h, radius, corner)
val half = stroke / 2f
val outline = dPath(half, half, w - half, h - half, radius - half, corner)
val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = if (dim) argb(245, 0xFF040507.toInt()) else argb(240, 0xFF0E1014.toInt()) }
val band = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(0f, 0f, w, 0f, argb(if (dim) 20 else 56, a), argb(0, a), Shader.TileMode.CLAMP)
}
val divider = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = argb(if (dim) 38 else 90, a) }
val blur = BlurMaskFilter(4.dp.toPx(), BlurMaskFilter.Blur.NORMAL)
val line = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = stroke; color = argb(if (dim) 90 else 255, a) }
val markH = min(h, w) * 0.55f
val markX = w - DroidDeckMark.widthFor(markH) - 16.dp.toPx()
val markBody = android.graphics.Color.argb(if (dim) 9 else 20, 255, 255, 255)
val markOrb = argb(if (dim) 9 else 20, a)
onDrawBehind {
drawIntoCanvas { canvas ->
val c = canvas.nativeCanvas
c.drawPath(body, fill)
c.save()
c.clipPath(body)
c.drawRect(0f, 0f, w, headerPx + 1f, band)
c.drawRect(0f, headerPx, w, headerPx + max(1f, density), divider)
DroidDeckMark.draw(c, markX, (h - markH) / 2f, markH, markBody, markOrb)
c.restore()
for (pass in 0..2) {
line.maskFilter = if (pass < 2) blur else null
c.drawPath(outline, line)
}
line.maskFilter = null
}
}
}
.padding(start = 24.dp, end = 34.dp, bottom = 14.dp),
) {
Row(Modifier.fillMaxWidth().height(HEADER), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DroidDeckLogo(16.dp, body = headerInk, orb = if (dim) accent.copy(alpha = 0.5f) else accent)
Text("DroidDeck", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = headerInk, maxLines = 1)
if (clock.isNotEmpty()) {
Text("·", fontSize = 13.sp, fontFamily = Mono, color = HudInk.sep)
Text(clock, fontSize = 13.sp, fontFamily = Mono, fontWeight = FontWeight.Bold, color = HudInk.dim, maxLines = 1)
}
if (right != null) {
Spacer(Modifier.weight(1f))
Text(right, fontSize = 13.sp, fontFamily = Mono, fontWeight = FontWeight.Bold, color = HudInk.dim, maxLines = 1)
}
}
Spacer(Modifier.height(12.dp))
content()
}
}
private fun argb(alpha: Int, rgb: Int): Int = (alpha shl 24) or (rgb and 0xFFFFFF)
private fun dPath(l: Float, t: Float, r: Float, b: Float, rad: Float, corner: Float) = android.graphics.Path().apply {
moveTo(l, t + corner)
lineTo(l, b - corner); quadTo(l, b, l + corner, b)
lineTo(r - rad, b)
arcTo(r - 2f * rad, b - 2f * rad, r, b, 90f, -90f, false)
lineTo(r, t + rad)
arcTo(r - 2f * rad, t, r, t + 2f * rad, 0f, -90f, false)
lineTo(l + corner, t); quadTo(l, t, l, t + corner)
close()
}
private fun f1(v: Float?): String = v?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–"
private fun gib(bytes: Long?): String = bytes?.let { "%.1f".format(it / 1073741824.0) } ?: "–"
private fun AnnotatedString.Builder.num(value: String, unit: String, size: TextUnit, color: Color = HudInk.value, unitScale: Float = UNIT) {
withStyle(SpanStyle(color = color)) { append(value) }
if (unit.isNotEmpty()) withStyle(SpanStyle(color = HudInk.dim, fontSize = size * unitScale)) { append(unit) }
}
private fun AnnotatedString.Builder.gap() = append(" ")
private fun tempLabel(d: HudData, c: Int): String = if (d.fahrenheit) "${c * 9 / 5 + 32}°F" else "$c°C"
private fun AnnotatedString.Builder.temp(d: HudData, sensor: HudMetrics.TempSensor, c: Int?, size: TextUnit) {
if (c == null) { num("–", "", size); return }
val shown = if (d.fahrenheit) (c * 9 / 5 + 32) else c
num(shown.toString(), if (d.fahrenheit) "°F" else "°C", size, d.tempColor(sensor, c))
}
@Composable
private fun Big(value: String, unit: String, size: TextUnit, color: Color, glow: Boolean, unitScale: Float = 0.27f, modifier: Modifier = Modifier) {
Text(
buildAnnotatedString { num(value, unit, size, color, unitScale) },
modifier = modifier, fontSize = size, fontFamily = Mono, fontWeight = FontWeight.Bold, maxLines = 1, softWrap = false,
style = if (glow) TextStyle(shadow = Shadow(color.copy(alpha = 0.85f), Offset.Zero, 18f)) else TextStyle.Default,
)
}
@Composable
private fun Line(text: AnnotatedString, size: TextUnit, modifier: Modifier = Modifier, align: TextAlign = TextAlign.Start) {
Text(text, modifier = modifier, fontSize = size, fontFamily = Mono, fontWeight = FontWeight.Bold, color = HudInk.value, maxLines = 1,
softWrap = false, overflow = TextOverflow.Clip, textAlign = align)
}
@Composable
private fun Small(text: String, size: TextUnit = 12.sp, color: Color = HudInk.dim, modifier: Modifier = Modifier) {
Text(text, modifier = modifier, fontSize = size, fontFamily = Mono, fontWeight = FontWeight.Bold, color = color, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
private fun lowsText(d: HudData, size: TextUnit, accent: Color, deep: Boolean = true): AnnotatedString = buildAnnotatedString {
fun part(label: String, v: Float) {
withStyle(SpanStyle(color = HudInk.dim)) { append("$label ") }
withStyle(SpanStyle(color = accent)) { append(f1(v)) }
}
part("1%", d.lows.low1Fps)
withStyle(SpanStyle(color = HudInk.sep)) { append(" · ") }
part("0.1%", d.lows.low01Fps)
if (deep) {
withStyle(SpanStyle(color = HudInk.sep)) { append(" · ") }
part("0.01%", d.lows.low001Fps)
}
}
@Composable
private fun Graph(values: List<Float>, lo: Float, hi: Float, color: Color, modifier: Modifier) {
Canvas(modifier) {
if (values.size < 2 || hi <= lo) return@Canvas
val path = Path()
values.forEachIndexed { i, v ->
val x = i / (values.size - 1f) * size.width
val y = size.height - ((v - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
drawPath(path, color.copy(alpha = 0.22f), style = Stroke(width = 6.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
drawPath(path, color, style = Stroke(width = 2.2f.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
}
}
@Composable
private fun FrameGraph(d: HudData, accent: Color, height: Dp) {
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height))
}
@Composable
private fun Rows(rows: List<Pair<Pair<String, Color>, AnnotatedString>>, size: TextUnit, labelWidth: Dp, gap: Dp) {
Column(verticalArrangement = Arrangement.spacedBy(gap)) {
for ((label, value) in rows) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label.first, modifier = Modifier.width(labelWidth), fontSize = size, fontFamily = Mono, fontWeight = FontWeight.Bold,
color = label.second, maxLines = 1, softWrap = false)
Line(value, size)
}
}
}
}
private fun row(label: String, color: Color, value: AnnotatedString) = (label to color) to value
@Composable
private fun RowScope.Tile(key: String, keyColor: Color, value: AnnotatedString, sub: String, glow: Color? = null) {
Column(
Modifier.weight(1f).fillMaxHeight().clip(RoundedCornerShape(9.dp)).background(HudInk.tile).padding(horizontal = 11.dp, vertical = 6.dp),
verticalArrangement = Arrangement.Center,
) {
Small(key, 12.sp, keyColor)
Text(value, fontSize = 28.sp, fontFamily = Mono, fontWeight = FontWeight.Bold, color = HudInk.value, maxLines = 1, softWrap = false,
style = if (glow != null) TextStyle(shadow = Shadow(glow.copy(alpha = 0.85f), Offset.Zero, 16f)) else TextStyle.Default)
Small(sub)
}
}
@Composable
private fun Ring(ringSize: Dp, fraction: Float?, color: Color, label: String, sub: String?, center: @Composable () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.size(ringSize), contentAlignment = Alignment.Center) {
Canvas(Modifier.fillMaxSize()) {
val w = size.minDimension * 0.085f
val tl = Offset(w, w)
val sz = Size(size.width - 2 * w, size.height - 2 * w)
drawArc(Color.White.copy(alpha = 0.08f), 0f, 360f, false, topLeft = tl, size = sz, style = Stroke(width = w))
if (fraction != null) {
val sweep = (fraction.coerceIn(0f, 1f) * 360f).coerceAtLeast(1f)
drawArc(color.copy(alpha = 0.25f), -90f, sweep, false, topLeft = tl, size = sz, style = Stroke(width = w * 1.9f, cap = StrokeCap.Round))
drawArc(color, -90f, sweep, false, topLeft = tl, size = sz, style = Stroke(width = w, cap = StrokeCap.Round))
}
}
center()
}
Small(label, 13.sp, color)
if (sub != null) Small(sub, 11.5.sp)
}
}
@Composable
private fun ColumnScope.BigFps(d: HudData, accent: Color) {
Big(f1(d.fps), "fps", 104.sp, accent, glow = true, modifier = Modifier.offset(x = (-4).dp))
Line(lowsText(d, 16.sp, accent), 16.sp, Modifier.padding(top = 10.dp))
Spacer(Modifier.weight(1f))
FrameGraph(d, accent, 72.dp)
Small(listOfNotNull(d.engine.ifEmpty { null }, d.title, d.played).joinToString(" · "), modifier = Modifier.padding(top = 8.dp))
}
@Composable
private fun ColumnScope.Full(d: HudData, accent: Color) {
val s = d.device
val z = 16.sp
val left = listOf(
row("GPU", StatsColors.gpu, AnnotatedString(d.gpuName)),
row("GPU", StatsColors.gpu, buildAnnotatedString { num(s?.gpuPercent?.toString() ?: "–", "%", z); gap(); temp(d, HudMetrics.TempSensor.GPU, s?.gpuTempC, z) }),
row("", StatsColors.gpu, buildAnnotatedString { num(s?.gpuClockMhz?.toString() ?: "–", "MHz", z) }),
row("CPU", StatsColors.cpu, buildAnnotatedString { num(s?.cpuPercent?.toString() ?: "–", "%", z); gap(); temp(d, HudMetrics.TempSensor.CPU, s?.cpuTempC, z) }),
row("", StatsColors.cpu, buildAnnotatedString { num(s?.cpuClockMhz?.toString() ?: "–", "MHz", z) }),
row("VRAM", HudInk.vram, buildAnnotatedString { num(gib(s?.vramUsedBytes), "GiB", z) }),
row("RAM", StatsColors.ram, buildAnnotatedString { num(gib(s?.ramUsedBytes), "GiB", z); gap(); num(s?.ramPercent?.roundToInt()?.toString() ?: "–", "%", z) }),
row("BAT", StatsColors.bat, buildAnnotatedString { num(s?.battery?.percent?.toString() ?: "–", "%", z); gap(); num(s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–", "W", z) }),
)
val right = listOf(
row(d.engine.ifEmpty { "FPS" }, accent, buildAnnotatedString { num(f1(d.fps), "FPS", z, accent) }),
row("", accent, buildAnnotatedString { num(f1(d.frametimeMs), "ms", z) }),
row("AVG", HudInk.low, buildAnnotatedString { num(f1(d.avgFps), "FPS", z) }),
row("1%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low1Fps), "FPS", z) }),
row("0.1%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low01Fps), "FPS", z) }),
row("0.01%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low001Fps), "FPS", z) }),
row("TEMP", StatsColors.bat, buildAnnotatedString { temp(d, HudMetrics.TempSensor.BATTERY, s?.battery?.tempC, z) }),
row("FAN", HudInk.dim, buildAnnotatedString { num(d.fanRpm?.toString() ?: "–", "rpm", z) }),
)
Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) {
Rows(left, z, 52.dp, 6.dp)
Rows(right, z, 62.dp, 6.dp)
}
Spacer(Modifier.weight(1f))
val frametime = stringResource(R.string.stats_hud_frametime)
Line(buildAnnotatedString {
withStyle(SpanStyle(color = accent)) { append(frametime) }
withStyle(SpanStyle(color = HudInk.dim)) { append(" min %.1f · max %.1f ms".format(d.graph.filter { it > 0f }.minOrNull() ?: 0f, d.graph.maxOrNull() ?: 0f)) }
}, 12.5.sp, Modifier.padding(bottom = 6.dp))
FrameGraph(d, accent, 44.dp)
}
@Composable
private fun ColumnScope.Tiles(d: HudData, accent: Color) {
val s = d.device
val z = 28.sp
val u = 0.5f
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Tile("FPS", accent, buildAnnotatedString { num(f1(d.fps), "", z) }, "${f1(d.avgFps)} avg · ${f1(d.lows.low1Fps)} 1%", glow = accent)
Tile("FRAME", HudInk.dim, buildAnnotatedString { num(f1(d.frametimeMs), "ms", z, unitScale = u) }, "max %.1f ms".format(d.graph.maxOrNull() ?: 0f))
Tile("GPU", StatsColors.gpu, buildAnnotatedString { num(s?.gpuPercent?.toString() ?: "–", "%", z, unitScale = u) },
listOfNotNull(s?.gpuTempC?.let { tempLabel(d, it) }, s?.gpuClockMhz?.let { "$it MHz" }).joinToString(" · "))
}
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Tile("CPU", StatsColors.cpu, buildAnnotatedString { num(s?.cpuPercent?.toString() ?: "–", "%", z, unitScale = u) },
listOfNotNull(s?.cpuTempC?.let { tempLabel(d, it) }, s?.cpuClockMhz?.let { "$it MHz" }).joinToString(" · "))
Tile("RAM", StatsColors.ram, buildAnnotatedString { num(s?.ramPercent?.roundToInt()?.toString() ?: "–", "%", z, unitScale = u) },
"${gib(s?.ramUsedBytes)} / ${gib(s?.ramTotalBytes)} GiB")
Tile("VRAM", HudInk.vram, buildAnnotatedString { num(gib(s?.vramUsedBytes), "GiB", z, unitScale = u) }, d.gpuName)
}
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Tile("BAT TEMP", StatsColors.bat, buildAnnotatedString { temp(d, HudMetrics.TempSensor.BATTERY, s?.battery?.tempC, z) }, stringResource(R.string.stats_hud_battery_temp))
Tile("FAN", HudInk.dim, buildAnnotatedString { num(d.fanRpm?.toString() ?: "–", "", z) }, "rpm")
Tile("BATTERY", StatsColors.bat, buildAnnotatedString { num(s?.battery?.percent?.toString() ?: "–", "%", z, unitScale = u) },
listOfNotNull(s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f W".format(it) }, s?.battery?.runtimeText).joinToString(" · "))
}
}
}
@Composable
private fun ColumnScope.Rings(d: HudData, accent: Color) {
val s = d.device
Row(Modifier.weight(1f).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(26.dp)) {
Ring(200.dp, d.fps?.let { it / d.hz }, accent, stringResource(R.string.stats_hud_fps_of, d.hz), "1% ${f1(d.lows.low1Fps)} · 0.1% ${f1(d.lows.low01Fps)}") {
Big(f1(d.fps), "fps", 40.sp, HudInk.value, glow = true, unitScale = 0.4f)
}
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(22.dp)) {
Ring(96.dp, s?.gpuPercent?.let { it / 100f }, StatsColors.gpu, "GPU", s?.gpuTempC?.let { tempLabel(d, it) }) { Big(s?.gpuPercent?.toString() ?: "–", "%", 20.sp, HudInk.value, false, 0.6f) }
Ring(96.dp, s?.cpuPercent?.let { it / 100f }, StatsColors.cpu, "CPU", s?.cpuTempC?.let { tempLabel(d, it) }) { Big(s?.cpuPercent?.toString() ?: "–", "%", 20.sp, HudInk.value, false, 0.6f) }
}
Row(horizontalArrangement = Arrangement.spacedBy(22.dp)) {
Ring(96.dp, s?.ramPercent?.let { it / 100f }, StatsColors.ram, "RAM", s?.let { "${gib(it.ramUsedBytes)} GiB" }) {
Big(s?.ramPercent?.roundToInt()?.toString() ?: "–", "%", 20.sp, HudInk.value, false, 0.6f)
}
Ring(96.dp, s?.battery?.percent?.let { it / 100f }, StatsColors.bat, "BAT", s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f W".format(it) }) {
Big(s?.battery?.percent?.toString() ?: "–", "%", 20.sp, HudInk.value, false, 0.6f)
}
}
}
}
}
@Composable
private fun ColumnScope.Graphs(d: HudData, accent: Color) {
val s = d.device
val frameMs = d.fpsHistory.map { 1000f / max(it, 1f) }
@Composable
fun graphRow(label: String, color: Color, values: List<Float>, hi: Float, value: AnnotatedString) {
Row(Modifier.fillMaxWidth().height(56.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Small(label, 14.sp, color, Modifier.width(62.dp))
Graph(values, 0f, hi, color, Modifier.weight(1f).height(52.dp))
Line(value, 24.sp, Modifier.width(128.dp), TextAlign.End)
}
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.SpaceEvenly) {
graphRow("FPS", accent, d.fpsHistory, max(d.hz.toFloat(), d.fpsHistory.maxOrNull() ?: 0f) * 1.1f, buildAnnotatedString { num(f1(d.fps), "fps", 24.sp, accent, 0.5f) })
graphRow("FRAME", lerp(accent, Color.White, 0.3f), frameMs, max(40f, (frameMs.maxOrNull() ?: 0f) * 1.2f), buildAnnotatedString { num(f1(d.frametimeMs), "ms", 24.sp, unitScale = 0.5f) })
graphRow("CPU", StatsColors.cpu, d.cpuHistory, 100f, buildAnnotatedString { num(s?.cpuPercent?.toString() ?: "–", "%", 24.sp, unitScale = 0.5f) })
graphRow("GPU", StatsColors.gpu, d.gpuHistory, 100f, buildAnnotatedString { num(s?.gpuPercent?.toString() ?: "–", "%", 24.sp, unitScale = 0.5f) })
}
}
@Composable
private fun ColumnScope.Cores(d: HudData, accent: Color) {
val s = d.device
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Big(f1(d.fps), "fps", 40.sp, accent, glow = true, unitScale = 0.38f)
Small(listOfNotNull(d.engine.ifEmpty { null }, d.frametimeMs?.let { "%.1f ms".format(it) },
s?.cpuPercent?.let { "CPU $it%" }, s?.cpuTempC?.let { tempLabel(d, it) }).joinToString(" · "), 15.sp, modifier = Modifier.padding(bottom = 6.dp))
}
Spacer(Modifier.height(10.dp))
val gameTag = stringResource(R.string.stats_hud_game_cores)
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
for (group in d.coreGroups) {
for (core in group.cores) {
val p = s?.perCorePercent?.getOrNull(core)?.takeIf { it >= 0 }
val mhz = s?.perCoreClockMhz?.getOrNull(core)?.takeIf { it > 0 }
Row(Modifier.fillMaxWidth().height(20.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Small("C$core", 14.sp, StatsColors.cpu, Modifier.width(40.dp))
Box(Modifier.weight(1f).height(14.dp).clip(RoundedCornerShape(7.dp)).background(Color.White.copy(alpha = 0.07f))) {
if (p != null && p > 0) Box(
Modifier.fillMaxHeight().fillMaxWidth(p / 100f).clip(RoundedCornerShape(7.dp))
.background(Brush.horizontalGradient(listOf(Color(0xFF2B6FC9), if (p >= 80) HudInk.amber else StatsColors.cpu))),
)
}
Line(buildAnnotatedString { num(p?.toString() ?: "–", "%", 14.sp); append(" "); num(mhz?.toString() ?: "–", "MHz", 14.sp) },
14.sp, Modifier.width(118.dp), TextAlign.End)
}
}
val label = group.maxGhz?.let { stringResource(R.string.stats_hud_core_group, group.cores.size, "%.2f".format(it)) }
?: stringResource(R.string.stats_hud_core_group_unknown, group.cores.size)
Row(Modifier.padding(start = 50.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Small(label, 11.5.sp)
if (d.gameCores.isNotEmpty() && group.cores.any { it in d.gameCores }) {
Text(gameTag.uppercase(), fontSize = 10.sp, fontFamily = Mono, fontWeight = FontWeight.Bold, color = Color(0xFF0A0B0D),
modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(StatsColors.cpu).padding(horizontal = 4.dp))
}
}
}
}
}
@Composable
private fun ColumnScope.Heat(d: HudData) {
val s = d.device
@Composable
fun bar(label: String, color: Color, sensor: HudMetrics.TempSensor, c: Int?) {
val red = d.redC(sensor)
Row(Modifier.fillMaxWidth().height(28.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Small(label, 15.sp, color, Modifier.width(48.dp))
BoxWithConstraints(Modifier.weight(1f).height(20.dp).clip(RoundedCornerShape(10.dp)).background(Color.White.copy(alpha = 0.07f))) {
if (c != null && c > 0) Box(Modifier.fillMaxHeight().fillMaxWidth((c / 100f).coerceIn(0f, 1f)).clip(RoundedCornerShape(10.dp))
.background(Brush.horizontalGradient(listOf(color.copy(alpha = 0.55f), color))))
if (red != null && red in 1..99) Box(Modifier.offset(x = maxWidth * (red / 100f)).width(2.dp).fillMaxHeight().background(Color(0xFFEF5350)))
}
Line(buildAnnotatedString { temp(d, sensor, c, 24.sp) }, 24.sp, Modifier.width(92.dp), TextAlign.End)
}
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
bar("CPU", StatsColors.cpu, HudMetrics.TempSensor.CPU, s?.cpuTempC)
bar("GPU", StatsColors.gpu, HudMetrics.TempSensor.GPU, s?.gpuTempC)
bar("BAT", StatsColors.bat, HudMetrics.TempSensor.BATTERY, s?.battery?.tempC)
}
Small(stringResource(R.string.stats_hud_red_mark), 12.sp, modifier = Modifier.padding(top = 8.dp))
Spacer(Modifier.weight(1f))
Row(Modifier.fillMaxWidth().height(96.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Tile("FAN", HudInk.dim, buildAnnotatedString { num(d.fanRpm?.toString() ?: "–", "", 28.sp) }, "rpm")
Tile("POWER", StatsColors.bat, buildAnnotatedString { num(s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–", "W", 28.sp, unitScale = 0.5f) },
stringResource(R.string.stats_hud_whole_device))
Tile("BATTERY", StatsColors.bat, buildAnnotatedString { num(s?.battery?.percent?.toString() ?: "–", "%", 28.sp, unitScale = 0.5f) }, s?.battery?.runtimeText ?: "")
}
}
@Composable
private fun ColumnScope.GameCard(d: HudData, accent: Color) {
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
Box(
Modifier.width(150.dp).fillMaxHeight().clip(RoundedCornerShape(10.dp))
.background(Brush.linearGradient(listOf(lerp(accent, Color.Black, 0.55f), Color(0xFF0B0D10)))),
contentAlignment = Alignment.BottomStart,
) {
if (d.cover != null) AsyncImage(model = d.cover, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
else Text(d.title.orEmpty(), fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color.White.copy(alpha = 0.85f), maxLines = 3,
modifier = Modifier.padding(12.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(d.title.orEmpty(), fontSize = 26.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 2, overflow = TextOverflow.Ellipsis)
d.played?.let { Small(stringResource(R.string.stats_hud_playing_for, it), 13.5.sp) }
Line(buildAnnotatedString {
withStyle(SpanStyle(color = accent)) { append(d.engine) }
if (d.engine.isNotEmpty() && d.proton != null) withStyle(SpanStyle(color = HudInk.dim)) { append(" · ") }
d.proton?.let { withStyle(SpanStyle(color = HudInk.vram)) { append(it) } }
}, 13.5.sp)
if (d.resolution.isNotEmpty()) Small(d.resolution, 13.5.sp)
Big(f1(d.fps), "fps", 60.sp, accent, glow = true, unitScale = 0.3f, modifier = Modifier.padding(top = 6.dp))
Line(lowsText(d, 14.sp, accent, deep = false), 14.sp)
}
}
Spacer(Modifier.height(10.dp))
FrameGraph(d, accent, 40.dp)
}
@Composable
private fun ColumnScope.Pacing(d: HudData, accent: Color) {
@Composable
fun stat(label: String, value: String, color: Color = HudInk.value) {
Column {
Small(label, 13.sp)
Text(value, fontSize = 22.sp, fontFamily = Mono, fontWeight = FontWeight.Bold, color = color, maxLines = 1)
}
}
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(24.dp)) {
Big(f1(d.fps), "fps", 52.sp, accent, glow = true, unitScale = 0.3f)
stat(stringResource(R.string.stats_hud_avg_frame), d.frametimeMs?.let { "%.1f ms".format(it) } ?: "–")
stat(stringResource(R.string.stats_hud_low), f1(d.lows.low1Fps))
val slow = d.slowPerMinute
stat(stringResource(R.string.stats_hud_slow_frames), slow?.let { stringResource(R.string.stats_hud_per_minute, it) } ?: "–",
if ((slow ?: 0) > 0) HudInk.amber else HudInk.value)
}
Spacer(Modifier.height(12.dp))
val target = 1000f / d.hz
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
val graphColor = lerp(accent, Color.White, 0.3f)
Canvas(Modifier.fillMaxSize()) {
val axis = Color.White.copy(alpha = 0.12f)
drawLine(axis, Offset(0f, 0f), Offset(0f, size.height), 1.dp.toPx())
drawLine(axis, Offset(0f, size.height), Offset(size.width, size.height), 1.dp.toPx())
val dash = PathEffect.dashPathEffect(floatArrayOf(6.dp.toPx(), 5.dp.toPx()))
for ((ms, color) in listOf(target to Color.White.copy(alpha = 0.25f), target * 2 to HudInk.amber.copy(alpha = 0.5f))) {
val y = size.height * (1f - ms / FrameStats.GRAPH_MAX_MS)
drawLine(color, Offset(0f, y), Offset(size.width, y), 1.dp.toPx(), pathEffect = dash)
}
}
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize())
for ((ms, color) in listOf(target to HudInk.dim, target * 2 to HudInk.amber)) {
Small("%.1f ms · %d fps".format(ms, (1000f / ms).roundToInt()), 11.sp, color,
Modifier.align(Alignment.TopEnd).offset(y = maxHeight * (1f - ms / FrameStats.GRAPH_MAX_MS) - 17.dp))
}
}
}
@Composable
private fun ColumnScope.Night(d: HudData, dim: Boolean) {
val clockInk = if (dim) Color(0xFF6F7A87) else HudInk.value
val fpsInk = if (dim) Color(0xFF4D7FA8) else LocalPalette.current.primary
Column(Modifier.weight(1f), verticalArrangement = Arrangement.Center) {
Text(d.clock, fontSize = 88.sp, fontWeight = FontWeight.Light, color = clockInk, maxLines = 1, softWrap = false)
Line(buildAnnotatedString {
num(f1(d.fps), "fps", 30.sp, fpsInk)
d.device?.battery?.percent?.let {
withStyle(SpanStyle(color = HudInk.sep)) { append(" · ") }
num(it.toString(), "%", 30.sp, if (dim) Color(0xFF6B5A44) else StatsColors.bat)
}
}, 30.sp, Modifier.padding(top = 14.dp))
}
}
@Composable
private fun ColumnScope.Mega(d: HudData, accent: Color) {
val s = d.device
val z = 13.5.sp
val left = mutableListOf(
row("GPU", StatsColors.gpu, AnnotatedString(d.gpuName)),
row("GPU", StatsColors.gpu, buildAnnotatedString {
num(s?.gpuPercent?.toString() ?: "–", "%", z); gap(); temp(d, HudMetrics.TempSensor.GPU, s?.gpuTempC, z); gap(); num(s?.gpuClockMhz?.toString() ?: "–", "MHz", z)
}),
row("CPU", StatsColors.cpu, buildAnnotatedString {
num(s?.cpuPercent?.toString() ?: "–", "%", z); gap(); temp(d, HudMetrics.TempSensor.CPU, s?.cpuTempC, z); gap(); num(s?.cpuClockMhz?.toString() ?: "–", "MHz", z)
}),
)
val cores = s?.perCorePercent?.size ?: 0
for (i in 0 until cores) {
left += row("C$i", StatsColors.cpu, buildAnnotatedString {
num(s?.perCorePercent?.getOrNull(i)?.takeIf { it >= 0 }?.toString() ?: "–", "%", z); gap()
num(s?.perCoreClockMhz?.getOrNull(i)?.takeIf { it > 0 }?.toString() ?: "–", "MHz", z)
})
}
val right = listOf(
row("VRAM", HudInk.vram, buildAnnotatedString { num(gib(s?.vramUsedBytes), "GiB", z) }),
row("RAM", StatsColors.ram, buildAnnotatedString { num("${gib(s?.ramUsedBytes)}/${gib(s?.ramTotalBytes)}", "GiB", z); gap(); num(s?.ramPercent?.roundToInt()?.toString() ?: "–", "%", z) }),
row("SWP", StatsColors.ram, buildAnnotatedString { num("${gib(s?.swapUsedBytes)}/${gib(s?.swapTotalBytes)}", "GiB", z) }),
row("NET", accent, buildAnnotatedString {
withStyle(SpanStyle(color = HudInk.dim)) { append("↓") }; num(s?.netDownBps?.let { (it / 1024).toString() } ?: "–", "", z)
append(" "); withStyle(SpanStyle(color = HudInk.dim)) { append("↑") }; num(s?.netUpBps?.let { (it / 1024).toString() } ?: "–", "KB/s", z)
}),
row("BAT", StatsColors.bat, buildAnnotatedString {
num(s?.battery?.percent?.toString() ?: "–", "%", z); gap(); temp(d, HudMetrics.TempSensor.BATTERY, s?.battery?.tempC, z); gap()
num(s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–", "W", z)
}),
row("FAN", HudInk.dim, buildAnnotatedString { num(d.fanRpm?.toString() ?: "–", "rpm", z) }),
row(d.engine.ifEmpty { "FPS" }, accent, buildAnnotatedString { num(f1(d.fps), "FPS", z, accent); gap(); num(f1(d.frametimeMs), "ms", z) }),
row("AVG", HudInk.low, buildAnnotatedString { num(f1(d.avgFps), "FPS", z) }),
row("1%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low1Fps), "FPS", z) }),
row("0.1%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low01Fps), "FPS", z) }),
row("0.01%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low001Fps), "FPS", z) }),
)
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
Rows(left, z, 44.dp, 3.dp)
Rows(right, z, 58.dp, 3.dp)
}
Spacer(Modifier.weight(1f))
Line(buildAnnotatedString {
withStyle(SpanStyle(color = HudInk.dim)) { append("RES ") }; append(d.resolution.ifEmpty { "–" })
d.proton?.let { withStyle(SpanStyle(color = HudInk.dim)) { append(" · ") }; withStyle(SpanStyle(color = HudInk.vram)) { append(it) } }
d.played?.let { withStyle(SpanStyle(color = HudInk.dim)) { append(" · ") }; append(it) }
}, 12.5.sp, Modifier.padding(bottom = 6.dp))
FrameGraph(d, accent, 30.dp)
}
@@ -36,18 +36,23 @@ import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.hud.DroidDeckMark
import com.droiddeck.launcher.hud.FrameStats
import com.droiddeck.launcher.hud.HudMetrics
import com.droiddeck.launcher.session.StatsStrip
import com.droiddeck.launcher.session.StripMetric
import kotlin.math.max
import kotlin.math.roundToInt
@@ -66,11 +71,21 @@ internal object StatsColors {
internal class History(private val capacity: Int) {
private val values = ArrayDeque<Float>(capacity)
fun add(v: Float) {
if (values.size == capacity) values.removeFirst()
private val times = ArrayDeque<Long>(capacity)
fun add(v: Float, atMs: Long = 0L) {
if (values.size == capacity) {
values.removeFirst()
times.removeFirst()
}
values.addLast(v)
times.addLast(atMs)
}
fun snapshot(): List<Float> = values.toList()
fun spanSeconds(): Int {
val first = times.firstOrNull()?.takeIf { it > 0L } ?: return 0
val last = times.last()
return ((last - first + 500L) / 1000L).toInt()
}
}
private val MARK_RATIO = DroidDeckMark.widthFor(1f)
@@ -144,6 +159,12 @@ internal class StripData(
val wattsHistory: List<Float>,
val clock: String,
val game: String? = null,
val engine: String = "",
val lows: FrameStats.Lows? = null,
val graph: FloatArray? = null,
val metrics: Set<StripMetric> = StripMetric.defaults,
val fahrenheit: Boolean = false,
val tempColor: ((HudMetrics.TempSensor, Int) -> Color)? = null,
)
private fun percentTemp(percent: Int?, temp: Int?): String =
@@ -158,6 +179,7 @@ private fun fpsText(fps: Float?): String = fps?.takeIf { it > 0f }?.let { "%.1f"
internal fun StatsStripView(style: StatsStrip, d: StripData, onTap: () -> Unit) {
Box(Modifier.fillMaxWidth().clickable(onClick = onTap)) {
when (style) {
StatsStrip.LINE -> LineStrip(d)
StatsStrip.PANEL -> PanelStrip(d)
StatsStrip.TILES -> TilesStrip(d)
StatsStrip.RINGS -> RingsStrip(d)
@@ -165,6 +187,113 @@ internal fun StatsStripView(style: StatsStrip, d: StripData, onTap: () -> Unit)
}
}
private object LineInk {
val value = Color(0xFFF2F5F9)
val dim = Color(0xFF9AA4B2)
val vram = Color(0xFFC98BFF)
}
private class LineItem(val priority: Int, val content: @Composable () -> Unit)
@Composable
internal fun LineStrip(d: StripData, modifier: Modifier = Modifier) {
val pal = LocalPalette.current
val shape = RoundedCornerShape(12.dp)
val m = d.metrics
val s = d.device
val items = ArrayList<LineItem>()
items += LineItem(0) { DroidDeckLogo(16.dp) }
if (StripMetric.ENGINE in m && d.engine.isNotEmpty()) items += LineItem(70) { LineText(d.engine, LineInk.dim, 12.sp) }
items += LineItem(0) {
Row(verticalAlignment = Alignment.Bottom) {
LineText(fpsText(d.fps), LineInk.value, 17.sp)
LineText("fps", LineInk.dim, 10.sp, Modifier.padding(start = 2.dp, bottom = 2.dp))
}
}
val graph = d.graph
if (StripMetric.GRAPH in m && graph != null) items += LineItem(60) {
Sparkline(graph.asList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(pal.primary, Color.White, 0.3f), Modifier.width(56.dp).height(16.dp))
}
if (StripMetric.LOWS in m) {
items += LineItem(10) { LinePair("1%", LineInk.dim, lowText(d.lows?.low1Fps), StatsColors.cpu) }
items += LineItem(90) { LinePair("0.1%", LineInk.dim, lowText(d.lows?.low01Fps), StatsColors.cpu) }
}
if (StripMetric.LOW001 in m) items += LineItem(100) { LinePair("0.01%", LineInk.dim, lowText(d.lows?.low001Fps), StatsColors.cpu) }
if (StripMetric.GPU in m || StripMetric.GPU_TEMP in m) items += LineItem(20) {
LineMetric("GPU", StatsColors.gpu, if (StripMetric.GPU in m) s?.gpuPercent else null,
if (StripMetric.GPU_TEMP in m) s?.gpuTempC else null, HudMetrics.TempSensor.GPU, d)
}
if (StripMetric.CPU in m || StripMetric.CPU_TEMP in m) items += LineItem(30) {
LineMetric("CPU", StatsColors.cpu, if (StripMetric.CPU in m) s?.cpuPercent else null,
if (StripMetric.CPU_TEMP in m) s?.cpuTempC else null, HudMetrics.TempSensor.CPU, d)
}
if (StripMetric.VRAM in m) items += LineItem(80) { LinePair("VRAM", LineInk.vram, gibText(s?.vramUsedBytes), LineInk.vram) }
if (StripMetric.RAM in m) items += LineItem(40) { LinePair("RAM", StatsColors.ram, s?.let { "${it.ramPercent.roundToInt()}%" } ?: "–", StatsColors.ram) }
val clock = StripMetric.CLOCK in m && d.clock.isNotEmpty()
if (clock) items += LineItem(50) { LineText(d.clock, LineInk.dim, 12.sp) }
PriorityRow(
IntArray(items.size) { items[it].priority }, 10.dp, pinLast = clock,
modifier = modifier.fillMaxWidth().clip(shape).background(pal.surface).border(1.dp, pal.primary.copy(alpha = 0.35f), shape)
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
items.forEach { it.content() }
}
}
@Composable
private fun PriorityRow(priorities: IntArray, spacing: Dp, pinLast: Boolean, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(content, modifier) { measurables, constraints ->
val gap = spacing.roundToPx()
val loose = Constraints(maxHeight = constraints.maxHeight)
val placeables = measurables.map { it.measure(loose) }
val max = if (constraints.hasBoundedWidth) constraints.maxWidth else Int.MAX_VALUE
val keep = fitByPriority(IntArray(placeables.size) { placeables[it].width }, priorities.copyOf(placeables.size), gap, max)
val height = maxOf(constraints.minHeight, placeables.filterIndexed { i, _ -> keep[i] }.maxOfOrNull { it.height } ?: 0)
val used = placeables.filterIndexed { i, _ -> keep[i] }.sumOf { it.width } + gap * maxOf(0, keep.count { it } - 1)
val width = if (constraints.hasBoundedWidth) constraints.maxWidth else used
layout(width, height) {
var x = 0
val last = placeables.lastIndex
placeables.forEachIndexed { i, p ->
if (!keep[i]) return@forEachIndexed
val px = if (pinLast && i == last) width - p.width else x
p.placeRelative(px, (height - p.height) / 2)
x += p.width + gap
}
}
}
}
@Composable
private fun LineText(text: String, color: Color, size: TextUnit = 13.sp, modifier: Modifier = Modifier) {
Text(text, fontSize = size, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = color, maxLines = 1, softWrap = false,
modifier = modifier)
}
@Composable
private fun LinePair(label: String, labelColor: Color, value: String, valueColor: Color) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
LineText(label, labelColor, 11.sp)
LineText(value, valueColor)
}
}
@Composable
private fun LineMetric(label: String, color: Color, percent: Int?, tempC: Int?, sensor: HudMetrics.TempSensor, d: StripData) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
LineText(label, color, 11.sp)
if (percent == null && tempC == null) LineText("–", color)
if (percent != null) LineText("$percent%", color)
if (tempC != null) LineText(tempText(tempC, d.fahrenheit), d.tempColor?.invoke(sensor, tempC) ?: LineInk.value)
}
}
private fun lowText(fps: Float?): String = fps?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–"
private fun gibText(bytes: Long?): String = bytes?.let { "%.1fGiB".format(it / 1073741824.0) } ?: "–"
private fun tempText(c: Int, fahrenheit: Boolean): String = if (fahrenheit) "${(c * 9f / 5f + 32f).roundToInt()}°F" else "$c°C"
@Composable
private fun PanelStrip(d: StripData) {
val pal = LocalPalette.current
@@ -0,0 +1,15 @@
package com.droiddeck.launcher.ui
internal fun fitByPriority(widths: IntArray, priorities: IntArray, spacing: Int, maxWidth: Int): BooleanArray {
val keep = BooleanArray(widths.size)
var used = 0
var count = 0
for (i in widths.indices.sortedWith(compareBy({ priorities[it] }, { it }))) {
val add = widths[i] + if (count > 0) spacing else 0
if (used + add > maxWidth) break
keep[i] = true
used += add
count++
}
return keep
}
+22 -1
View File
@@ -1946,7 +1946,21 @@
<string name="stats_fuel_t">Fuel, t</string>
<string name="stats_fuel_main">Main %1$s t</string>
<string name="stats_fuel_reserve">Reserve %1$s t</string>
<string name="stats_tap_hint">Tap the top strip or the dash to change its style.</string>
<string name="stats_tap_hint">Tap the HUD, the top strip or the dash to change its style.</string>
<string name="stats_hud_frametime">Frame time</string>
<string name="stats_hud_last_seconds">last %1$d s</string>
<string name="stats_hud_avg_frame">avg frame</string>
<string name="stats_hud_low">1 percent low</string>
<string name="stats_hud_slow_frames">frames over 33 ms</string>
<string name="stats_hud_per_minute">%1$d / min</string>
<string name="stats_hud_playing_for">Playing for %1$s</string>
<string name="stats_hud_core_group">%1$d cores, up to %2$s GHz</string>
<string name="stats_hud_core_group_unknown">%1$d cores</string>
<string name="stats_hud_game_cores">game</string>
<string name="stats_hud_fps_of">FPS of %1$d Hz</string>
<string name="stats_hud_red_mark">The red mark is where the HUD shows the temperature in red.</string>
<string name="stats_hud_whole_device">whole device</string>
<string name="stats_hud_battery_temp">battery</string>
<string name="bottom_telemetry_send_title">Send dashboard data</string>
<string name="bottom_telemetry_send_auto">Automatic</string>
<string name="bottom_telemetry_send_auto_hint">Turns it on for every game listed here, including games found later. Games you switch off stay off.</string>
@@ -1970,6 +1984,13 @@
<string name="bottom_chat_mic_unavailable">Mic is off for this game. To talk, turn on Microphone in DroidDeck\'s Steam settings (Audio) and start the game again.</string>
<string name="bottom_chat_volume">Volume %1$d%%</string>
<string name="bottom_page_telemetry">In-game stats</string>
<string name="bottom_hud_group">Bottom screen HUD</string>
<string name="bottom_hud_group_detail">Works with the top HUD off and does not change it. The number switches below are for the Line strip: under the tabs, and the Line style above a game\'s dashboard.</string>
<string name="bottom_hud_hint">The big HUD, the Line strip and the strip under the tabs. A game\'s dashboard stays.</string>
<string name="bottom_strip_tabs">Strip on every tab</string>
<string name="bottom_strip_tabs_hint">Shows the line under the tabs while you use the bottom screen.</string>
<string name="bottom_strip_lows" formatted="false">1% and 0.1% lows</string>
<string name="bottom_strip_cpu_temp">CPU temperature</string>
<string name="bottom_telemetry_fps">FPS</string>
<string name="bottom_telemetry_cpu">CPU</string>
<string name="bottom_telemetry_gpu">GPU</string>
@@ -68,6 +68,30 @@ class FrameStatsTest {
assertFalse(feed.poll(now + 1))
}
@Test fun slowFramesCountOnlyFramesOver33MsInTheWindow() {
val stats = FrameStats()
var now = 1_000_000_000L
for (ft in listOf(16_666_667L, 40_000_000L, 16_666_667L, 50_000_000L, 2_000_000_000L, 16_666_667L)) {
now += ft
stats.add(ft, now)
}
assertEquals(2, stats.slowFrames(now, 60_000_000_000L))
assertEquals(0, stats.slowFrames(now + 120_000_000_000L, 60_000_000_000L))
stats.reset()
assertEquals(0, stats.slowFrames(now, 60_000_000_000L))
}
@Test fun compositorFramesDoNotCountAsLiveGamescopeFrames() {
val feed = GameFrameFeed(java.io.File("/nonexistent/droiddeck-hud/frames"))
var now = 1_000_000_000L
assertFalse(feed.live)
feed.onCompositorFrame(now)
now += 16_666_667L
feed.onCompositorFrame(now)
feed.poll(now)
assertFalse(feed.live)
}
@Test fun aCompletedPeriodIsCountedWhenMangoHudWouldRedraw() {
val stats = FrameStats()
var now = 1_000_000_000L
@@ -5,16 +5,30 @@ import org.junit.Test
class StatsLayoutTest {
@Test fun unknownIdsFallBackToTheDefaults() {
assertEquals(StatsStrip.PANEL, StatsStrip.from(null))
assertEquals(StatsStrip.LINE, StatsStrip.from(null))
assertEquals(StatsStrip.LINE, StatsStrip.from("gone"))
assertEquals(StatsStrip.PANEL, StatsStrip.from("panel"))
assertEquals(StatsStrip.RINGS, StatsStrip.from("rings"))
assertEquals(StatsDash.AUTO, StatsDash.from("gone"))
assertEquals(StatsDash.TRACE, StatsDash.from("trace"))
}
@Test fun tappingTheStripCyclesThroughEveryStyle() {
assertEquals(StatsStrip.PANEL, StatsStrip.LINE.next())
assertEquals(StatsStrip.TILES, StatsStrip.PANEL.next())
assertEquals(StatsStrip.RINGS, StatsStrip.TILES.next())
assertEquals(StatsStrip.PANEL, StatsStrip.RINGS.next())
assertEquals(StatsStrip.LINE, StatsStrip.RINGS.next())
}
@Test fun stripNumbersStartWithEverythingButTheGpuTemperature() {
assertEquals(StripMetric.entries.toSet() - StripMetric.GPU_TEMP, StripMetric.parse(null))
}
@Test fun stripNumbersSurviveSavingAndSkipUnknownIds() {
val picked = setOf(StripMetric.GPU, StripMetric.RAM, StripMetric.CLOCK)
assertEquals(picked, StripMetric.parse(StripMetric.encode(picked)))
assertEquals(setOf(StripMetric.CPU), StripMetric.parse("cpu,gone, "))
assertEquals(emptySet<StripMetric>(), StripMetric.parse(""))
}
@Test fun autoPicksTheDashByTheGamesFormat() {
@@ -0,0 +1,33 @@
package com.droiddeck.launcher.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class HistoryTest {
@Test fun theSpanIsTheTimeTheKeptReadingsCover() {
val h = History(3)
h.add(1f, 1_000L)
h.add(2f, 1_500L)
assertEquals(1, h.spanSeconds())
h.add(3f, 2_000L)
h.add(4f, 4_000L)
assertEquals(listOf(2f, 3f, 4f), h.snapshot())
assertEquals(3, h.spanSeconds())
}
@Test fun aHistoryThatStopsBeingFedKeepsItsSpan() {
val h = History(4)
h.add(1f, 10_000L)
h.add(2f, 20_000L)
assertEquals(10, h.spanSeconds())
assertEquals(10, h.spanSeconds())
}
@Test fun readingsWithoutATimeHaveNoSpan() {
val h = History(2)
h.add(1f)
assertEquals(0, h.spanSeconds())
assertEquals(0, History(2).spanSeconds())
assertEquals(0, History(2).apply { add(1f, 5_000L) }.spanSeconds())
}
}
@@ -0,0 +1,26 @@
package com.droiddeck.launcher.ui
import org.junit.Assert.assertArrayEquals
import org.junit.Test
class StripFitTest {
@Test fun everythingFitsOnAWideLine() {
assertArrayEquals(booleanArrayOf(true, true, true), fitByPriority(intArrayOf(10, 20, 30), intArrayOf(0, 1, 2), 5, 100))
}
@Test fun theLeastImportantItemsAreLeftOutFirst() {
assertArrayEquals(booleanArrayOf(true, false, true), fitByPriority(intArrayOf(10, 20, 30), intArrayOf(0, 2, 1), 5, 60))
}
@Test fun anItemThatDoesNotFitEndsTheLine() {
assertArrayEquals(booleanArrayOf(true, false, false), fitByPriority(intArrayOf(10, 50, 5), intArrayOf(0, 1, 2), 2, 40))
}
@Test fun equalPrioritiesKeepTheirOrder() {
assertArrayEquals(booleanArrayOf(true, true, false), fitByPriority(intArrayOf(10, 10, 10), intArrayOf(0, 0, 0), 0, 25))
}
@Test fun nothingFitsInNoRoom() {
assertArrayEquals(booleanArrayOf(false, false), fitByPriority(intArrayOf(10, 10), intArrayOf(0, 1), 0, 5))
}
}