river: add rules system

This is a breaking change and replaces the previous
csd-filter-add/remove and float-filter-add/remove commands.

See the riverctl(1) man page for documentation on the new system.
This commit is contained in:
Isaac Freund 2023-03-12 15:40:42 +01:00
parent 05eac54b07
commit b2b2c9ed13
No known key found for this signature in database
GPG Key ID: 86DED400DDFD7A11
17 changed files with 662 additions and 271 deletions

View File

@ -163,6 +163,7 @@ pub fn build(b: *zbs.Builder) !void {
river.linkSystemLibrary("wlroots");
river.addPackagePath("flags", "common/flags.zig");
river.addPackagePath("globber", "common/globber.zig");
river.addCSourceFile("river/wlroots_log_wrapper.c", &[_][]const u8{ "-std=c99", "-O2" });
// TODO: remove when zig issue #131 is implemented
@ -254,6 +255,15 @@ pub fn build(b: *zbs.Builder) !void {
if (fish_completion) {
b.installFile("completions/fish/riverctl.fish", "share/fish/vendor_completions.d/riverctl.fish");
}
{
const globber_test = b.addTest("common/globber.zig");
globber_test.setTarget(target);
globber_test.setBuildMode(mode);
const test_step = b.step("test", "Run the tests");
test_step.dependOn(&globber_test.step);
}
}
const ScdocStep = struct {

223
common/globber.zig Normal file
View File

@ -0,0 +1,223 @@
// Basic prefix, suffix, and substring glob matching.
//
// Released under the Zero Clause BSD (0BSD) license:
//
// Copyright 2023 Isaac Freund
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
const std = @import("std");
const mem = std.mem;
/// Validate a glob, returning error.InvalidGlob if it is empty, "**" or has a
/// '*' at any position other than the first and/or last byte.
pub fn validate(glob: []const u8) error{InvalidGlob}!void {
switch (glob.len) {
0 => return error.InvalidGlob,
1 => {},
2 => if (glob[0] == '*' and glob[1] == '*') return error.InvalidGlob,
else => if (mem.indexOfScalar(u8, glob[1 .. glob.len - 1], '*') != null) {
return error.InvalidGlob;
},
}
}
test validate {
const testing = std.testing;
_ = try validate("*");
_ = try validate("a");
_ = try validate("*a");
_ = try validate("a*");
_ = try validate("*a*");
_ = try validate("ab");
_ = try validate("*ab");
_ = try validate("ab*");
_ = try validate("*ab*");
_ = try validate("abc");
_ = try validate("*abc");
_ = try validate("abc*");
_ = try validate("*abc*");
try testing.expectError(error.InvalidGlob, validate(""));
try testing.expectError(error.InvalidGlob, validate("**"));
try testing.expectError(error.InvalidGlob, validate("***"));
try testing.expectError(error.InvalidGlob, validate("a*c"));
try testing.expectError(error.InvalidGlob, validate("ab*c*"));
try testing.expectError(error.InvalidGlob, validate("*ab*c"));
try testing.expectError(error.InvalidGlob, validate("ab*c"));
try testing.expectError(error.InvalidGlob, validate("a*bc*"));
try testing.expectError(error.InvalidGlob, validate("**a"));
try testing.expectError(error.InvalidGlob, validate("abc**"));
}
/// Return true if s is matched by glob.
/// Asserts that the glob is valid, see `validate()`.
pub fn match(s: []const u8, glob: []const u8) bool {
if (std.debug.runtime_safety) {
validate(glob) catch unreachable;
}
if (glob.len == 1) {
return glob[0] == '*' or mem.eql(u8, s, glob);
}
const suffix_match = glob[0] == '*';
const prefix_match = glob[glob.len - 1] == '*';
if (suffix_match and prefix_match) {
return mem.indexOf(u8, s, glob[1 .. glob.len - 1]) != null;
} else if (suffix_match) {
return mem.endsWith(u8, s, glob[1..]);
} else if (prefix_match) {
return mem.startsWith(u8, s, glob[0 .. glob.len - 1]);
} else {
return mem.eql(u8, s, glob);
}
}
test match {
const testing = std.testing;
try testing.expect(match("", "*"));
try testing.expect(match("a", "*"));
try testing.expect(match("a", "*a*"));
try testing.expect(match("a", "a*"));
try testing.expect(match("a", "*a"));
try testing.expect(match("a", "a"));
try testing.expect(!match("a", "b"));
try testing.expect(!match("a", "*b*"));
try testing.expect(!match("a", "b*"));
try testing.expect(!match("a", "*b"));
try testing.expect(match("ab", "*"));
try testing.expect(match("ab", "*a*"));
try testing.expect(match("ab", "*b*"));
try testing.expect(match("ab", "a*"));
try testing.expect(match("ab", "*b"));
try testing.expect(match("ab", "*ab*"));
try testing.expect(match("ab", "ab*"));
try testing.expect(match("ab", "*ab"));
try testing.expect(match("ab", "ab"));
try testing.expect(!match("ab", "b*"));
try testing.expect(!match("ab", "*a"));
try testing.expect(!match("ab", "*c*"));
try testing.expect(!match("ab", "c*"));
try testing.expect(!match("ab", "*c"));
try testing.expect(!match("ab", "ac"));
try testing.expect(!match("ab", "*ac*"));
try testing.expect(!match("ab", "ac*"));
try testing.expect(!match("ab", "*ac"));
try testing.expect(match("abc", "*"));
try testing.expect(match("abc", "*a*"));
try testing.expect(match("abc", "*b*"));
try testing.expect(match("abc", "*c*"));
try testing.expect(match("abc", "a*"));
try testing.expect(match("abc", "*c"));
try testing.expect(match("abc", "*ab*"));
try testing.expect(match("abc", "ab*"));
try testing.expect(match("abc", "*bc*"));
try testing.expect(match("abc", "*bc"));
try testing.expect(match("abc", "*abc*"));
try testing.expect(match("abc", "abc*"));
try testing.expect(match("abc", "*abc"));
try testing.expect(match("abc", "abc"));
try testing.expect(!match("abc", "*a"));
try testing.expect(!match("abc", "*b"));
try testing.expect(!match("abc", "b*"));
try testing.expect(!match("abc", "c*"));
try testing.expect(!match("abc", "*ab"));
try testing.expect(!match("abc", "bc*"));
try testing.expect(!match("abc", "*d*"));
try testing.expect(!match("abc", "d*"));
try testing.expect(!match("abc", "*d"));
}
/// Returns .lt if a is less general than b.
/// Returns .gt if a is more general than b.
/// Returns .eq if a and b are equally general.
/// Both a and b must be valid globs, see `validate()`.
pub fn order(a: []const u8, b: []const u8) std.math.Order {
if (std.debug.runtime_safety) {
validate(a) catch unreachable;
validate(b) catch unreachable;
}
if (mem.eql(u8, a, "*") and mem.eql(u8, b, "*")) {
return .eq;
} else if (mem.eql(u8, a, "*")) {
return .gt;
} else if (mem.eql(u8, b, "*")) {
return .lt;
}
const count_a = @as(u2, @boolToInt(a[0] == '*')) + @boolToInt(a[a.len - 1] == '*');
const count_b = @as(u2, @boolToInt(b[0] == '*')) + @boolToInt(b[b.len - 1] == '*');
if (count_a == 0 and count_b == 0) {
return .eq;
} else if (count_a == count_b) {
// This may look backwards since e.g. "c*" is more general than "cc*"
return std.math.order(b.len, a.len);
} else {
return std.math.order(count_a, count_b);
}
}
test order {
const testing = std.testing;
const Order = std.math.Order;
try testing.expectEqual(Order.eq, order("*", "*"));
try testing.expectEqual(Order.eq, order("*a*", "*b*"));
try testing.expectEqual(Order.eq, order("a*", "*b"));
try testing.expectEqual(Order.eq, order("*a", "*b"));
try testing.expectEqual(Order.eq, order("*a", "b*"));
try testing.expectEqual(Order.eq, order("a*", "b*"));
const descending = [_][]const u8{
"*",
"*a*",
"*b*",
"*a*",
"*ab*",
"*bab*",
"*a",
"b*",
"*b",
"*a",
"a",
"bababab",
"b",
"a",
};
for (descending) |a, i| {
for (descending[i..]) |b| {
try testing.expect(order(a, b) != .lt);
}
}
var ascending = descending;
mem.reverse([]const u8, &ascending);
for (ascending) |a, i| {
for (ascending[i..]) |b| {
try testing.expect(order(a, b) != .gt);
}
}
}

View File

@ -8,9 +8,7 @@ function __riverctl_completion ()
keyboard-group-add \
keyboard-group-remove \
keyboard-layout \
csd-filter-add \
exit \
float-filter-add \
focus-output \
focus-view \
input \
@ -18,6 +16,9 @@ function __riverctl_completion ()
list-input-configs \
move \
resize \
rule-add \
rule-del \
list-rules \
snap \
send-to-output \
spawn \
@ -61,6 +62,8 @@ function __riverctl_completion ()
"focus-output"|"focus-view"|"send-to-output"|"swap") OPTS="next previous" ;;
"move"|"snap") OPTS="up down left right" ;;
"resize") OPTS="horizontal vertical" ;;
"rule-add"|"rule-del") OPTS="float no-float ssd csd" ;;
"list-rules") OPTS="float ssd" ;;
"map") OPTS="-release -repeat -layout" ;;
"unmap") OPTS="-release" ;;
"attach-mode") OPTS="top bottom" ;;

