Skip to content
Open
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
136 changes: 98 additions & 38 deletions Sources/AppBundle/config/DynamicConfigValue.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import Common
import Foundation
import TOMLKit

struct PerMonitorValue<Value: Equatable>: Equatable {
let description: MonitorDescription
let value: Value
let windows: Int?
let workspace: String?
}
extension PerMonitorValue: Sendable where Value: Sendable {}

Expand All @@ -14,22 +17,74 @@ enum DynamicConfigValue<Value: Equatable>: Equatable {
extension DynamicConfigValue: Sendable where Value: Sendable {}

extension DynamicConfigValue {
func getValue(for monitor: any Monitor) -> Value {
@MainActor
func getValue(for monitor: any Monitor, windowCount: Int = 1) -> Value {
let actualWindowCount = getActualWindowCount(monitor: monitor, windowCount: windowCount)

switch self {
case .constant(let value):
return value
case .perMonitor(let array, let defaultValue):
let sortedMonitors = sortedMonitors
return array
.lazy
.compactMap {
$0.description.resolveMonitor(sortedMonitors: sortedMonitors)?.rect.topLeftCorner == monitor.rect.topLeftCorner
? $0.value
: nil
}
.first ?? defaultValue
return getPerMonitorValue(
array: array,
monitor: monitor,
windowCount: actualWindowCount,
defaultValue: defaultValue
)
}
}

@MainActor
private func getActualWindowCount(monitor: any Monitor, windowCount: Int) -> Int {
guard !isUnitTest else { return windowCount }

return monitor.activeWorkspace.allLeafWindowsRecursive
.filter { !$0.isFloating }
.count
}

private func isMonitorMatching(_ description: MonitorDescription, _ monitor: any Monitor) -> Bool {
switch description {
case .main: return monitor.name == "main"
case .secondary: return monitor.name == "secondary"
case .pattern(_, let regex): return (try? regex.val.firstMatch(in: monitor.name)) != nil
case .sequenceNumber(let num): return num == monitor.monitorAppKitNsScreenScreensId
}
}

private func matchesWorkspace(_ pattern: String?, _ workspaceName: String) -> Bool {
guard let pattern else { return true }
guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
let range = NSRange(workspaceName.startIndex..., in: workspaceName)
return regex.firstMatch(in: workspaceName, range: range) != nil
}

@MainActor
private func getPerMonitorValue(
array: [PerMonitorValue<Value>],
monitor: any Monitor,
windowCount: Int,
defaultValue: Value
) -> Value {
let matchingValues = array.filter { isMonitorMatching($0.description, monitor) }
let workspaceName = monitor.activeWorkspace.name

if let value = matchingValues.first(where: {
$0.windows == windowCount && matchesWorkspace($0.workspace, workspaceName)
}) {
return value.value
}

if let value = matchingValues.first(where: {
$0.windows == nil && matchesWorkspace($0.workspace, workspaceName)
}) {
return value.value
}

return matchingValues.first(where: {
$0.windows == nil && $0.workspace == nil
})?.value ?? defaultValue
}
}

func parseDynamicValue<T>(
Expand All @@ -41,51 +96,56 @@ func parseDynamicValue<T>(
) -> DynamicConfigValue<T> {
if let simpleValue = parseSimpleType(raw) as T? {
return .constant(simpleValue)
} else if let array = raw.array {
if array.isEmpty {
errors.append(.semantic(backtrace, "The array must not be empty"))
return .constant(fallback)
}

guard let defaultValue = array.last.flatMap({ parseSimpleType($0) as T? }) else {
errors.append(.semantic(backtrace, "The last item in the array must be of type \(T.self)"))
return .constant(fallback)
}

if array.dropLast().isEmpty {
errors.append(.semantic(backtrace, "The array must contain at least one monitor pattern"))
return .constant(fallback)
}
}

let rules: [PerMonitorValue<T>] = parsePerMonitorValues(TOMLArray(array.dropLast()), backtrace, &errors)
guard let array = raw.array, !array.isEmpty else {
errors.append(.semantic(backtrace, "Expected non-empty array"))
return .constant(fallback)
}

return .perMonitor(rules, default: defaultValue)
} else {
errors.append(.semantic(backtrace, "Unsupported type: \(raw.type), expected: \(valueType) or array"))
guard let defaultValue = array.last.flatMap({ parseSimpleType($0) as T? }) else {
errors.append(.semantic(backtrace, "The last item in the array must be of type \(T.self)"))
return .constant(fallback)
}

let rules = parsePerMonitorValues(TOMLArray(array.dropLast()), backtrace, &errors) as [PerMonitorValue<T>]
return .perMonitor(rules, default: defaultValue)
}

func parsePerMonitorValues<T>(_ array: TOMLArray, _ backtrace: TomlBacktrace, _ errors: inout [TomlParseError]) -> [PerMonitorValue<T>] {
array.enumerated().compactMap { (index: Int, raw: TOMLValueConvertible) -> PerMonitorValue<T>? in
array.enumerated().compactMap { (index, raw) in
var backtrace = backtrace + .index(index)

guard let (key, value) = raw.unwrapTableWithSingleKey(expectedKey: "monitor", &backtrace)
guard let (key, configValue) = raw.unwrapTableWithSingleKey(expectedKey: "monitor", &backtrace)
.flatMap({ $0.value.unwrapTableWithSingleKey(expectedKey: nil, &backtrace) })
.getOrNil(appendErrorTo: &errors)
.getOrNil(appendErrorTo: &errors),
let monitorDescription = parseMonitorDescription(key, backtrace).getOrNil(appendErrorTo: &errors)
else { return nil }

if let simpleValue = parseSimpleType(configValue) as T? {
return PerMonitorValue(description: monitorDescription, value: simpleValue, windows: nil, workspace: nil)
}

guard let table = configValue.table,
let value = table["value"].flatMap({ parseSimpleType($0) as T? })
else {
errors.append(.semantic(backtrace, "Expected '\(T.self)' or table with 'value' field"))
return nil
}

let monitorDescriptionResult = parseMonitorDescription(key, backtrace)

guard let monitorDescription = monitorDescriptionResult.getOrNil(appendErrorTo: &errors) else { return nil }
let windows = table["windows"].flatMap { parseSimpleType($0) as Int? }
let workspace = table["workspace"].flatMap { parseSimpleType($0) as String? }

guard let value = parseSimpleType(value) as T? else {
errors.append(.semantic(backtrace, "Expected type is '\(T.self)'. But actual type is '\(value.type)'"))
if let workspace, (try? NSRegularExpression(pattern: workspace)) == nil {
errors.append(.semantic(backtrace, "Invalid workspace pattern"))
return nil
}

return PerMonitorValue(description: monitorDescription, value: value)
return PerMonitorValue(
description: monitorDescription,
value: value,
windows: windows,
workspace: workspace
)
}
}
15 changes: 8 additions & 7 deletions Sources/AppBundle/config/parseGaps.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,18 @@ struct ResolvedGaps {
let right: Int
}

init(gaps: Gaps, monitor: any Monitor) {
@MainActor
init(gaps: Gaps, monitor: any Monitor, windowCount: Int = 1) {
inner = .init(
vertical: gaps.inner.vertical.getValue(for: monitor),
horizontal: gaps.inner.horizontal.getValue(for: monitor)
vertical: gaps.inner.vertical.getValue(for: monitor, windowCount: windowCount),
horizontal: gaps.inner.horizontal.getValue(for: monitor, windowCount: windowCount)
)

outer = .init(
left: gaps.outer.left.getValue(for: monitor),
bottom: gaps.outer.bottom.getValue(for: monitor),
top: gaps.outer.top.getValue(for: monitor),
right: gaps.outer.right.getValue(for: monitor)
left: gaps.outer.left.getValue(for: monitor, windowCount: windowCount),
bottom: gaps.outer.bottom.getValue(for: monitor, windowCount: windowCount),
top: gaps.outer.top.getValue(for: monitor, windowCount: windowCount),
right: gaps.outer.right.getValue(for: monitor, windowCount: windowCount)
)
}
}
Expand Down
11 changes: 7 additions & 4 deletions Sources/AppBundleTests/config/ConfigTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,10 @@ final class ConfigTest: XCTestCase {
Gaps(
inner: .init(
vertical: .perMonitor(
[PerMonitorValue(description: .main, value: 1), PerMonitorValue(description: .secondary, value: 2)],
[
PerMonitorValue(description: .main, value: 1, windows: nil, workspace: nil),
PerMonitorValue(description: .secondary, value: 2, windows: nil, workspace: nil),
],
default: 5
),
horizontal: .constant(10)
Expand All @@ -312,12 +315,12 @@ final class ConfigTest: XCTestCase {
bottom: .constant(13),
top: .perMonitor(
[
PerMonitorValue(description: .pattern("built-in")!, value: 3),
PerMonitorValue(description: .secondary, value: 4),
PerMonitorValue(description: .pattern("built-in")!, value: 3, windows: nil, workspace: nil),
PerMonitorValue(description: .secondary, value: 4, windows: nil, workspace: nil),
],
default: 6
),
right: .perMonitor([PerMonitorValue(description: .sequenceNumber(2), value: 7)], default: 8)
right: .perMonitor([PerMonitorValue(description: .sequenceNumber(2), value: 7, windows: nil, workspace: nil)], default: 8)
)
)
)
Expand Down
158 changes: 158 additions & 0 deletions Sources/AppBundleTests/config/DynamicConfigValueTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import Common
import TOMLKit
import XCTest

@testable import AppBundle

final class DynamicConfigValueTests: XCTestCase {
@MainActor
func testConstantValue() {
let value: DynamicConfigValue<Int> = .constant(42)
let monitor = TestMonitor()

XCTAssertEqual(value.getValue(for: monitor), 42)
XCTAssertEqual(value.getValue(for: monitor, windowCount: 1), 42)
XCTAssertEqual(value.getValue(for: monitor, windowCount: 2), 42)
}

@MainActor
func testPerMonitorWithoutWindows() {
let mainMonitor = TestMonitor(id: "main")
let secondaryMonitor = TestMonitor(id: "secondary")

let mainDesc = MonitorDescription.pattern("main")!
let value: DynamicConfigValue<Int> = .perMonitor(
[
PerMonitorValue(description: mainDesc, value: 16, windows: nil, workspace: nil)
], default: 8)

// Should match monitor without considering windows
XCTAssertEqual(value.getValue(for: mainMonitor), 16)
XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 16)
XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 16)

// Should use default for non-matching monitor
XCTAssertEqual(value.getValue(for: secondaryMonitor), 8)
XCTAssertEqual(value.getValue(for: secondaryMonitor, windowCount: 1), 8)
}

@MainActor
func testPerMonitorWithWindows() {
let mainMonitor = TestMonitor(id: "main")
let secondaryMonitor = TestMonitor(id: "secondary")

let mainDesc = MonitorDescription.pattern("main")!
let value: DynamicConfigValue<Int> = .perMonitor(
[
PerMonitorValue(description: mainDesc, value: 16, windows: 1, workspace: nil),
PerMonitorValue(description: mainDesc, value: 32, windows: 2, workspace: nil),
PerMonitorValue(description: mainDesc, value: 24, windows: nil, workspace: nil),
], default: 8)

// Test exact matches - only exact matches should work
XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 16)
XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 32)

// Test fallback to monitor-only value when there's no exact match
XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 3), 24)
XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 4), 24)

