diff --git a/Sources/OmniWM/Core/Config/MonitorGapSettings.swift b/Sources/OmniWM/Core/Config/MonitorGapSettings.swift index bb0e33b9..63de88e8 100644 --- a/Sources/OmniWM/Core/Config/MonitorGapSettings.swift +++ b/Sources/OmniWM/Core/Config/MonitorGapSettings.swift @@ -9,15 +9,22 @@ struct MonitorGapSettings: MonitorSettingsType { var monitorName: String var monitorDisplayId: CGDirectDisplayID? + var innerGap: Double? var outerGapLeft: Double? var outerGapRight: Double? var outerGapTop: Double? var outerGapBottom: Double? + var hasOverrides: Bool { + innerGap != nil || outerGapLeft != nil || outerGapRight != nil || + outerGapTop != nil || outerGapBottom != nil + } + init( id: UUID = UUID(), monitorName: String, monitorDisplayId: CGDirectDisplayID? = nil, + innerGap: Double? = nil, outerGapLeft: Double? = nil, outerGapRight: Double? = nil, outerGapTop: Double? = nil, @@ -26,6 +33,7 @@ struct MonitorGapSettings: MonitorSettingsType { self.id = id self.monitorName = monitorName self.monitorDisplayId = monitorDisplayId + self.innerGap = innerGap self.outerGapLeft = outerGapLeft self.outerGapRight = outerGapRight self.outerGapTop = outerGapTop @@ -34,7 +42,7 @@ struct MonitorGapSettings: MonitorSettingsType { private enum CodingKeys: String, CodingKey { case id, monitorName, monitorDisplayId - case outerGapLeft, outerGapRight, outerGapTop, outerGapBottom + case innerGap, outerGapLeft, outerGapRight, outerGapTop, outerGapBottom } init(from decoder: Decoder) throws { @@ -42,6 +50,7 @@ struct MonitorGapSettings: MonitorSettingsType { id = try container.decode(UUID.self, forKey: .id) monitorName = try container.decode(String.self, forKey: .monitorName) monitorDisplayId = try container.decodeIfPresent(CGDirectDisplayID.self, forKey: .monitorDisplayId) + innerGap = try container.decodeIfPresent(Double.self, forKey: .innerGap) outerGapLeft = try container.decodeIfPresent(Double.self, forKey: .outerGapLeft) outerGapRight = try container.decodeIfPresent(Double.self, forKey: .outerGapRight) outerGapTop = try container.decodeIfPresent(Double.self, forKey: .outerGapTop) @@ -53,6 +62,7 @@ struct MonitorGapSettings: MonitorSettingsType { try container.encode(id, forKey: .id) try container.encode(monitorName, forKey: .monitorName) try container.encodeIfPresent(monitorDisplayId, forKey: .monitorDisplayId) + try container.encodeIfPresent(innerGap, forKey: .innerGap) try container.encodeIfPresent(outerGapLeft, forKey: .outerGapLeft) try container.encodeIfPresent(outerGapRight, forKey: .outerGapRight) try container.encodeIfPresent(outerGapTop, forKey: .outerGapTop) @@ -61,6 +71,7 @@ struct MonitorGapSettings: MonitorSettingsType { } struct ResolvedGapSettings: Equatable { + let innerGap: CGFloat let outerGapLeft: CGFloat let outerGapRight: CGFloat let outerGapTop: CGFloat diff --git a/Sources/OmniWM/Core/Config/MonitorSettingsType.swift b/Sources/OmniWM/Core/Config/MonitorSettingsType.swift index 20572afa..086be463 100644 --- a/Sources/OmniWM/Core/Config/MonitorSettingsType.swift +++ b/Sources/OmniWM/Core/Config/MonitorSettingsType.swift @@ -62,4 +62,14 @@ enum MonitorSettingsStore { static func remove(for monitorName: String, from settings: inout [T]) { settings.removeAll { $0.monitorName == monitorName } } + + static func remove(matching item: T, from settings: inout [T]) { + settings.removeAll { existing in + if let displayId = item.monitorDisplayId { + return existing.monitorDisplayId == displayId || + (existing.monitorDisplayId == nil && existing.monitorName == item.monitorName) + } + return existing.monitorDisplayId == nil && existing.monitorName == item.monitorName + } + } } diff --git a/Sources/OmniWM/Core/Config/SettingsStore.swift b/Sources/OmniWM/Core/Config/SettingsStore.swift index 236eedca..943611bc 100644 --- a/Sources/OmniWM/Core/Config/SettingsStore.swift +++ b/Sources/OmniWM/Core/Config/SettingsStore.swift @@ -828,7 +828,7 @@ final class SettingsStore { monitors: monitors ) monitorGapSettings = SettingsStore.reboundMonitorSettings( - export.monitorGapSettings, + export.monitorGapSettings.filter(\.hasOverrides), monitors: monitors ) @@ -1218,14 +1218,23 @@ final class SettingsStore { } func resolvedDwindleSettings(for monitor: Monitor) -> ResolvedDwindleSettings { - resolvedDwindleSettings(override: dwindleSettings(for: monitor)) + resolvedDwindleSettings( + override: dwindleSettings(for: monitor), + sharedInnerGap: resolvedGapSettings(for: monitor).innerGap + ) } func resolvedDwindleSettings(for monitorName: String) -> ResolvedDwindleSettings { - resolvedDwindleSettings(override: dwindleSettings(for: monitorName)) + resolvedDwindleSettings( + override: dwindleSettings(for: monitorName), + sharedInnerGap: resolvedInnerGap(gapSettings(for: monitorName)?.innerGap) + ) } - private func resolvedDwindleSettings(override: MonitorDwindleSettings?) -> ResolvedDwindleSettings { + private func resolvedDwindleSettings( + override: MonitorDwindleSettings?, + sharedInnerGap: CGFloat + ) -> ResolvedDwindleSettings { let useGlobalGaps = override?.useGlobalGaps ?? dwindleUseGlobalGaps return ResolvedDwindleSettings( smartSplit: override?.smartSplit ?? dwindleSmartSplit, @@ -1233,7 +1242,7 @@ final class SettingsStore { splitWidthMultiplier: CGFloat(override?.splitWidthMultiplier ?? dwindleSplitWidthMultiplier), singleWindowFit: override?.singleWindowFit ?? dwindleSingleWindowFit, useGlobalGaps: useGlobalGaps, - innerGap: useGlobalGaps ? CGFloat(gapSize) : CGFloat(override?.innerGap ?? gapSize) + innerGap: useGlobalGaps ? sharedInnerGap : CGFloat(override?.innerGap ?? gapSize) ) } @@ -1246,7 +1255,11 @@ final class SettingsStore { } func updateGapSettings(_ settings: MonitorGapSettings) { - MonitorSettingsStore.update(settings, in: &monitorGapSettings) + if settings.hasOverrides { + MonitorSettingsStore.update(settings, in: &monitorGapSettings) + } else { + MonitorSettingsStore.remove(matching: settings, from: &monitorGapSettings) + } } func removeGapSettings(for monitor: Monitor) { @@ -1260,6 +1273,7 @@ final class SettingsStore { func resolvedGapSettings(for monitor: Monitor) -> ResolvedGapSettings { let override = gapSettings(for: monitor) return ResolvedGapSettings( + innerGap: resolvedInnerGap(override?.innerGap), outerGapLeft: CGFloat(override?.outerGapLeft ?? outerGapLeft), outerGapRight: CGFloat(override?.outerGapRight ?? outerGapRight), outerGapTop: CGFloat(override?.outerGapTop ?? outerGapTop), @@ -1267,6 +1281,10 @@ final class SettingsStore { ) } + private func resolvedInnerGap(_ override: Double?) -> CGFloat { + CGFloat(min(64, max(0, override ?? gapSize))) + } + nonisolated static let defaultColumnWidthPresets: [Double] = BuiltInSettingsDefaults.niriColumnWidthPresets static func validatedPresets(_ presets: [Double]) -> [Double] { diff --git a/Sources/OmniWM/Core/Controller/CommandHandler.swift b/Sources/OmniWM/Core/Controller/CommandHandler.swift index 1818a672..b3d29605 100644 --- a/Sources/OmniWM/Core/Controller/CommandHandler.swift +++ b/Sources/OmniWM/Core/Controller/CommandHandler.swift @@ -281,7 +281,7 @@ final class CommandHandler { var state = controller.workspaceManager.niriViewportState(for: workspaceId) let motion = controller.motionPolicy.snapshot() let workingFrame = controller.insetWorkingFrame(for: monitor) - let gaps = CGFloat(controller.workspaceManager.gaps) + let gaps = controller.innerGap(for: monitor) let previousWindow = controller.workspaceManager.withEngineMutationScope { () -> NiriWindow? in if let currentId = state.selectedNodeId { @@ -543,7 +543,7 @@ final class CommandHandler { return } - let gap = CGFloat(controller.workspaceManager.gaps) + let gap = controller.innerGap(for: monitor) let workingFrame = controller.insetWorkingFrame(for: monitor) let motion = controller.motionPolicy.snapshot() guard let newNode = controller.workspaceManager.withEngineMutationScope(label: "focus_navigation", { diff --git a/Sources/OmniWM/Core/Controller/DwindleLayoutHandler.swift b/Sources/OmniWM/Core/Controller/DwindleLayoutHandler.swift index 781359c7..fb4126d3 100644 --- a/Sources/OmniWM/Core/Controller/DwindleLayoutHandler.swift +++ b/Sources/OmniWM/Core/Controller/DwindleLayoutHandler.swift @@ -1017,9 +1017,11 @@ import QuartzCore ) { guard let controller, let engine = controller.dwindleEngine, - let wsId = controller.activeWorkspace()?.id + let wsId = controller.activeWorkspace()?.id, + let monitor = controller.workspaceManager.monitor(for: wsId) else { return } controller.workspaceManager.withEngineMutationScope { + applyResolvedSettings(controller.settings.resolvedDwindleSettings(for: monitor), to: engine) perform(engine, wsId) } } diff --git a/Sources/OmniWM/Core/Controller/MouseEventHandler.swift b/Sources/OmniWM/Core/Controller/MouseEventHandler.swift index 7f2fdd67..65961256 100644 --- a/Sources/OmniWM/Core/Controller/MouseEventHandler.swift +++ b/Sources/OmniWM/Core/Controller/MouseEventHandler.swift @@ -621,7 +621,7 @@ final class MouseEventHandler { return } let workingFrame = controller.insetWorkingFrame(for: monitor) - let gaps = CGFloat(controller.workspaceManager.gaps) + let gaps = controller.innerGap(for: monitor) controller.workspaceManager.withNiriViewportState(for: workspaceId) { viewportState in engine.interactiveResizeEnd( motion: controller.motionPolicy.snapshot(), @@ -765,7 +765,7 @@ final class MouseEventHandler { let monitor = controller.workspaceManager.monitor(for: wsId) { let workingFrame = controller.insetWorkingFrame(for: monitor) - let gaps = CGFloat(controller.workspaceManager.gaps) + let gaps = controller.innerGap(for: monitor) let isInsertMode = modifiers.contains(.maskShift) var moveStarted = false @@ -942,7 +942,7 @@ final class MouseEventHandler { targetWindowId: nodeId, position: insertPosition, in: wsId, - gaps: CGFloat(controller.workspaceManager.gaps) + gaps: controller.innerGap(for: wsId) ) { state.dragGhostController?.showSwapTarget(frame: dropFrame) } @@ -980,8 +980,8 @@ final class MouseEventHandler { } let gaps = LayoutGaps( - horizontal: CGFloat(controller.workspaceManager.gaps), - vertical: CGFloat(controller.workspaceManager.gaps), + horizontal: controller.innerGap(for: monitor), + vertical: controller.innerGap(for: monitor), outer: controller.workspaceManager.outerGaps ) let insetFrame = controller.insetWorkingFrame(for: monitor) @@ -1014,7 +1014,7 @@ final class MouseEventHandler { let wsId = move.workspaceId if let monitor = controller.workspaceManager.monitor(for: wsId) { let workingFrame = controller.insetWorkingFrame(for: monitor) - let gaps = CGFloat(controller.workspaceManager.gaps) + let gaps = controller.innerGap(for: monitor) let movedToken = move.windowHandle.id var didEnd = false controller.workspaceManager.withNiriViewportState(for: wsId) { vstate in @@ -1613,7 +1613,7 @@ final class MouseEventHandler { ) { guard let controller else { return } let insetFrame = controller.insetWorkingFrame(for: monitor) - let gap = CGFloat(controller.workspaceManager.gaps) + let gap = controller.innerGap(for: monitor) let step = ticks > 0 ? 1 : -1 let motion = controller.motionPolicy.snapshot() @@ -1682,7 +1682,7 @@ final class MouseEventHandler { let insetFrame = controller.insetWorkingFrame(for: monitor) let columns = engine.columns(in: wsId) - let gap = CGFloat(controller.workspaceManager.gaps) + let gap = controller.innerGap(for: monitor) let scale = NSScreen.screens.first(where: { $0.displayId == monitor.displayId })? .backingScaleFactor ?? 2.0 diff --git a/Sources/OmniWM/Core/Controller/NiriLayoutHandler.swift b/Sources/OmniWM/Core/Controller/NiriLayoutHandler.swift index 7f452bc0..d5ee7006 100644 --- a/Sources/OmniWM/Core/Controller/NiriLayoutHandler.swift +++ b/Sources/OmniWM/Core/Controller/NiriLayoutHandler.swift @@ -444,7 +444,7 @@ enum StructuralMutationOutcome: Equatable { hasCompletedInitialRefresh: controller.layoutRefreshController.layoutState.hasCompletedInitialRefresh, useScrollAnimationPath: useScrollAnimationPath, removalSeed: removalSeed, - gap: CGFloat(controller.workspaceManager.gaps), + gap: controller.innerGap(for: monitor), outerGaps: controller.workspaceManager.outerGaps, displayRefreshRate: controller.layoutRefreshController.layoutState .refreshRateByDisplay[monitor.displayId] ?? 60.0, @@ -1310,7 +1310,7 @@ enum StructuralMutationOutcome: Equatable { var state = controller.workspaceManager.niriViewportState(for: workspaceId) controller.workspaceManager.withEngineMutationScope { if let monitor = controller.workspaceManager.monitor(for: workspaceId) { - let gap = CGFloat(controller.workspaceManager.gaps) + let gap = controller.innerGap(for: monitor) engine.ensureSelectionVisible( node: target, in: workspaceId, @@ -1391,7 +1391,7 @@ enum StructuralMutationOutcome: Equatable { } guard let monitor = controller.workspaceManager.monitor(for: wsId) else { return false } - let gap = CGFloat(controller.workspaceManager.gaps) + let gap = controller.innerGap(for: monitor) let workingFrame = controller.insetWorkingFrame(for: monitor) let newNode = controller.workspaceManager.withEngineMutationScope { () -> NiriNode? in @@ -1794,7 +1794,7 @@ enum StructuralMutationOutcome: Equatable { } if options.ensureVisible, let monitor = controller.workspaceManager.monitor(for: workspaceId) { - let gap = CGFloat(controller.workspaceManager.gaps) + let gap = controller.innerGap(for: monitor) let workingFrame = controller.insetWorkingFrame(for: monitor) engine.ensureSelectionVisible( node: node, @@ -1860,7 +1860,7 @@ enum StructuralMutationOutcome: Equatable { return } - let gap = CGFloat(controller.workspaceManager.gaps) + let gap = controller.innerGap(for: monitor) let workingFrame = controller.insetWorkingFrame(for: monitor) let orientation = engine.monitor(for: monitor.id)?.orientation ?? controller.settings.effectiveOrientation(for: monitor) @@ -1948,7 +1948,7 @@ enum StructuralMutationOutcome: Equatable { let workspaceId = entry.workspaceId let workingFrame = controller.insetWorkingFrame(for: monitor) - let gaps = CGFloat(controller.workspaceManager.gaps) + let gaps = controller.innerGap(for: monitor) let context = NiriOperationContext( controller: controller, engine: engine, @@ -2113,7 +2113,7 @@ enum StructuralMutationOutcome: Equatable { guard let controller, let engine = controller.niriEngine else { return } guard let monitor = controller.workspaceManager.monitor(for: workspaceId) else { return } let workingFrame = controller.insetWorkingFrame(for: monitor) - let gaps = CGFloat(controller.workspaceManager.gaps) + let gaps = controller.innerGap(for: monitor) var targetState = controller.workspaceManager.niriViewportState(for: workspaceId) var consumed = false @@ -2419,7 +2419,7 @@ enum StructuralMutationOutcome: Equatable { guard let monitor = controller.workspaceManager.monitor(for: wsId) else { return } let motion = controller.motionPolicy.snapshot() let workingFrame = controller.insetWorkingFrame(for: monitor) - let gaps = CGFloat(controller.workspaceManager.gaps) + let gaps = controller.innerGap(for: monitor) controller.workspaceManager.withNiriViewportState(for: wsId) { state in perform(engine, wsId, motion, &state, monitor, workingFrame, gaps) } @@ -2442,7 +2442,7 @@ enum StructuralMutationOutcome: Equatable { guard let monitor = controller.workspaceManager.monitor(for: workspaceId) else { return } let motion = controller.motionPolicy.snapshot() let workingFrame = controller.insetWorkingFrame(for: monitor) - let gaps = CGFloat(controller.workspaceManager.gaps) + let gaps = controller.innerGap(for: monitor) controller.workspaceManager.withNiriViewportState(for: workspaceId) { state in perform(engine, workspaceId, motion, &state, monitor, workingFrame, gaps) } diff --git a/Sources/OmniWM/Core/Controller/WMController.swift b/Sources/OmniWM/Core/Controller/WMController.swift index 7567b29a..e9a853ff 100644 --- a/Sources/OmniWM/Core/Controller/WMController.swift +++ b/Sources/OmniWM/Core/Controller/WMController.swift @@ -343,12 +343,13 @@ final class WMController { updateHotkeyBindings(settings.hotkeyBindings) setHotkeysEnabled(settings.hotkeysEnabled) - setGapSize(settings.gapSize) + setGapSize(settings.gapSize, publishChange: false) setOuterGaps( left: settings.outerGapLeft, right: settings.outerGapRight, top: settings.outerGapTop, - bottom: settings.outerGapBottom + bottom: settings.outerGapBottom, + publishChange: false ) if niriEngine == nil { @@ -486,12 +487,24 @@ final class WMController { refreshHotkeyFailureSnapshots() } - func setGapSize(_ size: Double) { + func setGapSize(_ size: Double, publishChange: Bool = true) { workspaceManager.setGaps(to: size) + if publishChange { + publishDisplayChanged() + } } - func setOuterGaps(left: Double, right: Double, top: Double, bottom: Double) { + func setOuterGaps( + left: Double, + right: Double, + top: Double, + bottom: Double, + publishChange: Bool = true + ) { workspaceManager.setOuterGaps(left: left, right: right, top: top, bottom: bottom) + if publishChange { + publishDisplayChanged() + } } func borderSettingsChanged() { @@ -708,6 +721,14 @@ final class WMController { func updateMonitorGapSettings() { layoutRefreshController.requestRelayout(reason: .monitorSettingsChanged) + publishDisplayChanged() + } + + private func publishDisplayChanged() { + guard let ipcApplicationBridge else { return } + Task { + await ipcApplicationBridge.publishEvent(.displayChanged) + } } func workspaceBarItems( @@ -877,6 +898,20 @@ final class WMController { mouseWarpHandler.resetTransientState() } + func innerGap(for monitor: Monitor) -> CGFloat { + guard settings.gapSettings(for: monitor)?.innerGap != nil else { + return CGFloat(workspaceManager.gaps) + } + return settings.resolvedGapSettings(for: monitor).innerGap + } + + func innerGap(for workspaceId: WorkspaceDescriptor.ID) -> CGFloat { + guard let monitor = workspaceManager.monitor(for: workspaceId) else { + return CGFloat(workspaceManager.gaps) + } + return innerGap(for: monitor) + } + func insetWorkingFrame(for monitor: Monitor) -> CGRect { let scale = NSScreen.screens.first(where: { $0.displayId == monitor.displayId })?.backingScaleFactor ?? 2.0 let reservedTopInset = workspaceBarReservedTopInset(for: monitor) diff --git a/Sources/OmniWM/Core/Controller/WindowActionHandler.swift b/Sources/OmniWM/Core/Controller/WindowActionHandler.swift index 717348b5..7cba063e 100644 --- a/Sources/OmniWM/Core/Controller/WindowActionHandler.swift +++ b/Sources/OmniWM/Core/Controller/WindowActionHandler.swift @@ -471,7 +471,7 @@ final class WindowActionHandler { } let cols = engine.columns(in: workspaceId) - let gap = CGFloat(controller.workspaceManager.gaps) + let gap = controller.innerGap(for: monitor) let settings = engine.effectiveSettings(in: workspaceId) let workingFrame = controller.insetWorkingFrame(for: monitor) targetState.transitionToColumn( diff --git a/Sources/OmniWM/Core/Controller/WorkspaceNavigationHandler.swift b/Sources/OmniWM/Core/Controller/WorkspaceNavigationHandler.swift index cf29c9e5..1ee1897b 100644 --- a/Sources/OmniWM/Core/Controller/WorkspaceNavigationHandler.swift +++ b/Sources/OmniWM/Core/Controller/WorkspaceNavigationHandler.swift @@ -118,7 +118,7 @@ final class WorkspaceNavigationHandler { var state = controller.workspaceManager.niriViewportState(for: workspaceId) state.selectedNodeId = movedNode.id - let gap = CGFloat(controller.workspaceManager.gaps) + let gap = controller.innerGap(for: monitor) let workingFrame = controller.insetWorkingFrame(for: monitor) controller.workspaceManager.withEngineMutationScope { engine.activateWindow(movedNode.id, in: workspaceId) @@ -1040,7 +1040,7 @@ final class WorkspaceNavigationHandler { let movedTokens = column.windowNodes.map(\.token) let targetWorkingFrame = controller.insetWorkingFrame(for: targetMonitor) - let gaps = CGFloat(controller.workspaceManager.gaps) + let gaps = controller.innerGap(for: targetMonitor) let motion = controller.motionPolicy.snapshot() guard let result = controller.workspaceManager.withBatchedWorkspaceMove( sourceWorkspaceId: sourceWorkspaceId, diff --git a/Sources/OmniWM/IPC/IPCQueryRouter.swift b/Sources/OmniWM/IPC/IPCQueryRouter.swift index 71c00e89..9842c55b 100644 --- a/Sources/OmniWM/IPC/IPCQueryRouter.swift +++ b/Sources/OmniWM/IPC/IPCQueryRouter.swift @@ -377,6 +377,7 @@ final class IPCQueryRouter { hasNotch: include("has-notch", in: fields) ? monitor.hasNotch : nil, orientation: include("orientation", in: fields) ? ipcDisplayOrientation(from: controller.settings.effectiveOrientation(for: monitor)) : nil, + innerGap: include("inner-gap", in: fields) ? Double(gaps.innerGap) : nil, outerGapLeft: include("outer-gap-left", in: fields) ? Double(gaps.outerGapLeft) : nil, outerGapRight: include("outer-gap-right", in: fields) ? Double(gaps.outerGapRight) : nil, outerGapTop: include("outer-gap-top", in: fields) ? Double(gaps.outerGapTop) : nil, diff --git a/Sources/OmniWM/UI/SettingsView.swift b/Sources/OmniWM/UI/SettingsView.swift index f592529b..27b96c6e 100644 --- a/Sources/OmniWM/UI/SettingsView.swift +++ b/Sources/OmniWM/UI/SettingsView.swift @@ -101,29 +101,47 @@ struct GeneralSettingsTab: View { ) } - Section("Layout") { - SettingsSliderRow( - label: "Inner Gaps", - value: $settings.gapSize, - range: 0 ... 32, - step: 1, - valueText: "\(Int(settings.gapSize)) px", - valueWidth: 64 - ) - .onChange(of: settings.gapSize) { _, newValue in - controller.setGapSize(newValue) + MonitorScopeSection( + selectedMonitor: $selectedGapMonitor, + monitors: connectedMonitors, + hasOverrides: { settings.gapSettings(for: $0) != nil }, + reset: { monitor in + settings.removeGapSettings(for: monitor) + controller.updateMonitorGapSettings() } - } + ) - Section("Outer Margins") { - Picker("Configure", selection: $selectedGapMonitor) { - Text("Global Defaults").tag(nil as Monitor.ID?) - ForEach(connectedMonitors, id: \.id) { monitor in - Text(monitor.isMain ? "\(monitor.name) (Main)" : monitor.name) - .tag(monitor.id as Monitor.ID?) + Section("Layout") { + if let monitorId = selectedGapMonitor, + let monitor = connectedMonitors.first(where: { $0.id == monitorId }) + { + OverridableSlider( + label: "Inner Gaps", + value: settings.gapSettings(for: monitor)?.innerGap, + globalValue: settings.gapSize, + range: 0 ... 32, + step: 1, + formatter: { "\(Int($0)) px" }, + onChange: { value in updateGapSetting(for: monitor) { $0.innerGap = value } }, + onReset: { updateGapSetting(for: monitor) { $0.innerGap = nil } } + ) + SettingsCaption("Overrides the global inner gap for \(monitor.name).") + } else { + SettingsSliderRow( + label: "Inner Gaps", + value: $settings.gapSize, + range: 0 ... 32, + step: 1, + valueText: "\(Int(settings.gapSize)) px", + valueWidth: 64 + ) + .onChange(of: settings.gapSize) { _, newValue in + controller.setGapSize(newValue) } } + } + Section("Outer Margins") { if let monitorId = selectedGapMonitor, let monitor = connectedMonitors.first(where: { $0.id == monitorId }) { diff --git a/Sources/OmniWMCtl/CLIRenderer.swift b/Sources/OmniWMCtl/CLIRenderer.swift index b51a3af0..fd5dbed6 100644 --- a/Sources/OmniWMCtl/CLIRenderer.swift +++ b/Sources/OmniWMCtl/CLIRenderer.swift @@ -322,7 +322,8 @@ enum CLIRenderer { } private static func formattedDisplays(_ payload: IPCDisplaysQueryResult, format: CLIOutputFormat) -> String { - let rows = payload.displays.map { display in + var headers = ["ID", "NAME", "MAIN", "CURRENT", "ORIENTATION", "ACTIVE WORKSPACE", "FRAME"] + var rows = payload.displays.map { display in [ display.id ?? "-", display.name ?? "-", @@ -334,11 +335,43 @@ enum CLIRenderer { ] } - return formatRows( - headers: ["ID", "NAME", "MAIN", "CURRENT", "ORIENTATION", "ACTIVE WORKSPACE", "FRAME"], - rows: rows, - format: format + appendDisplayColumn("INNER GAP", values: payload.displays.map(\.innerGap), headers: &headers, rows: &rows) + appendDisplayColumn("OUTER LEFT", values: payload.displays.map(\.outerGapLeft), headers: &headers, rows: &rows) + appendDisplayColumn( + "OUTER RIGHT", + values: payload.displays.map(\.outerGapRight), + headers: &headers, + rows: &rows ) + appendDisplayColumn("OUTER TOP", values: payload.displays.map(\.outerGapTop), headers: &headers, rows: &rows) + appendDisplayColumn( + "OUTER BOTTOM", + values: payload.displays.map(\.outerGapBottom), + headers: &headers, + rows: &rows + ) + + return formatRows(headers: headers, rows: rows, format: format) + } + + private static func appendDisplayColumn( + _ header: String, + values: [Double?], + headers: inout [String], + rows: inout [[String]] + ) { + guard values.contains(where: { $0 != nil }) else { return } + headers.append(header) + for index in rows.indices { + rows[index].append(values[index].map { gapValueDescription($0) } ?? "-") + } + } + + private static func gapValueDescription(_ value: Double) -> String { + guard value.isFinite else { return String(value) } + return value == value.rounded() + ? String(format: "%.0f", locale: Locale(identifier: "en_US_POSIX"), value) + : String(value) } private static func formattedRules(_ payload: IPCRulesQueryResult, format: CLIOutputFormat) -> String { diff --git a/Sources/OmniWMIPC/IPCAutomationManifest.swift b/Sources/OmniWMIPC/IPCAutomationManifest.swift index 219c0abe..fbd1e750 100644 --- a/Sources/OmniWMIPC/IPCAutomationManifest.swift +++ b/Sources/OmniWMIPC/IPCAutomationManifest.swift @@ -327,6 +327,7 @@ public enum IPCAutomationManifest { "visible-frame", "has-notch", "orientation", + "inner-gap", "outer-gap-left", "outer-gap-right", "outer-gap-top", diff --git a/Sources/OmniWMIPC/IPCModels.swift b/Sources/OmniWMIPC/IPCModels.swift index 6ea02daf..722a229e 100644 --- a/Sources/OmniWMIPC/IPCModels.swift +++ b/Sources/OmniWMIPC/IPCModels.swift @@ -2316,6 +2316,7 @@ public struct IPCDisplayQuerySnapshot: Codable, Equatable, Sendable { public let visibleFrame: IPCRect? public let hasNotch: Bool? public let orientation: IPCDisplayOrientation? + public let innerGap: Double? public let outerGapLeft: Double? public let outerGapRight: Double? public let outerGapTop: Double? @@ -2331,6 +2332,7 @@ public struct IPCDisplayQuerySnapshot: Codable, Equatable, Sendable { visibleFrame: IPCRect? = nil, hasNotch: Bool? = nil, orientation: IPCDisplayOrientation? = nil, + innerGap: Double? = nil, outerGapLeft: Double? = nil, outerGapRight: Double? = nil, outerGapTop: Double? = nil, @@ -2345,6 +2347,7 @@ public struct IPCDisplayQuerySnapshot: Codable, Equatable, Sendable { self.visibleFrame = visibleFrame self.hasNotch = hasNotch self.orientation = orientation + self.innerGap = innerGap self.outerGapLeft = outerGapLeft self.outerGapRight = outerGapRight self.outerGapTop = outerGapTop diff --git a/Tests/OmniWMTests/GapSettingsTests.swift b/Tests/OmniWMTests/GapSettingsTests.swift index c63e29ae..f2c00067 100644 --- a/Tests/OmniWMTests/GapSettingsTests.swift +++ b/Tests/OmniWMTests/GapSettingsTests.swift @@ -4,6 +4,7 @@ import CoreGraphics import Foundation @testable import OmniWM +import OmniWMIPC import XCTest final class GapSettingsTests: XCTestCase { @@ -36,6 +37,7 @@ final class GapSettingsTests: XCTestCase { let json = Data(#"{"id":"\#(UUID().uuidString)","monitorName":"Built-in","outerGapTop":20}"#.utf8) let decoded = try JSONDecoder().decode(MonitorGapSettings.self, from: json) XCTAssertEqual(decoded.outerGapTop, 20) + XCTAssertNil(decoded.innerGap) XCTAssertNil(decoded.outerGapLeft) XCTAssertNil(decoded.outerGapRight) XCTAssertNil(decoded.outerGapBottom) @@ -45,6 +47,7 @@ final class GapSettingsTests: XCTestCase { let original = MonitorGapSettings( monitorName: "Built-in", monitorDisplayId: 7, + innerGap: 6, outerGapTop: 20, outerGapBottom: 12 ) @@ -75,23 +78,219 @@ final class GapSettingsTests: XCTestCase { settings.outerGapRight = 12 settings.outerGapTop = 46 settings.outerGapBottom = 12 + settings.gapSize = 16 let monitor = makeMonitor(displayId: 1, name: "Built-in") let globalOnly = settings.resolvedGapSettings(for: monitor) + XCTAssertEqual(globalOnly.innerGap, 16) XCTAssertEqual(globalOnly.outerGapTop, 46) XCTAssertEqual(globalOnly.outerGapLeft, 12) settings.updateGapSettings( - MonitorGapSettings(monitorName: "Built-in", monitorDisplayId: 1, outerGapTop: 20) + MonitorGapSettings(monitorName: "Built-in", monitorDisplayId: 1, innerGap: 6, outerGapTop: 20) ) let resolved = settings.resolvedGapSettings(for: monitor) + XCTAssertEqual(resolved.innerGap, 6) XCTAssertEqual(resolved.outerGapTop, 20) XCTAssertEqual(resolved.outerGapLeft, 12) XCTAssertEqual(resolved.outerGapBottom, 12) } + @MainActor + func testResolvedInnerGapMatchesRuntimeBoundsAndPreservesExplicitZero() { + let settings = makeSettingsStore() + let monitor = makeMonitor(displayId: 1, name: "Built-in") + + settings.gapSize = 100 + XCTAssertEqual(settings.resolvedGapSettings(for: monitor).innerGap, 64) + + settings.updateGapSettings( + MonitorGapSettings(monitorName: monitor.name, monitorDisplayId: monitor.displayId, innerGap: -10) + ) + XCTAssertEqual(settings.resolvedGapSettings(for: monitor).innerGap, 0) + + settings.updateGapSettings( + MonitorGapSettings(monitorName: monitor.name, monitorDisplayId: monitor.displayId, innerGap: 0) + ) + XCTAssertEqual(settings.gapSettings(for: monitor)?.innerGap, 0) + } + + @MainActor + func testClearingFinalGapOverrideRemovesMonitorRecord() { + let settings = makeSettingsStore() + let monitor = makeMonitor(displayId: 1, name: "Built-in") + var override = MonitorGapSettings( + monitorName: monitor.name, + monitorDisplayId: monitor.displayId, + innerGap: 6, + outerGapTop: 20 + ) + + settings.updateGapSettings(override) + override.innerGap = nil + settings.updateGapSettings(override) + XCTAssertNil(settings.gapSettings(for: monitor)?.innerGap) + XCTAssertEqual(settings.gapSettings(for: monitor)?.outerGapTop, 20) + + override.outerGapTop = nil + settings.updateGapSettings(override) + XCTAssertNil(settings.gapSettings(for: monitor)) + XCTAssertTrue(settings.toExport().monitorGapSettings.isEmpty) + } + + @MainActor + func testApplyingExportDropsEmptyLegacyGapRecords() { + let settings = makeSettingsStore() + let monitor = makeMonitor(displayId: 1, name: "Built-in") + var export = SettingsExport.defaults() + export.monitorGapSettings = [ + MonitorGapSettings(monitorName: monitor.name, monitorDisplayId: monitor.displayId) + ] + + settings.applyExport(export, monitors: [monitor]) + + XCTAssertNil(settings.gapSettings(for: monitor)) + XCTAssertTrue(settings.toExport().monitorGapSettings.isEmpty) + } + + @MainActor + func testDwindleGeneralGapUsesDisplayOverrideWithoutChangingSpecificGapPrecedence() { + let settings = makeSettingsStore() + let monitor = makeMonitor(displayId: 1, name: "Built-in") + settings.gapSize = 16 + settings.dwindleUseGlobalGaps = true + settings.updateGapSettings( + MonitorGapSettings(monitorName: monitor.name, monitorDisplayId: monitor.displayId, innerGap: 24) + ) + + let generalResolved = settings.resolvedDwindleSettings(for: monitor) + XCTAssertEqual(generalResolved.innerGap, 24) + + let controller = WMController(settings: settings) + controller.workspaceManager.applyMonitorConfigurationChange([monitor]) + _ = controller.workspaceManager.focusWorkspace(named: "1") + controller.dwindleLayoutHandler.enableDwindleLayout() + controller.dwindleLayoutHandler.withDwindleContext { engine, _ in + XCTAssertEqual(engine.settings.innerGap, 24) + } + + settings.updateDwindleSettings( + MonitorDwindleSettings( + monitorName: monitor.name, + monitorDisplayId: monitor.displayId, + useGlobalGaps: false, + innerGap: 6 + ) + ) + XCTAssertEqual(settings.resolvedDwindleSettings(for: monitor).innerGap, 6) + } + + @MainActor + func testDisplaysQueryProjectsOnlyRequestedResolvedInnerGap() throws { + let settings = makeSettingsStore() + let monitor = makeMonitor(displayId: 1, name: "Built-in") + settings.updateGapSettings( + MonitorGapSettings(monitorName: monitor.name, monitorDisplayId: monitor.displayId, innerGap: 6) + ) + let controller = WMController(settings: settings) + controller.workspaceManager.applyMonitorConfigurationChange([monitor]) + let router = IPCQueryRouter(controller: controller, appVersion: nil, sessionToken: "gap-tests") + + XCTAssertTrue(IPCAutomationManifest.displayFieldCatalog.contains("inner-gap")) + let projected = try XCTUnwrap( + router.displaysResult(IPCQueryRequest(name: .displays, fields: ["id", "inner-gap"])).displays.first + ) + XCTAssertEqual(projected.innerGap, 6) + XCTAssertNotNil(projected.id) + XCTAssertNil(projected.outerGapLeft) + + let omitted = try XCTUnwrap( + router.displaysResult(IPCQueryRequest(name: .displays, fields: ["id"])).displays.first + ) + XCTAssertNil(omitted.innerGap) + } + + @MainActor + func testNiriLayoutRoutesInnerGapByWorkspaceDisplay() throws { + try assertLayoutRoutesInnerGapByWorkspaceDisplay(.niri) + } + + @MainActor + func testDwindleLayoutRoutesInnerGapByWorkspaceDisplay() throws { + try assertLayoutRoutesInnerGapByWorkspaceDisplay(.dwindle) + } + + @MainActor + private func assertLayoutRoutesInnerGapByWorkspaceDisplay(_ layout: LayoutType) throws { + let settings = makeSettingsStore() + let left = makeMonitor(displayId: 1, name: "Left", originX: 0) + let right = makeMonitor(displayId: 2, name: "Right", originX: 1440) + settings.workspaceConfigurations = [ + WorkspaceConfiguration( + name: "1", + monitorAssignment: .specificDisplay(OutputId(from: left)), + layoutType: layout + ), + WorkspaceConfiguration( + name: "2", + monitorAssignment: .specificDisplay(OutputId(from: right)), + layoutType: layout + ) + ] + settings.updateGapSettings( + MonitorGapSettings(monitorName: left.name, monitorDisplayId: left.displayId, innerGap: 4) + ) + settings.updateGapSettings( + MonitorGapSettings(monitorName: right.name, monitorDisplayId: right.displayId, innerGap: 24) + ) + let controller = WMController(settings: settings) + controller.workspaceManager.applyMonitorConfigurationChange([left, right]) + controller.workspaceManager.applySettings() + if layout == .niri { + controller.niriLayoutHandler.enableNiriLayout() + controller.syncMonitorsToNiriEngine() + } else { + controller.dwindleLayoutHandler.enableDwindleLayout() + } + let leftWorkspace = try XCTUnwrap(controller.workspaceManager.workspaceId(named: "1")) + let rightWorkspace = try XCTUnwrap(controller.workspaceManager.workspaceId(named: "2")) + XCTAssertTrue(controller.workspaceManager.setActiveWorkspace(leftWorkspace, on: left.id)) + XCTAssertTrue(controller.workspaceManager.setActiveWorkspace(rightWorkspace, on: right.id)) + + let leftTokens = [ + addWindow(pid: 101, windowId: 201, to: leftWorkspace, controller: controller), + addWindow(pid: 102, windowId: 202, to: leftWorkspace, controller: controller) + ] + let rightTokens = [ + addWindow(pid: 103, windowId: 203, to: rightWorkspace, controller: controller), + addWindow(pid: 104, windowId: 204, to: rightWorkspace, controller: controller) + ] + let plans = controller.workspaceManager.withBatchedLayoutBuild { + if layout == .niri { + controller.niriLayoutHandler.layoutWithNiriEngine( + activeWorkspaces: [leftWorkspace, rightWorkspace] + ) + } else { + controller.dwindleLayoutHandler.layoutWithDwindleEngine( + activeWorkspaces: [leftWorkspace, rightWorkspace] + ) + } + } + let leftPlan = try XCTUnwrap(plans.first { $0.workspaceId == leftWorkspace }) + let rightPlan = try XCTUnwrap(plans.first { $0.workspaceId == rightWorkspace }) + let leftFrames = try leftTokens.map { token in + try XCTUnwrap(leftPlan.diff.frameChanges.first { $0.token == token }?.frame) + } + let rightFrames = try rightTokens.map { token in + try XCTUnwrap(rightPlan.diff.frameChanges.first { $0.token == token }?.frame) + } + + XCTAssertEqual(separation(between: leftFrames[0], and: leftFrames[1]), 4, accuracy: 0.5) + XCTAssertEqual(separation(between: rightFrames[0], and: rightFrames[1]), 24, accuracy: 0.5) + } + func testDwindleApplyGapsEdgesAreFlush() { var settings = DwindleSettings() settings.innerGap = 8 @@ -136,21 +335,46 @@ final class GapSettingsTests: XCTestCase { ) } - private func makeMonitor(displayId: CGDirectDisplayID, name: String) -> Monitor { + private func makeMonitor(displayId: CGDirectDisplayID, name: String, originX: CGFloat = 0) -> Monitor { Monitor( id: .init(displayId: displayId), displayId: displayId, - frame: CGRect(x: 0, y: 0, width: 1440, height: 900), - visibleFrame: CGRect(x: 0, y: 0, width: 1440, height: 900), + frame: CGRect(x: originX, y: 0, width: 1440, height: 900), + visibleFrame: CGRect(x: originX, y: 0, width: 1440, height: 900), hasNotch: false, name: name ) } + private func separation(between first: CGRect, and second: CGRect) -> CGFloat { + max( + max(first.minX, second.minX) - min(first.maxX, second.maxX), + max(first.minY, second.minY) - min(first.maxY, second.maxY) + ) + } + + @MainActor + private func addWindow( + pid: pid_t, + windowId: Int, + to workspaceId: WorkspaceDescriptor.ID, + controller: WMController + ) -> WindowToken { + controller.workspaceManager.addWindow( + AXWindowRef(element: AXUIElementCreateApplication(pid), windowId: windowId), + pid: pid, + windowId: windowId, + to: workspaceId + ) + } + @MainActor private func makeSettingsStore() -> SettingsStore { let root = FileManager.default.temporaryDirectory .appendingPathComponent("OmniWMGapTests-\(UUID().uuidString)", isDirectory: true) + addTeardownBlock { + try? FileManager.default.removeItem(at: root) + } return SettingsStore( persistence: SettingsFilePersistence( directory: root.appendingPathComponent("config", isDirectory: true), diff --git a/Tests/OmniWMTests/SettingsTOMLCodecTests.swift b/Tests/OmniWMTests/SettingsTOMLCodecTests.swift index 6ce029fc..06390d6c 100644 --- a/Tests/OmniWMTests/SettingsTOMLCodecTests.swift +++ b/Tests/OmniWMTests/SettingsTOMLCodecTests.swift @@ -7,6 +7,25 @@ import Foundation import XCTest final class SettingsTOMLCodecTests: XCTestCase { + func testMonitorInnerGapOverrideRoundTrips() throws { + var export = SettingsExport.defaults() + export.monitorGapSettings = [ + MonitorGapSettings( + monitorName: "Built-in", + monitorDisplayId: 7, + innerGap: 6, + outerGapTop: 20 + ) + ] + + let data = try SettingsTOMLCodec.encode(export) + let decoded = try SettingsTOMLCodec.decode(data) + + XCTAssertEqual(decoded.monitorGapSettings, export.monitorGapSettings) + let toml = String(decoding: data, as: UTF8.self) + XCTAssertTrue(toml.contains("innerGap = 6.0")) + } + func testLoadingReinjectsNewlyAddedDefaultActionsMissingFromFile() throws { var export = SettingsExport.defaults() let customTrigger = HotkeyTrigger.chord( diff --git a/docs/IPC-CLI.md b/docs/IPC-CLI.md index f132a5f6..97f5a9f3 100644 --- a/docs/IPC-CLI.md +++ b/docs/IPC-CLI.md @@ -423,7 +423,7 @@ Field tokens are part of the CLI contract. Returned JSON still uses the payload **Workspace fields:** `id`, `raw-name`, `display-name`, `number`, `layout`, `display`, `is-focused`, `is-visible`, `is-current`, `window-counts`, `focused-window-id` -**Display fields:** `id`, `name`, `is-main`, `is-current`, `frame`, `visible-frame`, `has-notch`, `orientation`, `active-workspace` +**Display fields:** `id`, `name`, `is-main`, `is-current`, `frame`, `visible-frame`, `has-notch`, `orientation`, `inner-gap`, `outer-gap-left`, `outer-gap-right`, `outer-gap-top`, `outer-gap-bottom`, `active-workspace` ### Query Reference