mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(desktop): collapse icon decoding to a single memoized lazy
Phase 5.1 of the launch-optimization plan: the cold-boot critical path loaded /icon.png up to four separate times (taskbar setup, Window icon, Tor splash, account-loading splash). Two of those sites also paid an ImageIO.read to obtain a BufferedImage, and the Window-icon site additionally round-tripped the image back through ImageIO.write so Skia could re-decode it. IconResources holds one lazy each for the bytes, the decoded BufferedImage, the platform-adapted BufferedImage (squircle on macOS), and the two BitmapPainters (raw + adapted). All four call sites in Main.kt now consume the cached values directly — no remember, no re-decode. IconResourcesTest pins the memoization invariants (same instance on repeated access). All 271 desktopApp tests pass. End-to-end delta vs the baseline will be measured once Phase 3 benchmarks land; the worst-case savings on cold boot are two ImageIO.read calls plus three resource reads plus one ImageIO.write, all on the main thread.
This commit is contained in:
@@ -63,8 +63,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyShortcut
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -216,13 +214,7 @@ fun main() {
|
||||
// on macOS the logo is then wrapped in a squircle so it matches
|
||||
// first-party dock icons.
|
||||
try {
|
||||
val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes()
|
||||
val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
|
||||
val adapted =
|
||||
raw?.let {
|
||||
com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon
|
||||
.adaptForHost(it)
|
||||
}
|
||||
val adapted = com.vitorpamplona.amethyst.desktop.platform.IconResources.adaptedBufferedImage
|
||||
if (adapted != null && java.awt.Taskbar.isTaskbarSupported()) {
|
||||
val taskbar = java.awt.Taskbar.getTaskbar()
|
||||
if (taskbar.isSupported(java.awt.Taskbar.Feature.ICON_IMAGE)) {
|
||||
@@ -297,21 +289,7 @@ fun main() {
|
||||
// Window title-bar / taskbar thumbnail icon. On macOS the source logo
|
||||
// is wrapped in a squircle so it matches every other dock icon; on
|
||||
// other platforms the raw transparent logo is used as-is.
|
||||
val appIcon =
|
||||
remember {
|
||||
val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes()
|
||||
val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
|
||||
val adapted =
|
||||
com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon
|
||||
.adaptForHost(raw)
|
||||
val buf = java.io.ByteArrayOutputStream()
|
||||
javax.imageio.ImageIO.write(adapted, "png", buf)
|
||||
val bitmap =
|
||||
org.jetbrains.skia.Image
|
||||
.makeFromEncoded(buf.toByteArray())
|
||||
.toComposeImageBitmap()
|
||||
BitmapPainter(bitmap)
|
||||
}
|
||||
val appIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.adaptedBitmapPainter
|
||||
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
@@ -713,15 +691,7 @@ fun App(
|
||||
val torStatus by torManager.status.collectAsState()
|
||||
val isTorExpected = torSettings.torType != com.vitorpamplona.amethyst.commons.tor.TorType.OFF
|
||||
if (isTorExpected && torStatus !is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Active) {
|
||||
val splashIcon =
|
||||
remember {
|
||||
val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes()
|
||||
val bitmap =
|
||||
org.jetbrains.skia.Image
|
||||
.makeFromEncoded(bytes)
|
||||
.toComposeImageBitmap()
|
||||
BitmapPainter(bitmap)
|
||||
}
|
||||
val splashIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier =
|
||||
androidx.compose.ui.Modifier
|
||||
@@ -984,15 +954,7 @@ fun App(
|
||||
when (accountState) {
|
||||
is AccountState.Loading -> {
|
||||
// Branded loading screen while accounts load from storage
|
||||
val loadingIcon =
|
||||
remember {
|
||||
val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes()
|
||||
val bitmap =
|
||||
org.jetbrains.skia.Image
|
||||
.makeFromEncoded(bytes)
|
||||
.toComposeImageBitmap()
|
||||
BitmapPainter(bitmap)
|
||||
}
|
||||
val loadingIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.platform
|
||||
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import org.jetbrains.skia.Image
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
/**
|
||||
* Lazily-memoized icon resources shared across all consumers of `/icon.png`.
|
||||
*
|
||||
* Before this object existed the desktop launch path read and decoded the
|
||||
* icon four separate times on the cold-boot critical path (taskbar setup,
|
||||
* Window icon, Tor splash, account-loading splash). The taskbar and Window
|
||||
* sites also paid an `ImageIO.read` to obtain a `BufferedImage` before
|
||||
* either passing it straight to `Taskbar.iconImage` (taskbar) or
|
||||
* round-tripping it back through `ImageIO.write` so Skia can decode it
|
||||
* (Window icon).
|
||||
*
|
||||
* This object collapses the work to one resource read plus at most one
|
||||
* decode per output shape. All properties are `lazy { ... }` so the cost
|
||||
* is paid only when first observed.
|
||||
*
|
||||
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md
|
||||
* § Phase 5.1.
|
||||
*/
|
||||
object IconResources {
|
||||
/** Raw PNG bytes read from the `/icon.png` classpath resource. */
|
||||
val iconBytes: ByteArray by lazy {
|
||||
IconResources::class.java.getResourceAsStream("/icon.png")!!.readBytes()
|
||||
}
|
||||
|
||||
/** Decoded `BufferedImage` for AWT consumers (Taskbar, Window icon source). */
|
||||
val rawBufferedImage: BufferedImage by lazy {
|
||||
ImageIO.read(ByteArrayInputStream(iconBytes))
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-adapted icon (squircle on macOS, raw transparent PNG elsewhere).
|
||||
* Returns `null` when adaptation fails or is unsupported — callers should
|
||||
* fall back to [rawBufferedImage].
|
||||
*/
|
||||
val adaptedBufferedImage: BufferedImage? by lazy {
|
||||
PlatformAppIcon.adaptForHost(rawBufferedImage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose `BitmapPainter` for the raw PNG bytes. Used by splash screens
|
||||
* (Tor connecting, account loading) that paint the un-adapted logo with
|
||||
* a Material `tint`.
|
||||
*/
|
||||
val rawBitmapPainter: BitmapPainter by lazy {
|
||||
BitmapPainter(Image.makeFromEncoded(iconBytes).toComposeImageBitmap())
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose `BitmapPainter` for the platform-adapted icon. Used by the
|
||||
* main `Window(icon = …)` parameter so the title-bar / taskbar thumbnail
|
||||
* matches the dock icon shape on macOS.
|
||||
*/
|
||||
val adaptedBitmapPainter: BitmapPainter by lazy {
|
||||
val adapted = adaptedBufferedImage ?: rawBufferedImage
|
||||
val buf = ByteArrayOutputStream()
|
||||
ImageIO.write(adapted, "png", buf)
|
||||
BitmapPainter(Image.makeFromEncoded(buf.toByteArray()).toComposeImageBitmap())
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.platform
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Phase 5.1 of the launch-optimization plan: pin the memoization invariants
|
||||
* of [IconResources]. The launch path previously decoded the same `/icon.png`
|
||||
* resource up to four times during a single cold boot; this object collapses
|
||||
* the work and the tests below assert that the lazy holders return the same
|
||||
* cached instance on subsequent accesses.
|
||||
*
|
||||
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md
|
||||
* § Phase 5.1.
|
||||
*/
|
||||
class IconResourcesTest {
|
||||
@Test
|
||||
fun iconBytesAreMemoized() {
|
||||
val first = IconResources.iconBytes
|
||||
val second = IconResources.iconBytes
|
||||
assertSame(first, second, "Raw PNG byte array must be the same instance across calls")
|
||||
assertTrue(first.isNotEmpty(), "Bundled /icon.png must not be empty")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawBufferedImageIsMemoized() {
|
||||
val first = IconResources.rawBufferedImage
|
||||
val second = IconResources.rawBufferedImage
|
||||
assertSame(first, second, "Decoded BufferedImage must be the same instance across calls")
|
||||
assertTrue(first.width > 0 && first.height > 0, "Decoded image must have positive dimensions")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawBitmapPainterIsMemoized() {
|
||||
val first = IconResources.rawBitmapPainter
|
||||
val second = IconResources.rawBitmapPainter
|
||||
assertSame(first, second, "Raw BitmapPainter must be the same instance across calls")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun adaptedBitmapPainterIsMemoized() {
|
||||
val first = IconResources.adaptedBitmapPainter
|
||||
val second = IconResources.adaptedBitmapPainter
|
||||
assertSame(first, second, "Adapted BitmapPainter must be the same instance across calls")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun adaptedBufferedImageEitherProducesAValueOrIsNull() {
|
||||
// On macOS this returns a squircle; on other platforms it may return null
|
||||
// when PlatformAppIcon.adaptForHost is a no-op. Either is acceptable — we
|
||||
// just verify the lazy doesn't throw.
|
||||
val adapted = IconResources.adaptedBufferedImage
|
||||
// Accessing twice must yield the same instance (or both null).
|
||||
assertSame(adapted, IconResources.adaptedBufferedImage)
|
||||
if (adapted != null) {
|
||||
assertNotNull(adapted, "If adaptation returns non-null, it must be a valid image")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user