Efficiency: Release builds by default, only the visible island screen is built, character at 30 fps, adaptive mouse polling (60/30/10 Hz), hex colours parsed once

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-07 23:13:47 +03:00
parent 91878fa2e3
commit dc47247340
5 changed files with 48 additions and 9 deletions
+3 -2
View File
@@ -14,7 +14,8 @@ struct BotCanvasView: View {
@State private var fader = MemojiFader()
var body: some View {
TimelineView(.animation(paused: state.mode == .hidden)) { timeline in
// 30 fps is plenty for the character (the display would drive 120 on ProMotion).
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: state.mode == .hidden)) { timeline in
Canvas { context, size in
let now = timeline.date.timeIntervalSinceReferenceDate
let dtRaw = min(0.05, now - engine.lastTime)
@@ -191,7 +192,7 @@ struct MiniBotCanvasView: View {
}
var body: some View {
TimelineView(.animation) { timeline in
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in
Canvas { context, size in
let now = timeline.date.timeIntervalSinceReferenceDate
let dt = min(0.05, now - engine.lastTime)
+4 -1
View File
@@ -498,7 +498,9 @@ struct IslandContentView: View {
.animation(.easeInOut(duration: 0.2), value: state.view == .confused)
ZStack {
ForEach(IslandView.allCases, id: \.self) { v in
// Only the visible screen is built (plus the chat, to keep its draft and
// scroll): hidden screens used to keep their animations and timers running.
ForEach(IslandView.allCases.filter { $0 == state.view || $0 == .prompt }, id: \.self) { v in
let active = state.view == v
// Views that fill available height instead of the fixed 98pt content frame:
// chat (prompt) is always flexible; mail is flexible only when active so
@@ -514,6 +516,7 @@ struct IslandContentView: View {
.opacity(active ? 1 : 0)
.scaleEffect(active ? 1 : 0.97)
.allowsHitTesting(active)
.transition(.opacity)
.animation(anim, value: state.view)
}
}
@@ -217,16 +217,33 @@ final class IslandWindowController: NSWindowController {
// MARK: - 60 Hz polling loop
private func startPolling() {
frameTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
private var pollInterval: TimeInterval = 0
/// Mouse polling rate follows what's on screen: 60 Hz only while dragging (ghost,
/// chat handle), 30 Hz with the island open (the character draws at 30 fps),
/// 10 Hz when folded (hover still reacts within 0.1 s).
private var desiredPollInterval: TimeInterval {
if inAttachDrag || attachDragStart != nil || state.chatDragHeight != nil { return 1.0 / 60.0 }
return state.mode == .expanded ? 1.0 / 30.0 : 1.0 / 10.0
}
private func startPolling(interval: TimeInterval = 1.0 / 10.0) {
frameTimer?.invalidate()
pollInterval = interval
let t = Timer(timeInterval: interval, repeats: true) { [weak self] _ in
guard let self else { return }
Task { @MainActor in self.pollFrame() }
}
RunLoop.main.add(frameTimer!, forMode: .common)
// Let macOS coalesce wakeups with other timers.
t.tolerance = interval * 0.2
RunLoop.main.add(t, forMode: .common)
frameTimer = t
}
private func pollFrame() {
guard let panel = window as? IslandPanel else { return }
let want = desiredPollInterval
if abs(want - pollInterval) > 0.001 { startPolling(interval: want) }
let mouse = NSEvent.mouseLocation
+17 -1
View File
@@ -3,28 +3,44 @@ import CoreGraphics
// Hex color helpers shared by the Mac app and the iPhone app.
// Views ask for the same few dozen hex colours on every frame: parse each string once.
private enum HexCache {
nonisolated(unsafe) static var colors: [String: Color] = [:]
nonisolated(unsafe) static var cgColors: [String: CGColor] = [:]
static let lock = NSLock()
}
// MARK: - Color from hex string
extension Color {
init(hex: String) {
HexCache.lock.lock()
if let c = HexCache.colors[hex] { HexCache.lock.unlock(); self = c; return }
HexCache.lock.unlock()
let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
let val = UInt64(h, radix: 16) ?? 0
let r = Double((val >> 16) & 0xFF) / 255
let g = Double((val >> 8) & 0xFF) / 255
let b = Double( val & 0xFF) / 255
self.init(red: r, green: g, blue: b)
HexCache.lock.lock(); HexCache.colors[hex] = self; HexCache.lock.unlock()
}
}
// MARK: - CGColor from hex string
func cgColorFromHex(_ hex: String) -> CGColor? {
HexCache.lock.lock()
if let c = HexCache.cgColors[hex] { HexCache.lock.unlock(); return c }
HexCache.lock.unlock()
let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
guard let val = UInt64(h, radix: 16) else { return nil }
let r = CGFloat((val >> 16) & 0xFF) / 255
let g = CGFloat((val >> 8) & 0xFF) / 255
let b = CGFloat( val & 0xFF) / 255
return CGColor(red: r, green: g, blue: b, alpha: 1)
let c = CGColor(red: r, green: g, blue: b, alpha: 1)
HexCache.lock.lock(); HexCache.cgColors[hex] = c; HexCache.lock.unlock()
return c
}
extension CGColor {