feat(desktop): wrap logo in macOS squircle so dock icon looks native

macOS Big Sur+ mandates the squircle icon template for dock / Cmd+Tab —
bare transparent logos stick out next to first-party apps. PlatformAppIcon
wraps the source logo in a 1024x1024 white squircle with 10% padding at
runtime, only when the host OS is macOS (PlatformInfo.host, not .current
— the override is for in-app theming, the dock is drawn by the real OS).

No extra image file: Java2D renders the squircle from the existing
icon.png so Linux / Windows paths stay untouched (they still get the
raw transparent logo, which is the right call for GNOME and matches
Windows 11 taskbar rendering).

Apple's true shape is a superellipse; Java2D lacks a primitive for it,
so RoundRectangle2D with 22.5% corner radius is the practical approx
(invisible difference at dock icon sizes). Applied to both
java.awt.Taskbar.iconImage (dock) and Window(icon=) (title-bar thumb).

https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
This commit is contained in:
Claude
2026-04-24 13:55:38 +00:00
parent c6cb644103
commit e83e4c4663
2 changed files with 123 additions and 8 deletions
@@ -202,14 +202,21 @@ fun main() {
// iconImage, NOT the Window(icon=) composable parameter (which only sets
// the in-title-bar proxy icon). Without this, a JVM launched via gradle
// shows the generic Java coffee-cup square in Cmd+Tab. ImageIO preserves
// the PNG alpha channel so the dock renders the logo with transparency.
// the PNG alpha channel so the dock renders the logo with transparency;
// 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 awtImage = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
if (awtImage != null && java.awt.Taskbar.isTaskbarSupported()) {
val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
val adapted =
raw?.let {
com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon
.adaptForHost(it)
}
if (adapted != null && java.awt.Taskbar.isTaskbarSupported()) {
val taskbar = java.awt.Taskbar.getTaskbar()
if (taskbar.isSupported(java.awt.Taskbar.Feature.ICON_IMAGE)) {
taskbar.iconImage = awtImage
taskbar.iconImage = adapted
}
}
} catch (e: Exception) {
@@ -274,15 +281,21 @@ fun main() {
// Callback set by App() for single pane navigation from MenuBar
var navigateToScreen by remember { mutableStateOf<((DeckColumnType) -> Unit)?>(null) }
// Transparent 512x512 PNG shown in the macOS dock / Windows taskbar / GNOME
// & KDE task switchers while running via gradle (the packaged app uses the
// icon.icns / icon.ico configured in nativeDistributions).
// 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(bytes)
.makeFromEncoded(buf.toByteArray())
.toComposeImageBitmap()
BitmapPainter(bitmap)
}
@@ -0,0 +1,102 @@
/*
* 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 java.awt.AlphaComposite
import java.awt.Color
import java.awt.RenderingHints
import java.awt.geom.RoundRectangle2D
import java.awt.image.BufferedImage
/**
* Adapts a transparent source logo to the host OS's app-icon conventions.
*
* - macOS (Big Sur+) requires a "squircle" app icon template: the mark sits on
* a rounded-square background at ~22.5% corner radius with padding around the
* edges. A raw transparent logo in the dock looks out-of-place next to
* first-party apps, so we wrap it in a white squircle when running on macOS.
* - Other platforms return the source image unchanged Windows and most Linux
* DEs render transparent icons fine, and GNOME apps specifically tend to
* avoid background shapes.
*/
object PlatformAppIcon {
/**
* Wraps [source] in a macOS-style squircle if the host OS is macOS; returns
* the source unchanged otherwise. Not scaled by [PlatformInfo.current] (the
* preview override) the dock is drawn by the real host OS, so only the
* real host platform matters here.
*/
fun adaptForHost(source: BufferedImage): BufferedImage =
when (PlatformInfo.host) {
Platform.MACOS -> macOsSquircle(source)
else -> source
}
/**
* Renders [source] inside a 1024x1024 white squircle with padding around
* the mark. Matches Apple's HIG macOS app icon template (Big Sur+).
*
* Implementation notes:
* - Apple's "squircle" is a superellipse, not a standard rounded rectangle;
* Java2D doesn't ship a superellipse primitive, so [RoundRectangle2D]
* with a 22.5% corner radius is the practical approximation used by most
* third-party tooling. At dock-icon sizes the difference is invisible.
* - 10% padding on each side keeps the mark from touching the squircle
* corners, matching Apple's template content area (824/1024 80%).
*/
private fun macOsSquircle(source: BufferedImage): BufferedImage {
val size = 1024
val cornerDiameter = (size * 0.45f) // diameter = 2 * radius; radius = 22.5%
val padding = (size * 0.10f).toInt()
val innerSize = size - 2 * padding
val out = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB)
val g = out.createGraphics()
try {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC)
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
// Paint the squircle background in white. Clip must be set BEFORE
// the fill so the fill respects the rounded corners.
val squircle =
RoundRectangle2D.Float(
0f,
0f,
size.toFloat(),
size.toFloat(),
cornerDiameter,
cornerDiameter,
)
g.composite = AlphaComposite.Src
g.color = Color(0xFFFFFF)
g.fill(squircle)
// Composite the source logo on top, scaled into the padded area.
g.composite = AlphaComposite.SrcOver
g.clip = squircle // keep the logo within the squircle on overhang
g.drawImage(source, padding, padding, innerSize, innerSize, null)
} finally {
g.dispose()
}
return out
}
}