|
| 1 | +#!/usr/bin/env swift |
| 2 | +// |
| 3 | +// Draws Sajilo's app icon and writes scripts/AppIcon.icns. |
| 4 | +// |
| 5 | +// The icon is generated rather than hand-drawn so it stays in step with the |
| 6 | +// palette: the brass and charcoal below are the same values Theme.Palette uses |
| 7 | +// for the patro skin, and changing them there means re-running this. |
| 8 | +// |
| 9 | +// swift scripts/make-app-icon.swift |
| 10 | +// |
| 11 | +// Every size is drawn at its own resolution rather than downscaled from 1024. |
| 12 | +// The 16pt icon is two thirds spine and one third glyph — at that size a |
| 13 | +// downscaled version of the large art turns to mud. |
| 14 | + |
| 15 | +import AppKit |
| 16 | +import CoreText |
| 17 | + |
| 18 | +// MARK: - Palette |
| 19 | + |
| 20 | +/// Warm charcoal, tinted toward the brass rather than a neutral grey, so the |
| 21 | +/// two colours read as one material. |
| 22 | +let inkTop = NSColor(srgbRed: 0.153, green: 0.141, blue: 0.125, alpha: 1) |
| 23 | +let inkBottom = NSColor(srgbRed: 0.086, green: 0.078, blue: 0.067, alpha: 1) |
| 24 | + |
| 25 | +/// The `patro` brand brass, lit from above as a real gilt surface would be. |
| 26 | +let brassLight = NSColor(srgbRed: 0.831, green: 0.686, blue: 0.384, alpha: 1) |
| 27 | +let brassMid = NSColor(srgbRed: 0.690, green: 0.549, blue: 0.247, alpha: 1) |
| 28 | +let brassDeep = NSColor(srgbRed: 0.478, green: 0.365, blue: 0.141, alpha: 1) |
| 29 | + |
| 30 | +// MARK: - Drawing |
| 31 | + |
| 32 | +func drawIcon(side: CGFloat, into context: CGContext) { |
| 33 | + let scale = side / 1024 |
| 34 | + |
| 35 | + context.setFillColor(NSColor.clear.cgColor) |
| 36 | + context.fill(CGRect(x: 0, y: 0, width: side, height: side)) |
| 37 | + |
| 38 | + // Apple's macOS grid: an 824pt body on a 1024pt canvas, so the icon sits |
| 39 | + // at the same visual weight as every system app beside it in the Dock. |
| 40 | + let body = CGRect(x: 100 * scale, y: 108 * scale, width: 824 * scale, height: 824 * scale) |
| 41 | + let radius = 185.4 * scale |
| 42 | + let shape = CGPath(roundedRect: body, cornerWidth: radius, cornerHeight: radius, transform: nil) |
| 43 | + |
| 44 | + // Contact shadow, so the tile sits on the desktop instead of floating. |
| 45 | + context.saveGState() |
| 46 | + context.setShadow( |
| 47 | + offset: CGSize(width: 0, height: -10 * scale), |
| 48 | + blur: 22 * scale, |
| 49 | + color: NSColor(white: 0, alpha: 0.34).cgColor |
| 50 | + ) |
| 51 | + context.addPath(shape) |
| 52 | + context.setFillColor(inkBottom.cgColor) |
| 53 | + context.fillPath() |
| 54 | + context.restoreGState() |
| 55 | + |
| 56 | + context.saveGState() |
| 57 | + context.addPath(shape) |
| 58 | + context.clip() |
| 59 | + |
| 60 | + fillVertical(context, rect: body, from: inkTop, to: inkBottom) |
| 61 | + |
| 62 | + // The spine. A patro hangs from a printed band, and it is the one element |
| 63 | + // that still reads as a calendar at 16pt, where the glyph is four pixels |
| 64 | + // tall. Proportionally deeper at small sizes for exactly that reason. |
| 65 | + let spineFraction: CGFloat = side <= 32 ? 0.34 : 0.25 |
| 66 | + let spine = CGRect( |
| 67 | + x: body.minX, |
| 68 | + y: body.maxY - body.height * spineFraction, |
| 69 | + width: body.width, |
| 70 | + height: body.height * spineFraction |
| 71 | + ) |
| 72 | + fillVertical(context, rect: spine, from: brassLight, to: brassMid) |
| 73 | + |
| 74 | + // A single hanging hole punched out of the spine, in the tile's own ink so |
| 75 | + // it reads as a hole rather than a dot. Dropped below 64pt, where it would |
| 76 | + // close up into a smudge. |
| 77 | + if side >= 64 { |
| 78 | + let holeSide = spine.height * 0.26 |
| 79 | + let hole = CGRect( |
| 80 | + x: body.midX - holeSide / 2, |
| 81 | + y: spine.midY - holeSide / 2, |
| 82 | + width: holeSide, |
| 83 | + height: holeSide |
| 84 | + ) |
| 85 | + context.setFillColor(inkTop.cgColor) |
| 86 | + context.fillEllipse(in: hole) |
| 87 | + } |
| 88 | + |
| 89 | + drawGlyph(in: body, below: spine, context: context, side: side) |
| 90 | + |
| 91 | + context.restoreGState() |
| 92 | + |
| 93 | + // Hairline edge, so the tile keeps its shape against a dark wallpaper. |
| 94 | + context.addPath(shape) |
| 95 | + context.setStrokeColor(NSColor(white: 1, alpha: 0.10).cgColor) |
| 96 | + context.setLineWidth(max(1, 2 * scale)) |
| 97 | + context.strokePath() |
| 98 | +} |
| 99 | + |
| 100 | +/// स — the first letter of सजिलो, and a monogram rather than a flag or a |
| 101 | +/// generic calendar page. It carries the Devanagari headline across its top, |
| 102 | +/// which echoes the spine above it. |
| 103 | +func drawGlyph(in body: CGRect, below spine: CGRect, context: CGContext, side: CGFloat) { |
| 104 | + let field = CGRect( |
| 105 | + x: body.minX, |
| 106 | + y: body.minY, |
| 107 | + width: body.width, |
| 108 | + height: spine.minY - body.minY |
| 109 | + ) |
| 110 | + |
| 111 | + // Chunkier at small sizes: below 32pt the letter is a handful of pixels |
| 112 | + // and needs the extra weight, where at 512 the same ratio crowds the tile. |
| 113 | + let fill: CGFloat = side <= 32 ? 0.78 : 0.66 |
| 114 | + let font = devanagariFont(ofSize: field.height * fill) |
| 115 | + let attributed = NSAttributedString( |
| 116 | + string: "स", |
| 117 | + attributes: [.font: font, .foregroundColor: brassLight] |
| 118 | + ) |
| 119 | + |
| 120 | + let line = CTLineCreateWithAttributedString(attributed) |
| 121 | + let bounds = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds) |
| 122 | + |
| 123 | + context.saveGState() |
| 124 | + context.textMatrix = .identity |
| 125 | + // Nudged above the geometric centre. Devanagari hangs from its headline |
| 126 | + // rather than sitting on a baseline, so a mathematically centred glyph |
| 127 | + // reads as having sunk. |
| 128 | + context.textPosition = CGPoint( |
| 129 | + x: field.midX - bounds.midX, |
| 130 | + y: field.midY - bounds.midY + field.height * 0.04 |
| 131 | + ) |
| 132 | + |
| 133 | + // Filled from the glyph's own path so the gilt gradient runs through the |
| 134 | + // letter rather than sitting behind a flat fill. |
| 135 | + if let path = glyphPath(from: line, at: context.textPosition) { |
| 136 | + context.addPath(path) |
| 137 | + context.clip() |
| 138 | + fillVertical(context, rect: bounds.offsetBy(dx: context.textPosition.x, dy: context.textPosition.y), |
| 139 | + from: brassLight, to: brassDeep) |
| 140 | + } else { |
| 141 | + CTLineDraw(line, context) |
| 142 | + } |
| 143 | + context.restoreGState() |
| 144 | +} |
| 145 | + |
| 146 | +func glyphPath(from line: CTLine, at origin: CGPoint) -> CGPath? { |
| 147 | + let combined = CGMutablePath() |
| 148 | + for run in CTLineGetGlyphRuns(line) as! [CTRun] { |
| 149 | + let attributes = CTRunGetAttributes(run) as NSDictionary |
| 150 | + guard let font = attributes[kCTFontAttributeName as String] else { continue } |
| 151 | + let runFont = font as! CTFont |
| 152 | + |
| 153 | + let count = CTRunGetGlyphCount(run) |
| 154 | + var glyphs = [CGGlyph](repeating: 0, count: count) |
| 155 | + var positions = [CGPoint](repeating: .zero, count: count) |
| 156 | + CTRunGetGlyphs(run, CFRangeMake(0, count), &glyphs) |
| 157 | + CTRunGetPositions(run, CFRangeMake(0, count), &positions) |
| 158 | + |
| 159 | + for index in 0..<count { |
| 160 | + guard let letter = CTFontCreatePathForGlyph(runFont, glyphs[index], nil) else { continue } |
| 161 | + let transform = CGAffineTransform( |
| 162 | + translationX: origin.x + positions[index].x, |
| 163 | + y: origin.y + positions[index].y |
| 164 | + ) |
| 165 | + combined.addPath(letter, transform: transform) |
| 166 | + } |
| 167 | + } |
| 168 | + return combined.isEmpty ? nil : combined |
| 169 | +} |
| 170 | + |
| 171 | +/// Kohinoor Devanagari is the face macOS itself sets Devanagari in, so the |
| 172 | +/// icon matches the app's own text rather than introducing a second voice. |
| 173 | +func devanagariFont(ofSize size: CGFloat) -> NSFont { |
| 174 | + for name in ["KohinoorDevanagari-Semibold", "KohinoorDevanagari-Medium", "DevanagariMT-Bold"] { |
| 175 | + if let font = NSFont(name: name, size: size) { return font } |
| 176 | + } |
| 177 | + return NSFont.systemFont(ofSize: size, weight: .semibold) |
| 178 | +} |
| 179 | + |
| 180 | +func fillVertical(_ context: CGContext, rect: CGRect, from top: NSColor, to bottom: NSColor) { |
| 181 | + guard let gradient = CGGradient( |
| 182 | + colorsSpace: CGColorSpaceCreateDeviceRGB(), |
| 183 | + colors: [bottom.cgColor, top.cgColor] as CFArray, |
| 184 | + locations: [0, 1] |
| 185 | + ) else { return } |
| 186 | + |
| 187 | + context.saveGState() |
| 188 | + context.clip(to: rect) |
| 189 | + context.drawLinearGradient( |
| 190 | + gradient, |
| 191 | + start: CGPoint(x: rect.midX, y: rect.minY), |
| 192 | + end: CGPoint(x: rect.midX, y: rect.maxY), |
| 193 | + options: [.drawsBeforeStartLocation, .drawsAfterEndLocation] |
| 194 | + ) |
| 195 | + context.restoreGState() |
| 196 | +} |
| 197 | + |
| 198 | +// MARK: - Output |
| 199 | + |
| 200 | +func render(side: Int) throws -> Data { |
| 201 | + guard let rep = NSBitmapImageRep( |
| 202 | + bitmapDataPlanes: nil, |
| 203 | + pixelsWide: side, pixelsHigh: side, |
| 204 | + bitsPerSample: 8, samplesPerPixel: 4, |
| 205 | + hasAlpha: true, isPlanar: false, |
| 206 | + colorSpaceName: .deviceRGB, |
| 207 | + bytesPerRow: 0, bitsPerPixel: 0 |
| 208 | + ) else { throw Failure("could not allocate a \(side)px bitmap") } |
| 209 | + |
| 210 | + NSGraphicsContext.saveGraphicsState() |
| 211 | + defer { NSGraphicsContext.restoreGraphicsState() } |
| 212 | + guard let graphics = NSGraphicsContext(bitmapImageRep: rep) else { |
| 213 | + throw Failure("could not bind a drawing context to the bitmap") |
| 214 | + } |
| 215 | + NSGraphicsContext.current = graphics |
| 216 | + drawIcon(side: CGFloat(side), into: graphics.cgContext) |
| 217 | + graphics.flushGraphics() |
| 218 | + |
| 219 | + guard let data = rep.representation(using: .png, properties: [:]) else { |
| 220 | + throw Failure("could not encode the \(side)px bitmap as PNG") |
| 221 | + } |
| 222 | + return data |
| 223 | +} |
| 224 | + |
| 225 | +struct Failure: Error, CustomStringConvertible { |
| 226 | + let description: String |
| 227 | + init(_ description: String) { self.description = description } |
| 228 | +} |
| 229 | + |
| 230 | +let scriptsDirectory = URL(fileURLWithPath: CommandLine.arguments[0]) |
| 231 | + .deletingLastPathComponent() |
| 232 | +let iconset = scriptsDirectory.appendingPathComponent("AppIcon.iconset") |
| 233 | +let icns = scriptsDirectory.appendingPathComponent("AppIcon.icns") |
| 234 | + |
| 235 | +try? FileManager.default.removeItem(at: iconset) |
| 236 | +try FileManager.default.createDirectory(at: iconset, withIntermediateDirectories: true) |
| 237 | + |
| 238 | +// The ten variants `iconutil` expects. Missing one leaves macOS interpolating |
| 239 | +// that size from another, which is what makes a hand-made icon look soft. |
| 240 | +let variants: [(name: String, side: Int)] = [ |
| 241 | + ("icon_16x16", 16), ("icon_16x16@2x", 32), |
| 242 | + ("icon_32x32", 32), ("icon_32x32@2x", 64), |
| 243 | + ("icon_128x128", 128), ("icon_128x128@2x", 256), |
| 244 | + ("icon_256x256", 256), ("icon_256x256@2x", 512), |
| 245 | + ("icon_512x512", 512), ("icon_512x512@2x", 1024), |
| 246 | +] |
| 247 | + |
| 248 | +for variant in variants { |
| 249 | + let data = try render(side: variant.side) |
| 250 | + try data.write(to: iconset.appendingPathComponent("\(variant.name).png")) |
| 251 | +} |
| 252 | + |
| 253 | +let iconutil = Process() |
| 254 | +iconutil.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil") |
| 255 | +iconutil.arguments = ["--convert", "icns", iconset.path, "--output", icns.path] |
| 256 | +try iconutil.run() |
| 257 | +iconutil.waitUntilExit() |
| 258 | +guard iconutil.terminationStatus == 0 else { |
| 259 | + throw Failure("iconutil exited \(iconutil.terminationStatus)") |
| 260 | +} |
| 261 | + |
| 262 | +print("Wrote \(icns.path)") |
0 commit comments