#!/usr/bin/perl
# PODNAME: papersway
# ABSTRACT: PaperWM-like scrollable tiling window management for Sway/i3wm
#
# Copyright (C) 2019-2026  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/>.


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(mkfifo);
use File::Basename qw(basename dirname);
use File::Spec::Functions qw(catfile);
use List::Util qw(first min max none zip);
use Data::Dumper;

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

# Ensure that if we die during a handler it makes it out.
if (AnyEvent::detect eq "AnyEvent::Impl::EV") {
    no warnings "once";
    $EV::DIED = sub { print STDERR $@; exit 255 };
}

my ($pipe, $i3status, $debug, $with_ws_cmd, $user_cols, $cols_min_width);

GetOptions
  'debug!'	     => \$debug,
  'i3status!'	     => \$i3status,
  'with-ws-cmd!'     => \$with_ws_cmd,
  'cols=i'	     => \$user_cols,
  'cols-min-width=i' => \$cols_min_width,
  or die "failed to parse command line options";

$user_cols && $cols_min_width
  and die "papersway: --cols incompatible with --cols-min-width\n";
unless ($cols_min_width) {
    $user_cols //= 2;
    $user_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;
	# Skip the version header and the start of the infinite array.
	# Block on this so that we know i3status is ready, such that a
	# SIGUSR1 won't kill it because it hasn't set its own handler yet.
	<$pipe>; <$pipe>;
    } else {
	$pipe->writer;
	open STDOUT, ">&=", $pipe->fileno
	  or die "couldn't re-open i3status's STDOUT: $!";
	exec "i3status" or die;
    }
}

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

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} // die}) {
	    $_[0]->($node // die);
	    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_normalise, @pending_msgs, @pending_resurrec, $pending_bandf);

