WP-4a: menu-bar shell, AppModel, session strip per SPEC-A2b + integration contracts
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import ShotdeckCore
|
||||
|
||||
struct MenuBarView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
statusRow
|
||||
SessionStrip()
|
||||
Divider()
|
||||
actionsList
|
||||
if !model.allReturns.isEmpty {
|
||||
Divider()
|
||||
returnsBlock
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.frame(width: 320, alignment: .leading)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusRow: some View {
|
||||
if !model.screenRecordingGranted {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundStyle(.yellow)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(
|
||||
ShotdeckError.screenRecordingNotGranted.errorDescription
|
||||
?? "Screen Recording is turned off."
|
||||
)
|
||||
.font(.caption)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Button("Open Screen Recording settings") {
|
||||
model.openScreenRecordingSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(model.statusLine ?? defaultStatusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultStatusText: String {
|
||||
guard let region = model.region else {
|
||||
return "No region yet — press ⌥⇧2 to pick one."
|
||||
}
|
||||
let w = Int(region.rect.width)
|
||||
let h = Int(region.rect.height)
|
||||
let display = displayName(for: region)
|
||||
if model.session.isEmpty {
|
||||
return "Region \(w) × \(h) on \(display) · Nothing captured yet."
|
||||
}
|
||||
let count = model.session.captures.count
|
||||
return "\(count) captures · region \(w) × \(h) on \(display)"
|
||||
}
|
||||
|
||||
private func displayName(for region: CaptureRegion) -> String {
|
||||
for (index, screen) in NSScreen.screens.enumerated() {
|
||||
let id = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?
|
||||
.uint32Value
|
||||
if id == region.displayID {
|
||||
return "Display \(index + 1)"
|
||||
}
|
||||
}
|
||||
return "Display 1"
|
||||
}
|
||||
|
||||
private var actionsList: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Button {
|
||||
let anchor = NSApp.keyWindow?.contentView
|
||||
if let sender = model as? SendCapable {
|
||||
Task { await sender.send(anchor: anchor) }
|
||||
} else {
|
||||
model.setStatus("Send is not available in this build.")
|
||||
}
|
||||
} label: {
|
||||
actionLabel("Send…")
|
||||
}
|
||||
.disabled(model.session.isEmpty || model.isSending)
|
||||
|
||||
Button {
|
||||
Task { await model.captureNow() }
|
||||
} label: {
|
||||
actionLabel("Capture now", trailing: "⌥⇧2")
|
||||
}
|
||||
.disabled(model.isCapturing)
|
||||
|
||||
Button {
|
||||
Task { await model.rePickRegion() }
|
||||
} label: {
|
||||
actionLabel("Re-select area")
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await model.copyCommentedLinks() }
|
||||
} label: {
|
||||
actionLabel(
|
||||
"Copy commented links",
|
||||
trailing: model.commentedReturns.isEmpty ? nil : "\(model.commentedReturns.count)"
|
||||
)
|
||||
}
|
||||
.disabled(model.commentedReturns.isEmpty)
|
||||
|
||||
Button {
|
||||
model.openSpoolFolder()
|
||||
} label: {
|
||||
actionLabel("Open spool folder")
|
||||
}
|
||||
|
||||
Button {
|
||||
if let presenter = model as? SettingsWindowPresenting {
|
||||
presenter.presentSettingsWindow()
|
||||
} else {
|
||||
model.setStatus("Settings is not available in this build.")
|
||||
}
|
||||
} label: {
|
||||
actionLabel("Settings…")
|
||||
}
|
||||
|
||||
Button {
|
||||
NSApp.terminate(nil)
|
||||
} label: {
|
||||
actionLabel("Quit Shotdeck")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func actionLabel(_ title: String, trailing: String? = nil) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
Spacer(minLength: 8)
|
||||
if let trailing {
|
||||
Text(trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.padding(.vertical, 3)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var returnsBlock: some View {
|
||||
if let provider = model as? ReturnsSectionProviding {
|
||||
provider.returnsSection()
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Came back from your device")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ForEach(newestReturns.prefix(8)) { doc in
|
||||
HStack(spacing: 8) {
|
||||
Text(doc.fileURL.lastPathComponent)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
Text(doc.isCommented
|
||||
? "\(doc.annotatedPages.count) page\(doc.annotatedPages.count == 1 ? "" : "s") marked"
|
||||
: "not marked")
|
||||
.font(.caption)
|
||||
.foregroundStyle(doc.isCommented ? .primary : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var newestReturns: [ReturnedDocument] {
|
||||
model.allReturns.sorted { $0.detectedAt > $1.detectedAt }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user