// Test fallback to default
XCTAssertEqual(value.getValue(for: secondaryMonitor, windowCount: 1), 8)
XCTAssertEqual(value.getValue(for: secondaryMonitor, windowCount: 2), 8)
}

@MainActor
func testSingleWindowRule() {
// This test verifies the case when a rule is specified only for 1 window
let mainMonitor = TestMonitor(id: "main")

let mainDesc = MonitorDescription.pattern("main")!
let value: DynamicConfigValue<Int> = .perMonitor(
[
PerMonitorValue(description: mainDesc, value: 720, windows: 1, workspace: nil)
], default: 8)

// Exact match for 1 window
XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 720)

// Default value should be used for 2 or more windows
XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 8)
XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 3), 8)
}

@MainActor
func testIgnoreFloatingWindows() {
// This test verifies that floating windows are ignored in the count
// However, in tests we simply use the windowCount value
// So we verify this indirectly using isUnitTest and corresponding code

let mainMonitor = TestMonitor(id: "main")

let mainDesc = MonitorDescription.pattern("main")!
let value: DynamicConfigValue<Int> = .perMonitor(
[
PerMonitorValue(description: mainDesc, value: 720, windows: 1, workspace: nil),
PerMonitorValue(description: mainDesc, value: 360, windows: 2, workspace: nil),
], default: 8)

// Tests work with the provided windowCount
XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 720)
XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 360)
}

