From e83e4c4663c3b281af9e0c15d1dbe22ae4d95644 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 13:55:38 +0000 Subject: [PATCH] feat(desktop): wrap logo in macOS squircle so dock icon looks native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../vitorpamplona/amethyst/desktop/Main.kt | 29 +++-- .../desktop/platform/PlatformAppIcon.kt | 102 ++++++++++++++++++ 2 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index c63db689f..df890e6b7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt new file mode 100644 index 000000000..82358de9e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt @@ -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 + } +}