Skip to content

Commit

Permalink
Implement named workspaces
Browse files Browse the repository at this point in the history
This is an implementation of named, pre-declared workspaces. With this
implementation, workspaces can be declared in the configuration file by
name:

```
workspace "name" {
  open-on-output "winit"
}
```

The `open-on-output` property is optional, and can be skipped, in which
case the workspace will open on the primary output.

All actions that were able to target a workspace by index can now target
them by either an index, or a name. In case of the command line, where
we do not have types available, this means that workspace names that
also pass as `u8` cannot be switched to by name, only by index.

Unlike dynamic workspaces, named workspaces do not close when they are
empty, they remain static. Like dynamic workspaces, named workspaces are
bound to a particular output. Switching to a named workspace, or moving
a window or column to one will also switch to, or move the thing in
question to the output of the workspace.

When reloading the configuration, newly added named workspaces will be
created, and removed ones will lose their name. If any such orphaned
workspace was empty, they will be removed. If they weren't, they'll
remain as a dynamic workspace, without a name. Re-declaring a workspace
with the same name later will create a new one.

Additionally, this also implements a `open-on-workspace "<name>"` window
rule. Matching windows will open on the given workspace (or the current
one, if the named workspace does not exist).

Signed-off-by: Gergely Nagy <[email protected]>
  • Loading branch information
algernon authored and YaLTeR committed May 16, 2024
1 parent 229ca90 commit eb9bbe3
Show file tree
Hide file tree
Showing 11 changed files with 784 additions and 99 deletions.
170 changes: 162 additions & 8 deletions niri-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use bitflags::bitflags;
use knuffel::errors::DecodeError;
use knuffel::Decode as _;
use miette::{miette, Context, IntoDiagnostic, NarratableReportHandler};
use niri_ipc::{ConfiguredMode, LayoutSwitchTarget, SizeChange, Transform};
use niri_ipc::{ConfiguredMode, LayoutSwitchTarget, SizeChange, Transform, WorkspaceReferenceArg};
use regex::Regex;
use smithay::input::keyboard::keysyms::KEY_NoSymbol;
use smithay::input::keyboard::xkb::{keysym_from_name, KEYSYM_CASE_INSENSITIVE};
Expand Down Expand Up @@ -52,6 +52,8 @@ pub struct Config {
pub binds: Binds,
#[knuffel(child, default)]
pub debug: DebugConfig,
#[knuffel(children(name = "workspace"))]
pub workspaces: Vec<Workspace>,
}

// FIXME: Add other devices.
Expand Down Expand Up @@ -693,6 +695,17 @@ pub struct EnvironmentVariable {
pub value: Option<String>,
}

