| 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 |