diff --git a/bin/shutter b/bin/shutter index ebf2715d..3160a08f 100755 --- a/bin/shutter +++ b/bin/shutter @@ -670,14 +670,6 @@ sub STARTUP { $sm->{_menuitem_tooltip}->signal_connect('activate', \&evt_take_screenshot, 'tooltip'); $sm->{_menuitem_iclipboard}->signal_connect('activate', \&fct_clipboard_import); - unless ($x11_supported) { - # on Wayland selection/window/awindow go through the XDG portal; - # only menu/tooltip capture stay disabled (not supported by the portal) - for my $name ('menu', 'tooltip') { - $sm->{"_menuitem_$name"}->set_sensitive(FALSE); - } - } - #gnome web photo is optional, don't enable it when gnome-web-photo is not in PATH if ($gnome_web_photo) { $sm->{_menuitem_web}->set_sensitive(TRUE); @@ -969,11 +961,9 @@ sub STARTUP { } unless ($x11_supported) { - my $wayland_warning = Gtk3::Label->new; - $wayland_warning->set_line_wrap(TRUE); - $wayland_warning->set_markup($d->get("Wayland support is limited, for more advanced screenshots please switch back to Xorg. Click here for details. Selection and window -modes are currently experimental, your desktop environment might fall back to an interactive mode chooser or full screen capture.")); - $vbox->pack_start($wayland_warning, FALSE, TRUE, 0); + my $wayland_info = Gtk3::Label->new($d->get("Available capture modes on Wayland depend on your desktop environment. Some modes require manual selection.")); + $wayland_info->set_line_wrap(TRUE); + $vbox->pack_start($wayland_info, FALSE, TRUE, 0); } $vbox->pack_start($st->create_toolbar, FALSE, TRUE, 0); @@ -1028,15 +1018,8 @@ modes are currently experimental, your desktop environment might fall back to an $st->{_upload}->set_sensitive(FALSE); unless ($x11_supported) { - # on Wayland _select/_window use the XDG portal; menu/tooltip stay disabled - my $tooltip = $d->get("Can't take screenshots without X11 server"); for my $name ('_menu', '_tooltip') { - $st->{$name}->set_sensitive(FALSE); - $st->{$name}->set_tooltip_text($tooltip); - } - # dropdown menus (workspace list / window list) still need X11 to enumerate - for my $name ('_full', '_window') { - $st->{$name}->set_arrow_tooltip_text($tooltip); + $st->{$name}->set_tooltip_text($d->get("Capture after a delay, then select the menu or tooltip in the frozen screenshot")); } } @@ -2053,15 +2036,10 @@ modes are currently experimental, your desktop environment might fall back to an 'menu_delay_changed' ); - if ($x11_supported) { - if (defined $settings_xml->{'general'}->{'menu_delay'}) { - $menu_delay->set_value($settings_xml->{'general'}->{'menu_delay'}); - } else { - $menu_delay->set_value(10); - } + if (defined $settings_xml->{'general'}->{'menu_delay'} && $settings_xml->{'general'}->{'menu_delay'} > 0) { + $menu_delay->set_value($settings_xml->{'general'}->{'menu_delay'}); } else { - $menu_delay->set_value(0); - $menu_delay->set_sensitive(FALSE); + $menu_delay->set_value(10); } $menu_delay->set_tooltip_text($d->get("Capture menu/tooltip after a delay of n seconds")); @@ -3257,11 +3235,10 @@ modes are currently experimental, your desktop environment might fall back to an fct_control_signals('unblock'); return TRUE; } elsif (!$x11_supported - && $data !~ /^(tray_)?(full|select|window|awindow)$/ + && $data !~ /^(tray_)?(full|select|window|awindow|menu|tooltip)$/ && $data !~ /^shutter_monitor_direct/ && $data !~ /^redoshot/) { - # full/select/window/awindow/monitor/redo are served via the XDG portal or supported on Wayland; - # everything else (menu, tooltip, ...) still needs X11 + # Supported captures use the XDG portal; other modes still need X11. my $sd = Shutter::App::SimpleDialogs->new; $sd->dlg_error_message($d->get("Can't take screenshots without X11 server"), $d->get("Failed")); fct_control_signals('unblock'); @@ -3278,7 +3255,7 @@ modes are currently experimental, your desktop environment might fall back to an my $scd_text; if ($data eq "menu" || $data eq "tray_menu") { $scd_text = $d->get("Please activate the menu you want to capture"); - if ($ENV{GDK_SCALE} > 1) { + if ($x11_supported && $ENV{GDK_SCALE} > 1) { my $sd = Shutter::App::SimpleDialogs->new; $sd->dlg_info_message("Capturing a cascading menu is known to be broken with HiDPI.\nPlease unset GDK_SCALE variable and restart Shutter.\nPlease follow https://github.com/shutter-project/shutter/issues/326 and send us the patch\nWill attempt capturing it anyway...", undef, 'gtk-ok'); } @@ -4543,28 +4520,24 @@ modes are currently experimental, your desktop environment might fall back to an #enable/disable controls if ($st->{_select} && $sm->{_menuitem_selection}) { - # if $x11_supported is false, we are on Wayland, and all these buttons are already disabled and should stay disabled + # These capture modes are supported on both X11 and Wayland. #menu - if ($x11_supported) { - $sm->{_menuitem_selection}->set_sensitive($sensitive); - $sm->{_menuitem_window}->set_sensitive($sensitive); - #$sm->{_menuitem_section}->set_sensitive($sensitive); - $sm->{_menuitem_menu}->set_sensitive($sensitive); - $sm->{_menuitem_tooltip}->set_sensitive($sensitive); - } + $sm->{_menuitem_selection}->set_sensitive($sensitive); + $sm->{_menuitem_window}->set_sensitive($sensitive); + #$sm->{_menuitem_section}->set_sensitive($sensitive); + $sm->{_menuitem_menu}->set_sensitive($sensitive); + $sm->{_menuitem_tooltip}->set_sensitive($sensitive); $sm->{_menuitem_web}->set_sensitive($sensitive) if ($gnome_web_photo); $sm->{_menuitem_iclipboard}->set_sensitive($sensitive); #toolbar - if ($x11_supported) { - $st->{_select}->set_sensitive($sensitive); - $st->{_window}->set_sensitive($sensitive); - #$st->{_section}->set_sensitive($sensitive); - $st->{_menu}->set_sensitive($sensitive); - $st->{_tooltip}->set_sensitive($sensitive); - } + $st->{_select}->set_sensitive($sensitive); + $st->{_window}->set_sensitive($sensitive); + #$st->{_section}->set_sensitive($sensitive); + $st->{_menu}->set_sensitive($sensitive); + $st->{_tooltip}->set_sensitive($sensitive); $st->{_web}->set_sensitive($sensitive) if ($gnome_web_photo); #special case: redoshot (toolbar and menu) @@ -6358,7 +6331,8 @@ modes are currently experimental, your desktop environment might fall back to an my $target; # capture mode sent to xdg-portal my $interactive = 1; # whether to use the desktop environment's GUI (only for the time till DEs support non-interactive calls) - if ($data eq "full" || $data eq "tray_full") { + my $popup_mode = Shutter::Screenshot::Wayland::popup_mode($data); + if ($data eq "full" || $data eq "tray_full" || defined $popup_mode) { $target = 1; } elsif ($data eq "window" || $data eq "tray_window") { $target = 2; @@ -6377,7 +6351,7 @@ modes are currently experimental, your desktop environment might fall back to an } } - $screenshooter = Shutter::Screenshot::Wayland->new($sc, $interactive, $target, $monitor); + $screenshooter = Shutter::Screenshot::Wayland->new($sc, $interactive, $target, $monitor, $popup_mode, $menu_delay->get_value); $screenshot = $screenshooter->xdg_portal(); } } else { @@ -8958,7 +8932,7 @@ modes are currently experimental, your desktop environment might fall back to an #menu my $menuitem_window_menu = Gtk3::ImageMenuItem->new_with_mnemonic($d->get('_Menu')); - $menuitem_window_menu->set_sensitive($x11_supported); + $menuitem_window_menu->set_sensitive(TRUE); if ($traytheme->has_icon('alacarte')) { $menuitem_window_menu->set_image(Gtk3::Image->new_from_icon_name('alacarte', 'menu')); } else { @@ -8971,7 +8945,7 @@ modes are currently experimental, your desktop environment might fall back to an #tooltip my $menuitem_window_tooltip = Gtk3::ImageMenuItem->new_with_mnemonic($d->get('_Tooltip')); - $menuitem_window_tooltip->set_sensitive($x11_supported); + $menuitem_window_tooltip->set_sensitive(TRUE); if ($traytheme->has_icon('help-faq')) { $menuitem_window_tooltip->set_image(Gtk3::Image->new_from_icon_name('help-faq', 'menu')); } else { diff --git a/share/shutter/resources/modules/Shutter/Screenshot/Popup.pm b/share/shutter/resources/modules/Shutter/Screenshot/Popup.pm new file mode 100644 index 00000000..04c5f2d2 --- /dev/null +++ b/share/shutter/resources/modules/Shutter/Screenshot/Popup.pm @@ -0,0 +1,72 @@ +package Shutter::Screenshot::Popup; + +use utf8; +use strict; +use warnings; +use Gtk3; +use Gtk3::ImageView; + +# Coordinates come from ImageView in source-image pixels, even when the +# preview is scaled to fit. Never capture the desktop again after selection. +sub crop_region { + my ($pixbuf, $region) = @_; + return unless defined $region; + my ($x, $y, $w, $h) = @{$region}{qw/x y width height/}; + return unless defined $x && defined $y && defined $w && defined $h; + if ($x < 0) { $w += $x; $x = 0; } + if ($y < 0) { $h += $y; $y = 0; } + $w = $pixbuf->get_width - $x if $x + $w > $pixbuf->get_width; + $h = $pixbuf->get_height - $y if $y + $h > $pixbuf->get_height; + return if $w <= 0 || $h <= 0; + return $pixbuf->new_subpixbuf($x, $y, $w, $h)->copy; +} + +sub select_region { + my ($sc, $pixbuf) = @_; + my $d = $sc->get_gettext; + my $dialog = Gtk3::Dialog->new( + $d->get("Select the menu or tooltip"), $sc->get_mainwindow, + ['modal', 'destroy-with-parent'], 'gtk-cancel' => 'cancel', 'gtk-ok' => 'ok'); + $dialog->set_default_size(900, 650); + $dialog->set_default_response('ok'); + $dialog->set_response_sensitive('ok', 0); + + my $content = $dialog->get_content_area; + my $label = Gtk3::Label->new($d->get("Drag around the menu or tooltip in this frozen screenshot, then confirm.")); + $label->set_line_wrap(1); + $content->pack_start($label, 0, 0, 8); + my $view = Gtk3::ImageView->new; + $view->set_tool(Gtk3::ImageView::Tool::Selector->new($view)); + $view->set_pixbuf($pixbuf); + # Fit only after GTK allocates the preview; fitting a 1-pixel placeholder + # can request a zoom below ImageView's supported minimum on large screens. + my $fitted; + $view->signal_connect('size-allocate' => sub { + my ($widget, $allocation) = @_; + if (!$fitted && $allocation->{width} > 1 && $allocation->{height} > 1) { + $fitted = 1; + $widget->set_zoom_to_fit(1); + } + }); + $content->pack_start($view, 1, 1, 0); + $view->signal_connect('selection-changed' => sub { + my $region = $view->get_selection; + $dialog->set_response_sensitive('ok', defined $region && $region->{width} > 0 && $region->{height} > 0); + }); + + my $output; + eval { + $dialog->show_all; + $view->grab_focus; + while ($dialog->run eq 'ok') { + $output = crop_region($pixbuf, $view->get_selection); + last if defined $output; + } + }; + my $error = $@; + $dialog->destroy; + die $error if $error; + return $output; +} + +1; diff --git a/share/shutter/resources/modules/Shutter/Screenshot/Wayland.pm b/share/shutter/resources/modules/Shutter/Screenshot/Wayland.pm index 1d408a83..5ed987f8 100644 --- a/share/shutter/resources/modules/Shutter/Screenshot/Wayland.pm +++ b/share/shutter/resources/modules/Shutter/Screenshot/Wayland.pm @@ -18,6 +18,8 @@ sub new { _target => shift, # screenshot mode (full, select, window, awindow) to send to xdg-portal _monitor => shift, # undef captures the whole desktop }; + $self->{_popup_mode} = shift; + $self->{_repeat_delay} = shift; $self->{_gdk_screen} = Gtk3::Gdk::Screen::get_default(); bless $self, $class; return $self; @@ -26,9 +28,31 @@ sub new { sub redo_capture { my $self = shift; return 3 unless defined $self->{_history}; + if (defined $self->{_popup_mode}) { + # The initial request uses the application's menu/tooltip countdown. + # Repeats need the same opportunity to reopen the transient popup. + $self->wait_for_popup; + } return $self->xdg_portal; } +# Menus and tooltips cannot be enumerated through the screenshot portal. +# Capture the desktop before presenting any selection UI. +sub popup_mode { + my ($mode) = @_; + return unless defined $mode; + return $1 if $mode =~ /^(?:tray_)?(menu|tooltip)$/; + return; +} + +sub wait_for_popup { + my $self = shift; + my $loop = Glib::MainLoop->new; + my $delay = $self->{_repeat_delay} || 10; + Glib::Timeout->add($delay * 1000, sub { $loop->quit; return 0; }); + $loop->run; +} + sub get_history { my $self = shift; return $self->{_history}; @@ -53,6 +77,8 @@ sub xdg_portal { my $pixbuf; my $portal_error; + my $cancelled; + delete $self->{_error_text}; eval { my $portal_service = $bus->get_service('org.freedesktop.portal.Desktop'); @@ -78,6 +104,9 @@ sub xdg_portal { # only define a target if xdg-portal supports non-interactive calls $options{target} = Net::DBus::dbus_uint32($self->{_target}) if $self->{_interactive} ne 1; + # An interactive chooser could dismiss the menu or tooltip before capture. + $options{interactive} = Net::DBus::dbus_boolean(0) if defined $self->{_popup_mode}; + my $request_path = $portal->Screenshot('', \%options); if ($request->get_object_path ne $request_path) { @@ -92,7 +121,8 @@ sub xdg_portal { if (!defined $num || $num != 0) { if (defined $num && $num == 1) { # portal Response: 1 = user cancelled -> treat as abort (code 5), not error - return 5; + $cancelled = 1; + return; } $portal_error = "Response " . (defined $num ? $num : "timeout") . " from XDG portal"; return; @@ -107,23 +137,40 @@ sub xdg_portal { $giofile->delete; }; - if (defined $self->{_monitor}) { - $pixbuf = crop_to_monitor($pixbuf, $self->{_gdk_screen}, $self->{_monitor}); - } - - #a history marker makes this capture repeatable through redoshot - $self->{_history} = Shutter::Screenshot::History->new($self->{_sc}); - if ($@) { - $self->{_error_text} = $@; + # A return inside eval only leaves that block. Handle cancellation and + # errors before accessing the pixbuf or recording a successful capture. + my $exception = $@; + if ($exception) { + $self->{_error_text} = $exception; return 9; } + return 5 if $cancelled; if (defined $portal_error) { $self->{_error_text} = $portal_error; return 9; } + if (defined $self->{_monitor}) { + $pixbuf = crop_to_monitor($pixbuf, $self->{_gdk_screen}, $self->{_monitor}); + } + + if (defined $self->{_popup_mode}) { + my $selected = eval { $self->select_popup($pixbuf) }; + if ($@) { + $self->{_error_text} = $@; + return 9; + } + return 5 unless defined $selected; + $pixbuf = $selected; + } + + #a history marker makes this capture repeatable through redoshot + $self->{_history} = Shutter::Screenshot::History->new($self->{_sc}); + # get name - if ($self->{_target} eq 1) { + if (defined $self->{_popup_mode}) { + $self->{_action_name} = $self->{_popup_mode} eq 'menu' ? $d->get("Menu") : $d->get("Tooltip"); + } elsif ($self->{_target} eq 1) { if (defined $self->{_monitor}) { $self->{_action_name} = $self->{_gdk_screen}->get_monitor_plug_name($self->{_monitor}); } else { @@ -144,6 +191,14 @@ sub xdg_portal { return $pixbuf; } +# Select from the captured pixels, not the live desktop: moving the pointer +# or opening this dialog can no longer dismiss the captured popup. +sub select_popup { + my ($self, $pixbuf) = @_; + require Shutter::Screenshot::Popup; + return Shutter::Screenshot::Popup::select_region($self->{_sc}, $pixbuf); +} + #The XDG portal always returns the whole desktop spanning every monitor. Crop #that pixbuf down to a single monitor's area so users can capture just one. sub crop_to_monitor { diff --git a/t/Shutter/Screenshot/001_wayland.t b/t/Shutter/Screenshot/001_wayland.t new file mode 100644 index 00000000..dee91db4 --- /dev/null +++ b/t/Shutter/Screenshot/001_wayland.t @@ -0,0 +1,155 @@ +#!/usr/bin/perl +use strict; +use warnings; +use Test::More; +use Shutter::Screenshot::Wayland; + +# Exercise portal responses without a desktop session or image files. +{ + package WaylandTest::Object; + use strict; + use warnings; + sub new { bless {}, shift } + sub get_gettext { shift } + sub get { $_[1] } + sub get_debug { 0 } + sub get_unique_name { ':1.42' } + sub get_service { shift } + sub get_object { shift } + sub get_object_path { '/request/test' } + sub connect_to_signal { $_[0]->{callback} = $_[2]; 1 } + sub disconnect_from_signal { delete $_[0]->{callback} } + sub Screenshot { + $_[0]->{options} = $_[2]; + die "Portal unavailable\n" if $_[0]->{throw}; + return '/request/test'; + } + sub run { $_[0]->{callback}->($_[0]->{response}, $_[0]->{output}) } + sub shutdown { } + sub get_path { '/test/screenshot.png' } + sub delete { $_[0]->{deleted}++ } + sub get_width { 640 } + sub get_height { 480 } + sub get_monitor_plug_name { 'test-monitor' } +} + +my $portal = WaylandTest::Object->new; +my $file = WaylandTest::Object->new; +my $pixbuf = WaylandTest::Object->new; +my $decode_error; +my $crop_calls = 0; +{ + no warnings qw/redefine once/; + local *Net::DBus::find = sub { $portal }; + local *Net::DBus::Reactor::main = sub { $portal }; + local *Glib::IO::File::new_for_uri = sub { $file }; + local *Gtk3::Gdk::Pixbuf::new_from_file = sub { + die "Invalid image\n" if $decode_error; + return $pixbuf; + }; + local *Shutter::Screenshot::Wayland::crop_to_monitor = sub { + $crop_calls++; + return $_[0]; + }; + + for my $target (1, 2, 4, 8) { + subtest "target $target" => sub { + my $capture = bless { + _sc => WaylandTest::Object->new, + _target => $target, _interactive => 1, + _monitor => 0, _gdk_screen => WaylandTest::Object->new, + }, 'Shutter::Screenshot::Wayland'; + $portal->{response} = 1; + $portal->{output} = {}; + my $before = $crop_calls; + is($capture->xdg_portal, 5, 'cancellation is an abort'); + is($crop_calls, $before, 'cancelled image is not cropped'); + ok(!defined $capture->get_history, 'cancel does not create history'); + is($capture->redo_capture, 3, 'cannot redo an unsuccessful first capture'); + + $portal->{response} = 2; + is($capture->xdg_portal, 9, 'portal failure is an error'); + like($capture->get_error_text, qr/Response 2/, 'response error preserved'); + ok(!defined $capture->get_history, 'failure does not create history'); + + $portal->{response} = undef; + is($capture->xdg_portal, 9, 'missing response is an error'); + like($capture->get_error_text, qr/timeout/, 'missing response has details'); + + $portal->{response} = 0; + is($capture->xdg_portal, 9, 'missing URI is an error'); + like($capture->get_error_text, qr/no screenshot URI/, 'missing URI has details'); + + $portal->{throw} = 1; + is($capture->xdg_portal, 9, 'D-Bus exception is an error'); + like($capture->get_error_text, qr/Portal unavailable/, 'exception preserved'); + $portal->{throw} = 0; + + $portal->{output} = { uri => 'file:///test/screenshot.png' }; + $decode_error = 1; + is($capture->xdg_portal, 9, 'image decoding failure is an error'); + like($capture->get_error_text, qr/Invalid image/, 'decode error preserved'); + is($crop_calls, $before, 'no error path reaches cropping'); + ok(!defined $capture->get_history, 'no error path records history'); + $decode_error = 0; + + is($capture->xdg_portal, $pixbuf, 'success returns decoded image'); + is($crop_calls, $before + 1, 'successful image is cropped'); + ok(defined $capture->get_history, 'success records history'); + ok(!defined $capture->get_error_text, 'success clears previous error'); + ok($file->{deleted}, 'temporary screenshot is deleted'); + is($capture->redo_capture, $pixbuf, 'successful capture can be repeated'); + + my $history = $capture->get_history; + $portal->{response} = 1; + is($capture->redo_capture, 5, 'redo can be cancelled'); + is($capture->get_history, $history, 'cancel preserves previous history'); + }; + } + for my $mode ('menu', 'tray_menu', 'tooltip', 'tray_tooltip') { + subtest $mode => sub { + my $popup = Shutter::Screenshot::Wayland::popup_mode($mode); + like($popup, qr/^(menu|tooltip)$/, 'popup mode normalized'); + my $capture = bless { + _sc => WaylandTest::Object->new, _target => 1, + _interactive => 1, _popup_mode => $popup, _repeat_delay => 10, + }, 'Shutter::Screenshot::Wayland'; + my ($selected, $waited) = (0, 0); + my $cancel_selection; + local *Shutter::Screenshot::Wayland::select_popup = sub { + is($_[1], $pixbuf, 'selector receives the captured image'); + $selected++; + return $cancel_selection ? undef : $pixbuf; + }; + local *Shutter::Screenshot::Wayland::wait_for_popup = sub { $waited++ }; + $portal->{response} = 0; + $portal->{output} = {uri => 'file:///test/screenshot.png'}; + is($capture->xdg_portal, $pixbuf, 'popup captured and selected'); + is_deeply($portal->{options}->{interactive}, Net::DBus::dbus_boolean(0), 'no live chooser before capture'); + ok(!exists $portal->{options}->{target}, 'legacy portal does not receive unsupported target option'); + is($capture->get_action_name, $popup eq 'menu' ? 'Menu' : 'Tooltip', 'popup action named correctly'); + is($selected, 1, 'selector shown exactly once'); + my $history = $capture->get_history; + $cancel_selection = 1; + is($capture->redo_capture, 5, 'cancelling crop aborts repeat'); + is($waited, 1, 'repeat waits for the popup to be reopened'); + is($capture->get_history, $history, 'crop cancellation preserves history'); + $portal->{response} = 1; + is($capture->xdg_portal, 5, 'portal cancellation is an abort'); + is($selected, 2, 'portal cancellation never opens selector'); + $portal->{response} = 2; + is($capture->xdg_portal, 9, 'portal failure is an error'); + is($selected, 2, 'portal failure never opens selector'); + $capture->{_interactive} = 0; + $portal->{response} = 0; + $cancel_selection = 0; + is($capture->xdg_portal, $pixbuf, 'popup works with target-aware portal'); + is_deeply($portal->{options}->{target}, Net::DBus::dbus_uint32(1), 'target-aware portal receives desktop target'); + is_deeply($portal->{options}->{interactive}, Net::DBus::dbus_boolean(0), 'target-aware portal is also non-interactive'); + }; + } + ok(!defined Shutter::Screenshot::Wayland::popup_mode('window'), 'ordinary capture is not a popup'); + +} + +done_testing; diff --git a/t/Shutter/Screenshot/002_popup.t b/t/Shutter/Screenshot/002_popup.t new file mode 100644 index 00000000..babe4772 --- /dev/null +++ b/t/Shutter/Screenshot/002_popup.t @@ -0,0 +1,63 @@ +#!/usr/bin/perl +use strict; +use warnings; +use Test::More; +use Gtk3 '-init'; +use Shutter::Screenshot::Popup; + +{ + package PopupTest::Common; + use strict; + use warnings; + sub get_gettext { shift } + sub get { $_[1] } + sub get_mainwindow { undef } +} +my $sc = bless {}, 'PopupTest::Common'; +my $pixbuf = Gtk3::Gdk::Pixbuf->new('rgb', 0, 8, 100, 80); +$pixbuf->fill(0xff0000ff); +my $patch = Gtk3::Gdk::Pixbuf->new('rgb', 0, 8, 20, 10); +$patch->fill(0x00ff00ff); +$patch->copy_area(0, 0, 20, 10, $pixbuf, 30, 40); +my $crop = Shutter::Screenshot::Popup::crop_region($pixbuf, {x=>30,y=>40,width=>20,height=>10}); +is($crop->get_width, 20, 'crop width in source pixels'); +is($crop->get_height, 10, 'crop height in source pixels'); +is(substr($crop->get_pixels, 0, 3), "\x00\xff\x00", 'crop contains the selected pixels'); +$pixbuf->fill(0xff0000ff); +is(substr($crop->get_pixels, 0, 3), "\x00\xff\x00", 'crop is independent of the original buffer'); + +for my $region (undef, {}, {x=>0,y=>0,width=>0,height=>1}, {x=>110,y=>0,width=>20,height=>10}) { + ok(!defined Shutter::Screenshot::Popup::crop_region($pixbuf, $region), 'invalid selection rejected'); +} +$crop = Shutter::Screenshot::Popup::crop_region($pixbuf, {x=>-10,y=>-5,width=>200,height=>200}); +is($crop->get_width, 100, 'crop clamped horizontally'); +is($crop->get_height, 80, 'crop clamped vertically'); + +# Drive the actual GTK dialog in Xvfb, including selection at a scaled zoom. +$pixbuf = Gtk3::Gdk::Pixbuf->new('rgb', 0, 8, 3840, 2160); +$pixbuf->fill(0xff0000ff); +for my $response ('ok', 'cancel', 'delete-event') { + my $driven; + Glib::Idle->add(sub { + my ($dialog) = grep { $_->isa('Gtk3::Dialog') } Gtk3::Window::list_toplevels(); + return 1 unless $dialog; + my ($view) = grep { $_->isa('Gtk3::ImageView') } $dialog->get_content_area->get_children; + ok(!$dialog->get_widget_for_response('ok')->get_sensitive, 'empty selection cannot be confirmed'); + $view->set_zoom(0.5); + $view->set_selection({x=>30,y=>40,width=>20,height=>10}); + ok($dialog->get_widget_for_response('ok')->get_sensitive, 'valid selection enables confirmation'); + $driven = 1; + $dialog->response($response); + return 0; + }); + my $result = Shutter::Screenshot::Popup::select_region($sc, $pixbuf); + ok($driven, "dialog driven for $response"); + if ($response eq 'ok') { + is($result->get_width, 20, 'scaled preview preserves source crop width'); + is($result->get_height, 10, 'scaled preview preserves source crop height'); + } else { + ok(!defined $result, "$response cancels without an image"); + } +} + +done_testing;