Skip to content

feat: add per-display inner gap overrides#477

Open
1Pio wants to merge 2 commits into
BarutSRB:mainfrom
1Pio:feat/per-display-inner-gaps
Open

feat: add per-display inner gap overrides#477
1Pio wants to merge 2 commits into
BarutSRB:mainfrom
1Pio:feat/per-display-inner-gaps

Conversation

@1Pio

@1Pio 1Pio commented Jul 13, 2026

Copy link
Copy Markdown

Related Ideas discussion: #476

This draft makes the tested implementation available while the interaction and IPC scope are discussed. I am happy to adjust the shape of the change before marking it ready for review.

Problem

General inner gaps currently apply to every display, while outer margins can already be overridden per display. Mixed-display setups therefore cannot tune the full General spacing model coherently. A value that works well on a large external display may feel excessive on a built-in panel.

What changed

  • Extend the existing monitorGapOverrides record with an optional innerGap value.
  • Use one General Configuration Scope selector for inner gaps and outer margins.
  • Resolve Niri gaps from the display assigned to each workspace across layout, navigation, mouse, and command paths.
  • Resolve Dwindle's General gap from the workspace display while preserving Dwindle-specific gap precedence.
  • Treat a missing value as inheritance, preserve 0 as an explicit override, and remove records after their final override is reset.
  • Add the optional resolved inner-gap field to display IPC queries and render requested gap fields in non-JSON CLI output.
  • Emit one display-changed event when global or monitor-specific gap settings change.
  • Document the IPC field and cover persistence, routing, precedence, cleanup, and projection with focused tests.

Defaults and compatibility

Existing configurations need no migration. The new field is optional and absent values continue to inherit the current global inner gap. New installations keep the existing global default, and selecting a display does not create an override until a value changes.

The existing runtime 0...64 gap bounds remain unchanged. The General UI continues to expose its existing 0...32 range.

Verification

Automated checks:

  • git diff --check
  • SwiftFormat 0.61.1: 0/462 files require formatting
  • SwiftLint 0.65.0: existing warning baseline, no serious violations
  • Focused GapSettingsTests: 16 passed
  • Full Swift test suite: 1,123 passed
  • make check: passed with the required SwiftFormat and SwiftLint versions
  • App packaging and ad-hoc code-sign verification: passed; app and CLI are arm64

Two-display acceptance against the packaged candidate:

  • Niri: 32 px on the external display and 8 px on the built-in display
  • Dwindle with Use Global Gaps enabled: 32 px on the external display and 8 px on the built-in display
  • Global and display-specific values were visible and resettable through the shared General scope selector
  • query displays --fields inner-gap --format table returned the resolved values
  • A hot-reloaded gap change emitted one display-changed event

Toolchain note: the locally installed stable compiler is Swift 6.3.2. Build, test, and packaging used temporary compatibility-only edits to Package.swift and one unchanged upstream FoundationModels call. Those edits are not part of this diff, and all feature files in the tested candidate match this PR. The available Swift 6.4 development snapshot crashes while compiling unchanged upstream code, so I could not repeat the build with that snapshot.

Screenshots

Global defaults

General settings using global gap defaults

Built-in display override

General settings using a built-in display inner-gap override

AI disclosure

I used Codex while investigating and implementing this change. I reviewed the resulting code and verified it manually.

Summary by CodeRabbit

  • New Features
    • Added per-monitor inner-gap overrides in Settings, including a monitor-specific control with reset to clear overrides.
    • Added inner-gap to display query snapshots and CLI output, with updated supported display fields.
  • Bug Fixes
    • Improved consistency of gap spacing across navigation, resizing, and window transfer (including Niri/dwindle) using monitor-specific inner-gap resolution and proper fallback behavior.
    • Empty/cleared monitor gap overrides are no longer exported.
  • Documentation
    • Updated IPC/CLI “Query Fields” documentation to include inner-gap.

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 330f0d86-d903-46a4-a7b8-102cdc0aa70e

📥 Commits

Reviewing files that changed from the base of the PR and between c35974f and 18e6925.

📒 Files selected for processing (1)
  • Sources/OmniWMCtl/CLIRenderer.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Sources/OmniWMCtl/CLIRenderer.swift

📝 Walkthrough

Walkthrough

Monitor gap settings now support optional per-monitor inner-gap overrides. Resolved values flow through layout operations, settings UI, IPC display queries, CLI output, serialization, and tests.

Changes

Monitor gap configuration and resolution

