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:
@@ -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)
|
||||
}
|
||||
|
||||
+102
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user