commit 756ab6e2777342468a076de70cf0dccd0ab361fb
parent 92b4a77e48c6a42886b318eb36f9387d2f274592
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Sun, 20 Sep 2026 16:41:37 -0300
appicon: vary a bundle by appearance
An icon drawn once looks wrong tinted or in the dark, which is why the
manifest holds a list per value rather than a value. The plain field and the
list say the same thing, so only one of the two is written.
Diffstat:
5 files changed, 420 insertions(+), 17 deletions(-)
diff --git a/appicon/appearance.go b/appicon/appearance.go
@@ -0,0 +1,108 @@
+package appicon
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+// Appearance names a variant a value is specialised for. The zero value is
+// the appearance the others vary from.
+type Appearance string
+
+// The appearances an icon is drawn in.
+const (
+ // AppearanceDefault is the appearance the others vary from.
+ AppearanceDefault Appearance = ""
+ // AppearanceDark is the icon as drawn in dark mode.
+ AppearanceDark Appearance = "dark"
+ // AppearanceTinted is the icon as drawn when the system tints it.
+ AppearanceTinted Appearance = "tinted"
+)
+
+// Specialized is a value that differs by appearance. An entry with the
+// default appearance is the one the others vary from, and is written first.
+type Specialized[T any] struct {
+ Appearance Appearance `json:"appearance,omitempty"`
+ Value T `json:"value"`
+}
+
+// For returns the value specialised for an appearance, falling back to the
+// default entry. The boolean reports whether either was found.
+func For[T any](values []Specialized[T], a Appearance) (T, bool) {
+ var (
+ out T
+ found bool
+ )
+ for _, v := range values {
+ if v.Appearance == a {
+ return v.Value, true
+ }
+ if v.Appearance == AppearanceDefault {
+ out, found = v.Value, true
+ }
+ }
+ return out, found
+}
+
+// Fill is how a surface is filled. Exactly one of the three is written, in
+// the order they are listed here.
+type Fill struct {
+ // Gradient is a list of colour stops, first to last, each a colour space
+ // and its components, such as "srgb:1.00000,0.25279,1.00000,1.00000".
+ Gradient []string
+ // Solid is a single colour, written the same way as a stop.
+ Solid string
+ // Name is a fill the system provides, such as "automatic",
+ // "system-light" or "system-dark".
+ Name string
+}
+
+// NamedFill returns a fill the system provides.
+func NamedFill(name string) Fill { return Fill{Name: name} }
+
+// SolidFill returns a fill of one colour, such as
+// SolidFill("srgb:1.00000,0.25279,1.00000,1.00000").
+func SolidFill(colour string) Fill { return Fill{Solid: colour} }
+
+// GradientFill returns a linear gradient through the colours given.
+func GradientFill(stops ...string) Fill { return Fill{Gradient: stops} }
+
+// MarshalJSON writes the fill the way the manifest holds it: a bare string
+// for a named fill, and an object naming the kind for the others.
+func (f Fill) MarshalJSON() ([]byte, error) {
+ switch {
+ case len(f.Gradient) > 0:
+ return json.Marshal(map[string][]string{"linear-gradient": f.Gradient})
+ case f.Solid != "":
+ return json.Marshal(map[string]string{"solid": f.Solid})
+ case f.Name != "":
+ return json.Marshal(f.Name)
+ }
+ return nil, fmt.Errorf("%w: a fill names no colour", ErrEmptyFill)
+}
+
+// UnmarshalJSON reads a fill written either way.
+func (f *Fill) UnmarshalJSON(data []byte) error {
+ var name string
+ if err := json.Unmarshal(data, &name); err == nil {
+ *f = Fill{Name: name}
+ return nil
+ }
+ var object struct {
+ Gradient []string `json:"linear-gradient"`
+ Solid string `json:"solid"`
+ }
+ if err := json.Unmarshal(data, &object); err != nil {
+ return fmt.Errorf("reading a fill: %w", err)
+ }
+ *f = Fill{Gradient: object.Gradient, Solid: object.Solid}
+ return nil
+}
+
+// Position is where a layer sits relative to the space it is drawn in.
+type Position struct {
+ // Scale multiplies the layer's size, 1 leaving it as it is.
+ Scale float64 `json:"scale"`
+ // Translation moves it, across and down, in points.
+ Translation [2]float64 `json:"translation-in-points"`
+}
diff --git a/appicon/appearance_test.go b/appicon/appearance_test.go
@@ -0,0 +1,105 @@
+package appicon
+
+import (
+ "encoding/json"
+ "errors"
+ "reflect"
+ "testing"
+)
+
+func TestFillWritesTheShapeItNames(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ fill Fill
+ want string
+ }{
+ {"a fill the system provides", NamedFill("automatic"), `"automatic"`},
+ {"one colour", SolidFill("srgb:1.00000,0.25279,1.00000,1.00000"), `{"solid":"srgb:1.00000,0.25279,1.00000,1.00000"}`},
+ {
+ name: "a gradient",
+ fill: GradientFill("display-p3:0.90000,0.90000,0.90000,0.83000", "srgb:1.00000,1.00000,1.00000,0.41987"),
+ want: `{"linear-gradient":["display-p3:0.90000,0.90000,0.90000,0.83000","srgb:1.00000,1.00000,1.00000,0.41987"]}`,
+ },
+ // A gradient wins over the other two, so a fill given more than one
+ // is written the way it is documented to be.
+ {"a gradient beside a colour", Fill{Gradient: []string{"srgb:0,0,0,1"}, Solid: "srgb:1,1,1,1"}, `{"linear-gradient":["srgb:0,0,0,1"]}`},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := json.Marshal(tt.fill)
+ if err != nil {
+ t.Fatalf("writing: %v", err)
+ }
+ if string(got) != tt.want {
+ t.Errorf("wrote %s, want %s", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestFillRejectsNamingNothing(t *testing.T) {
+ if _, err := json.Marshal(Fill{}); !errors.Is(err, ErrEmptyFill) {
+ t.Errorf("writing an empty fill returned %v, want ErrEmptyFill", err)
+ }
+}
+
+func TestFillReadsBackWhatItWrote(t *testing.T) {
+ for _, want := range []Fill{
+ NamedFill("system-dark"),
+ SolidFill("srgb:1,0,0,1"),
+ GradientFill("srgb:1,1,1,1", "srgb:0,0,0,1"),
+ } {
+ data, err := json.Marshal(want)
+ if err != nil {
+ t.Fatalf("writing %v: %v", want, err)
+ }
+ var got Fill
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("reading %s: %v", data, err)
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("read %v from %s, want %v", got, data, want)
+ }
+ }
+}
+
+func TestSpecializedWritesTheAppearanceOnlyWhenItHasOne(t *testing.T) {
+ data, err := json.Marshal([]Specialized[Fill]{
+ {Value: NamedFill("system-light")},
+ {Appearance: AppearanceDark, Value: NamedFill("system-dark")},
+ })
+ if err != nil {
+ t.Fatalf("writing: %v", err)
+ }
+ want := `[{"value":"system-light"},{"appearance":"dark","value":"system-dark"}]`
+ if string(data) != want {
+ t.Errorf("wrote %s, want %s", data, want)
+ }
+}
+
+func TestForFallsBackToTheDefault(t *testing.T) {
+ values := []Specialized[string]{
+ {Value: "normal"},
+ {Appearance: AppearanceTinted, Value: "lighten"},
+ }
+ for _, tt := range []struct {
+ appearance Appearance
+ want string
+ }{
+ {AppearanceDefault, "normal"},
+ {AppearanceTinted, "lighten"},
+ {AppearanceDark, "normal"},
+ } {
+ got, ok := For(values, tt.appearance)
+ if !ok || got != tt.want {
+ t.Errorf("For(%q) = %q, %v; want %q, true", tt.appearance, got, ok, tt.want)
+ }
+ }
+ if _, ok := For[string](nil, AppearanceDark); ok {
+ t.Error("For on nothing reported a value")
+ }
+ // Without a default there is nothing to fall back to.
+ only := []Specialized[string]{{Appearance: AppearanceTinted, Value: "lighten"}}
+ if _, ok := For(only, AppearanceDark); ok {
+ t.Error("For fell back to an appearance that is not the default")
+ }
+}
diff --git a/appicon/appicon.go b/appicon/appicon.go
@@ -24,13 +24,20 @@ var (
// ErrDuplicateLayer means two layers share a name, so one image would
// overwrite the other.
ErrDuplicateLayer = errors.New("two layers share a name")
+ // ErrEmptyFill means a fill names neither a gradient, a colour nor a
+ // fill the system provides.
+ ErrEmptyFill = errors.New("fill names no colour")
)
// Bundle is an icon: a manifest naming layers, and the images they hold.
type Bundle struct {
// Fill is how the space behind the layers is filled. Empty is written as
- // "automatic", which leaves the choice to the compiler.
+ // "automatic", which leaves the choice to the compiler, and is ignored
+ // when Fills is given.
Fill string
+ // Fills is the fill specialised by appearance, for an icon whose
+ // background differs in dark mode or when tinted.
+ Fills []Specialized[Fill]
// Groups are composited back to front.
Groups []Group
// Platforms are the platforms the icon offers square artwork for. Empty
@@ -46,8 +53,23 @@ type Group struct {
// manifest.
Shadow *Shadow
// Translucency is how far the group lets the material behind it through.
- // Nil leaves it out of the manifest.
+ // Nil leaves it out of the manifest, and it is ignored when
+ // Translucencies is given.
Translucency *Translucency
+ // Translucencies is the translucency specialised by appearance.
+ Translucencies []Specialized[Translucency]
+ // Lighting is how the group is lit, such as "individual" or "combined".
+ Lighting string
+ // Specular asks for a specular highlight across the group.
+ Specular bool
+ // BlurMaterial is the material the group blurs what is behind it with.
+ // Nil leaves it out, and it is ignored when BlurMaterials is given.
+ BlurMaterial *float64
+ // BlurMaterials is the blur material specialised by appearance.
+ BlurMaterials []Specialized[float64]
+ // BlendModes is how the group composites, specialised by appearance,
+ // such as "normal" or "lighten".
+ BlendModes []Specialized[string]
}
// Layer is one image in the stack.
@@ -59,6 +81,15 @@ type Layer struct {
// Glass asks for the layer to be treated as glass, which the compiler
// lights and refracts rather than drawing flat.
Glass bool
+ // Hidden keeps the layer in the manifest without drawing it.
+ Hidden bool
+ // Position is where the layer sits. Nil leaves it where it was drawn.
+ Position *Position
+ // Fills is the layer's own fill, specialised by appearance. A layer
+ // given one is filled with it rather than with its image's colour.
+ Fills []Specialized[Fill]
+ // BlendModes is how the layer composites, specialised by appearance.
+ BlendModes []Specialized[string]
}
// Shadow is the shadow a group casts.
diff --git a/appicon/appicon_test.go b/appicon/appicon_test.go
@@ -143,6 +143,135 @@ func TestGlassAndSeveralGroups(t *testing.T) {
}
}
+// TestSpecializedValuesReplaceThePlainOnes covers the pairs where the
+// manifest holds either a value or a list of them specialised by appearance,
+// never both.
+func TestSpecializedValuesReplaceThePlainOnes(t *testing.T) {
+ material := 1.0
+ b := Bundle{
+ Fill: "automatic",
+ Fills: []Specialized[Fill]{{Value: NamedFill("system-light")}},
+ Groups: []Group{{
+ Layers: []Layer{{Name: "One", Image: art(16)}},
+ Translucency: &Translucency{Enabled: true, Value: 0.5},
+ Translucencies: []Specialized[Translucency]{{Value: Translucency{Enabled: false, Value: 0.8}}},
+ BlurMaterial: &material,
+ BlurMaterials: []Specialized[float64]{{Value: 2}},
+ }},
+ }
+ files, err := b.Files()
+ if err != nil {
+ t.Fatalf("rendering: %v", err)
+ }
+ doc := decode(t, files)
+ if _, ok := doc["fill"]; ok {
+ t.Errorf("wrote a plain fill beside the specialised one: %v", doc["fill"])
+ }
+ if _, ok := doc["fill-specializations"]; !ok {
+ t.Error("did not write the specialised fill")
+ }
+ group := doc["groups"].([]any)[0].(map[string]any)
+ for _, plain := range []string{"translucency", "blur-material"} {
+ if _, ok := group[plain]; ok {
+ t.Errorf("wrote a plain %s beside the specialised one: %v", plain, group[plain])
+ }
+ }
+ for _, special := range []string{"translucency-specializations", "blur-material-specializations"} {
+ if _, ok := group[special]; !ok {
+ t.Errorf("did not write %s", special)
+ }
+ }
+}
+
+// TestRichManifestMatchesTheShippingShape builds the manifest a composed icon
+// needs and holds its keys against the ones a shipping app's bundle uses.
+func TestRichManifestMatchesTheShippingShape(t *testing.T) {
+ b := Bundle{
+ Fills: []Specialized[Fill]{
+ {Value: NamedFill("system-light")},
+ {Appearance: AppearanceDark, Value: NamedFill("system-dark")},
+ },
+ Groups: []Group{{
+ BlendModes: []Specialized[string]{{Appearance: AppearanceTinted, Value: "normal"}},
+ Lighting: "individual",
+ Specular: true,
+ Shadow: &Shadow{Kind: "layer-color", Opacity: 0.5},
+ Translucencies: []Specialized[Translucency]{
+ {Value: Translucency{Enabled: true, Value: 0.84}},
+ {Appearance: AppearanceTinted, Value: Translucency{Enabled: false, Value: 0.84}},
+ },
+ Layers: []Layer{{
+ Name: "Cube",
+ Image: art(32),
+ Glass: true,
+ Position: &Position{Scale: 1.24, Translation: [2]float64{0, 0}},
+ Fills: []Specialized[Fill]{
+ {Appearance: AppearanceDark, Value: NamedFill("automatic")},
+ {Appearance: AppearanceTinted, Value: GradientFill(
+ "display-p3:0.90000,0.90000,0.90000,0.83000",
+ "srgb:1.00000,1.00000,1.00000,0.41987",
+ )},
+ },
+ BlendModes: []Specialized[string]{{Appearance: AppearanceDark, Value: "lighten"}},
+ }},
+ }},
+ }
+ files, err := b.Files()
+ if err != nil {
+ t.Fatalf("rendering: %v", err)
+ }
+ doc := decode(t, files)
+ group := doc["groups"].([]any)[0].(map[string]any)
+ for _, key := range []string{
+ "blend-mode-specializations", "lighting", "shadow", "specular",
+ "translucency-specializations", "layers",
+ } {
+ if _, ok := group[key]; !ok {
+ t.Errorf("group is missing %s", key)
+ }
+ }
+ layer := group["layers"].([]any)[0].(map[string]any)
+ for _, key := range []string{
+ "blend-mode-specializations", "fill-specializations", "glass",
+ "image-name", "name", "position",
+ } {
+ if _, ok := layer[key]; !ok {
+ t.Errorf("layer is missing %s", key)
+ }
+ }
+ // A gradient is the one fill written as an object naming its kind.
+ tinted := layer["fill-specializations"].([]any)[1].(map[string]any)
+ gradient, ok := tinted["value"].(map[string]any)["linear-gradient"].([]any)
+ if !ok || len(gradient) != 2 {
+ t.Errorf("gradient is %v, want two stops", tinted["value"])
+ }
+ if got := layer["position"].(map[string]any)["scale"]; got != 1.24 {
+ t.Errorf("scale is %v, want 1.24", got)
+ }
+}
+
+// TestHiddenIsWrittenOnlyWhenSet keeps a manifest from carrying a key for
+// every default a layer did not set.
+func TestHiddenIsWrittenOnlyWhenSet(t *testing.T) {
+ files, err := New(art(16), "Plain").Files()
+ if err != nil {
+ t.Fatalf("rendering: %v", err)
+ }
+ layer := decode(t, files)["groups"].([]any)[0].(map[string]any)["layers"].([]any)[0].(map[string]any)
+ if _, ok := layer["hidden"]; ok {
+ t.Errorf("wrote hidden for a layer that is not: %v", layer)
+ }
+ b := Bundle{Groups: []Group{{Layers: []Layer{{Name: "Gone", Image: art(16), Hidden: true}}}}}
+ files, err = b.Files()
+ if err != nil {
+ t.Fatalf("rendering: %v", err)
+ }
+ layer = decode(t, files)["groups"].([]any)[0].(map[string]any)["layers"].([]any)[0].(map[string]any)
+ if layer["hidden"] != true {
+ t.Errorf("hidden is %v, want true", layer["hidden"])
+ }
+}
+
func TestFilesRejectsWhatItCannotWrite(t *testing.T) {
for _, tt := range []struct {
name string
diff --git a/appicon/writer.go b/appicon/writer.go
@@ -74,9 +74,14 @@ func (b Bundle) document() (icon, [][]string, error) {
}
doc := icon{
Fill: b.Fill,
+ Fills: b.Fills,
Platforms: platforms{Squares: b.Platforms},
}
- if doc.Fill == "" {
+ // A specialised fill says everything the plain one does, so only one of
+ // the two is written.
+ if len(doc.Fills) > 0 {
+ doc.Fill = ""
+ } else if doc.Fill == "" {
doc.Fill = FillAutomatic
}
if len(doc.Platforms.Squares) == 0 {
@@ -89,7 +94,17 @@ func (b Bundle) document() (icon, [][]string, error) {
)
for i, group := range b.Groups {
names[i] = make([]string, len(group.Layers))
- out := jsonGroup{Layers: make([]jsonLayer, len(group.Layers))}
+ out := jsonGroup{
+ BlendModes: group.BlendModes,
+ BlurMaterials: group.BlurMaterials,
+ Layers: make([]jsonLayer, len(group.Layers)),
+ Lighting: group.Lighting,
+ Specular: group.Specular,
+ Translucencies: group.Translucencies,
+ }
+ if len(group.BlurMaterials) == 0 {
+ out.BlurMaterial = group.BlurMaterial
+ }
for j, layer := range group.Layers {
if layer.Image == nil {
return icon{}, nil, fmt.Errorf("layer %q has no image", layer.Name)
@@ -103,9 +118,13 @@ func (b Bundle) document() (icon, [][]string, error) {
file := name + ".png"
names[i][j] = file
out.Layers[j] = jsonLayer{
- Glass: layer.Glass,
- ImageName: file,
- Name: name,
+ BlendModes: layer.BlendModes,
+ Fills: layer.Fills,
+ Glass: layer.Glass,
+ Hidden: layer.Hidden,
+ ImageName: file,
+ Name: name,
+ Position: layer.Position,
}
}
if s := group.Shadow; s != nil {
@@ -115,7 +134,7 @@ func (b Bundle) document() (icon, [][]string, error) {
}
out.Shadow = &jsonShadow{Kind: kind, Opacity: s.Opacity}
}
- if t := group.Translucency; t != nil {
+ if t := group.Translucency; t != nil && len(group.Translucencies) == 0 {
out.Translucency = &jsonTranslucency{Enabled: t.Enabled, Value: t.Value}
}
doc.Groups = append(doc.Groups, out)
@@ -145,9 +164,10 @@ func layerName(name string, position int) string {
// The manifest's shape. The names are the ones Icon Composer writes, which
// is what actool reads.
type icon struct {
- Fill string `json:"fill"`
- Groups []jsonGroup `json:"groups"`
- Platforms platforms `json:"supported-platforms"`
+ Fill string `json:"fill,omitempty"`
+ Fills []Specialized[Fill] `json:"fill-specializations,omitempty"`
+ Groups []jsonGroup `json:"groups"`
+ Platforms platforms `json:"supported-platforms"`
}
type platforms struct {
@@ -155,15 +175,25 @@ type platforms struct {
}
type jsonGroup struct {
- Layers []jsonLayer `json:"layers"`
- Shadow *jsonShadow `json:"shadow,omitempty"`
- Translucency *jsonTranslucency `json:"translucency,omitempty"`
+ BlendModes []Specialized[string] `json:"blend-mode-specializations,omitempty"`
+ BlurMaterial *float64 `json:"blur-material,omitempty"`
+ BlurMaterials []Specialized[float64] `json:"blur-material-specializations,omitempty"`
+ Layers []jsonLayer `json:"layers"`
+ Lighting string `json:"lighting,omitempty"`
+ Shadow *jsonShadow `json:"shadow,omitempty"`
+ Specular bool `json:"specular,omitempty"`
+ Translucency *jsonTranslucency `json:"translucency,omitempty"`
+ Translucencies []Specialized[Translucency] `json:"translucency-specializations,omitempty"`
}
type jsonLayer struct {
- Glass bool `json:"glass"`
- ImageName string `json:"image-name"`
- Name string `json:"name"`
+ BlendModes []Specialized[string] `json:"blend-mode-specializations,omitempty"`
+ Fills []Specialized[Fill] `json:"fill-specializations,omitempty"`
+ Glass bool `json:"glass"`
+ Hidden bool `json:"hidden,omitempty"`
+ ImageName string `json:"image-name"`
+ Name string `json:"name"`
+ Position *Position `json:"position,omitempty"`
}
type jsonShadow struct {