Skip to content

Commit c0ac027

Browse files
Add URL trigger expansion and marketing asset generation scripts
New pika:// URL triggers: set foreground/background color, show/hide/toggle history drawer, open About/Help/Preferences windows, resize window, and force light/dark/system appearance. Updates HelpView with all new triggers. Adds build/marketing/ scripts (capture.sh, generate.ts, manifest.json) for automated screenshot capture and compositing of release marketing assets. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a280d68 commit c0ac027

7 files changed

Lines changed: 378 additions & 10 deletions

File tree

.gitignore

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,14 @@ xcuserdata/
1010
*.xccheckout
1111

1212
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13-
build/
13+
build/Debug/
14+
build/Release/
15+
build/RelWithDebInfo/
16+
build/*.xcarchive
1417
DerivedData/
18+
19+
## Marketing asset capture output (not checked in)
20+
build/marketing/source/
1521
*.moved-aside
1622
*.pbxuser
1723
!default.pbxuser

Pika/Services/URLSchemeHandler.swift

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,25 @@ final class URLSchemeHandler: NSObject {
1616

1717
var list = url.pathComponents.dropFirst()
1818
let task = list.popFirst()
19-
let colorFormat = list.popFirst()
19+
let arg1 = list.popFirst()
20+
let arg2 = list.popFirst()
2021

21-
if let colorFormat, let format = ColorFormat.withLabel(colorFormat) {
22+
if let arg1, let format = ColorFormat.withLabel(arg1) {
2223
Defaults[.colorFormat] = format
2324
}
2425

2526
switch action {
26-
case "format": handleFormat(task: task)
27-
case "pick": handlePick(task: task)
28-
case "system": handleSystem(task: task)
29-
case "copy": handleCopy(task: task)
30-
case "swap": NSApp.sendAction(#selector(AppDelegate.triggerSwap), to: nil, from: nil)
31-
case "undo": NSApp.sendAction(#selector(AppDelegate.triggerUndo), to: nil, from: nil)
32-
case "redo": NSApp.sendAction(#selector(AppDelegate.triggerRedo), to: nil, from: nil)
27+
case "format": handleFormat(task: task)
28+
case "pick": handlePick(task: task)
29+
case "system": handleSystem(task: task)
30+
case "copy": handleCopy(task: task)
31+
case "set": handleSet(task: task, hex: arg2)
32+
case "history": handleHistory(task: task)
33+
case "window": handleWindow(task: task, arg1: arg1, arg2: arg2)
34+
case "appearance": handleAppearance(task: task)
35+
case "swap": NSApp.sendAction(#selector(AppDelegate.triggerSwap), to: nil, from: nil)
36+
case "undo": NSApp.sendAction(#selector(AppDelegate.triggerUndo), to: nil, from: nil)
37+
case "redo": NSApp.sendAction(#selector(AppDelegate.triggerRedo), to: nil, from: nil)
3338
default: break
3439
}
3540
}
@@ -65,4 +70,56 @@ final class URLSchemeHandler: NSObject {
6570
default: break
6671
}
6772
}
73+
74+
private func handleSet(task: String?, hex: String?) {
75+
guard
76+
let hex,
77+
hex.count == 6,
78+
let appDelegate = NSApp.delegate as? AppDelegate
79+
else { return }
80+
let color = NSColor(hex: hex)
81+
if task == "foreground" {
82+
appDelegate.eyedroppers.foreground.set(color)
83+
} else if task == "background" {
84+
appDelegate.eyedroppers.background.set(color)
85+
}
86+
}
87+
88+
private func handleHistory(task: String?) {
89+
let visible = Defaults[.historyDrawerVisible]
90+
switch task {
91+
case "show" where !visible: Defaults[.historyDrawerVisible] = true
92+
case "hide" where visible: Defaults[.historyDrawerVisible] = false
93+
case "toggle": Defaults[.historyDrawerVisible].toggle()
94+
default: break
95+
}
96+
}
97+
98+
private func handleWindow(task: String?, arg1: String?, arg2: String?) {
99+
switch task {
100+
case "about": NSApp.sendAction(#selector(AppDelegate.openAboutWindow), to: nil, from: nil)
101+
case "help": NSApp.sendAction(#selector(AppDelegate.openHelpWindow), to: nil, from: nil)
102+
case "preferences": NSApp.sendAction(#selector(AppDelegate.openPreferencesWindow), to: nil, from: nil)
103+
case "resize": handleWindowResize(w: arg1, h: arg2)
104+
default: break
105+
}
106+
}
107+
108+
private func handleWindowResize(w: String?, h: String?) {
109+
guard
110+
let w, let h,
111+
let width = Double(w), let height = Double(h),
112+
let window = NSApp.windows.first(where: { $0.isVisible && $0.canBecomeKey })
113+
else { return }
114+
window.setContentSize(NSSize(width: width, height: height))
115+
}
116+
117+
private func handleAppearance(task: String?) {
118+
switch task {
119+
case "light": NSApp.appearance = NSAppearance(named: .aqua)
120+
case "dark": NSApp.appearance = NSAppearance(named: .darkAqua)
121+
case "system": NSApp.appearance = nil
122+
default: break
123+
}
124+
}
68125
}

Pika/Views/HelpView.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,26 @@ private let urlGroups: [URLGroup] = [
252252
("pika://undo", PikaText.textColorUndo),
253253
("pika://redo", PikaText.textColorRedo),
254254
]),
255+
("Set Color", [
256+
("pika://set/foreground/<hex>", "Set foreground color (e.g. pika://set/foreground/fbbf24)"),
257+
("pika://set/background/<hex>", "Set background color (e.g. pika://set/background/e74661)"),
258+
]),
259+
("History", [
260+
("pika://history/show", "Show the history drawer"),
261+
("pika://history/hide", "Hide the history drawer"),
262+
("pika://history/toggle", "Toggle the history drawer"),
263+
]),
264+
("Window", [
265+
("pika://window/about", "Open the About window"),
266+
("pika://window/help", "Open the Help window"),
267+
("pika://window/preferences", "Open the Preferences window"),
268+
("pika://window/resize/<w>/<h>", "Resize window (e.g. pika://window/resize/480/300)"),
269+
]),
270+
("Appearance", [
271+
("pika://appearance/light", "Force light appearance"),
272+
("pika://appearance/dark", "Force dark appearance"),
273+
("pika://appearance/system", "Restore system appearance"),
274+
]),
255275
]
256276

257277
private let formats: [(name: String, example: String, shortcut: String)] = [

build/marketing/capture.sh

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/bin/sh
2+
# Usage: ./capture.sh <output.png> <fg-hex> <bg-hex> <appearance> [history]
3+
# Example: ./capture.sh window-dark-1.png fbbf24 e74661 dark hide
4+
# Pika must be running.
5+
6+
OUTPUT="$1"
7+
FG="$2"
8+
BG="$3"
9+
APPEARANCE="$4"
10+
HISTORY="${5:-hide}"
11+
12+
# Configure Pika state via URL triggers
13+
open "pika://appearance/$APPEARANCE"
14+
sleep 0.3
15+
open "pika://set/foreground/$FG"
16+
sleep 0.3
17+
open "pika://set/background/$BG"
18+
sleep 0.3
19+
open "pika://history/$HISTORY"
20+
sleep 0.3
21+
open "pika://window/resize/480/300"
22+
sleep 0.5
23+
24+
# Look up the Pika window ID
25+
WINDOW_ID=$(swift - <<'EOF'
26+
import Quartz
27+
let list = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]] ?? []
28+
for w in list {
29+
if (w["kCGWindowOwnerName"] as? String) == "Pika",
30+
let layer = w["kCGWindowLayer"] as? Int, layer == 3 {
31+
print(w["kCGWindowNumber"] as? Int ?? 0)
32+
break
33+
}
34+
}
35+
EOF
36+
)
37+
38+
if [ -z "$WINDOW_ID" ]; then
39+
echo "Error: Pika window not found. Is Pika running?" >&2
40+
exit 1
41+
fi
42+
43+
screencapture -l "$WINDOW_ID" "$OUTPUT"
44+
echo "Captured → $OUTPUT"

build/marketing/generate.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
#!/usr/bin/env tsx
2+
/**
3+
* Pika marketing asset generator.
4+
*
5+
* Usage:
6+
* npx tsx build/marketing/generate.ts --version <version> [--capture] [--web] [--figma] [--all]
7+
*
8+
* Flags:
9+
* --version Required. Version string, e.g. 1.3.0 or 1.2.0-beta1.
10+
* --capture Run capture loop via capture.sh (requires Pika running).
11+
* --web Composite source PNGs into website JPGs.
12+
* --figma Copy shadow-trimmed PNGs to figma/ output folder.
13+
* --all Run capture, web, and figma in sequence.
14+
*
15+
* Output: pika-releases/Marketing/v<version>/
16+
*/
17+
18+
import { execSync } from "child_process";
19+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "fs";
20+
import { dirname, join, resolve } from "path";
21+
import sharp from "sharp";
22+
23+
// ---------------------------------------------------------------------------
24+
// Paths
25+
// ---------------------------------------------------------------------------
26+
27+
const SCRIPT_DIR = dirname(new URL(import.meta.url).pathname);
28+
const REPO_ROOT = resolve(SCRIPT_DIR, "../..");
29+
const WORKSPACE_ROOT = resolve(REPO_ROOT, "..");
30+
const SOURCE_DIR = join(SCRIPT_DIR, "source");
31+
const RELEASES_DIR = join(WORKSPACE_ROOT, "pika-releases", "Marketing");
32+
33+
// ---------------------------------------------------------------------------
34+
// Manifest
35+
// ---------------------------------------------------------------------------
36+
37+
interface Shot {
38+
file: string;
39+
fg: string;
40+
bg: string;
41+
appearance: "light" | "dark";
42+
history: "show" | "hide";
43+
}
44+
45+
interface Manifest {
46+
shots: Shot[];
47+
web: { dark: string[]; light: string[] };
48+
figma: Record<string, string>;
49+
}
50+
51+
const manifest: Manifest = JSON.parse(
52+
readFileSync(join(SCRIPT_DIR, "manifest.json"), "utf8")
53+
);
54+
55+
// ---------------------------------------------------------------------------
56+
// Args
57+
// ---------------------------------------------------------------------------
58+
59+
const args = process.argv.slice(2);
60+
61+
function flag(name: string): boolean {
62+
return args.includes(name);
63+
}
64+
65+
function arg(name: string): string | undefined {
66+
const idx = args.indexOf(name);
67+
return idx !== -1 ? args[idx + 1] : undefined;
68+
}
69+
70+
const version = arg("--version");
71+
if (!version) {
72+
console.error("Error: --version is required");
73+
process.exit(1);
74+
}
75+
76+
const runCapture = flag("--capture") || flag("--all");
77+
const runWeb = flag("--web") || flag("--all");
78+
const runFigma = flag("--figma") || flag("--all");
79+
80+
if (!runCapture && !runWeb && !runFigma) {
81+
console.error("Error: specify at least one of --capture, --web, --figma, or --all");
82+
process.exit(1);
83+
}
84+
85+
const OUTPUT_DIR = join(RELEASES_DIR, `v${version}`);
86+
const FIGMA_DIR = join(OUTPUT_DIR, "figma");
87+
88+
// ---------------------------------------------------------------------------
89+
// Capture
90+
// ---------------------------------------------------------------------------
91+
92+
async function runCaptureStep(): Promise<void> {
93+
console.log("\n── Capture ──────────────────────────────────────");
94+
mkdirSync(SOURCE_DIR, { recursive: true });
95+
96+
const captureScript = join(SCRIPT_DIR, "capture.sh");
97+
98+
for (const shot of manifest.shots) {
99+
const outputPath = join(SOURCE_DIR, shot.file);
100+
const cmd = `"${captureScript}" "${outputPath}" "${shot.fg}" "${shot.bg}" "${shot.appearance}" "${shot.history}"`;
101+
console.log(` ${shot.file}`);
102+
execSync(cmd, { stdio: "inherit" });
103+
}
104+
}
105+
106+
// ---------------------------------------------------------------------------
107+
// Web composite
108+
// ---------------------------------------------------------------------------
109+
110+
const CANVAS_WIDTH = 2380;
111+
const CANVAS_HEIGHT = 838;
112+
113+
// Layout: left (x=-30, y=54), center (x=893, y=0), right (x=1820, y=54)
114+
const WINDOW_POSITIONS = [
115+
{ left: -30, top: 54 },
116+
{ left: 893, top: 0 },
117+
{ left: 1820, top: 54 },
118+
];
119+
120+
const BACKGROUNDS: Record<"dark" | "light", { r: number; g: number; b: number }> = {
121+
dark: { r: 26, g: 26, b: 26 },
122+
light: { r: 255, g: 255, b: 255 },
123+
};
124+
125+
async function compositeWeb(mode: "dark" | "light"): Promise<void> {
126+
const files = manifest.web[mode];
127+
const bg = BACKGROUNDS[mode];
128+
129+
const trimmed = await Promise.all(
130+
files.map((f) =>
131+
sharp(join(SOURCE_DIR, f))
132+
.trim()
133+
.toBuffer({ resolveWithObject: true })
134+
)
135+
);
136+
137+
const composites = trimmed.map(({ data, info }, i) => ({
138+
input: data,
139+
left: Math.max(0, WINDOW_POSITIONS[i].left),
140+
top: WINDOW_POSITIONS[i].top,
141+
}));
142+
143+
mkdirSync(OUTPUT_DIR, { recursive: true });
144+
145+
const base2x = sharp({
146+
create: {
147+
width: CANVAS_WIDTH,
148+
height: CANVAS_HEIGHT,
149+
channels: 3,
150+
background: bg,
151+
},
152+
});
153+
154+
const jpg2x = join(OUTPUT_DIR, `pika-screenshot-${mode}@2x.jpg`);
155+
const jpg1x = join(OUTPUT_DIR, `pika-screenshot-${mode}.jpg`);
156+
157+
const composited = await base2x
158+
.composite(composites)
159+
.jpeg({ quality: 95 })
160+
.toBuffer();
161+
162+
await sharp(composited).toFile(jpg2x);
163+
console.log(` → ${jpg2x}`);
164+
165+
await sharp(composited)
166+
.resize(Math.round(CANVAS_WIDTH / 2), Math.round(CANVAS_HEIGHT / 2))
167+
.jpeg({ quality: 95 })
168+
.toFile(jpg1x);
169+
console.log(` → ${jpg1x}`);
170+
}
171+
172+
async function runWebStep(): Promise<void> {
173+
console.log("\n── Web composites ───────────────────────────────");
174+
await compositeWeb("dark");
175+
await compositeWeb("light");
176+
}
177+
178+
// ---------------------------------------------------------------------------
179+
// Figma export
180+
// ---------------------------------------------------------------------------
181+
182+
async function runFigmaStep(): Promise<void> {
183+
console.log("\n── Figma exports ────────────────────────────────");
184+
mkdirSync(FIGMA_DIR, { recursive: true });
185+
186+
for (const [key, file] of Object.entries(manifest.figma)) {
187+
const src = join(SOURCE_DIR, file);
188+
const dest = join(FIGMA_DIR, `${key}.png`);
189+
190+
await sharp(src).trim().toFile(dest);
191+
console.log(` ${key}.png ← ${file}`);
192+
}
193+
}
194+
195+
// ---------------------------------------------------------------------------
196+
// Main
197+
// ---------------------------------------------------------------------------
198+
199+
(async () => {
200+
console.log(`Pika marketing assets v${version}`);
201+
console.log(`Output: ${OUTPUT_DIR}`);
202+
203+
if (runCapture) await runCaptureStep();
204+
if (runWeb) await runWebStep();
205+
if (runFigma) await runFigmaStep();
206+
207+
console.log("\nDone.");
208+
})();

0 commit comments

Comments
 (0)