WP-4a: menu-bar shell, AppModel, session strip per SPEC-A2b + integration contracts

This commit is contained in:
2026-08-31 15:59:37 +04:00
parent 8dc97d02f2
commit fdac2f2f47
4 changed files with 596 additions and 4 deletions
+180
View File
@@ -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 }
}
}