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)