#[derive(knuffel::Decode, Debug, Clone, PartialEq, Eq)]
pub struct Workspace {
#[knuffel(argument)]
pub name: WorkspaceName,
#[knuffel(child, unwrap(argument))]
pub open_on_output: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceName(pub String);

#[derive(knuffel::Decode, Debug, Default, Clone, PartialEq)]
pub struct WindowRule {
#[knuffel(children(name = "match"))]
Expand All @@ -706,6 +719,8 @@ pub struct WindowRule {
#[knuffel(child, unwrap(argument))]
pub open_on_output: Option<String>,
#[knuffel(child, unwrap(argument))]
pub open_on_workspace: Option<String>,
#[knuffel(child, unwrap(argument))]
pub open_maximized: Option<bool>,
#[knuffel(child, unwrap(argument))]
pub open_fullscreen: Option<bool>,
Expand Down Expand Up @@ -890,14 +905,14 @@ pub enum Action {
CenterColumn,
FocusWorkspaceDown,
FocusWorkspaceUp,
FocusWorkspace(#[knuffel(argument)] u8),
FocusWorkspace(#[knuffel(argument)] WorkspaceReference),
FocusWorkspacePrevious,
MoveWindowToWorkspaceDown,
MoveWindowToWorkspaceUp,
MoveWindowToWorkspace(#[knuffel(argument)] u8),
MoveWindowToWorkspace(#[knuffel(argument)] WorkspaceReference),
MoveColumnToWorkspaceDown,
MoveColumnToWorkspaceUp,
MoveColumnToWorkspace(#[knuffel(argument)] u8),
MoveColumnToWorkspace(#[knuffel(argument)] WorkspaceReference),
MoveWorkspaceDown,
MoveWorkspaceUp,
FocusMonitorLeft,
Expand Down Expand Up @@ -962,14 +977,20 @@ impl From<niri_ipc::Action> for Action {
niri_ipc::Action::CenterColumn => Self::CenterColumn,
niri_ipc::Action::FocusWorkspaceDown => Self::FocusWorkspaceDown,
niri_ipc::Action::FocusWorkspaceUp => Self::FocusWorkspaceUp,
niri_ipc::Action::FocusWorkspace { index } => Self::FocusWorkspace(index),
niri_ipc::Action::FocusWorkspace { reference } => {
Self::FocusWorkspace(WorkspaceReference::from(reference))
}
niri_ipc::Action::FocusWorkspacePrevious => Self::FocusWorkspacePrevious,
niri_ipc::Action::MoveWindowToWorkspaceDown => Self::MoveWindowToWorkspaceDown,
niri_ipc::Action::MoveWindowToWorkspaceUp => Self::MoveWindowToWorkspaceUp,
niri_ipc::Action::MoveWindowToWorkspace { index } => Self::MoveWindowToWorkspace(index),
niri_ipc::Action::MoveWindowToWorkspace { reference } => {
Self::MoveWindowToWorkspace(WorkspaceReference::from(reference))
}
niri_ipc::Action::MoveColumnToWorkspaceDown => Self::MoveColumnToWorkspaceDown,
niri_ipc::Action::MoveColumnToWorkspaceUp => Self::MoveColumnToWorkspaceUp,
niri_ipc::Action::MoveColumnToWorkspace { index } => Self::MoveColumnToWorkspace(index),
niri_ipc::Action::MoveColumnToWorkspace { reference } => {
Self::MoveColumnToWorkspace(WorkspaceReference::from(reference))
}
niri_ipc::Action::MoveWorkspaceDown => Self::MoveWorkspaceDown,
niri_ipc::Action::MoveWorkspaceUp => Self::MoveWorkspaceUp,
niri_ipc::Action::FocusMonitorLeft => Self::FocusMonitorLeft,
Expand Down Expand Up @@ -1002,6 +1023,59 @@ impl From<niri_ipc::Action> for Action {
}
}

#[derive(Debug, PartialEq, Eq, Clone)]
pub enum WorkspaceReference {
Index(u8),
Name(String),
}

impl From<WorkspaceReferenceArg> for WorkspaceReference {
fn from(reference: WorkspaceReferenceArg) -> WorkspaceReference {
match reference {
WorkspaceReferenceArg::Index(i) => Self::Index(i),
WorkspaceReferenceArg::Name(n) => Self::Name(n),
}
}
}

impl<S: knuffel::traits::ErrorSpan> knuffel::DecodeScalar<S> for WorkspaceReference {
fn type_check(
type_name: &Option<knuffel::span::Spanned<knuffel::ast::TypeName, S>>,
ctx: &mut knuffel::decode::Context<S>,
) {
if let Some(type_name) = &type_name {
ctx.emit_error(DecodeError::unexpected(
type_name,
"type name",
"no type name expected for this node",
));
}
}

fn raw_decode(
val: &knuffel::span::Spanned<knuffel::ast::Literal, S>,
ctx: &mut knuffel::decode::Context<S>,
) -> Result<WorkspaceReference, DecodeError<S>> {
match &**val {
knuffel::ast::Literal::String(ref s) => Ok(WorkspaceReference::Name(s.clone().into())),
knuffel::ast::Literal::Int(ref value) => match value.try_into() {
Ok(v) => Ok(WorkspaceReference::Index(v)),
Err(e) => {
ctx.emit_error(DecodeError::conversion(val, e));
Ok(WorkspaceReference::Index(0))
}
},
_ => {
ctx.emit_error(DecodeError::unsupported(
val,
"Unsupported value, only numbers and strings are recognized",
));
Ok(WorkspaceReference::Index(0))
}
}
}
}

#[derive(knuffel::Decode, Debug, Default, PartialEq)]
pub struct DebugConfig {
#[knuffel(child, unwrap(argument))]
Expand Down Expand Up @@ -1409,6 +1483,54 @@ where
}
}

impl<S: knuffel::traits::ErrorSpan> knuffel::DecodeScalar<S> for WorkspaceName {
fn type_check(
type_name: &Option<knuffel::span::Spanned<knuffel::ast::TypeName, S>>,
ctx: &mut knuffel::decode::Context<S>,
) {
if let Some(type_name) = &type_name {
ctx.emit_error(DecodeError::unexpected(
type_name,
"type name",
"no type name expected for this node",
));
}
}

fn raw_decode(
val: &knuffel::span::Spanned<knuffel::ast::Literal, S>,
ctx: &mut knuffel::decode::Context<S>,
) -> Result<WorkspaceName, DecodeError<S>> {
#[derive(Debug)]
struct WorkspaceNameSet(HashSet<String>);
match &**val {
knuffel::ast::Literal::String(ref s) => {
let mut name_set: HashSet<String> = match ctx.get::<WorkspaceNameSet>() {
Some(h) => h.0.clone(),
None => HashSet::new(),
};
if !name_set.insert(s.clone().to_string()) {
ctx.emit_error(DecodeError::unexpected(
val,
"named workspace",
format!("duplicate named workspace: {}", s),
));
return Ok(Self(String::new()));
}
ctx.set(WorkspaceNameSet(name_set));
Ok(Self(s.clone().into()))
}
_ => {
ctx.emit_error(DecodeError::unsupported(
val,
"workspace names must be strings",
));
Ok(Self(String::new()))
}
}
}
}

impl<S> knuffel::Decode<S> for WindowOpenAnim
where
S: knuffel::traits::ErrorSpan,
Expand Down Expand Up @@ -2278,13 +2400,20 @@ mod tests {
Mod+Ctrl+Shift+L { move-window-to-monitor-right; }
Mod+Comma { consume-window-into-column; }
Mod+1 { focus-workspace 1; }
Mod+Shift+1 { focus-workspace "workspace-1"; }
Mod+Shift+E { quit skip-confirmation=true; }
Mod+WheelScrollDown cooldown-ms=150 { focus-workspace-down; }
}
debug {
render-drm-device "/dev/dri/renderD129"
}
workspace "workspace-1" {
open-on-output "eDP-1"
}
workspace "workspace-2"
workspace "workspace-3"
"##,
Config {
input: Input {
Expand Down Expand Up @@ -2489,6 +2618,20 @@ mod tests {
},
..Default::default()
}],
workspaces: vec![
Workspace {
name: WorkspaceName("workspace-1".to_string()),
open_on_output: Some("eDP-1".to_string()),
},
Workspace {
name: WorkspaceName("workspace-2".to_string()),
open_on_output: None,
},
Workspace {
name: WorkspaceName("workspace-3".to_string()),
open_on_output: None,
},
],
binds: Binds(vec![
Bind {
key: Key {
Expand Down Expand Up @@ -2540,7 +2683,18 @@ mod tests {
trigger: Trigger::Keysym(Keysym::_1),
modifiers: Modifiers::COMPOSITOR,
},
action: Action::FocusWorkspace(1),
action: Action::FocusWorkspace(WorkspaceReference::Index(1)),
cooldown: None,
allow_when_locked: false,
},
Bind {
key: Key {
trigger: Trigger::Keysym(Keysym::_1),
modifiers: Modifiers::COMPOSITOR | Modifiers::SHIFT,
},
action: Action::FocusWorkspace(WorkspaceReference::Name(
"workspace-1".to_string(),
)),
cooldown: None,
allow_when_locked: false,
},
Expand Down
45 changes: 36 additions & 9 deletions niri-ipc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,33 +146,33 @@ pub enum Action {
FocusWorkspaceDown,
/// Focus the workspace above.
FocusWorkspaceUp,
/// Focus a workspace by index.
/// Focus a workspace by reference (index or name).
FocusWorkspace {
/// Index of the workspace to focus.
/// Reference (index or name) of the workspace to focus.
#[cfg_attr(feature = "clap", arg())]
index: u8,
reference: WorkspaceReferenceArg,
},
/// Focus the previous workspace.
FocusWorkspacePrevious,
/// Move the focused window to the workspace below.
MoveWindowToWorkspaceDown,
/// Move the focused window to the workspace above.
MoveWindowToWorkspaceUp,
/// Move the focused window to a workspace by index.
/// Move the focused window to a workspace by reference (index or name).
MoveWindowToWorkspace {
/// Index of the target workspace.
/// Reference (index or name) of the workspace to move the window to.
#[cfg_attr(feature = "clap", arg())]
index: u8,
reference: WorkspaceReferenceArg,
},
/// Move the focused column to the workspace below.
MoveColumnToWorkspaceDown,
/// Move the focused column to the workspace above.
MoveColumnToWorkspaceUp,
/// Move the focused column to a workspace by index.
/// Move the focused column to a workspace by reference (index or name).
MoveColumnToWorkspace {
/// Index of the target workspace.
/// Reference (index or name) of the workspace to move the column to.
#[cfg_attr(feature = "clap", arg())]
index: u8,
reference: WorkspaceReferenceArg,
},
/// Move the focused workspace down.
MoveWorkspaceDown,
Expand Down Expand Up @@ -257,6 +257,15 @@ pub enum SizeChange {
AdjustProportion(f64),
}

/// Workspace reference (index or name) to operate on.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub enum WorkspaceReferenceArg {
/// Index of the workspace.
Index(u8),
/// Name of the workspace.
Name(String),
}

/// Layout to switch to.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayoutSwitchTarget {
Expand Down Expand Up @@ -475,6 +484,24 @@ pub enum OutputConfigChanged {
OutputWasMissing,
}

impl FromStr for WorkspaceReferenceArg {
type Err = &'static str;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let reference = if let Ok(index) = s.parse::<i32>() {
if let Ok(idx) = u8::try_from(index) {
Self::Index(idx)
} else {
return Err("workspace indexes must be between 0 and 255");
}
} else {
Self::Name(s.to_string())
};

Ok(reference)
}
}

impl FromStr for SizeChange {
type Err = &'static str;

Expand Down
18 changes: 15 additions & 3 deletions src/handlers/compositor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,22 +119,27 @@ impl CompositorHandler for State {

let toplevel = window.toplevel().expect("no X11 support");

let (rules, width, is_full_width, output) =
let (rules, width, is_full_width, output, workspace_name) =
if let InitialConfigureState::Configured {
rules,
width,
is_full_width,
output,
workspace_name,
} = state
{
// Check that the output is still connected.
let output =
output.filter(|o| self.niri.layout.monitor_for_output(o).is_some());

(rules, width, is_full_width, output)
// Chech that the workspace still exists.
let workspace_name = workspace_name
.filter(|n| self.niri.layout.find_workspace_by_name(n).is_some());

(rules, width, is_full_width, output, workspace_name)
} else {
error!("window map must happen after initial configure");
(ResolvedWindowRules::empty(), None, false, None)
(ResolvedWindowRules::empty(), None, false, None, None)
};

let parent = toplevel
Expand All @@ -160,6 +165,13 @@ impl CompositorHandler for State {
self.niri
.layout
.add_window_right_of(&p, mapped, width, is_full_width)
} else if let Some(workspace_name) = &workspace_name {
self.niri.layout.add_window_to_named_workspace(
workspace_name,
mapped,
width,
is_full_width,
)
} else if let Some(output) = &output {
self.niri
.layout
Expand Down
Loading

0 comments on commit eb9bbe3

Please sign in to comment.