=head1 NAME
Gtk2::CV::Schnauzer - a widget for displaying image collections
=head1 SYNOPSIS
use Gtk2::CV::Schnauzer;
=head1 DESCRIPTION
=head2 METHODS
=over 4
=cut
package Gtk2::CV::Schnauzer::DrawingArea;
use Glib::Object::Subclass Gtk2::DrawingArea,
signals => { size_allocate => \&Gtk2::CV::Schnauzer::do_size_allocate_rounded };
package Gtk2::CV::Schnauzer;
use common::sense;
use integer;
use Gtk2;
use Gtk2::Pango;
use Gtk2::Gdk::Keysyms;
use Gtk2::CV;
use Glib::Object::Subclass
Gtk2::VBox::,
signals => {
activate => { flags => [qw/run-first/], return_type => undef, param_types => [Glib::Scalar::] },
popup => { flags => [qw/run-first/], return_type => undef, param_types => [Gtk2::Menu::, Glib::Scalar::, Gtk2::Gdk::Event::] },
popup_selected => { flags => [qw/run-first/], return_type => undef, param_types => [Gtk2::Menu::, Glib::Scalar::] },
selection_changed => { flags => [qw/run-first/], return_type => undef, param_types => [Glib::Scalar::] },
chpaths => { flags => [qw/run-first/], return_type => undef, param_types => [] },
chdir => { flags => [qw/run-first/], return_type => undef, param_types => [Glib::Scalar::] },
};
use List::Util qw(min max);
use File::Spec;
use File::Copy;
use File::Temp ();
use Cwd ();
use POSIX qw(ceil ENOTDIR _exit strftime);
use Encode ();
use Errno ();
use Fcntl;
use IO::AIO;
use Gtk2::CV::Jobber;
use base Gtk2::CV::Jobber::Client::;
use strict qw(subs vars);
my %dir;
my $dirid;
my %directory_visited;
sub regdir($) {
$dir{$_[0]} ||= ++$dirid;
}
my $curdir = File::Spec->curdir;
my $updir = File::Spec->updir;
sub parent_dir($) {
my ($volume, $dirs, $file) = File::Spec->splitpath ($_[0], 1);
my @dirs = File::Spec->splitdir ($dirs);
pop @dirs; # oh my god
$dirs = File::Spec->catdir (@dirs);
File::Spec->catpath ($volume, $dirs, $file)
}
sub IW() { 80 } # must be the same as in CV.xs(!)
sub IH() { 60 } # must be the same as in CV.xs(!)
sub IX() { 20 }
sub IY() { 16 }
sub FY() { 12 } # font-y
sub SCROLL_Y() { 1 }
sub SCROLL_X() { 10 }
sub SCROLL_TIME() { 500 }
sub FAST_RANGE() { 2500 } # searching for this many entries * 2 is fast
sub SLOW_RANGE() { 10 } # doing this many slow ops per idle callback is ok
sub F_CHECKED() { 1 } # entry has been investigated
sub F_ISDIR() { 2 } # entry is certainly a directory
sub F_HASXVPIC() { 4 } # entry (likely) has a thumbnail file
sub F_HASPM() { 8 } # entry has a pixmap
# entries are arrays in this format:
# [0] dir
# [1] file
# [2] flags
# [3] pixmap
sub img {
scalar +(Gtk2::CV::require_image $_[0])->render_pixmap_and_mask (0.5)
}
my %combine;
sub img_combine {
my ($self, $base, $add) = @_;
$combine{"$base,$add"} ||= do {
my ($w, $h) = $base->get_size;
my $pm = new Gtk2::Gdk::Pixmap $base, $w, $h, -1;
$pm->draw_drawable ($self->style->white_gc, $base, 0, 0, 0, 0, $w, $h);
$pm->draw_pixbuf ($self->style->white_gc, $add , 0, 0, 0, 0, $w, $h, "max", 0, 0);
$pm
}
}
my %ext_logo = (
jpeg => "jpeg",
jfif => "jpeg",
jp2 => "jpeg",
jpc => "jpeg",
jpg => "jpeg",
jpe => "jpeg",
png => "png",
gif => "gif",
tif => "tif",
tiff => "tif",
mp2 => "mp2",
mp3 => "mp3",
pcm => "audio", # rare
sun => "audio", # rare
au => "audio",
iff => "audio", # rare
aif => "audio",
aiff => "audio",
wav => "audio",
mpeg => "mpeg",
mpg => "mpeg",
mpv => "mpeg",
mpa => "mpeg",
mpe => "mpeg",
m1v => "mpeg",
mp4 => "mpeg",
ogm => "ogm",
mkv => "ogm", # sigh
mov => "mov",
qt => "mov",
divx => "avi",
avi => "avi",
wmv => "wmv",
asf => "asf",
rm => "rm",
ram => "rm",
rmvb => "rm",
txt => "txt",
csv => "txt",
crc => "txt",
mid => "midi",
midi => "midi",
rar => "rar",
zip => "zip",
ace => "ace",
par => "par",
par2 => "par",
);
my $file_img = img "file.png";
my $diru_img = img "dir-unvisited.png";
my $dirv_img = img "dir-visited.png";
my $dirx_layer = Gtk2::CV::require_image "dir-xvpics.png";
my $dire_layer = Gtk2::CV::require_image "dir-empty.png";
my $dirs_layer = Gtk2::CV::require_image "dir-symlink.png";
my %file_img = do {
my %logo = reverse %ext_logo;
map +($_ => img "file-$_.png"), keys %logo
};
# get pathname of corresponding xvpics-dir
sub xvdir($) {
$_[0] =~ m%^(.*/)?([^/]+)$%sm
or Carp::croak "FATAL: unable to split <$_[0]> into dirname/basename";
"$1.xvpics"
}
# get filename of corresponding xvpic-file
sub xvpic($) {
$_[0] =~ m%^(.*/)?([^/]+)$%sm
or Carp::croak "FATAL: unable to split <$_[0]> into dirname/basename";
"$1.xvpics/$2"
}
sub dirname($) {
$_[0] =~ m%^(.*)/([^/]+)%sm
? wantarray ? ($1 , $2 ) : $1
: wantarray ? ($curdir, $_[0]) : $curdir
}
# filename => extension
sub extension {
$_[0] =~ /\.([a-z0-9]{2,4})[\.\-_0-9~]*$/i
? lc $1 : ();
}
sub read_thumb($) {
if (my $pb = eval { Gtk2::CV::load_jpeg $_[0] }) {
return Gtk2::CV::dealpha $pb;
} elsif (open my $p7, "<:raw", $_[0]) {
if (<$p7> =~ /^P7 332/) {
1 while ($_ = <$p7>) =~ /^#/;
if (/^(\d+)\s+(\d+)\s+255/) {
local $/;
return p7_to_pb $1, $2, <$p7>;
}
}
}
();
}
# rotate a file
Gtk2::CV::Jobber::define rotate =>
pri => -1000,
read => 1,
fork => 1,
sub {
my ($job) = @_;
my $path = $job->{path};
my $rot = $job->{data};
delete $job->{data};
eval {
die "can only rotate regular files"
unless Fcntl::S_ISREG ($job->{stat}[2]);
$rot = $rot eq "auto" ? -a
: ($rot % 360) == 0 ? undef
: ($rot % 360) == 90 ? -9
: ($rot % 360) == 180 ? -1
: ($rot % 360) == 270 ? -2
: die "can only rotate by 0, 90, 180 and 270 degrees";
if ($rot) {
system "exiftran", "-ip", $rot, $path
and die "exiftran failed: $?";
}
};
$job->finish;
};
sub video_thumbnail_at {
my ($path, $time, $w) = @_;
utf8::downgrade $path; # work around bugs in Gtk2
if (0) {
# fucking ffmpeg can't be told to be quiet except on errors
open my $fh, "exec ffmpeg -i \Q$path\E -ss $time -vframes 1 -an -f rawvideo -vcodec ppm -y /dev/fd/3 3>&1 1>&2 2>/dev/null |"
# open my $fh, "exec totem-gstreamer-video-thumbnailer -t $time -s $w \Q$path\E /dev/fd/3 3>&1 1>&2 |"
or die "ffmpeg: $!";
my $pb = new_with_type Gtk2::Gdk::PixbufLoader "pnm";
local $/; $pb->write ("" . scalar <$fh>);
$pb->close;
return $pb->get_pixbuf;
} else {
my $dir = File::Temp::tempdir DIR => "/dev/shm"
or return;
system "exec mplayer -really-quiet -nosound -ss $time -frames 2 -vo pnm:outdir=$dir \Q$path\E >/dev/null 2>&1 </dev/null";
my $pb = eval { new_from_file Gtk2::Gdk::Pixbuf "$dir/00000002.ppm" };
unlink "$dir/00000001.ppm";
unlink "$dir/00000002.ppm";
rmdir $dir;
return $pb;
}
}
sub video_thumbnail {
my ($path) = @_;
# do primitive filetype detection - gstreamer hates us otherwise
# return unless $path =~ /\.(?: asf | wmv | avi | mpg | mpeg )$/xi;
my $t1 = video_thumbnail_at $path, 0, 512 or return;
my $t2 = $t1 && video_thumbnail_at $path, 5, 512;
my $t3 = $t2 && video_thumbnail_at $path, 30, 512;
my $t4 = $t3 && video_thumbnail_at $path, 180, 512;
# assume all four images to have the same size
my $tw = $t1->get_width;
my $th = $t1->get_height;
my $pb = new Gtk2::Gdk::Pixbuf "rgb", 0, 8, $tw * 2, $th * 2;
$pb->fill (0xff69b400); # pinku eiga *g*
# make sure all frames have the right size
$t2 = $t2->scale_simple ($tw, $th, "bilinear") if $t2;
$t3 = $t3->scale_simple ($tw, $th, "bilinear") if $t3;
$t4 = $t4->scale_simple ($tw, $th, "bilinear") if $t4;
$t1->copy_area (0, 0, $tw, $th, $pb, 0, 0);
$t2->copy_area (0, 0, $tw, $th, $pb, $tw, 0) if $t2;
$t3->copy_area (0, 0, $tw, $th, $pb, 0, $th) if $t3;
$t4->copy_area (0, 0, $tw, $th, $pb, $tw, $th) if $t4;
return $pb;
}
# generate a thumbnail for a file
Gtk2::CV::Jobber::define gen_thumb =>
pri => -1000,
read => 1,
fork => 1,
sub {
my ($job) = @_;
my $path = $job->{path};
delete $job->{data};
eval {
die "can only generate thumbnail for regular files"
unless Fcntl::S_ISREG ($job->{stat}[2]);
utf8::downgrade $path;
mkdir +(dirname $path) . "/.xvpics", 0777;
my $pb = eval { $path =~ /\.jpe?g$/i && Gtk2::CV::load_jpeg $path, 1 }
|| eval {
# pixbuf only supports utf-8 filenames so do I/O ourselves. sigh.
open my $fh, "<", $path
or die "$path: $!";
my $loader = new Gtk2::Gdk::PixbufLoader;
#$loader->set_size (IW, IH);
# should set size-prepared callback to scale down, but
# we only really care about this for jpegs, which are handled above.
local $/; $loader->write (<$fh>);
$loader->close;
$loader->get_pixbuf
}
|| eval { video_thumbnail $path }
|| Gtk2::CV::require_image "error.png";
utf8::downgrade $path; # Gtk2::Gdk::Pixbuf upgrades :/
my ($w, $h) = ($pb->get_width, $pb->get_height);
if ($w * IH > $h * IW) {
$h = (int $h * IW / $w + 0.5) || 1;
$w = IW;
} else {
$w = (int $w * IH / $h + 0.5) || 1;
$h = IH;
}
$pb = Gtk2::CV::dealpha $pb->scale_simple ($w, $h, 'tiles');
open my $fh, ">:raw", xvpic $path
or die "xvpic($path): $!";
syswrite $fh, $pb->save_to_buffer ("jpeg", quality => 95);;
close $fh;
$job->{data} = $pb->get_pixels . pack "SSS", $w, $h, $pb->get_rowstride;
utime $job->{stat}[9], $job->{stat}[9], xvpic $path;
};
warn "$@" if $@;#d#
$job->finish;
};
Gtk2::CV::Jobber::define upd_thumb =>
pri => -2000,
stat => 1,
sub {
my ($job) = @_;
my $path = $job->{path};
aioreq_pri -2;
aio_stat xvpic $path, sub {
Gtk2::CV::Jobber::submit gen_thumb => $path
unless $job->{stat}[9] == (stat _)[9];
$job->finish;
};
};
# recursively delete a directory
Gtk2::CV::Jobber::define rm_rf =>
pri => 1000,
fork => 1,
hide => 1,
sub {
my ($job) = @_;
#$Gtk2::CV::Jobber::jobs{$job->{path}} = { }; # no further jobs make sense
system "rm", "-rf", $job->{path};
$job->finish;
};
# remove a file, or move it to the unlink directory
Gtk2::CV::Jobber::define unlink =>
pri => 1000,
class => "stat",
hide => 1,
sub {
my ($job) = @_;
$Gtk2::CV::Jobber::jobs{$job->{path}} = { }; # no further jobs make sense
aioreq_pri -2;
aio_unlink $job->{path}, sub {
my $status = shift;
aio_unlink xvpic $job->{path}, sub {
aioreq_pri -4;
aio_rmdir xvdir $job->{path}, sub {
$job->{data} = $status;
$job->finish;
};
};
};
};
Gtk2::CV::Jobber::define unlink_thumb =>
pri => 0,
class => "stat",
sub {
my ($job) = @_;
aioreq_pri -2;
aio_unlink xvpic $job->{path}, sub {
aioreq_pri -4;
aio_rmdir xvdir $job->{path}, sub {
$job->finish;
};
};
};
Gtk2::CV::Jobber::define mv =>
pri => -2000,
class => "read",
hide => 1,
sub {
my ($job) = @_;
my $src = $job->{path};
my $dst = $job->{data};
#TODO: move directories, too
aioreq_pri -4;
aio_stat $dst, sub {
$dst .= "/" . (dirname $src)[1] if -d _;
my $basedst = $dst;
my $idx = 0;
1 while $basedst =~ s/-\d\d\d$//;
my $try_open; $try_open = sub {
aioreq_pri -2;
aio_stat "$dst/.", sub {
if ($_[0] >= 0 || $! != Errno::ENOENT) {
$dst = sprintf "%s-%03d", $basedst, $idx++;
return $try_open->();
}
aioreq_pri -2;
aio_open $dst, O_WRONLY | O_CREAT | O_EXCL, 0200, sub {
if (my $fh = $_[0]) {
close $_[0];
undef $try_open;
aioreq_pri -2;
aio_move $src, $dst, sub {
if ($_[0]) {
print "$src => $dst [ERROR $1]\n";
aio_unlink $dst, sub {
$job->finish;
};
} else {
$job->event (unlink => $src);
$job->event (create => $dst);
aioreq_pri -2;
aio_move xvpic $src, xvpic $dst, sub {
# we do not care about the xvpic being copied correctly
print "$src to $dst\n";
$job->finish;
};
}
};
} elsif ($! == Errno::EEXIST) {
$dst = sprintf "%s-%03d", $basedst, $idx++;
$try_open->();
} else {
undef $try_open;
print "$src => $dst [ERROR $!]\n";
$job->finish;
}
};
};
};
$try_open->();
};
# # TODO: don't use /bin/mv and generate create events.
# system "/bin/mv", "-v", "-b", $path, "$dest/.";
# system "/bin/mv", "-b", xvpic $path, "$dest/.xvpics/."
# if -e xvpic $path;
# $job->event (unlink => $src);
# $job->event (create => $dest);
};
sub jobber_update {
my ($self, $job) = @_;
# update path => index map for faster access
unless (exists $self->{map}) {
my %map;
@map{ map "$_->[0]/$_->[1]", @{$self->{entry}} }
= (0 .. $#{$self->{entry}});
$self->{map} = \%map;
}
exists $self->{map}{$job->{path}}
or return; # not for us
my $idx = $self->{map}{$job->{path}};
if ($job->{type} eq "unlink") {
return if $job->{status};
--$self->{cursor} if $self->{cursor} > $_;
delete $self->{sel}{$idx};
splice @{$self->{entry}}, $idx, 1;
$self->entry_changed;
$self->invalidate_all;
} else {
if ($job->{type} eq "gen_thumb" && exists $job->{data}) {
my $data = Encode::encode "iso-8859-1", $job->{data};
my ($w, $h, $rs) = unpack "SSS", substr $data, -6;
for ($self->{entry}[$idx]) {
$_->[2] &= ~F_HASPM;
$_->[2] |= F_HASXVPIC;
$_->[3] = new_from_data Gtk2::Gdk::Pixbuf $data, 'rgb', 0, 8, $w, $h, $rs;
}
} elsif ($job->{type} eq "unlink_thumb") {
for ($self->{entry}[$idx]) {
$_->[2] &= ~(F_HASPM | F_HASXVPIC);
$_->[3] = undef;
}
} elsif ( $job->{type} eq "rotate" ) {
Gtk2::CV::Jobber::submit gen_thumb => "$job->{path}"
if $self->{entry}[$idx][2] & F_HASXVPIC;
}
$self->draw_entry ($idx);
}
}
sub force_pixmap($$) {
my ($self, $entry) = @_;
return if $entry->[2] & F_HASPM;
if ($entry->[3]) {
my $pb = ARRAY:: eq ref $entry->[3]
? p7_to_pb @{$entry->[3]}
: $entry->[3];
my ($w, $h) = ($pb->get_width, $pb->get_height);
$entry->[3] = new Gtk2::Gdk::Pixmap $self->{window}, $w, $h, -1;
$entry->[3]->draw_pixbuf ($self->style->white_gc, $pb, 0, 0, 0, 0, $w, $h, "max", 0, 0);
} else {
$entry->[3] = $file_img{ $ext_logo{ extension $entry->[1] } } || $file_img;
}
$entry->[2] |= F_HASPM;
}
# prefetch a file after a timeout
sub prefetch {
my ($self, $inc) = @_;
return unless $self->cursor_valid;
my $prefetch = $self->{cursor} + $inc;
return if $prefetch < 0 || $prefetch > $#{$self->{entry}};
my $e = $self->{entry}[$prefetch];
$self->{prefetch} = "$e->[0]/$e->[1]";
remove Glib::Source delete $self->{prefetch_source}
if $self->{prefetch_source};
$self->{prefetch_source} = add Glib::Timeout 100, sub {
my $id = ++$self->{prefetch_aio};
aioreq_pri -1;
aio_open $self->{prefetch}, O_RDONLY, 0, sub {
my $fh = $_[0]
or return;
my $ofs = 0;
$self->{aio_reader} = sub {
return unless $id == $self->{prefetch_aio};
aioreq_pri -1;
aio_read $fh, $ofs, 4096, my $buffer, 0, sub {
return unless $self->{aio_reader};
return delete $self->{aio_reader}
if $_[0] <= 0 || $ofs > 1024*1024;
$ofs += 4096;
$self->{aio_reader}->();
};
};
$self->{aio_reader}->();
};
delete $self->{prefetch_source};
0
};
}
sub prefetch_cancel {
my ($self) = @_;
delete $self->{prefetch};
delete $self->{prefetch_aio};
}
sub coord {
my ($self, $event) = @_;
my $x = $event->x / (IW + IX);
my $y = $event->y / (IH + IY);
(
(max 0, min $self->{cols} - 1, $x),
(max 0, min $self->{page} - 1, $y) + $self->{row},
);
}
sub INIT_INSTANCE {
my ($self) = @_;
$self->{cols} = 1; # just pretend, simplifies code a lot
$self->{page} = 1;
$self->{offs} = 0;
$self->{entry} = [];
$self->push_composite_child;
$self->pack_start (my $hbox = new Gtk2::HBox, 1, 1, 0);
$self->pack_start (new Gtk2::HSeparator, 0, 0, 0);
$self->pack_end (my $labelwindow = new Gtk2::EventBox, 0, 1, 0);
$labelwindow->add ($self->{info} = new Gtk2::Label);
$labelwindow->signal_connect_after (size_request => sub { $_[1]->width (0) });
# the above 3 lines are just to make the text clip to the window *sigh*
$self->{info}->set (selectable => 1, xalign => 0, justify => "left");
$self->signal_connect (destroy => sub { %{$_[0]} = () });
$hbox->pack_start ($self->{draw} = new Gtk2::CV::Schnauzer::DrawingArea, 1, 1, 0);
$hbox->pack_end ($self->{scroll} = new Gtk2::VScrollbar , 0, 0, 0);
$self->{adj} = $self->{scroll}->get ("adjustment");
$self->{adj}->signal_connect (value_changed => sub {
my $row = $_[0]->value;
if (my $diff = $self->{row} - $row) {
$self->{row} = $row;
$self->{offs} = $row * $self->{cols};
if ($self->{window}) {
if ($self->{page} > abs $diff) {
if ($diff > 0) {
$self->{window}->scroll (0, $diff * (IH + IY));
} else {
$self->{window}->scroll (0, $diff * (IH + IY));
}
$self->{window}->process_all_updates;
} else {
$self->invalidate_all;
}
}
}
0
});
#$self->{draw}->set_redraw_on_allocate (0); # nope
$self->{draw}->double_buffered (1);
$self->{draw}->signal_connect (size_request => sub {
$_[1]->width ((IW + IX) * 4);
$_[1]->height ((IH + IY) * 3);
1
});
$self->{draw}->signal_connect_after (realize => sub {
$self->{window} = $_[0]->window;
$self->setadj;
$self->make_visible ($self->{cursor}) if $self->cursor_valid;
0
});
$self->{draw}->signal_connect (configure_event => sub {
$self->{width} = $_[1]->width;
$self->{height} = $_[1]->height;
$self->{cols} = ($self->{width} / (IW + IX)) || 1;
$self->{page} = ($self->{height} / (IH + IY)) || 1;
$self->{row} = ($self->{offs} + $self->{cols} / 2) / $self->{cols};
$self->{offs} = $self->{row} * $self->{cols};
$self->setadj;
$self->{adj}->set_value ($self->{row});
$self->invalidate_all;
1
});
$self->{draw}->signal_connect (expose_event => sub {
$self->expose ($_[1]);
});
$self->{draw}->signal_connect (scroll_event => sub {
my $dir = $_[1]->direction;
$self->prefetch_cancel;
if ($dir eq "down") {
my $value = $self->{adj}->value + $self->{page};
$self->{adj}->set_value ($value <= $self->{maxrow} ? $value : $self->{maxrow});
$self->clear_cursor;
} elsif ($dir eq "up") {
my $value = $self->{adj}->value;
$self->{adj}->set_value ($value >= $self->{page} ? $value - $self->{page} : 0);
$self->clear_cursor;
} else {
return 0;
}
return 1;
});
$self->{draw}->signal_connect (button_press_event => sub {
my ($x, $y) = $self->coord ($_[1]);
my $cursor = $x + $y * $self->{cols};
$self->prefetch_cancel;
if ($_[1]->type eq "button-press") {
if ($_[1]->button == 1) {
$_[0]->grab_focus;
delete $self->{cursor};
unless ($_[1]->state * "shift-mask") {
$self->clear_selection;
$self->invalidate_all;
delete $self->{cursor_current};
$self->{cursor} = $cursor if $cursor < @{$self->{entry}};
}
if ($cursor < @{$self->{entry}} && $self->{sel}{$cursor}) {
delete $self->{sel}{$cursor};
delete $self->{sel_x1};
$self->emit_sel_changed ($_[1]->time);
$self->invalidate (
(($cursor - $self->{offs}) % $self->{cols},
($cursor - $self->{offs}) / $self->{cols}) x 2
);
} else {
($self->{sel_x1}, $self->{sel_y1}) =
($self->{sel_x2}, $self->{sel_y2}) = ($x, $y);
$self->{oldsel} = $self->{sel};
$self->selrect;
}
} elsif ($_[1]->button == 3) {
$self->emit_popup ($_[1],
$cursor < @{$self->{entry}} ? $cursor : undef);
}
} elsif ($_[1]->type eq "2button-press") {
$self->emit_activate ($cursor) if $cursor < @{$self->{entry}};
}
1
});
my $scroll_diff; # for drag & scroll
$self->{draw}->signal_connect (motion_notify_event => sub {
return unless exists $self->{sel_x1};
my ($x, $y) = $self->get_pointer;
$self->{sel_mouse_scroll} ||= [$x, 0];
my $dx = ($self->{sel_mouse_scroll}[0] - $x) / SCROLL_X;
if ($y < - SCROLL_Y) {
$self->sel_scroll ($self->{sel_mouse_scroll}[1] - $dx);
$self->{sel_mouse_scroll}[1] = $dx;
$scroll_diff = -1;
} elsif ($y > $self->{page} * (IH + IY) + SCROLL_Y) {
$self->sel_scroll ($self->{sel_mouse_scroll}[1] - $dx);
$self->{sel_mouse_scroll}[1] = $dx;
$scroll_diff = +1;
} else {
$scroll_diff = 0;
delete $self->{sel_mouse_scroll};
}
$self->{scroll_id} ||= add Glib::Timeout SCROLL_TIME, sub {
$self->sel_scroll ($scroll_diff);
1
};
my ($x, $y) = $self->coord ($_[1]);
if ($x != $self->{sel_x2} || $y != $self->{sel_y2}) {
($self->{sel_x2}, $self->{sel_y2}) = ($x, $y);
$self->selrect;
}
1;
});
$self->{draw}->signal_connect (button_release_event => sub {
delete $self->{oldsel};
remove Glib::Source delete $self->{scroll_id} if exists $self->{scroll_id};
return unless exists $self->{sel_x1};
# nop
1
});
# selection
$self->{sel_widget} = new Gtk2::Invisible;
# we need to hardcode the internal target atom list, as there is no
# other way to get at this info (for drag & drop, it's easy, so, again,
# the normal selection comes as an afterthought at best).
$self->{sel_widget}->selection_add_target (Gtk2::Gdk->SELECTION_PRIMARY, Gtk2::Gdk::Atom->new ($_), 1)
for qw(STRING TEXT UTF8_STRING COMPOUND_TEXT text/plain text/plain;charset=utf-8);
$self->{sel_widget}->signal_connect (selection_get => sub {
my (undef, $data, $info, $time) = @_;
$data->set_text (
join " ",
map /[^\x2b-\x39\x3d\x40-\x5a\x5e-\x5f\x61-\x7a\xa0-]/
? do { s/([\x00-\x1f\"\\!])/\\$1/g; "\"$_\"" }
: $_,
sort
map Glib::filename_to_unicode "$_->[0]/$_->[1]",
values %{$self->{sel} || {}}
);
});
# unnecessary redraws...
$self->{draw}->signal_connect (focus_in_event => sub { 1 });
$self->{draw}->signal_connect (focus_out_event => sub { 1 });
# gtk+ supports no motion compression, a major lacking feature. we have to pay for the
# workaround with incorrect behaviour and extra server-turnarounds.
$self->{draw}->add_events ([qw(button_press_mask button_release_mask button-motion-mask scroll_mask pointer-motion-hint-mask)]);
$self->{draw}->can_focus (1);
$self->signal_connect (key_press_event => sub { $self->handle_key ($_[1]->keyval, $_[1]->state) });
$self->add_events ([qw(key_press_mask key_release_mask)]);
$self->pop_composite_child;
$self->jobber_register;
}
sub do_size_allocate_rounded {
$_[1]->width ($_[1]->width / (IW + IX) * (IW + IX));
$_[1]->height ($_[1]->height / (IH + IY) * (IH + IY));
$_[0]->signal_chain_from_overridden ($_[1]);
}
sub set_geometry_hints {
my ($self) = @_;
my $window = $self->get_toplevel
or return;
my $hints = new Gtk2::Gdk::Geometry;
$hints->base_width (IW + IX); $hints->base_height (IH + IY);
$hints->width_inc (IW + IX); $hints->height_inc (IH + IY);
$window->set_geometry_hints ($self->{draw}, $hints, [qw(base-size resize-inc)]);
}
sub finish_info_update {
my ($self) = @_;
delete $self->{info_text};
(delete $self->{info_updater_group})->cancel
if exists $self->{info_updater_group};
remove Glib::Source delete $self->{info_updater}
if exists $self->{info_updater};
}
sub emit_sel_changed {
my ($self, $time) = @_;
$time ||= Gtk2->get_current_event_time;
$self->finish_info_update;
my $sel = $self->{sel};
if (!$sel || !%$sel) {
Gtk2::Selection->owner_set (undef, Gtk2::Gdk->SELECTION_PRIMARY, $time)
if Gtk2::Gdk::Selection->owner_get (Gtk2::Gdk->SELECTION_PRIMARY) == $self->{sel_widget}->window;
$self->{info}->set_text ("");
} else {
Gtk2::Selection->owner_set ($self->{sel_widget}, Gtk2::Gdk->SELECTION_PRIMARY, $time)
if $time;
if (1 < scalar keys %$sel) {
$self->{info_text} = sprintf "%d entries selected", scalar keys %$sel;
$self->{info}->set_text ("$self->{info_text} (...)");
# start stat'ing all entries after a second
$self->{info_updater} = add Glib::Timeout 300, sub {
my $todo = [values %{ $self->{sel} }];
$self->{info_size} = 0;
$self->{info_disk} = 0;
$self->{info_updater} = add Glib::Timeout 300, sub {
no integer;
$self->{info}->set_text (sprintf "%s (%.6g+%.3gMB, %d entries to stat)", $self->{info_text},
$self->{info_size} / 1e6, $self->{info_disk} / 1e6, scalar @$todo);
1
};
$self->{info_updater_group} = aio_group sub {
no integer;
$self->{info}->set_text (sprintf "%s (%.6g+%.3gMB)", $self->{info_text},
$self->{info_size} / 1e6, $self->{info_disk} / 1e6);
$self->finish_info_update;
};
$self->{info_updater_group}->feed (sub {
my $entry = pop @$todo
or return;
$_[0]->add (aio_stat "$entry->[0]/$entry->[1]", sub {
my ($size, $blocks) = (stat _)[7, 12];
$self->{info_size} += $size;
$self->{info_disk} += $blocks * 512 - $size;
});
});
0
};
} else {
my $entry = $self->{entry}[(keys %$sel)[0]];
my $id = ++$self->{aio_sel_changed};
aioreq_pri 3;
aio_stat "$entry->[0]/$entry->[1]", sub {
return unless $id == $self->{aio_sel_changed};
$self->{info}->set_text (
sprintf "%s: %d bytes, last modified %s (in %s)",
(Glib::filename_display_name $entry->[1]),
-s _,
(strftime "%Y-%m-%d %H:%M:%S", localtime +(stat _)[9]),
(Glib::filename_display_name $entry->[0]),
);
};
}
}
$self->signal_emit (selection_changed => $self->{sel});
}
sub emit_popup {
my ($self, $event, $cursor) = @_;
my $idx = $self->cursor_valid ? $self->{cursor} : $cursor;
my $entry = $self->{entry}[$idx];
my $menu = new Gtk2::Menu;
if (exists $self->{dir}) {
$menu->append (my $i_up = new Gtk2::MenuItem "Parent (^)");
$i_up->signal_connect (activate => sub {
$self->updir;
});
}
my @sel = keys %{$self->{sel}};
@sel = $cursor if !@sel && defined $cursor;
if (@sel) {
$menu->append (my $item = new Gtk2::MenuItem "Do");
$item->set_submenu (my $sel = new Gtk2::Menu);
$sel->append (my $item = new Gtk2::MenuItem @sel . " file(s)");
$item->set_sensitive (0);
$sel->append (my $item = new Gtk2::MenuItem "Generate Thumbnails (Ctrl-G)");
$item->signal_connect (activate => sub { $self->generate_thumbnails (@sel) });
$sel->append (my $item = new Gtk2::MenuItem "Update Thumbnails (Ctrl-U)");
$item->signal_connect (activate => sub { $self->update_thumbnails (@sel) });
$sel->append (my $item = new Gtk2::MenuItem "Remove Thumbnails");
$item->signal_connect (activate => sub { $self->unlink_thumbnails (@sel) });
$sel->append (my $item = new Gtk2::MenuItem "Delete");
$item->set_submenu (my $del = new Gtk2::Menu);
$del->append (my $item = new Gtk2::MenuItem "Physically (Ctrl-D)");
$item->signal_connect (activate => sub { $self->unlink (0, @sel) });
$del->append (my $item = new Gtk2::MenuItem "Physically & Recursive (Ctrl-Shift-D)");
$item->signal_connect (activate => sub { $self->unlink (1, @sel) });
$sel->append (my $item = new Gtk2::MenuItem "Rotate");
$item->set_submenu (my $rotate = new Gtk2::Menu);
$rotate->append (my $item = new Gtk2::MenuItem "90 clockwise");
$item->signal_connect (activate => sub { $self->rotate (90, @sel) });
$rotate->append (my $item = new Gtk2::MenuItem "90 counter-clockwise");
$item->signal_connect (activate => sub { $self->rotate (270, @sel) });
$rotate->append (my $item = new Gtk2::MenuItem "180");
$item->signal_connect (activate => sub { $self->rotate (180, @sel) });
$rotate->append (my $item = new Gtk2::MenuItem "automatic (exif orientation tag)");
$item->signal_connect (activate => sub { $self->rotate ("auto", @sel) });
$self->signal_emit (popup_selected => $menu, \@sel);
}
{
$menu->append (my $item = new Gtk2::MenuItem "Select");
$item->set_submenu (my $sel = new Gtk2::Menu);
$sel->append (my $item = new Gtk2::MenuItem "By Prefix");
$item->set_submenu (my $by_pfx = new Gtk2::Menu);
my $name = $entry->[1];
my %cnt;
$cnt{Gtk2::CV::common_prefix_length $name, $self->{entry}[$_][1]}++
for (max 0, $idx - FAST_RANGE) .. min ($idx + FAST_RANGE, $#{$self->{entry}});
my $sum = 0;
for my $len (reverse 1 .. -2 + length $entry->[1]) {
my $cnt = $cnt{$len}
or next;
$sum += $cnt;
my $pfx = substr $entry->[1], 0, $len;
$by_pfx->append (my $item = new Gtk2::MenuItem "$pfx*\t($sum)");
$item->signal_connect (activate => sub {
delete $self->{sel};
for ((max 0, $idx - FAST_RANGE) .. min ($idx + FAST_RANGE, $#{$self->{entry}})) {
next unless $len <= Gtk2::CV::common_prefix_length $name, $self->{entry}[$_][1];
$self->{sel}{$_} = $self->{entry}[$_];
}
$self->invalidate_all;
});
}
$sel->append (my $item = new Gtk2::MenuItem "Unselect Thumbnailed (Ctrl -)");
$item->signal_connect (activate => sub { $self->selection_remove_thumbnailed });
$sel->append (my $item = new Gtk2::MenuItem "Keep only Thumbnailed (Ctrl +)");
$item->signal_connect (activate => sub { $self->selection_keep_only_thumbnailed });
}
$self->signal_emit (popup => $menu, $cursor, $event);
$_->show_all for $menu->get_children;
$menu->popup (undef, undef, undef, undef, $event->button, $event->time);
}
sub emit_activate {
my ($self, $cursor) = @_;
$self->prefetch_cancel;
my $entry = $self->{entry}[$cursor];
my $path = "$entry->[0]/$entry->[1]";
$self->{cursor_current} = $path;
if (-d $path) {
$self->push_state;
$self->set_dir ($path);
} else {
$self->signal_emit (activate => $path);
}
}
sub make_visible {
my ($self, $offs) = @_;
my $row = $offs / $self->{cols};
$self->{adj}->set_value ($row < $self->{maxrow} ? $row : $self->{maxrow})
if $row < $self->{row} || $row >= $self->{row} + $self->{page};
}
sub draw_entry {
my ($self, $offs) = @_;
my $row = $offs / $self->{cols};
if ($row >= $self->{row} and $row < $self->{row} + $self->{page}) {
$offs -= $self->{offs};
$self->invalidate (
($offs % $self->{cols}, $offs / $self->{cols}) x 2,
);
}
}
sub cursor_valid {
my ($self) = @_;
my $cursor = $self->{cursor};
defined $cursor
&& $self->{sel}{$cursor}
&& $cursor < @{$self->{entry}}
&& $cursor >= $self->{offs}
&& $cursor < $self->{offs} + $self->{page} * $self->{cols};
}
sub cursor_move {
my ($self, $inc, $time) = @_;
my $cursor = $self->{cursor};
my $cursor_prev = $cursor;
if ($self->cursor_valid) {
$self->clear_selection;
my $oldcursor = $cursor;
$cursor += $inc;
$cursor -= $inc if $cursor < 0 || $cursor >= @{$self->{entry}};
if ($cursor < $self->{offs}) {
$self->{adj}->set_value ($self->{row} - 1);
} elsif ($cursor >= $self->{offs} + $self->{page} * $self->{cols}) {
$self->{adj}->set_value ($self->{row} + $self->{page});
}
$self->invalidate (
(($oldcursor - $self->{offs}) % $self->{cols},
($oldcursor - $self->{offs}) / $self->{cols}) x 2
);
} else {
$self->clear_selection;
$cursor_prev = -1;
if (!$cursor) {
if ($inc < 0) {
$cursor = $self->{offs} + $self->{page} * $self->{cols} - 1;
} else {
$cursor = $self->{offs};
$cursor++ while $cursor < $#{$self->{entry}}
&& -d "$self->{entry}[$cursor][0]/$self->{entry}[$cursor][1]/$curdir";
}
} else {
my $entry = $self->{entry}[$cursor];
$cursor += $inc if $self->{cursor_current} eq "$entry->[0]/$entry->[1]";
$cursor -= $inc if $cursor < 0 || $cursor >= @{$self->{entry}};
}
$self->make_visible ($cursor);
}
$self->{cursor} = $cursor;
delete $self->{cursor_current};
$self->{sel}{$cursor} = $self->{entry}[$cursor];
$self->emit_sel_changed;
$self->invalidate (
(($cursor - $self->{offs}) % $self->{cols},
($cursor - $self->{offs}) / $self->{cols}) x 2
);
$cursor_prev != $cursor
}
sub clear_cursor {
my ($self) = @_;
if (
defined (my $cursor = delete $self->{cursor})
&& 1 >= scalar keys %{ $self->{sel} }
) {
delete $self->{sel}{$cursor};
$self->emit_sel_changed;
$self->draw_entry ($cursor);
}
}
sub clear_selection {
my ($self) = @_;
delete $self->{cursor};
$self->draw_entry ($_) for keys %{delete $self->{sel} || {}};
$self->emit_sel_changed;
}
sub get_selection {
my ($self) = @_;
$self->{sel};
}
=item $schnauzer->rotate( degrees, idx[, idx...])
Rotate the raw images on the given entries.
=cut
sub rotate {
my ($self, $degrees, @idx) = @_;
Gtk2::CV::Jobber::inhibit {
for (sort { $b <=> $a } @idx) {
my $e = $self->{entry}[$_];
Gtk2::CV::Jobber::submit rotate => "$e->[0]/$e->[1]", $degrees;
delete $self->{sel}{$_};
}
$self->invalidate_all;
$self->emit_sel_changed;
};
}
=item $schnauzer->generate_thumbnails (idx[, idx...])
Generate (unconditionally) the thumbnails on the given entries.
=cut
sub generate_thumbnails {
my ($self, @idx) = @_;
Gtk2::CV::Jobber::inhibit {
for (sort { $b <=> $a } @idx) {
my $e = $self->{entry}[$_];
Gtk2::CV::Jobber::submit gen_thumb => "$e->[0]/$e->[1]";
delete $self->{sel}{$_};
}
$self->invalidate_all;
$self->emit_sel_changed;
};
}
=item $schnauzer->update_thumbnails (idx[, idx...])
Update (if needed) the thumbnails on the given entries.
=cut
sub update_thumbnails {
my ($self, @idx) = @_;
Gtk2::CV::Jobber::inhibit {
for (sort { $b <=> $a } @idx) {
my $e = $self->{entry}[$_];
Gtk2::CV::Jobber::submit upd_thumb => "$e->[0]/$e->[1]";
delete $self->{sel}{$_};
}
$self->invalidate_all;
$self->emit_sel_changed;
};
}
=item $schnauzer->unlink_thumbnails (idx[, idx...])
Physically remove the thumbnails on the given entries.
=cut
sub unlink_thumbnails {
my ($self, @idx) = @_;
Gtk2::CV::Jobber::inhibit {
for (sort { $b <=> $a } @idx) {
my $e = $self->{entry}[$_];
Gtk2::CV::Jobber::submit unlink_thumb => "$e->[0]/$e->[1]";
delete $self->{sel}{$_};
}
$self->invalidate_all;
$self->emit_sel_changed;
};
}
=item $schnauzer->selection_remove_thumbnailed
=item $schnauzer->selection_keep_only_thumbnailed
=cut
sub selection_remove_thumbnailed {
my ($self) = @_;
for (keys %{$self->{sel}}) {
delete $self->{sel}{$_} if $self->{entry}[$_][2] & F_HASXVPIC;
}
$self->invalidate_all;
$self->emit_sel_changed;
}
sub selection_keep_only_thumbnailed {
my ($self) = @_;
for (keys %{$self->{sel}}) {
delete $self->{sel}{$_} unless $self->{entry}[$_][2] & F_HASXVPIC;
}
$self->invalidate_all;
$self->emit_sel_changed;
}
=item $schnauzer->unlink (recursive, idx[, idx...])
Physically delete the given entries.
=cut
sub unlink {
my ($self, $recursive, @idx) = @_;
Gtk2::CV::Jobber::inhibit {
for (sort { $b <=> $a } @idx) {
my $e = $self->{entry}[$_];
if (exists $ENV{CV_TRASHCAN} and !$recursive) {
Gtk2::CV::Jobber::submit mv => "$e->[0]/$e->[1]", $ENV{CV_TRASHCAN};
} elsif ($e->[2] & F_ISDIR) {
Gtk2::CV::Jobber::submit rm_rf => "$e->[0]/$e->[1]"
if $recursive;
} else {
Gtk2::CV::Jobber::submit unlink => "$e->[0]/$e->[1]";
}
--$self->{cursor} if $self->{cursor} > $_;
delete $self->{sel}{$_};
splice @{$self->{entry}}, $_, 1, ();
}
$self->entry_changed;
$self->setadj;
$self->emit_sel_changed;
$self->invalidate_all;
};
}
sub handle_key {
my ($self, $key, $state) = @_;
$self->prefetch_cancel;
if ($state * "control-mask") {
if ($key == $Gtk2::Gdk::Keysyms{g}) {
my @sel = keys %{$self->{sel}};
$self->generate_thumbnails (@sel ? @sel : 0 .. $#{$self->{entry}});
} elsif ($key == $Gtk2::Gdk::Keysyms{a}) {
$self->select_all;
} elsif ($key == $Gtk2::Gdk::Keysyms{A}) {
$self->select_range ($self->{offs}, $self->{offs} + $self->{cols} * $self->{page} - 1);
} elsif ($key == $Gtk2::Gdk::Keysyms{s}) {
$self->rescan;
} elsif ($key == $Gtk2::Gdk::Keysyms{d}) {
$self->unlink (0, keys %{$self->{sel}});
} elsif ($key == $Gtk2::Gdk::Keysyms{D}) {
$self->unlink (1, keys %{$self->{sel}});
} elsif ($key == $Gtk2::Gdk::Keysyms{u}) {
my @sel = keys %{$self->{sel}};
$self->update_thumbnails (@sel ? @sel : 0 .. $#{$self->{entry}});
} elsif ($key == ord '-') {
$self->selection_remove_thumbnailed;
} elsif ($key == ord '+') {
$self->selection_keep_only_thumbnailed;
} elsif ($key == $Gtk2::Gdk::Keysyms{Return}) {
$self->cursor_move (0) unless $self->cursor_valid;
$self->emit_activate ($self->{cursor});
} elsif ($key == $Gtk2::Gdk::Keysyms{space}) {
$self->cursor_move (1) or return 1
if $self->{cursor_current} || !$self->cursor_valid;
$self->emit_activate ($self->{cursor}) if $self->cursor_valid;
$self->prefetch (1);
} elsif ($key == $Gtk2::Gdk::Keysyms{BackSpace}) {
$self->cursor_move (-1) or return 1;
$self->emit_activate ($self->{cursor}) if $self->cursor_valid;
$self->prefetch (-1);
} else {
return 0;
}
} else {
if ($key == $Gtk2::Gdk::Keysyms{Page_Up}) {
my $value = $self->{adj}->value;
$self->{adj}->set_value ($value >= $self->{page} ? $value - $self->{page} : 0);
$self->clear_cursor;
} elsif ($key == $Gtk2::Gdk::Keysyms{Page_Down}) {
my $value = $self->{adj}->value + $self->{page};
$self->{adj}->set_value ($value <= $self->{maxrow} ? $value : $self->{maxrow});
$self->clear_cursor;
} elsif ($key == $Gtk2::Gdk::Keysyms{Home}) {
$self->{adj}->set_value (0);
$self->clear_cursor;
} elsif ($key == $Gtk2::Gdk::Keysyms{End}) {
$self->{adj}->set_value ($self->{maxrow});
$self->clear_cursor;
} elsif ($key == $Gtk2::Gdk::Keysyms{Up}) {
$self->cursor_move (-$self->{cols});
} elsif ($key == $Gtk2::Gdk::Keysyms{Down}) {
$self->cursor_move (+$self->{cols});
} elsif ($key == $Gtk2::Gdk::Keysyms{Left}) {
$self->cursor_move (-1);
} elsif ($key == $Gtk2::Gdk::Keysyms{Right}) {
$self->cursor_move (+1);
} elsif ($key == $Gtk2::Gdk::Keysyms{Return}) {
$self->cursor_move (0) unless $self->cursor_valid;
$self->emit_activate ($self->{cursor});
} elsif ($key == $Gtk2::Gdk::Keysyms{space}) {
$self->cursor_move (1) or return 1
if $self->{cursor_current} || !$self->cursor_valid;
$self->emit_activate ($self->{cursor}) if $self->cursor_valid;
$self->prefetch (1);
} elsif ($key == $Gtk2::Gdk::Keysyms{BackSpace}) {
$self->cursor_move (-1) or return 1;
$self->emit_activate ($self->{cursor}) if $self->cursor_valid;
$self->prefetch (-1);
} elsif ($key == ord '^') {
$self->updir if exists $self->{dir};
} elsif (($key >= (ord '0') && $key <= (ord '9'))
|| ($key >= (ord 'a') && $key <= (ord 'z'))) {
$key = chr $key;
my ($idx, $cursor) = (0, 0);
$self->clear_selection;
for my $entry (@{$self->{entry}}) {
$idx++;
$cursor = $idx if $key gt lcfirst $entry->[1];
}
if ($cursor < @{$self->{entry}}) {
delete $self->{cursor_current};
$self->{sel}{$cursor} = $self->{entry}[$cursor];
$self->{cursor} = $cursor;
$self->{adj}->set_value (min $self->{maxrow}, $cursor / $self->{cols});
$self->emit_sel_changed;
$self->invalidate_all;
}
} else {
return 0;
}
}
1
}
sub invalidate {
my ($self, $x1, $y1, $x2, $y2) = @_;
return unless $self->{window};
$self->{draw}->queue_draw_area (
$x1 * (IW + IX), $y1 * (IH + IY),
($x2 - $x1 + 1) * (IW + IX), ($y2 - $y1 + 1) * (IH + IY),
);
}
sub invalidate_all {
my ($self) = @_;
$self->invalidate (0, 0, $self->{cols} - 1, $self->{page} - 1);
}
sub select_range {
my ($self, $a, $b) = @_;
for ($a .. $b) {
next if 0 > $_ || $_ > $#{$self->{entry}};
$self->{sel}{$_} = $self->{entry}[$_];
$self->draw_entry ($_);
}
$self->emit_sel_changed;
}
sub select_all {
my ($self) = @_;
$self->select_range (0, $#{$self->{entry}});
}
=item $schnauer->entry_changed
This method needs to be called whenever the C<< $schnauzer->{entry} >>
member has been changed in any way.
=cut
sub entry_changed {
my ($self) = @_;
# remove entries for which a job exists that will eventually delete it (hide attr)
{
my %delete;
@delete{ grep exists $Gtk2::CV::Jobber::hide{"$_->[0]/$_->[1]"}, @{ $self->{entry} } }
= ();
# a rare event
$self->{entry} = [grep !exists $delete{$_}, @{ $self->{entry} }]
if %delete;
}
delete $self->{map};
delete $self->{idle_check_done};
delete $self->{idle_check_idx};
delete $self->{idle_check};
$self->{generation}++;
$self->start_idle_check;
}
sub selrect {
my ($self) = @_;
return unless $self->{oldsel};
my ($x1, $y1) = ($self->{sel_x1}, $self->{sel_y1});
my ($x2, $y2) = ($self->{sel_x2}, $self->{sel_y2});
my $prev = $self->{sel};
$self->{sel} = { %{$self->{oldsel}} };
if (0) {
# rectangular selection
($x1, $x2) = ($x2, $x1) if $x1 > $x2;
($y1, $y2) = ($y2, $y1) if $y1 > $y2;
outer:
for my $y ($y1 .. $y2) {
my $idx = $y * $self->{cols};
for my $x ($x1 .. $x2) {
my $idx = $idx + $x;
last outer if $idx > $#{$self->{entry}};
$self->{sel}{$idx} = $self->{entry}[$idx];
}
}
} else {
# range selection
my $a = $x1 + $y1 * $self->{cols};
my $b = $x2 + $y2 * $self->{cols};
($a, $b) = ($b, $a) if $a > $b;
for my $idx ($a .. $b) {
last if $idx > $#{$self->{entry}};
$self->{sel}{$idx} = $self->{entry}[$idx];
}
}
$self->emit_sel_changed;
for my $idx (keys %{$self->{sel}}) {
$self->draw_entry ($idx) if !exists $prev->{$idx};
}
for my $idx (keys %$prev) {
$self->draw_entry ($idx) if !exists $self->{sel}{$idx};
}
}
sub sel_scroll {
my ($self, $row_diff) = @_;
my $row = $self->{row} + $row_diff;
$row = max 0, min $row, $self->{maxrow};
if ($self->{row} != $row) {
$self->{sel_y2} += $row - $self->{row};
$self->selrect;
$self->{adj}->set_value ($row);
}
}
sub setadj {
my ($self) = @_;
no integer;
$self->{rows} = ceil @{$self->{entry}} / $self->{cols};
$self->{maxrow} = $self->{rows} - $self->{page};
$self->{adj}->step_increment (1);
$self->{adj}->page_size ($self->{page});
$self->{adj}->page_increment ($self->{page});
$self->{adj}->lower (0);
$self->{adj}->upper ($self->{rows});
$self->{adj}->changed;
$self->{adj}->set_value ($self->{maxrow})
if $self->{row} > $self->{maxrow};
}
sub expose {
my ($self, $event) = @_;
no integer;
return unless @{$self->{entry}};
my ($x1, $y1, $x2, $y2) = $event->area->values; # x y w h
$self->{window}->clear_area ($x1, $y1, $x2, $y2);
$x2 += $x1;
$y2 += $y1;
$x1 -= IW + IX;
$y1 -= IH + IY;
my @x = map $_ * (IW + IX), 0 .. $self->{cols} - 1;
my @y = map $_ * (IH + IY), 0 .. $self->{page} - 1;
# 'orrible, why do they deprecate _convinience_ functions? :(
my $context = $self->get_pango_context;
my $font = $context->get_font_description;
$font->set_absolute_size (FY * Gtk2::Pango->scale);
my $maxwidth = IW + IX * 0.85;
my $idx = $self->{offs} + 0;
my $layout = new Gtk2::Pango::Layout $context;
$layout->set_ellipsize ('end');
$layout->set_width ($maxwidth * Gtk2::Pango->scale);
my $first_unchecked;
my @jobs;
my $black_gc = $self->style->black_gc;
my $white_gc = $self->style->white_gc;
outer:
for my $y (@y) {
for my $x (@x) {
if ($y > $y1 && $y < $y2
&& $x > $x1 && $x < $x2) {
my $entry = $self->{entry}[$idx];
my $path = "$entry->[0]/$entry->[1]";
my $text_gc;
# selected?
if (exists $self->{sel}{$idx}) {
$self->{window}->draw_rectangle ($black_gc, 1,
$x, $y, IW + IX, IH + IY);
$text_gc = $white_gc;
} else {
$text_gc = $black_gc;
}
if (exists $Gtk2::CV::Jobber::jobs{$path}) {
push @jobs, $path;
$self->{window}->draw_rectangle ($self->style->dark_gc ('normal'), 1,
$x + IX * 0.1, $y, IW + IX * 0.8, IH);
}
# pre-render thumb into pixmap
$self->force_pixmap ($entry)
unless $entry->[2] & F_HASPM;
$first_unchecked = $idx
unless $entry->[2] & F_CHECKED || defined $first_unchecked;
my $pm = $entry->[3];
my ($pw, $ph) = $pm->get_size;
$self->{window}->draw_drawable ($white_gc,
$pm,
0, 0,
$x + (IX + IW - $pw) * 0.5,
$y + ( IH - $ph) * 0.5,
$pw, $ph);
utf8::downgrade $entry->[1];#d# ugly workaround for Glib-bug, costs performance like nothing :)
$layout->set_text (Glib::filename_display_name $entry->[1]);
my ($w, $h) = $layout->get_pixel_size;
$self->{window}->draw_layout (
$text_gc,
$x + (IX + IW - $w) * 0.5, $y + IH,
$layout
);
}
last outer if ++$idx >= @{$self->{entry}};
}
}
push @Gtk2::CV::Jobber::jobs, reverse @jobs;
$self->{idle_check_idx} = $first_unchecked
if defined $first_unchecked;
1;
}
sub do_activate {
# nop
}
sub start_idle_check {
my ($self) = @_;
return if $self->{idle_check_done};
$self->realize;
# $self->{inhibit_guard} ||= Gtk2::CV::Jobber::inhibit_guard;
$self->{idle_check_watcher} ||= add Glib::Idle sub { $self->idle_check }, undef, 500;
}
sub idle_check {
my ($self) = @_;
my $generation = $self->{generation};
my $todo = FAST_RANGE;
while (--$todo) {
my $idx = $self->{idle_check_idx}++;
if ($idx >= @{$self->{entry}}) {
if ($self->{idle_check}) {
# found a file, restart scan
delete $self->{idle_check};
delete $self->{idle_check_idx};
return 1;
}
# finish
$self->{idle_check_done}++;
delete $self->{idle_check_watcher};
return 0;
}
my $entry = $self->{entry}[$idx];
unless ($entry->[2] & F_CHECKED) {
$entry->[2] |= F_CHECKED;
my $path = "$entry->[0]/$entry->[1]";
$self->{idle_check}++;
if ($entry->[2] & F_HASXVPIC) {
my $xvpic = xvpic $path;
aioreq_pri -1;
aio_open $xvpic, O_RDONLY, 0, sub {
return unless $generation == $self->{generation};
my $fh = shift
or return $self->start_idle_check;
aioreq_pri 0;
aio_readahead $fh, 0, -s $fh, sub {
return unless $generation == $self->{generation};
$entry->[2] &= ~F_HASPM;
$entry->[3] = read_thumb $xvpic;
$self->draw_entry ($idx);
$self->start_idle_check;
};
};
} elsif ($entry->[2] & F_ISDIR) {
aioreq_pri -1;
aio_lstat $path, sub {
return unless $generation == $self->{generation};
my $is_symlink = -l _;
aioreq_pri -1;
aio_stat "$path/.xvpics", sub { # hopefully this pulls in part of the dir
return unless $generation == $self->{generation};
my $has_xvpics = -d _;
my $is_empty = 1;
opendir my $fh, $path #d# no clue how to do this sensibly with IO::AIO
or return;
while (defined ($_ = readdir $fh)) {
next if $_ eq $updir;
next if $_ eq $curdir;
next if $_ eq ".xvpics";
$is_empty = 0;
last;
}
closedir $fh;
my $img = $directory_visited{$path} ? $dirv_img : $diru_img;
$img = $self->img_combine ($img, $dire_layer) if $is_empty;
$img = $self->img_combine ($img, $dirx_layer) if $has_xvpics;
$img = $self->img_combine ($img, $dirs_layer) if $is_symlink;
$entry->[2] |= F_HASPM;
$entry->[3] = $img;
$self->draw_entry ($idx);
$self->start_idle_check;
};
};
} else {
aioreq_pri -1;
aio_stat $path, sub {
return unless $generation == $self->{generation};
if (-d _) {
$entry->[2] |= F_ISDIR;
$entry->[2] &= ~F_CHECKED;
}
$self->start_idle_check;
};
}
delete $self->{idle_check_watcher};
return 0;
}
}
return 1;
}
sub do_chpaths {
# nop
}
sub chpaths {
my ($self, $paths, $nosort, $cb) = @_;
$self->{chpaths_grp}->cancel if $self->{chpaths_grp};
my $all_group = $self->{chpaths_grp} = aio_group $cb;
$self->set_sensitive (0);
my $inhibit_guard = Gtk2::CV::Jobber::inhibit_guard;
my $base = $self->{dir};
delete $self->{cursor_current};
delete $self->{cursor};
delete $self->{sel};
delete $self->{map};
$self->{entry} = [];
$self->entry_changed;
$self->emit_sel_changed;
my %exclude = ($curdir => 0, $updir => 0, ".xvpics" => 0);
my %xvpics;
my $leaves = -1;
my $order = $paths;
### phase 1, .xvpics scan, if applicable, and sort
my $grp = add $all_group aio_group;
if (defined $base) {
$leaves = (stat $base)[3];
$leaves -= 2; # . and ..
add $grp aio_readdir "$base/.xvpics", sub {
return unless $_[0];
$leaves--; # .xvpics itself
$xvpics{$_}++ for @{ $_[0] };
};
add $grp aio_nop sub {
# try stat'ing entries that look like directories first
my (@d, @f);
for (@$paths) {
$_->[1] =~ /.\./ ? push @f, $_ : push @d, $_
}
push @d, @f;
$paths = \@d;
};
}
cb $grp sub {
my $grp = add $all_group aio_group;
limit $grp 32;
my $progress = new Gtk2::CV::Progress title => "scanning...", work => scalar @$paths;
my (@d, @f);
my $i = 0;
feed $grp sub {
while ($i < @$paths) {
my ($dir, $file) = @{ $paths->[$i++] };
# work around bugs in the Glib module early
utf8::downgrade $dir;
utf8::downgrade $file;
if (exists $exclude{$file}) {
# ignore
$progress->increment;
} elsif ($base eq $dir ? exists $xvpics{$file} : -r "$dir/.xvpics/$file") {
delete $xvpics{$file};
push @f, [$dir, $file, F_HASXVPIC];
$progress->increment;
} elsif ($leaves) {
# this is faster than a normal stat on many modern filesystems
add $grp aio_stat "$dir/$file/$curdir", sub {
if (!$_[0]) { # no error
add $grp aio_lstat "$dir/$file", sub {
if (-d _) {
$leaves--;
push @d, [$dir, $file, F_ISDIR | F_HASPM, $directory_visited{"$dir/$file"} ? $dirv_img : $diru_img];
} else {
push @f, [$dir, $file];
}
$progress->increment;
};
} elsif ($! == ENOTDIR) {
push @f, [$dir, $file];
$progress->increment;
} else {
# does not exist:
# ELOOP, ENAMETOOLONG => symlink pointing nowhere
# ENOENT => entry does not even exist
# EACCESS, EIO, EOVERFLOW => we have to give up
$progress->increment;
}
};
return;
} else {
push @f, [$dir, $file, undef, 0];
$progress->increment;
}
}
};
cb $grp sub {
$progress->set_title ("sorting...");
if ($nosort) {
for (\@d, \@f) {
my %h = map +("$_->[0]/$_->[1]" => $_), @$_;
@$_ = grep $_, map $h{"$_->[0]/$_->[1]"}, @$order;
}
} else {
for (\@d, \@f) {
@$_ = map $_->[1],
sort { $a->[0] cmp $b->[0] }
map [foldcase $_->[1], $_],
@$_;
}
}
$self->{entry} = [@d, @f];
$self->entry_changed;
$self->{adj}->set_value (0);
$self->setadj;
$self->{draw}->queue_draw;
# remove extraneous .xvpics files (but only after an extra check)
my @xvpics = keys %xvpics;
$progress = new Gtk2::CV::Progress title => "clean thumbails...", work => scalar @xvpics;
my $grp = aio_group sub {
# try to nuke .xvpics at the end, maybe it is empty
rmdir "$base/.xvpics";
};
limit $grp 1;
feed $grp sub {
@xvpics or return;
my $file = pop @xvpics;
aioreq_pri -3;
add $grp aio_stat "$base/$file", sub {
if (!$_[0] || -d _) {
$progress->increment;
} else {
aioreq_pri -3;
add $grp aio_unlink "$self->{dir}/.xvpics/$file", sub {
$progress->increment;
};
}
};
};
delete $self->{chpaths_grp};
undef $inhibit_guard;
$self->set_sensitive (1);
$self->{draw}->grab_focus unless eval { $self->get_toplevel->get_focus };
$self->start_idle_check;
$self->signal_emit ("chpaths");
};
};
}
sub set_paths {
my ($self, $paths, $nosort, $cb) = @_;
$paths = [
map /^(.*)\/([^\/]*)$/s
? [$1, $2]
: [$curdir, $_],
@$paths
];
delete $self->{dir};
$self->chpaths ($paths, $nosort, sub {
$self->window->property_delete (Gtk2::Gdk::Atom->intern ("_X_CWD", 0))
if $self->window;
$cb->() if $cb;
});
}
sub do_chdir {
# nop
}
sub chdir {
my ($self, $dir, $cb) = @_;
$dir = Cwd::abs_path $dir;
$directory_visited{$dir}++;
-d $dir
or die "$dir: $!";
$self->realize;
$self->window->property_change (
Gtk2::Gdk::Atom->intern ("_X_CWD", 0),
Gtk2::Gdk::Atom->intern ("STRING", 0),
Gtk2::Gdk::CHARS, 'replace',
$dir,
);
aio_readdirx $dir, IO::AIO::READDIR_STAT_ORDER, sub {
$self->{dir} = $dir;
$self->chpaths ([map [$dir, $_], @{$_[0]}], 0, $cb);
$self->signal_emit (chdir => $dir);
};
}
=item $schnauzer->set_dir ($path[, $cb])
Replace the schnauzer contents by the files in the given directory.
=cut
sub set_dir {
my ($self, $dir, $cb) = @_;
$self->chdir ($dir, $cb);
}
=item $state = $schnauzer->get_state
=item $schnauzer->set_state ($state)
Saves/restores (some of) the current state, such as the current directory
or currently displayed files.
Can be used to switch temporarily to another directory.
=cut
sub get_state {
my ($self) = @_;
exists $self->{dir}
? [$self->{dir}, undef]
: [undef, $self->get_paths]
}
sub set_state {
my ($self, $state, $cb) = @_;
my ($dir, $paths) = @$state;
if ($paths) {
$self->set_paths ($paths, $cb);
} else {
$self->set_dir ($dir, $cb);
}
}
=item $schnauzer->push_state
=item $schnauzer->pop_state ([$cb])
Pushes/pops the state from the state stack. Is used internally to
implement recursing into subdirectories and returning.
=cut
sub push_state {
my ($self) = @_;
push @{ $self->{state_stack} ||= [] }, $self->get_state;
}
sub pop_state {
my ($self, $cb) = @_;
$self->set_state (pop @{ $self->{state_stack} }, $cb);
}
sub updir {
my ($self) = @_;
if (@{ $self->{state_stack} || [] }) {
$self->pop_state;
} elsif (exists $self->{dir}) {
$self->set_dir (parent_dir $self->{dir});
}
}
sub get_paths {
my ($self) = @_;
[ map "$_->[0]/$_->[1]", @{$self->{entry}} ]
}
sub rescan {
my ($self) = @_;
if ($self->{dir}) {
$self->set_dir ($self->{dir});
} else {
$self->set_paths ($self->get_paths);
}
}
=back
=head1 AUTHOR
Marc Lehmann <schmorp@schmorp.de>
=cut
1