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()
|
@State private var fader = MemojiFader()
|
||||||
|
|
||||||
var body: some View {
|
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
|
Canvas { context, size in
|
||||||
let now = timeline.date.timeIntervalSinceReferenceDate
|
let now = timeline.date.timeIntervalSinceReferenceDate
|
||||||
let dtRaw = min(0.05, now - engine.lastTime)
|
let dtRaw = min(0.05, now - engine.lastTime)
|
||||||
@@ -191,7 +192,7 @@ struct MiniBotCanvasView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TimelineView(.animation) { timeline in
|
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in
|
||||||
Canvas { context, size in
|
Canvas { context, size in
|
||||||
let now = timeline.date.timeIntervalSinceReferenceDate
|
let now = timeline.date.timeIntervalSinceReferenceDate
|
||||||
let dt = min(0.05, now - engine.lastTime)
|
let dt = min(0.05, now - engine.lastTime)
|
||||||
|
|||||||
@@ -498,7 +498,9 @@ struct IslandContentView: View {
|
|||||||
.animation(.easeInOut(duration: 0.2), value: state.view == .confused)
|
.animation(.easeInOut(duration: 0.2), value: state.view == .confused)
|
||||||
|
|
||||||
ZStack {
|
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
|
let active = state.view == v
|
||||||
// Views that fill available height instead of the fixed 98pt content frame:
|
// Views that fill available height instead of the fixed 98pt content frame:
|
||||||
// chat (prompt) is always flexible; mail is flexible only when active so
|
// chat (prompt) is always flexible; mail is flexible only when active so
|
||||||
@@ -514,6 +516,7 @@ struct IslandContentView: View {
|
|||||||
.opacity(active ? 1 : 0)
|
.opacity(active ? 1 : 0)
|
||||||
.scaleEffect(active ? 1 : 0.97)
|
.scaleEffect(active ? 1 : 0.97)
|
||||||
.allowsHitTesting(active)
|
.allowsHitTesting(active)
|
||||||
|
.transition(.opacity)
|
||||||
.animation(anim, value: state.view)
|
.animation(anim, value: state.view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,16 +217,33 @@ final class IslandWindowController: NSWindowController {
|
|||||||
|
|
||||||
// MARK: - 60 Hz polling loop
|
// MARK: - 60 Hz polling loop
|
||||||
|
|
||||||
private func startPolling() {
|
private var pollInterval: TimeInterval = 0
|
||||||
frameTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
|
|
||||||
|
/// 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 }
|
guard let self else { return }
|
||||||
Task { @MainActor in self.pollFrame() }
|
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() {
|
private func pollFrame() {
|
||||||
guard let panel = window as? IslandPanel else { return }
|
guard let panel = window as? IslandPanel else { return }
|
||||||
|
let want = desiredPollInterval
|
||||||
|
if abs(want - pollInterval) > 0.001 { startPolling(interval: want) }
|
||||||
|
|
||||||
let mouse = NSEvent.mouseLocation
|
let mouse = NSEvent.mouseLocation
|
||||||
|
|
||||||
|
|||||||
@@ -3,28 +3,44 @@ import CoreGraphics
|
|||||||
|
|
||||||
// Hex color helpers shared by the Mac app and the iPhone app.
|
// 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
|
// MARK: - Color from hex string
|
||||||
|
|
||||||
extension Color {
|
extension Color {
|
||||||
init(hex: String) {
|
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 h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||||
let val = UInt64(h, radix: 16) ?? 0
|
let val = UInt64(h, radix: 16) ?? 0
|
||||||
let r = Double((val >> 16) & 0xFF) / 255
|
let r = Double((val >> 16) & 0xFF) / 255
|
||||||
let g = Double((val >> 8) & 0xFF) / 255
|
let g = Double((val >> 8) & 0xFF) / 255
|
||||||
let b = Double( val & 0xFF) / 255
|
let b = Double( val & 0xFF) / 255
|
||||||
self.init(red: r, green: g, blue: b)
|
self.init(red: r, green: g, blue: b)
|
||||||
|
HexCache.lock.lock(); HexCache.colors[hex] = self; HexCache.lock.unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CGColor from hex string
|
// MARK: - CGColor from hex string
|
||||||
|
|
||||||
func cgColorFromHex(_ hex: String) -> CGColor? {
|
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: "#"))
|
let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||||
guard let val = UInt64(h, radix: 16) else { return nil }
|
guard let val = UInt64(h, radix: 16) else { return nil }
|
||||||
let r = CGFloat((val >> 16) & 0xFF) / 255
|
let r = CGFloat((val >> 16) & 0xFF) / 255
|
||||||
let g = CGFloat((val >> 8) & 0xFF) / 255
|
let g = CGFloat((val >> 8) & 0xFF) / 255
|
||||||
let b = CGFloat( val & 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 {
|
extension CGColor {
|
||||||
|
|||||||
+4
-2
@@ -6,10 +6,12 @@ cd "$(dirname "$0")/../NotchBuddy"
|
|||||||
D="$HOME/Library/Developer/Xcode/DerivedData/BroV"
|
D="$HOME/Library/Developer/Xcode/DerivedData/BroV"
|
||||||
xcodegen -q
|
xcodegen -q
|
||||||
LOG=$(mktemp)
|
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 -E 'error:|BUILD (SUCCEEDED|FAILED)' "$LOG" | sort -u
|
||||||
grep -q 'BUILD SUCCEEDED' "$LOG" || { echo "build failed, BroV not reinstalled"; exit 1; }
|
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
|
# Stop the running copy. If launchd keeps it alive, unload the job first or it would
|
||||||
# restart the old binary mid-copy.
|
# restart the old binary mid-copy.
|
||||||
PLIST="$HOME/Library/LaunchAgents/local.maksar.brov.plist"
|
PLIST="$HOME/Library/LaunchAgents/local.maksar.brov.plist"
|
||||||
|
|||||||
Reference in New Issue
Block a user