View File

@ -12,9 +12,7 @@ end
# Actions
complete -c riverctl -x -n '__fish_riverctl_complete_arg 1' -a 'close' -d 'Close the focued view'
complete -c riverctl -x -n '__fish_riverctl_complete_arg 1' -a 'csd-filter-add' -d 'Add app-id to the CSD filter list'
complete -c riverctl -x -n '__fish_riverctl_complete_arg 1' -a 'exit' -d 'Exit the compositor, terminating the Wayland session'
complete -c riverctl -x -n '__fish_riverctl_complete_arg 1' -a 'float-filter-add' -d 'Add app-id to the float filter list'
complete -c riverctl -x -n '__fish_riverctl_complete_arg 1' -a 'focus-output' -d 'Focus the next or previous output'
complete -c riverctl -x -n '__fish_riverctl_complete_arg 1' -a 'focus-view' -d 'Focus the next or previous view in the stack'
complete -c riverctl -x -n '__fish_riverctl_complete_arg 1' -a 'input' -d 'Create a configuration rule for an input device'
@ -49,6 +47,10 @@ complete -c riverctl -x -n '__fish_riverctl_complete_arg 1' -a 'map-switch '
complete -c riverctl -x -n '__fish_riverctl_complete_arg 1' -a 'unmap' -d 'Remove the mapping defined by the arguments'
complete -c riverctl -x -n '__fish_riverctl_complete_arg 1' -a 'unmap-pointer' -d 'Remove the pointer mapping defined by the arguments'
complete -c riverctl -x -n '__fish_riverctl_complete_arg 1' -a 'unmap-switch' -d 'Remove the switch mapping defined by the arguments'
# Rules
complete -c riverctl -x -n '__fish_riverctl_complete_arg 1' -a 'rule-add' -d 'Apply an action to matching views'
complete -c riverctl -x -n '__fish_riverctl_complete_arg 1' -a 'rule-del' -d 'Delete a rule added with rule-add'
complete -c riverctl -x -n '__fish_riverctl_complete_arg 1' -a 'list-rules' -d 'Print rules in a given list'
# Configuration
complete -c riverctl -x -n '__fish_riverctl_complete_arg 1' -a 'attach-mode' -d 'Configure where new views should attach to the view stack'
complete -c riverctl -x -n '__fish_riverctl_complete_arg 1' -a 'background-color' -d 'Set the background color'
@ -81,6 +83,9 @@ complete -c riverctl -x -n '__fish_seen_subcommand_from unmap' -a
complete -c riverctl -x -n '__fish_seen_subcommand_from attach-mode' -a 'top bottom'
complete -c riverctl -x -n '__fish_seen_subcommand_from focus-follows-cursor' -a 'disabled normal always'
complete -c riverctl -x -n '__fish_seen_subcommand_from set-cursor-warp' -a 'disabled on-output-change on-focus-change'
complete -c riverctl -x -n '__fish_seen_subcommand_from rule-add' -a 'float no-float ssd csd'
complete -c riverctl -x -n '__fish_seen_subcommand_from rule-del' -a 'float no-float ssd csd'
complete -c riverctl -x -n '__fish_seen_subcommand_from list-rules' -a 'float ssd'
# Subcommands for 'input'
complete -c riverctl -x -n '__fish_seen_subcommand_from input; and __fish_riverctl_complete_arg 2' -a "(__riverctl_list_input_devices)"

