#!/usr/bin/perl
# PODNAME: papersway
# ABSTRACT: PaperWM-like scrollable tiling window management for Sway/i3wm
#
# Copyright (C) 2019-2025  Sean Whitton <spwhitton@spwhitton.name>
#
# 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 <https://www.gnu.org/licenses/>.

=head1 SYNOPSIS

B<papersway> [B<--i3status>] [B<--cols=>I<N>] [B<--debug>]

=head1 DESCRIPTION

This is an implementation of PaperWM-like scrollable tiling window management
for Sway/i3wm.  If you like Sway/i3wm's commitments to stability, avoiding
scope creep etc. but dislike the window management model, papersway is an
alternative.

=head1 OPTIONS

=over 4

=item B<--i3status>

Start a background instance of B<i3status>, filter and print its output.

=item B<--cols=>I<N>

Set the number of columns on a new workspace.  The default, and minimum, is 2.

=item B<--debug>

Enable some debug printing and don't hide Sway's workspace buttons.

=back

=head1 USAGE

Here we discuss how to integrate papersway into your existing Sway/i3wm
configuration file, usually found at F<~/.config/sway/config> or
F<~/.config/i3/config>, as appropriate.

=head2 Activation

The recommended way to activate papersway is by using it as your bar command:

=over 4

    bar {
        status_command papersway --i3status

        # [ .. further bar options .. ]
    }

=back

This ensures that you can see a visual representation of your paper
workspaces, which will be useful while getting the hang of papersway.

=head2 Binding keys

Here are some sample bindings to get you started.

=over 4

    set $mod Mod4

    bindsym $mod+Left exec papersway-msg focus left
    bindsym $mod+Down focus down
    bindsym $mod+Up focus up
    bindsym $mod+Right exec papersway-msg focus right

    bindsym $mod+Shift+Left exec papersway-msg move left
    bindsym $mod+Shift+Down move down
    bindsym $mod+Shift+Up move up
    bindsym $mod+Shift+Right move exec papersway-msg move right

    bindsym $mod+f exec papersway-msg width toggle
    bindsym $mod+o exec papersway-msg other-column

    bindsym $mod+a exec papersway-msg fresh-workspace
    bindsym $mod+n exec papersway-msg fresh-workspace send
    bindsym $mod+t exec papersway-msg fresh-workspace take
    bindsym $mod+b exec papersway-msg bury-workspace

    bindsym $mod+e exec papersway-msg absorb-expel left
    bindsym $mod+r exec papersway-msg absorb-expel right

    bindsym $mod+minus exec papersway-msg cols decr
    bindsym $mod+equal exec papersway-msg cols incr

    bindsym $mod+u exec papersway-msg workspace prev
    bindsym $mod+i exec papersway-msg workspace next
    bindsym $mod+Shift+u exec papersway-msg move-to-workspace prev
    bindsym $mod+Shift+i exec papersway-msg move-to-workspace next

    bindsym $mod+c [con_mark=caffeinated] inhibit_idle none; \
        inhibit_idle open, mark caffeinated
    bindsym $mod+Shift+c [con_mark=caffeinated] inhibit_idle none, \
        mark --toggle caffeinated
    for_window [con_mark=caffeinated] inhibit_idle open

=back

Delete any bindings you have for the I<split>, I<splith>, I<splitv>,
I<splitt>, I<layout>, I<focus parent> and I<focus child> commands, to avoid
confusion (on the parts of both yourself and of papersway).

=head2 Other configuration

=over 4

=item

Set I<focus_wrapping> to I<no>.

=item

Leave I<workspace_layout> with its default value, I<default>.

=back

=head1 SEE ALSO

L<https://github.com/paperwm/PaperWM>, i3status(1), sway(5)

=cut

use 5.032;
use warnings;

use JSON;
use IO::Pipe;
use AnyEvent;
use AnyEvent::I3 qw(:all);
use Getopt::Long;
use Sys::Hostname;
use POSIX qw(floor mkfifo);
use File::Basename qw(basename dirname);
use File::Spec::Functions qw(catfile);
use List::Util qw(first min max zip);
use Data::Dumper;

$| = 1;
$Data::Dumper::Sortkeys = 1;

my ($pipe, $i3status, $debug);

my $init_cols = 2;

GetOptions
  'debug!'    => \$debug,
  'i3status!' => \$i3status,
  'cols=i'    => \$init_cols,
  or die "failed to parse command line options";

$init_cols >= 2 or die "papersway: --cols must be at least 2\n";

