mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
feat(desktop): icon mark sized to match neighbors, add drop shadow, unify screen headers
Icon: - Mark now fills ~85% of the squircle (was 80%) so the goose reads at the same visual weight as first-party dock icons. Apple's template specifies 7.5-10% padding inside the squircle; 7.5% lands closer to the average first-party icon. - Baked a drop shadow into the icon PNG: 8px offset, 18px Gaussian blur at ~28% black, rendered under the white squircle on its own layer so the blur doesn't leak into the mark. First-party macOS icons include this shadow in the PNG — the dock doesn't add one at render time. Separable Gaussian (2x 1D passes) keeps startup fast. Screen header consistency (match Messages pattern): - Bookmarks, Drafts, Search, Reads, Highlights: titles switched from headlineMedium to titleMedium. - All five header rows now pad horizontal = 12.dp, vertical = 8.dp (matching ConversationListPane's "Messages" header). - Removed the 16.dp outer wrapper padding from Bookmarks and the 16.dp bottom padding from Reads header — screens now sit edge-to-edge. - DeckColumnContainer's 12.dp outer padding around column content removed for the same reason SinglePaneLayout's was: creates a `#F5F5F7` frame around every screen that reads as inconsistent with Messages (and every native desktop app). https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
This commit is contained in:
+69
-14
@@ -25,6 +25,8 @@ import java.awt.Color
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.geom.RoundRectangle2D
|
||||
import java.awt.image.BufferedImage
|
||||
import java.awt.image.ConvolveOp
|
||||
import java.awt.image.Kernel
|
||||
|
||||
/**
|
||||
* Adapts a transparent source logo to the host OS's app-icon conventions.
|
||||
@@ -74,10 +76,45 @@ object PlatformAppIcon {
|
||||
val squircleMargin = (canvas - squircleSize) / 2
|
||||
// Apple's reference corner radius on the 824-box is ~185px (≈22.45%).
|
||||
val cornerDiameter = (squircleSize * 0.4490f)
|
||||
// Mark padding inside the squircle: 10% of the squircle size.
|
||||
val markPadding = (squircleSize * 0.10f).toInt()
|
||||
// Mark padding inside the squircle: 7.5% each side (~85% fill) so the
|
||||
// mark reads at roughly the same visual weight as first-party dock icons.
|
||||
val markPadding = (squircleSize * 0.075f).toInt()
|
||||
val markOrigin = squircleMargin + markPadding
|
||||
val markSize = squircleSize - 2 * markPadding
|
||||
// Drop shadow under the squircle — Apple's dock icons include a subtle
|
||||
// shadow baked into the PNG; the dock doesn't add one at render time.
|
||||
val shadowOffset = 8
|
||||
val shadowBlur = 18
|
||||
|
||||
val squircle =
|
||||
RoundRectangle2D.Float(
|
||||
squircleMargin.toFloat(),
|
||||
squircleMargin.toFloat(),
|
||||
squircleSize.toFloat(),
|
||||
squircleSize.toFloat(),
|
||||
cornerDiameter,
|
||||
cornerDiameter,
|
||||
)
|
||||
|
||||
// Rasterize the shadow onto its own layer so the blur doesn't leak
|
||||
// into the white squircle or the mark.
|
||||
val shadowLayer = BufferedImage(canvas, canvas, BufferedImage.TYPE_INT_ARGB)
|
||||
shadowLayer.createGraphics().apply {
|
||||
setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
color = Color(0, 0, 0, 72) // ~28% black
|
||||
val shadowShape =
|
||||
RoundRectangle2D.Float(
|
||||
squircleMargin.toFloat(),
|
||||
(squircleMargin + shadowOffset).toFloat(),
|
||||
squircleSize.toFloat(),
|
||||
squircleSize.toFloat(),
|
||||
cornerDiameter,
|
||||
cornerDiameter,
|
||||
)
|
||||
fill(shadowShape)
|
||||
dispose()
|
||||
}
|
||||
val blurredShadow = gaussianBlur(shadowLayer, shadowBlur)
|
||||
|
||||
val out = BufferedImage(canvas, canvas, BufferedImage.TYPE_INT_ARGB)
|
||||
val g = out.createGraphics()
|
||||
@@ -86,21 +123,13 @@ object PlatformAppIcon {
|
||||
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC)
|
||||
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
|
||||
|
||||
val squircle =
|
||||
RoundRectangle2D.Float(
|
||||
squircleMargin.toFloat(),
|
||||
squircleMargin.toFloat(),
|
||||
squircleSize.toFloat(),
|
||||
squircleSize.toFloat(),
|
||||
cornerDiameter,
|
||||
cornerDiameter,
|
||||
)
|
||||
g.composite = AlphaComposite.Src
|
||||
// Shadow first, then the white squircle on top, then the mark.
|
||||
g.composite = AlphaComposite.SrcOver
|
||||
g.drawImage(blurredShadow, 0, 0, null)
|
||||
|
||||
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
|
||||
g.drawImage(source, markOrigin, markOrigin, markSize, markSize, null)
|
||||
} finally {
|
||||
@@ -108,4 +137,30 @@ object PlatformAppIcon {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Separable Gaussian blur via [ConvolveOp]. Splits the 2D kernel into two
|
||||
* 1D passes (horizontal then vertical) — O(N) per pixel instead of O(N²)
|
||||
* for a radius-N blur, which keeps startup snappy even at 1024x1024.
|
||||
*/
|
||||
private fun gaussianBlur(
|
||||
src: BufferedImage,
|
||||
radius: Int,
|
||||
): BufferedImage {
|
||||
if (radius < 1) return src
|
||||
val size = radius * 2 + 1
|
||||
val sigma = radius / 2f
|
||||
val kernel = FloatArray(size)
|
||||
var sum = 0f
|
||||
for (i in 0 until size) {
|
||||
val x = (i - radius).toFloat()
|
||||
kernel[i] = kotlin.math.exp(-(x * x) / (2f * sigma * sigma))
|
||||
sum += kernel[i]
|
||||
}
|
||||
for (i in 0 until size) kernel[i] /= sum
|
||||
|
||||
val horiz = ConvolveOp(Kernel(size, 1, kernel), ConvolveOp.EDGE_NO_OP, null)
|
||||
val vert = ConvolveOp(Kernel(1, size, kernel), ConvolveOp.EDGE_NO_OP, null)
|
||||
return vert.filter(horiz.filter(src, null), null)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -252,12 +252,12 @@ fun BookmarksScreen(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Bookmarks",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
|
||||
@@ -68,13 +68,16 @@ fun DraftsScreen(
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Drafts",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Button(onClick = { onOpenEditor(null) }) {
|
||||
|
||||
@@ -294,7 +294,10 @@ fun ReadsScreen(
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header — wraps on narrow columns
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
@@ -305,7 +308,7 @@ fun ReadsScreen(
|
||||
) {
|
||||
Text(
|
||||
"Reads",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
|
||||
@@ -331,13 +331,16 @@ fun SearchScreen(
|
||||
|
||||
// Title row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Search",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
|
||||
+4
-4
@@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -130,9 +129,10 @@ fun DeckColumnContainer(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(12.dp),
|
||||
) {
|
||||
// Content runs edge-to-edge; each screen adds its own header padding
|
||||
// to match the Messages pattern (padding(horizontal = 12, vertical = 8)
|
||||
// on the title row, no outer wrapper).
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Always keep RootContent composed so state (e.g. search results) survives navigation
|
||||
RootContent(
|
||||
columnType = column.type,
|
||||
|
||||
+3
-2
@@ -74,11 +74,12 @@ fun MyHighlightsScreen(
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
"Highlights",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (allHighlights.isEmpty()) {
|
||||
EmptyState(
|
||||
|
||||
Reference in New Issue
Block a user