View File

@ -9,9 +9,7 @@ _riverctl_subcommands()
riverctl_subcommands=(
# Actions
'close:Close the focused view'
'csd-filter-add:Add app-id to the CSD filter list'
'exit:Exit the compositor, terminating the Wayland session'
'float-filter-add:Add app-id to the float filter list'
'focus-output:Focus the next or previous output'
'focus-view:Focus the next or previous view in the stack'
'move:Move the focused view in the specified direction'
@ -43,6 +41,10 @@ _riverctl_subcommands()
'unmap:Remove the mapping defined by the arguments'
'unmap-pointer:Remove the pointer mapping defined by the arguments'
'unmap-switch:Remove the switch mapping defined by the arguments'
# Rules
'rule-add:Apply an action to matching views'
'rule-del:Delete a rule added with rule-add'
'list-rules:Print rules in a given list'
# Configuration
'attach-mode:Configure where new views should attach to the view stack'
'background-color:Set the background color'
@ -181,6 +183,9 @@ _riverctl()
focus-follows-cursor) _alternative 'arguments:args:(disabled normal always)' ;;
set-cursor-warp) _alternative 'arguments:args:(disabled on-output-change on-focus-change)' ;;
hide-cursor) _riverctl_hide_cursor ;;
rule-add) _alternative 'arguments:args:(float no-float ssd csd)' ;;
rule-del) _alternative 'arguments:args:(float no-float ssd csd)' ;;
list-rules) _alternative 'arguments:args:(float ssd)' ;;
*) return 0 ;;
esac
;;

View File

@ -28,28 +28,9 @@ over the Wayland protocol.
*close*
Close the focused view.
*csd-filter-add* *app-id*|*title* _pattern_
Add _pattern_ to the CSD filter list. Views with this _pattern_ are told to
use client side decoration instead of the default server side decoration.
Note that this affects new views as well as already existing ones. Title
updates are not taken into account.
*csd-filter-remove* *app-id*|*title* _pattern_
Remove _pattern_ from the CSD filter list. Note that this affects new views
as well as already existing ones.
*exit*
Exit the compositor, terminating the Wayland session.
*float-filter-add* *app-id*|*title* _pattern_
Add a pattern to the float filter list. Note that this affects only new
views, not already existing ones. Title updates are also not taken into
account.
*float-filter-remove* *app-id*|*title* _pattern_
Remove an app-id or title from the float filter list. Note that this
affects only new views, not already existing ones.
*focus-output* *next*|*previous*|*up*|*right*|*down*|*left*|_name_
Focus the next or previous output, the closest output in any direction
or an output by name.
@ -192,18 +173,18 @@ A complete list may be found in _/usr/include/linux/input-event-codes.h_
*enter-mode* _name_
Switch to given mode if it exists.
*map* [_-release_|_-repeat_|_-layout_ _index_] _mode_ _modifiers_ _key_ _command_
*map* [*-release*|*-repeat*|*-layout* _index_] _mode_ _modifiers_ _key_ _command_
Run _command_ when _key_ is pressed while _modifiers_ are held down
and in the specified _mode_.
- _-release_: if passed activate on key release instead of key press
- _-repeat_: if passed activate repeatedly until key release; may not
be used with -release
- _-layout_: if passed, a specific layout is pinned to the mapping.
- *-release*: if passed activate on key release instead of key press
- *-repeat*: if passed activate repeatedly until key release; may not
be used with *-release*
- *-layout*: if passed, a specific layout is pinned to the mapping.
When the mapping is checked against a pressed key, this layout is
used to translate the key independent of the active layout
- _index_: zero-based index of a layout set with the *keyboard-layout*
command. If the index is out of range, the _-layout_ option will
command. If the index is out of range, the *-layout* option will
have no effect
- _mode_: name of the mode for which to create the mapping
- _modifiers_: one or more of the modifiers listed above, separated
@ -239,10 +220,10 @@ A complete list may be found in _/usr/include/linux/input-event-codes.h_
- off
- _command_: any command that may be run with riverctl
*unmap* [_-release_] _mode_ _modifiers_ _key_
*unmap* [*-release*] _mode_ _modifiers_ _key_
Remove the mapping defined by the arguments:
- _-release_: if passed unmap the key release instead of the key press
- *-release*: if passed unmap the key release instead of the key press
- _mode_: name of the mode for which to remove the mapping
- _modifiers_: one or more of the modifiers listed above, separated
by a plus sign (+).
@ -263,6 +244,65 @@ A complete list may be found in _/usr/include/linux/input-event-codes.h_
- _lid_|_tablet_: the switch for which to remove the mapping
- _state_: a state as listed above
## RULES
Rules match either the app-id and title of views against a _glob_ pattern.
A _glob_ is a string that may optionally have an _\*_ at the beginning and/or
end. A _\*_ in a _glob_ matches zero or more arbitrary characters in the
app-id or title.
For example, _abc_ is matched by _a\*_, _\*a\*_, _\*b\*_, _\*c_, _abc_, and
_\*_ but not matched by _\*a_, _b\*_, _\*b_, _\*c_, or _ab_. Note that _\*_
matches everything while _\*\*_ and the empty string are invalid.
*rule-add* _action_ [*-app-id* _glob_|*-title* _glob_]
Add a rule that applies an _action_ to views with *app-id* and *title*
matched by the respective _glob_. Omitting *-app-id* or *-title*
is equivalent to passing *-app-id* _\*_ or *-title* _\*_.
The supported _action_ types are:
- *float*: Make the view floating. Applies only to new views.
- *no-float*: Don't make the view floating. Applies only to
new views.
- *ssd*: Use server-side decorations for the view. Applies to new
and existing views.
- *csd*: Use client-side decorations for the view. Applies to new
and existing views.
Both *float* and *no-float* rules are added to the same list,
which means that adding a *no-float* rule with the same arguments
as a *float* rule will overwrite it. The same holds for *ssd* and
*csd* rules.
If multiple rules in a list match a given view the most specific
rule will be applied. For example with the following rules
```
action app-id title
ssd foo bar
csd foo *
csd * bar
ssd * baz
```
a view with app-id 'foo' and title 'bar' would get ssd despite matching
two csd rules as the first rule is most specific. Furthermore a view
with app-id 'foo' and title 'baz' would get csd despite matching the
last rule in the list since app-id specificity takes priority over
title specificity.
If a view is not matched by any rule, river will respect the csd/ssd
wishes of the client and may start the view floating based on simple
heuristics intended to catch popup-like views.
*rule-del* _action_ [*-app-id* _glob_|*-title* _glob_]
Delete a rule created using *rule-add* with the given arguments.
*list-rules* *float*|*ssd*
Print the specified rule list. The output is ordered from most specific
to least specific, the same order in which views are checked against
when searching for a match. Only the first matching rule in the list
has an effect on a given view.
## CONFIGURATION
*attach-mode* *top*|*bottom*

