#!/usr/bin/perl
#
# 0wx-gtk.pl -- a small GTK window for the 0wx API.
#
#     ./0wx-gtk.pl
#
# Unlike the other clients in this directory, this one is NOT core-only: it
# needs the Perl GTK3 bindings.
#
#     Debian/Ubuntu   apt install libgtk3-perl
#     Fedora          dnf install perl-Gtk3
#     Arch            pacman -S gtk3-perl
#
# Everything else it uses -- HTTP::Tiny, JSON::PP -- ships with Perl, and the
# code that talks to the API is deliberately the same shape as in 0wx.pl, so
# the two can be read against each other.
#
# The window has one tab per thing you can do. Nothing is hidden: every
# action here is one you could run from the command line client, and the
# reply is shown as the server sent it as well as summarised.

use strict;
use warnings;

use Gtk3 -init;
use HTTP::Tiny ();
use JSON::PP   ();
use File::Basename qw(basename);

# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
# Read from the environment at startup, and editable in the Settings tab.
# The key is never written to disk by this program: closing the window
# forgets it, which is the behaviour to expect from something that did not
# ask permission to store a credential.
my %CFG = (
    url => $ENV{OWX_URL} // 'https://0wx.es/api.cgi',
    key => $ENV{OWX_KEY} // '',
);

# ---------------------------------------------------------------------------
# Talking to the API
# ---------------------------------------------------------------------------
# Same shape as 0wx.pl. Returns ( $data, $error ): exactly one is defined,
# so a caller cannot forget to check.
sub form_escape {
    my ($s) = @_;
    $s = '' unless defined $s;
    utf8::encode($s) if utf8::is_utf8($s);
    $s =~ s/([^A-Za-z0-9._~-])/sprintf '%%%02X', ord $1/ge;
    return $s;
}

sub multipart {
    my ( $fields, $files ) = @_;

    my $boundary = '----0wx'
        . unpack( 'H*', join '', map { chr int rand 256 } 1 .. 16 );
    my $body = '';

    for my $name ( sort keys %$fields ) {
        for my $value ( @{ $fields->{$name} } ) {
            my $v = $value;
            utf8::encode($v) if utf8::is_utf8($v);
            $body .= "--$boundary\r\n"
                   . qq(Content-Disposition: form-data; name="$name"\r\n\r\n)
                   . "$v\r\n";
        }
    }

    for my $path (@$files) {
        open my $fh, '<:raw', $path or return ( undef, "cannot read $path: $!" );
        my $blob = do { local $/; <$fh> };
        close $fh;

        ( my $name = basename($path) ) =~ s/"//g;
        utf8::encode($name) if utf8::is_utf8($name);

        $body .= "--$boundary\r\n"
               . qq(Content-Disposition: form-data; name="file"; filename="$name"\r\n)
               . "Content-Type: application/octet-stream\r\n\r\n"
               . $blob . "\r\n";
    }

    $body .= "--$boundary--\r\n";
    return ( $body, "multipart/form-data; boundary=$boundary" );
}

