Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ struct ExtensionPreview: View {
case "focusDim": focusDimPreview(phase: phase)
case "presenter": presenterPreview(phase: phase)
case "colorPicker": colorPickerPreview(phase: phase)
case "captureTools": captureToolsPreview(phase: phase)
case "homebrew": homebrewPreview(phase: phase)
case "cleaner": cleanerPreview(phase: phase)
case "downloads": downloadsPreview(phase: phase)
Expand Down Expand Up @@ -543,6 +544,36 @@ struct ExtensionPreview: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private func captureToolsPreview(phase: Double) -> some View {
let progress = loopProgress(phase, duration: 3.2)
let scan = CGFloat(abs(progress * 2 - 1))
return ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: UIScale.pt(8))
.fill(DashSkin.paper2(dark))
.overlay {
VStack(alignment: .leading, spacing: UIScale.pt(5)) {
Capsule().fill(DashSkin.inkFaint(dark)).frame(width: 72, height: 4)
Capsule().fill(DashSkin.inkFaint(dark)).frame(width: 96, height: 4)
Capsule().fill(DashSkin.inkFaint(dark)).frame(width: 54, height: 4)
}
}
Rectangle()
.fill(DashSkin.accent(dark).opacity(0.75))
.frame(height: UIScale.pt(2))
.offset(y: UIScale.pt(8 + scan * 34))
Image(systemName: "qrcode")
.font(.system(size: UIScale.pt(13), weight: .medium))
.foregroundStyle(DashSkin.accent(dark))
.padding(UIScale.pt(6))
}
.frame(width: UIScale.pt(118), height: UIScale.pt(52))
.overlay {
RoundedRectangle(cornerRadius: UIScale.pt(8)).strokeBorder(
DashSkin.lineStrong(dark))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private func loopProgress(_ phase: Double, duration: Double) -> Double {
phase.truncatingRemainder(dividingBy: duration) / duration
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import AppKit
import EdithKit
import SwiftUI

struct CaptureToolsRows: View {
@AppStorage(AppStorageKeys.Capture.enabled, store: SharedDefaults.store) private
var captureToolsEnabled = false
@AppStorage(AppStorageKeys.Capture.copyMode, store: SharedDefaults.store) private
var copyMode: CaptureCopyMode = .smart
@AppStorage(AppStorageKeys.Capture.detectCodes, store: SharedDefaults.store) private
var detectCodes = true
@AppStorage(AppStorageKeys.Capture.historySize, store: SharedDefaults.store) private
var historySize = 10
@AppStorage(AppStorageKeys.Capture.saveScreenshots, store: SharedDefaults.store) private
var saveScreenshots = false
@State private var history: [CaptureRecognition] = []

var body: some View {
Group {
Section("Capture") {
HStack {
Button("Read screen") {
_ = CaptureToolOperationExecution.request(.read)
}
.buttonStyle(.edith(.primary))
Button("Screenshot") {
_ = CaptureToolOperationExecution.request(.screenshot)
}
.buttonStyle(.edith(.secondary))
}
LabeledContent("Read shortcut") {
HotKeyRecorderControl(
keyPrefix: "captureReadHotKey", defaultLabel: "⌃⌥⌘R")
}
LabeledContent("Screenshot shortcut") {
HotKeyRecorderControl(
keyPrefix: "captureScreenshotHotKey", defaultLabel: "⌃⌥⌘S")
}
}

Section("Recognition") {
Picker(
"Copy after reading",
selection: $copyMode.configured(AppStorageKeys.Capture.copyMode)
) {
ForEach(CaptureCopyMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
Toggle(
"Read QR and matrix codes",
isOn: $detectCodes.configured(AppStorageKeys.Capture.detectCodes))
Toggle(
"Keep read screenshots in Pictures",
isOn: $saveScreenshots.configured(AppStorageKeys.Capture.saveScreenshots))
Stepper(
"Recent reads: \(historySize)",
value: $historySize.configured(AppStorageKeys.Capture.historySize), in: 1...25)
Text(
"Recognition runs offline with macOS Vision. Screen images are temporary unless you save them or enable screenshot retention."
)
.settingsCaption()
}

Section("Related tools") {
Text(
"Use Color Picker for exact pixel colors. The Notch Shelf owns the camera preview for mirror checks."
)
.settingsCaption()
}

if !history.isEmpty {
Section {
ForEach(history.prefix(historySize)) { capture in
Button {
copy(capture.output(for: copyMode))
} label: {
HStack {
Image(
systemName: capture.codes.isEmpty
? "text.alignleft" : "qrcode.viewfinder"
)
.foregroundStyle(.secondary)
Text(capture.output(for: .smart))
.lineLimit(2)
Spacer()
Image(systemName: "doc.on.doc")
.foregroundStyle(.secondary)
}
}
.buttonStyle(.edith(.row))
}
} header: {
HStack {
Text("Recent reads")
Spacer()
Button("Clear") {
CaptureHistoryStore.clear()
history = []
IPC.post(IPC.Name.settingsChanged)
}
.buttonStyle(.edith(.borderless))
}
}
}
}
.disabled(!captureToolsEnabled)
.opacity(captureToolsEnabled ? 1 : 0.5)
.onAppear { history = CaptureHistoryStore.load() }
.onReceive(
DistributedNotificationCenter.default().publisher(for: IPC.Name.settingsChanged)
) { _ in history = CaptureHistoryStore.load() }
}

private func copy(_ value: String) {
guard !value.isEmpty else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(value, forType: .string)
IPC.post(IPC.Name.clipboardChanged)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,7 @@ private struct ExtensionSettingsSheet: View {
case "lidAwake": 400
case "music": 460
case "focusDim", "colorPicker", "keystrokeHighlight": 430
case "captureTools": 560
case "system": 500
case "notchShelf", "presenter": 580
default: 620
Expand Down Expand Up @@ -1067,6 +1068,7 @@ private struct ExtensionDetailRows: View {
case .focusDim: FocusDimRows()
case .presenter: PresenterRows()
case .colorPicker: ColorPickerRows()
case .captureTools: CaptureToolsRows()
case .emoji: EmojiRows()
}
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ struct ShortcutsSettingsPane: View {
@AppStorage(AppStorageKeys.ColorPicker.enabled, store: SharedDefaults.store) private
var colorPickerEnabled =
false
@AppStorage(AppStorageKeys.Capture.enabled, store: SharedDefaults.store) private
var captureToolsEnabled = false
@AppStorage(AppStorageKeys.Emoji.enabled, store: SharedDefaults.store) private
var emojiEnabled =
false
Expand All @@ -27,7 +29,8 @@ struct ShortcutsSettingsPane: View {
ExtensionShortcutVisibility.visible(
clipboard: clipboardEnabled, emoji: emojiEnabled, micMute: micMuteEnabled,
focusDim: focusDimEnabled, presenter: presenterEnabled,
colorPicker: colorPickerEnabled, keystrokeHighlight: keystrokeHighlightEnabled)
colorPicker: colorPickerEnabled, keystrokeHighlight: keystrokeHighlightEnabled,
captureTools: captureToolsEnabled)
}

var body: some View {
Expand Down Expand Up @@ -129,6 +132,14 @@ struct ShortcutsSettingsPane: View {
shortcutRow(
"Pick a color", subtitle: "Summons the color picker loupe",
keyPrefix: "colorPickerHotKey", defaultLabel: "⌃⌥⌘C")
case .captureRead:
shortcutRow(
"Read screen", subtitle: "Recognizes text and codes in a selected region",
keyPrefix: "captureReadHotKey", defaultLabel: "⌃⌥⌘R")
case .captureScreenshot:
shortcutRow(
"Quick screenshot", subtitle: "Captures a selected region into a preview",
keyPrefix: "captureScreenshotHotKey", defaultLabel: "⌃⌥⌘S")
case .keystrokeHighlight:
shortcutRow(
"Keystroke highlight", subtitle: "Starts or pauses the on-screen keycaps",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ extension FocusDimDisplayMode: ConfigurationBindingValue {
var configurationValue: JSONValue { .string(rawValue) }
}

extension CaptureCopyMode: ConfigurationBindingValue {
var configurationValue: JSONValue { .string(rawValue) }
}

extension Binding where Value: ConfigurationBindingValue {
func configured(
_ key: String, executor: ConfigurationExecutor = .application
Expand Down
3 changes: 3 additions & 0 deletions Packages/Edith/Sources/EdithCLI/CommandTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ public enum CommandTree {
typealias Spec = CommandSpec

static let specs: [String: Spec] = [
"ed capture read": Spec(options: ["--json", "--help"]),
"ed capture screenshot": Spec(options: ["--json", "--help"]),
"ed capture": Spec(options: ["--json", "--help"]),
"ed": Spec(options: ["--help", "--version"]),
"ed guide": Spec(options: ["--json"], arguments: [.guideTopic]),
"ed version": Spec(options: ["--json", "-h", "--help", "--version"]),
Expand Down
62 changes: 62 additions & 0 deletions Packages/Edith/Sources/EdithCLI/Commands/CaptureCommands.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import ArgumentParser
import EdithKit

struct CaptureCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "capture",
abstract: "Offline screen recognition and quick screenshots.",
subcommands: [CaptureReadCommand.self, CaptureScreenshotCommand.self],
defaultSubcommand: CaptureReadCommand.self)
}

enum CaptureCommandBridge {
static func request(_ operation: CaptureToolOperation, json: Bool) async throws {
try await execute {
guard
ExtensionRegistry.entry("captureTools")?.isEnabled(
in: CLIEnvironment.sharedDefaults) == true
else {
throw CLIFailure.unavailable(
"the Capture Tools extension is off",
hint: "run `ed extensions enable captureTools`, then retry")
}
try AppBridge.requireHelper("capturing the screen")
let descriptor = CaptureToolOperationExecution.request(operation) {
AppBridge.post($0)
}
guard !json else {
CLIOut.json(
.object([
"operation": .string(descriptor.id.rawValue),
"requested": .bool(true),
]))
return
}
CLIOut.out(operation == .read ? "screen read requested" : "screenshot requested")
}
}
}

struct CaptureReadCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "read", abstract: "Select screen content and copy recognized text or codes.")

@Flag(name: .long, help: "Emit JSON on stdout.")
var json = false

func run() async throws {
try await CaptureCommandBridge.request(.read, json: json)
}
}

struct CaptureScreenshotCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "screenshot", abstract: "Select screen content for a quick preview.")

@Flag(name: .long, help: "Emit JSON on stdout.")
var json = false

func run() async throws {
try await CaptureCommandBridge.request(.screenshot, json: json)
}
}
1 change: 1 addition & 0 deletions Packages/Edith/Sources/EdithCLI/Commands/Root.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ public struct EdRoot: AsyncParsableCommand {
AppsCommand.self,
ToolsCommand.self,
ColorCommand.self,
CaptureCommand.self,
EmojiCommand.self,
ShelfCommand.self,
CleanerCommand.self,
Expand Down
2 changes: 2 additions & 0 deletions Packages/Edith/Sources/EdithCLI/Guide.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ public enum Guide {
ed permissions ls every macOS permission Edith uses
ed color pick open Edith's system colour sampler
ed color copy 1 --format hex
ed capture read copy text or codes from a selected screen region
ed capture screenshot open a quick preview of a selected screen region
ed emoji pick open Edith's emoji picker
ed usage sources the agents that produced your usage history
ed usage export branded PNG cards for sharing your activity
Expand Down
34 changes: 34 additions & 0 deletions Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,40 @@ public enum ExtensionLifecycleCatalog {
"history", "Read sampled colors",
"Confirm the color history repository responds.", "ed color ls --json")
]),
descriptor(
"captureTools", "Read text and codes from the screen without sending pixels anywhere.",
workflows: [
instruction(
"read", "Read the screen",
"Select a region, recognize its text and codes, and copy the result.",
"ed capture read"),
instruction(
"screenshot", "Take a quick screenshot",
"Select a region and open a lightweight copy and save preview.",
"ed capture screenshot"),
],
prerequisites: [
instruction(
"permission", "Grant Screen Recording",
"Screen Recording lets Edith capture the region you select.",
"ed permissions request screenRecording")
],
examples: [
"ed extensions enable captureTools", "ed capture read --json",
"ed capture screenshot --json",
],
docs: [documentation("guide", "Capture Tools guide", "docs/cli/capture/README.md")],
recovery: [
instruction(
"permission", "Refresh capture access",
"Refresh the mirrored Screen Recording grant.", "ed permissions refresh")
],
verification: [
instruction(
"config", "Inspect capture settings",
"Confirm recognition, history, and screenshot preferences.",
"ed config ls --group capture --json")
]),
descriptor(
"homebrew", "Install, upgrade and remove Homebrew packages from one client.",
workflows: [
Expand Down
7 changes: 7 additions & 0 deletions Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,13 @@ public enum ExtensionRegistry {
subtitle: "Crawl sitemaps, inspect page metadata, and keep every run local.",
symbolName: "doc.text.magnifyingglass", suite: .data, host: .agent, featured: false,
defaultsKey: "tabSEOAuditEnabled", requiredCapabilities: [.siteAuditing]),
ExtensionRegistryEntry(
id: "captureTools", title: "Capture Tools",
subtitle: "Offline screen OCR, QR recognition, and quick screenshots.",
symbolName: "viewfinder", suite: .media, host: .bar, featured: false,
defaultsKey: "captureToolsEnabled",
requiredCapabilities: [.screenContentRecognition],
optionalCapabilities: [.globalShortcuts]),
]

public static func entry(_ id: String) -> ExtensionRegistryEntry? {
Expand Down
2 changes: 2 additions & 0 deletions Packages/Edith/Sources/EdithCore/PlatformCapabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public enum PlatformCapability: String, CaseIterable, Codable, Hashable, Sendabl
case preventSleep
case runningApplications
case screenColorSampling
case screenContentRecognition
case screenShareDetection
case skillInstallation
case siteAuditing
Expand Down Expand Up @@ -103,6 +104,7 @@ public struct PlatformCapabilities: Equatable, Sendable {
.keystrokeObservation: .permissionRequired,
.notifications: .permissionRequired,
.screenColorSampling: .permissionRequired,
.screenContentRecognition: .permissionRequired,
.screenShareDetection: .permissionRequired,
.windowDimming: .permissionRequired,
]))
Expand Down
Loading
Loading