@MainActor
func testConfigParsing() async throws {
let config = """
[gaps]
inner.vertical = [
{ monitor.main = { value = 16, windows = 1 } },
{ monitor.main = { value = 32, windows = 2 } },
{ monitor.secondary = 24 },
8
]
"""

let (parsed, parseErrors) = parseConfig(config)
XCTAssertTrue(parseErrors.isEmpty)

let mainMonitor = TestMonitor(id: "main")
let secondaryMonitor = TestMonitor(id: "secondary")

let resolvedMain1 = ResolvedGaps(gaps: parsed.gaps, monitor: mainMonitor, windowCount: 1)
XCTAssertEqual(resolvedMain1.inner.vertical, 16)

let resolvedMain2 = ResolvedGaps(gaps: parsed.gaps, monitor: mainMonitor, windowCount: 2)
XCTAssertEqual(resolvedMain2.inner.vertical, 32)

let resolvedSecondary = ResolvedGaps(gaps: parsed.gaps, monitor: secondaryMonitor)
XCTAssertEqual(resolvedSecondary.inner.vertical, 24)
}
}

@MainActor
private struct TestMonitor: Monitor {
var id: String
var name: String
var monitorAppKitNsScreenScreensId: Int
var rect: Rect
var visibleRect: Rect
var width: CGFloat
var height: CGFloat

init(id: String = "test") {
self.id = id
self.name = id
self.monitorAppKitNsScreenScreensId = 1
self.rect = Rect(topLeftX: 0, topLeftY: 0, width: 0, height: 0)
self.visibleRect = Rect(topLeftX: 0, topLeftY: 0, width: 0, height: 0)
self.width = 0
self.height = 0
}
}

private struct MockBacktrace {
init() {}
func appending(_ component: String) -> Self { self }
static func + (lhs: Self, rhs: String) -> Self { lhs }
}
Loading
Loading