View File

@ -18,13 +18,14 @@ const Self = @This();
const std = @import("std");
const mem = std.mem;
const globber = @import("globber");
const xkb = @import("xkbcommon");
const util = @import("util.zig");
const Server = @import("Server.zig");
const Mode = @import("Mode.zig");
const View = @import("View.zig");
const RuleList = @import("RuleList.zig");
pub const AttachMode = enum {
top,
@ -72,13 +73,8 @@ mode_to_id: std.StringHashMap(u32),
/// All user-defined keymap modes, indexed by mode id
modes: std.ArrayListUnmanaged(Mode),
/// Sets of app_ids and titles which will be started floating
float_filter_app_ids: std.StringHashMapUnmanaged(void) = .{},
float_filter_titles: std.StringHashMapUnmanaged(void) = .{},
/// Sets of app_ids and titles which are allowed to use client side decorations
csd_filter_app_ids: std.StringHashMapUnmanaged(void) = .{},
csd_filter_titles: std.StringHashMapUnmanaged(void) = .{},
float_rules: RuleList = .{},
ssd_rules: RuleList = .{},
/// The selected focus_follows_cursor mode
focus_follows_cursor: FocusFollowsCursorMode = .disabled,
@ -152,64 +148,11 @@ pub fn deinit(self: *Self) void {
for (self.modes.items) |*mode| mode.deinit();
self.modes.deinit(util.gpa);
{
var it = self.float_filter_app_ids.keyIterator();
while (it.next()) |key| util.gpa.free(key.*);
self.float_filter_app_ids.deinit(util.gpa);
}
{
var it = self.float_filter_titles.keyIterator();
while (it.next()) |key| util.gpa.free(key.*);
self.float_filter_titles.deinit(util.gpa);
}
{
var it = self.csd_filter_app_ids.keyIterator();
while (it.next()) |key| util.gpa.free(key.*);
self.csd_filter_app_ids.deinit(util.gpa);
}
{
var it = self.csd_filter_titles.keyIterator();
while (it.next()) |key| util.gpa.free(key.*);
self.csd_filter_titles.deinit(util.gpa);
}
self.float_rules.deinit();
self.ssd_rules.deinit();
util.gpa.free(self.default_layout_namespace);
self.keymap.unref();
self.xkb_context.unref();
}
pub fn shouldFloat(self: Self, view: *View) bool {
if (view.getAppId()) |app_id| {
if (self.float_filter_app_ids.contains(std.mem.span(app_id))) {
return true;
}
}
if (view.getTitle()) |title| {
if (self.float_filter_titles.contains(std.mem.span(title))) {
return true;
}
}
return false;
}
pub fn csdAllowed(self: Self, view: *View) bool {
if (view.getAppId()) |app_id| {
if (self.csd_filter_app_ids.contains(std.mem.span(app_id))) {
return true;
}
}
if (view.getTitle()) |title| {
if (self.csd_filter_titles.contains(std.mem.span(title))) {
return true;
}
}
return false;
}

View File

@ -877,7 +877,7 @@ fn processMotion(self: *Self, device: *wlr.InputDevice, time: u32, delta_x: f64,
{
// Modify the pending box, taking constraints into account
const border_width = if (data.view.pending.borders) server.config.border_width else 0;
const border_width = if (data.view.pending.ssd) server.config.border_width else 0;
var output_width: i32 = undefined;
var output_height: i32 = undefined;

View File

@ -132,7 +132,7 @@ pub fn apply(self: *Self, layout: *Layout) void {
// Here we apply the offset to align the coords with the origin of the
// usable area and shrink the dimensions to accommodate the border size.
const border_width = if (view.inflight.borders) server.config.border_width else 0;
const border_width = if (view.inflight.ssd) server.config.border_width else 0;
view.inflight.box = .{
.x = proposed.x + output.usable_box.x + border_width,
.y = proposed.y + output.usable_box.y + border_width,

106
river/RuleList.zig Normal file
View File

@ -0,0 +1,106 @@
// This file is part of river, a dynamic tiling wayland compositor.
//
// Copyright 2023 The River Developers
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const RuleList = @This();
const std = @import("std");
const mem = std.mem;
const globber = @import("globber");
const util = @import("util.zig");
const View = @import("View.zig");
const Rule = struct {
app_id_glob: []const u8,
title_glob: []const u8,
value: bool,
};
/// Ordered from most specific to most general.
/// Ordered first by app-id generality then by title generality.
rules: std.ArrayListUnmanaged(Rule) = .{},
pub fn deinit(list: *RuleList) void {
for (list.rules.items) |rule| {
util.gpa.free(rule.app_id_glob);
util.gpa.free(rule.title_glob);
}
list.rules.deinit(util.gpa);
}
pub fn add(list: *RuleList, rule: Rule) error{OutOfMemory}!void {
const index = for (list.rules.items) |*existing, i| {
if (mem.eql(u8, rule.app_id_glob, existing.app_id_glob) and
mem.eql(u8, rule.title_glob, existing.title_glob))
{
existing.value = rule.value;
return;
}
switch (globber.order(rule.app_id_glob, existing.app_id_glob)) {
.lt => break i,
.eq => {
if (globber.order(rule.title_glob, existing.title_glob) == .lt) {
break i;
}
},
.gt => {},
}
} else list.rules.items.len;
const owned_app_id_glob = try util.gpa.dupe(u8, rule.app_id_glob);
errdefer util.gpa.free(owned_app_id_glob);
const owned_title_glob = try util.gpa.dupe(u8, rule.title_glob);
errdefer util.gpa.free(owned_title_glob);
try list.rules.insert(util.gpa, index, .{
.app_id_glob = owned_app_id_glob,
.title_glob = owned_title_glob,
.value = rule.value,
});
}
pub fn del(list: *RuleList, rule: Rule) void {
for (list.rules.items) |existing, i| {
if (mem.eql(u8, rule.app_id_glob, existing.app_id_glob) and
mem.eql(u8, rule.title_glob, existing.title_glob))
{
util.gpa.free(existing.app_id_glob);
util.gpa.free(existing.title_glob);
_ = list.rules.orderedRemove(i);
return;
}
}
}
/// Returns the value of the most specific rule matching the view.
/// Returns null if no rule matches.
pub fn match(list: *RuleList, view: *View) ?bool {
const app_id = mem.sliceTo(view.getAppId(), 0) orelse "";
const title = mem.sliceTo(view.getTitle(), 0) orelse "";
for (list.rules.items) |rule| {
if (globber.match(app_id, rule.app_id_glob) and
globber.match(title, rule.title_glob))
{
return rule.value;
}
}
return null;
}

View File

@ -72,13 +72,13 @@ pub const State = struct {
float: bool = false,
fullscreen: bool = false,
urgent: bool = false,
borders: bool = true,
ssd: bool = false,
resizing: bool = false,
/// Modify the x/y of the given state by delta_x/delta_y, clamping to the
/// bounds of the output.
pub fn move(state: *State, delta_x: i32, delta_y: i32) void {
const border_width = if (state.borders) server.config.border_width else 0;
const border_width = if (state.ssd) server.config.border_width else 0;
var output_width: i32 = math.maxInt(i32);
var output_height: i32 = math.maxInt(i32);
@ -106,7 +106,7 @@ pub const State = struct {
var output_height: i32 = undefined;
output.wlr_output.effectiveResolution(&output_width, &output_height);
const border_width = if (state.borders) server.config.border_width else 0;
const border_width = if (state.ssd) server.config.border_width else 0;
state.box.width = math.min(state.box.width, output_width - (2 * border_width));
state.box.height = math.min(state.box.height, output_height - (2 * border_width));
@ -265,7 +265,7 @@ pub fn updateCurrent(view: *Self) void {
view.tree.node.setPosition(box.x, box.y);
view.popup_tree.node.setPosition(box.x, box.y);
const enable_borders = view.current.borders and !view.current.fullscreen;
const enable_borders = view.current.ssd and !view.current.fullscreen;
const border_width: c_int = config.border_width;
view.borders.left.node.setEnabled(enable_borders);
@ -428,7 +428,12 @@ pub fn map(view: *Self) !void {
view.foreign_toplevel_handle.map();
view.pending.borders = !server.config.csdAllowed(view);
if (server.config.float_rules.match(view)) |float| {
view.pending.float = float;
}
if (server.config.ssd_rules.match(view)) |ssd| {
view.pending.ssd = ssd;
}
if (server.input_manager.defaultSeat().focused_output) |output| {
// Center the initial pending box on the output

View File

@ -42,7 +42,14 @@ pub fn init(wlr_decoration: *wlr.XdgToplevelDecorationV1) void {
wlr_decoration.events.destroy.add(&decoration.destroy);
wlr_decoration.events.request_mode.add(&decoration.request_mode);
handleRequestMode(&decoration.request_mode, decoration.wlr_decoration);
const ssd = server.config.ssd_rules.match(xdg_toplevel.view) orelse
(decoration.wlr_decoration.requested_mode != .client_side);
// TODO(wlroots): make sure this is properly batched in a single configure
// with all other initial state when wlroots makes this possible.
_ = wlr_decoration.setMode(if (ssd) .server_side else .client_side);
xdg_toplevel.view.pending.ssd = ssd;
}
// TODO(wlroots): remove this function when updating to 0.17.0
@ -72,9 +79,13 @@ fn handleRequestMode(
const decoration = @fieldParentPtr(XdgDecoration, "request_mode", listener);
const xdg_toplevel = @intToPtr(*XdgToplevel, decoration.wlr_decoration.surface.data);
if (server.config.csdAllowed(xdg_toplevel.view)) {
_ = decoration.wlr_decoration.setMode(.client_side);
} else {
_ = decoration.wlr_decoration.setMode(.server_side);
const view = xdg_toplevel.view;
const ssd = server.config.ssd_rules.match(xdg_toplevel.view) orelse
(decoration.wlr_decoration.requested_mode != .client_side);
if (view.pending.ssd != ssd) {
view.pending.ssd = ssd;
server.root.applyPending();
}
}

View File

@ -115,6 +115,7 @@ pub fn configure(self: *Self) bool {
(inflight.focus != 0) == (current.focus != 0) and
inflight_fullscreen == current_fullscreen and
inflight_float == current_float and
inflight.ssd == current.ssd and
inflight.resizing == current.resizing)
{
return false;
@ -130,6 +131,10 @@ pub fn configure(self: *Self) bool {
_ = self.xdg_toplevel.setTiled(.{ .top = true, .bottom = true, .left = true, .right = true });
}
if (self.decoration) |decoration| {
_ = decoration.wlr_decoration.setMode(if (inflight.ssd) .server_side else .client_side);
}
_ = self.xdg_toplevel.setResizing(inflight.resizing);
// Only track configures with the transaction system if they affect the dimensions of the view.
@ -226,9 +231,8 @@ fn handleMap(listener: *wl.Listener(void)) void {
(state.min_width == state.max_width or state.min_height == state.max_height);
if (self.xdg_toplevel.parent != null or has_fixed_size) {
// If the self.xdg_toplevel has a parent or has a fixed size make it float
view.pending.float = true;
} else if (server.config.shouldFloat(view)) {
// If the self.xdg_toplevel has a parent or has a fixed size make it float.
// This will be overwritten in View.map() if the view is matched by a rule.
view.pending.float = true;
}

View File

@ -51,6 +51,8 @@ set_override_redirect: wl.Listener(*wlr.XwaylandSurface) =
// Listeners that are only active while the view is mapped
set_title: wl.Listener(*wlr.XwaylandSurface) = wl.Listener(*wlr.XwaylandSurface).init(handleSetTitle),
set_class: wl.Listener(*wlr.XwaylandSurface) = wl.Listener(*wlr.XwaylandSurface).init(handleSetClass),
set_decorations: wl.Listener(*wlr.XwaylandSurface) =
wl.Listener(*wlr.XwaylandSurface).init(handleSetDecorations),
request_fullscreen: wl.Listener(*wlr.XwaylandSurface) =
wl.Listener(*wlr.XwaylandSurface).init(handleRequestFullscreen),
request_minimize: wl.Listener(*wlr.XwaylandSurface.event.Minimize) =
@ -168,6 +170,7 @@ pub fn handleMap(listener: *wl.Listener(*wlr.XwaylandSurface), xwayland_surface:
// Add listeners that are only active while mapped
xwayland_surface.events.set_title.add(&self.set_title);
xwayland_surface.events.set_class.add(&self.set_class);
xwayland_surface.events.set_decorations.add(&self.set_decorations);
xwayland_surface.events.request_fullscreen.add(&self.request_fullscreen);
xwayland_surface.events.request_minimize.add(&self.request_minimize);
@ -194,12 +197,14 @@ pub fn handleMap(listener: *wl.Listener(*wlr.XwaylandSurface), xwayland_surface:
false;
if (self.xwayland_surface.parent != null or has_fixed_size) {
// If the toplevel has a parent or has a fixed size make it float
view.pending.float = true;
} else if (server.config.shouldFloat(view)) {
// If the toplevel has a parent or has a fixed size make it float by default.
// This will be overwritten in View.map() if the view is matched by a rule.
view.pending.float = true;
}
// This will be overwritten in View.map() if the view is matched by a rule.
view.pending.ssd = !xwayland_surface.decorations.no_border;
view.pending.fullscreen = xwayland_surface.fullscreen;
view.map() catch {
@ -276,6 +281,19 @@ fn handleSetClass(listener: *wl.Listener(*wlr.XwaylandSurface), _: *wlr.Xwayland
self.view.notifyAppId();
}
fn handleSetDecorations(listener: *wl.Listener(*wlr.XwaylandSurface), _: *wlr.XwaylandSurface) void {
const self = @fieldParentPtr(Self, "set_decorations", listener);
const view = self.view;
const ssd = server.config.ssd_rules.match(view) orelse
!self.xwayland_surface.decorations.no_border;
if (view.pending.ssd != ssd) {
view.pending.ssd = ssd;
server.root.applyPending();
}
}
fn handleRequestFullscreen(listener: *wl.Listener(*wlr.XwaylandSurface), xwayland_surface: *wlr.XwaylandSurface) void {
const self = @fieldParentPtr(Self, "request_fullscreen", listener);
if (self.view.pending.fullscreen != xwayland_surface.fullscreen) {

View File

@ -47,28 +47,32 @@ const command_impls = std.ComptimeStringMap(
.{ "border-color-urgent", @import("command/config.zig").borderColorUrgent },
.{ "border-width", @import("command/config.zig").borderWidth },
.{ "close", @import("command/close.zig").close },
.{ "csd-filter-add", @import("command/filter.zig").csdFilterAdd },
.{ "csd-filter-remove", @import("command/filter.zig").csdFilterRemove },
.{ "declare-mode", @import("command/declare_mode.zig").declareMode },
.{ "default-layout", @import("command/layout.zig").defaultLayout },
.{ "enter-mode", @import("command/enter_mode.zig").enterMode },
.{ "exit", @import("command/exit.zig").exit },
.{ "float-filter-add", @import("command/filter.zig").floatFilterAdd },
.{ "float-filter-remove", @import("command/filter.zig").floatFilterRemove },
.{ "focus-follows-cursor", @import("command/focus_follows_cursor.zig").focusFollowsCursor },
.{ "focus-output", @import("command/output.zig").focusOutput },
.{ "focus-previous-tags", @import("command/tags.zig").focusPreviousTags },
.{ "focus-view", @import("command/focus_view.zig").focusView },
.{ "hide-cursor", @import("command/cursor.zig").cursor },
.{ "input", @import("command/input.zig").input },
.{ "keyboard-group-add", @import("command/keyboard_group.zig").keyboardGroupAdd },
.{ "keyboard-group-create", @import("command/keyboard_group.zig").keyboardGroupCreate },
.{ "keyboard-group-destroy", @import("command/keyboard_group.zig").keyboardGroupDestroy },
.{ "keyboard-group-remove", @import("command/keyboard_group.zig").keyboardGroupRemove },
.{ "keyboard-layout", @import("command/keyboard.zig").keyboardLayout },
.{ "list-input-configs", @import("command/input.zig").listInputConfigs},
.{ "list-inputs", @import("command/input.zig").listInputs },
.{ "list-rules", @import("command/rule.zig").listRules},
.{ "map", @import("command/map.zig").map },
.{ "map-pointer", @import("command/map.zig").mapPointer },
.{ "map-switch", @import("command/map.zig").mapSwitch },
.{ "move", @import("command/move.zig").move },
.{ "output-layout", @import("command/layout.zig").outputLayout },
.{ "resize", @import("command/move.zig").resize },
.{ "rule-add", @import("command/rule.zig").ruleAdd },
.{ "rule-del", @import("command/rule.zig").ruleDel },
.{ "send-layout-cmd", @import("command/layout.zig").sendLayoutCmd },
.{ "send-to-output", @import("command/output.zig").sendToOutput },
.{ "send-to-previous-tags", @import("command/tags.zig").sendToPreviousTags },
@ -89,11 +93,6 @@ const command_impls = std.ComptimeStringMap(
.{ "unmap-switch", @import("command/map.zig").unmapSwitch },
.{ "xcursor-theme", @import("command/xcursor_theme.zig").xcursorTheme },
.{ "zoom", @import("command/zoom.zig").zoom },
.{ "keyboard-layout", @import("command/keyboard.zig").keyboardLayout },
.{ "keyboard-group-create", @import("command/keyboard_group.zig").keyboardGroupCreate },
.{ "keyboard-group-destroy", @import("command/keyboard_group.zig").keyboardGroupDestroy },
.{ "keyboard-group-add", @import("command/keyboard_group.zig").keyboardGroupAdd },
.{ "keyboard-group-remove", @import("command/keyboard_group.zig").keyboardGroupRemove },
},
);
// zig fmt: on
@ -107,6 +106,7 @@ pub const Error = error{
InvalidButton,
InvalidCharacter,
InvalidDirection,
InvalidGlob,
InvalidPhysicalDirection,
InvalidOutputIndicator,
InvalidOrientation,
@ -136,7 +136,7 @@ pub fn run(
try impl_fn(seat, args, out);
}
/// Return a short error message for the given error. Passing Error.Other is UB
/// Return a short error message for the given error. Passing Error.Other is invalid.
pub fn errToMsg(err: Error) [:0]const u8 {
return switch (err) {
Error.NoCommand => "no command given",
@ -149,6 +149,7 @@ pub fn errToMsg(err: Error) [:0]const u8 {
Error.InvalidButton => "invalid button",
Error.InvalidCharacter => "invalid character in argument",
Error.InvalidDirection => "invalid direction. Must be 'next' or 'previous'",
Error.InvalidGlob => "invalid glob. '*' is only allowed as the first and/or last character",
Error.InvalidPhysicalDirection => "invalid direction. Must be 'up', 'down', 'left' or 'right'",
Error.InvalidOutputIndicator => "invalid indicator for an output. Must be 'next', 'previous', 'up', 'down', 'left', 'right' or a valid output name",
Error.InvalidOrientation => "invalid orientation. Must be 'horizontal', or 'vertical'",

View File

@ -1,147 +0,0 @@
// This file is part of river, a dynamic tiling wayland compositor.
//
// Copyright 2020 The River Developers
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const assert = std.debug.assert;
const mem = std.mem;
const server = &@import("../main.zig").server;
const util = @import("../util.zig");
const View = @import("../View.zig");
const Error = @import("../command.zig").Error;
const Seat = @import("../Seat.zig");
const FilterKind = enum {
@"app-id",
title,
};
pub fn floatFilterAdd(
_: *Seat,
args: []const [:0]const u8,
_: *?[]const u8,
) Error!void {
if (args.len < 3) return Error.NotEnoughArguments;
if (args.len > 3) return Error.TooManyArguments;
const kind = std.meta.stringToEnum(FilterKind, args[1]) orelse return Error.UnknownOption;
const map = switch (kind) {
.@"app-id" => &server.config.float_filter_app_ids,
.title => &server.config.float_filter_titles,
};
const key = args[2];
const gop = try map.getOrPut(util.gpa, key);
if (gop.found_existing) return;
errdefer assert(map.remove(key));
gop.key_ptr.* = try util.gpa.dupe(u8, key);
}
pub fn floatFilterRemove(
_: *Seat,
args: []const [:0]const u8,
_: *?[]const u8,
) Error!void {
if (args.len < 3) return Error.NotEnoughArguments;
if (args.len > 3) return Error.TooManyArguments;
const kind = std.meta.stringToEnum(FilterKind, args[1]) orelse return Error.UnknownOption;
const map = switch (kind) {
.@"app-id" => &server.config.float_filter_app_ids,
.title => &server.config.float_filter_titles,
};
const key = args[2];
if (map.fetchRemove(key)) |kv| util.gpa.free(kv.key);
}
pub fn csdFilterAdd(
_: *Seat,
args: []const [:0]const u8,
_: *?[]const u8,
) Error!void {
if (args.len < 3) return Error.NotEnoughArguments;
if (args.len > 3) return Error.TooManyArguments;
const kind = std.meta.stringToEnum(FilterKind, args[1]) orelse return Error.UnknownOption;
const map = switch (kind) {
.@"app-id" => &server.config.csd_filter_app_ids,
.title => &server.config.csd_filter_titles,
};
const key = args[2];
const gop = try map.getOrPut(util.gpa, key);
if (gop.found_existing) return;
errdefer assert(map.remove(key));
gop.key_ptr.* = try util.gpa.dupe(u8, key);
csdFilterUpdateViews(kind, key, .add);
server.root.applyPending();
}
pub fn csdFilterRemove(
_: *Seat,
args: []const [:0]const u8,
_: *?[]const u8,
) Error!void {
if (args.len < 3) return Error.NotEnoughArguments;
if (args.len > 3) return Error.TooManyArguments;
const kind = std.meta.stringToEnum(FilterKind, args[1]) orelse return Error.UnknownOption;
const map = switch (kind) {
.@"app-id" => &server.config.csd_filter_app_ids,
.title => &server.config.csd_filter_titles,
};
const key = args[2];
if (map.fetchRemove(key)) |kv| {
util.gpa.free(kv.key);
csdFilterUpdateViews(kind, key, .remove);
server.root.applyPending();
}
}
fn csdFilterUpdateViews(kind: FilterKind, pattern: []const u8, operation: enum { add, remove }) void {
var it = server.root.views.iterator(.forward);
while (it.next()) |view| {
if (view.impl == .xdg_toplevel) {
if (view.impl.xdg_toplevel.decoration) |decoration| {
if (viewMatchesPattern(kind, pattern, view)) {
switch (operation) {
.add => {
_ = decoration.wlr_decoration.setMode(.client_side);
view.pending.borders = false;
},
.remove => {
_ = decoration.wlr_decoration.setMode(.server_side);
view.pending.borders = true;
},
}
}
}
}
}
}
fn viewMatchesPattern(kind: FilterKind, pattern: []const u8, view: *View) bool {
const p = switch (kind) {
.@"app-id" => mem.span(view.getAppId()),
.title => mem.span(view.getTitle()),
} orelse return false;
return mem.eql(u8, pattern, p);
}

164
river/command/rule.zig Normal file
View File

@ -0,0 +1,164 @@
// This file is part of river, a dynamic tiling wayland compositor.
//
// Copyright 2023 The River Developers
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const fmt = std.fmt;
const globber = @import("globber");
const flags = @import("flags");
const server = &@import("../main.zig").server;
const util = @import("../util.zig");
const Error = @import("../command.zig").Error;
const RuleList = @import("../RuleList.zig");
const Seat = @import("../Seat.zig");
const View = @import("../View.zig");
const Action = enum {
float,
@"no-float",
ssd,
csd,
};
pub fn ruleAdd(_: *Seat, args: []const [:0]const u8, _: *?[]const u8) Error!void {
if (args.len < 2) return Error.NotEnoughArguments;
const result = flags.parser([:0]const u8, &.{
.{ .name = "app-id", .kind = .arg },
.{ .name = "title", .kind = .arg },
}).parse(args[2..]) catch {
return error.InvalidValue;
};
if (result.args.len > 0) return Error.TooManyArguments;
const action = std.meta.stringToEnum(Action, args[1]) orelse return Error.UnknownOption;
const app_id_glob = result.flags.@"app-id" orelse "*";
const title_glob = result.flags.title orelse "*";
try globber.validate(app_id_glob);
try globber.validate(title_glob);
switch (action) {
.float, .@"no-float" => {
try server.config.float_rules.add(.{
.app_id_glob = app_id_glob,
.title_glob = title_glob,
.value = (action == .float),
});
},
.ssd, .csd => {
try server.config.ssd_rules.add(.{
.app_id_glob = app_id_glob,
.title_glob = title_glob,
.value = (action == .ssd),
});
apply_ssd_rules();
server.root.applyPending();
},
}
}
pub fn ruleDel(_: *Seat, args: []const [:0]const u8, _: *?[]const u8) Error!void {
if (args.len < 2) return Error.NotEnoughArguments;
const result = flags.parser([:0]const u8, &.{
.{ .name = "app-id", .kind = .arg },
.{ .name = "title", .kind = .arg },
}).parse(args[2..]) catch {
return error.InvalidValue;
};
if (result.args.len > 0) return Error.TooManyArguments;
const action = std.meta.stringToEnum(Action, args[1]) orelse return Error.UnknownOption;
const app_id_glob = result.flags.@"app-id" orelse "*";
const title_glob = result.flags.title orelse "*";
switch (action) {
.float, .@"no-float" => {
server.config.float_rules.del(.{
.app_id_glob = app_id_glob,
.title_glob = title_glob,
.value = (action == .float),
});
},
.ssd, .csd => {
server.config.ssd_rules.del(.{
.app_id_glob = app_id_glob,
.title_glob = title_glob,
.value = (action == .ssd),
});
apply_ssd_rules();
server.root.applyPending();
},
}
}
fn apply_ssd_rules() void {
var it = server.root.views.iterator(.forward);
while (it.next()) |view| {
if (server.config.ssd_rules.match(view)) |ssd| {
view.pending.ssd = ssd;
}
}
}
pub fn listRules(_: *Seat, args: []const [:0]const u8, out: *?[]const u8) Error!void {
if (args.len < 2) return error.NotEnoughArguments;
if (args.len > 2) return error.TooManyArguments;
const list = std.meta.stringToEnum(enum { float, ssd }, args[1]) orelse return Error.UnknownOption;
const rules = switch (list) {
.float => server.config.float_rules.rules.items,
.ssd => server.config.ssd_rules.rules.items,
};
var action_column_max = "action".len;
var app_id_column_max = "app-id".len;
for (rules) |rule| {
const action = switch (list) {
.float => if (rule.value) "float" else "no-float",
.ssd => if (rule.value) "ssd" else "csd",
};
action_column_max = @max(action_column_max, action.len);
app_id_column_max = @max(app_id_column_max, rule.app_id_glob.len);
}
action_column_max += 2;
app_id_column_max += 2;
var buffer = std.ArrayList(u8).init(util.gpa);
const writer = buffer.writer();
try fmt.formatBuf("action", .{ .width = action_column_max, .alignment = .Left }, writer);
try fmt.formatBuf("app-id", .{ .width = app_id_column_max, .alignment = .Left }, writer);
try writer.writeAll("title\n");
for (rules) |rule| {
const action = switch (list) {
.float => if (rule.value) "float" else "no-float",
.ssd => if (rule.value) "ssd" else "csd",
};
try fmt.formatBuf(action, .{ .width = action_column_max, .alignment = .Left }, writer);
try fmt.formatBuf(rule.app_id_glob, .{ .width = app_id_column_max, .alignment = .Left }, writer);
try writer.print("{s}\n", .{rule.title_glob});
}
out.* = buffer.toOwnedSlice();
}