Compare commits

...

18 Commits

Author SHA1 Message Date
Gergely Nagy
a4e044caae
Merge 4bfa58cdba into 3e385d5c48 2024-05-15 17:57:41 +02:00
Ivan Molodetskikh
3e385d5c48 Clear fd flags before sending selection 2024-05-15 16:49:46 +04:00
Gergely Nagy
4bfa58cdba
wip: output_by_name case insensitive find
Signed-off-by: Gergely Nagy <niri@gergo.csillger.hu>
2024-05-12 21:38:33 +02:00
Gergely Nagy
816533bd31
wip: workspacereferencearg i32 parse & u8 try_from
Signed-off-by: Gergely Nagy <niri@gergo.csillger.hu>
2024-05-12 21:31:48 +02:00
Gergely Nagy
574c449418
wip: code cleanup
Signed-off-by: Gergely Nagy <niri@gergo.csillger.hu>
2024-05-12 21:24:01 +02:00
Gergely Nagy
1af47499af
wip: unwrap
Signed-off-by: Gergely Nagy <niri@gergo.csillger.hu>
2024-05-12 21:14:45 +02:00
Gergely Nagy
0f8c4939d7
wip: unmapped: workspace_name docs
Signed-off-by: Gergely Nagy <niri@gergo.csillger.hu>
2024-05-12 21:10:00 +02:00
Gergely Nagy
2b8443b548
wip: unname_workspace: clean up after
Signed-off-by: Gergely Nagy <niri@gergo.csillger.hu>
2024-05-12 21:09:01 +02:00
Gergely Nagy
4fc02f22eb
wip: workspace.new_with_config() config should be owned
Signed-off-by: Gergely Nagy <niri@gergo.csillger.hu>
2024-05-12 21:04:25 +02:00
Gergely Nagy
aef479c37a
wip: code cleanup
Signed-off-by: Gergely Nagy <niri@gergo.csillger.hu>
2024-05-12 21:01:46 +02:00
Gergely Nagy
a870412e08
wip: w.name.as_deref()
Signed-off-by: Gergely Nagy <niri@gergo.csillger.hu>
2024-05-12 21:01:35 +02:00
Gergely Nagy
b78da7c060
wip: add_window_to_named_workspace
Signed-off-by: Gergely Nagy <niri@gergo.csillger.hu>
2024-05-12 20:58:22 +02:00
Gergely Nagy
afc6958add
wip: code cleanup
Signed-off-by: Gergely Nagy <niri@gergo.csillger.hu>
2024-05-12 20:57:03 +02:00
Gergely Nagy
5d98a58ec0
wip: WorkspaceReference cleanup
Signed-off-by: Gergely Nagy <niri@gergo.csillger.hu>
2024-05-12 20:02:27 +02:00
Gergely Nagy
5310794b0c
wip: WorkspaceReferenceArg Eq
Signed-off-by: Gergely Nagy <niri@gergo.csillger.hu>
2024-05-12 19:59:02 +02:00
Gergely Nagy
ca5292f21f
wip: cli doc updates (by index -> by reference)
Signed-off-by: Gergely Nagy <niri@gergo.csillger.hu>
2024-05-12 19:38:56 +02:00
Gergely Nagy
e666a0e724
wip: add a focus-workspace "workspace-1" test
Signed-off-by: Gergely Nagy <niri@gergo.csillger.hu>
2024-05-12 19:33:55 +02:00
Gergely Nagy
d8ad6f2d56
Initial implementation of named workspaces
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 <niri@gergo.csillger.hu>
2024-05-11 22:40:30 +02:00
12 changed files with 644 additions and 83 deletions

View File

