Import Coucou 0.1.9 (Louis-CFM/coucou@83708fe), Mac app only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,631 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Top-level SwiftUI view rendered inside the 720×320 transparent panel.
|
||||
/// The island is drawn at the top-center; everything else is transparent and click-through.
|
||||
/// Note: drag-drop is handled at the AppKit level in IslandWindowController (FileDropNSView),
|
||||
/// not in SwiftUI, to avoid interfering with SwiftUI hit-testing.
|
||||
struct IslandRootView: View {
|
||||
@EnvironmentObject var state: AppState
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
IslandContainer(state: state)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Island container
|
||||
|
||||
struct IslandContainer: View {
|
||||
@ObservedObject var state: AppState
|
||||
@State private var islandWidth: CGFloat = IslandConst.notchWidth
|
||||
@State private var islandHeight: CGFloat = IslandConst.notchHeight
|
||||
@State private var cornerRadius: CGFloat = IslandConst.roundedCorner
|
||||
// topRadius > 0 → convex expanded corners; < 0 → concave ear cutouts
|
||||
@State private var islandTopRadius: CGFloat = 0
|
||||
@State private var greetNotif: Bool = false
|
||||
|
||||
private let openSpring = Animation.spring(response: 0.5, dampingFraction: 0.72)
|
||||
private let closeEase = Animation.timingCurve(0.45, 0, 0.2, 1, duration: 0.34)
|
||||
|
||||
private var chatPromptHeight: CGFloat {
|
||||
let base: CGFloat = 240
|
||||
let perMsg: CGFloat = 40
|
||||
return min(300, base + CGFloat(state.chatHistory.count) * perMsg)
|
||||
}
|
||||
|
||||
/// Pixels the content must be pushed down to clear the concave ear transparent area.
|
||||
/// = 0 in expanded mode (no ears), = earRadius in compact/notch mode.
|
||||
private var earOffset: CGFloat { max(0, -islandTopRadius) }
|
||||
|
||||
var body: some View {
|
||||
// Canvas active during drag-over (.upload), post-drop animation (.uploading),
|
||||
// AND choose overlay (.choose) — canvas handles the full sequence through user action.
|
||||
// Engine deactivates when user clicks a canvas choose button or navigates away.
|
||||
let uploadActive = state.mode == .expanded
|
||||
&& UploadSequenceEngine.shared.isActive
|
||||
&& (state.view == .upload || state.view == .uploading || state.view == .choose)
|
||||
|
||||
let greetingActive = state.mode == .expanded && state.view == .greeting
|
||||
|
||||
return ZStack(alignment: .topLeading) {
|
||||
// Black island shape
|
||||
IslandShape(width: islandWidth, height: islandHeight,
|
||||
cornerRadius: cornerRadius, topRadius: islandTopRadius)
|
||||
.fill(Color.black)
|
||||
|
||||
// Content
|
||||
if state.mode == .expanded {
|
||||
if greetingActive {
|
||||
// Greeting canvas: fixed 640-wide, centered by offset so x=320 aligns with island center
|
||||
GreetingCanvasView(state: state)
|
||||
.frame(width: IslandConst.expandedWidth, height: 150)
|
||||
.offset(x: (islandWidth - IslandConst.expandedWidth) / 2)
|
||||
.clipShape(IslandShape(width: islandWidth, height: islandHeight,
|
||||
cornerRadius: cornerRadius, topRadius: islandTopRadius))
|
||||
.transition(.opacity)
|
||||
} else if uploadActive {
|
||||
ZStack(alignment: .topLeading) {
|
||||
UploadCanvasView(state: state)
|
||||
.frame(width: islandWidth, height: islandHeight)
|
||||
.clipShape(IslandShape(width: islandWidth, height: islandHeight,
|
||||
cornerRadius: cornerRadius, topRadius: islandTopRadius))
|
||||
// Header overlaid: canvas CARD_Y=42 aligns exactly with header bottom,
|
||||
// matching normal view proportions (8pt top + 34pt header + card + 10pt bottom).
|
||||
IslandHeader(state: state)
|
||||
.frame(width: islandWidth, height: 34)
|
||||
.offset(y: 8)
|
||||
}
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
IslandContentView(state: state)
|
||||
.frame(width: islandWidth, height: islandHeight - earOffset)
|
||||
.offset(y: earOffset)
|
||||
.clipShape(IslandShape(width: islandWidth, height: islandHeight,
|
||||
cornerRadius: cornerRadius, topRadius: islandTopRadius))
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
// Single BotPlacement — always alive in the view tree so spring animations
|
||||
// fire from the current position (e.g. choose at 60,101) when canvas deactivates.
|
||||
// Hidden during upload canvas or greeting (both draw their own Mochi).
|
||||
BotPlacement(state: state, islandW: islandWidth, islandH: islandHeight)
|
||||
// Keep idle animations inside the resting strip. Expanded views
|
||||
// retain the panel's full height for particles and hands.
|
||||
.mask(alignment: .topLeading) {
|
||||
Rectangle().frame(width: islandWidth,
|
||||
height: state.mode == .expanded ? 320 : islandHeight)
|
||||
}
|
||||
.opacity(uploadActive || greetingActive ? 0 : 1)
|
||||
.animation(.easeInOut(duration: 0.25), value: uploadActive || greetingActive)
|
||||
|
||||
CountdownBar(state: state, islandW: islandWidth)
|
||||
|
||||
Group {
|
||||
if state.mode == .compact {
|
||||
CompactMiniGrid(state: state)
|
||||
.scaleEffect(IslandRestingLayout(width: islandWidth, height: islandHeight).miniGridScale)
|
||||
.position(x: islandWidth - 40, y: islandHeight / 2)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: state.mode == .compact)
|
||||
}
|
||||
.frame(width: islandWidth, height: islandHeight, alignment: .topLeading)
|
||||
.onChange(of: state.mode) { oldMode, newMode in
|
||||
let shrinking = modeOrder(newMode) < modeOrder(oldMode)
|
||||
let anim = shrinking ? closeEase : openSpring
|
||||
let (w, h) = islandSize(mode: newMode, view: state.view,
|
||||
progress: state.uploadProgress,
|
||||
nw: state.notchWidth, nh: state.notchHeight)
|
||||
let cr = newMode == .expanded ? IslandConst.expandedCorner : IslandConst.roundedCorner
|
||||
let tr: CGFloat = 0
|
||||
withAnimation(anim) {
|
||||
islandWidth = w
|
||||
islandHeight = (newMode == .expanded && state.view == .prompt) ? chatPromptHeight : h
|
||||
cornerRadius = cr
|
||||
islandTopRadius = tr
|
||||
}
|
||||
}
|
||||
.onChange(of: state.view) { _, newView in
|
||||
guard state.mode == .expanded else { return }
|
||||
// Deactivate engine if user navigates outside the upload flow
|
||||
let uploadViews: Set<IslandView> = [.upload, .uploading, .choose]
|
||||
if UploadSequenceEngine.shared.isActive && !uploadViews.contains(newView) {
|
||||
UploadSequenceEngine.shared.deactivate()
|
||||
}
|
||||
let (w, h) = islandSize(mode: .expanded, view: newView,
|
||||
progress: state.uploadProgress,
|
||||
nw: state.notchWidth, nh: state.notchHeight)
|
||||
withAnimation(openSpring) {
|
||||
islandWidth = w
|
||||
islandHeight = newView == .prompt ? chatPromptHeight : h
|
||||
}
|
||||
}
|
||||
.onChange(of: state.chatHistory.count) { _, _ in
|
||||
guard state.mode == .expanded, state.view == .prompt else { return }
|
||||
withAnimation(openSpring) { islandHeight = chatPromptHeight }
|
||||
}
|
||||
.onAppear {
|
||||
let (w, h) = islandSize(mode: state.mode, view: state.view,
|
||||
progress: state.uploadProgress,
|
||||
nw: state.notchWidth, nh: state.notchHeight)
|
||||
islandWidth = w
|
||||
islandHeight = state.view == .prompt ? chatPromptHeight : h
|
||||
cornerRadius = state.mode == .expanded ? IslandConst.expandedCorner : IslandConst.roundedCorner
|
||||
islandTopRadius = 0
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .botGreet)) { _ in
|
||||
greetNotif.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
private func modeOrder(_ m: IslandMode) -> Int {
|
||||
switch m { case .hidden: return 0; case .compact: return 1; case .expanded: return 2 }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Island shape
|
||||
//
|
||||
// topRadius > 0 → convex rounded top corners (expanded mode)
|
||||
// topRadius < 0 → concave ear cutouts, |topRadius| = ear radius (compact/notch mode)
|
||||
// topRadius = 0 → sharp top corners (transient during animation)
|
||||
|
||||
struct IslandShape: Shape {
|
||||
var width: CGFloat
|
||||
var height: CGFloat
|
||||
var cornerRadius: CGFloat // bottom corners
|
||||
var topRadius: CGFloat // see above
|
||||
|
||||
var animatableData: AnimatablePair<AnimatablePair<AnimatablePair<CGFloat, CGFloat>, CGFloat>, CGFloat> {
|
||||
get { .init(.init(.init(width, height), cornerRadius), topRadius) }
|
||||
set {
|
||||
width = newValue.first.first.first
|
||||
height = newValue.first.first.second
|
||||
cornerRadius = newValue.first.second
|
||||
topRadius = newValue.second
|
||||
}
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let cr = max(0, cornerRadius)
|
||||
var p = Path()
|
||||
|
||||
if topRadius >= 0 {
|
||||
// ── Convex rounded top corners (expanded) ──────────────────────────
|
||||
let tr = min(topRadius, min(width / 2, height / 2))
|
||||
p.move(to: CGPoint(x: tr, y: 0))
|
||||
p.addLine(to: CGPoint(x: width - tr, y: 0))
|
||||
// Top-right convex corner
|
||||
p.addArc(center: CGPoint(x: width - tr, y: tr), radius: tr,
|
||||
startAngle: .degrees(270), endAngle: .degrees(0), clockwise: false)
|
||||
// Right edge
|
||||
p.addLine(to: CGPoint(x: width, y: height - cr))
|
||||
// Bottom-right corner
|
||||
p.addArc(center: CGPoint(x: width - cr, y: height - cr), radius: cr,
|
||||
startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)
|
||||
// Bottom edge
|
||||
p.addLine(to: CGPoint(x: cr, y: height))
|
||||
// Bottom-left corner
|
||||
p.addArc(center: CGPoint(x: cr, y: height - cr), radius: cr,
|
||||
startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false)
|
||||
// Left edge
|
||||
p.addLine(to: CGPoint(x: 0, y: tr))
|
||||
// Top-left convex corner
|
||||
p.addArc(center: CGPoint(x: tr, y: tr), radius: tr,
|
||||
startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)
|
||||
} else {
|
||||
// ── Concave ear cutouts (compact / notch) ─────────────────────────
|
||||
let er = -topRadius // positive ear radius
|
||||
p.move(to: CGPoint(x: 0, y: 0))
|
||||
// Top-left ear
|
||||
p.addArc(center: CGPoint(x: 0, y: er), radius: er,
|
||||
startAngle: .degrees(270), endAngle: .degrees(0), clockwise: false)
|
||||
// Top edge
|
||||
p.addLine(to: CGPoint(x: width - er, y: er))
|
||||
// Top-right ear
|
||||
p.addArc(center: CGPoint(x: width, y: er), radius: er,
|
||||
startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)
|
||||
// Right edge
|
||||
p.addLine(to: CGPoint(x: width, y: height - cr))
|
||||
// Bottom-right corner
|
||||
p.addArc(center: CGPoint(x: width - cr, y: height - cr), radius: cr,
|
||||
startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)
|
||||
// Bottom edge
|
||||
p.addLine(to: CGPoint(x: cr, y: height))
|
||||
// Bottom-left corner
|
||||
p.addArc(center: CGPoint(x: cr, y: height - cr), radius: cr,
|
||||
startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false)
|
||||
// Left edge back to top-left corner
|
||||
p.addLine(to: CGPoint(x: 0, y: 0))
|
||||
}
|
||||
|
||||
p.closeSubpath()
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bot placement helper
|
||||
|
||||
struct BotPlacement: View {
|
||||
@ObservedObject var state: AppState
|
||||
let islandW: CGFloat
|
||||
let islandH: CGFloat
|
||||
|
||||
var body: some View {
|
||||
let (cx, cy, diameter, opacity) = botPosition(mode: state.mode, view: state.view, islandW: islandW, islandH: islandH, uploadProgress: state.uploadProgress, hasNotch: state.hasNotch)
|
||||
let canvasSize = diameter / 0.6
|
||||
let overhang: CGFloat = 40
|
||||
let isUploading = state.view == .uploading
|
||||
|
||||
Group {
|
||||
// No glow in uploading mode — the tiny dot doesn't need it
|
||||
if state.mode == .expanded && !isUploading {
|
||||
Circle()
|
||||
.fill(RadialGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: botGlowColor(state.effectiveState), location: 0),
|
||||
.init(color: .clear, location: 0.62)
|
||||
]),
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: diameter * 1.1
|
||||
))
|
||||
.frame(width: diameter * 2.2, height: diameter * 2.2)
|
||||
.blur(radius: 6)
|
||||
.opacity(botGlowOpacity(state.effectiveState))
|
||||
.position(x: cx, y: cy)
|
||||
.animation(.easeInOut(duration: 0.4), value: state.effectiveState)
|
||||
}
|
||||
|
||||
// Uploading: no particle overhang (no hearts during upload), positioned directly at cy.
|
||||
// BotEngine cy = H/2 + 0 + oy*R + R*0.06 ≈ H/2 (body centered in canvas).
|
||||
// With .position(x:y:) placing the frame center at (uploadCx, cy), bot is at cy ✓.
|
||||
//
|
||||
// Normal: extra 40pt canvas at top for heart particles; position offset up by 20pt;
|
||||
// BotEngine compensates with cy = H/2 + particleOverhang/2 + oy*R + R*0.06.
|
||||
if isUploading {
|
||||
TimelineView(.animation) { tl in
|
||||
let elapsed: Double = {
|
||||
guard let start = state.uploadStartTime else { return 0 }
|
||||
return tl.date.timeIntervalSince(start)
|
||||
}()
|
||||
let t = min(1.0, max(0, elapsed / state.uploadDuration))
|
||||
// cx = 36 + 526*t: bot center at fill right edge (bar left=36, width=526)
|
||||
let uploadCx = 36 + CGFloat(t * (2 - t)) * 526
|
||||
BotCanvasView(state: state, particleOverhang: 0)
|
||||
.frame(width: canvasSize, height: canvasSize)
|
||||
.opacity(state.isDraggingBot || state.mochiOnDesktop ? 0 : opacity)
|
||||
.position(x: uploadCx, y: cy)
|
||||
}
|
||||
.transition(.scale(scale: 0.01, anchor: .center).combined(with: .opacity))
|
||||
} else {
|
||||
BotCanvasView(state: state, particleOverhang: overhang)
|
||||
.frame(width: canvasSize, height: canvasSize + overhang)
|
||||
.opacity(state.isDraggingBot || state.mochiOnDesktop ? 0 : opacity)
|
||||
.position(x: cx, y: cy - overhang / 2)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.72), value: cx)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.72), value: cy)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.72), value: canvasSize)
|
||||
.transition(.scale(scale: 0.01, anchor: .center).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
// Branch switch (uploading ↔ normal) animates with a fast spring: uploading dot
|
||||
// scales out at bar-end while normal bot scales in at choose position.
|
||||
.animation(.spring(response: 0.36, dampingFraction: 0.72), value: isUploading)
|
||||
// Slap, drag, and hover are handled by the AppKit NSEvent monitor in
|
||||
// IslandWindowController — not SwiftUI gestures — so this is safe.
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
private func botGlowColor(_ s: BotState) -> Color {
|
||||
switch s {
|
||||
case .working: return Color(hex: "#3B9EFF")
|
||||
case .thinking: return Color(hex: "#A78BFA")
|
||||
case .searching: return Color(hex: "#6366F1")
|
||||
case .approval: return Color(hex: "#F5A524")
|
||||
case .error: return Color(hex: "#F4505E")
|
||||
case .finished: return Color(hex: "#34D399")
|
||||
case .ratelimit: return Color(hex: "#F59E0B")
|
||||
default: return Color.white
|
||||
}
|
||||
}
|
||||
|
||||
private func botGlowOpacity(_ s: BotState) -> Double {
|
||||
switch s {
|
||||
case .idle, .sleeping: return 0.15
|
||||
case .dizzy: return 0.0
|
||||
default: return 0.65
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func botPosition(mode: IslandMode, view: IslandView, islandW: CGFloat, islandH: CGFloat, uploadProgress: Double, hasNotch: Bool = true) -> (CGFloat, CGFloat, CGFloat, Double) {
|
||||
let resting = IslandRestingLayout(width: islandW, height: islandH)
|
||||
switch mode {
|
||||
case .hidden:
|
||||
return hasNotch ? (46, 16, 6, 0)
|
||||
: (islandW / 2, resting.botCenterY, resting.botDiameter, 1)
|
||||
case .compact: return (40, resting.botCenterY, resting.botDiameter, 1)
|
||||
case .expanded:
|
||||
let layout = IslandConst.viewLayouts[view]!
|
||||
let diameter = layout.botDiameter
|
||||
// Uploading: Mochi dot rides the leading edge of the progress fill.
|
||||
// Bar in island coords: left=36, width=526. cx = 36 + progress*526 (dot center at fill right edge).
|
||||
// cy comes from ViewLayout.botY (bar center in island coords).
|
||||
if view == .uploading {
|
||||
let cx = 36 + CGFloat(uploadProgress) * 526
|
||||
return (cx, layout.botY ?? 103, diameter, 1)
|
||||
}
|
||||
let cx = layout.botX
|
||||
let cy: CGFloat
|
||||
if let fixedY = layout.botY {
|
||||
cy = fixedY
|
||||
} else {
|
||||
// Center of the fixed 84pt card (VStack top=8, header=34 → content starts at y=42)
|
||||
let headerBottom: CGFloat = 42
|
||||
let cardH: CGFloat = 84
|
||||
cy = headerBottom + (islandH - headerBottom - cardH) / 2 + cardH / 2
|
||||
}
|
||||
return (cx, cy, diameter, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Countdown bar
|
||||
|
||||
struct CountdownBar: View {
|
||||
@ObservedObject var state: AppState
|
||||
let islandW: CGFloat
|
||||
@State private var barWidth: CGFloat = 0
|
||||
@State private var timer: Timer? = nil
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { _ in
|
||||
Rectangle()
|
||||
.fill(Color.white.opacity(0.35))
|
||||
.frame(width: barWidth, height: 2)
|
||||
.cornerRadius(2)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
}
|
||||
.onAppear { startTimer() }
|
||||
.onDisappear { timer?.invalidate() }
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
|
||||
updateBar()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateBar() {
|
||||
guard state.mode == .expanded && !state.isPinned else {
|
||||
barWidth = 0
|
||||
return
|
||||
}
|
||||
let autoClose = state.autoCloseInterval
|
||||
let window = min(10.0, autoClose * 0.6)
|
||||
let elapsed = Date.now.timeIntervalSince(state.lastActivity)
|
||||
let remaining = autoClose - elapsed
|
||||
if remaining < window {
|
||||
barWidth = max(0, CGFloat(remaining / window) * 160)
|
||||
} else {
|
||||
barWidth = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Island content (header + views, only in expanded mode)
|
||||
|
||||
struct IslandContentView: View {
|
||||
@ObservedObject var state: AppState
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
IslandHeader(state: state)
|
||||
.frame(height: 34)
|
||||
.opacity(state.view == .confused ? 0 : 1)
|
||||
.animation(.easeInOut(duration: 0.2), value: state.view == .confused)
|
||||
|
||||
ZStack {
|
||||
ForEach(IslandView.allCases, 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
|
||||
// it doesn't push the ZStack taller when inactive.
|
||||
let isTall = v == .prompt || (v == .mail && active)
|
||||
let anim: Animation = active
|
||||
? .spring(response: 0.4, dampingFraction: 0.8).delay(0.16)
|
||||
: .easeIn(duration: 0.16)
|
||||
IslandViewContent(view: v, state: state)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: isTall ? nil : 98)
|
||||
.frame(minHeight: (isTall && !active) ? 0 : nil, maxHeight: isTall ? .infinity : nil)
|
||||
.opacity(active ? 1 : 0)
|
||||
.scaleEffect(active ? 1 : 0.97)
|
||||
.allowsHitTesting(active)
|
||||
.animation(anim, value: state.view)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 10)
|
||||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Island header (tabs + icons)
|
||||
|
||||
struct IslandHeader: View {
|
||||
@ObservedObject var state: AppState
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Left: tab capsules
|
||||
HStack(spacing: 5) {
|
||||
TabButton(icon: "house.fill", view: .overview, state: state)
|
||||
TabButton(icon: "bubble.left.fill", view: .prompt, state: state, preAction: {
|
||||
#if !APPSTORE
|
||||
if state.promptContext == nil {
|
||||
state.promptContext = WindowContextCapture.captureActive(from: state.lastExternalApp)
|
||||
}
|
||||
#endif
|
||||
})
|
||||
TabButton(icon: "plus", view: .upload, state: state)
|
||||
}
|
||||
.padding(.leading, 14)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Right: plan pill (GitHub build, home view only) + action icons
|
||||
HStack(spacing: 8) {
|
||||
#if !APPSTORE
|
||||
if state.view == .overview && state.showPlanInNotch && state.planRelayInstalled {
|
||||
ClaudePlanHeaderPill(state: state)
|
||||
}
|
||||
#endif
|
||||
HStack(spacing: 14) {
|
||||
Button(action: {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
state.view = .settings
|
||||
}
|
||||
}) {
|
||||
Image(systemName: state.view == .settings ? "gearshape.fill" : "gearshape")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(state.view == .settings ? Color(hex: "#F5F6F8") : Color(hex: "#8E939C"))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: { state.soundEnabled.toggle() }) {
|
||||
Image(systemName: state.soundEnabled ? "speaker.wave.2" : "speaker.slash")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Color(hex: "#8E939C"))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct TabButton: View {
|
||||
let icon: String
|
||||
let view: IslandView
|
||||
@ObservedObject var state: AppState
|
||||
var preAction: (() -> Void)? = nil
|
||||
@State private var isHovered = false
|
||||
|
||||
private var isOn: Bool {
|
||||
if view == .overview { return state.view == .overview || state.view == .empty }
|
||||
return state.view == view
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
preAction?()
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
state.view = view
|
||||
}
|
||||
}) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(isOn ? Color(hex: "#F5F6F8") : (isHovered ? Color(hex: "#B0B5BE") : Color(hex: "#8E939C")))
|
||||
.frame(width: 30, height: 22)
|
||||
.background(
|
||||
isOn ? Color(hex: "#1D1F23") :
|
||||
isHovered ? Color.white.opacity(0.07) : Color.clear
|
||||
)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { isHovered = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Claude Plan header pill (GitHub build only)
|
||||
|
||||
#if !APPSTORE
|
||||
struct ClaudePlanHeaderPill: View {
|
||||
@ObservedObject var state: AppState
|
||||
@State private var isHovered = false
|
||||
|
||||
private var effectiveColor: String {
|
||||
ClaudePlanGauge.color(for: state.claudePlanUsage.flatMap { ClaudePlanGauge.dominantPct($0) })
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
guard let usage = state.claudePlanUsage,
|
||||
let pct = ClaudePlanGauge.dominantPct(usage) else { return "Claude —" }
|
||||
return "Claude \(Int(pct.rounded()))%"
|
||||
}
|
||||
|
||||
private var isActive: Bool { state.showingPlanDetail || isHovered }
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
|
||||
state.showingPlanDetail.toggle()
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(Color(hex: effectiveColor))
|
||||
.frame(width: 6, height: 6)
|
||||
Text(label)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundColor(isActive
|
||||
? Color(hex: effectiveColor).lighter(by: 0.3)
|
||||
: Color(hex: "#6B7079"))
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(isActive
|
||||
? Color(hex: effectiveColor).opacity(0.18)
|
||||
: Color(hex: "#0E0F11"))
|
||||
)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(Color(hex: effectiveColor).opacity(isActive ? 0.55 : 0.14), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { h in
|
||||
withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h }
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Compact mini mochi grid (2×2 to the right of the notch)
|
||||
|
||||
struct CompactMiniGrid: View {
|
||||
@ObservedObject var state: AppState
|
||||
|
||||
private var others: [AgentTask] {
|
||||
Array(state.tasks.filter { $0.id != state.focusId }.prefix(4))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let cols = [GridItem(.fixed(12), spacing: 4), GridItem(.fixed(12), spacing: 4)]
|
||||
LazyVGrid(columns: cols, spacing: 4) {
|
||||
ForEach(others) { task in
|
||||
MiniBotCanvasView(task: task)
|
||||
.frame(width: 12 / 0.6, height: 12 / 0.6)
|
||||
.frame(width: 12, height: 12, alignment: .center)
|
||||
}
|
||||
}
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user