import Commander
import CoreGraphics
import Foundation
import PeekabooCore
import PeekabooFoundation
/// Scrolls the mouse wheel in a specified direction.
/// Supports scrolling on specific elements or at the current mouse position.
@available(macOS 14.0, *)
@MainActor
struct ScrollCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConfigurable {
@Option(help: "Scroll direction: up, down, left, or right")
var direction: String
@Option(help: "Number of scroll ticks")
var amount: Int = 3
@Option(help: "Element ID to scroll on (from 'see' command)")
var on: String?
@Option(help: "Session ID (uses latest if not specified)")
var session: String?
@Option(help: "Delay between scroll ticks in milliseconds")
var delay: Int = 2
@Flag(help: "Use smooth scrolling with smaller increments")
var smooth = false
@Option(name: .long, help: "Target application to focus before scrolling")
var app: String?
@OptionGroup var focusOptions: FocusCommandOptions
@RuntimeStorage private var runtime: CommandRuntime?
var runtimeOptions = CommandRuntimeOptions()
private var resolvedRuntime: CommandRuntime {
guard let runtime else {
preconditionFailure("CommandRuntime must be configured before accessing runtime resources")
}
return runtime
}
private var services: any PeekabooServiceProviding { self.resolvedRuntime.services }
private var logger: Logger { self.resolvedRuntime.logger }
var outputLogger: Logger { self.logger }
var jsonOutput: Bool { self.runtime?.configuration.jsonOutput ?? self.runtimeOptions.jsonOutput }
@MainActor
mutating func run(using runtime: CommandRuntime) async throws {
self.runtime = runtime
let startTime = Date()
self.logger.setJsonOutputMode(self.jsonOutput)
do {
// Parse direction
guard let scrollDirection = ScrollDirection(rawValue: direction.lowercased()) else {
throw ValidationError("Invalid direction. Use: up, down, left, or right")
}
// Determine session ID if element target is specified
let sessionId: String? = if self.on != nil {
if let providedSession = session {
providedSession
} else {
await self.services.sessions.getMostRecentSession()
}
} else {
nil
}
// Ensure window is focused before scrolling
try await ensureFocused(
sessionId: sessionId,
applicationName: self.app,
options: self.focusOptions,
services: self.services
)
// Perform scroll using the service
let scrollRequest = ScrollRequest(
direction: scrollDirection,
amount: self.amount,
target: self.on,
smooth: self.smooth,
delay: self.delay,
sessionId: sessionId
)
try await AutomationServiceBridge.scroll(
automation: self.services.automation,
request: scrollRequest
)
AutomationEventLogger.log(
.scroll,
"direction=\(self.direction) amount=\(self.amount) smooth=\(self.smooth) "
+ "target=\(self.on ?? "pointer") session=\(sessionId ?? "latest")"
)
// Calculate total ticks for output
let totalTicks = self.smooth ? self.amount * 3 : self.amount
// Determine scroll location for output
let scrollLocation: CGPoint = if let elementId = on {
// Try to get element location from session
if let activeSessionId = sessionId,
let detectionResult = try? await self.services.sessions
.getDetectionResult(sessionId: activeSessionId),
let element = detectionResult.elements.findById(elementId) {
CGPoint(
x: element.bounds.midX,
y: element.bounds.midY
)
} else {
// Fallback to zero if element not found (scroll still happened though)
.zero
}
} else {
// Get current mouse position
CGEvent(source: nil)?.location ?? .zero
}
// Output results
let outputPayload = ScrollResult(
success: true,
direction: direction,
amount: amount,
location: ["x": scrollLocation.x, "y": scrollLocation.y],
totalTicks: totalTicks,
executionTime: Date().timeIntervalSince(startTime)
)
output(outputPayload) {
print("✅ Scroll completed")
print("🎯 Direction: \(self.direction)")
print("📊 Amount: \(self.amount) ticks")
if self.on != nil {
print("📍 Location: (\(Int(scrollLocation.x)), \(Int(scrollLocation.y)))")
}
print("⏱️ Completed in \(String(format: "%.2f", Date().timeIntervalSince(startTime)))s")
}
} catch {
self.handleError(error)
throw ExitCode.failure
}
}
// Error handling is provided by ErrorHandlingCommand protocol
}
// MARK: - JSON Output Structure
struct ScrollResult: Codable {
let success: Bool
let direction: String
let amount: Int
let location: [String: Double]
let totalTicks: Int
let executionTime: TimeInterval
}
// MARK: - Conformances
@MainActor
extension ScrollCommand: ParsableCommand {
nonisolated(unsafe) static var commandDescription: CommandDescription {
MainActorCommandDescription.describe {
CommandDescription(
commandName: "scroll",
abstract: "Scroll the mouse wheel in any direction",
discussion: """
The 'scroll' command simulates mouse wheel scrolling events.
It can scroll up, down, left, or right by a specified amount.
EXAMPLES:
peekaboo scroll --direction down --amount 5
peekaboo scroll --direction up --amount 10 --on element_42
peekaboo scroll --direction right --amount 3 --smooth
DIRECTION:
up - Scroll content up (wheel down)
down - Scroll content down (wheel up)
left - Scroll content left
right - Scroll content right
AMOUNT:
The number of scroll "lines" or "ticks" to perform.
Each tick is equivalent to one notch on a physical mouse wheel.
""",
showHelpOnEmptyInvocation: true
)
}
}
}
extension ScrollCommand: AsyncRuntimeCommand {}
@MainActor
extension ScrollCommand: CommanderBindableCommand {
mutating func applyCommanderValues(_ values: CommanderBindableValues) throws {
self.direction = try values.requireOption("direction", as: String.self)
if let amount: Int = try values.decodeOption("amount", as: Int.self) {
self.amount = amount
}
self.on = values.singleOption("on")
self.session = values.singleOption("session")
if let delay: Int = try values.decodeOption("delay", as: Int.self) {
self.delay = delay
}
self.smooth = values.flag("smooth")
self.app = values.singleOption("app")
self.focusOptions = try values.makeFocusOptions()
}
}