diff --git a/README.md b/README.md index 37a485e..f0bbf08 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ # macoc +

+ macoc icon - a furious programmer on a burning office chair +

+ 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. diff --git a/Resources/AppIcon-preview.png b/Resources/AppIcon-preview.png new file mode 100644 index 0000000..7a723d8 Binary files /dev/null and b/Resources/AppIcon-preview.png differ diff --git a/Resources/AppIcon.png b/Resources/AppIcon.png new file mode 100644 index 0000000..6cec614 Binary files /dev/null and b/Resources/AppIcon.png differ diff --git a/Resources/AppIconArtwork.svg b/Resources/AppIconArtwork.svg new file mode 100644 index 0000000..632e3ca --- /dev/null +++ b/Resources/AppIconArtwork.svg @@ -0,0 +1,19 @@ + + + macoc app icon - a furious programmer on a burning office chair + + + + + + + + + + + + diff --git a/Resources/AppIconGrid.svg b/Resources/AppIconGrid.svg new file mode 100644 index 0000000..00bdf4f --- /dev/null +++ b/Resources/AppIconGrid.svg @@ -0,0 +1,18 @@ + + + macoc icon reference grid - design only, not included in the app + + + + + + + + + + + 1024 x 1024 canvas + 824px inset body / 100px margins + + diff --git a/Resources/AppIconLayout.json b/Resources/AppIconLayout.json new file mode 100644 index 0000000..53f91b6 --- /dev/null +++ b/Resources/AppIconLayout.json @@ -0,0 +1,17 @@ +{ + "canvasPixels": 1024, + "bodyPixels": 824, + "bodyOrigin": [ + 100, + 100 + ], + "superellipseExponent": 4.4, + "sourceCrop": [ + 0, + 0, + 1, + 1 + ], + "sourceFile": "IconArtwork.jpg", + "legacyFormat": "icns" +} diff --git a/Resources/IconArtwork.jpg b/Resources/IconArtwork.jpg new file mode 100644 index 0000000..c353db5 Binary files /dev/null and b/Resources/IconArtwork.jpg differ diff --git a/scripts/icon.sh b/scripts/icon.sh index 969714e..beba42d 100755 --- a/scripts/icon.sh +++ b/scripts/icon.sh @@ -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" diff --git a/scripts/lint.sh b/scripts/lint.sh index c87489a..0a3d950 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -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 diff --git a/scripts/tests/test_icon_assets.py b/scripts/tests/test_icon_assets.py new file mode 100644 index 0000000..6e46ccd --- /dev/null +++ b/scripts/tests/test_icon_assets.py @@ -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)