mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 19:41:10 +00:00
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:
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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user