(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_normalise {
    $pending_normalise++;
    $have_pending->send unless $ignore_events
}
$wmipc->subscribe({
    tick => sub {
	my $payload = shift->{payload} // die;
	if ($payload eq "papersway-ign") {
	    $ignore_events = 1;
	} elsif ($payload eq "papersway-unign") {
	    $ignore_events = 0;
	    $have_pending->send if $pending_normalise;
	}
    },

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

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

	# 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} // die);
	    queue_normalise if exists $paper_ws{$focused_ws};
	} elsif ($e->{change} && exists $paper_ws{$focused_ws}
		 && ($e->{change} eq "new" || !$ignore_events
		     && ($e->{change} eq "floating"
			 || $e->{change} eq "focus" || $e->{change} eq "move"
			 && $e->{container}
			 && ($e->{container}{type} // die) eq "con")))
	  { queue_normalise }
    },

    workspace => sub {
	my $e = shift; my $name;
	if (!$e->{change}) {
	    return;
	} elsif (!$ignore_events
		 && $e->{change} eq "focus" && $e->{current}) {
	    $last_focused_ws = $paper_ws{$focused_ws};
	    $focused_ws = $e->{current}{id} // die;
	    # 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_normalise;
	    } else {
		# Update status bar display.
		signal_i3status();
	    }
	} elsif (!$ignore_events
		 && $e->{change} eq "init" && $e->{current}
		 && ($name = $e->{current}{name} // die)
		 && grep $_ eq $name, @all_workspaces) {
	    init_ws(($e->{current}{id} // die), name => $name,
		    output => $e->{current}{output} // die);
	} elsif (!$ignore_events && $e->{change} eq "rename"
		 && exists $paper_ws{$e->{current}{id} // die}) {
	    $paper_ws{$e->{current}{id} // die}{name}
	      = $e->{current}{name} // die;
	    signal_i3status();
	} elsif ($e->{change} eq "move" && $e->{current}) {
	    $paper_ws{$e->{current}{id} // die}{output} =
	      $e->{current}{output} // die;
	} elsif ($e->{change} eq "empty" && $e->{current}) {
	    my $ws = delete $paper_ws{$e->{current}{id} // die}
	      or return;
	    if ($ws->{off_left}->@* || $ws->{off_right}->@*) {
		push @pending_resurrec, $ws;
		queue_normalise;
	    } else {
		signal_i3status();
	    }
	}
    },

    # Update values in %output_init_cols and $paper_ws{*}{output} values.
    output => \&queue_normalise
})->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.
my @old_ids;
for_each_node {
    my $node = shift;
    my $type = $node->{type} // die;
    if ($type eq "output") {
	sync_output($node);
    } elsif ($type eq "workspace") {
	my $name = $node->{name} // die;
	if (grep $_ eq $name, @all_workspaces) {
	    my $entry = init_ws(($node->{id} // die), name => $name,
				output => $node->{output} // die);
	    sync_cols($node => $entry);
	} elsif ($name =~ /\A\*(\d+)\*\z/) {
	    # Under i3, only, there is an extra container wrapping each
	    # floating container, and it's the inner container that's floating
	    # and so upon which we are able to call 'floating disable'.
	    push @old_ids,
	      map +($have_sway ? $_->{id} : $_->{nodes}[0]{id}) // die,
	      @{$node->{floating_nodes} // die};
	}
    } elsif (grep $_ eq "caffeinated", @{$node->{marks} // die}) {
	register_caffeinated($node);
    }
};
while (my ($id, $entry) = each %paper_ws) {
    $entry->{ncols} = max $output_init_cols{$entry->{output}},
      scalar $entry->{cols}->@*;
}
for (@{$wmipc->get_workspaces->recv // die}) {
    $_->{focused} or next;
    $focused_ws = $_->{id} // die;
    $last_focused_ws = $paper_ws{$focused_ws};
    last;
}
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 && !$with_ws_cmd;
$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 };

my $i3status_wrapper;
if ($i3status) {
    # Print our own version header and start of infinite array.
    print encode_json({ version => 1, click_events => JSON::false,
			# Ask not to be sent these signals.
			stop_signal => 0, cont_signal => 0 })
      . "\n[\n";
    $i3status_wrapper = AnyEvent->io(
	fh => $pipe, poll => "r", cb => sub {
	    my ($statusline, $blocks);

	    unless (!eof $pipe
		    && (($statusline) = (<$pipe> =~ /^,?(.+)/))
		    && defined ($blocks = eval { decode_json $statusline })) {
		print encode_json([{ markup => "pango", full_text =>
'<span color="#FF0000">papersway: i3status output EOF or parse error</span>'
				 }]) . ",\n";
		undef $i3status_wrapper;
		return;
	    }

	    if ($focused_ws && !$with_ws_cmd && 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;

  PENDING_RESURREC: while (my $ws = shift @pending_resurrec) {
	# Pull on a single container in order to resurrect the workspace.
	# Leave it to normalise_ws_cols() when we next switch to that
	# workspace to pull in any others.
	update_hidden($ws);
	my $pulled = $ws->{off_left}->@*
	  ? pop $ws->{off_left}->@* : pop $ws->{off_right}->@*
	  or next;
	$cmds->cmd_ign(sprintf "[con_id=%s] %s", $pulled, join ", ",
		       "move container to workspace $ws->{name}",
		       "floating disable");
	$pending_bandf++;
	$cmds->flush;
	for (@{$wmipc->get_workspaces->recv // die}) {
	    next unless $_->{name} eq $ws->{name};
	    @{$ws}{qw(id focused_col cols)}
	      = (($_->{id} // die), $pulled, [$pulled]);
	    $paper_ws{$_->{id}} = $ws;
	    $col_w{$pulled} = $_->{rect}{width} // die;
	    next PENDING_RESURREC;
	}
	die;
    }

    if ($pending_normalise) {
	$pending_normalise = 0;
	# 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 "{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{";
	debugdump [\%paper_ws], ["*paper_ws BEFORE"];
	normalise_ws_cols();
	$cmds->need_signal;
	debugdump [\%paper_ws], ["*paper_ws  AFTER"];
	debugsay "}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}";
    }

    process_msg(shift @pending_msgs) while @pending_msgs;

    # With i3 (but not Sway), 'move container to workspace foo' as done by
    # hide_con() means that a subsequent 'workspace back_and_forth' will
    # switch to workspace foo, but we don't ever want to switch to the hidden
    # workspaces.  These two commands should ensure that any subsequent
    # 'workspace back_and_forth' will always switch to $last_focused_ws
    # (and they are harmless under Sway).
    # See also <https://github.com/swaywm/sway/issues/6081>.
    if ($pending_bandf) {
	if (!$debug && $have_sway) {
	    $pending_bandf = 0;
	} elsif (
	    # Skip if no columns: switching away would destroy the workspace.
	    exists $paper_ws{$focused_ws} && $paper_ws{$focused_ws}{cols}->@*
	) {
	    my $name;
	    my %used_ws = map +($paper_ws{$_}{name}, 1), keys %paper_ws;
	    # See if current $last_focused_ws is usable for this purpose.
	    # If it no longer exists, that's okay so long as its name has not
	    # been reused by, e.g., compact_workspaces.
	    if ($last_focused_ws
		&& $last_focused_ws->{id} != $focused_ws
		&& (exists $paper_ws{$last_focused_ws->{id}}
		    || !$used_ws{$last_focused_ws->{name}})) {
		$name = $last_focused_ws->{name};
	    } else {
		# Current $last_focused_ws not usable, try changing it.
		# Use sorted_paper_ws() because we prefer a workspace to
		# the left of $focused_ws here.  Don't accept a workspace
		# on another output because otherwise the commands would
		# change the workspace displayed on that output.
		my @all_ws = grep $paper_ws{$_}{output}
		  eq $paper_ws{$focused_ws}{output}, sorted_paper_ws();
		if (@all_ws >= 2) {
		    (my $k = first { $all_ws[$_] == $focused_ws } 0..$#all_ws)
		      // die;
		    $last_focused_ws = $paper_ws{$all_ws[$k ? $k-1 : 1]};
		    $name = $last_focused_ws->{name};
		} else {
		    # No usable value for $last_focused_ws.  Leave it undef,
		    # see if there is some usable unused workspace name.
		    undef $last_focused_ws;
		    $name = first { !$used_ws{$_} } @all_workspaces;
		}
	    }
	    if ($name) {
		$cmds->cmd_ign("workspace $name",
			       "workspace $paper_ws{$focused_ws}{name}");
		$pending_bandf = 0;
	    }
	}
    }

    $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'.

    delete $entry->{focused_col}; $entry->{cols} = [];
    my @nodes = grep $_->{id} // die, @{$node->{nodes} // die};

    foreach my $child_id (@{$node->{focus} // die}) {
	die unless defined $child_id;
	# If this child is floating it won't appear in @nodes.
	next unless defined
	  (my $child_node = first { $_->{id} == $child_id } @nodes);
	$entry->{focused_col} = $child_id, last
	  if ($child_node->{type} // die) eq "con";
    }
    foreach my $child_node (@nodes) {
	next unless ($child_node->{type} // die) eq "con";
	push $entry->{cols}->@*, $child_node->{id};
	$col_rows{$child_node->{id}} = @{$child_node->{nodes} // die};
	$col_w{$child_node->{id}} = $child_node->{rect}{width} // die;
    }

    !$entry->{cols}->@* || $entry->{focused_col} || die;
}

sub sync_output {
    my $node = shift;
    $output_init_cols{$node->{name} // die} = $user_cols
      // max 2, int +($node->{rect}{width} // die)/$cols_min_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 = $ws->{focused_col} && widep($ws->{focused_col});
    for_each_node {
	my $node = shift;
	if (($node->{type} // die) eq "output") {
	    sync_output($node);
	} elsif (($node->{id} // die) == $focused_ws) {
	    sync_cols($node => $ws);
	    $ws->{output} = $node->{output} // die;
	    my $first_focus = ($node->{focus} // die)->[0];
	    $floating_focus = $first_focus && none { $_ == $first_focus }
	      $ws->{cols}->@*;
	    goto DONE;
	}
    };
  DONE:
    update_hidden($ws);
    my $cols = $ws->{cols};
    my $empty = !@$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 && $empty;

    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 = $ws->{focused_col} && 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;
	# Update the position of the focused container for the pulling code.
	$i -= $left;

	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 (($empty || !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's index either moves left
	# or stays the same.  So always $old_i >= $i.
	# (... unless the focused column was one of the ones that closed!
	#  We don't know whether that might have happened and this algorithm
	#  doesn't try to do a smart thing for that case.  Fortunately it's
	#  the less important case to try to make good choices for.  I.e.,
	#  doing something good when the focused column didn't close is more
	#  important.)
	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 abs($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.
	if ($avail_l == -1) {
	    if (@$cols || $from_r) {
		$from_l = 0;
	    } elsif ($from_l) {
		$from_r = 0;
	    }
	}
	if ($avail_r == -1) {
	    if (@$cols || $from_l) {
		$from_r = 0;
	    } elsif ($from_r) {
		$from_l = 0;
	    }
	}

	my $j = $i;
	if ($from_l //= min abs($avail_l), $want-$from_r) {
	    push @cmds, ("focus left")x(@$cols > 1 && $j),
	      n_from_l($ws, $from_l);
	    $j = 0;
	}
	if ($from_r //= min abs($avail_r), $want-$from_l) {
	    push @cmds, ("focus right")x(@$cols > 1 && $#$cols-$j),
	      n_from_r($ws, $from_r);
	    $j = $#$cols;
	}
	if ($from_l || $from_r) {
	    # The most important thing is to restore the user's focused
	    # container when that container was not among the ones that
	    # closed.  In that case, $i was the index of that container before
	    # pulling, so after pulling, $i+$from_l gives its new index.
	    # So in the case that the workspace was not empty we assume the
	    # user's focused container didn't close and target $i+$from_l.
	    #
	    # If the workspace was empty we assume that the user's container
	    # is gone, and target the first container pulled from the left,
	    # which can be thought of as the lost container's neighbour.
	    # Fall back to the first container pulled from the right.
	    my $new_i = $empty
	      ? ($from_l ? $from_l-1 : 0)
	      : $i+$from_l;

	    if ($j > $new_i) {
		push @cmds, ("focus left")x($j-$new_i);
	    } elsif ($new_i > $j) {
		push @cmds, ("focus right")x($new_i-$j);
	    }

	    $ws->{focused_col} = $cols->[$new_i];
	    push @cmds, "focus child" if $col_rows{$ws->{focused_col}};
	}
    }

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

{
    # Simple class to batch up commands before sending them.
    package App::papersway::cmds;
$App::papersway::cmds::VERSION = '3.001';
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;

    return unless exists $paper_ws{$focused_ws};
    my $ws = $paper_ws{$focused_ws};
    my $cols = $ws->{cols} // die;
    my $i = focused_col_idx($ws);

    my $sent_focus_tiling;
    my $mv = sub {
	return unless @$cols;
	my ($j, $move) = @_;
	$ws->{focused_col} // die;
	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(
		    $sent_focus_tiling++ ? () : "focus tiling",
		                 $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"}) > 0) {
	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 (@$cols && $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-column$/) {
	@$cols == 1 && $ws->{off_left}->@* == $ws->{off_right}->@* == 0
	  || fresh_workspace(go => 1, send => 1) and bury_workspace();
    } elsif ($cmd =~ /^bury-workspace$/) {
	bury_workspace();
    } elsif (@$cols && $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("focus tiling", 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, "focus tiling";
		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)_on_output$/) {
	my ($move, $dir) = (!!$1, $2);
	return if $move && !@$cols;
	my $orig_focused = $ws->{focused_col};
	$orig_focused // die if $move;

	# If we need to move the only visible container, hide it and normalise
	# first.  This ensures we don't leave this workspace empty.
	if ($move && @$cols == 1
	    && ($ws->{off_left}->@* || $ws->{off_right}->@*)) {
	    $cmds->cmd_ign(hide_con($orig_focused));
	    normalise_ws_cols($col_wide{$orig_focused});
	}

	my @keys = grep $paper_ws{$_}{output} eq $ws->{output},
	  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}_on_output";
	    $last_focused_ws = $paper_ws{$focused_ws};
	    $focused_ws = $keys[$dir eq "next" ? $k+1 : $k-1];
	    if ($move) {
		push @cmds, show_con($orig_focused);
		push @cmds, "move right"
		  if defined $paper_ws{$focused_ws}{focused_col}
		  && $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 %opts = @_;
	my $ws = $paper_ws{$focused_ws};
	my $cols = $ws->{cols};
	return unless @$cols;
	my $do_early_hide = $opts{send} && @$cols == 1;
	return if $do_early_hide
	  && !@{$ws->{off_left}} && !@{$ws->{off_right}};
	my $to_send = $ws->{focused_col} // die;

	# 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 only container, then that workspace will get an empty event.
	# We'd resurrect it, but it's better to avoid that.
	if ($do_early_hide) {
	    $cmds->cmd_ign(hide_con($to_send));
	    normalise_ws_cols($col_wide{$to_send});
	}

	# We 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 ;.
	#
	# This is implemented in such a way as to avoid running into a
	# difference in behaviour between i3 and Sway: with i3 but not Sway,
	# 'move container to workspace foo' means that a subsequent
	# 'workspace back_and_forth' will switch to workspace foo.
	# See <https://github.com/swaywm/sway/issues/6081>.
	#
	# What we would really like to do here is a for_each_node that
	# calls sync_cols() on the new workspace's entry.  However, only
	# get_workspaces can tell us the currently focused workspace.
	# To avoid doing both get_workspaces and a for_each_node, we do
	# a poor man's sync_cols(), updating %col_w within the loop and
	# then $paper_ws{$new_ws}->%* afterwards.

	$cmds->cmd_ign("workspace $next_free_workspace");
	$cmds->flush;
	my $new_ws;
	for (@{$wmipc->get_workspaces->recv // die}) {
	    next unless $_->{focused};
	    init_ws(($new_ws = $_->{id} // die),
		    (name => $_->{name} // die),
		    output => $ws->{output});
	    $col_w{$to_send} = $_->{rect}{width} // die if $opts{send};
	    last;
	}
	$new_ws // die;
	if ($opts{send}) {
	    $cmds->cmd_ign(show_con($to_send, $next_free_workspace));
	    $paper_ws{$new_ws}{cols} = [$to_send];
	    $paper_ws{$new_ws}{focused_col} = $to_send;
	}
	if ($opts{go}) {
	    $last_focused_ws = $paper_ws{$focused_ws};
	    $focused_ws = $new_ws;
	} else {
	    $cmds->cmd_ign("workspace back_and_forth");
	    $last_focused_ws = $paper_ws{$new_ws};
	    normalise_ws_cols() unless $do_early_hide;
	}
	$cmds->need_signal;
    }
    $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 @all_ws = sorted_paper_ws();
    (my $k = first { $all_ws[$_] == $focused_ws } 0..$#all_ws) // die;
    $k > 0 or return;
    my $o = $paper_ws{$focused_ws}{output};
    my @output_ws = grep $paper_ws{$_}{output} eq $o, @all_ws[0..$k-1];
    0 < @output_ws < @all_ws < @all_workspaces or return;
    my $to_bury = $focused_ws;
    $focused_ws = pop @output_ws;

    my @pairs;

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

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

    # We need a distinct $last_focused_ws in order that we can fix 'workspace
    # back_and_forth' post-burying because Sway and i3 track the most recent
    # other workspace by name, not ID.  If $last_focused_ws still exists and
    # is distinct from both the focused workspace and the workspace we just
    # buried, we can use it.  If it no longer exists but we didn't rename
    # anything else into that slot, that's fine too.  Otherwise, we have to
    # assign a new $last_focused_ws that is hopefully an intuitive choice.
    my $name;
    my %used_ws = map +($paper_ws{$_}{name}, 1), keys %paper_ws;
    if ($last_focused_ws
	&& $last_focused_ws->{id} != $focused_ws
	&& $last_focused_ws->{id} != $to_bury
	&& (exists $paper_ws{$last_focused_ws->{id}}
	    || !$used_ws{$last_focused_ws->{name}})) {
	$name = $last_focused_ws->{name};
    } elsif (defined (my $id = pop @output_ws
		      // (first { $paper_ws{$_}{output} eq $o }
			  @all_ws[$k+1..$#all_ws]))) {
	$last_focused_ws = $paper_ws{$id};
	$name = $last_focused_ws->{name};
    } else {
	$name = first { !$used_ws{$_} } @all_workspaces;
    }

    $cmds->cmd_ign(@cmds,
		   "workspace $name",
		   "workspace $paper_ws{$focused_ws}{name}");

    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} // die) == $node_id) {
	    $child_id = $node->{nodes}[0]{id} // die;
	    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.
    $pending_bandf++;
    (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";
}

{
    # Class representing entries in %paper_ws.
    package App::papersway::paper_ws;
$App::papersway::paper_ws::VERSION = '3.001';
    # We can't always populate this value when a workspace is initialised
    # because we might not know the output's width yet.  This happens when
    # initialising a new workspace on a newly enabled output.
    sub ncols {
	my $ws = shift;
	$ws->{ncols} //= $output_init_cols{$ws->{output}}
    }
}

sub init_ws {
    my $id = shift;
    bless +($paper_ws{$id} = { id => $id,
			       cols => [], off_left => [], off_right => [],
			       last_dir => 1, @_ }
	) => "App::papersway::paper_ws"
}

sub update_hidden {
    my $ws = shift;
    my %still_hidden = map +($_, 1),
      grep defined, map $_->{name} =~ /\A\*(\d+)\*\z/,
      grep $_->{name} // die, @{$wmipc->get_workspaces->recv // die};
    debugdump [\%still_hidden], ["*still_hidden"];
    $ws->{off_left}  = [ grep $still_hidden{$_}, $ws->{off_left}->@*  ];
    $ws->{off_right} = [ grep $still_hidden{$_}, $ws->{off_right}->@* ];
}

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

sub focused_col_idx {
    my $ws = shift;
    $ws->{focused_col}
      ? first { $ws->{cols}[$_] == $ws->{focused_col} } 0..$#{$ws->{cols}}
      : 0
}

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

__END__

=pod

=encoding UTF-8

=head1 NAME

papersway - PaperWM-like scrollable tiling window management for Sway/i3wm

=head1 VERSION

version 3.001

=head1 SYNOPSIS

B<papersway> [B<--i3status>] [B<--cols=>I<N>|B<--cols-min-width=>I<N>] [B<--with-ws-cmd>] [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.
Incompatible with I<--cols-min-width>.

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

Set the number of columns on a new workspace to be the maximum that will fit
while maintaining a column width of at least I<N> pixels (with Sway,
implicitly scaled by the output's scale factor).  Incompatible with I<--cols>.

=item B<--with-ws-cmd>

Don't hide the workspace buttons, and when I<--i3status>, don't include
information about workspaces in the status output.

=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.
For Sway,

=over 4

    bar {
        status_command papersway --i3status

        # [ .. further bar options .. ]
    }

=back

and for i3,

=over 4

    bar {
        status_command papersway --i3status --with-ws-cmd
        workspace_command papersway-ws-cmd

        # [ .. 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+Shift+b exec papersway-msg bury-column

    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_on_output
    bindsym $mod+i exec papersway-msg workspace next_on_output
    bindsym $mod+Shift+u exec papersway-msg move-to-workspace prev_on_output
    bindsym $mod+Shift+i exec papersway-msg move-to-workspace next_on_output

    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

Set I<default_orientation> to I<horizontal>.

=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)

=head1 AUTHOR

Sean Whitton <spwhitton@spwhitton.name>

=head1 COPYRIGHT AND LICENSE

This software is Copyright (c) 2019-2026 by Sean Whitton <spwhitton@spwhitton.name>.

This is free software, licensed under:

  The GNU General Public License, Version 3, June 2007

=cut
