返回 CodeWhale
CodewhalePetApp.swift
根目录 / pet / macos / CodewhalePetApp.swift
1 import SwiftUI
2 import AppKit
3 import ServiceManagement
4
5 @main
6 struct CodewhalePetApp: App {
7 @StateObject private var host = PetHost(points: WHALE_POINTS)
8 @Environment(\.openWindow) private var openWindow
9 @State private var login = SMAppService.mainApp.status == .enabled
10 @State private var notice = ""
11 @State private var openedCompanion = false
12 @State private var pendingSource: PetSource?
13 @State private var confirmLeave = false
14 var body: some Scene {
15 Window("Codewhale · Shared habitat", id: "habitat") {
16 VStack(alignment: .leading, spacing: 14) {
17 Text("A WHALE LIVING IN CODE").font(.caption.monospaced()).foregroundStyle(.secondary)
18 PetSharedHabitat(host: host.shared, still: host.still)
19 Toggle("Still", isOn: $host.still)
20 }.padding(22).frame(minWidth: 420, minHeight: 360).preferredColorScheme(.dark)
21 }.defaultSize(width: 740, height: 520)
22 MenuBarExtra {
23 VStack(spacing: 12) {
24 HStack { Text("Codewhale").font(.headline); Spacer(); Text("A living field").font(.caption).foregroundStyle(.secondary) }
25 Button("Open companion window") { openWindow(id: "habitat"); NSApplication.shared.activate(ignoringOtherApps: true) }
26 PetHabitatView(host: host).frame(height: 250)
27 Picker("World", selection: Binding(get: { host.source }, set: { next in
28 if !host.selectSource(next) { pendingSource = next; confirmLeave = true }
29 })) { ForEach(PetSource.allCases) { Text($0.label).tag($0) } }.pickerStyle(.segmented)
30 HStack {
31 Toggle("Still", isOn: $host.still)
32 Toggle("Launch at login", isOn: $login).onChange(of: login) { _, value in
33 do { if value { try SMAppService.mainApp.register() } else { try SMAppService.mainApp.unregister() }; notice = "" }
34 catch { notice = error.localizedDescription; login = SMAppService.mainApp.status == .enabled }
35 }
36 }.font(.caption)
37 if host.source == .file { Text("Local file study: ~/.codewhale/pet-state").font(.caption2).textSelection(.enabled) }
38 if !notice.isEmpty { Text(notice).font(.caption).foregroundStyle(.secondary) }
39 HStack {
40 Menu("Earlier recordings") {
41 ForEach(host.archives, id: \.self) { name in
42 Button(petArchiveLabel(name)) {
43 do {
44 let data = try host.archivedRecording(name)
45 let panel = NSSavePanel(); panel.nameFieldStringValue = "codewhale-pet-earlier.json"
46 if panel.runModal() == .OK, let url = panel.url { try data.write(to: url, options: .atomic); notice = "Earlier recording saved." }
47 } catch { notice = error.localizedDescription }
48 }
49 }
50 }.disabled(host.archives.isEmpty)
51 Button("Save recording…") {
52 let panel = NSSavePanel(); panel.nameFieldStringValue = "codewhale-pet-replay.json"
53 if panel.runModal() == .OK, let url = panel.url {
54 if host.source == .live {
55 Task { do { try await host.shared.export().write(to: url, options: .atomic); notice = "Shared recording saved." } catch { notice = error.localizedDescription } }
56 } else { do { try host.exportRecording(to: url); notice = "Recording saved. Files over 8 MiB open in the browser." } catch { notice = error.localizedDescription } }
57 }
58 }
59 Spacer(); Button("Quit") { host.suspend(true); NSApplication.shared.terminate(nil) }
60 }
61 }.padding(16).frame(width: 390)
62 .alert("This world has not been saved", isPresented: $confirmLeave) {
63 Button("Keep this world", role: .cancel) { pendingSource = nil }
64 Button("Leave without saving", role: .destructive) {
65 if let next = pendingSource { host.selectSource(next, discardingUnsaved: true) }
66 pendingSource = nil
67 }
68 } message: { Text("Keep this world and use Save recording to retain the current visit. Leaving opens the other world and discards this visit’s unsaved progress.") }
69 } label: {
70 PetMenuIcon(host: host)
71 .onAppear {
72 host.setLiveFile(URL(fileURLWithPath: NSHomeDirectory() + "/.codewhale/pet-state"))
73 if ProcessInfo.processInfo.arguments.contains("--companion") && !openedCompanion {
74 openedCompanion = true; openWindow(id: "habitat"); NSApplication.shared.activate(ignoringOtherApps: true)
75 }
76 }
77 }.menuBarExtraStyle(.window)
78 }
79 }
80
81 private struct PetMenuIcon: View {
82 @ObservedObject var host: PetHost
83 var body: some View {
84 Canvas { ctx, size in
85 guard let core = host.core else {
86 let path = Path(ellipseIn: CGRect(x:3,y:7,width:17,height:9));ctx.stroke(path,with:.color(.primary),lineWidth:1)
87 var tail = Path();tail.move(to:CGPoint(x:18,y:11));tail.addLine(to:CGPoint(x:23,y:6));tail.addLine(to:CGPoint(x:22,y:14));ctx.stroke(tail,with:.color(.primary),lineWidth:1)
88 return
89 }
90 let lay = petLayout(w: size.width, h: size.height, state: core.frame.state), frame = core.sim.frame
91 for q in core.sim.p {
92 let dot = Path(ellipseIn: CGRect(x: lay.ox + q.x * lay.scale * lay.flipX, y: lay.oy + q.y * lay.scale, width: 0.7, height: 0.7))
93 if frame.hollow { ctx.stroke(dot, with: .color(.primary.opacity(0.55)), lineWidth: 0.3) }
94 else { ctx.fill(dot, with: .color(.primary.opacity(0.8))) }
95 }
96 }.frame(width: 24, height: 24)
97 .accessibilityLabel(host.core.map { petA11yLabel(sim: $0.sim, state: $0.frame.state) } ?? "Codewhale shared habitat")
98 }
99 }
100
100 lines Plain Text