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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+4
-2
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user