feat: Hide window count for single-window apps (PR #6)

- Only show window count when it's not 1 in list apps output
- Extract formatApplicationList method for better testability
- Fix Swift test compatibility with new CaptureError signatures
- Add comprehensive test coverage for window count display logic

This improves readability by reducing visual clutter for the common
case of apps with single windows.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Peter Steinberger 2025-06-08 07:07:53 +01:00
parent f3c3cbb073
commit c6148849f8
5 changed files with 244 additions and 22 deletions

View file

@ -102,6 +102,27 @@ struct ImageCommand: ParsableCommand {
} else {
.unknownError(error.localizedDescription)
}
// Log the full error details for debugging
Logger.shared.debug("Image capture error: \(error)")
// If it's a CaptureError with an underlying error, log that too
switch captureError {
case let .captureCreationFailed(underlyingError):
if let underlying = underlyingError {
Logger.shared.debug("Underlying capture creation error: \(underlying)")
}
case let .windowCaptureFailed(underlyingError):
if let underlying = underlyingError {
Logger.shared.debug("Underlying window capture error: \(underlying)")
}
case let .fileWriteError(_, underlyingError):
if let underlying = underlyingError {
Logger.shared.debug("Underlying file write error: \(underlying)")
}
default:
break
}
if jsonOutput {
let code: ErrorCode = switch captureError {
@ -122,10 +143,22 @@ struct ImageCommand: ParsableCommand {
default:
.CAPTURE_FAILED
}
// Provide additional details for app not found errors
var details: String? = nil
if case .appNotFound = captureError {
let runningApps = NSWorkspace.shared.runningApplications
.filter { $0.activationPolicy == .regular }
.compactMap(\.localizedName)
.sorted()
.joined(separator: ", ")
details = "Available applications: \(runningApps)"
}
outputError(
message: captureError.localizedDescription,
code: code,
details: "Image capture operation failed"
details: details ?? "Image capture operation failed"
)
} else {
var localStandardErrorStream = FileHandleTextOutputStream(FileHandle.standardError)
@ -214,7 +247,15 @@ struct ImageCommand: ParsableCommand {
}
private func captureApplicationWindow(_ appIdentifier: String) throws -> [SavedFile] {
let targetApp = try ApplicationFinder.findApplication(identifier: appIdentifier)
let targetApp: NSRunningApplication
do {
targetApp = try ApplicationFinder.findApplication(identifier: appIdentifier)
} catch ApplicationError.notFound(let identifier) {
throw CaptureError.appNotFound(identifier)
} catch ApplicationError.ambiguous(let identifier, let matches) {
let appNames = matches.map { $0.localizedName ?? $0.bundleIdentifier ?? "Unknown" }
throw CaptureError.unknownError("Multiple applications match '\(identifier)': \(appNames.joined(separator: ", "))")
}
if captureFocus == .foreground || (captureFocus == .auto && !targetApp.isActive) {
try PermissionsChecker.requireAccessibilityPermission()
@ -260,7 +301,15 @@ struct ImageCommand: ParsableCommand {
}
private func captureAllApplicationWindows(_ appIdentifier: String) throws -> [SavedFile] {
let targetApp = try ApplicationFinder.findApplication(identifier: appIdentifier)
let targetApp: NSRunningApplication
do {
targetApp = try ApplicationFinder.findApplication(identifier: appIdentifier)
} catch ApplicationError.notFound(let identifier) {
throw CaptureError.appNotFound(identifier)
} catch ApplicationError.ambiguous(let identifier, let matches) {
let appNames = matches.map { $0.localizedName ?? $0.bundleIdentifier ?? "Unknown" }
throw CaptureError.unknownError("Multiple applications match '\(identifier)': \(appNames.joined(separator: ", "))")
}
if captureFocus == .foreground || (captureFocus == .auto && !targetApp.isActive) {
try PermissionsChecker.requireAccessibilityPermission()
@ -324,7 +373,7 @@ struct ImageCommand: ParsableCommand {
if isScreenRecordingPermissionError(error) {
throw CaptureError.screenRecordingPermissionDenied
}
throw CaptureError.captureCreationFailed
throw CaptureError.captureCreationFailed(error)
}
}
@ -335,7 +384,7 @@ struct ImageCommand: ParsableCommand {
// Find the display by ID
guard let scDisplay = availableContent.displays.first(where: { $0.displayID == displayID }) else {
throw CaptureError.captureCreationFailed
throw CaptureError.captureCreationFailed(nil)
}
// Create content filter for the entire display
@ -392,7 +441,7 @@ struct ImageCommand: ParsableCommand {
if isScreenRecordingPermissionError(error) {
throw CaptureError.screenRecordingPermissionDenied
}
throw CaptureError.windowCaptureFailed
throw CaptureError.windowCaptureFailed(error)
}
}

View file

@ -74,18 +74,27 @@ struct AppsSubcommand: ParsableCommand {
Foundation.exit(captureError.exitCode)
}
private func printApplicationList(_ applications: [ApplicationInfo]) {
print("Running Applications (\(applications.count)):")
print()
internal func printApplicationList(_ applications: [ApplicationInfo]) {
let output = formatApplicationList(applications)
print(output)
}
internal func formatApplicationList(_ applications: [ApplicationInfo]) -> String {
var output = "Running Applications (\(applications.count)):\n\n"
for (index, app) in applications.enumerated() {
print("\(index + 1). \(app.app_name)")
print(" Bundle ID: \(app.bundle_id)")
print(" PID: \(app.pid)")
print(" Status: \(app.is_active ? "Active" : "Background")")
print(" Windows: \(app.window_count)")
print()
output += "\(index + 1). \(app.app_name)\n"
output += " Bundle ID: \(app.bundle_id)\n"
output += " PID: \(app.pid)\n"
output += " Status: \(app.is_active ? "Active" : "Background")\n"
// Only show window count if it's not 1
if app.window_count != 1 {
output += " Windows: \(app.window_count)\n"
}
output += "\n"
}
return output
}
}

View file

@ -271,6 +271,170 @@ struct ListCommandTests {
let data = try encoder.encode(appData)
#expect(!data.isEmpty)
}
// MARK: - Window Count Display Tests
@Test("printApplicationList hides window count when count is 1", .tags(.fast))
func printApplicationListHidesWindowCountForSingleWindow() throws {
// Create test applications with different window counts
let applications = [
ApplicationInfo(
app_name: "Single Window App",
bundle_id: "com.test.single",
pid: 123,
is_active: false,
window_count: 1
),
ApplicationInfo(
app_name: "Multi Window App",
bundle_id: "com.test.multi",
pid: 456,
is_active: true,
window_count: 5
),
ApplicationInfo(
app_name: "No Windows App",
bundle_id: "com.test.none",
pid: 789,
is_active: false,
window_count: 0
)
]
// Get formatted output using the testable method
let command = AppsSubcommand()
let output = command.formatApplicationList(applications)
// Verify that "Windows: 1" is NOT present for single window app
#expect(!output.contains("Windows: 1"))
// Verify that the single window app is listed but without window count
#expect(output.contains("Single Window App"))
// Verify that "Windows: 5" IS present for multi window app
#expect(output.contains("Windows: 5"))
// Verify that "Windows: 0" IS present for no windows app
#expect(output.contains("Windows: 0"))
}
@Test("printApplicationList shows window count for non-1 values", .tags(.fast))
func printApplicationListShowsWindowCountForNonSingleWindow() throws {
let applications = [
ApplicationInfo(
app_name: "Zero Windows",
bundle_id: "com.test.zero",
pid: 100,
is_active: false,
window_count: 0
),
ApplicationInfo(
app_name: "Two Windows",
bundle_id: "com.test.two",
pid: 200,
is_active: false,
window_count: 2
),
ApplicationInfo(
app_name: "Many Windows",
bundle_id: "com.test.many",
pid: 300,
is_active: false,
window_count: 10
)
]
let command = AppsSubcommand()
let output = command.formatApplicationList(applications)
// All these should show window counts since they're not 1
#expect(output.contains("Windows: 0"))
#expect(output.contains("Windows: 2"))
#expect(output.contains("Windows: 10"))
}
@Test("printApplicationList formats output correctly", .tags(.fast))
func printApplicationListFormatsOutputCorrectly() throws {
let applications = [
ApplicationInfo(
app_name: "Test App",
bundle_id: "com.test.app",
pid: 12345,
is_active: true,
window_count: 1
)
]
let command = AppsSubcommand()
let output = command.formatApplicationList(applications)
// Verify basic formatting is present
#expect(output.contains("Running Applications (1):"))
#expect(output.contains("1. Test App"))
#expect(output.contains("Bundle ID: com.test.app"))
#expect(output.contains("PID: 12345"))
#expect(output.contains("Status: Active"))
// Verify "Windows: 1" is NOT present
#expect(!output.contains("Windows: 1"))
}
@Test("printApplicationList edge cases", .tags(.fast))
func printApplicationListEdgeCases() throws {
let applications = [
ApplicationInfo(
app_name: "Edge Case 1",
bundle_id: "com.test.edge1",
pid: 1,
is_active: false,
window_count: 1
),
ApplicationInfo(
app_name: "Edge Case 2",
bundle_id: "com.test.edge2",
pid: 2,
is_active: true,
window_count: 1
)
]
let command = AppsSubcommand()
let output = command.formatApplicationList(applications)
// Both apps have 1 window, so neither should show "Windows: 1"
#expect(!output.contains("Windows: 1"))
// But both apps should be listed
#expect(output.contains("Edge Case 1"))
#expect(output.contains("Edge Case 2"))
#expect(output.contains("Status: Background"))
#expect(output.contains("Status: Active"))
}
@Test("printApplicationList mixed window counts", .tags(.fast))
func printApplicationListMixedWindowCounts() throws {
let applications = [
ApplicationInfo(app_name: "App A", bundle_id: "com.a", pid: 1, is_active: false, window_count: 0),
ApplicationInfo(app_name: "App B", bundle_id: "com.b", pid: 2, is_active: false, window_count: 1),
ApplicationInfo(app_name: "App C", bundle_id: "com.c", pid: 3, is_active: false, window_count: 2),
ApplicationInfo(app_name: "App D", bundle_id: "com.d", pid: 4, is_active: false, window_count: 3)
]
let command = AppsSubcommand()
let output = command.formatApplicationList(applications)
// Should show window counts for 0, 2, and 3, but NOT for 1
#expect(output.contains("Windows: 0"))
#expect(!output.contains("Windows: 1"))
#expect(output.contains("Windows: 2"))
#expect(output.contains("Windows: 3"))
// All apps should be listed
#expect(output.contains("App A"))
#expect(output.contains("App B"))
#expect(output.contains("App C"))
#expect(output.contains("App D"))
}
}
// MARK: - Extended List Command Tests

View file

@ -272,9 +272,9 @@ struct ModelsTests {
.contains("Screen recording permission is required")
)
#expect(CaptureError.invalidDisplayID.errorDescription == "Invalid display ID provided.")
#expect(CaptureError.captureCreationFailed.errorDescription == "Failed to create the screen capture.")
#expect(CaptureError.captureCreationFailed(nil).errorDescription == "Failed to create the screen capture.")
#expect(CaptureError.windowNotFound.errorDescription == "The specified window could not be found.")
#expect(CaptureError.windowCaptureFailed.errorDescription == "Failed to capture the specified window.")
#expect(CaptureError.windowCaptureFailed(nil).errorDescription == "Failed to capture the specified window.")
let fileError = CaptureError.fileWriteError("/tmp/test.png", nil)
#expect(fileError.errorDescription?
.starts(with: "Failed to write capture file to path: /tmp/test.png.") == true
@ -292,9 +292,9 @@ struct ModelsTests {
(.screenRecordingPermissionDenied, 11),
(.accessibilityPermissionDenied, 12),
(.invalidDisplayID, 13),
(.captureCreationFailed, 14),
(.captureCreationFailed(nil), 14),
(.windowNotFound, 15),
(.windowCaptureFailed, 16),
(.windowCaptureFailed(nil), 16),
(.fileWriteError("test", nil), 17),
(.appNotFound("test"), 18),
(.invalidWindowIndex(0), 19),

View file

@ -299,7 +299,7 @@ struct ScreenshotValidationTests {
}
guard let image = capturedImage else {
throw CaptureError.windowCaptureFailed
throw CaptureError.windowCaptureFailed(nil)
}
return image
@ -311,7 +311,7 @@ struct ScreenshotValidationTests {
format: ImageFormat
) throws -> ImageCaptureData {
guard let image = CGDisplayCreateImage(displayID) else {
throw CaptureError.captureCreationFailed
throw CaptureError.captureCreationFailed(nil)
}
let nsImage = NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height))