From 82c3e79434be843f391e598ee0020cb9e80da111 Mon Sep 17 00:00:00 2001 From: Luke Amdor Date: Wed, 19 Aug 2026 12:20:09 -0500 Subject: [PATCH] Add a setting for what happens when moving a window past the last workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving a window down past the last workspace on a monitor created the next numbered workspace, while moving up past the first workspace did nothing at all — the keystroke was silently dropped. Workspace switching, bound to the same keys without Shift, has always wrapped in both directions, so the two paths disagreed at both edges. Introduce a MovePastLastWorkspacePolicy setting, exposed as [workspace] movePastLastWorkspace in settings.toml. The default, create, preserves the existing workspace-creating behavior at the end of the list. The wrap case instead wraps to the first workspace, matching switching. Moving up past the first workspace now always wraps regardless of the setting: workspaces are numbered from 1, so there is no lower-numbered workspace to create, and the previous behavior left the keystroke with no effect. Workspace switching deliberately keeps its own always-wrap behavior and does not read the new setting. --- ...ing-for-what-happens-when-moving-a-wind.md | 6 +++ .../Core/Config/CanonicalTOMLConfig.swift | 39 ++++++++++++++++++- .../Nehir/Core/Config/SettingsExport.swift | 3 ++ Sources/Nehir/Core/Config/SettingsStore.swift | 10 +++++ .../WorkspaceNavigationHandler.swift | 37 ++++++++++++++++-- .../MovePastLastWorkspacePolicy.swift | 36 +++++++++++++++++ Sources/Nehir/UI/BehaviorSettingsTab.swift | 10 +++++ 7 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 .changeset/20260819120000-add-a-setting-for-what-happens-when-moving-a-wind.md create mode 100644 Sources/Nehir/Core/Workspace/MovePastLastWorkspacePolicy.swift diff --git a/.changeset/20260819120000-add-a-setting-for-what-happens-when-moving-a-wind.md b/.changeset/20260819120000-add-a-setting-for-what-happens-when-moving-a-wind.md new file mode 100644 index 00000000..a133cbf9 --- /dev/null +++ b/.changeset/20260819120000-add-a-setting-for-what-happens-when-moving-a-wind.md @@ -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. diff --git a/Sources/Nehir/Core/Config/CanonicalTOMLConfig.swift b/Sources/Nehir/Core/Config/CanonicalTOMLConfig.swift index 2cb4d41b..e28ede77 100644 --- a/Sources/Nehir/Core/Config/CanonicalTOMLConfig.swift +++ b/Sources/Nehir/Core/Config/CanonicalTOMLConfig.swift @@ -24,6 +24,7 @@ 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 @@ -31,7 +32,8 @@ struct CanonicalTOMLConfig: Codable, Equatable { 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, 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) + 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) diff --git a/Sources/Nehir/Core/Config/SettingsExport.swift b/Sources/Nehir/Core/Config/SettingsExport.swift index d9a630fd..8fd38bcf 100644 --- a/Sources/Nehir/Core/Config/SettingsExport.swift +++ b/Sources/Nehir/Core/Config/SettingsExport.swift @@ -38,6 +38,8 @@ struct SettingsExport: Equatable, Sendable { var niriColumnWidthPresets: [Double]? var niriDefaultColumnWidth: Double? + var movePastLastWorkspace: String + var workspaceConfigurations: [WorkspaceConfiguration] var bordersEnabled: Bool @@ -147,6 +149,7 @@ extension SettingsExport { niriLoneWindowMaxWidth: nil, niriColumnWidthPresets: BuiltInSettingsDefaults.niriColumnWidthPresets, niriDefaultColumnWidth: nil, + movePastLastWorkspace: MovePastLastWorkspacePolicy.create.rawValue, workspaceConfigurations: BuiltInSettingsDefaults.workspaceConfigurations, bordersEnabled: false, borderWidth: 5.0, diff --git a/Sources/Nehir/Core/Config/SettingsStore.swift b/Sources/Nehir/Core/Config/SettingsStore.swift index 10dc93ff..f21d3448 100644 --- a/Sources/Nehir/Core/Config/SettingsStore.swift +++ b/Sources/Nehir/Core/Config/SettingsStore.swift @@ -107,6 +107,12 @@ final class SettingsStore { didSet { scheduleSave() } } + var movePastLastWorkspace = MovePastLastWorkspacePolicy( + rawValue: SettingsStore.defaultExport.movePastLastWorkspace + ) ?? .create { + didSet { scheduleSave() } + } + var niriLoneWindowMaxWidth = SettingsStore.validatedLoneWindowMaxWidth( SettingsStore.defaultExport.niriLoneWindowMaxWidth ) { @@ -476,6 +482,7 @@ final class SettingsStore { niriLoneWindowMaxWidth: niriLoneWindowMaxWidth, niriColumnWidthPresets: niriColumnWidthPresets, niriDefaultColumnWidth: niriDefaultColumnWidth, + movePastLastWorkspace: movePastLastWorkspace.rawValue, workspaceConfigurations: workspaceConfigurations, bordersEnabled: bordersEnabled, borderWidth: borderWidth, @@ -563,6 +570,9 @@ final class SettingsStore { export.niriColumnWidthPresets ?? baseline.niriColumnWidthPresets ?? SettingsStore.defaultColumnWidthPresets ) niriDefaultColumnWidth = SettingsStore.validatedDefaultColumnWidth(export.niriDefaultColumnWidth) + movePastLastWorkspace = MovePastLastWorkspacePolicy( + rawValue: export.movePastLastWorkspace + ) ?? .create workspaceConfigurations = SettingsStore.normalizedWorkspaceConfigurations( export.workspaceConfigurations, diff --git a/Sources/Nehir/Core/Controller/WorkspaceNavigationHandler.swift b/Sources/Nehir/Core/Controller/WorkspaceNavigationHandler.swift index 7a743a49..f665ff6b 100644 --- a/Sources/Nehir/Core/Controller/WorkspaceNavigationHandler.swift +++ b/Sources/Nehir/Core/Controller/WorkspaceNavigationHandler.swift @@ -571,6 +571,8 @@ 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 { @@ -578,14 +580,41 @@ final class WorkspaceNavigationHandler { } 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 } diff --git a/Sources/Nehir/Core/Workspace/MovePastLastWorkspacePolicy.swift b/Sources/Nehir/Core/Workspace/MovePastLastWorkspacePolicy.swift new file mode 100644 index 00000000..455371ac --- /dev/null +++ b/Sources/Nehir/Core/Workspace/MovePastLastWorkspacePolicy.swift @@ -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" + } + } +} diff --git a/Sources/Nehir/UI/BehaviorSettingsTab.swift b/Sources/Nehir/UI/BehaviorSettingsTab.swift index a141d8f8..9599ddb7 100644 --- a/Sources/Nehir/UI/BehaviorSettingsTab.swift +++ b/Sources/Nehir/UI/BehaviorSettingsTab.swift @@ -49,6 +49,16 @@ struct BehaviorSettingsTab: View { "When moving a window to another workspace, switches your active workspace to follow it." ) + Picker("Move Past Last Workspace", selection: $settings.movePastLastWorkspace) { + ForEach(MovePastLastWorkspacePolicy.allCases) { policy in + Text(policy.displayName).tag(policy) + } + } + SettingsCaption( + "When moving a window down past the last workspace, either create a new workspace " + + "or wrap around to the first. Moving up past the first workspace always wraps." + ) + Picker("Reveal Style", selection: $settings.revealStyle) { ForEach(RevealStyle.allCases, id: \.self) { policy in Text(policy.displayName).tag(policy)