@ -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};
@ -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.
@ -687,6 +689,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"))]
@ -700,6 +713,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>,
@ -884,14 +899,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,
@ -956,14 +971,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,
@ -996,6 +1017,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))]
@ -1403,6 +1477,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,
@ -2261,6 +2383,7 @@ 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; }
}
@ -2268,6 +2391,12 @@ mod tests {
debug {
render-drm-device "/dev/dri/renderD129"
}
workspace "workspace-1" {
open-on-output "eDP-1"
}
workspace "workspace-2"
workspace "workspace-3"
"##,
Config {
input: Input {
@ -2469,6 +2598,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 {
@ -2520,7 +2663,16 @@ 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,
},

View File

@ -146,11 +146,11 @@ 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,
@ -158,21 +158,21 @@ pub enum Action {
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,
@ -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 {
@ -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;

View File

@ -119,22 +119,23 @@ 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)
(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
@ -160,6 +161,16 @@ impl CompositorHandler for State {
self.niri
.layout
.add_window_right_of(&p, mapped, width, is_full_width)
} else if let (Some(workspace_name), Some(output)) = (&workspace_name, &output)
{
self.niri.layout.add_window_to_named_workspace(
output,
workspace_name,
mapped,
width,
is_full_width,
);
Some(output)
} else if let Some(output) = &output {
self.niri
.layout

View File

@ -15,6 +15,7 @@ use smithay::desktop::{PopupKind, PopupManager};
use smithay::input::pointer::{CursorIcon, CursorImageStatus, PointerHandle};
use smithay::input::{keyboard, Seat, SeatHandler, SeatState};
use smithay::output::Output;
use smithay::reexports::rustix::fs::{fcntl_setfl, OFlags};
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
use smithay::reexports::wayland_server::protocol::wl_data_source::WlDataSource;
use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
@ -188,6 +189,10 @@ impl SelectionHandler for State {
let buf = user_data.clone();
thread::spawn(move || {
// Clear O_NONBLOCK, otherwise io::copy() will stop halfway.
if let Err(err) = fcntl_setfl(&fd, OFlags::empty()) {
warn!("error clearing flags on selection target fd: {err:?}");
}
if let Err(err) = File::from(fd).write_all(&buf) {
warn!("error writing selection: {err:?}");
}

View File

@ -369,10 +369,17 @@ impl XdgShellHandler for State {
width,
is_full_width,
output,
workspace_name,
} => {
// Figure out the monitor following a similar logic to initial configure.
// FIXME: deduplicate.
let mon = output
let mon = workspace_name
.as_deref()
.and_then(|name| self.niri.layout.monitor_for_workspace(name))
.map(|mon| (mon, false));
let mon = mon.or_else(|| {
output
.as_ref()
.and_then(|o| self.niri.layout.monitor_for_output(o))
.map(|mon| (mon, false))
@ -380,7 +387,9 @@ impl XdgShellHandler for State {
.or_else(|| {
toplevel
.parent()
.and_then(|parent| self.niri.layout.find_window_and_output(&parent))
.and_then(|parent| {
self.niri.layout.find_window_and_output(&parent)
})
.map(|(_win, output)| output)
.and_then(|o| self.niri.layout.monitor_for_output(o))
.map(|mon| (mon, true))
@ -391,6 +400,7 @@ impl XdgShellHandler for State {
.layout
.active_monitor_ref()
.map(|mon| (mon, false))
})
});
*output = mon
@ -398,9 +408,13 @@ impl XdgShellHandler for State {
.map(|(mon, _)| mon.output.clone());
let mon = mon.map(|(mon, _)| mon);
let ws = mon
.map(|mon| mon.active_workspace_ref())
.or_else(|| self.niri.layout.active_workspace());
let ws = workspace_name
.as_deref()
.and_then(|name| mon.map(|mon| mon.workspace_ref(name)))
.unwrap_or_else(|| {
mon.map(|mon| mon.active_workspace_ref())
.or_else(|| self.niri.layout.active_workspace())
});
if let Some(ws) = ws {
toplevel.with_pending_state(|state| {
@ -577,12 +591,20 @@ impl State {
return;
};
// Pick the target monitor. First, check if we had an output set in the window rules.
// Pick the target monitor. First, check if we had a workspace set in the window rules.
let mon = rules
.open_on_workspace
.as_deref()
.and_then(|name| self.niri.layout.monitor_for_workspace(name));
// If not, check if we had an output set in the window rules.
let mon = mon.or_else(|| {
rules
.open_on_output
.as_deref()
.and_then(|name| self.niri.output_by_name.get(name))
.and_then(|o| self.niri.layout.monitor_for_output(o));
.and_then(|o| self.niri.layout.monitor_for_output(o))
});
// If not, check if the window requested one for fullscreen.
let mon = mon.or_else(|| {
@ -622,9 +644,14 @@ impl State {
let is_full_width = rules.open_maximized.unwrap_or(false);
// Tell the surface the preferred size and bounds for its likely output.
let ws = mon
.map(|mon| mon.active_workspace_ref())
.or_else(|| self.niri.layout.active_workspace());
let ws = rules
.open_on_workspace
.as_deref()
.and_then(|name| mon.map(|mon| mon.workspace_ref(name)))
.unwrap_or_else(|| {
mon.map(|mon| mon.active_workspace_ref())
.or_else(|| self.niri.layout.active_workspace())
});
if let Some(ws) = ws {
// Set a fullscreen state based on window request and window rule.
@ -663,6 +690,7 @@ impl State {
width,
is_full_width,
output,
workspace_name: ws.and_then(|w| w.name.clone()),
};
toplevel.send_configure();

View File

@ -587,13 +587,23 @@ impl State {
// FIXME: granular
self.niri.queue_redraw_all();
}
Action::MoveWindowToWorkspace(idx) => {
let idx = idx.saturating_sub(1) as usize;
self.niri.layout.move_to_workspace(idx);
Action::MoveWindowToWorkspace(reference) => {
if let Some((output, index)) = self.niri.find_output_and_workspace_index(reference)
{
if let Some(output) = output {
self.niri.layout.move_to_workspace_on_output(&output, index);
if !self.maybe_warp_cursor_to_focus_centered() {
self.move_cursor_to_output(&output);
}
} else {
self.niri.layout.move_to_workspace(index);
self.maybe_warp_cursor_to_focus();
}
// FIXME: granular
self.niri.queue_redraw_all();
}
}
Action::MoveColumnToWorkspaceDown => {
self.niri.layout.move_column_to_workspace_down();
self.maybe_warp_cursor_to_focus();
@ -606,13 +616,25 @@ impl State {
// FIXME: granular
self.niri.queue_redraw_all();
}
Action::MoveColumnToWorkspace(idx) => {
let idx = idx.saturating_sub(1) as usize;
self.niri.layout.move_column_to_workspace(idx);
Action::MoveColumnToWorkspace(reference) => {
if let Some((output, index)) = self.niri.find_output_and_workspace_index(reference)
{
if let Some(output) = output {
self.niri
.layout
.move_column_to_workspace_on_output(&output, index);
if !self.maybe_warp_cursor_to_focus_centered() {
self.move_cursor_to_output(&output);
}
} else {
self.niri.layout.move_column_to_workspace(index);
self.maybe_warp_cursor_to_focus();
}
// FIXME: granular
self.niri.queue_redraw_all();
}
}
Action::FocusWorkspaceDown => {
self.niri.layout.switch_workspace_down();
self.maybe_warp_cursor_to_focus();
@ -625,20 +647,29 @@ impl State {
// FIXME: granular
self.niri.queue_redraw_all();
}
Action::FocusWorkspace(idx) => {
let idx = idx.saturating_sub(1) as usize;
Action::FocusWorkspace(reference) => {
if let Some((output, index)) = self.niri.find_output_and_workspace_index(reference)
{
if let Some(output) = output {
self.niri.layout.focus_output(&output);
self.niri.layout.switch_workspace(index);
if !self.maybe_warp_cursor_to_focus_centered() {
self.move_cursor_to_output(&output);
}
} else {
let config = &self.niri.config;
if config.borrow().input.workspace_auto_back_and_forth {
self.niri.layout.switch_workspace_auto_back_and_forth(idx);
self.niri.layout.switch_workspace_auto_back_and_forth(index);
} else {
self.niri.layout.switch_workspace(idx);
self.niri.layout.switch_workspace(index);
}
self.maybe_warp_cursor_to_focus();
}
self.maybe_warp_cursor_to_focus();
// FIXME: granular
self.niri.queue_redraw_all();
}
}
Action::FocusWorkspacePrevious => {
self.niri.layout.switch_workspace_previous();
// FIXME: granular

View File

@ -34,7 +34,7 @@ use std::mem;
use std::rc::Rc;
use std::time::Duration;
use niri_config::{CenterFocusedColumn, Config, Struts};
use niri_config::{CenterFocusedColumn, Config, Struts, Workspace as WorkspaceConfig};
use niri_ipc::SizeChange;
use smithay::backend::renderer::element::solid::{SolidColorBuffer, SolidColorRenderElement};
use smithay::backend::renderer::element::surface::WaylandSurfaceRenderElement;
@ -278,7 +278,7 @@ impl Options {
impl<W: LayoutElement> Layout<W> {
pub fn new(config: &Config) -> Self {
Self::with_options(Options::from_config(config))
Self::with_options_and_workspaces(&config, Options::from_config(config))
}
pub fn with_options(options: Options) -> Self {
@ -288,6 +288,20 @@ impl<W: LayoutElement> Layout<W> {
}
}
fn with_options_and_workspaces(config: &Config, options: Options) -> Self {
let opts = Rc::new(options);
let workspaces = config.workspaces
.iter()
.map(|ws| Workspace::new_with_config_no_outputs(Some(ws.clone()), opts.clone()))
.collect();
Self {
monitor_set: MonitorSet::NoOutputs { workspaces },
options: opts,
}
}
pub fn add_output(&mut self, output: Output) {
let id = OutputId::new(&output);
@ -317,7 +331,7 @@ impl<W: LayoutElement> Layout<W> {
// The user could've closed a window while remaining on this workspace, on
// another monitor. However, we will add an empty workspace in the end
// instead.
if ws.has_windows() {
if ws.has_windows() || ws.name.is_some() {
workspaces.push(ws);
}
@ -462,6 +476,56 @@ impl<W: LayoutElement> Layout<W> {
}
}
/// Adds a new window to the layout on a specific output & workspace.
pub fn add_window_to_named_workspace(
&mut self,
output: &Output,
workspace_name: &str,
window: W,
width: Option<ColumnWidth>,
is_full_width: bool,
) {
let mut width = width.unwrap_or_else(|| ColumnWidth::Fixed(window.size().w));
if let ColumnWidth::Fixed(w) = &mut width {
let rules = window.rules();
let border_config = rules.border.resolve_against(self.options.border);
if !border_config.off {
*w += border_config.width as i32 * 2;
}
}
let MonitorSet::Normal {
monitors,
active_monitor_idx,
..
} = &mut self.monitor_set
else {
panic!()
};
let (mon_idx, mon) = monitors
.iter_mut()
.enumerate()
.find(|(_, mon)| mon.output == *output)
.unwrap();
let ws_idx = &mon
.find_workspace_index(workspace_name)
.unwrap();
// Don't steal focus from an active fullscreen window.
let mut activate = true;
let ws = &mon.workspaces[*ws_idx];
if mon_idx == *active_monitor_idx
&& !ws.columns.is_empty()
&& ws.columns[ws.active_column_idx].is_fullscreen
{
activate = false;
}
mon.add_window(*ws_idx, window, activate, width, is_full_width);
}
pub fn add_column_by_idx(
&mut self,
monitor_idx: usize,
@ -648,6 +712,7 @@ impl<W: LayoutElement> Layout<W> {
&& idx != mon.active_workspace_idx
&& idx != mon.workspaces.len() - 1
&& mon.workspace_switch.is_none()
&& mon.workspaces[idx].name.is_none()
{
mon.workspaces.remove(idx);
@ -667,7 +732,7 @@ impl<W: LayoutElement> Layout<W> {
rv = Some(ws.remove_window(window));
// Clean up empty workspaces.
if !ws.has_windows() {
if !ws.has_windows() && workspaces[idx].name.is_none() {
workspaces.remove(idx);
}
@ -717,6 +782,35 @@ impl<W: LayoutElement> Layout<W> {
None
}
pub fn find_workspace_by_name(&self, workspace_name: &str) -> Option<(usize, &Workspace<W>)> {
if let MonitorSet::Normal { ref monitors, .. } = &self.monitor_set {
for mon in monitors {
if let Some((index, workspace)) = mon
.workspaces
.iter()
.enumerate()
.find(|(_, w)| w.name.as_deref() == Some(workspace_name))
{
return Some((index, workspace));
}
}
}
None
}
pub fn unname_workspace(&mut self, workspace_name: &str) {
if let MonitorSet::Normal {
ref mut monitors, ..
} = &mut self.monitor_set
{
for mon in monitors {
mon.unname_workspace(workspace_name);
mon.clean_up_workspaces();
}
}
}
pub fn find_window_and_output_mut(
&mut self,
wl_surface: &WlSurface,
@ -969,6 +1063,19 @@ impl<W: LayoutElement> Layout<W> {
monitors.iter().find(|monitor| &monitor.output == output)
}
pub fn monitor_for_workspace(&self, workspace_name: &str) -> Option<&Monitor<W>> {
let MonitorSet::Normal { monitors, .. } = &self.monitor_set else {
return None;
};
monitors.iter().find(|monitor| {
monitor
.workspaces
.iter()
.any(|ws| ws.name.as_deref() == Some(workspace_name))
})
}
pub fn outputs(&self) -> impl Iterator<Item = &Output> + '_ {
let monitors = if let MonitorSet::Normal { monitors, .. } = &self.monitor_set {
&monitors[..]
@ -1126,6 +1233,12 @@ impl<W: LayoutElement> Layout<W> {
monitor.move_to_workspace(idx);
}
pub fn move_to_workspace_on_output(&mut self, output: &Output, idx: usize) {
self.move_to_output(&output);
self.focus_output(&output);
self.move_to_workspace(idx);
}
pub fn move_column_to_workspace_up(&mut self) {
let Some(monitor) = self.active_monitor() else {
return;
@ -1147,6 +1260,12 @@ impl<W: LayoutElement> Layout<W> {
monitor.move_column_to_workspace(idx);
}
pub fn move_column_to_workspace_on_output(&mut self, output: &Output, idx: usize) {
self.move_to_output(&output);
self.focus_output(&output);
self.move_column_to_workspace(idx);
}
pub fn switch_workspace_up(&mut self) {
let Some(monitor) = self.active_monitor() else {
return;
@ -1256,6 +1375,7 @@ impl<W: LayoutElement> Layout<W> {
use crate::layout::monitor::WorkspaceSwitch;
let mut seen_workspace_id = HashSet::new();
let mut seen_workspace_name = HashSet::new();
let (monitors, &primary_idx, &active_monitor_idx) = match &self.monitor_set {
MonitorSet::Normal {
@ -1280,6 +1400,13 @@ impl<W: LayoutElement> Layout<W> {
"workspace id must be unique"
);
if let Some(name) = &workspace.name {
assert!(
seen_workspace_name.insert(name),
"workspace name must be unique"
);
}
workspace.verify_invariants();
}
@ -1342,6 +1469,11 @@ impl<W: LayoutElement> Layout<W> {
"monitor must have an empty workspace in the end"
);
assert!(
monitor.workspaces.last().unwrap().name.is_none(),
"monitor must have an unnamed workspace in the end"
);
// If there's no workspace switch in progress, there can't be any non-last non-active
// empty workspaces.
if monitor.workspace_switch.is_none() {
@ -1369,6 +1501,13 @@ impl<W: LayoutElement> Layout<W> {
"workspace id must be unique"
);
if let Some(name) = &workspace.name {
assert!(
seen_workspace_name.insert(name),
"workspace name must be unique"
);
}
workspace.verify_invariants();
}
}
@ -1429,6 +1568,29 @@ impl<W: LayoutElement> Layout<W> {
}
}
pub fn new_workspace_for_output(
&mut self,
workspace_config: WorkspaceConfig,
output: Option<Output>,
config: &Config,
) {
let options = Rc::new(Options::from_config(config));
let output = output.or(self.active_output().cloned());
let ws = if let Some(output) = output {
Workspace::new_with_config(output, Some(workspace_config), options)
} else {
Workspace::new_with_config_no_outputs(Some(workspace_config.clone()), options)
};
let Some(mon) = self.active_monitor() else {
return;
};
mon.workspaces.push(ws);
}
pub fn update_config(&mut self, config: &Config) {
let options = Rc::new(Options::from_config(config));

View File

@ -103,6 +103,18 @@ impl<W: LayoutElement> Monitor<W> {
&self.workspaces[self.active_workspace_idx]
}
pub fn workspace_ref(&self, workspace_name: &str) -> Option<&Workspace<W>> {
self.workspaces
.iter()
.find(|w| w.name.as_deref() == Some(workspace_name))
}
pub fn find_workspace_index(&self, workspace_name: &str) -> Option<usize> {
self.workspaces
.iter()
.position(|w| w.name.as_deref() == Some(workspace_name))
}
pub fn active_workspace(&mut self) -> &mut Workspace<W> {
&mut self.workspaces[self.active_workspace_idx]
}
@ -204,7 +216,7 @@ impl<W: LayoutElement> Monitor<W> {
continue;
}
if !self.workspaces[idx].has_windows() {
if !self.workspaces[idx].has_windows() && self.workspaces[idx].name.is_none() {
self.workspaces.remove(idx);
if self.active_workspace_idx > idx {
self.active_workspace_idx -= 1;
@ -213,6 +225,17 @@ impl<W: LayoutElement> Monitor<W> {
}
}
pub fn unname_workspace(&mut self, workspace_name: &str) {
for idx in 0..self.workspaces.len() - 1 {
if let Some(name) = &self.workspaces[idx].name {
if name == workspace_name {
self.workspaces[idx].unname();
return;
}
}
}
}
pub fn move_left(&mut self) {
self.active_workspace().move_left();
}

View File

@ -3,7 +3,7 @@ use std::iter::{self, zip};
use std::rc::Rc;
use std::time::Duration;
use niri_config::{CenterFocusedColumn, PresetWidth, Struts};
use niri_config::{CenterFocusedColumn, PresetWidth, Struts, Workspace as WorkspaceConfig};
use niri_ipc::SizeChange;
use smithay::backend::renderer::gles::GlesRenderer;
use smithay::desktop::{layer_map_for_output, Window};
@ -94,6 +94,9 @@ pub struct Workspace<W: LayoutElement> {
/// Configurable properties of the layout.
pub options: Rc<Options>,
/// Optional name of this workspace.
pub name: Option<String>,
/// Unique ID of this workspace.
id: WorkspaceId,
}
@ -313,9 +316,23 @@ impl TileData {
impl<W: LayoutElement> Workspace<W> {
pub fn new(output: Output, options: Rc<Options>) -> Self {
Self::new_with_config(output, None, options)
}
pub fn new_with_config(
output: Output,
config: Option<WorkspaceConfig>,
options: Rc<Options>,
) -> Self {
let original_output = config
.clone()
.and_then(|c| c.open_on_output.clone())
.and_then(|c| Some(OutputId(c)))
.unwrap_or(OutputId::new(&output));
let working_area = compute_working_area(&output, options.struts);
Self {
original_output: OutputId::new(&output),
original_output,
view_size: output_size(&output),
working_area,
output: Some(output),
@ -329,14 +346,24 @@ impl<W: LayoutElement> Workspace<W> {
view_offset_before_fullscreen: None,
closing_windows: vec![],
options,
name: config.and_then(|c| Some(c.name.clone().0)),
id: WorkspaceId::next(),
}
}
pub fn new_no_outputs(options: Rc<Options>) -> Self {
pub fn new_with_config_no_outputs(
config: Option<WorkspaceConfig>,
options: Rc<Options>,
) -> Self {
let original_output = OutputId(
config
.clone()
.and_then(|c| c.open_on_output)
.unwrap_or(String::new()),
);
Self {
output: None,
original_output: OutputId(String::new()),
original_output,
view_size: Size::from((1280, 720)),
working_area: Rectangle::from_loc_and_size((0, 0), (1280, 720)),
columns: vec![],
@ -349,14 +376,23 @@ impl<W: LayoutElement> Workspace<W> {
view_offset_before_fullscreen: None,
closing_windows: vec![],
options,
name: config.and_then(|c| Some(c.name.0)),
id: WorkspaceId::next(),
}
}
pub fn new_no_outputs(options: Rc<Options>) -> Self {
Self::new_with_config_no_outputs(None, options)
}
pub fn id(&self) -> WorkspaceId {
self.id
}
pub fn unname(&mut self) {
self.name = None;
}
pub fn advance_animations(&mut self, current_time: Duration) {
if let Some(ViewOffsetAdjustment::Animation(anim)) = &mut self.view_offset_adj {
anim.set_current_time(current_time);
@ -435,6 +471,10 @@ impl<W: LayoutElement> Workspace<W> {
.map(Tile::window_mut)
}
pub fn get_output_name(&self) -> Option<String> {
self.output.as_ref().and_then(|o| Some(o.name()))
}
pub fn set_output(&mut self, output: Option<Output>) {
if self.output == output {
return;

View File

@ -11,7 +11,7 @@ use std::{env, mem, thread};
use _server_decoration::server::org_kde_kwin_server_decoration_manager::Mode as KdeDecorationsMode;
use anyhow::{ensure, Context};
use calloop::futures::Scheduler;
use niri_config::{Config, Key, Modifiers, PreviewRender, TrackLayout};
use niri_config::{Config, Key, Modifiers, PreviewRender, TrackLayout, WorkspaceReference};
use smithay::backend::allocator::Fourcc;
use smithay::backend::renderer::damage::OutputDamageTracker;
use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement;
@ -857,6 +857,35 @@ impl State {
self.niri.config_error_notification.hide();
// Create new named workspaces
for ws in &config.workspaces {
if self
.niri
.layout
.find_workspace_by_name(&ws.name.0)
.is_none()
{
let output = ws
.open_on_output
.clone()
.and_then(|name| self.niri.output_by_name(Some(&name)));
self.niri
.layout
.new_workspace_for_output(ws.clone(), output, &config);
}
}
// Find & orphan removed named workspaces
let mut removed_workspaces: Vec<String> = vec![];
for ws in &self.niri.config.borrow().workspaces {
if !config.workspaces.iter().any(|w| w.name == ws.name) {
removed_workspaces.push(ws.name.0.clone());
}
}
for name in removed_workspaces {
self.niri.layout.unname_workspace(&name);
}
self.niri.layout.update_config(&config);
let slowdown = if config.animations.off {
@ -2087,6 +2116,46 @@ impl Niri {
.cloned()
}
pub fn output_by_name(&self, name: Option<&str>) -> Option<Output> {
let Some(name) = name else {
return None;
};
self.global_space
.outputs()
.find(|output| output.name().eq_ignore_ascii_case(&name))
.cloned()
}
pub fn find_output_and_workspace_index(
&self,
workspace_reference: WorkspaceReference,
) -> Option<(Option<Output>, usize)> {
let workspace_name = match workspace_reference {
WorkspaceReference::Index(index) => {
return Some((None, index.saturating_sub(1) as usize));
}
WorkspaceReference::Name(name) => name,
};
let (target_workspace_index, target_workspace) =
self.layout.find_workspace_by_name(&workspace_name)?;
let Some(active_workspace) = self.layout.active_workspace() else {
return None;
};
if target_workspace.get_output_name() == active_workspace.get_output_name() {
return Some((None, target_workspace_index));
}
let Some(target_output) =
self.output_by_name(target_workspace.get_output_name().as_deref())
else {
return None;
};
Some((Some(target_output), target_workspace_index))
}
pub fn output_down(&self) -> Option<Output> {
let active = self.layout.active_output()?;
let active_geo = self.global_space.output_geometry(active).unwrap();

View File

@ -33,6 +33,9 @@ pub struct ResolvedWindowRules {
/// Output to open this window on.
pub open_on_output: Option<String>,
/// Workspace to open this window on.
pub open_on_workspace: Option<String>,
/// Whether the window should open full-width.
pub open_maximized: Option<bool>,
@ -99,6 +102,7 @@ impl ResolvedWindowRules {
Self {
default_width: None,
open_on_output: None,
open_on_workspace: None,
open_maximized: None,
open_fullscreen: None,
min_width: None,
@ -151,6 +155,7 @@ impl ResolvedWindowRules {
}
let mut open_on_output = None;
let mut open_on_workspace = None;
for rule in rules {
let matches = |m| window_matches(window, &role, m);
@ -175,6 +180,10 @@ impl ResolvedWindowRules {
open_on_output = Some(x);
}
if let Some(x) = rule.open_on_workspace.as_deref() {
open_on_workspace = Some(x);
}
if let Some(x) = rule.open_maximized {
resolved.open_maximized = Some(x);
}
@ -217,6 +226,7 @@ impl ResolvedWindowRules {
}
resolved.open_on_output = open_on_output.map(|x| x.to_owned());
resolved.open_on_workspace = open_on_workspace.map(|x| x.to_owned());
});
resolved

View File

@ -42,6 +42,9 @@ pub enum InitialConfigureState {
/// - This is a dialog with a parent, and there was no explicit output set, so this dialog
/// should fetch the parent's current output again upon mapping.
output: Option<Output>,
/// Workspace to open this window on.
workspace_name: Option<String>,
},
}