#!/usr/bin/env perl
# i3status-wrapper -- wrapper for i3status(1), plus other monitoring
#
# Copyright (C) 2019, 2021-2024 Sean Whitton
#
# 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, either version 3 of the License, or (at
# your option) any later version.
#
# 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 .
use 5.032;
use strict;
use warnings;
use lib "$ENV{HOME}/src/dotfiles/perl5";
use JSON;
use IO::Pipe;
use IPC::Shareable ":lock";
use Local::Desktop::WMIPC;
use Sys::Hostname;
use POSIX "floor", "mkfifo";
use File::Basename "basename", "dirname";
use File::Spec::Functions "catfile";
use List::Util qw(first min max zip);
$| = 1;
my $pipe = IO::Pipe->new;
my $i3status = fork // die "couldn't fork()!";
unless ($i3status) {
$pipe->writer;
open STDOUT, ">&=", $pipe->fileno
or die "couldn't open child's STDOUT";
exec "i3status";
}
tie my %info, "IPC::Shareable", undef, { destroy => 1 };
my $wmipc = Local::Desktop::WMIPC->new;
sub with_ignored_events (&) {
$wmipc->send_tick("i3status-wrapper-ign");
$_[0]->();
$wmipc->send_tick("i3status-wrapper-unign");
}
sub for_each_node (&) {
my @trees = $wmipc->get_tree;
while (@trees) {
foreach my $node ((shift @trees)->{nodes}->@*) {
$_[0]->($node);
unshift @trees, $node;
}
}
}
my @all_workspaces = (
"1", "2", "3", "4", "5", "6",
"7", "8", "9", "10", "11:F1", "12:F2",
"13:F3", "14:F4", "15:F5", "16:F6", "17:F7", "18:F8",
"19:F9", "20:F10", "21:F11", "22:F12"
);
unless (fork // warn "couldn't fork monitoring loop") {
my $events = Local::Desktop::WMIPC->new;
$events->subscribe(qw(tick window workspace));
# Determine the initial state -- the WM might just have been reloaded.
# Move any previously-hidden containers to a fresh workspace for perusal.
tied(%info)->lock;
my @old_ids;
for ($wmipc->get_workspaces->@*) {
$info{focused_ws} = $_->{id} if $_->{focused};
push @old_ids, $1 if $_->{name} =~ /\A\*(\d+)\*\z/;
}
if (@old_ids) {
fresh_workspace(go => 1);
$wmipc->cmd(
map("[con_id=$_] move container workspace current, floating disable",
@old_ids),
"focus child");
}
for_each_node {
my $node = shift;
if ($node->{type} eq "workspace"
&& grep $_ eq $node->{name}, @all_workspaces) {
my $entry = $info{paper_ws}{$node->{id}}
//= { name => $node->{name}, off_left => [], off_right => [],
last_dir => 1 };
sync_cols($node => $entry);
$entry->{ncols} = max 2, scalar $entry->{cols}->@*;
} elsif (grep $_ eq "caffeinated", $node->{marks}->@*) {
register_caffeinated($node);
}
};
tied(%info)->unlock;
# Now loop forever reading events, assuming no exceptions.
eval {
while (my $e = <$events>) {
state $last_e;
tied(%info)->lock;
# New containers
if ($last_e && $last_e->{change} && $last_e->{change} eq "new") {
normalise_ws_cols()
unless $e->{change} && $e->{change} eq "floating";
undef $last_e;
} elsif ($e->{change} && $e->{change} eq "new"
&& exists $info{paper_ws}{$info{focused_ws}}) {
# We have to go round the loop once more to find out if it's
# just a floating dialog that we'll ignore.
$last_e = $e;
} elsif ($e->{change} && exists $info{paper_ws}{$info{focused_ws}}
&& $e->{change} eq "floating"
&& $e->{container}{type} ne "floating_con") {
# A container stopped floating -- it's as though it's new.
normalise_ws_cols();
kill USR1 => $i3status;
}
# Other container changes
elsif ($e->{change} && exists $info{paper_ws}{$info{focused_ws}}
&& ($e->{change} eq "close"
|| $e->{change} eq "focus" && !$e->{current}
|| $e->{change} eq "move"
&& $e->{container} && $e->{container}{type} eq "con"
|| $e->{change} eq "floating"
&& $e->{container}{type} eq "floating_con")) {
# Generally we seek to update $info{paper_ws} with the
# information we receive by subscription, but in some cases we
# can't be sure of what has happened.
# For example, as we don't maintain a representation of the
# whole tree, on a change=move event, we don't know where the
# container has gone. Or a focus change might be due to a new
# container, in which case we might need to push one off.
normalise_ws_cols();
kill USR1 => $i3status;
}
# Ticks
elsif ($e->{payload} && $e->{payload} eq "i3status-wrapper-ign") {
# Ignore everything until tick telling us to unignore.
# Forked child that sent the ignore is responsible for
# updating data structures in the meantime.
while (my $next = <$events>) {
last if $next->{payload}
&& $next->{payload} eq "i3status-wrapper-unign";
}
}
# Workspace changes
elsif ($e->{change} && $e->{change} eq "focus" && $e->{current}) {
$info{focused_ws} = $e->{current}{id};
# Must normalise in case containers have moved to or from here
# in our absence.
normalise_ws_cols()
if exists $info{paper_ws}{$info{focused_ws}};
kill USR1 => $i3status;
} elsif ($e->{change} && $e->{change} eq "init" && $e->{current}
&& grep $_ eq $e->{current}{name}, @all_workspaces) {
$info{paper_ws}{$e->{current}{id}}
= { name => $e->{current}{name}, ncols => 2, cols => [],
off_left => [], off_right => [], last_dir => 1 };
} elsif ($e->{change} && $e->{change} eq "rename"
&& exists $info{paper_ws}{$e->{current}{id}}) {
$info{paper_ws}{$e->{current}{id}}{name}
= $e->{current}{name};
kill USR1 => $i3status;
} elsif ($e->{change} && $e->{change} eq "empty"
&& $e->{current}) {
delete $info{paper_ws}{$e->{current}{id}};
kill USR1 => $i3status;
}
# Mark changes
elsif ($e->{change} && $e->{change} eq "mark") {
if (grep $_ eq "caffeinated", $e->{container}{marks}->@*) {
register_caffeinated($e->{container});
} elsif ($info{caffeinated_id}
and $info{caffeinated_id} == $e->{container}{id}) {
clear_caffeinated();
}
}
tied(%info)->unlock;
}
};
# Give up if there's a decoding error. We can't ignore the problem
# because we don't want our ideas regarding what workspaces there are, and
# whether anything is caffeinated, to get out of sync.
#
# The user can use the WM's "reload" command to restart this loop.
$@ and wsbuttons("yes"), clear_caffeinated();
}
my $wm_ipc_socket = $ENV{SWAYSOCK} || $ENV{I3SOCK};
(basename $wm_ipc_socket) =~ /\d[\d.]*\d/;
my $cmdpipe = catfile dirname($wm_ipc_socket), "i3status-wrapper.$&.pipe";
-e and unlink for $cmdpipe;
unless (fork // warn "couldn't fork command pipe reader") {
mkfifo $cmdpipe, 0700 or die "mkfifo $cmdpipe failed: $!";
open my $cmdpipe_r, "<", $cmdpipe;
# Hold the pipe open with a writer that won't write anything.
open my $cmdpipe_w, ">", $cmdpipe;
while (my $cmd = <$cmdpipe_r>) {
tied(%info)->lock;
my $ws = $info{paper_ws}{$info{focused_ws}};
my $cols = $ws->{cols};
my $col_rows = $info{col_rows}{$ws->{focused_col}};
my $i = first { $cols->[$_] == $ws->{focused_col} } 0..$#$cols;
my $mv = sub {
my ($j, $move) = @_;
if (@$cols > $j >= 0) {
if ($move) {
# This does not trigger any events.
$wmipc->cmd(
"[con_id=@$cols[$i]] swap container with con_id @$cols[$j]"
);
@$cols[$i, $j] = @$cols[$j, $i];
} else {
$wmipc->cmd($j > $i ? "focus right" : "focus left");
}
} elsif ($move && $ws->{monocle}) {
if ($j > $i && $ws->{off_right}->@*) {
push $ws->{off_left}->@*, pop $ws->{off_right}->@*;
} elsif ($j < $i && $ws->{off_left}->@*) {
push $ws->{off_right}->@*, pop $ws->{off_left}->@*;
}
} elsif ($j == @$cols && $ws->{off_right}->@*) {
my $pushed = shift @$cols;
my $pulled = pop $ws->{off_right}->@*;
my @cmds = show_con($pulled);
push $ws->{off_left}->@*, $pushed;
if ($move) {
if ($col_rows || @$cols) {
push @cmds, $col_rows
? "move left"
: "swap container with con_id @$cols[-1]";
push @cmds, "focus right";
}
if (@$cols) {
my $tem = pop @$cols;
push @$cols, $pulled, $tem;
} else {
push @$cols, $pulled;
}
} else {
$ws->{focused_col} = $pulled;
push @cmds, "move right" if $col_rows;
push @$cols, $pulled;
push @cmds, "focus child" if $info{col_rows}{$pulled};
}
with_ignored_events { $wmipc->cmd(@cmds, hide_con($pushed)) };
kill USR1 => $i3status;
} elsif ($j == -1 && $ws->{off_left}->@*) {
my $pushed = pop @$cols;
my $pulled = pop $ws->{off_left}->@*;
my @cmds = show_con($pulled);
push $ws->{off_right}->@*, $pushed;
if ($move) {
if (@$cols) {
push @cmds, "move right" if $col_rows;
push @cmds, "focus left";
my $tem = shift @$cols;
unshift @$cols, $tem, $pulled;
} else {
unshift @$cols, $pulled;
}
} else {
if ($col_rows) {
push @cmds, "move left";
} elsif (@$cols) {
push @cmds, "swap container with con_id @$cols[0]";
}
$ws->{focused_col} = $pulled;
unshift @$cols, $pulled;
push @cmds, "focus child" if $info{col_rows}{$pulled};
}
with_ignored_events { $wmipc->cmd(@cmds, hide_con($pushed)) };
kill USR1 => $i3status;
}
$ws->{last_dir} = $j > $i ? 1 : -1;
};
# Command dispatch
if ($cmd =~ /^(focus|move) (left|right)$/) {
$mv->($2 eq "right" ? $i+1 : $i-1, $1 eq "move");
} elsif ($cmd =~ /^cols (incr|decr)$/) {
$info{paper_ws}{$info{focused_ws}}{ncols}
+= $1 eq "incr" ? 1 : -1;
normalise_ws_cols();
kill USR1 => $i3status;
}
elsif ($cmd =~ /^other column$/) {
# This is meant to be similar to my custom Emacs C-x o.
if ($i == 0 || $ws->{last_dir} == -1 && $i < $#$cols) {
$mv->($i+1);
} elsif ($i == $#$cols || $ws->{last_dir} == 1) {
$mv->($i-1);
}
} elsif ($cmd eq "monocle toggle\n") {
unless (ensure_disable_monocle($ws)) {
$ws->{monocle} = -$i-1;
normalise_ws_cols();
}
kill USR1 => $i3status;
}
elsif ($cmd =~ /^fresh-workspace ?(take|send)?$/) {
fresh_workspace(do {
if ($1 && $1 eq "take") {
go => 1, send => 1;
} elsif ($1 && $1 eq "send") {
send => 1;
} else {
go => 1;
}
});
}
elsif ($cmd =~ /^absorb_expel ?(left|right)?$/) {
my $dir = $1 eq "right" ? 1 : -1;
if ($col_rows > 1) { # expel
# If the column to the right or left also has rows, we'll just
# move the container into that column instead of expelling it.
# Possibly we could float the container, select the
# appropriate full column and unfloat it into place?
$wmipc->cmd(sprintf "move %s", $dir > 0 ? "right" : "left");
$ws->{last_dir} = $dir;
} else { # absorb
my @cmds;
if ($i == 0 && $dir < 0 && $ws->{off_left}->@*) {
my $pulled = pop $ws->{off_left}->@*;
push @cmds, show_con($pulled), "move left";
push @cmds, "splitv" unless $info{col_rows}{$pulled};
push @cmds, "focus right", "move left";
with_ignored_events { $wmipc->cmd(@cmds) };
normalise_ws_cols();
} elsif ($i == $#$cols && $dir > 0 && $ws->{off_right}->@*) {
my $pulled = pop $ws->{off_right}->@*;
push @cmds, show_con($pulled);
push @cmds, "move right" if $col_rows;
push @cmds, "splitv" unless $info{col_rows}{$pulled};
push @cmds, "focus left", "move right";
with_ignored_events { $wmipc->cmd(@cmds) };
normalise_ws_cols();
} elsif ($i == $#$cols && $dir < 0
|| $#$cols > $i > 0
|| $i == 0 && $dir > 0) {
push @cmds, $dir > 0
? ("focus right", "splitv", "focus left")
: ("focus left", "splitv", "focus right")
unless $info{col_rows}{ @$cols[$i+$dir] };
push @cmds, $dir > 0 ? "move right" : "move left";
with_ignored_events { $wmipc->cmd(@cmds) };
normalise_ws_cols($ws->{off_left}->@* && $dir > 0
|| $ws->{off_right}->@* && $dir < 0
? min($#$cols, max 0, $i+$dir) : $i);
}
if (@cmds) {
$ws->{last_dir} = $dir;
kill USR1 => $i3status;
}
}
}
# Basic purpose of this wrapper command is to prevent accidentally
# moving to one of the holding workspaces using Sway's own commands.
elsif ($cmd =~ /^(move_)?workspace (prev|next)$/) {
my ($move, $dir) = (!!$1, $2);
$move && ensure_disable_monocle($ws);
my @keys = sorted_paper_ws();
my $k = first { $keys[$_] == $info{focused_ws} } 0..$#keys;
if ($dir eq "next" && $k < $#keys || $dir eq "prev" && $k > 0) {
my @cmds = "workspace $dir";
$info{focused_ws} = $keys[$dir eq "next" ? $k+1 : $k-1];
if ($move) {
push @cmds, show_con($ws->{focused_col});
push @cmds, "move right"
if $info{col_rows}
{ $info{paper_ws}{$info{focused_ws}}{focused_col} };
push @cmds, "focus child" if $col_rows;
}
$wmipc->cmd(@cmds);
}
}
tied(%info)->unlock;
}
}
$pipe->reader;
open STDIN, "<&=", $pipe->fileno or die "couldn't reopen STDIN!";
# Following based on Michael Stapelberg's sample i3status-wrapper script.
my $hostname = hostname;
my $username = $ENV{LOGNAME} || $ENV{USER} || getpwuid($<);
my $hostinfo
= { name => "hostinfo", full_text => $username . "@" . $hostname };
# Skip the first line which contains the version header.
print scalar <>;
# The second line contains the start of the infinite array.
print scalar <>;
wsbuttons("no");
# Read lines forever, ignore a comma at the beginning if it exists.
while (my ($statusline) = (<> =~ /^,?(.*)/)) {
# If there is a decoding error, just skip this line, to minimise status
# bar freezes. This should be fine here because this filtering loop is in
# itself stateless. It's only if the decoding error involves newlines in
# the wrong places, or similar, that this skip could cause us to produce
# invalid output.
my $blocks = eval { decode_json $statusline } // next;
tied(%info)->lock(LOCK_SH);
if ($info{focused_ws}
&& $info{paper_ws} && keys $info{paper_ws}->%* > 1) {
my @disp;
my @keys = sorted_paper_ws();
foreach my $key (@keys) {
push @disp,
sprintf +($info{focused_ws} == $key ? "%s" : "%s"),
ws_name($info{paper_ws}{$key}{name})
}
unshift @$blocks,
{ name => "ws", markup => "pango", full_text => join " ", @disp };
}
if ($info{focused_ws} && exists $info{paper_ws}{ $info{focused_ws} }) {
sub nwin { join " ", ("\x{2021}")x$_[0] }
my $ws = $info{paper_ws}{ $info{focused_ws} };
my $left = $ws->{off_left}->@*;
my $right = $ws->{off_right}->@*;
my $disp = sprintf "%s",
$ws->{monocle} ? "\x{2020}" : nwin($ws->{ncols});
$disp = sprintf "%s %s", nwin($left), $disp if $left;
$disp = sprintf "%s %s", $disp, nwin($right) if $right;
unshift @$blocks,
{ name => "cols", markup => "pango", full_text => $disp };
}
unshift @$blocks,
{
name => "caffeinated",
full_text => "Caffeinated: " . $info{caffeinated_name} }
if $info{caffeinated_name};
tied(%info)->unlock;
unshift @$blocks, $hostinfo;
print encode_json($blocks) . ",\n";
}
sub wsbuttons {
return unless $ENV{XDG_CURRENT_DESKTOP} eq "sway";
$wmipc->cmd("bar bar-0 workspace_buttons $_[0]");
}
sub register_caffeinated {
$info{caffeinated_id} = $_[0]->{id};
$info{caffeinated_name} = $_[0]->{name};
kill USR1 => $i3status;
}
sub clear_caffeinated {
undef $info{caffeinated_id};
undef $info{caffeinated_name};
kill USR1 => $i3status;
}
sub sync_cols {
my ($node, $entry) = @_;
# Here we assume that the containers for the columns are directly below
# the type=workspace node. That won't be true if workspace_layout is not
# configured to 'default'.
foreach my $child_id ($node->{focus}->@*) {
my $child_node = first { $_->{id} == $child_id } $node->{nodes}->@*;
$entry->{focused_col} = $child_id, last
if $child_node->{type} eq "con";
}
$entry->{cols} = [];
foreach my $child_node ($node->{nodes}->@*) {
next unless $child_node->{type} eq "con";
push $entry->{cols}->@*, $child_node->{id};
$info{col_rows}{$child_node->{id}} = $child_node->{nodes}->@*;
}
}
sub normalise_ws_cols {
my $ws = $info{paper_ws}{$info{focused_ws}};
my $floating_focus;
my $old_cols = $ws->{cols};
my $old_i = shift // first { $old_cols->[$_] == $ws->{focused_col} }
0..$#$old_cols;
for_each_node {
my $node = shift;
if ($node->{id} == $info{focused_ws}) {
sync_cols($node => $ws);
my $first_focus = $node->{focus}->[0];
$floating_focus = ! grep $_ == $first_focus, $ws->{cols}->@*;
goto DONE;
}
};
DONE:
my $cols = $ws->{cols};
my $i = first { $cols->[$_] eq $ws->{focused_col} } 0..$#$cols;
my @cmds;
my $avail_l = scalar $ws->{off_left}->@*;
my $avail_r = scalar $ws->{off_right}->@*;
if ($ws->{monocle} && !@$cols) {
undef $ws->{monocle};
$i = $old_i = !!$avail_l;
}
if ($ws->{focused_col} && $info{col_rows}{$ws->{focused_col}}
&& $info{col_rows}{$ws->{focused_col}} == 1) {
# Attempt to delete the vertically split container by moving the
# single window it contains over one of its edges.
# We can't always do this. We assume the default focus_wrapping.
if ($i < $#$cols && !$info{col_rows}{ @$cols[$i+1] }) {
push @cmds, "move right";
delete $info{col_rows}{$ws->{focused_col}};
$ws->{focused_col} = $cols->[$i] = node_first_child($cols->[$i]);
} elsif ($i > 0 && !$info{col_rows}{ @$cols[$i-1] }) {
push @cmds, "move left";
delete $info{col_rows}{$ws->{focused_col}};
$ws->{focused_col} = $cols->[$i] = node_first_child($cols->[$i]);
}
}
if (!$ws->{monocle} && $ws->{ncols} > @$cols && ($avail_l || $avail_r)) {
# Pull columns in if there are too few columns but some available.
# Want the focused column, after pulls, to be the $old_i'th.
my ($from_l, $from_r);
my $want = $ws->{ncols} - @$cols;
# When we lose columns, the focused column either moves left or
# stays the same. So always $old_i >= $i.
if ($old_i > $i) {
if ($old_i == $#$old_cols) {
# We were in the final column. Either we closed the
# rightmost column, or we lost arbitrary columns from the
# left (e.g. monocle from the last column).
# In either case it is fine to pull more from the left.
$from_l = min $avail_l, $want;
} else {
# We have $i < $old_i < $#$old_cols.
# We must have lost at least $old_i-$i from the left.
$from_l = min $avail_l, $old_i-$i;
}
} else { # $old_i == $i.
if ($old_i == 0) {
# We were in the first column. Either we closed the leftmost
# column, or we lost arbitrary columns from the left
# (e.g. monocle from the first column). We prefer to pull
# from the left in the former case. If we are indeed exiting
# monocle mode, we must pull from the right.
if (@$cols == 1) {
$from_r = min $avail_r, $want;
} else {
$from_l = !!$avail_l;
}
} else {
# It must be that we lost columns from the right.
$from_r = min $avail_r, $want;
}
}
if ($from_l //= min $avail_l, $want-$from_r) {
my @pulled = splice $ws->{off_left}->@*, -$from_l, $from_l;
my @to_pull = reverse @pulled;
@to_pull = zip \@to_pull, [@$cols[0], @to_pull[0..$#to_pull-1]];
push @cmds, ("focus left")x$i;
for (@to_pull) {
push @cmds, show_con(@$_[0]);
next unless @$_[1];
push @cmds, $info{col_rows}{@$_[1]}
? "move left"
: "swap container with con_id @$_[1]";
}
unshift @$cols, @pulled;
$i = 0;
}
if ($from_r //= min $avail_r, $want-$from_l) {
my @pulled
= reverse splice $ws->{off_right}->@*, -$from_r, $from_r;
my @to_pull = zip \@pulled, [@$cols[-1], @pulled[1..$#pulled]];
push @cmds, ("focus right")x($#$cols-$i);
for (@to_pull) {
push @cmds, show_con(@$_[0]);
push @cmds, "move right" if @$_[1] && $info{col_rows}{@$_[1]};
}
push @$cols, @pulled;
$i = $#$cols;
}
if ($i > $old_i) {
push @cmds, ("focus left")x($i-$old_i);
} elsif ($old_i > $i) {
push @cmds, ("focus right")x($old_i-$i);
}
$ws->{focused_col} = $cols->[$old_i];
push @cmds, "focus child" if $info{col_rows}{$ws->{focused_col}};
}
# Push columns off if there are too many columns.
# This should never change which container is focused.
elsif (my $n = $ws->{monocle} ? @$cols-1 : @$cols-$ws->{ncols} > 0) {
my $left = $i;
my $right = $#$cols-$i;
if ($left >= $right) {
$left = min $left, $n;
$right = $n-$left;
} else {
$right = min $right, $n;
$left = $n-$right;
}
my @to_left = splice @$cols, 0, $left;
my @to_right = reverse splice @$cols, -$right, $right;
push @cmds, map hide_con($_), @to_left, @to_right;
push $ws->{off_left}->@*, @to_left;
push $ws->{off_right}->@*, @to_right;
}
if (@cmds) {
push @cmds, "focus floating" if $floating_focus;
with_ignored_events { $wmipc->cmd("focus tiling", @cmds) }
}
}
=head fresh_workspace(%opts)
Switch to the next free workspace, if any. Return the name of that workspace,
or undef if no workspace was available.
=cut
sub fresh_workspace {
my $next_free_workspace = compact_workspaces(leave_gap => 1);
if ($next_free_workspace) {
my @cmds;
my %opts = @_;
# Special case: if we're about to leave a workspace empty by removing
# its monocle mode container, then that workspace will get an empty
# event, and we'll lose track of any windows pushed off to the sides.
# So turn off monocle mode first.
ensure_disable_monocle(my $ws = $info{paper_ws}{$info{focused_ws}});
# We need to ensure that the monitoring loop doesn't process the move
# event before it knows about the workspace change. Otherwise, that
# loop might try to unhide containers from the old workspace onto the
# new one. We do need it to process the workspace init event, else we
# don't know the ID of the new workspace without making our own query.
#
# We also want to ensure that the fresh workspace is the one that
# C-i ; will take us to. In the case that !$opts{go}, can use C-i M-j
# to move any other wanted containers over, before a final C-i ;.
#
# There is a relevant i3/Sway difference here:
# .
# (Our use of hide_con elsewhere assumes Sway's behaviour. Possibly
# we should write wrapper code that can handle either case.)
push @cmds, "workspace $next_free_workspace";
push @cmds, show_con($info{paper_ws}{$info{focused_ws}}{focused_col})
if $opts{send};
push @cmds, "workspace back_and_forth" unless $opts{go};
$wmipc->cmd(@cmds);
}
$next_free_workspace
}
=head compact_workspaces(%opts)
Rename workspaces so as to remove gaps in the sequence of workspaces.
If C<$opts{leave_gap}>, ensure there is a gap of one workspace after the
currently focused workspace and return the name of the gap workspace, or just
return undef if there is no space for a gap.
=cut
sub compact_workspaces {
my %opts = @_;
my @workspaces = sorted_paper_ws();
@workspaces < @all_workspaces or return;
my ($i, $gap_workspace, @pairs);
while (my $next = shift @workspaces) {
my $workspace = $all_workspaces[$i++];
$opts{leave_gap}
and $next == $info{focused_ws}
and $gap_workspace = $all_workspaces[$i++];
my $next_name = $info{paper_ws}{$next}{name};
next if $next_name eq $workspace;
my $pair = [$next, $workspace];
ws_num($next_name) > ws_num($workspace)
? push @pairs, $pair
: unshift @pairs, $pair
}
with_ignored_events {
$wmipc->cmd(
map sprintf("rename workspace %s to %s",
$info{paper_ws}{$_->[0]}{name}, $_->[1]),
@pairs)
};
$info{paper_ws}{$_->[0]}{name} = $_->[1] for @pairs;
$opts{leave_gap} and $gap_workspace
}
sub node_first_child {
my $node_id = shift;
my $child_id;
for_each_node {
my $node = shift;
if ($node->{id} == $node_id) {
$child_id = $node->{nodes}[0]{id};
goto DONE;
}
};
DONE:
return $child_id;
}
sub ensure_disable_monocle {
my $ws = shift;
my $m = $ws->{monocle} or return 0;
undef $ws->{monocle};
normalise_ws_cols(abs ++$m);
return 1;
}
sub sorted_paper_ws {
sort { ws_num($info{paper_ws}{$a}{name})
<=> ws_num($info{paper_ws}{$b}{name}) }
keys $info{paper_ws}->%*
}
sub hide_con {
# Enable floating in order to preserve any rows the container might have.
# Otherwise, Sway subsumes the rows to the hidden workspace and the
# container with our known ID ceases to exist, s.t. we can't unhide it.
sprintf "[con_id=%s] floating enable, move container to workspace %s",
$_[0], "*$_[0]*"
}
sub show_con {
sprintf "[con_id=%s] %s",
$_[0], join ", ", "move container to workspace current",
"floating disable", "focus";
}
sub ws_name {
my ($before, $after) = split /:/, $_[0];
$after // $before
}
sub ws_num { (split /:/, $_[0])[0] }