# This is a port of um-crop-area.c from GNOME’s 'Cheese' application, see # https://gitlab.gnome.org/GNOME/cheese/-/blob/3.34.0/libcheese/um-crop-area.c # # This file is part of Gajim. # # Gajim is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published # by the Free Software Foundation; version 3 only. # # Gajim is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Gajim. If not, see . import os import logging from enum import IntEnum from enum import unique from gi.repository import Gdk from gi.repository import GdkPixbuf from gi.repository import GLib from gi.repository import Gtk import cairo from gajim.common.const import AvatarSize from gajim.common.i18n import _ from gajim.common.helpers import get_file_path_from_dnd_dropped_uri from .util import scale_with_ratio log = logging.getLogger('gajim.gui.avatar_selector') @unique class Loc(IntEnum): OUTSIDE = 0 INSIDE = 1 TOP = 2 TOP_LEFT = 3 TOP_RIGHT = 4 BOTTOM = 5 BOTTOM_LEFT = 6 BOTTOM_RIGHT = 7 LEFT = 8 RIGHT = 9 @unique class Range(IntEnum): BELOW = 0 LOWER = 1 BETWEEN = 2 UPPER = 3 ABOVE = 4 class AvatarSelector(Gtk.Box): def __init__(self): Gtk.Box.__init__(self) self.set_orientation(Gtk.Orientation.VERTICAL) self.get_style_context().add_class('padding-18') uri_entry = Gtk.TargetEntry.new( 'text/uri-list', Gtk.TargetFlags.OTHER_APP, 80) dst_targets = Gtk.TargetList.new([uri_entry]) self.drag_dest_set( Gtk.DestDefaults.ALL, [uri_entry], Gdk.DragAction.COPY | Gdk.DragAction.MOVE) self.drag_dest_set_target_list(dst_targets) self.connect('drag-data-received', self._on_drag_data_received) self._crop_area = CropArea() self._crop_area.set_vexpand(True) self.add(self._crop_area) self._helper_label = Gtk.Label( label=_('Select a picture or drop it here')) self._helper_label.get_style_context().add_class('bold') self._helper_label.get_style_context().add_class('dim-label') self._helper_label.set_vexpand(True) self._helper_label.set_no_show_all(True) self._helper_label.show() self.add(self._helper_label) self.show_all() def prepare_crop_area(self, path): pixbuf = self._get_pixbuf_from_path(path) self._crop_area.set_pixbuf(pixbuf) self._helper_label.hide() self._crop_area.show() def _on_drag_data_received(self, _widget, _context, _x_coord, _y_coord, selection, target_type, _timestamp): if not selection.get_data(): return if target_type == 80: uri_split = selection.get_uris() # Might be more than one path = get_file_path_from_dnd_dropped_uri(uri_split[0]) if not os.path.isfile(path): return self.prepare_crop_area(path) @staticmethod def _get_pixbuf_from_path(path): try: pixbuf = GdkPixbuf.Pixbuf.new_from_file(path) return pixbuf except GLib.Error as err: log.error('Unable to load file %s: %s', path, str(err)) return None def get_prepared(self): return bool(self._crop_area.get_pixbuf()) @staticmethod def _scale_for_publish(pixbuf): width = pixbuf.get_width() height = pixbuf.get_height() if width > AvatarSize.PUBLISH or height > AvatarSize.PUBLISH: # Scale only down, never up width, height = scale_with_ratio(AvatarSize.PUBLISH, width, height) pixbuf = pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR) return pixbuf, width, height def get_avatar_surface(self): pixbuf = self._crop_area.get_pixbuf() if pixbuf is None: return None scaled, width, height = self._scale_for_publish(pixbuf) return Gdk.cairo_surface_create_from_pixbuf( scaled, self.get_scale_factor()), width, height def get_avatar_bytes(self): pixbuf = self._crop_area.get_pixbuf() if pixbuf is None: return False, None, 0, 0 scaled, width, height = self._scale_for_publish(pixbuf) success, data = scaled.save_to_bufferv('png', [], []) return success, data, width, height class CropArea(Gtk.DrawingArea): def __init__(self): Gtk.DrawingArea.__init__(self) self.set_no_show_all(True) self.add_events( Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK | Gdk.EventMask.POINTER_MOTION_MASK) self._image = Gdk.Rectangle() self._crop = Gdk.Rectangle() self._pixbuf = None self._browse_pixbuf = None self._color_shifted_pixbuf = None self._current_cursor = None self._scale = float(0.0) self._image.x = 0 self._image.y = 0 self._image.width = 0 self._image.height = 0 self._active_region = Loc.OUTSIDE self._last_press_x = -1 self._last_press_y = -1 self._base_width = 10 self._base_height = 10 self._aspect = float(1.0) self.set_size_request(self._base_width, self._base_height) self.connect('draw', self._on_draw) self.connect('button-press-event', self._on_button_press) self.connect('button-release-event', self._on_button_release) self.connect('motion-notify-event', self._on_motion_notify) def set_min_size(self, width, height): self._base_width = width self._base_height = height self.set_size_request(self._base_width, self._base_height) if self._aspect > 0: self._aspect = self._base_width / self._base_height def set_contstrain_aspect(self, constrain): if constrain: self._aspect = self._base_width / self._base_height else: self._aspect = -1 def set_pixbuf(self, pixbuf): if pixbuf: self._browse_pixbuf = pixbuf width = pixbuf.get_width() height = pixbuf.get_height() else: width = 0 height = 0 self._crop.width = 2 * self._base_width self._crop.height = 2 * self._base_height self._crop.x = abs((width - self._crop.width) / 2) self._crop.y = abs((height - self._crop.height) / 2) self._scale = 0.0 self._image.x = 0 self._image.y = 0 self._image.width = 0 self._image.height = 0 self.queue_draw() def get_pixbuf(self): if self._browse_pixbuf is None: return None width = self._browse_pixbuf.get_width() height = self._browse_pixbuf.get_height() width = min(self._crop.width, width - self._crop.x) height = min(self._crop.height, height - self._crop.y) if width <= 0 or height <= 0: return None return GdkPixbuf.Pixbuf.new_subpixbuf( self._browse_pixbuf, self._crop.x, self._crop.y, width, height) def _on_draw(self, _widget, context): if self._browse_pixbuf is None: return False self._update_pixbufs() width = self._pixbuf.get_width() height = self._pixbuf.get_height() crop = self._crop_to_widget() ix = self._image.x iy = self._image.y Gdk.cairo_set_source_pixbuf( context, self._color_shifted_pixbuf, ix, iy) context.rectangle( ix, iy, width, crop.y - iy) context.rectangle( ix, crop.y, crop.x - ix, crop.height) context.rectangle( crop.x + crop.width, crop.y, width - crop.width - (crop.x - ix), crop.height) context.rectangle( ix, crop.y + crop.height, width, height - crop.height - (crop.y - iy)) context.fill() Gdk.cairo_set_source_pixbuf(context, self._pixbuf, ix, iy) context.rectangle(crop.x, crop.y, crop.width, crop.height) context.fill() if self._active_region != Loc.OUTSIDE: context.set_source_rgb(150, 150, 150) context.set_line_width(1.0) x1 = crop.x + crop.width / 3.0 x2 = crop.x + 2 * crop.width / 3.0 y1 = crop.y + crop.height / 3.0 y2 = crop.y + 2 * crop.height / 3.0 context.move_to(x1 + 0.5, crop.y) context.line_to(x1 + 0.5, crop.y + crop.height) context.move_to(x2 + 0.5, crop.y) context.line_to(x2 + 0.5, crop.y + crop.height) context.move_to(crop.x, y1 + 0.5) context.line_to(crop.x + crop.width, y1 + 0.5) context.move_to(crop.x, y2 + 0.5) context.line_to(crop.x + crop.width, y2 + 0.5) context.stroke() context.set_source_rgb(1, 1, 1) context.set_line_width(1.0) context.rectangle( crop.x + 0.5, crop.y + 0.5, crop.width - 1.0, crop.height - 1.0) context.stroke() context.set_source_rgb(1, 1, 1) context.set_line_width(2.0) context.rectangle( crop.x + 2.0, crop.y + 2.0, crop.width - 4.0, crop.height - 4.0) context.stroke() return False def _on_button_press(self, _widget, event): if self._browse_pixbuf is None: return False crop = self._crop_to_widget() self._last_press_x = (event.x - self._image.x) / self._scale self._last_press_y = (event.y - self._image.y) / self._scale self._active_region = self._find_location(crop, event.x, event.y) self.queue_draw_area( crop.x - 1, crop.y - 1, crop.width + 2, crop.height + 2) return False def _on_button_release(self, _widget, _event): if self._browse_pixbuf is None: return False crop = self._crop_to_widget() self._last_press_x = -1 self._last_press_y = -1 self._active_region = Loc.OUTSIDE self.queue_draw_area( crop.x - 1, crop.y - 1, crop.width + 2, crop.height + 2) return False def _on_motion_notify(self, _widget, event): # pylint: disable=too-many-boolean-expressions # pylint: disable=too-many-branches # pylint: disable=too-many-statements if self._browse_pixbuf is None: return False self._update_cursor(event.x, event.y) damage = self._crop_to_widget() self.queue_draw_area( damage.x - 1, damage.y - 1, damage.width + 2, damage.height + 2) pb_width = self._browse_pixbuf.get_width() pb_height = self._browse_pixbuf.get_height() x_coord = int((event.x - self._image.x) / self._scale) y_coord = int((event.y - self._image.y) / self._scale) delta_x = int(x_coord - self._last_press_x) delta_y = int(y_coord - self._last_press_y) self._last_press_x = x_coord self._last_press_y = y_coord left = int(self._crop.x) right = int(self._crop.x + self._crop.width - 1) top = int(self._crop.y) bottom = int(self._crop.y + self._crop.height - 1) center_x = float((left + right) / 2.0) center_y = float((top + bottom) / 2.0) if self._active_region == Loc.INSIDE: width = right - left + 1 height = bottom - top + 1 left += delta_x right += delta_x top += delta_y bottom += delta_y if left < 0: left = 0 if top < 0: top = 0 if right > pb_width: right = pb_width if bottom > pb_height: bottom = pb_height adj_width = int(right - left + 1) adj_height = int(bottom - top + 1) if adj_width != width: if delta_x < 0: right = left + width - 1 else: left = right - width + 1 if adj_height != height: if delta_y < 0: bottom = top + height - 1 else: top = bottom - height + 1 elif self._active_region == Loc.TOP_LEFT: if self._aspect < 0: top = y_coord left = x_coord elif y_coord < self._eval_radial_line( center_x, center_y, left, top, x_coord): top = y_coord new_width = float((bottom - top) * self._aspect) left = right - new_width else: left = x_coord new_height = float((right - left) / self._aspect) top = bottom - new_height elif self._active_region == Loc.TOP: top = y_coord if self._aspect > 0: new_width = float((bottom - top) * self._aspect) right = left + new_width elif self._active_region == Loc.TOP_RIGHT: if self._aspect < 0: top = y_coord right = x_coord elif y_coord < self._eval_radial_line( center_x, center_y, right, top, x_coord): top = y_coord new_width = float((bottom - top) * self._aspect) right = left + new_width else: right = x_coord new_height = float((right - left) / self._aspect) top = bottom - new_height elif self._active_region == Loc.LEFT: left = x_coord if self._aspect > 0: new_height = float((right - left) / self._aspect) bottom = top + new_height elif self._active_region == Loc.BOTTOM_LEFT: if self._aspect < 0: bottom = y_coord left = x_coord elif y_coord < self._eval_radial_line( center_x, center_y, left, bottom, x_coord): left = x_coord new_height = float((right - left) / self._aspect) bottom = top + new_height else: bottom = y_coord new_width = float((bottom - top) * self._aspect) left = right - new_width elif self._active_region == Loc.RIGHT: right = x_coord if self._aspect > 0: new_height = float((right - left) / self._aspect) bottom = top + new_height elif self._active_region == Loc.BOTTOM_RIGHT: if self._aspect < 0: bottom = y_coord right = x_coord elif y_coord < self._eval_radial_line( center_x, center_y, right, bottom, x_coord): right = x_coord new_height = float((right - left) / self._aspect) bottom = top + new_height else: bottom = y_coord new_width = float((bottom - top) * self._aspect) right = left + new_width elif self._active_region == Loc.BOTTOM: bottom = y_coord if self._aspect > 0: new_width = float((bottom - top) * self._aspect) right = left + new_width else: return False min_width = int(self._base_width / self._scale) min_height = int(self._base_height / self._scale) width = right - left + 1 height = bottom - top + 1 if self._aspect < 0: if left < 0: left = 0 if top < 0: top = 0 if right > pb_width: right = pb_width if bottom > pb_height: bottom = pb_height width = right - left + 1 height = bottom - top + 1 if self._active_region in ( Loc.LEFT, Loc.TOP_LEFT, Loc.BOTTOM_LEFT): if width < min_width: left = right - min_width elif self._active_region in ( Loc.RIGHT, Loc.TOP_RIGHT, Loc.BOTTOM_RIGHT): if width < min_width: right = left + min_width if self._active_region in ( Loc.TOP, Loc.TOP_LEFT, Loc.TOP_RIGHT): if height < min_height: top = bottom - min_height elif self._active_region in ( Loc.BOTTOM, Loc.BOTTOM_LEFT, Loc.BOTTOM_RIGHT): if height < min_height: bottom = top + min_height else: if (left < 0 or top < 0 or right > pb_width or bottom > pb_height or width < min_width or height < min_height): left = self._crop.x right = self._crop.x + self._crop.width - 1 top = self._crop.y bottom = self._crop.y + self._crop.height - 1 self._crop.x = left self._crop.y = top self._crop.width = right - left + 1 self._crop.height = bottom - top + 1 damage = self._crop_to_widget() self.queue_draw_area( damage.x - 1, damage.y - 1, damage.width + 2, damage.height + 2) return False def _update_pixbufs(self): allocation = self.get_allocation() width = self._browse_pixbuf.get_width() height = self._browse_pixbuf.get_height() scale = allocation.height / float(height) if scale * width > allocation.width: scale = allocation.width / float(width) dest_width = width * scale dest_height = height * scale if (self._pixbuf is None or self._pixbuf.get_width != allocation.width or self._pixbuf.get_height != allocation.height): self._pixbuf = GdkPixbuf.Pixbuf.new( GdkPixbuf.Colorspace.RGB, self._browse_pixbuf.get_has_alpha(), 8, dest_width, dest_height) self._pixbuf.fill(0x0) self._browse_pixbuf.scale( self._pixbuf, 0, 0, dest_width, dest_height, 0, 0, scale, scale, GdkPixbuf.InterpType.BILINEAR) self._generate_color_shifted_pixbuf() if self._scale == 0.0: scale_to_80 = float(min( (self._pixbuf.get_width() * 0.8 / self._base_width), (self._pixbuf.get_height() * 0.8 / self._base_height))) scale_to_image = float(min( (dest_width / self._base_width), (dest_height / self._base_height))) crop_scale = float(min(scale_to_80, scale_to_image)) self._crop.width = crop_scale * self._base_width / scale self._crop.height = crop_scale * self._base_height / scale self._crop.x = ( self._browse_pixbuf.get_width() - self._crop.width) / 2 self._crop.y = ( self._browse_pixbuf.get_height() - self._crop.height) / 2 self._scale = scale self._image.x = (allocation.width - dest_width) / 2 self._image.y = (allocation.height - dest_height) / 2 self._image.width = dest_width self._image.height = dest_height def _crop_to_widget(self): crop = Gdk.Rectangle() crop.x = self._image.x + self._crop.x * self._scale crop.y = self._image.y + self._crop.y * self._scale crop.width = self._crop.width * self._scale crop.height = self._crop.height * self._scale return crop def _update_cursor(self, x_coord, y_coord): region = self._active_region if self._active_region == Loc.OUTSIDE: crop = self._crop_to_widget() region = self._find_location(crop, x_coord, y_coord) if region == Loc.TOP_LEFT: cursor_type = Gdk.CursorType.TOP_LEFT_CORNER elif region == Loc.TOP: cursor_type = Gdk.CursorType.TOP_SIDE elif region == Loc.TOP_RIGHT: cursor_type = Gdk.CursorType.TOP_RIGHT_CORNER elif region == Loc.LEFT: cursor_type = Gdk.CursorType.LEFT_SIDE elif region == Loc.INSIDE: cursor_type = Gdk.CursorType.FLEUR elif region == Loc.RIGHT: cursor_type = Gdk.CursorType.RIGHT_SIDE elif region == Loc.BOTTOM_LEFT: cursor_type = Gdk.CursorType.BOTTOM_LEFT_CORNER elif region == Loc.BOTTOM: cursor_type = Gdk.CursorType.BOTTOM_SIDE elif region == Loc.BOTTOM_RIGHT: cursor_type = Gdk.CursorType.BOTTOM_RIGHT_CORNER else: # Loc.OUTSIDE cursor_type = Gdk.CursorType.LEFT_PTR if cursor_type is not self._current_cursor: cursor = Gdk.Cursor.new_for_display( Gdk.Display.get_default(), cursor_type) self.get_window().set_cursor(cursor) self._current_cursor = cursor_type @staticmethod def _eval_radial_line(center_x, center_y, bounds_x, bounds_y, user_x): slope_y = float(bounds_y - center_y) slope_x = bounds_x - center_x if slope_y == 0 or slope_x == 0: # Prevent division by zero return 0 decision_slope = slope_y / slope_x decision_intercept = - float(decision_slope * bounds_x) return int(decision_slope * user_x + decision_intercept) def _find_location(self, rect, x_coord, y_coord): # pylint: disable=line-too-long location = [ [Loc.OUTSIDE, Loc.OUTSIDE, Loc.OUTSIDE, Loc.OUTSIDE, Loc.OUTSIDE], [Loc.OUTSIDE, Loc.TOP_LEFT, Loc.TOP, Loc.TOP_RIGHT, Loc.OUTSIDE], [Loc.OUTSIDE, Loc.LEFT, Loc.INSIDE, Loc.RIGHT, Loc.OUTSIDE], [Loc.OUTSIDE, Loc.BOTTOM_LEFT, Loc.BOTTOM, Loc.BOTTOM_RIGHT, Loc.OUTSIDE], [Loc.OUTSIDE, Loc.OUTSIDE, Loc.OUTSIDE, Loc.OUTSIDE, Loc.OUTSIDE], ] # pylint: enable=line-too-long x_range = self._find_range(x_coord, rect.x, rect.x + rect.width) y_range = self._find_range(y_coord, rect.y, rect.y + rect.height) return location[y_range][x_range] @staticmethod def _find_range(coord, min_v, max_v): tolerance = 12 if coord < min_v - tolerance: return Range.BELOW if coord <= min_v + tolerance: return Range.LOWER if coord < max_v - tolerance: return Range.BETWEEN if coord <= max_v + tolerance: return Range.UPPER return Range.ABOVE def _generate_color_shifted_pixbuf(self): # pylint: disable=no-member surface = cairo.ImageSurface( cairo.Format.ARGB32, self._pixbuf.get_width(), self._pixbuf.get_height()) context = cairo.Context(surface) # pylint: enable=no-member Gdk.cairo_set_source_pixbuf(context, self._pixbuf, 0, 0) context.paint() context.rectangle(0, 0, 1, 1) context.set_source_rgba(0, 0, 0, 0.5) context.paint() surface = context.get_target() self._color_shifted_pixbuf = Gdk.pixbuf_get_from_surface( surface, 0, 0, surface.get_width(), surface.get_height())