sub debugsay {
    return unless $debug;
    my @args = @_ > 1 ? (sprintf $_[0], @_[1..$#_]) : @_;
    print STDERR "papersway: ", @args, "\n";
}
sub debugdump {
    return unless $debug;
    print STDERR Data::Dumper->Dump(@_) =~ s/^/papersway: /mgr;
}

if ($i3status) {
    $pipe = IO::Pipe->new;
    if ($i3status = fork // die "couldn't fork: $!") {
	$pipe->reader;
    } else {
	$pipe->writer;
	open STDOUT, ">&=", $pipe->fileno
	  or die "couldn't re-open i3status's STDOUT: $!";
	exec "i3status";
    }
}

my (%paper_ws, $focused_ws, %output_widths,
    %col_rows, %col_w, %col_wide, $caffe_id, $caffe_name);

my $have_sway = !!$ENV{SWAYSOCK};
my $wm_ipc_socket = $have_sway ? $ENV{SWAYSOCK} : $ENV{I3SOCK};
my $wmipc = AnyEvent::I3->new($wm_ipc_socket);
$wmipc->connect->recv or die "couldn't connect to WM IPC socket";
my $cmds = App::papersway::cmds->new;

sub for_each_node (&) {
    $cmds->flush;
    my @trees = $wmipc->get_tree->recv;
    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"
);

my $have_pending = AnyEvent->condvar;
my (@pending_events, @pending_msgs);

(basename $wm_ipc_socket) =~ /\d[\d.]*\d/;
my $cmdpipe = catfile dirname($wm_ipc_socket), "papersway.$&.pipe";
-e and unlink for $cmdpipe;
mkfifo $cmdpipe, 0700 or die "mkfifo $cmdpipe failed: $!";

# Hold the pipe open with a writer that won't write anything.
open(my $cmdpipe_w, ">", $cmdpipe), sleep
  unless fork // die "couldn't fork: $!";

open my $cmdpipe_r, "<", $cmdpipe;
my $cmdpipe_reader = AnyEvent->io(
    fh => $cmdpipe_r, poll => "r", cb => sub {
	# There are a few cases where we can handle the command by only
	# updating data structures, but for simplicity, always handle commands
	# outside of the event loop.
	push @pending_msgs, scalar <$cmdpipe_r>;
	$have_pending->send;
    });

my $ignore_events;
sub queue_event { push @pending_events, shift; $have_pending->send }
$wmipc->subscribe({
    tick => sub {
	my $payload = shift->{payload};
	$ignore_events = 1 if $payload eq "papersway-ign";
	$ignore_events = 0 if $payload eq "papersway-unign";
    },

    window => sub {
	return if $ignore_events;
	my $e = shift; state $last_e;

	# New containers: have to read two events to find out whether it's
	# just a floating dialog that we'll ignore.
	if ($last_e) {
	    undef $last_e;
	    queue_event $e unless $e->{change} && $e->{change} eq "floating";
	} elsif ($e->{change} && $e->{change} eq "new"
		 && exists $paper_ws{$focused_ws}) {
	    $last_e = $e;
	}

	# Mark changes -- can handle these without leaving event processing.
	elsif ($e->{change} && $e->{change} eq "mark") {
	    if (grep $_ eq "caffeinated", $e->{container}{marks}->@*) {
		register_caffeinated($e->{container});
	    } elsif ($caffe_id && $caffe_id == $e->{container}{id}) {
		clear_caffeinated();
	    }
	}

	# Title changes -- relevant for the caffeinated window.
	elsif ($e->{change} && $e->{change} eq "title"
	       && $caffe_id && $caffe_id == $e->{container}{id}) {
	    # Call register_caffeinated to update $caffe_name and bar display.
	    register_caffeinated($e->{container});
	}

	# Other container changes we need to handle outside of any callback.
	elsif ($e->{change} && $e->{change} eq "close") {
	    clear_caffeinated()
	      if $caffe_id && $caffe_id == $e->{container}{id};
	    queue_event $e if exists $paper_ws{$focused_ws};
	} elsif ($e->{change} && exists $paper_ws{$focused_ws}
	       && (# A container stopped floating: it's as though it's new.
		   $e->{change} eq "floating"
		   && $e->{container}{type} ne "floating_con"

		   || $e->{change} eq "focus"

	 	   || $e->{change} eq "move"
	 	   && $e->{container} && $e->{container}{type} eq "con"))
	  { queue_event $e }
    },

    workspace => sub {
	my $e = shift;
	if ($ignore_events || !$e->{change}) {
	    return;
	} elsif ($e->{change} eq "focus" && $e->{current}) {
	    $focused_ws = $e->{current}{id};
	    # If this is one of our workspaces, then we must normalise:
	    # containers might have moved to or from here in our absence.
	    if (exists $paper_ws{$focused_ws}) {
		queue_event $e;
	    } else {
		# Update status bar display.
		signal_i3status();
	    }
	} elsif ($e->{change} eq "init" && $e->{current}
		 && grep $_ eq $e->{current}{name}, @all_workspaces) {
	    $paper_ws{$e->{current}{id}}
	      = { name => $e->{current}{name},
		  ncols => $init_cols, cols => [],
		  off_left => [], off_right => [], last_dir => 1,
		  output => $e->{current}{output} };
	} elsif ($e->{change} eq "rename"
		 && exists $paper_ws{$e->{current}{id}}) {
	    $paper_ws{$e->{current}{id}}{name} = $e->{current}{name};
	    signal_i3status();
	} elsif ($e->{change} eq "move" && $e->{current}) {
	    $paper_ws{$e->{current}{id}}{output} = $e->{current}{output};
	} elsif ($e->{change} eq "empty" && $e->{current}) {
	    delete $paper_ws{$e->{current}{id}};
	    signal_i3status();
	}
    },
})->recv->{success} or die "couldn't subscribe to window manager events";

# Determine the initial state -- the WM might just have been reloaded.
# Move any previously-hidden containers to a fresh workspace for perusal.
for_each_node {
    my $node = shift;
    if ($node->{type} eq "output") {
	$output_widths{$node->{name}} = $node->{rect}{width};
    } elsif ($node->{type} eq "workspace"
	     && grep $_ eq $node->{name}, @all_workspaces) {
	my $entry = $paper_ws{$node->{id}}
	  //= { name => $node->{name},
		off_left => [], off_right => [], last_dir => 1,
		output => $node->{output} };
	sync_cols($node => $entry);
	$entry->{ncols} = max $init_cols, scalar $entry->{cols}->@*;
    } elsif (grep $_ eq "caffeinated", $node->{marks}->@*) {
	register_caffeinated($node);
    }
};
my @old_ids;
for ($wmipc->get_workspaces->recv->@*) {
    $focused_ws = $_->{id} if $_->{focused};
    push @old_ids, $1 if $_->{name} =~ /\A\*(\d+)\*\z/;
}
if (@old_ids) {
    fresh_workspace(go => 1);
    $cmds->cmd(
	map("[con_id=$_] move container workspace current, floating disable",
	    @old_ids),
	"focus child");
}
$cmds->cmd("bar bar-0 workspace_buttons no")
  if $have_sway && $i3status && !$debug;
$cmds->flush;
debugsay "initialised";
debugdump [\%paper_ws], [qw(*paper_ws)];

my $username = $ENV{LOGNAME} || $ENV{USER} || getpwuid $<;
my $hostinfo = { name => "hostinfo",
		 full_text => sprintf "%s@%s", $username, hostname };

# Skip the first line which contains the version header.
print scalar <$pipe> if $i3status;

# The second line contains the start of the infinite array.
print scalar <$pipe> if $i3status;

# Basic idea here from Michael Stapelberg's sample i3status-wrapper.
my $i3status_wrapper = $i3status && AnyEvent->io(
    fh => $pipe, poll => "r", cb => sub {

	# If there is a decoding error then we just skip this line, as it's
	# not worth crashing this script over that.  It should be fine to do
	# this here because this filtering loop is in itself stateless.
	# It's only if the decoding error involves newlines in wrong places,
	# or similar, that this skip could cause us to produce invalid output.
	my ($statusline) = (<$pipe> =~ /^,?(.*)/);
	my $blocks = eval { decode_json $statusline } // return;

	if ($focused_ws && keys %paper_ws > 1) {
	    my @disp;
	    foreach my $key (sorted_paper_ws()) {
		push @disp,
		  sprintf +($focused_ws == $key ? "<b>%s</b>" : "%s"),
		  ws_name($paper_ws{$key}{name})
	    }
	    unshift @$blocks,
	      { name => "ws", markup => "pango", full_text => "@disp" };
	}

	if ($focused_ws && exists $paper_ws{$focused_ws}) {
	    # ⌗, ⋕ and 井 are possible alternatives to ‡‡.
	    sub nwin { join " ", map +(widep($_) ? "\x{2021}"x2 : "\x{2021}"),
			 @_ }

	    my $ws    = $paper_ws{$focused_ws};
	    my @left  = $ws->{off_left}->@*;
	    my @right = reverse $ws->{off_right}->@*;
	    my @cols  = $ws->{cols}->@*;

	    my $disp;
	    if (!@left && !@right && (!@cols || !widep($cols[0]))) {
		$disp = sprintf "<b>%s</b>",
		  join " ", ("\x{2021}")x$ws->{ncols};
	    } else {
		$disp = sprintf "<b>%s</b>", nwin(@cols);
		$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: $caffe_name" }
	  if $caffe_name;

	unshift @$blocks, $hostinfo;

	print encode_json($blocks) . ",\n";
    });

# Start main loop.
for (;;) {
    $have_pending->recv;
    $have_pending = AnyEvent::condvar;

    if (@pending_events) {
	# Generally we would like to update %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.  Currently,
	# if we can't handle it within the callback, then we always normalise.
	debugsay "{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{";
	debugsay "normalising for these changes: %s",
	  join "; ", map $_->{change}, @pending_events;
	debugdump [\%paper_ws], ["*paper_ws BEFORE"];
	normalise_ws_cols();
	$cmds->need_signal;
	debugdump [\%paper_ws], ["*paper_ws  AFTER"];
	debugsay "}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}";
	@pending_events = ();
    }
    process_msg(shift @pending_msgs) while @pending_msgs;
    $cmds->flush;
}

sub register_caffeinated {
    $caffe_id = $_[0]->{id};
    $caffe_name = $_[0]->{name};
    signal_i3status();
}

sub clear_caffeinated {
    undef $caffe_id;
    undef $caffe_name;
    signal_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};
	$col_rows{$child_node->{id}} = $child_node->{nodes}->@*;
	$col_w{$child_node->{id}} = $child_node->{rect}{width};
    }
}

sub normalise_ws_cols {
    my $ws = $paper_ws{$focused_ws};
    my $floating_focus;
    my $old_cols = $ws->{cols};
    my $old_i = shift // focused_col_idx($ws);
    my $old_wide = widep($ws->{focused_col});
    for_each_node {
	my $node = shift;
	if ($node->{id} == $focused_ws) {
	    sync_cols($node => $ws);
	    my $first_focus = $node->{focus}->[0];
	    $floating_focus = ! grep $_ == $first_focus, $ws->{cols}->@*;
	    goto DONE;
	}
    };
  DONE:
    my %still_hidden = map +($_, 1), grep defined,
      map $_->{name} =~ /\A\*(\d+)\*\z/, $wmipc->get_workspaces->recv->@*;
    debugdump [\%still_hidden], ["*still_hidden"];
    $ws->{off_left}  = [ grep $still_hidden{$_}, $ws->{off_left}->@*  ];
    $ws->{off_right} = [ grep $still_hidden{$_}, $ws->{off_right}->@* ];
    my $cols = $ws->{cols};
    my $i = focused_col_idx($ws);

    my @cmds;
    my $avail_l = avail_from($ws->{off_left});

    $i = $old_i = 0+!!$avail_l if $old_wide && !@$cols;

    if ($ws->{focused_col} && $col_rows{$ws->{focused_col}}
	&& $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.
	# Here we may be relying on focus_wrapping=no, but I am not sure.
	if ($i < $#$cols && !$col_rows{ @$cols[$i+1] }) {
	    push @cmds, "move right";
	    delete $col_rows{$ws->{focused_col}};
	    $ws->{focused_col} = $cols->[$i] = node_first_child($cols->[$i]);
	} elsif ($i > 0 && !$col_rows{ @$cols[$i-1] }) {
	    push @cmds, "move left";
	    delete $col_rows{$ws->{focused_col}};
	    $ws->{focused_col} = $cols->[$i] = node_first_child($cols->[$i]);
	}
    }

    # Push columns off if there are too many columns.
    # This should never change which container is focused.
    if ((my $n = widep($ws->{focused_col}) || $old_wide
	 ? @$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;
    }

    $avail_l = avail_from($ws->{off_left});
    my $avail_r = avail_from($ws->{off_right});

    if ((!@$cols || !widep($ws->{focused_col}) && $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. last column toggled to wide).
		# In either case it is fine to pull more from the left.
		$from_l = min abs($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 abs($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 right
		# (e.g. first column toggled to wide).  We prefer to pull from
		# the left in the former case.  If we are indeed unwidening,
		# we must pull more from the right.
		if (@$old_cols == 1) {
		    $from_r = min abs($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;
	    }
	}
	debugsay "want to pull %d from left", $from_l if defined $from_l;
	debugsay "want to pull %d from right", $from_r if defined $from_r;

	# We can only pull a wide column if the workspace is currently empty,
	# and in case we do pull one we must not pull anything else.
	$from_l = 0 if $avail_l == -1 && (@$cols || $from_r);
	$from_r = 0 if $avail_r == -1 && (@$cols || $from_l);

	if ($from_l //= min abs($avail_l), $want-$from_r) {
	    push @cmds, ("focus left")x$i, n_from_l($ws, $from_l);
	    $i = 0;
	}
	if ($from_r //= min abs($avail_r), $want-$from_l) {
	    push @cmds, ("focus right")x($#$cols-$i), n_from_r($ws, $from_r);
	    $i = $#$cols;
	}
	if ($from_l || $from_r) {
	    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 $col_rows{$ws->{focused_col}};
	}
    }

    if (@cmds) {
	push @cmds, "focus floating" if $floating_focus;
	$cmds->cmd_ign("focus tiling", @cmds);
    }
}

{
    # Simple class to batch up commands before sending them.
    package App::papersway::cmds;

    sub new {
	bless { cmds => [], need_ignore => 0, need_signal => 0 } => shift
    };

    sub cmd {
	my $self = shift;
	$self->flush if $self->{need_ignore};
	push $self->{cmds}->@*, @_;
	$self->{need_ignore} = 0;
    }
    sub cmd_ign {
	my $self = shift;
	$self->flush unless $self->{need_ignore};
	push $self->{cmds}->@*, @_;
	$self->{need_ignore} = 1;
    }
    sub need_signal { shift->{need_signal}++ }

    sub flush {
	my $self = shift;
	if (my @cmds = $self->{cmds}->@*) {
	    main::debugsay $self->{need_ignore}
	      ? "Sending (with ignored events):" : "Sending:";
	    main::debugdump [\@cmds], ["*cmds"];
	    $wmipc->send_tick("papersway-ign")->recv
	      if $self->{need_ignore};
	    $wmipc->command(join "; ", @cmds)->recv;
	    $wmipc->send_tick("papersway-unign")->recv
	      if $self->{need_ignore};
	    $self->{cmds} = [];
	}
	main::signal_i3status() if $self->{need_signal};
	$self->{need_signal} = 0;
    }
}

sub process_msg {
    my $cmd = shift;

    my $ws = $paper_ws{$focused_ws};
    my $cols = $ws->{cols};
    my $i = focused_col_idx($ws);

    my $mv = sub {
	my ($j, $move) = @_;
	my $rows = $col_rows{$ws->{focused_col}};
	if (@$cols > $j >= 0) {
	    if ($move) {
		# This command does not actually trigger any events.
		$cmds->cmd_ign(
"[con_id=@$cols[$i]] swap container with con_id @$cols[$j]"
		);
		@$cols[$i, $j] = @$cols[$j, $i];
	    } else {
		$cmds->cmd_ign($j > $i ? "focus right" : "focus left");
		$ws->{focused_col} = @$cols[$j];
	    }
	} elsif ($move && widep($ws->{focused_col})) {
	    if ($j > $i && $ws->{off_right}->@*) {
		push $ws->{off_left}->@*, pop $ws->{off_right}->@*;
		signal_i3status();
	    } elsif ($j < $i && $ws->{off_left}->@*) {
		push $ws->{off_right}->@*, pop $ws->{off_left}->@*;
		signal_i3status();
	    }
	} elsif ($j == @$cols
		 && (my $avail_r = avail_from($ws->{off_right}))) {
	    my $pulled = pop $ws->{off_right}->@*;

	    if ($move) {	# !widep($ws->{focused_col})
		if (widep($pulled)) {
		    my @pushed = splice @$cols, 0, $#$cols;
		    push $ws->{off_left}->@*, @pushed, $pulled;
		    $cmds->cmd_ign(map hide_con($_), @pushed);

		    my $n = min $ws->{ncols}-1, avail_from($ws->{off_right});
		    $cmds->cmd_ign(n_from_r($ws, $n), ("focus left")x$n)
		      if $n > 0;
		} else {
		    my $pushed = shift @$cols;
		    push $ws->{off_left}->@*, $pushed;

		    $cmds->cmd_ign(show_con($pulled));

		    if ($rows || @$cols) {
			$cmds->cmd_ign($rows
				   ? "move left"
				   : "swap container with con_id @$cols[-1]");
			$cmds->cmd_ign("focus right");
		    }
		    if (@$cols) {
			my $tem = pop @$cols;
			push @$cols, $pulled, $tem;
		    } else {
			push @$cols, $pulled;
		    }
		    $cmds->cmd_ign(hide_con($pushed));
		}
	    } else {		# ?widep($ws->{focused_col})
		if (widep($pulled)) {
		    # In this case, want to hide first, to avoid the wide
		    # window being shrunk and immediately widened.
		    my @pushed = splice @$cols;
		    push $ws->{off_left}->@*, @pushed;
		    $cmds->cmd_ign(map hide_con($_), @pushed);
		    $cmds->cmd_ign(show_con($pulled));
		    push @$cols, $pulled;
		} else {
		    my $pushed = shift @$cols;
		    push $ws->{off_left}->@*, $pushed;

		    $cmds->cmd_ign(show_con($pulled));
		    $cmds->cmd_ign("move right") if $rows;
		    push @$cols, $pulled;
		    $cmds->cmd_ign(hide_con($pushed));

		    my $n
		      = min $ws->{ncols}-@$cols, avail_from($ws->{off_right});
		    $cmds->cmd_ign(n_from_r($ws, $n), ("focus left")x$n)
		      if $n > 0;
		}
		$ws->{focused_col} = $pulled;
		$cmds->cmd_ign("focus child") if $col_rows{$pulled};
	    }

	    $cmds->need_signal;
	} elsif ($j == -1 && (my $avail_l = avail_from($ws->{off_left}))) {
	    my $pulled = pop $ws->{off_left}->@*;

	    if ($move) {	# !widep($ws->{focused_col})
		if (widep($pulled)) {
		    my @pushed = splice @$cols, 1;
		    push $ws->{off_right}->@*, reverse(@pushed), $pulled;
		    $cmds->cmd_ign(map hide_con($_), @pushed);

		    my $n = min $ws->{ncols}-1, avail_from($ws->{off_left});
		    $cmds->cmd_ign(n_from_l($ws, $n), ("focus right")x$n)
		      if $n > 0;
		} else {
		    my $pushed = pop @$cols;
		    push $ws->{off_right}->@*, $pushed;

		    $cmds->cmd_ign(show_con($pulled));

		    if (@$cols) {
			$cmds->cmd_ign("move right") if $rows;
			$cmds->cmd_ign("focus left");
			my $tem = shift @$cols;
			unshift @$cols, $tem, $pulled;
		    } else {
			unshift @$cols, $pulled;
		    }
		    $cmds->cmd_ign(hide_con($pushed));
		}
	    } else {		# ?widep($ws->{focused_col})
		if (widep($pulled)) {
		    # In this case, want to hide first, to avoid the wide
		    # window being shrunk and immediately widened.
		    my @pushed = splice @$cols;
		    push $ws->{off_right}->@*, reverse @pushed;
		    $cmds->cmd_ign(map hide_con($_), @pushed);
		    $cmds->cmd_ign(show_con($pulled));
		    unshift @$cols, $pulled;
		} else {
		    my $pushed = pop @$cols;
		    push $ws->{off_right}->@*, $pushed;

		    $cmds->cmd_ign(show_con($pulled));

		    if ($rows) {
			$cmds->cmd_ign("move left");
		    } elsif (@$cols) {
			$cmds->cmd_ign(
			    "swap container with con_id @$cols[0]");
		    }
		    unshift @$cols, $pulled;
		    $cmds->cmd_ign(hide_con($pushed));

		    my $n
		      = min $ws->{ncols}-@$cols, avail_from($ws->{off_left});
		    $cmds->cmd_ign(n_from_l($ws, $n), ("focus right")x$n)
		      if $n > 0;
		}
		$ws->{focused_col} = $pulled;
		$cmds->cmd_ign("focus child") if $col_rows{$pulled};
	    }

	    $cmds->need_signal;
	}
    };

    # Command dispatch

    if ($cmd =~ /^(focus|move) (left|right)$/) {
	my ($move, $dir) = ($1, $2);
	$mv->($dir eq "right" ? $i+1 : $i-1, $move eq "move");
	$ws->{last_dir} = $dir eq "right" ? 1 : -1;
	$cmds->flush;
    } elsif ($cmd =~ /^cols (incr|decr)$/
	     && ($ws->{ncols} > 2 || $1 eq "incr")) {
	$ws->{ncols} += $1 eq "incr" ? 1 : -1;
	normalise_ws_cols();
	$cmds->need_signal;
    } elsif ($cmd =~ /^other-column$/ && @$cols > 1) {
	# 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);
	    $ws->{last_dir} = 1;
	} elsif ($i == $#$cols || $ws->{last_dir} == 1) {
	    $mv->($i-1);
	    $ws->{last_dir} = -1;
	}
	$cmds->flush;
    } elsif ($cmd =~ /^scroll (left|right)$/ && avail_from($ws->{"off_$1"})) {
	if ($1 eq "right") {
	    my $old_i = $i == 0 ? $i : $i-1;
	    $mv->($i+1), $i++ while $i < $#$cols;
	    $mv->(scalar @$cols);
	    $mv->($i-1), $i-- while $i > $old_i;
	} else {
	    my $old_i = $i == $#$cols ? $i : $i+1;
	    $mv->($i-1), $i-- while $i > 0;
	    $mv->(-1);
	    $mv->($i+1), $i++ while $i < $old_i;
	}
	$cmds->flush;
    } elsif ($cmd eq "width toggle\n") {
	# Save the column that was focused when this column was widened.
	# This means that if the window is toggled wide and then immediately
	# toggled back, normalise_ws_cols can try to ensure it pulls back on
	# the very same column(s) that it just pushed off.
	my $prev = $col_wide{$ws->{focused_col}};
	$col_wide{$ws->{focused_col}} = defined $prev ? undef : $i;
	normalise_ws_cols($prev);
	$cmds->need_signal;
    } 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 =~ /^bury-workspace$/) {
	bury_workspace();
    } elsif ($cmd =~ /^absorb-expel ?(left|right)?$/) {
	my $dir = $1 eq "right" ? 1 : -1;
	my $rows = $col_rows{$ws->{focused_col}};
	undef $col_wide{$ws->{focused_col}};
	if ($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?
	    $cmds->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}->@*;
		undef $col_wide{$pulled};
		push @cmds, show_con($pulled), "move left";
		push @cmds, "splitv" unless $col_rows{$pulled};
		push @cmds, "focus right", "move left";

		$cmds->cmd_ign(@cmds);
		normalise_ws_cols();
	    } elsif ($i == $#$cols && $dir > 0 && $ws->{off_right}->@*) {
		my $pulled = pop $ws->{off_right}->@*;
		undef $col_wide{$pulled};
		push @cmds, show_con($pulled);
		push @cmds, "move right" if $rows;
		push @cmds, "splitv" unless $col_rows{$pulled};
		push @cmds, "focus left", "move right";

		$cmds->cmd_ign(@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 $col_rows{ @$cols[$i+$dir] };
		push @cmds, $dir > 0 ? "move right" : "move left";

		$cmds->cmd_ign(@cmds);
		normalise_ws_cols(
		       avail_from($ws->{off_left}) > 0 && $dir > 0
		    || avail_from($ws->{off_right}) > 0 && $dir < 0
		    ? min($#$cols, max 0, $i+$dir) : $i
		   );
	    }
	    if (@cmds) {
		$ws->{last_dir} = $dir;
		signal_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-to-)?workspace (prev|next)$/) {
	my ($move, $dir) = (!!$1, $2);
	my $orig_focused = $ws->{focused_col};

	# If we need to move a wide container, hide it and normalise first.
	# This ensures we don't leave this workspace empty such that it ceases
	# to exist, losing track of any containers pushed off to its sides.
	if ($move && defined(my $prev = $col_wide{$orig_focused})) {
	    $cmds->cmd_ign(hide_con($orig_focused));
	    normalise_ws_cols($prev);
	}

	my @keys = sorted_paper_ws();
	my $k = first { $keys[$_] == $focused_ws } 0..$#keys;
	if ($dir eq "next" && $k < $#keys || $dir eq "prev" && $k > 0) {
	    my @cmds = "workspace $dir";
	    $focused_ws = $keys[$dir eq "next" ? $k+1 : $k-1];
	    if ($move) {
		push @cmds, show_con($orig_focused);
		push @cmds, "move right"
		  if $col_rows{ $paper_ws{$focused_ws}{focused_col} };
		push @cmds, "focus child" if $col_rows{$orig_focused};
	    }
	    $cmds->cmd(@cmds);
	}
    }
}

# fresh_workspace(%opts)
#
# Switch to the next free workspace, if any.  Return the name of that
# workspace, or undef if no workspace was available.

sub fresh_workspace {
    my $next_free_workspace = compact_workspaces(leave_gap => 1);

    if ($next_free_workspace) {
	my @cmds;
	my %opts = @_;
	my $ws = $paper_ws{$focused_ws};
	my $to_send = $ws->{focused_col};

	# Abort if the container to send has rows, because otherwise Sway
	# subsumes the rows to the workspace upon arrival (see hide_con).
	return if $opts{send} && $col_rows{$to_send};

	# Special case: if we're about to leave a workspace empty by removing
	# its wide container, then that workspace will get an empty event, and
	# we'll lose track of any windows pushed off to its sides.
	if ($opts{send} && defined(my $prev = $col_wide{$to_send})) {
	    $cmds->cmd_ign(hide_con($to_send));
	    normalise_ws_cols($prev);
	}

	# 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-;
	# to move any other wanted containers over, before a final C-i ;.
	#
	# There is a relevant i3/Sway difference here:
	#     <https://github.com/swaywm/sway/issues/6081>.
	# (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($to_send, $next_free_workspace) if $opts{send};
	push @cmds, "workspace back_and_forth" unless $opts{go};

	$cmds->cmd(@cmds);
    }
    $next_free_workspace
}

# 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.

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 == $focused_ws
          and $gap_workspace = $all_workspaces[$i++];
	my $next_name = $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
    }

    $cmds->cmd_ign(map sprintf("rename workspace %s to %s",
			       $paper_ws{$_->[0]}{name}, $_->[1]),
		   @pairs);
    $paper_ws{$_->[0]}{name} = $_->[1] for @pairs;

    $opts{leave_gap} and $gap_workspace
}

# "I don't need this workspace's windows today, but might do again tomorrow."
# Bury to the left end of the list, not the right end, because we create fresh
# workspaces to the right.
sub bury_workspace {
    my @workspaces = sorted_paper_ws();
    1 < @workspaces < @all_workspaces or return;
    (my $k = first { $workspaces[$_] == $focused_ws } 0..$#workspaces) // die;
    $k > 0 or return;
    my $prev = $workspaces[$k-1];

    my @pairs;

    my $i = 0;
    unshift @pairs, [$workspaces[$i], $all_workspaces[++$i]]
      while $i < $k
      && $paper_ws{$workspaces[$i]}{name} eq $all_workspaces[$i];

    $cmds->cmd_ign("rename workspace to *bury-tmp*",
		   "workspace $paper_ws{$prev}{name}",
		   (map sprintf("rename workspace %s to %s",
				$paper_ws{$_->[0]}{name}, $_->[1]),
		    @pairs),
		   "rename workspace *bury-tmp* to 1");
    $paper_ws{$focused_ws}{name} = "1";
    $paper_ws{$_->[0]}{name} = $_->[1] for @pairs;
    $focused_ws = $prev;

    normalise_ws_cols();	# for the workspace switch
    $cmds->need_signal;
}

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 avail_from {
    my @off = shift->@* or return 0;
    widep($off[$#off]) and return -1;
    my $i = 0;
    $i++ until !@off || widep(pop @off);
    return $i;
}

sub sorted_paper_ws {
    sort { ws_num($paper_ws{$a}{name}) <=> ws_num($paper_ws{$b}{name}) }
      keys %paper_ws
}

sub signal_i3status { kill USR1 => $i3status if $i3status }

sub n_from_l {
    my ($ws, $n) = @_;
    my @cmds;
    debugsay "filling $n from left";

    my @pulled = splice $ws->{off_left}->@*, -$n, $n;
    my @to_pull = reverse @pulled;
    @to_pull = zip \@to_pull, [$ws->{cols}[0], @to_pull[0..$#to_pull-1]];

    for (@to_pull) {
	push @cmds, show_con(@$_[0]);
	next unless @$_[1];
	push @cmds, $col_rows{@$_[1]}
	  ? "move left"
	  : "swap container with con_id @$_[1]";
    }

    unshift $ws->{cols}->@*, @pulled;
    return @cmds;
}

sub n_from_r {
    my ($ws, $n) = @_;
    my @cmds;
    debugsay "filling $n from right";

    my @pulled = reverse splice $ws->{off_right}->@*, -$n, $n;
    my @to_pull = zip \@pulled, [$ws->{cols}[-1], @pulled[0..$#pulled-1]];

    for (@to_pull) {
	push @cmds, show_con(@$_[0]);
	push @cmds, "move right" if @$_[1] && $col_rows{@$_[1]};
    }

    push $ws->{cols}->@*, @pulled;
    return @cmds;
}

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.
    #
    # After enabling floating, resize the container to close to the size we
    # think it will have when unhidden.  This reduces observed flickering
    # because the application has already rearranged its contents before the
    # unfloat.  Our strategy is to use the last seen container width.  This is
    # based on the idea that moving along the row of windows is much more
    # frequent than opening and closing windows or changing ncols.
    #
    # There is still flickering with new containers are added to the row and
    # when containers in the row are closed.  What would seem to be going on
    # is that upon one of these events, other containers start resizing their
    # contents before we get a chance to push/pull containers.  We then do
    # that, and the other containers resize right back to where they were.
    sprintf +("[con_id=%s] ".join ", ",
	      "floating enable",
	      "resize set %dpx 100ppt",
	      "move container to workspace %s"),
		$_[0], $col_w{$_[0]}, "*$_[0]*";
}

sub show_con {
    my $on = $_[1] // $paper_ws{$focused_ws}{name};
    sprintf "[con_id=%s] %s", $_[0], join ", ",
      "move container to workspace $on",
      "floating disable",
      "focus";
}

sub ws_name {
    my ($before, $after) = split /:/, $_[0];
    $after // $before
}
sub ws_num { (split /:/, $_[0])[0] }

sub focused_col_idx {
    first { $_[0]->{cols}->[$_] == $_[0]->{focused_col} } 0..$#{$_[0]->{cols}}
}

sub widep { defined $col_wide{(shift)} }