Layer / File(s) Summary
Gap configuration and resolution
Sources/OmniWM/Core/Config/*, Tests/OmniWMTests/GapSettingsTests.swift, Tests/OmniWMTests/SettingsTOMLCodecTests.swift
MonitorGapSettings stores and serializes innerGap; empty overrides are removed; resolved inner gaps are clamped to 0–64 and used by dwindle resolution.
Controller gap publication and lookup
Sources/OmniWM/Core/Controller/WMController.swift, Sources/OmniWM/Core/Controller/DwindleLayoutHandler.swift
WMController provides monitor/workspace inner-gap lookup, controls display-change publication, and applies resolved dwindle settings.
Monitor-specific layout interaction routing
Sources/OmniWM/Core/Controller/*, Tests/OmniWMTests/GapSettingsTests.swift
Niri, dwindle, mouse, keyboard, window, and workspace operations use monitor-specific inner gaps, with layout geometry tests covering both engines.

Per-monitor settings and display projection

Layer / File(s) Summary
Per-monitor settings UI
Sources/OmniWM/UI/SettingsView.swift
The layout settings UI selects a monitor, edits its inner-gap override, and removes the override through monitor scope controls.
Display query and CLI projection
Sources/OmniWM/IPC/IPCQueryRouter.swift, Sources/OmniWMIPC/*, Sources/OmniWMCtl/CLIRenderer.swift, docs/IPC-CLI.md, Tests/OmniWMTests/GapSettingsTests.swift
The inner-gap display field is added to IPC models, field catalogs, query routing, CLI tables, documentation, and projection tests.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SettingsView
  participant SettingsStore
  participant WMController
  participant LayoutHandler
  participant IPCQueryRouter
  SettingsView->>SettingsStore: update monitor inner-gap override
  SettingsStore->>WMController: resolve and publish gap settings
  WMController->>LayoutHandler: provide monitor-specific inner gap
  IPCQueryRouter->>SettingsStore: request resolved display gap
  SettingsStore-->>IPCQueryRouter: return innerGap
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.70% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding per-display inner gap overrides.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@BarutSRB

Copy link
Copy Markdown
Owner

Thank you will take a look at this tomorrow and if all good will merge it ♥️

@1Pio 1Pio marked this pull request as ready for review July 14, 2026 18:59

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
Sources/OmniWM/UI/SettingsView.swift (1)

47-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Refresh connectedMonitors when displays change. It’s only seeded from Monitor.current() and reloaded in .onAppear, so hot-plugged displays won’t show up in the picker until Settings is reopened. Subscribe this tab to NSApplication.didChangeScreenParametersNotification or the controller’s display-change event and repopulate the list live.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/OmniWM/UI/SettingsView.swift` around lines 47 - 48, Update
SettingsView’s connectedMonitors state and lifecycle handling so it refreshes
when display parameters change, not only during initialization or .onAppear.
Subscribe to NSApplication.didChangeScreenParametersNotification or the existing
controller display-change event, repopulate connectedMonitors from
Monitor.current(), and preserve the current picker behavior.
Sources/OmniWM/Core/Controller/WMController.swift (2)

490-508: 🚀 Performance & Scalability | 🔵 Trivial

Confirm publishDisplayChanged() won't fire excessively; consider coalescing.

setGapSize/setOuterGaps publish .displayChanged by default, and updateMonitorGapSettings() now unconditionally publishes on every call — including the unconditional call from applyPersistedSettings (Line 385), which runs whenever settings are (re)applied, not only when gap settings changed. If any UI call site invokes setGapSize/setOuterGaps per intermediate slider-drag value (not shown in this batch), each tick spawns a fire-and-forget Task publishing an IPC event, and every unrelated settings reload also emits a spurious displayChanged.

Consider gating publishDisplayChanged() behind an actual-change check (e.g., compare against previous resolved values) or debouncing the publish, so IPC subscribers aren't flooded by unrelated settings churn or continuous drags.

Suggested direction
     func updateMonitorGapSettings() {
         layoutRefreshController.requestRelayout(reason: .monitorSettingsChanged)
-        publishDisplayChanged()
+        // Only publish when this call is actually gap-related / triggered by an explicit user edit,
+        // not on every applyPersistedSettings() pass.
+        publishDisplayChanged()
     }

Also applies to: 722-732, 346-353

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/OmniWM/Core/Controller/WMController.swift` around lines 490 - 508,
Update setGapSize, setOuterGaps, and updateMonitorGapSettings to publish
displayChanged only when the resolved gap values actually change; preserve
applying settings without emitting events for unchanged values, and coalesce or
otherwise suppress redundant publications during repeated slider updates.

901-914: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Simplify innerGap(for:) to avoid a duplicate lookup and a second source of truth.

settings.resolvedGapSettings(for: monitor).innerGap already falls back to the global default when there's no override (see resolvedInnerGap(override?.innerGap)), so the "no override → workspaceManager.gaps" branch here is redundant and forces a second, separate lookup on the override path (gapSettings(for:) is called once here, then again inside resolvedGapSettings(for:)). This is called on hot interactive paths (focus navigation, mouse drag, scroll ticks), and — since it's brand-new code — no test exercises the no-override branch, so a latent divergence between workspaceManager.gaps and settings.gapSize's resolved value wouldn't be caught.

Suggested simplification
     func innerGap(for monitor: Monitor) -> CGFloat {
-        guard settings.gapSettings(for: monitor)?.innerGap != nil else {
-            return CGFloat(workspaceManager.gaps)
-        }
-        return settings.resolvedGapSettings(for: monitor).innerGap
+        settings.resolvedGapSettings(for: monitor).innerGap
     }

If workspaceManager.gaps is intentionally used here for a "live preview" value that differs from the persisted settings.gapSize during in-progress edits, please confirm — otherwise this simplification removes the divergence risk.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/OmniWM/Core/Controller/WMController.swift` around lines 901 - 914,
Simplify the monitor overload of innerGap(for:) to directly return
settings.resolvedGapSettings(for: monitor).innerGap, removing the preliminary
gapSettings(for:) lookup and workspaceManager.gaps fallback. Keep the
workspaceId overload unchanged so it continues resolving the monitor before
delegating to the monitor-based innerGap(for:).
Tests/OmniWMTests/GapSettingsTests.swift (1)

225-292: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Test-only coverage gap: no-override fallback path in WMController.innerGap(for:) is never exercised.

Both Left/Right monitors here get explicit innerGap overrides, so the "no override" fallback branch in WMController.innerGap(for:) (which returns workspaceManager.gaps instead of settings.resolvedGapSettings(for:).innerGap) is untested. Worth adding a case with one monitor left un-overridden to confirm the fallback still matches the globally resolved value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/OmniWMTests/GapSettingsTests.swift` around lines 225 - 292, Extend
assertLayoutRoutesInnerGapByWorkspaceDisplay to leave one monitor without a
MonitorGapSettings override while retaining the other monitor’s explicit
override, then assert that the un-overridden workspace’s frame separation
matches the global workspaceManager.gaps inner-gap value. Keep the existing
explicit-override assertion to cover both paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/OmniWMCtl/CLIRenderer.swift`:
- Around line 357-368: Update appendDisplayColumn to format whole-number Double
values without a trailing “.0”, while preserving decimal output for fractional
values and “-” for nil values. Reuse the file’s existing numeric formatting
approach used by frameDescription or sizeDescription rather than converting
every value directly with String.

---

Nitpick comments:
In `@Sources/OmniWM/Core/Controller/WMController.swift`:
- Around line 490-508: Update setGapSize, setOuterGaps, and
updateMonitorGapSettings to publish displayChanged only when the resolved gap
values actually change; preserve applying settings without emitting events for
unchanged values, and coalesce or otherwise suppress redundant publications
during repeated slider updates.
- Around line 901-914: Simplify the monitor overload of innerGap(for:) to
directly return settings.resolvedGapSettings(for: monitor).innerGap, removing
the preliminary gapSettings(for:) lookup and workspaceManager.gaps fallback.
Keep the workspaceId overload unchanged so it continues resolving the monitor
before delegating to the monitor-based innerGap(for:).

In `@Sources/OmniWM/UI/SettingsView.swift`:
- Around line 47-48: Update SettingsView’s connectedMonitors state and lifecycle
handling so it refreshes when display parameters change, not only during
initialization or .onAppear. Subscribe to
NSApplication.didChangeScreenParametersNotification or the existing controller
display-change event, repopulate connectedMonitors from Monitor.current(), and
preserve the current picker behavior.

In `@Tests/OmniWMTests/GapSettingsTests.swift`:
- Around line 225-292: Extend assertLayoutRoutesInnerGapByWorkspaceDisplay to
leave one monitor without a MonitorGapSettings override while retaining the
other monitor’s explicit override, then assert that the un-overridden
workspace’s frame separation matches the global workspaceManager.gaps inner-gap
value. Keep the existing explicit-override assertion to cover both paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 004b3a23-db52-4bfd-8775-3d06450e95af

📥 Commits

Reviewing files that changed from the base of the PR and between be68cfb and c35974f.

📒 Files selected for processing (18)
  • Sources/OmniWM/Core/Config/MonitorGapSettings.swift
  • Sources/OmniWM/Core/Config/MonitorSettingsType.swift
  • Sources/OmniWM/Core/Config/SettingsStore.swift
  • Sources/OmniWM/Core/Controller/CommandHandler.swift
  • Sources/OmniWM/Core/Controller/DwindleLayoutHandler.swift
  • Sources/OmniWM/Core/Controller/MouseEventHandler.swift
  • Sources/OmniWM/Core/Controller/NiriLayoutHandler.swift
  • Sources/OmniWM/Core/Controller/WMController.swift
  • Sources/OmniWM/Core/Controller/WindowActionHandler.swift
  • Sources/OmniWM/Core/Controller/WorkspaceNavigationHandler.swift
  • Sources/OmniWM/IPC/IPCQueryRouter.swift
  • Sources/OmniWM/UI/SettingsView.swift
  • Sources/OmniWMCtl/CLIRenderer.swift
  • Sources/OmniWMIPC/IPCAutomationManifest.swift
  • Sources/OmniWMIPC/IPCModels.swift
  • Tests/OmniWMTests/GapSettingsTests.swift
  • Tests/OmniWMTests/SettingsTOMLCodecTests.swift
  • docs/IPC-CLI.md

Comment thread Sources/OmniWMCtl/CLIRenderer.swift
@BarutSRB

Copy link
Copy Markdown
Owner

Sorry will look at this tomorrow as tonight I'll be pushing a new release that targets one specific problem that is a bit risky and might break stuff, just wanted to let you know that I'm not ignoring and that I appreciate the PR and help ♥️♥️♥️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants