Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
153 changes: 153 additions & 0 deletions Sources/AppBundleTests/config/DynamicConfigValueTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
@testable import AppBundle
import Common
import TOMLKit
import XCTest

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 - должны работать только точные совпадения
XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 16)
XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 32)

// Test fallback to monitor-only value при отсутствии точного совпадения
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() {
// Этот тест проверяет случай, когда указано правило только для 1 окна
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)

// Точное совпадение для 1 окна
XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 1), 720)

// Для 2 и более окон должно использоваться значение по умолчанию
XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 2), 8)
XCTAssertEqual(value.getValue(for: mainMonitor, windowCount: 3), 8)
}

@MainActor
func testIgnoreFloatingWindows() {
// Этот тест проверяет, что плавающие окна игнорируются при подсчете

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments in Russian, you may want to translate so others can read them :)

// Однако, в тестах мы просто используем значение windowCount
// Поэтому мы проверяем это косвенно, при помощи isUnitTest и соответствующего кода

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)

// Тесты работают с переданным 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