sub api {
    my ( $action, $fields, $files ) = @_;
    $fields ||= {};
    $files  ||= [];

    # Drop empty values rather than sending them: an empty --gallery means
    # "no gallery" to the API, and a blank text box should mean the same
    # thing as an untouched one.
    my %all = ( action => [$action] );
    for my $name ( keys %$fields ) {
        my @values = grep { defined && length } @{ $fields->{$name} };
        $all{$name} = \@values if @values;
    }

    my ( $body, $type );
    if (@$files) {
        ( $body, $type ) = multipart( \%all, $files );
        return ( undef, $type ) unless defined $body;    # read failure
    }
    else {
        $body = join '&', map {
            my $name = $_;
            map { form_escape($name) . '=' . form_escape($_) } @{ $all{$name} };
        } sort keys %all;
        $type = 'application/x-www-form-urlencoded';
    }

    my %headers = ( 'Content-Type' => $type );
    $headers{Authorization} = "Bearer $CFG{key}" if length $CFG{key};

    my $res = HTTP::Tiny->new( agent => '0wx-gtk.pl', timeout => 60 )
        ->request( 'POST', $CFG{url},
                   { content => $body, headers => \%headers } );

    # 599 is HTTP::Tiny's marker for "never got an answer", not a real HTTP
    # status, and its content is a plain-text message rather than JSON.
    # Decoding it would report a parse error instead of the real problem.
    if ( $res->{status} == 599 ) {
        my $why = $res->{content} // $res->{reason} // 'unknown error';
        $why =~ s/\s+$//;
        return ( undef, "could not reach $CFG{url}: $why" );
    }

    my $data = eval { JSON::PP->new->utf8->decode( $res->{content} ) };
    return ( undef, "the server did not return JSON:\n"
                  . substr( $res->{content} // '', 0, 500 ) )
        unless ref $data eq 'HASH';

    return ( $data, undef );
}

# Fetch one thumbnail and turn it into a pixbuf.
#
# Returns undef rather than dying on anything that goes wrong: a thumbnail
# is decoration, and a gallery that refuses to draw because one image is
# missing is worse than one with a gap in it.
my %THUMB_CACHE;

sub thumbnail {
    my ( $url, $size ) = @_;
    return undef unless defined $url && length $url;

    # Cached by URL. A share name never points at different content -- the
    # server mints a new one instead -- so this can be kept for the life of
    # the window without going stale.
    return $THUMB_CACHE{$url} if exists $THUMB_CACHE{$url};

    my $res = HTTP::Tiny->new( agent => '0wx-gtk.pl', timeout => 20 )
        ->get($url);

    return $THUMB_CACHE{$url} = undef
        unless $res->{success} && length( $res->{content} // '' );

    my $pixbuf = eval {
        my $loader = Gtk3::Gdk::PixbufLoader->new;
        $loader->write( [ unpack 'C*', $res->{content} ] );
        $loader->close;
        $loader->get_pixbuf;
    };
    return $THUMB_CACHE{$url} = undef unless $pixbuf;

    # Scaled to fit a square, keeping the proportions -- a stretched
    # thumbnail is harder to recognise than a small one.
    my ( $w, $h ) = ( $pixbuf->get_width, $pixbuf->get_height );
    if ( $w > $size || $h > $size ) {
        my $scale = $w > $h ? $size / $w : $size / $h;
        $pixbuf = $pixbuf->scale_simple( int( $w * $scale ) || 1,
                                         int( $h * $scale ) || 1,
                                         'bilinear' );
    }

    return $THUMB_CACHE{$url} = $pixbuf;
}

# ---------------------------------------------------------------------------
# Formatting
# ---------------------------------------------------------------------------
# Binary units, matching OWX::Util::human_bytes on the server.
sub human_bytes {
    my ($n) = @_;
    return '?' unless defined $n && $n =~ /^\d+$/;
    return '0 B' unless $n > 0;

    my @unit = qw(B KiB MiB GiB TiB);
    my ( $v, $i ) = ( $n, 0 );
    while ( $v >= 1024 && $i < $#unit ) { $v /= 1024; $i++ }
    return $i == 0 ? "$n B" : sprintf( '%.1f %s', $v, $unit[$i] );
}

# A field of a reply, with a fallback for missing OR null. A JSON null
# decodes to undef, and printing that warns.
sub at {
    my ( $row, $name, $fallback ) = @_;
    $fallback = '?' unless defined $fallback;
    my $v = $row->{$name};
    return ( defined $v && $v ne '' ) ? $v : $fallback;
}

# ---------------------------------------------------------------------------
# The window
# ---------------------------------------------------------------------------
my $WINDOW = Gtk3::Window->new('toplevel');
$WINDOW->set_title('0wx');
$WINDOW->set_default_size( 760, 560 );
$WINDOW->signal_connect( destroy => sub { Gtk3::main_quit() } );

# Draw the selection ourselves.
#
# GTK marks every selected child with the "selected" state -- verified -- but
# whether that state is VISIBLE is the theme's business, and several themes
# style only the focused child. The result is a grid where the first click
# highlights and the rest do not, while the selection underneath is
# perfectly correct: right and unreadable.
#
# So the highlight is stated here rather than hoped for. @define-color picks
# up the theme's own accent where it has one, so this follows the desktop
# instead of imposing a colour on it.
{
    my $css = Gtk3::CssProvider->new;

    my $ok = eval {
        $css->load_from_data( <<'STYLE' );
@define-color owx_pick @theme_selected_bg_color;

/* Room for the highlight to be seen.
 *
 * The default is 3px, and a 3px band around a 128px thumbnail is a sliver
 * nobody notices -- which is why the selection looked as though it had not
 * taken. 6px of padding and a 2px border give it something to show. */
flowbox flowboxchild {
    padding: 6px;
    border: 2px solid transparent;
    border-radius: 4px;
}

flowbox flowboxchild:selected {
    background-color: @owx_pick;
    border-color: @owx_pick;
}

flowbox flowboxchild:selected label {
    color: @theme_selected_fg_color;
}
STYLE
        1;
    };

    if ($ok) {
        Gtk3::StyleContext::add_provider_for_screen(
            Gtk3::Gdk::Screen::get_default(), $css,
            600 );    # above the theme, below anything the user sets
    }
    else {
        # A stylesheet failing to parse is not worth stopping for: the
        # window still works, the selection is merely harder to see.
        warn "0wx-gtk.pl: could not load the selection style\n";
    }
}

my $NOTEBOOK = Gtk3::Notebook->new;
my $STATUS   = Gtk3::Label->new('Ready.');
$STATUS->set_xalign(0);
$STATUS->set_selectable(1);

# The raw reply, always available. A GUI that only shows its own summary
# leaves you unable to see what the server actually said, which is the first
# thing you want when something looks wrong.
my $RAW = Gtk3::TextView->new;
$RAW->set_editable(0);
$RAW->set_monospace(1);

sub say_status {
    my ($text) = @_;
    $STATUS->set_text($text);
}

# Open a URL in whatever the desktop uses for one.
#
# http and https ONLY. The URL comes from the server, and show_uri hands
# whatever it is to the desktop's URI handler -- which will act on
# file:///etc/passwd, smb://somewhere, mailto: and anything else registered.
# A hostile or compromised server could put one of those in a "url" field,
# and the click would look like the user's own doing.
#
# Refusing everything else costs a line and removes the whole class. The
# check belongs here rather than in the server because this is the part
# running on the user's machine.
sub open_url {
    my ($url) = @_;

    unless ( defined $url && $url =~ m{^https?://}i ) {
        say_status( 'Refusing to open that link: only http and https are '
                  . 'opened, and this one is ' . ( $url // '(empty)' ) );
        return 0;
    }

    my $ok = eval { Gtk3::show_uri_on_window( $WINDOW, $url, 0 ); 1 };
    say_status( $ok ? "Opened $url" : "Could not open $url" );
    return $ok ? 1 : 0;
}

# Ask for one line of text. Returns the string, or undef if cancelled --
# which is different from an empty string, and callers rely on that: empty
# means "no gallery", cancelled means "do nothing at all".
sub ask_text {
    my ( $title, $prompt, $preset ) = @_;

    my $dialog = Gtk3::Dialog->new_with_buttons(
        $title, $WINDOW, 'modal',
        'Cancel' => 'cancel',
        'OK'     => 'ok' );

    my $entry = Gtk3::Entry->new;
    $entry->set_text( $preset // '' );
    $entry->set_activates_default(1);
    $dialog->set_default_response('ok');

    my $area = $dialog->get_content_area;
    $area->set_border_width(9);
    $area->set_spacing(6);
    $area->add( Gtk3::Label->new($prompt) );
    $area->add($entry);
    $dialog->show_all;

    my $response = $dialog->run;
    my $text     = $entry->get_text;
    $dialog->destroy;

    return $response eq 'ok' ? $text : undef;
}

# Yes or no, defaulting to no.
#
# The destructive answer is not the default: pressing Enter on a dialog you
# have not read should not delete anything.
sub confirm {
    my ($question) = @_;

    my $dialog = Gtk3::MessageDialog->new(
        $WINDOW, 'modal', 'question', 'none', '%s', $question );
    $dialog->add_button( 'Cancel', 'cancel' );
    $dialog->add_button( 'Delete', 'ok' );
    $dialog->set_default_response('cancel');

    my $response = $dialog->run;
    $dialog->destroy;

    return $response eq 'ok';
}

# The files the gallery grid is currently showing, in cell order. The
# FlowBox reports its children by index, so this is how a selection turns
# back into a list of shares.
#
# "our" rather than "my" so the test suite can read it: a lexical here is
# invisible to anything that loads this file, and the test was silently
# reading a different, empty variable.
our @SHOWN;

# Galleries the account has, as last reported. Declared here rather than
# beside the Gallery tab because context_menu() below reads it, and a "my"
# declared later in the file is not visible to code above it.
my @KNOWN_GALLERIES;

# Unwrap a Gtk3 getter that returns a GList.
#
# Introspected bindings hand a GList back as an array REFERENCE, while the
# method names are plural and Perl's own wrappers return lists. That
# mismatch produced ARRAY(0x...) once already, from get_filenames.
#
# get_selected_children is the second one, so this is a pattern rather than
# a quirk of one call, and it is worth having in one place.
sub glist {
    my ($value) = @_;
    return ()      unless defined $value;
    return @$value if ref $value eq 'ARRAY';
    return ($value);
}

# Put text on the clipboard.
#
# One place, because getting the atom wrong puts it on the X selection
# instead -- which pastes with a middle click and not with Ctrl-V, and looks
# like nothing happened.
sub clip {
    my ($text) = @_;
    return unless defined $text && length $text;
    Gtk3::Clipboard::get( Gtk3::Gdk::Atom::intern( 'CLIPBOARD', 0 ) )
        ->set_text( $text, -1 );
    return 1;
}

# share -> url for whatever the gallery is showing, so the menu can copy an
# address without being handed one for every item it might build.
my %URL_OF;

# How many files the server accepts in one upload.
#
# whoami reports it as limits.files_per_call, and pressing "Test the key" on
# the Settings tab updates this. Until then it is the value the server
# currently ships with -- a guess that is only ever used to stop the user
# picking too many, and the server refuses anything over its real limit
# regardless, so being wrong here is an inconvenience rather than a fault.
my $FILES_PER_CALL = 6;

# Ask what "delete" means for a gallery.
#
# Two different destructive acts share one word, and they are not close: one
# ungroups files, the other destroys them. Three buttons rather than a
# checkbox, so the choice is made by which one is pressed and cannot be
# missed. Returns 'gallery', 'everything', or undef for cancel.
sub confirm_gallery_delete {
    my ( $name, $count ) = @_;

    my $dialog = Gtk3::MessageDialog->new(
        $WINDOW, 'modal', 'question', 'none',
        '%s', "Delete the gallery \"$name\"?" );

    $dialog->format_secondary_text(
        $count
        ? "It holds $count file(s).\n\n"
        . "\"Gallery only\" keeps the files and returns them to the main "
        . "listing.\n\n"
        . "\"Gallery and files\" deletes them as well. That cannot be undone."
        : 'It is empty.' );

    $dialog->add_button( 'Cancel', 'cancel' );
    $dialog->add_button( 'Gallery only', 'no' );
    $dialog->add_button( 'Gallery and files', 'ok' ) if $count;
    $dialog->set_default_response('cancel');

    my $response = $dialog->run;
    $dialog->destroy;

    return $response eq 'ok' ? 'everything'
         : $response eq 'no' ? 'gallery'
         :                     undef;
}

# The right-click menu for a row in one of the list tabs.
#
# $spec says what the row is:
#   url      sub returning the address to copy, or undef
#   label    sub returning something to call it in a message
#   delete   sub returning the parameters that delete it
#   publish  true for galleries, which can also be published
sub row_menu {
    my ( $row, $event, $reload, $spec ) = @_;

    my $menu  = Gtk3::Menu->new;
    my $label = $spec->{label}->($row);

    # --- copy ---
    my $url  = $spec->{url}->($row);
    my $copy = Gtk3::MenuItem->new_with_label('Copy the link');
    $copy->set_sensitive( defined $url && length $url );
    $copy->signal_connect( activate => sub {
        clip($url);
        say_status("Copied $url");
    } );
    $menu->append($copy);

    # --- publish, galleries only ---
    if ( $spec->{publish} ) {
        my $public = $row->{published} ? 1 : 0;
        my $item   = Gtk3::MenuItem->new_with_label(
            $public ? 'Withdraw (make private)' : 'Publish' );

        $item->signal_connect( activate => sub {
            run( 'gallery',
                 { g => [ $row->{id} ], publish => [ $public ? 'false' : 'true' ] },
                 [],
                 sub {
                     my ($d) = @_;
                     $d->{published}
                         ? 'Published: ' . at( $d, 'url' )
                         : "\"$label\" is private again. The old link no "
                         . 'longer works.';
                 } );

            # The new link is worth having to hand, since publishing is
            # done in order to send it to somebody.
            clip( $row->{url} ) if !$public;
            $reload->() if $reload;
        } );
        $menu->append($item);
    }

    $menu->append( Gtk3::SeparatorMenuItem->new );

    # --- delete ---
    my $delete = Gtk3::MenuItem->new_with_label('Delete');
    $delete->signal_connect( activate => sub {
        my $params = $spec->{delete}->( $row, $label );
        return unless $params;          # cancelled in the dialog

        run( 'delete', $params, [], sub { "Deleted $label." } );
        $reload->() if $reload;
    } );
    $menu->append($delete);

    $menu->show_all;
    $menu->popup_at_pointer($event) if $event;
    return $menu;
}

# The right-click menu for the gallery grid.
#
# Takes a LIST of files, because the grid selects like a file manager and a
# menu that only ever acted on one would make the selection pointless.
# Everything below is phrased for a set, and reads naturally for a set of
# one -- which is the common case.
sub gallery_menu {
    my ( $files, $event, $after ) = @_;
    my @files = @$files;
    return unless @files;

    my @shares = map { at( $_, 'share' ) } @files;
    my $many   = @files > 1;

    # What to call them in a message. A name for one, a count for several:
    # listing five filenames in a status bar helps nobody.
    my $label = $many ? scalar(@files) . ' files'
                      : at( $files[0], 'name' );

    my $menu = Gtk3::Menu->new;

    # --- open, since the click no longer does it ---
    my $open = Gtk3::MenuItem->new_with_label(
        $many ? 'Open all in a browser' : 'Open' );
    $open->signal_connect( activate => sub { open_url( $_->{url} ) for @files } );
    $menu->append($open);

    # --- copy the address ---
    my $copy = Gtk3::MenuItem->new_with_label(
        $many ? 'Copy the links' : 'Copy the link' );
    $copy->signal_connect( activate => sub {
        # One per line for several: that is what pastes usefully into
        # anything, and keeps the order shown on screen.
        my $text = join "\n", grep { defined && length }
                   map { $_->{url} } @files;
        return say_status('No addresses to copy.') unless length $text;
        clip($text);
        say_status( $many ? "Copied $label." : "Copied $text" );
    } );
    $menu->append($copy);

    # --- one-time link ---
    # Offered for one file only. A one-time link is for handing to a
    # person, and minting five at once is more likely a mis-click than an
    # intention.
    unless ($many) {
        my $otl = Gtk3::MenuItem->new_with_label('Create a one-time link');
        $otl->signal_connect( activate => sub {
            my $data = run( 'otl', { share => [ $shares[0] ] }, [], sub {
                'One-time link: ' . at( $_[0], 'url' );
            } );
            clip( $data->{url} ) if $data && defined $data->{url};
        } );
        $menu->append($otl);
    }

    # --- move ---
    my $move    = Gtk3::MenuItem->new_with_label('Move to');
    my $submenu = Gtk3::Menu->new;

    my $new = Gtk3::MenuItem->new_with_label('New gallery...');
    $new->signal_connect( activate => sub {
        my $wanted = ask_text( 'New gallery',
            "Name for the new gallery holding $label:", '' );
        return unless defined $wanted && length $wanted;

        run( 'move', { share => \@shares, gallery => [$wanted] }, [],
             sub { sprintf 'Moved %s to %s.', $label, $wanted } );
        $after->() if $after;
    } );
    $submenu->append($new);
    $submenu->append( Gtk3::SeparatorMenuItem->new );

    for my $gallery (@KNOWN_GALLERIES) {
        my ( $id, $name ) = ( $gallery->{id}, at( $gallery, 'name' ) );
        my $item = Gtk3::MenuItem->new_with_label($name);
        $item->signal_connect( activate => sub {
            run( 'move', { share => \@shares, g => [$id] }, [],
                 sub { sprintf 'Moved %s to %s.', $label, $name } );
            $after->() if $after;
        } );
        $submenu->append($item);
    }

    $submenu->append( Gtk3::SeparatorMenuItem->new );
    my $out = Gtk3::MenuItem->new_with_label('No gallery');
    $out->signal_connect( activate => sub {
        run( 'move', { share => \@shares }, [],
             sub { sprintf 'Moved %s out of its gallery.', $label } );
        $after->() if $after;
    } );
    $submenu->append($out);

    unless (@KNOWN_GALLERIES) {
        my $hint = Gtk3::MenuItem->new_with_label(
            '(press "Reload galleries" to list them)' );
        $hint->set_sensitive(0);
        $submenu->append($hint);
    }

    $move->set_submenu($submenu);
    $menu->append($move);
    $menu->append( Gtk3::SeparatorMenuItem->new );

    # --- delete ---
    my $delete = Gtk3::MenuItem->new_with_label(
        $many ? "Delete $label" : 'Delete' );
    $delete->signal_connect( activate => sub {
        # The count is in the question, because the difference between
        # deleting one file and deleting nine is the whole of what is being
        # confirmed.
        return unless confirm(
            "Delete $label?\n\n"
          . ( $many ? 'Each file and its thumbnail are removed'
                    : 'The file and its thumbnail are removed' )
          . ' immediately, and the links to them stop working. This cannot '
          . 'be undone.' );

        run( 'delete', { share => \@shares }, [],
             sub { sprintf 'Deleted %s.', $label } );
        $after->() if $after;
    } );
    $menu->append($delete);

    $menu->show_all;
    $menu->popup_at_pointer($event) if $event;
    return $menu;
}

sub show_raw {
    my ($data) = @_;
    my $text = eval { JSON::PP->new->pretty->canonical->encode($data) } // '';
    $RAW->get_buffer->set_text( $text, -1 );
}

# Run an action and report. $summarise turns the reply into one line.
sub run {
    my ( $action, $fields, $files, $summarise ) = @_;

    say_status("Working...");

    # Let the label repaint before the request blocks. HTTP::Tiny is
    # synchronous, so without this the window stays frozen showing the old
    # text for the whole call and looks like it has hung.
    Gtk3::main_iteration() while Gtk3::events_pending();

    my ( $data, $error ) = api( $action, $fields, $files );

    if ($error) {
        say_status($error);
        $RAW->get_buffer->set_text( $error, -1 );
        return;
    }

    show_raw($data);

    unless ( $data->{ok} ) {
        say_status( 'Error: ' . at( $data, 'error', 'unknown' ) . ' -- '
                  . at( $data, 'message', '' ) );
        return;
    }

    say_status( $summarise ? $summarise->($data) : 'Done.' );
    return $data;
}

# --- a tab holding a vertical stack of widgets -----------------------------
sub tab {
    my ( $title, @widgets ) = @_;

    my $box = Gtk3::Box->new( 'vertical', 6 );
    $box->set_border_width(9);
    $box->pack_start( $_, 0, 0, 0 ) for @widgets;

    $NOTEBOOK->append_page( $box, Gtk3::Label->new($title) );
    return $box;
}

sub labelled {
    my ( $text, $widget ) = @_;
    my $row = Gtk3::Box->new( 'horizontal', 6 );
    my $lab = Gtk3::Label->new($text);
    $lab->set_xalign(0);
    $lab->set_size_request( 110, -1 );
    $row->pack_start( $lab, 0, 0, 0 );
    $row->pack_start( $widget, 1, 1, 0 );
    return $row;
}

# ---------------------------------------------------------------------------
# A paginated list tab
# ---------------------------------------------------------------------------
# Files, Pastes, Shortens, OTLs and Galleries are the same thing five times:
# ask the server for a page, put it in a tree, page through it, and offer a
# right-click menu. Written once.
#
#   title    the tab label
#   action   the API action to call
#   key      the field in the reply holding the rows
#   columns  [ [ heading, sub { one cell from a row } ], ... ]
#   menu     sub { my ($row, $event, $reload) = @_; ... } or undef
#   paged    whether the action takes a page parameter
#
# listgalleries returns everything at once and takes NO parameters -- the API
# rejects anything it did not declare, so sending "page" to it is a 400, not
# a value quietly ignored. The paging buttons are also left disabled there,
# because there is nothing to page through.
# tab title -> sub building that tab's row menu for row N.
our %MENU_FOR;

sub list_tab {
    my (%a) = @_;

    my $store = Gtk3::ListStore->new(
        ('Glib::String') x scalar @{ $a{columns} } );
    my $tree = Gtk3::TreeView->new($store);

    my $n = 0;
    for my $spec ( @{ $a{columns} } ) {
        my $renderer = Gtk3::CellRendererText->new;

        # A hugeurl share is 242 characters. Left to itself the column grows
        # to fit it and pushes everything else off the window, which is what
        # made the Shortens tab unreadable.
        #
        # Ellipsized rather than cut: "abc..." says there is more, where a
        # blind truncation looks like the value simply ends there. The full
        # text is still on the clipboard through the right-click menu, which
        # is how anyone would use it anyway -- nobody retypes a 242-character
        # token off a screen.
        $renderer->set_property( 'ellipsize',       'end' );
        $renderer->set_property( 'max-width-chars', $spec->[2] // 40 );

        my $column = Gtk3::TreeViewColumn->new_with_attributes(
            $spec->[0], $renderer, text => $n++ );

        # Resizable, so anyone who wants the wide column wide can have it.
        $column->set_sizing('fixed');
        $column->set_resizable(1);
        $column->set_min_width(60);
        $column->set_fixed_width( 9 * ( $spec->[2] // 20 ) );

        $tree->append_column($column);
    }

    my $scroll = Gtk3::ScrolledWindow->new;
    $scroll->set_size_request( -1, 300 );
    $scroll->add($tree);

    my ( $page, $pages ) = ( 1, 1 );
    my @rows;                       # the reply, so the menu has the whole row

    my $where = Gtk3::Label->new('');
    my $prev  = Gtk3::Button->new('< Previous');
    my $next  = Gtk3::Button->new('Next >');
    $prev->set_sensitive(0);
    $next->set_sensitive(0);

    my $load;
    $load = sub {
        # page only where the action declares it. Sending a parameter an
        # action does not take is an error from this API by design, so
        # "harmless extra" is not a thing here.
        my $data = run( $a{action},
            ( $a{paged} ? { page => [$page] } : {} ), [], sub {
            my ($d) = @_;
            defined $d->{pages}
                ? sprintf( 'Page %s of %s, %s in total.',
                           at( $d, 'page', 1 ), at( $d, 'pages', 1 ),
                           at( $d, 'total', 0 ) )
                : sprintf( '%d row(s).', scalar @{ $d->{ $a{key} } || [] } );
        } );
        return unless $data;

        # From the reply: the server clamps a page past the end.
        $page  = at( $data, 'page',  1 );
        $pages = at( $data, 'pages', 1 );

        my $paged = $a{paged} && defined $data->{pages};
        $where->set_text( $paged ? "Page $page of $pages" : '' );
        $prev->set_sensitive( $paged && $page > 1 );
        $next->set_sensitive( $paged && $page < $pages );

        @rows = @{ $data->{ $a{key} } || [] };

        $store->clear;
        for my $row (@rows) {
            my $iter = $store->append;
            my $col  = 0;
            for my $spec ( @{ $a{columns} } ) {
                $store->set( $iter, $col++, $spec->[1]->($row) );
            }
        }
    };

    my $refresh = Gtk3::Button->new('Refresh');
    $refresh->signal_connect( clicked => sub { $page = 1; $load->() } );
    $prev->signal_connect( clicked => sub { $page--; $load->() } );
    $next->signal_connect( clicked => sub { $page++; $load->() } );

    # Which row was right-clicked. The tree reports a path; the row itself
    # is in @rows at the same position, which is why the reply is kept.
    # Double-clicking a row opens it, where there is something to open.
    # This was lost when the five tabs were folded into one builder: the
    # hand-written Files tab had it and the builder did not.
    if ( $a{open} ) {
        $tree->signal_connect( 'row-activated' => sub {
            my ( $view, $path ) = @_;
            my $index = ( $path->get_indices )[0];
            return unless defined $index && $rows[$index];
            my $url = $a{open}->( $rows[$index] );
            open_url($url) if defined $url && length $url;
        } );
    }

    # Exposed so the menu can be built without synthesising a GDK event,
    # which is the only practical way to check it without a person clicking.
    $MENU_FOR{ $a{title} } = sub {
        my ($index) = @_;
        return undef unless $a{menu} && $rows[$index];
        return $a{menu}->( $rows[$index], undef, $load );
    };

    if ( $a{menu} ) {
        $tree->signal_connect( 'button-press-event' => sub {
            my ( $widget, $event ) = @_;
            return 0 unless $event->button == 3;

            my ($path) = $tree->get_path_at_pos( $event->x, $event->y );
            return 0 unless $path;

            my $index = ( $path->get_indices )[0];
            return 0 unless defined $index && $rows[$index];

            $a{menu}->( $rows[$index], $event, $load );
            return 1;
        } );
    }

    my $nav = Gtk3::Box->new( 'horizontal', 6 );
    $nav->pack_start( $refresh, 0, 0, 0 );
    $nav->pack_start( $prev,    0, 0, 0 );
    $nav->pack_start( $where,   1, 1, 0 );
    $nav->pack_start( $next,    0, 0, 0 );

    my $box = tab( $a{title}, $nav );
    $box->pack_start( $scroll, 1, 1, 0 );
    return $load;
}

# --- Upload ----------------------------------------------------------------
{
    # FileChooserButton cannot select several files, so this is a plain
    # button opening a dialog that can. The label doubles as the display of
    # what is chosen, which is what the button was providing before.
    my @chosen;
    my $pick = Gtk3::Button->new('Choose files...');

    my $chosen_label = Gtk3::Label->new('Nothing chosen.');
    $chosen_label->set_xalign(0);
    $chosen_label->set_ellipsize('middle');

    my $describe = sub {
        return $chosen_label->set_text('Nothing chosen.') unless @chosen;
        $chosen_label->set_text(
            @chosen == 1
            ? basename( $chosen[0] )
            : sprintf( '%d files: %s', scalar @chosen,
                       join ', ', map { basename($_) } @chosen ) );
    };

    $pick->signal_connect( clicked => sub {
        my $dialog = Gtk3::FileChooserDialog->new(
            'Choose files to upload', $WINDOW, 'open',
            'Cancel' => 'cancel', 'Choose' => 'ok' );
        $dialog->set_select_multiple(1);

        # A preview beside the list, for images.
        #
        # update-preview fires as the highlighted file changes, and
        # get_preview_filename is the one being pointed at -- which is not
        # the same as the selection, and is why the signal exists.
        my $preview = Gtk3::Image->new;
        $preview->set_size_request( 160, 160 );
        $dialog->set_preview_widget($preview);

        $dialog->signal_connect( 'update-preview' => sub {
            my $path = $dialog->get_preview_filename;

            # Anything that is not a readable image: a directory, a text
            # file, a broken symlink, or a file the reader cannot decode.
            # The preview simply disappears rather than the dialog
            # complaining -- choosing a text file to upload is normal.
            my $pixbuf = ( defined $path && -f $path )
                ? eval {
                      Gtk3::Gdk::Pixbuf->new_from_file_at_scale(
                          $path, 160, 160, 1 );
                  }
                : undef;

            if ($pixbuf) {
                $preview->set_from_pixbuf($pixbuf);
                $dialog->set_preview_widget_active(1);
            }
            else {
                $dialog->set_preview_widget_active(0);
            }
        } );

        if ( $dialog->run eq 'ok' ) {
            # get_filenames returns ONE value: an array reference.
            #
            # The name is plural and Perl's own file-dialog wrappers return
            # lists, so "my @picked = $dialog->get_filenames" looks right --
            # and puts the reference itself into $picked[0], which then
            # displayed as ARRAY(0x...) and was passed to open() as a
            # filename.
            #
            # Introspected bindings return a GList as a reference, so this
            # is the shape to expect from Gtk3 generally, not a quirk of
            # this call.
            my @picked = glist( $dialog->get_filenames );

            # The server refuses more than max_upcount per call and says so,
            # but stopping here means the user finds out before waiting for
            # an upload to be rejected. $FILES_PER_CALL is what whoami
            # reported, or the documented default until it has been asked.
            if ( @picked > $FILES_PER_CALL ) {
                say_status( sprintf
                    'At most %d files at a time; keeping the first %d of %d.',
                    $FILES_PER_CALL, $FILES_PER_CALL, scalar @picked );
                @picked = @picked[ 0 .. $FILES_PER_CALL - 1 ];
            }
            @chosen = @picked;
            $describe->();
        }
        $dialog->destroy;
    } );

    # Editable, so an existing gallery can be picked or a new name typed.
    # has_entry is what makes a ComboBoxText behave like a combo box rather
    # than a fixed list.
    my $gallery = Gtk3::ComboBoxText->new_with_entry;
    $gallery->get_child->set_placeholder_text('optional: gallery name');

    my $reload = Gtk3::Button->new('Galleries');
    $reload->signal_connect( clicked => sub {
        my $data = run( 'listgalleries', {}, [], sub {
            sprintf '%d gallery(s).', scalar @{ $_[0]{galleries} || [] };
        } );
        return unless $data;

        @KNOWN_GALLERIES = @{ $data->{galleries} || [] };

        # The typed text survives a reload: someone half-way through a new
        # name should not lose it because they pressed this.
        my $typed = $gallery->get_child->get_text;
        $gallery->remove_all;
        $gallery->append_text( at( $_, 'name' ) ) for @KNOWN_GALLERIES;
        $gallery->get_child->set_text($typed);
    } );

    my $gallery_row = Gtk3::Box->new( 'horizontal', 6 );
    $gallery_row->pack_start( $gallery, 1, 1, 0 );
    $gallery_row->pack_start( $reload,  0, 0, 0 );

    my $resize = Gtk3::Entry->new;
    $resize->set_placeholder_text('optional: 800x600, 50%, 1024');

    my $lifetime = Gtk3::Entry->new;
    $lifetime->set_placeholder_text('optional: 5m, 1h, 7d');

    my $go = Gtk3::Button->new('Upload');
    $go->signal_connect( clicked => sub {
        return say_status('Choose at least one file first.') unless @chosen;

        run( 'upload',
             { gallery  => [ $gallery->get_child->get_text ],
               resize   => [ $resize->get_text ],
               lifetime => [ $lifetime->get_text ] },
             [@chosen],
             sub {
                 my ($d) = @_;
                 my @urls = map { at( $_, 'url' ) } @{ $d->{files} || [] };
                 my @bad  = @{ $d->{rejected} || [] };
                 my $line = @urls ? join( '  ', @urls ) : 'Uploaded.';
                 $line .= sprintf ' (%d rejected)', scalar @bad if @bad;
                 clip( $urls[0] ) if @urls == 1;
                 return $line;
             } );
    } );

    # The chosen files are state too, and after an upload they are still
    # listed -- pressing Upload again would send them a second time. This
    # is the way to say "no, I am done with those".
    my $clear = Gtk3::Button->new('Clear');
    $clear->signal_connect( clicked => sub {
        @chosen = ();
        $describe->();
        say_status('Cleared the file selection.');
    } );

    my $file_row = Gtk3::Box->new( 'horizontal', 6 );
    $file_row->pack_start( $pick,         0, 0, 0 );
    $file_row->pack_start( $clear,        0, 0, 0 );
    $file_row->pack_start( $chosen_label, 1, 1, 0 );

    tab( 'Upload',
         labelled( 'Files',    $file_row ),
         labelled( 'Gallery',  $gallery_row ),
         labelled( 'Resize',   $resize ),
         labelled( 'Lifetime', $lifetime ),
         $go );
}

# --- Paste -----------------------------------------------------------------
{
    my $view = Gtk3::TextView->new;
    $view->set_monospace(1);
    my $scroll = Gtk3::ScrolledWindow->new;
    $scroll->set_size_request( -1, 220 );
    $scroll->add($view);

    my $lifetime = Gtk3::Entry->new;
    $lifetime->set_placeholder_text('optional: 5m, 1h, 7d');

    my $go = Gtk3::Button->new('Paste');
    $go->signal_connect( clicked => sub {
        my $buf = $view->get_buffer;
        my $text = $buf->get_text( $buf->get_start_iter, $buf->get_end_iter, 0 );
        return say_status('Nothing to paste.') unless length $text;

        run( 'paste',
             { content => [$text], lifetime => [ $lifetime->get_text ] }, [],
             sub {
                 my $url = at( $_[0], 'url' );
                 clip($url);
                 return "$url (copied)";
             } );
    } );

    my $clear = Gtk3::Button->new('Clear');
    $clear->signal_connect( clicked => sub {
        $view->get_buffer->set_text( '', -1 );
        say_status('Cleared.');
    } );

    my $buttons = Gtk3::Box->new( 'horizontal', 6 );
    $buttons->pack_start( $go,    0, 0, 0 );
    $buttons->pack_start( $clear, 0, 0, 0 );

    my $box = tab( 'Paste', labelled( 'Lifetime', $lifetime ), $buttons );
    $box->pack_start( $scroll, 1, 1, 0 );
    $box->reorder_child( $scroll, 0 );
}

# --- Shorten ---------------------------------------------------------------
{
    my $entry = Gtk3::Entry->new;
    $entry->set_placeholder_text('https://example.com/a/long/address');

    my $tiny = Gtk3::Button->new('TinyURL');
    my $huge = Gtk3::Button->new('HugeURL');

    my $lifetime = Gtk3::Entry->new;
    $lifetime->set_placeholder_text('optional: 5m, 1h, 7d');

    for my $pair ( [ $tiny, 'tinyurl' ], [ $huge, 'hugeurl' ] ) {
        my ( $button, $action ) = @$pair;
        $button->signal_connect( clicked => sub {
            return say_status('Enter a URL first.')
                unless length $entry->get_text;

            run( $action,
                 { url      => [ $entry->get_text ],
                   lifetime => [ $lifetime->get_text ] }, [],
                 sub {
                     my $url = at( $_[0], 'url' );
                     clip($url);
                     return "$url (copied)";
                 } );
        } );
    }

    my $row = Gtk3::Box->new( 'horizontal', 6 );
    $row->pack_start( $tiny, 1, 1, 0 );
    $row->pack_start( $huge, 1, 1, 0 );

    tab( 'Shorten',
         labelled( 'URL',      $entry ),
         labelled( 'Lifetime', $lifetime ),
         $row );
}

# --- the list tabs ---------------------------------------------------------
# Five tabs from one builder. Written separately they would drift, which is
# what happened to every other thing duplicated in this project.
{
    list_tab(
        title   => 'Files',
        paged   => 1,
        open    => sub { $_[0]{url} },
        action  => 'listfiles',
        key     => 'files',
        columns => [
            [ 'Share',     sub { at( $_[0], 'share' ) }, 14 ],
            [ 'Size',      sub { human_bytes( $_[0]{bytes} ) }, 10 ],
            [ 'Type',      sub { at( $_[0], 'mime' ) }, 20 ],
            [ 'Dimension', sub { at( $_[0], 'dimension', '' ) }, 12 ],
            [ 'Name',      sub { at( $_[0], 'name' ) }, 30 ],
        ],
        menu => sub {
            my ( $row, $event, $reload ) = @_;
            row_menu( $row, $event, $reload, {
                url    => sub { $_[0]{url} },
                label  => sub { at( $_[0], 'name' ) },
                delete => sub {
                    my ( $r, $label ) = @_;
                    return undef unless confirm(
                        "Delete $label?\n\n"
                      . 'The file and its thumbnail are removed immediately, '
                      . 'and the links to them stop working. This cannot be '
                      . 'undone.' );
                    return { share => [ at( $r, 'share' ) ] };
                },
            } );
        },
    );

    list_tab(
        title   => 'Pastes',
        paged   => 1,
        open    => sub { $_[0]{url} },
        action  => 'listpastes',
        key     => 'pastes',
        columns => [
            [ 'Share',   sub { at( $_[0], 'share' ) }, 14 ],
            [ 'Size',    sub { human_bytes( $_[0]{bytes} ) }, 10 ],
            [ 'Created', sub { at( $_[0], 'created', '' ) }, 18 ],
            [ 'Preview', sub {
                  my $text = $_[0]{preview} // '';
                  $text =~ s/\n/ /g;
                  substr( $text, 0, 60 );
              } ],
        ],
        menu => sub {
            my ( $row, $event, $reload ) = @_;
            row_menu( $row, $event, $reload, {
                url    => sub { $_[0]{url} },
                label  => sub { 'paste ' . at( $_[0], 'share' ) },
                delete => sub {
                    my ( $r, $label ) = @_;
                    return undef unless confirm(
                        "Delete $label?\n\n"
                      . 'The text is removed immediately and the link stops '
                      . 'working. This cannot be undone.' );
                    return { share => [ at( $r, 'share' ) ] };
                },
            } );
        },
    );

    list_tab(
        title   => 'Shortens',
        paged   => 1,
        open    => sub { $_[0]{url} },
        action  => 'listurls',
        key     => 'urls',
        columns => [
            [ 'Share',  sub { at( $_[0], 'share' ) }, 14 ],
            [ 'Kind',   sub { at( $_[0], 'kind' ) }, 9 ],
            [ 'Target', sub { at( $_[0], 'target' ) }, 44 ],
        ],
        menu => sub {
            my ( $row, $event, $reload ) = @_;
            row_menu( $row, $event, $reload, {
                url    => sub { $_[0]{url} },
                label  => sub { at( $_[0], 'kind' ) . ' ' . at( $_[0], 'share' ) },
                delete => sub {
                    my ( $r, $label ) = @_;
                    return undef unless confirm(
                        "Delete $label?\n\n"
                      . 'The short link stops working immediately. Anyone who '
                      . 'has it will get nothing. This cannot be undone.' );
                    return { share => [ at( $r, 'share' ) ] };
                },
            } );
        },
    );

    list_tab(
        title   => 'OTLs',
        paged   => 1,
        open    => sub { $_[0]{url} },
        action  => 'listotl',
        key     => 'otl',
        columns => [
            [ 'Link',  sub { at( $_[0], 'otl' ) }, 14 ],
            [ 'File',  sub { at( $_[0], 'share' ) }, 14 ],
            [ 'Name',  sub { at( $_[0], 'name' ) }, 30 ],
            [ 'State', sub { $_[0]{expired} ? 'file expired' : '' }, 14 ],
        ],
        menu => sub {
            my ( $row, $event, $reload ) = @_;
            row_menu( $row, $event, $reload, {
                url    => sub { $_[0]{url} },
                label  => sub { 'one-time link ' . at( $_[0], 'otl' ) },
                delete => sub {
                    my ( $r, $label ) = @_;
                    # Deleting a one-time link is the mildest of these: the
                    # file stays, only the invitation is withdrawn. The
                    # wording says so, or it reads like the file goes too.
                    return undef unless confirm(
                        "Delete $label?\n\n"
                      . 'The link stops working. The file it points at is '
                      . 'not deleted.' );
                    return { otl => [ at( $r, 'otl' ) ] };
                },
            } );
        },
    );

    list_tab(
        title   => 'Galleries',
        open    => sub { $_[0]{url} },
        action  => 'listgalleries',
        key     => 'galleries',
        columns => [
            [ 'Id',    sub { at( $_[0], 'id' ) }, 6 ],
            [ 'Name',  sub { at( $_[0], 'name' ) }, 30 ],
            [ 'Files', sub { at( $_[0], 'files', 0 ) }, 8 ],
            [ 'State', sub { $_[0]{published} ? 'public' : 'private' }, 10 ],
        ],
        menu => sub {
            my ( $row, $event, $reload ) = @_;
            row_menu( $row, $event, $reload, {
                publish => 1,
                url     => sub { $_[0]{url} },
                label   => sub { 'gallery ' . at( $_[0], 'name' ) },
                delete  => sub {
                    my ( $r, $label ) = @_;
                    my $how = confirm_gallery_delete(
                        at( $r, 'name' ), at( $r, 'files', 0 ) );
                    return undef unless $how;

                    # withfiles is what tells the API to take the contents
                    # too. Without it the files return to the main listing,
                    # which is the safe default the API documents.
                    return {
                        g => [ at( $r, 'id' ) ],
                        ( $how eq 'everything'
                          ? ( withfiles => ['true'] ) : () ),
                    };
                },
            } );
        },
    );
}

# --- Translate -------------------------------------------------------------
# Source above, result below, as translation views normally are.
{
    my $source = Gtk3::TextView->new;
    $source->set_wrap_mode('word');
    my $source_scroll = Gtk3::ScrolledWindow->new;
    $source_scroll->set_size_request( -1, 150 );
    $source_scroll->add($source);

    my $result = Gtk3::TextView->new;
    $result->set_wrap_mode('word');
    # Editable, so the result can be corrected before being copied, and so
    # swapping it upwards is not the only way to reuse it.
    my $result_scroll = Gtk3::ScrolledWindow->new;
    $result_scroll->set_size_request( -1, 150 );
    $result_scroll->add($result);

    my $text_of = sub {
        my ($view) = @_;
        my $buffer = $view->get_buffer;
        return $buffer->get_text( $buffer->get_start_iter,
                                  $buffer->get_end_iter, 0 );
    };

    # The API has no list of languages to offer, so this is a short list of
    # common ones and the box is editable: any ISO 639 code can be typed.
    #
    # Two-letter codes, not country codes -- the server says so when it
    # refuses one, and Danish being "da" rather than "dk" is the usual trap.
    my $lang = Gtk3::ComboBoxText->new_with_entry;
    $lang->append_text($_) for (
        'en  English',
        'de  German',
        'es  Spanish',
        'fr  French',
        'ja  Japanese',
    );
    $lang->get_child->set_placeholder_text('ISO 639 code, e.g. en');
    $lang->get_child->set_text('en');

    # The list shows "en  English" so it can be read; only the code is sent.
    my $code_of = sub {
        my $typed = $lang->get_child->get_text // '';
        $typed =~ s/^\s+|\s+$//g;
        $typed =~ s/\s.*$//;          # drop the name after the code
        return $typed;
    };

    my $detected = Gtk3::Label->new('');
    $detected->set_xalign(0);

    # Swap moves the TEXT only.
    #
    # A normal translation view swaps the languages too, but this API
    # detects the source itself -- there is only one language to choose, the
    # target. So this takes the result back up to be translated onwards,
    # which is the half that is available.
    my $swap = Gtk3::Button->new('Swap text');
    $swap->signal_connect( clicked => sub {
        my $upper = $text_of->($source);
        my $lower = $text_of->($result);
        $source->get_buffer->set_text( $lower, -1 );
        $result->get_buffer->set_text( $upper, -1 );
        say_status('Swapped. Choose the language to translate into.');
    } );

    my $copy = Gtk3::Button->new('Copy result');
    $copy->signal_connect( clicked => sub {
        my $text = $text_of->($result);
        return say_status('Nothing to copy.') unless length $text;
        clip($text);
        say_status('Copied the translation.');
    } );

    my $go = Gtk3::Button->new('Translate');
    $go->signal_connect( clicked => sub {
        my $text = $text_of->($source);
        return say_status('Nothing to translate.') unless length $text;

        my $to = $code_of->();
        return say_status('Give a language code, such as en or de.')
            unless length $to;

        my $data = run( 'translate',
            { content => [$text], to => [$to] }, [],
            sub {
                my ($d) = @_;
                my $from = at( $d, 'detected', '' );
                length $from ? "Translated from $from to " . at( $d, 'to' )
                             : 'Translated to ' . at( $d, 'to' );
            } );
        return unless $data;

        $result->get_buffer->set_text( at( $data, 'text', '' ), -1 );
        $detected->set_text(
            length at( $data, 'detected', '' )
            ? sprintf( 'Detected source: %s', $data->{detected} )
            : '' );
    } );

    # Clears BOTH boxes. Emptying only the top one would leave a
    # translation of text that is no longer there, which reads as though it
    # belongs to whatever gets typed next.
    my $clear = Gtk3::Button->new('Clear');
    $clear->signal_connect( clicked => sub {
        $source->get_buffer->set_text( '', -1 );
        $result->get_buffer->set_text( '', -1 );
        $detected->set_text('');
        say_status('Cleared.');
    } );

    my $row = Gtk3::Box->new( 'horizontal', 6 );
    $row->pack_start( $lang,  1, 1, 0 );
    $row->pack_start( $go,    0, 0, 0 );
    $row->pack_start( $swap,  0, 0, 0 );
    $row->pack_start( $copy,  0, 0, 0 );
    $row->pack_start( $clear, 0, 0, 0 );

    my $box = tab( 'Translate', labelled( 'Into', $row ), $detected );
    $box->pack_start( $source_scroll, 1, 1, 0 );
    $box->pack_start( $result_scroll, 1, 1, 0 );

    # Source above the controls, result below them.
    $box->reorder_child( $source_scroll, 0 );
}

# --- Gallery ---------------------------------------------------------------
# The same files as the Files tab, shown as thumbnails, with a chooser for
# which gallery to look at. The web interface groups files into galleries and
# this is the equivalent view of them.
{
    my $chooser = Gtk3::ComboBoxText->new;
    $chooser->append( 'all',  'All files' );
    $chooser->append( 'none', 'Not in a gallery' );
    $chooser->set_active(0);

    my $flow = Gtk3::FlowBox->new;
    $flow->set_valign('start');
    $flow->set_max_children_per_line(6);
    $flow->set_row_spacing(9);
    $flow->set_column_spacing(9);

    # Like a file manager: click selects, double-click opens.
    #
    # activate-on-single-click is true by default, which would open a file
    # the moment it was selected -- so a rubber-band drag across the grid
    # would open everything it touched.
    $flow->set_selection_mode('multiple');
    $flow->set_activate_on_single_click(0);

    my $scroll = Gtk3::ScrolledWindow->new;
    $scroll->set_size_request( -1, 340 );
    $scroll->add($flow);

    # Refill the gallery chooser from the server. Kept separate so pressing
    # Show does not re-fetch the gallery list every time.
    my $galleries = Gtk3::Button->new('Reload galleries');
    $galleries->signal_connect( clicked => sub {
        my $data = run( 'listgalleries', {}, [], sub {
            sprintf '%d gallery(s).', scalar @{ $_[0]{galleries} || [] };
        } );
        return unless $data;

        $chooser->remove_all;
        $chooser->append( 'all',  'All files' );
        $chooser->append( 'none', 'Not in a gallery' );

        # Kept for the right-click "Move to" submenu as well as the chooser,
        # so the menu offers what the account actually has rather than
        # making the user type a name they already chose once.
        @KNOWN_GALLERIES = @{ $data->{galleries} || [] };

        for my $g (@KNOWN_GALLERIES) {
            $chooser->append( $g->{id},
                sprintf( '%s (%s)', at( $g, 'name' ), at( $g, 'files', 0 ) ) );
        }
        $chooser->set_active(0);
    } );

    my ( $page, $pages ) = ( 1, 1 );

    my $where = Gtk3::Label->new('');
    my $prev  = Gtk3::Button->new('< Previous');
    my $next  = Gtk3::Button->new('Next >');
    $prev->set_sensitive(0);
    $next->set_sensitive(0);

    my $fill;
    $fill = sub {
        my $which = $chooser->get_active_id // 'all';

        my $data = run( 'listfiles',
            { page => [$page],
              ( $which eq 'all' ? () : ( g => [$which] ) ) }, [],
            sub {
                my ($d) = @_;
                sprintf 'Page %s of %s, %s file(s).',
                    at( $d, 'page', 1 ), at( $d, 'pages', 1 ),
                    at( $d, 'total', 0 );
            } );
        return unless $data;

        # From the reply, not from what was asked for: the server clamps a
        # page past the end.
        $page  = at( $data, 'page',  1 );
        $pages = at( $data, 'pages', 1 );
        $where->set_text("Page $page of $pages");
        $prev->set_sensitive( $page > 1 );
        $next->set_sensitive( $page < $pages );

        $_->destroy for $flow->get_children;
        %URL_OF = ();

        my @files = @{ $data->{files} || [] };
        unless (@files) {
            $flow->add( Gtk3::Label->new('Nothing here.') );
            $flow->show_all;
            return;
        }

        # One request per thumbnail, and HTTP::Tiny is synchronous. The
        # event loop is pumped after each so the window fills in visibly
        # rather than freezing and then appearing all at once -- and the
        # count says how far along it is.
        # Cell position -> the file it shows. The FlowBox reports children
        # by index, so this is how a selection becomes a list of shares.
        @SHOWN = @files;

        my $done = 0;
        for my $file (@files) {
            my $cell = Gtk3::Box->new( 'vertical', 3 );

            my $pixbuf = thumbnail( $file->{thumb}, 128 );
            my $image  = $pixbuf
                ? Gtk3::Image->new_from_pixbuf($pixbuf)
                : Gtk3::Image->new_from_icon_name( 'text-x-generic', 'dialog' );

            # A plain image, NOT a button.
            #
            # A button consumes the click, so the FlowBox never sees it and
            # the cell cannot be selected -- which is the whole point of
            # laying files out in a grid. Opening moves to double-click and
            # to the menu, as a file manager does it.
            $image->set_tooltip_text( at( $file, 'url', '' ) );
            $cell->pack_start( $image, 0, 0, 0 );

            $URL_OF{ at( $file, 'share' ) } = $file->{url};

            my $caption = Gtk3::Label->new( at( $file, 'name' ) );
            $caption->set_max_width_chars(18);
            $caption->set_ellipsize('middle');
            $cell->pack_start( $caption, 0, 0, 0 );

            my $size = Gtk3::Label->new( human_bytes( $file->{bytes} ) );
            $cell->pack_start( $size, 0, 0, 0 );

            # Dimensions rather than the share name: the share is already
            # in the tooltip and the right-click menu, and it tells you
            # nothing about the picture you are looking at.
            my $dim = Gtk3::Label->new( at( $file, 'dimension', '' ) );
            $cell->pack_start( $dim, 0, 0, 0 );

            $flow->add($cell);
            $flow->show_all;

            $done++;
            say_status( sprintf 'Loading thumbnails... %d of %d',
                        $done, scalar @files );
            Gtk3::main_iteration() while Gtk3::events_pending();
        }

        say_status( sprintf '%d file(s) shown.', scalar @files );
    };

    # --- what the grid does with a click ---------------------------------
    # Double-click opens. Single click selects, which is what the grid is
    # for; activate-on-single-click is off, so the two do not collide.
    $flow->signal_connect( 'child-activated' => sub {
        my ( $box, $child ) = @_;
        my $file = $SHOWN[ $child->get_index ] or return;
        open_url( $file->{url} );
    } );

    # Right-click acts on the SELECTION when the clicked file is part of
    # it, and on the clicked file alone otherwise -- which is how every
    # file manager behaves, and avoids the trap of right-clicking one file
    # and silently deleting five.
    $flow->signal_connect( 'button-press-event' => sub {
        my ( $widget, $event ) = @_;
        return 0 unless $event->button == 3;

        my $child = $flow->get_child_at_pos( $event->x, $event->y );
        return 0 unless $child;

        my @selected = map { $SHOWN[ $_->get_index ] }
                       grep {defined} glist( $flow->get_selected_children );

        my $clicked = $SHOWN[ $child->get_index ];
        my $on_selection =
            grep { $_->{share} eq $clicked->{share} } @selected;

        my @targets = $on_selection ? @selected : ($clicked);

        # Not part of the selection: make that visible before acting, so
        # the menu is never about a file that is not highlighted.
        unless ($on_selection) {
            $flow->unselect_all;
            $flow->select_child($child);
        }

        gallery_menu( \@targets, $event, $fill );
        return 1;
    } );

    my $show = Gtk3::Button->new('Show');
    # Changing the gallery starts again at page one; paging keeps the
    # gallery. Anything else means a page number carried over from a
    # gallery that had more pages than this one.
    $show->signal_connect( clicked => sub { $page = 1; $fill->() } );
    $prev->signal_connect( clicked => sub { $page--; $fill->() } );
    $next->signal_connect( clicked => sub { $page++; $fill->() } );

    my $row = Gtk3::Box->new( 'horizontal', 6 );
    $row->pack_start( $chooser,   1, 1, 0 );
    $row->pack_start( $show,      0, 0, 0 );
    $row->pack_start( $galleries, 0, 0, 0 );

    my $all = Gtk3::Button->new('Select all');
    $all->signal_connect( clicked => sub {
        $flow->select_all;
        say_status( sprintf '%d selected.',
                    scalar glist( $flow->get_selected_children ) );
    } );

    my $none = Gtk3::Button->new('Select none');
    $none->signal_connect( clicked => sub {
        $flow->unselect_all;
        say_status('Selection cleared.');
    } );

    my $nav = Gtk3::Box->new( 'horizontal', 6 );
    $nav->pack_start( $prev,  0, 0, 0 );
    $nav->pack_start( $where, 1, 1, 0 );
    $nav->pack_start( $next,  0, 0, 0 );
    $nav->pack_start( $all,   0, 0, 0 );
    $nav->pack_start( $none,  0, 0, 0 );

    # The interaction changed from "click opens" to "click selects", which
    # nothing on screen would otherwise tell anyone.
    my $hint = Gtk3::Label->new(
        'Click to select, Ctrl or Shift to select more, '
      . 'double-click to open, right-click for actions.' );
    $hint->set_xalign(0);

    my $box = tab( 'Gallery', $row, $nav, $hint );
    $box->pack_start( $scroll, 1, 1, 0 );
}

# --- Settings --------------------------------------------------------------
{
    my $url = Gtk3::Entry->new;
    $url->set_text( $CFG{url} );

    my $key = Gtk3::Entry->new;
    $key->set_text( $CFG{key} );
    # Hidden by default: this is a credential, and a window someone may be
    # screen-sharing should not display it by accident.
    $key->set_visibility(0);

    my $reveal = Gtk3::CheckButton->new('Show the key');
    $reveal->signal_connect( toggled => sub {
        $key->set_visibility( $reveal->get_active ? 1 : 0 );
    } );

    my $save = Gtk3::Button->new('Use these');
    $save->signal_connect( clicked => sub {
        $CFG{url} = $url->get_text;
        $CFG{key} = $key->get_text;
        say_status('Settings applied for this session.');
    } );

    my $test = Gtk3::Button->new('Test the key');
    $test->signal_connect( clicked => sub {
        $CFG{url} = $url->get_text;
        $CFG{key} = $key->get_text;
        run( 'whoami', {}, [], sub {
            my ($d) = @_;

            # Take the real limit while we are here: this is the one call
            # that reports it, and knowing it stops the Upload tab guessing.
            my $limit = $d->{limits} ? $d->{limits}{files_per_call} : undef;
            $FILES_PER_CALL = $limit
                if defined $limit && $limit =~ /^\d+$/ && $limit > 0;

            ( $d->{anonymous} ? 'No key: anonymous.'
                              : 'Key works: signed in as ' . at( $d, 'user' ) )
                . sprintf( ' Up to %d file(s) per upload.', $FILES_PER_CALL );
        } );
    } );

    my $note = Gtk3::Label->new(
        "The key is kept in memory only. Closing this window forgets it;\n"
      . "set OWX_KEY in your environment to have it filled in at startup." );
    $note->set_xalign(0);

    my $row = Gtk3::Box->new( 'horizontal', 6 );
    $row->pack_start( $save, 1, 1, 0 );
    $row->pack_start( $test, 1, 1, 0 );

    tab( 'Settings',
         labelled( 'Endpoint', $url ),
         labelled( 'API key',  $key ),
         $reveal, $row, $note );
}

# ---------------------------------------------------------------------------
# Assembling
# ---------------------------------------------------------------------------
{
    my $rawscroll = Gtk3::ScrolledWindow->new;
    $rawscroll->set_size_request( -1, 150 );
    $rawscroll->add($RAW);

    my $expander = Gtk3::Expander->new('Server reply');
    $expander->add($rawscroll);

    my $outer = Gtk3::Box->new( 'vertical', 6 );
    $outer->set_border_width(6);
    $outer->pack_start( $NOTEBOOK, 1, 1, 0 );
    $outer->pack_start( $expander, 0, 0, 0 );
    $outer->pack_start( $STATUS,   0, 0, 0 );

    $WINDOW->add($outer);
}

# Nothing above this line contacts the network, so the window appears at
# once. Everything happens on a button press.
$WINDOW->show_all;
Gtk3::main();
