Repository navigation
Add a setting for what happens when moving a window past the last workspace #202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "nehir": minor | ||
|
|
||
| --- | ||
|
|
||
| Add a "Move Past Last Workspace" setting that controls what happens when you move a window down past the last workspace on a monitor. The default, "Create New Workspace", keeps the existing behavior of creating the next numbered workspace. Choosing "Wrap to First Workspace" instead moves the window to the first workspace, matching how workspace switching already wraps. Moving a window up past the first workspace now wraps to the last workspace instead of doing nothing. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,14 +24,16 @@ struct CanonicalTOMLConfig: Codable, Equatable { | |
| var mouseWarp: MouseWarp | ||
| var gaps: Gaps | ||
| var niri: Niri | ||
| var workspace: Workspace | ||
| var borders: Borders | ||
| var workspaceBar: WorkspaceBar | ||
| var gestures: Gestures | ||
| var statusBar: StatusBar | ||
| var appearance: Appearance | ||
|
|
||
| enum CodingKeys: String, CodingKey, CaseIterable { | ||
| case general, focus, mouseWarp, gaps, niri, borders, workspaceBar, gestures, statusBar, appearance | ||
| case general, focus, mouseWarp, gaps, niri, workspace, borders, workspaceBar, gestures, | ||
| statusBar, appearance | ||
| } | ||
|
|
||
| struct General: Codable, Equatable { | ||
|
|
@@ -121,6 +123,15 @@ struct CanonicalTOMLConfig: Codable, Equatable { | |
| } | ||
| } | ||
|
|
||
| struct Workspace: Codable, Equatable { | ||
| var movePastLastWorkspace: String | ||
| var unknownFields: [String: SettingsTOMLUnknownValue] = [:] | ||
|
|
||
| enum CodingKeys: String, CodingKey, CaseIterable { | ||
| case movePastLastWorkspace | ||
| } | ||
| } | ||
|
|
||
| struct Borders: Codable, Equatable { | ||
| var enabled: Bool | ||
| var width: Double | ||
|
|
@@ -305,6 +316,10 @@ extension CanonicalTOMLConfig { | |
| defaultColumnWidth: export.niriDefaultColumnWidth, | ||
| unknownFields: unknown["niri"] ?? [:] | ||
| ) | ||
| workspace = Workspace( | ||
| movePastLastWorkspace: export.movePastLastWorkspace, | ||
| unknownFields: unknown["workspace"] ?? [:] | ||
| ) | ||
| borders = Borders( | ||
| enabled: export.bordersEnabled, | ||
| width: export.borderWidth, | ||
|
|
@@ -405,6 +420,7 @@ extension CanonicalTOMLConfig { | |
| niriLoneWindowMaxWidth: niri.loneWindowMaxWidth, | ||
| niriColumnWidthPresets: niri.columnWidthPresets, | ||
| niriDefaultColumnWidth: niri.defaultColumnWidth, | ||
| movePastLastWorkspace: workspace.movePastLastWorkspace, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Preserve unknown Line 423 transfers the recognized policy value, but Add Proposed fix add("gaps.outer", gaps.outer.unknownFields)
add("niri", niri.unknownFields)
+ add("workspace", workspace.unknownFields)
add("borders", borders.unknownFields)🤖 Prompt for AI Agents |
||
| workspaceConfigurations: BuiltInSettingsDefaults.workspaceConfigurations, | ||
| bordersEnabled: borders.enabled, | ||
| borderWidth: borders.width, | ||
|
|
@@ -476,6 +492,7 @@ extension CanonicalTOMLConfig { | |
| mouseWarp = try container.decodeWithDefault(MouseWarp.self, forKey: .mouseWarp, default: d.mouseWarp) | ||
| gaps = try container.decodeWithDefault(Gaps.self, forKey: .gaps, default: d.gaps) | ||
| niri = try container.decodeWithDefault(Niri.self, forKey: .niri, default: d.niri) | ||
| workspace = try container.decodeWithDefault(Workspace.self, forKey: .workspace, default: d.workspace) | ||
| borders = try container.decodeWithDefault(Borders.self, forKey: .borders, default: d.borders) | ||
| workspaceBar = try container.decodeWithDefault( | ||
| WorkspaceBar.self, | ||
|
|
@@ -494,6 +511,7 @@ extension CanonicalTOMLConfig { | |
| try container.encode(mouseWarp, forKey: .mouseWarp) | ||
| try container.encode(gaps, forKey: .gaps) | ||
| try container.encode(niri, forKey: .niri) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Canonical encoding now always emits Prompt To Fix With AIThis is a comment left during a code review.
Path: Sources/Nehir/Core/Config/CanonicalTOMLConfig.swift
Line: 513
Comment:
**Canonical Fixture No Longer Matches**
Canonical encoding now always emits `[workspace]` and `movePastLastWorkspace`, while the existing golden fixture contains neither, causing `canonicalDefaultsMatchGoldenFixture` to record a test failure.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid — confirmed against the fixture.
I did not catch this because I cannot build the test target locally — Swift Testing ships with Xcode, which is not installed on this machine — and the CI run for this branch predates the golden-fixture comparison being reached. That is my gap, not the reviewer's. The fixture needs regenerating with the new section. I will do that together with whatever comes out of the creation-path question in the other thread, since that decides whether There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That sequencing makes sense — the fixture should be regenerated after the creation-path question is settled, since whether the One thing to keep in mind when you do regenerate: make sure you run the test suite ( Tip: You can customize Greptile's behavior for this repo with |
||
| try container.encode(workspace, forKey: .workspace) | ||
| try container.encode(borders, forKey: .borders) | ||
| try container.encode(workspaceBar, forKey: .workspaceBar) | ||
| try container.encode(gestures, forKey: .gestures) | ||
|
|
@@ -706,6 +724,25 @@ extension CanonicalTOMLConfig.Niri { | |
| } | ||
| } | ||
|
|
||
| extension CanonicalTOMLConfig.Workspace { | ||
| init(from decoder: Decoder) throws { | ||
| let container = try decoder.container(keyedBy: CodingKeys.self) | ||
| let d = CanonicalTOMLConfig.defaults().workspace | ||
| movePastLastWorkspace = try container.decodeWithDefault( | ||
| String.self, | ||
| forKey: .movePastLastWorkspace, | ||
| default: d.movePastLastWorkspace | ||
| ) | ||
| unknownFields = try SettingsTOMLUnknownValue.decodeUnknownFields(from: decoder, excluding: CodingKeys.self) | ||
| } | ||
|
|
||
| func encode(to encoder: Encoder) throws { | ||
| var container = encoder.container(keyedBy: SettingsTOMLDynamicKey.self) | ||
| try container.encode(movePastLastWorkspace, forKey: "movePastLastWorkspace") | ||
| try container.encodeUnknownFields(unknownFields) | ||
| } | ||
| } | ||
|
|
||
| extension CanonicalTOMLConfig.Borders { | ||
| init(from decoder: Decoder) throws { | ||
| let container = try decoder.container(keyedBy: CodingKeys.self) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -571,21 +571,50 @@ final class WorkspaceNavigationHandler { | |
| guard let controller else { return nil } | ||
| let wm = controller.workspaceManager | ||
|
|
||
| // Interior moves resolve without wrapping, so the edge cases below are | ||
| // reached only when there is no further workspace in `direction`. | ||
| let existing: WorkspaceDescriptor? = if direction == .down { | ||
| wm.nextWorkspaceInOrder(on: monitorId, from: workspaceId, wrapAround: false) | ||
| } else { | ||
| wm.previousWorkspaceInOrder(on: monitorId, from: workspaceId, wrapAround: false) | ||
| } | ||
| if let existing { return existing } | ||
|
|
||
| // Past the end of the list, `movePastLastWorkspace` decides between | ||
| // creating the next numbered workspace and wrapping to the first. | ||
| // Moving up past the first workspace always wraps: workspaces are | ||
| // numbered from 1, so there is no lower-numbered workspace to create. | ||
| if direction == .down, | ||
| controller.settings.movePastLastWorkspace == .create, | ||
| let created = createNextNumberedWorkspace(after: workspaceId, on: monitorId) | ||
| { | ||
| return created | ||
| } | ||
|
|
||
| return if direction == .down { | ||
| wm.nextWorkspaceInOrder(on: monitorId, from: workspaceId, wrapAround: true) | ||
| } else { | ||
| wm.previousWorkspaceInOrder(on: monitorId, from: workspaceId, wrapAround: true) | ||
| } | ||
| } | ||
|
|
||
| /// Creates the workspace numbered one higher than `workspaceId` and assigns | ||
| /// it to `monitorId`. Returns `nil` when the current workspace is not | ||
| /// numerically named, or when the successor name is already taken (in which | ||
| /// case the caller wraps instead of silently reusing another monitor's | ||
| /// workspace). | ||
| private func createNextNumberedWorkspace( | ||
| after workspaceId: WorkspaceDescriptor.ID, | ||
| on monitorId: Monitor.ID | ||
| ) -> WorkspaceDescriptor? { | ||
| guard let controller else { return nil } | ||
| let wm = controller.workspaceManager | ||
|
|
||
| guard let currentName = wm.descriptor(for: workspaceId)?.name, | ||
| let currentNumber = Int(currentName) | ||
| else { return nil } | ||
|
|
||
| let candidateNumber = direction == .down ? currentNumber + 1 : currentNumber - 1 | ||
| guard candidateNumber > 0 else { return nil } | ||
|
|
||
| let candidateName = String(candidateNumber) | ||
| let candidateName = String(currentNumber + 1) | ||
| guard wm.workspaceId(named: candidateName) == nil else { return nil } | ||
|
|
||
| guard let targetId = wm.workspaceId(for: candidateName, createIfMissing: false) else { return nil } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a window or column moves down from the last numbered workspace under the default Prompt To Fix With AIThis is a comment left during a code review.
Path: Sources/Nehir/Core/Controller/WorkspaceNavigationHandler.swift
Line: 620
Comment:
**Creation Lookup Disables Creation**
When a window or column moves down from the last numbered workspace under the default `create` policy, the helper first confirms the successor is absent and then requests it with `createIfMissing: false`, so creation always returns `nil` and the move wraps to the first workspace instead.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid, and it matters more than it first looks — thank you. Confirmed: This is pre-existing on That changes the shape of this PR. Two options:
Note the I'd like the maintainers' preference before changing it, since option 2 removes a documented-but-nonfunctional behaviour and option 1 revives one that has never shipped working. Holding the PR as-is until then, and I'll correct the description either way. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Your root-cause analysis is correct and the pre-existing nature of the bug is an important nuance — thank you for tracing it back to From a purely technical standpoint, the tradeoffs are: Option 1 ( Option 2 (drop My recommendation leans toward Option 2 for this PR with a follow-up issue tracking proper workspace creation, because:
That said, this is a product/maintainer call. The PR is correctly held, and the description correction you mentioned is needed regardless of which option is chosen. |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| // SPDX-FileCopyrightText: 2026 BarutSRB | ||
| // SPDX-FileCopyrightText: 2026 Aleksei Gurianov and Nehir contributors | ||
| // SPDX-FileComment: Provenance=upstream-derived; Upstream-Project=OmniWM; Upstream-Author=BarutSRB; Nehir-Changes-Since=2026; See=NOTICE.md | ||
| // | ||
| // SPDX-License-Identifier: GPL-2.0-only | ||
|
|
||
| import Foundation | ||
|
|
||
| /// What "move the focused window one workspace further" does when there is no | ||
| /// further workspace in that direction on the monitor. | ||
| /// | ||
| /// This governs only the end-of-list edge reached by moving *down* (toward | ||
| /// higher-numbered workspaces). Moving *up* past the first workspace always | ||
| /// wraps: workspaces are numbered from 1, so there is no lower-numbered | ||
| /// workspace to create, and refusing to move would leave the keystroke with no | ||
| /// effect at all. | ||
| /// | ||
| /// Workspace *switching* is deliberately not governed by this policy — it has | ||
| /// always wrapped in both directions and never created workspaces. | ||
| enum MovePastLastWorkspacePolicy: String, CaseIterable, Codable, Identifiable, Sendable { | ||
| /// Create the next numbered workspace and move the window into it. | ||
| case create | ||
| /// Wrap around to the first workspace on the monitor. | ||
| case wrap | ||
|
|
||
| var id: String { | ||
| rawValue | ||
| } | ||
|
|
||
| var displayName: String { | ||
| switch self { | ||
| case .create: "Create New Workspace" | ||
| case .wrap: "Wrap to First Workspace" | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add the Nehir issue reference.
Add the associated Nehir ticket in the form
Fixes#nnn``. Do not add an upstream ticket reference here. Do not invent the issue number.As per coding guidelines, user-visible changesets must reference only the Nehir repository ticket; upstream references must use the full
BarutSRB/OmniWM#nnnform when needed.🤖 Prompt for AI Agents
Source: Coding guidelines