From dc4724734030ca4dbef447885627b175e06666ad Mon Sep 17 00:00:00 2001 From: maksarsanjeev Date: Wed, 7 Oct 2026 23:13:47 +0300 Subject: [PATCH] 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 --- NotchBuddy/Sources/App/BotCanvasView.swift | 5 ++-- NotchBuddy/Sources/App/IslandRootView.swift | 5 +++- .../Sources/App/IslandWindowController.swift | 23 ++++++++++++++++--- NotchBuddy/Sources/CoucouKit/ColorHex.swift | 18 ++++++++++++++- scripts/build.sh | 6 +++-- 5 files changed, 48 insertions(+), 9 deletions(-) diff --git a/NotchBuddy/Sources/App/BotCanvasView.swift b/NotchBuddy/Sources/App/BotCanvasView.swift index 9421fad..3e8d320 100644 --- a/NotchBuddy/Sources/App/BotCanvasView.swift +++ b/NotchBuddy/Sources/App/BotCanvasView.swift @@ -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) diff --git a/NotchBuddy/Sources/App/IslandRootView.swift b/NotchBuddy/Sources/App/IslandRootView.swift index 88733aa..2ed5c13 100644 --- a/NotchBuddy/Sources/App/IslandRootView.swift +++ b/NotchBuddy/Sources/App/IslandRootView.swift @@ -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) } } diff --git a/NotchBuddy/Sources/App/IslandWindowController.swift b/NotchBuddy/Sources/App/IslandWindowController.swift index ea92987..a1bfa5e 100644 --- a/NotchBuddy/Sources/App/IslandWindowController.swift +++ b/NotchBuddy/Sources/App/IslandWindowController.swift @@ -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 diff --git a/NotchBuddy/Sources/CoucouKit/ColorHex.swift b/NotchBuddy/Sources/CoucouKit/ColorHex.swift index 161a5aa..dec58f1 100644 --- a/NotchBuddy/Sources/CoucouKit/ColorHex.swift +++ b/NotchBuddy/Sources/CoucouKit/ColorHex.swift @@ -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 { diff --git a/scripts/build.sh b/scripts/build.sh index 7be0e3d..21963a4 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -6,10 +6,12 @@ cd "$(dirname "$0")/../NotchBuddy" D="$HOME/Library/Developer/Xcode/DerivedData/BroV" xcodegen -q LOG=$(mktemp) -xcodebuild -scheme BroV -configuration "${1:-Debug}" -derivedDataPath "$D" -skipPackagePluginValidation -skipMacroValidation build > "$LOG" 2>&1 || true +# Release by default: optimised code (Debug is several times heavier on CPU). +CONF="${1:-Release}" +xcodebuild -scheme BroV -configuration "$CONF" -derivedDataPath "$D" -skipPackagePluginValidation -skipMacroValidation build > "$LOG" 2>&1 || true grep -E 'error:|BUILD (SUCCEEDED|FAILED)' "$LOG" | sort -u grep -q 'BUILD SUCCEEDED' "$LOG" || { echo "build failed, BroV not reinstalled"; exit 1; } -APP="$D/Build/Products/${1:-Debug}/BroV.app" +APP="$D/Build/Products/$CONF/BroV.app" # Stop the running copy. If launchd keeps it alive, unload the job first or it would # restart the old binary mid-copy. PLIST="$HOME/Library/LaunchAgents/local.maksar.brov.plist"