feat: polish macOS icon and clean generated documentation
Some checks failed
macoc CI (Docker) / swift-core (push) Failing after -4s
Some checks failed
macoc CI (Docker) / swift-core (push) Failing after -4s
This commit is contained in:
parent
d4e033e97e
commit
f0ac170dbb
10 changed files with 290 additions and 9 deletions
43
README.md
43
README.md
|
|
@ -1,12 +1,16 @@
|
|||
# macoc
|
||||
|
||||
<p align="center">
|
||||
<img src="Resources/AppIcon-preview.png" alt="macoc icon - a furious programmer on a burning office chair" width="172">
|
||||
</p>
|
||||
|
||||
Native macOS menu-bar layout switcher for macOS Sonoma (14+) without
|
||||
the stock Caps Lock delay. Plain **Caps** switches to the next configured
|
||||
input source on **release**, including during fast typing. **Option+Caps**
|
||||
in either order toggles the real system Caps Lock state.
|
||||
|
||||
The icon is a SFW cartoon of someone thoroughly fed up with keyboard
|
||||
layout switching, complete with a literally hot seat.
|
||||
macoc keeps input switching simple. The icon is an SFW illustration
|
||||
of exactly how frustrating the old Caps Lock behavior can feel.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -16,6 +20,38 @@ root daemons, third-party input libraries or hidutil permission required.
|
|||
|
||||
Apple technical note: https://developer.apple.com/library/archive/technotes/tn2450/
|
||||
|
||||
## App icon
|
||||
|
||||
The icon uses a 1024 x 1024 RGBA master with a centered 824 x 824
|
||||
superellipse body and transparent margins. This matches the visual
|
||||
balance of the macOS application icon grid without accidentally
|
||||
shipping a square, white-backed image.
|
||||
|
||||
- [Full-resolution master](Resources/AppIcon.png) - the single source for
|
||||
the shipped classic Sonoma AppIcon.icns.
|
||||
- [Editable source layout](Resources/AppIconArtwork.svg) - includes the
|
||||
artwork and the clipping geometry, without requiring a third-party
|
||||
image tool during app builds.
|
||||
- [Non-shipping design grid](Resources/AppIconGrid.svg) - alignment lines,
|
||||
guides and 100px margins; never embedded in the icon.
|
||||
- [Layout measurements](Resources/AppIconLayout.json) - canvas, silhouette,
|
||||
crop and inset values.
|
||||
- [Illustration source](Resources/IconArtwork.jpg) - editable raster scene.
|
||||
|
||||
When running bash scripts/icon.sh (or task icon) on macOS, the build
|
||||
uses sips to generate all ten Apple iconset PNG representations:
|
||||
16, 32, 128, 256 and 512 points, each at 1x and 2x. iconutil
|
||||
combines them into Contents/Resources/AppIcon.icns. The shipped master
|
||||
already has transparency, a subtle edge shadow, and safe spacing.
|
||||
|
||||
This is a classic precomposed macOS 14 .icns, not the layered Icon
|
||||
Composer format introduced for newer icon appearances.
|
||||
|
||||
Apple design references:
|
||||
- [Apple Human Interface Guidelines - app icons](https://developer.apple.com/design/human-interface-guidelines/app-icons)
|
||||
- [Apple Design Resources - grids and templates](https://developer.apple.com/design/resources/)
|
||||
- [Apple archive - iconset filenames and scales](https://developer.apple.com/library/archive/documentation/Xcode/Reference/xcode_ref-Asset_Catalog_Format/IconSetType.html)
|
||||
|
||||
## Build and install
|
||||
|
||||
Requires Apple's Command Line Tools (not the Xcode IDE). Task is optional.
|
||||
|
|
@ -114,8 +150,7 @@ nonexistent macOS binaries.
|
|||
For a release archive, check out the tag on a real Mac and run
|
||||
bash scripts/package.sh (or task package), then execute
|
||||
bash scripts/publish-release.sh with a repository-scoped Forgejo token.
|
||||
See [CI and release instructions](docs/ci.md). Fully automated macOS
|
||||
binary releases require a real Mac runner or another Mac build service.
|
||||
Fully automated macOS binary releases require a real Mac runner or another Mac build service.
|
||||
|
||||
Ad-hoc artifacts are suited to local evaluation; public distribution
|
||||
requires Developer ID signing and Apple notarization.
|
||||
|
|
|
|||
BIN
Resources/AppIcon-preview.png
Normal file
BIN
Resources/AppIcon-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
Resources/AppIcon.png
Normal file
BIN
Resources/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 485 KiB |
19
Resources/AppIconArtwork.svg
Normal file
19
Resources/AppIconArtwork.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 36 KiB |
18
Resources/AppIconGrid.svg
Normal file
18
Resources/AppIconGrid.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 13 KiB |
17
Resources/AppIconLayout.json
Normal file
17
Resources/AppIconLayout.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"canvasPixels": 1024,
|
||||
"bodyPixels": 824,
|
||||
"bodyOrigin": [
|
||||
100,
|
||||
100
|
||||
],
|
||||
"superellipseExponent": 4.4,
|
||||
"sourceCrop": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"sourceFile": "IconArtwork.jpg",
|
||||
"legacyFormat": "icns"
|
||||
}
|
||||
BIN
Resources/IconArtwork.jpg
Normal file
BIN
Resources/IconArtwork.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
|
|
@ -2,22 +2,29 @@
|
|||
source "$(dirname "$0")/common.sh"
|
||||
macos_only
|
||||
|
||||
if [[ ! -f "$SOURCE_ICON" ]]; then
|
||||
echo "Missing icon source: $SOURCE_ICON" >&2
|
||||
# The composited Sonoma icon is committed as 1024px RGBA. The transparent
|
||||
# outer margins and squircle silhouette must be preserved at every scale.
|
||||
# The art grid in AppIconGrid.svg is never embedded in production images.
|
||||
master="$ROOT/Resources/AppIcon.png"
|
||||
if [[ ! -s "$master" ]]; then
|
||||
echo "Missing icon master: $master" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
iconset="$ROOT/build/macoc.iconset"
|
||||
rm -rf "$iconset"
|
||||
mkdir -p "$iconset"
|
||||
|
||||
for size in 16 32 128 256 512; do
|
||||
sips -s format png -z "$size" "$size" "$SOURCE_ICON" \
|
||||
sips -s format png -z "$size" "$size" "$master" \
|
||||
--out "$iconset/icon_${size}x${size}.png" >/dev/null
|
||||
done
|
||||
|
||||
for specification in "16 32" "32 64" "128 256" "256 512" "512 1024"; do
|
||||
read -r name pixels <<<"$specification"
|
||||
sips -s format png -z "$pixels" "$pixels" "$SOURCE_ICON" \
|
||||
sips -s format png -z "$pixels" "$pixels" "$master" \
|
||||
--out "$iconset/icon_${name}x${name}@2x.png" >/dev/null
|
||||
done
|
||||
|
||||
mkdir -p "$APP_PATH/Contents/Resources"
|
||||
iconutil -c icns "$iconset" -o "$APP_PATH/Contents/Resources/AppIcon.icns"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ git diff --check
|
|||
git diff --cached --check
|
||||
test -f Package.swift
|
||||
test -f Resources/Info.plist
|
||||
test -s Resources/IconSource.jpg
|
||||
test -s Resources/AppIcon.png
|
||||
test -s Resources/AppIcon-preview.png
|
||||
test -s Resources/IconArtwork.jpg
|
||||
python3 scripts/tests/test_icon_assets.py
|
||||
test -f Sources/CapswitchApp/MacOSInputController.swift
|
||||
test -f Sources/CapswitchApp/InputSourceManager.swift
|
||||
test -f Sources/CapswitchApp/ApplicationRelauncher.swift
|
||||
|
|
|
|||
182
scripts/tests/test_icon_assets.py
Normal file
182
scripts/tests/test_icon_assets.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Portable macOS app icon checks. Uses only the Python standard library."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import unittest
|
||||
import zlib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / "Resources/IconArtwork.jpg"
|
||||
MASTER = ROOT / "Resources/AppIcon.png"
|
||||
PREVIEW = ROOT / "Resources/AppIcon-preview.png"
|
||||
LAYOUT = ROOT / "Resources/AppIconLayout.json"
|
||||
SVG = ROOT / "Resources/AppIconArtwork.svg"
|
||||
ICONSET = ROOT / "build/macoc.iconset"
|
||||
|
||||
SIZE_NAMES = {
|
||||
"icon_16x16.png": 16,
|
||||
"icon_16x16@2x.png": 32,
|
||||
"icon_32x32.png": 32,
|
||||
"icon_32x32@2x.png": 64,
|
||||
"icon_128x128.png": 128,
|
||||
"icon_128x128@2x.png": 256,
|
||||
"icon_256x256.png": 256,
|
||||
"icon_256x256@2x.png": 512,
|
||||
"icon_512x512.png": 512,
|
||||
"icon_512x512@2x.png": 1024,
|
||||
}
|
||||
|
||||
|
||||
def jpeg_dimensions(path: Path) -> tuple[int, int]:
|
||||
data = path.read_bytes()
|
||||
if not data.startswith(b"\xff\xd8"):
|
||||
raise ValueError(f"not a JPEG: {path}")
|
||||
index = 2
|
||||
while index < len(data):
|
||||
if data[index] != 0xFF:
|
||||
index += 1
|
||||
continue
|
||||
while index < len(data) and data[index] == 0xFF:
|
||||
index += 1
|
||||
marker = data[index]
|
||||
index += 1
|
||||
if marker in (0xD8, 0xD9, 0x00) or 0xD0 <= marker <= 0xD7:
|
||||
continue
|
||||
if index + 2 > len(data):
|
||||
break
|
||||
segment_length = struct.unpack(">H", data[index:index+2])[0]
|
||||
if marker in (0xC0, 0xC1, 0xC2, 0xC3):
|
||||
height, width = struct.unpack(">HH", data[index+3:index+7])
|
||||
return width, height
|
||||
index += segment_length
|
||||
raise ValueError(f"no JPEG SOF marker: {path}")
|
||||
|
||||
|
||||
def png_info(path: Path) -> tuple[int, int, int]:
|
||||
header = path.read_bytes()[:26]
|
||||
if header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR":
|
||||
raise ValueError(f"not a PNG: {path}")
|
||||
width, height = struct.unpack(">II", header[16:24])
|
||||
return width, height, header[25]
|
||||
|
||||
|
||||
def alpha_at(path: Path, x: int, y: int) -> int:
|
||||
"""Decode an RGBA PNG scanline, supporting all five PNG row filters."""
|
||||
data = path.read_bytes()
|
||||
width, height, color = png_info(path)
|
||||
if color != 6 or data[24] != 8:
|
||||
raise ValueError("expected 8-bit RGBA PNG")
|
||||
if not (0 <= x < width and 0 <= y < height):
|
||||
raise ValueError("pixel outside PNG")
|
||||
|
||||
offset = 8
|
||||
compressed = []
|
||||
while offset < len(data):
|
||||
size = struct.unpack(">I", data[offset:offset+4])[0]
|
||||
kind = data[offset+4:offset+8]
|
||||
if kind == b"IDAT":
|
||||
compressed.append(data[offset+8:offset+8+size])
|
||||
offset += 12 + size
|
||||
if kind == b"IEND":
|
||||
break
|
||||
|
||||
raw = zlib.decompress(b"".join(compressed))
|
||||
stride = width * 4
|
||||
previous = bytearray(stride)
|
||||
position = 0
|
||||
|
||||
for row_number in range(y + 1):
|
||||
filter_type = raw[position]
|
||||
position += 1
|
||||
row = bytearray(raw[position:position+stride])
|
||||
position += stride
|
||||
|
||||
for i in range(stride):
|
||||
left = row[i-4] if i >= 4 else 0
|
||||
above = previous[i]
|
||||
upper_left = previous[i-4] if i >= 4 else 0
|
||||
|
||||
if filter_type == 1:
|
||||
predictor = left
|
||||
elif filter_type == 2:
|
||||
predictor = above
|
||||
elif filter_type == 3:
|
||||
predictor = (left + above) // 2
|
||||
elif filter_type == 4:
|
||||
prediction = left + above - upper_left
|
||||
da = abs(prediction - left)
|
||||
db = abs(prediction - above)
|
||||
dc = abs(prediction - upper_left)
|
||||
predictor = left if da <= db and da <= dc else (
|
||||
above if db <= dc else upper_left
|
||||
)
|
||||
elif filter_type == 0:
|
||||
predictor = 0
|
||||
else:
|
||||
raise ValueError(f"unsupported PNG filter: {filter_type}")
|
||||
|
||||
row[i] = (row[i] + predictor) & 0xFF
|
||||
|
||||
previous = row
|
||||
|
||||
return previous[x * 4 + 3]
|
||||
|
||||
|
||||
class AppIconAssetTests(unittest.TestCase):
|
||||
def test_artwork_source_is_square(self):
|
||||
w, h = jpeg_dimensions(SOURCE)
|
||||
self.assertEqual(w, h)
|
||||
self.assertGreaterEqual(w, 256)
|
||||
|
||||
def test_reference_layout_uses_centered_1024_grid(self):
|
||||
layout = json.loads(LAYOUT.read_text())
|
||||
self.assertEqual(layout["canvasPixels"], 1024)
|
||||
self.assertEqual(layout["bodyPixels"], 824)
|
||||
self.assertEqual(layout["bodyOrigin"], [100, 100])
|
||||
self.assertGreater(layout["superellipseExponent"], 3)
|
||||
self.assertLess(layout["superellipseExponent"], 6)
|
||||
self.assertEqual(len(layout["sourceCrop"]), 4)
|
||||
self.assertTrue(all(0 <= x <= 1 for x in layout["sourceCrop"]))
|
||||
self.assertLess(layout["sourceCrop"][0], layout["sourceCrop"][2])
|
||||
self.assertLess(layout["sourceCrop"][1], layout["sourceCrop"][3])
|
||||
|
||||
def test_master_has_transparent_corners(self):
|
||||
self.assertEqual(png_info(MASTER), (1024, 1024, 6))
|
||||
for point in [(0, 0), (1023, 0), (0, 1023), (1023, 1023)]:
|
||||
with self.subTest(corner=point):
|
||||
self.assertEqual(alpha_at(MASTER, *point), 0)
|
||||
self.assertEqual(alpha_at(MASTER, 512, 512), 255)
|
||||
|
||||
def test_preview_is_real_png_and_linked_from_readme(self):
|
||||
w, h, color = png_info(PREVIEW)
|
||||
self.assertEqual((w, h, color), (256, 256, 6))
|
||||
self.assertEqual(alpha_at(PREVIEW, 0, 0), 0)
|
||||
readme = (ROOT / "README.md").read_text()
|
||||
self.assertIn("Resources/AppIcon-preview.png", readme)
|
||||
|
||||
def test_svg_is_editable_and_pipeline_uses_master(self):
|
||||
svg = SVG.read_text()
|
||||
self.assertIn('viewBox="0 0 1024 1024"', svg)
|
||||
self.assertIn('clipPath id="icon-body"', svg)
|
||||
self.assertIn('IconArtwork.jpg', svg)
|
||||
script = (ROOT / "scripts/icon.sh").read_text()
|
||||
self.assertIn("Resources/AppIcon.png", script)
|
||||
self.assertIn("iconutil -c icns", script)
|
||||
self.assertIn("sips -s format png", script)
|
||||
|
||||
@unittest.skipUnless(ICONSET.exists(), "generated macOS iconset unavailable")
|
||||
def test_iconset_contains_all_ten_variants(self):
|
||||
self.assertEqual({p.name for p in ICONSET.glob("*.png")}, set(SIZE_NAMES))
|
||||
for name, pixels in SIZE_NAMES.items():
|
||||
with self.subTest(filename=name):
|
||||
w, h, color = png_info(ICONSET / name)
|
||||
self.assertEqual((w, h), (pixels, pixels))
|
||||
self.assertIn(color, (2, 6))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Loading…
Reference in a new issue