diff --git a/meson.build b/meson.build index eaa5e59f..14686169 100644 --- a/meson.build +++ b/meson.build @@ -6,13 +6,13 @@ project( ) app_uuid = 'com.github.maoschanz.drawing' -# Dependencies ################################################################# +# Dependencies +pygobject = dependency('pygobject-3.0', version: '>=3.0.0') -# TODO the proper way to check python3 libs dependencies is this: -# https://mesonbuild.com/Python-module.html#dependency but the doc is quite bad -# so i've no idea how to make it work +py_module = import('python') +py_instln = py_module.find_installation('python3') +py_dep = py_instln.dependency() -################################################################################ # Will be used in po and data if get_option('enable-translations-and-appdata') diff --git a/po/eu.po b/po/eu.po index ae79ad27..fd19aac1 100644 --- a/po/eu.po +++ b/po/eu.po @@ -103,18 +103,18 @@ msgid "" "A former option of the paint bucket tool, which didn't belong there, has " "been improved and moved to the eraser tool." msgstr "Pintura-ontzi tresnaren lehen aukera batek, ez zegokiona," -"hobetu eta borragoma tresnara eraman da". +"hobetu eta borragoma tresnara eraman da." #: data/com.github.maoschanz.drawing.appdata.xml.in msgid "" "Transparency is henceforth correctly preserved when the \"skew\" tool " "expands the canvas with a solid color." msgstr "Gardentasuna behar bezala gordeko da aurrerantzean \" okertu \" tresna " -"kolore solido batekin ohiala zabaltzen du". +"kolore solido batekin ohiala zabaltzen du." #: data/com.github.maoschanz.drawing.appdata.xml.in msgid "You can now adjust the position of the canvas when you're cropping it." -msgstr "Orain ohialaren posizioa doitu dezakezu mozten duzunean". +msgstr "Orain ohialaren posizioa doitu dezakezu mozten duzunean." #: data/com.github.maoschanz.drawing.appdata.xml.in msgid "" @@ -148,7 +148,7 @@ msgid "" "shortcuts to change tab." msgstr "" "\"hautatu\" tresnaren erabiltzailearen interfazea aldatu da, baita ere " -"fitxa aldatzeko lasterbideak". +"fitxa aldatzeko lasterbideak." #: data/com.github.maoschanz.drawing.appdata.xml.in msgid "" @@ -171,7 +171,7 @@ msgid "" "the message dialog to warn you about the release notes is less intrusive." msgstr "" "Komunitatearen eskaeraren arabera, \"nabarmendu\" tresna lehenespenez gaituta dago orain, eta " -"argitalpen-oharrei buruz ohartarazteko mezuen elkarrizketa-koadroa ez da hain intrusiboa". +"argitalpen-oharrei buruz ohartarazteko mezuen elkarrizketa-koadroa ez da hain intrusiboa." #: data/com.github.maoschanz.drawing.appdata.xml.in msgid "" @@ -193,7 +193,7 @@ msgid "" "sandbox has also been fixed." msgstr "" "Komando-lerroaren analisia okerra aplikazioa flatpaketik kanpo erabiltzean" -"hondar-kutxa ere konpondu da". +"hondar-kutxa ere konpondu da." #: data/com.github.maoschanz.drawing.appdata.xml.in msgid "" @@ -201,7 +201,7 @@ msgid "" "been mended too." msgstr "" "Elementary OS duten erabiltzaileentzat, leihoen tamaina aldatzeari buruzko arazo txiki bat" -"ere konponduta". +"ere konponduta." #: data/com.github.maoschanz.drawing.appdata.xml.in msgid "" @@ -235,8 +235,8 @@ msgid "" "Zooming very deep is now possible, and the rendering will be very crisp, " "even at 2000%%." msgstr "" -"Orain zoom oso sakona egitea posible da, eta errendatzea oso kurruskaria izango da". -"%2000ean ere". +"Orain zoom oso sakona egitea posible da, eta errendatzea oso kurruskaria izango da." +"%2000ean ere." #: data/com.github.maoschanz.drawing.appdata.xml.in msgid "" diff --git a/src/image.py b/src/image.py index 8d36f3b0..6616a2a7 100644 --- a/src/image.py +++ b/src/image.py @@ -24,867 +24,867 @@ from .utilities_overlay import utilities_generic_canvas_outline class DrMotionBehavior(): - _LIMIT = 10 + _LIMIT = 10 - HOVER = 0 - DRAW = 1 - SLIP = 2 + HOVER = 0 + DRAW = 1 + SLIP = 2 class NoPixbufNoChangeException(Exception): - def __init__(self, pb_name): - # Context: an error message - message = _("New pixbuf empty, no change applied to %s") - super().__init__(message % pb_name) + def __init__(self, pb_name): + # Context: an error message + message = _("New pixbuf empty, no change applied to %s") + super().__init__(message % pb_name) ################################################################################ @Gtk.Template(resource_path='/com/github/maoschanz/drawing/ui/image.ui') class DrImage(Gtk.Box): - __gtype_name__ = 'DrImage' - - _drawing_area = Gtk.Template.Child() - _h_scrollbar = Gtk.Template.Child() - _v_scrollbar = Gtk.Template.Child() - reload_info_bar = Gtk.Template.Child() - reload_label = Gtk.Template.Child() - - # HiDPI scale factor - SCALE_FACTOR = 1.0 # XXX doesn't work well enough to be anything else - - # Threshold between normal rendering and crisp rendering - ZOOM_THRESHOLD = 4.0 - - # Maximal level of zoom (crisp rendering only) - ZOOM_MAX = 2000 - - def __init__(self, window, **kwargs): - super().__init__(**kwargs) - self.window = window - - self.gfile = None - self.filename = None - self._gfile_monitor = None - self.enable_monitoring() - self._update_can_reload_action() - - # Closing the info bar - self.reload_info_bar.connect('close', self.hide_reload_message) - self.reload_info_bar.connect('response', self.hide_reload_message) - - # Framerate limit - self._rendering_is_locked = False - self._framerate_hint = 0 - - self._ctrl_pressed = False - - if self.window.devel_mode: - # Framerate tracking (debug only) - self._skipped_frames = 0 - self._fps_counter = 0 - if self.window.should_track_framerate: - self.reset_fps_counter() - - self._init_drawing_area() - - self._update_background_color() - self.window.gsettings.connect('changed::ui-background-rgba', \ - self._update_background_color) - - self._update_zoom_behavior() - self.window.gsettings.connect('changed::ctrl-zoom', \ - self._update_zoom_behavior) - - def _init_drawing_area(self): - self._drawing_area.add_events( \ - Gdk.EventMask.BUTTON_PRESS_MASK | \ - Gdk.EventMask.BUTTON_RELEASE_MASK | \ - Gdk.EventMask.POINTER_MOTION_MASK | \ - Gdk.EventMask.SMOOTH_SCROLL_MASK | \ - Gdk.EventMask.ENTER_NOTIFY_MASK | \ - Gdk.EventMask.LEAVE_NOTIFY_MASK) - # Using BUTTON_MOTION_MASK instead of POINTER_MOTION_MASK would be less - # algorithmically complex but not "powerful" enough. - - # For displaying things on the widget - self._drawing_area.connect('draw', self.on_draw) - - # For drawing with tools - self._drawing_area.connect('motion-notify-event', self.on_motion_on_area) - self._drawing_area.connect('button-press-event', self.on_press_on_area) - self._drawing_area.connect('button-release-event', self.on_release_on_area) - - # For scrolling - self._drawing_area.connect('scroll-event', self.on_scroll_on_area) - self._h_scrollbar.connect('value-changed', self.on_scrollbar_value_change) - self._v_scrollbar.connect('value-changed', self.on_scrollbar_value_change) - - # For the cursor - self._drawing_area.connect('enter-notify-event', self.on_enter_image) - self._drawing_area.connect('leave-notify-event', self.on_leave_image) - - def _update_background_color(self, *args): - rgba = self.window.gsettings.get_strv('ui-background-rgba') - self._bg_rgba = (float(rgba[0]), float(rgba[1]), \ - float(rgba[2]), float(rgba[3])) - # We remember this data here for performance: it will eb used by the - # `on_draw` method which is called a lot, and reading a gsettings costs - # a lot. - - def _update_zoom_behavior(self, *args): - self._ctrl_to_zoom = self.window.gsettings.get_boolean('ctrl-zoom') - - ############################################################################ - # Image initialization ##################################################### - - def _init_image_common(self): - """Part of the initialization common to both a new blank image and an - opened image.""" - self._is_pressed = False - - # Zoom and scroll initialization - self.scroll_x = 0 - self.scroll_y = 0 - self.zoom_level = 1.0 - self.motion_behavior = DrMotionBehavior.HOVER - self._slip_press_x = 0.0 - self._slip_press_y = 0.0 - self._slip_init_x = 0.0 - self._slip_init_y = 0.0 - - # Selection initialization - self.selection = DrSelectionManager(self) - - # History initialization - self._history = DrHistoryManager(self) - self.set_action_sensitivity('undo', False) - self.set_action_sensitivity('redo', False) - - def init_background(self, width, height, background_rgba): - self._init_image_common() - self._history.set_initial_operation(background_rgba, None, width, height) - self.restore_last_state() - - def try_load_pixbuf(self, pixbuf): - self._init_image_common() - self._load_pixbuf_common(pixbuf) - self.restore_last_state() - self.update_title() - - def _new_blank_pixbuf(self, w, h): - return GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, w, h) - - def restore_last_state(self): - """Set the last saved pixbuf from the history as the main_pixbuf. This - is used to rebuild the picture from its history.""" - last_saved_pixbuf_op = self._history.get_last_saved_state() - self._apply_state(last_saved_pixbuf_op) - - def reset_to_initial_pixbuf(self): - self._apply_state(self._history.initial_operation) - self._history.rewind_history() - - def _apply_state(self, state_op): - # restore the state found in the history - pixbuf = state_op['pixbuf'] - width = state_op['width'] - height = state_op['height'] - self.set_temp_pixbuf(self._new_blank_pixbuf(1, 1)) - self.selection.init_pixbuf() - self.surface = cairo.ImageSurface(cairo.Format.ARGB32, width, height) - if pixbuf is None: - # no pixbuf in the operation: the restored state is a blank one - rgba = state_op['rgba'] - r = rgba.red - g = rgba.green - b = rgba.blue - a = rgba.alpha - self.set_main_pixbuf(self._new_blank_pixbuf(width, height)) - cairo_context = cairo.Context(self.surface) - cairo_context.set_source_rgba(r, g, b, a) - cairo_context.paint() - self.update() - self.set_surface_as_stable_pixbuf() - else: - self.set_main_pixbuf(state_op['pixbuf'].copy()) - self.use_stable_pixbuf() - - ############################################################################ - # (re)loading the pixbuf of a given file ################################### - - def _load_pixbuf_common(self, pixbuf): - if not pixbuf.get_has_alpha(): - pixbuf = pixbuf.add_alpha(False, 255, 255, 255) - background_rgba = self.window.gsettings.get_strv('default-rgba') - self._history.set_initial_operation(background_rgba, pixbuf, \ - pixbuf.get_width(), pixbuf.get_height()) - self.set_main_pixbuf(pixbuf) - - def reload_from_disk(self): - """Safely reloads the image from the disk.""" - if self.gfile is None: - # the action shouldn't be active in the first place - return - disk_pixbuf = GdkPixbuf.Pixbuf.new_from_file(self.get_file_path()) - self._load_pixbuf_common(disk_pixbuf) - self.use_stable_pixbuf() - self.update() - self.remember_current_state() - self.window.update_picture_title() - - def try_load_file(self, gfile): - try: - self.gfile = gfile - pixbuf = GdkPixbuf.Pixbuf.new_from_file(self.get_file_path()) - self._connect_gfile_monitoring() - except Exception as ex: - if not ex.message: - ex.message = "[exception without a valid message]" - ex = InvalidFileFormatException(ex.message, gfile.get_path()) - self.window.reveal_action_report(ex.message) - self.gfile = None - pixbuf = self._new_blank_pixbuf(100, 100) - # XXX dans l'idéal on devrait ne rien ouvrir non ? ou si besoin (si - # ya pas de fenêtre) ouvrir un truc respectant les settings, plutôt - # qu'un petit pixbuf corrompu - self.try_load_pixbuf(pixbuf) - self._update_can_reload_action() - - def _connect_gfile_monitoring(self): - if self._gfile_monitor: - # Probably no need to reconnect it again. - # XXX est-il vraiment bon ? on devrait le déconnecter, le refaire ? - # Tout cela n'est pas débuggable parce que le fichier que je traque - # est le proxy de la sandbox (/run/user/1000/...) - self._gfile_monitor = None - flags = Gio.FileMonitorFlags.WATCH_MOUNTS - self._gfile_monitor = self.gfile.monitor(flags) - self._gfile_monitor.connect('changed', self.reveal_reload_message) - - def disable_monitoring(self): - self._monitoring_disabled = True - - def enable_monitoring(self): - """This should only be called initially, or asynchronously, or in case - of an exception""" - self._monitoring_disabled = False - - def reveal_reload_message(self, *args): - """This method is called when the file changed, which is async: we can't - enable or disable the monitoring in the DrSavingManager's method because - saving the pixbuf takes longer to trigger the 'changed' signal on the - monitor than it takes to run the end of the saving process.""" - # I'm not sure this lock is 100% correct because i'm monitoring the - # portal proxy file when testing with flatpak. - if self._monitoring_disabled: - # The idea is that the message banner is temporarily disabled until - # the monitor sends its next "changed" event, which we expect (it's - # the one from our own saving process). - if args[3] != Gio.FileMonitorEvent.CHANGED: - try: - self.reload_from_disk() - except Exception as ex: - print(e) - # Context: an error message - self._window.reveal_message(_("Failed to reload %s") % \ - self.gfile.get_path()) - self.enable_monitoring() - return - self._update_can_reload_action() - self.reload_label.set_visible(self.window.get_allocated_width() > 500) - self.reload_info_bar.set_visible(True) - - def hide_reload_message(self, *args): - self.reload_info_bar.set_visible(False) - - def _update_can_reload_action(self): - self.set_action_sensitivity('reload_file', self.gfile is not None) - - ############################################################################ - # Image title and tab management ########################################### - - def build_tab_widget(self): - """Build the GTK widget displayed as the tab title.""" - # The tab can be closed with a button. - btn = Gtk.Button.new_from_icon_name('window-close-symbolic', Gtk.IconSize.BUTTON) - btn.set_relief(Gtk.ReliefStyle.NONE) - btn.connect('clicked', self.try_close_tab) - # The title is a label. Middle-clicking on it closes the tab too. - self.tab_label = Gtk.Label(label=self.get_filename_for_display()) - self.tab_label.set_ellipsize(Pango.EllipsizeMode.END) - event_box = Gtk.EventBox() - event_box.add(self.tab_label) - event_box.connect('button-press-event', self.on_tab_title_clicked) - # These widgets are packed in a regular box, which is returned. - tab_title = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, expand=True) - if self.window.deco_layout == 'he': - tab_title.pack_start(btn, expand=False, fill=False, padding=0) - tab_title.pack_end(event_box, expand=True, fill=True, padding=0) - else: - tab_title.pack_start(event_box, expand=True, fill=True, padding=0) - tab_title.pack_end(btn, expand=False, fill=False, padding=0) - tab_title.show_all() - return tab_title - - def on_tab_title_clicked(self, widget, event_button): - if event_button.type == Gdk.EventType.BUTTON_PRESS \ - and event_button.button == Gdk.BUTTON_MIDDLE: - self.try_close_tab() - return True - return False # This callback HAS TO return a boolean - - def update_title(self): - main_title = self.get_filename_for_display() - if not self.is_saved(): - main_title = "*" + main_title - self.set_tab_label(main_title) - return main_title - - def get_filename_for_display(self): - if self.get_file_path() is None: - unsaved_file_name = _("Unsaved file") - else: - unsaved_file_name = self.get_file_path().split('/')[-1] - return unsaved_file_name - - def try_close_tab(self, *args): - """Ask the window to close the image/tab. Then unallocate widgets and - pixbufs.""" - if self.window.close_tab(self): - self.destroy() - self.selection.reset(False) - self.main_pixbuf = None - self.temp_pixbuf = None - self._history.empty_history() - return True - else: - return False - - def set_tab_label(self, title_text): - self.tab_label.set_label(title_text) - - def get_file_path(self): - if self.gfile is None: - return None - else: - return self.gfile.get_path() - - def show_properties(self): - DrPropertiesDialog(self.window, self) - - def update_image_wide_actions(self): - self.update_history_sensitivity() - self._update_can_reload_action() - - ############################################################################ - # History management ####################################################### - - def try_undo(self): - self._history.try_undo() - - def try_redo(self): - self._history.try_redo() - - def is_saved(self): - return self._history.get_saved() - - def remember_current_state(self): - self._history.add_state(self.main_pixbuf.copy()) - - def update_history_sensitivity(self): - self.set_action_sensitivity('undo', self._history.can_undo()) - self.set_action_sensitivity('redo', self._history.can_redo()) - # self.update_history_actions_labels() - - def add_to_history(self, operation): - self._history.add_operation(operation) - - def should_replace(self): - if self._history.can_undo(): - return False - return not self._history.has_initial_pixbuf() - - def get_initial_rgba(self): - return self._history.initial_operation['rgba'] - - ############################################################################ - # Misc ? ################################################################### - - def set_action_sensitivity(self, action_name, state): - self.window.lookup_action(action_name).set_enabled(state) - - def update_actions_state(self): - # XXX shouldn't it be done by the selection_manager? - state = self.selection.is_active - self.set_action_sensitivity('unselect', state) - self.set_action_sensitivity('select_all', not state) - self.set_action_sensitivity('selection_cut', state) - self.set_action_sensitivity('selection_copy', state) - self.set_action_sensitivity('selection_delete', state) - self.set_action_sensitivity('selection_export', state) - self.set_action_sensitivity('new_tab_selection', state) - self.set_action_sensitivity('selection-replace-canvas', state) - self.set_action_sensitivity('selection-expand-canvas', state) - self.active_tool().update_actions_state() - - def active_tool(self): - return self.window.active_tool() - - def get_mouse_is_pressed(self): - return self._is_pressed - - ############################################################################ - # Drawing area, main pixbuf, and surface management ######################## - - def on_draw(self, area, cairo_context): - """Signal callback. Executed when self._drawing_area is redrawn.""" - if self.window.devel_mode: - self._fps_counter += 1 - - # Background color - cairo_context.set_source_rgba(*self._bg_rgba) - cairo_context.paint() - - # Zoom level - cairo_context.scale(self.zoom_level, self.zoom_level) - - # Image (with scroll position) - cairo_context.set_source_surface(self.get_surface(), \ - -1 * self.scroll_x, -1 * self.scroll_y) - if self.is_zoomed_surface_sharp(): - cairo_context.get_source().set_filter(cairo.FILTER_NEAREST) - cairo_context.paint() - - # What the tool shows on the canvas, upon what it paints, for example an - # overlay to imply how to interact with a previewed operation. - self.active_tool().on_draw_above(area, cairo_context) - - # Limit of the canvas (for readability) - utilities_generic_canvas_outline(cairo_context, self.zoom_level, \ - self.get_pixbuf_width() - self.scroll_x, \ - self.get_pixbuf_height() - self.scroll_y) - - def on_press_on_area(self, area, event): - """Signal callback. Executed when a mouse button is pressed on - self._drawing_area, if the button is the mouse wheel the colors are - exchanged, otherwise the signal is transmitted to the selected tool.""" - if self._is_pressed: - # reject attempts to draw with a given button if an other one is - # already doing something - return - event_x, event_y = self.get_event_coords(event) - if event.button == 2: - self.motion_behavior = DrMotionBehavior.SLIP - self._slip_press_x = event.x - self._slip_press_y = event.y - self._slip_init_x = self.scroll_x - self._slip_init_y = self.scroll_y - return - self.motion_behavior = DrMotionBehavior.DRAW - self._is_pressed = True - self.window.set_window_subtitles() - # subtitles must be generated *before* calling the tool, otherwise any - # property changed by a modifier would be reset by `get_editing_tips` - self.active_tool().on_press_on_area(event, self.surface, event_x, event_y) - - def on_motion_on_area(self, area, event): - """Signal callback. Executed when the mouse pointer moves upon - self._drawing_area, the signal is transmitted to the selected tool. - If a button (not the mouse wheel) is pressed, the tool's method should - have an effect on the image, otherwise it shouldn't change anything - except the mouse cursor icon for example.""" - event_x, event_y = self.get_event_coords(event) - - if self.motion_behavior == DrMotionBehavior.HOVER: - # Some tools need the coords in the image, others need the coords on - # the widget, so the entire event is given. - self.active_tool().on_unclicked_motion_on_area(event, self.surface) - - elif self.motion_behavior == DrMotionBehavior.DRAW: - # implicitly impossible if not self._is_pressed - self.active_tool().on_motion_on_area(event, self.surface, event_x, \ - event_y, self._rendering_is_locked) - if self._rendering_is_locked: - if self.window.devel_mode: - self._skipped_frames += 1 - return - self._rendering_is_locked = True - self.update() - GLib.timeout_add(self._framerate_hint, self._async_unlock, {}) - - else: # self.motion_behavior == DrMotionBehavior.SLIP: - self.scroll_x = self._slip_init_x - self.scroll_y = self._slip_init_y - delta_x = self._slip_press_x - event.x - delta_y = self._slip_press_y - event.y - self.add_deltas(delta_x, delta_y, 1 / self.zoom_level) - - # If is pressed, a tooltip displaying contextual information is - # shown: by default, it contains at least the pointer coordinates. - if (event.state & Gdk.ModifierType.CONTROL_MASK) == Gdk.ModifierType.CONTROL_MASK: - self._ctrl_pressed = True - if self._ctrl_pressed: - full_tooltip_text = str(event_x) + ", " + str(event_y) - tool_tooltip = self._get_tool_tooltip(event_x, event_y) - if tool_tooltip is not None: - full_tooltip_text += "\n" + tool_tooltip - self._drawing_area.set_tooltip_text(full_tooltip_text) - else: - self._drawing_area.set_tooltip_text(None) - - def on_release_on_area(self, area, event): - """Signal callback. Executed when a mouse button is released on - self._drawing_area, if the button is not the signal is transmitted to - the selected tool.""" - self._ctrl_pressed = False - if self.motion_behavior == DrMotionBehavior.SLIP: - if not self._is_slip_moving(): - self.window.on_middle_click() - self.motion_behavior = DrMotionBehavior.HOVER - return - self.motion_behavior = DrMotionBehavior.HOVER - event_x, event_y = self.get_event_coords(event) - self.active_tool().on_release_on_area(event, self.surface, event_x, event_y) - self._is_pressed = False - self.window.update_picture_title() # just to add the star - self.window.set_window_subtitles() # the tool's state changed - - def _is_slip_moving(self): - """Tells if the pointer moved while the middle button of the mouse is - pressed, depending on a constant hardcoded limit.""" - mx = abs(self._slip_init_x - self.scroll_x) > DrMotionBehavior._LIMIT - my = abs(self._slip_init_y - self.scroll_y) > DrMotionBehavior._LIMIT - return mx or my - - def _get_tool_tooltip(self, ev_x, ev_y): - """Generates the part of the tooltip which is specific to the active - tool (it can return None!) to show when Ctrl is pressed.""" - return self.active_tool().get_tooltip(ev_x, ev_y ,self.motion_behavior) - - def update(self): - # print('image.py: _drawing_area.queue_draw') - self._drawing_area.queue_draw() - - def _async_unlock(self, content_params={}): - """This is used as a GSourceFunc so it should return False.""" - self._rendering_is_locked = False - return False - - def get_surface(self): - return self.surface - - def on_enter_image(self, *args): - self.window.set_cursor(True) - - def on_leave_image(self, *args): - self.window.set_cursor(False) - - def pre_save(self, gfile): - """The gfile is set before saving, because reveal_reload_message expects - self.gfile to be correct.""" - self.gfile = gfile - self._connect_gfile_monitoring() - - def set_surface_as_stable_pixbuf(self): - w = self.surface.get_width() - h = self.surface.get_height() - self.main_pixbuf = Gdk.pixbuf_get_from_surface(self.surface, 0, 0, w, h) - self._framerate_hint = math.sqrt(w * h) - 1000 - self._framerate_hint = int(self._framerate_hint * 0.2) - # between 500 and 33ms (= between 2 and 30 fps) - self._framerate_hint = max(33, min(500, self._framerate_hint)) - # print("image.py: hint =", self._framerate_hint) - - def use_stable_pixbuf(self): - """This is called by tools' `restore_pixbuf`, so at the beginning of - each operation (even unapplied).""" - # maybe the "scale" parameter should be 1 instead of 0 - self.surface = Gdk.cairo_surface_create_from_pixbuf(self.main_pixbuf, 0, None) - # print('image.py: use_stable_pixbuf') - self.surface.set_device_scale(self.SCALE_FACTOR, self.SCALE_FACTOR) - - def get_pixbuf_width(self): - return self.main_pixbuf.get_width() - - def get_pixbuf_height(self): - return self.main_pixbuf.get_height() - - def set_main_pixbuf(self, new_pixbuf): - """Safely set a pixbuf as the main one (not used everywhere internally - in image.py, but it's normal).""" - if new_pixbuf is None: - raise NoPixbufNoChangeException('main_pixbuf') - else: - self.main_pixbuf = new_pixbuf - - ############################################################################ - # Temporary pixbuf management ############################################## - - def set_temp_pixbuf(self, new_pixbuf): - if new_pixbuf is None: - raise NoPixbufNoChangeException('temp_pixbuf') - else: - self.temp_pixbuf = new_pixbuf - - def reset_temp(self): - self.set_temp_pixbuf(self._new_blank_pixbuf(1, 1)) - self.use_stable_pixbuf() - self.update() - - ############################################################################ - # Framerate tracking (debug only) ########################################## - - def reset_fps_counter(self, async_cb_data={}): - """Development only: live-display the evolution of the framerate of the - drawing area. The max should be around 60, but many tools don't require - so many redraws. - This is used as a GSourceFunc so it should return False.""" - if self.window.should_track_framerate: - # Context: this is a debug information that users will never see - msg = _("%s frames per second") % self._fps_counter - msg += " (" + str(self._skipped_frames) + " motion inputs skipped)" - self.window.reveal_message(msg) - self._fps_counter = 0 - self._skipped_frames = 0 - GLib.timeout_add(1000, self.reset_fps_counter, {}) - elif self.window.info_bar.get_visible(): - self.window.reveal_message("Tracking stopped.", True) - return False - - ############################################################################ - # Interaction with the minimap ############################################# - - def get_widget_width(self): - return self._drawing_area.get_allocated_width() - - def get_widget_height(self): - return self._drawing_area.get_allocated_height() - - def generate_mini_pixbuf(self, preview_size): - mpb_width = self.get_pixbuf_width() - mpb_height = self.get_pixbuf_height() - if mpb_height > mpb_width: - mw = preview_size * (mpb_width/mpb_height) - mh = preview_size - else: - mw = preview_size - mh = preview_size * (mpb_height/mpb_width) - return self.main_pixbuf.scale_simple(mw, mh, GdkPixbuf.InterpType.TILES) - - def get_minimap_need_overlay(self): - mpb_width = self.get_pixbuf_width() - mpb_height = self.get_pixbuf_height() - show_x = self.get_widget_width() < mpb_width * self.zoom_level - show_y = self.get_widget_height() < mpb_height * self.zoom_level - show_overlay = show_x or show_y - return show_overlay - - def get_minimap_ratio(self, mini_width): - return mini_width/self.get_pixbuf_width() - - def get_visible_size(self): - visible_width = int(self.get_widget_width() / self.zoom_level) - visible_height = int(self.get_widget_height() / self.zoom_level) - return visible_width, visible_height - - ############################################################################ - # Scroll and zoom levels ################################################### - - def get_previewed_width(self): - # Indirect way to know if it's a transform tool - if self.active_tool().menu_id == 1: - if not self.active_tool().apply_to_selection: - return self.temp_pixbuf.get_width() + 12 - return self.get_pixbuf_width() - - def get_previewed_height(self): - # Indirect way to know if it's a transform tool - if self.active_tool().menu_id == 1: - if not self.active_tool().apply_to_selection: - return self.temp_pixbuf.get_height() + 12 - return self.get_pixbuf_height() - - def fake_scrollbar_update(self): - self.add_deltas(0, 0, 0) - - def get_event_coords(self, event, as_integers=True): - event_x = self.scroll_x + (event.x / self.zoom_level) - event_y = self.scroll_y + (event.y / self.zoom_level) - if as_integers: - # `int()` will truncate to the lower integer so we need this to get - # an accurate behavior when doing pixel-art for example - event_x += 0.5 - event_y += 0.5 - return int(event_x), int(event_y) - else: - return event_x, event_y - - def get_corrected_coords(self, x1, x2, y1, y2, with_selection, with_zoom): - """Do whatever coordinates conversions are needed by tools like `crop` - and `scale` to display things (selection pixbuf, mouse cursors on hover, - etc.) correctly enough.""" - w = x2 - x1 - h = y2 - y1 - x1 = x1 - self.scroll_x - y1 = y1 - self.scroll_y - if with_selection: - x1 += self.selection.selection_x - y1 += self.selection.selection_y - x2 = x1 + w - y2 = y1 + h - if with_zoom: - x1 *= self.zoom_level - x2 *= self.zoom_level - y1 *= self.zoom_level - y2 *= self.zoom_level - return x1, x2, y1, y2 - - def get_nineths_sizes(self, apply_to_selection, x1, y1): - """Returns the sizes of the 'nineths' of the image used for example by - 'scale' or 'crop' to decide the cursor they'll show.""" - height = self.temp_pixbuf.get_height() - width = self.temp_pixbuf.get_width() - if not apply_to_selection: - x1 = 0 - y1 = 0 - # width_left, width_right, height_top, height_bottom - wl, wr, ht, hb = self.get_corrected_coords(int(x1), width, int(y1), \ - height, apply_to_selection, True) - # XXX using local deltas this way "works" but isn't mathematically - # correct: scaled selections have a "null" and excentred central ninth - # ^ c'est toujours vrai ça ?? - wl += 0.4 * width * self.zoom_level - wr -= 0.4 * width * self.zoom_level - ht += 0.4 * height * self.zoom_level - hb -= 0.4 * height * self.zoom_level - return {'wl': wl, 'wr': wr, 'ht': ht, 'hb': hb} - - def on_scroll_on_area(self, area, event): - # TODO https://lazka.github.io/pgi-docs/index.html#Gdk-3.0/classes/EventScroll.html#Gdk.EventScroll - ctrl_is_used = (event.state & Gdk.ModifierType.CONTROL_MASK) == Gdk.ModifierType.CONTROL_MASK - if ctrl_is_used == self._ctrl_to_zoom: - self._zoom_to_point(event) - else: - acceleration = 20 / self._zoom_profile() - self.add_deltas(event.delta_x, event.delta_y, acceleration) - - def on_scrollbar_value_change(self, scrollbar): - self.correct_coords(self._h_scrollbar.get_value(), self._v_scrollbar.get_value()) - self.update() # allowing imperfect framerate would likely be useless - - def reset_deltas(self, delta_x, delta_y): - if delta_x > 0: - wanted_x = self.get_previewed_width() - elif delta_x < 0: - wanted_x = 0 - else: - wanted_x = self.scroll_x - - if delta_y > 0: - wanted_y = self.get_previewed_height() - elif delta_y < 0: - wanted_y = 0 - else: - wanted_y = self.scroll_y - - self.correct_coords(wanted_x, wanted_y) - self.window.minimap.update_overlay() - - def add_deltas(self, delta_x, delta_y, factor): - wanted_x = self.scroll_x + int(delta_x * factor) - wanted_y = self.scroll_y + int(delta_y * factor) - self.correct_coords(wanted_x, wanted_y) - self.window.minimap.update_overlay() - - def correct_coords(self, wanted_x, wanted_y): - available_w = self.get_widget_width() - available_h = self.get_widget_height() - if available_w < 2: - return # could be better handled - - # Update the horizontal scrollbar - mpb_width = self.get_previewed_width() - wanted_x = min(wanted_x, self.get_max_coord(mpb_width, available_w)) - wanted_x = max(wanted_x, 0) - self.update_scrollbar(False, available_w, int(mpb_width), int(wanted_x)) - - # Update the vertical scrollbar - mpb_height = self.get_previewed_height() - wanted_y = min(wanted_y, self.get_max_coord(mpb_height, available_h)) - wanted_y = max(wanted_y, 0) - self.update_scrollbar(True, available_h, int(mpb_height), int(wanted_y)) - - def get_max_coord(self, mpb_size, available_size): - max_coord = mpb_size - (available_size / self.zoom_level) - return max_coord - - def update_scrollbar(self, is_vertical, allocated_size, pixbuf_size, coord): - if is_vertical: - scrollbar = self._v_scrollbar - else: - scrollbar = self._h_scrollbar - scrollbar.set_visible(allocated_size / self.zoom_level < pixbuf_size) - scrollbar.set_range(0, pixbuf_size) - scrollbar.get_adjustment().set_page_size(allocated_size / self.zoom_level) - scrollbar.set_value(coord) - if is_vertical: - self.scroll_y = int(scrollbar.get_value()) - else: - self.scroll_x = int(scrollbar.get_value()) - - def _zoom_to_point(self, event): - """Zoom in or out the image in a way such that the point under the - pointer stays as much as possible under the pointer.""" - event_x, event_y = self.get_event_coords(event) - - # Updating the zoom level - zoom_delta = (event.delta_x + event.delta_y) * -1 * self._zoom_profile() - self.inc_zoom_level(zoom_delta) - - new_event_x, new_event_y = self.get_event_coords(event) - delta_correction_x = event_x - new_event_x - delta_correction_y = event_y - new_event_y - - # Updating the scroll position based on the values previously found - self.add_deltas(delta_correction_x, delta_correction_y, 1) - - def inc_zoom_level(self, delta): - self.set_zoom_level((self.zoom_level * 100) + delta) - - def set_zoom_level(self, level): - normalized_zoom_level = max(min(level, self.ZOOM_MAX), 20) - self.zoom_level = (int(normalized_zoom_level)/100) - self.window.minimap.update_zoom_scale(self.zoom_level) - if self.is_zoomed_surface_sharp(): - self.window.minimap.set_zoom_label(self.zoom_level * 100) - self.fake_scrollbar_update() - self.update() - - def set_opti_zoom_level(self, *args): - allocated_width = self.get_widget_width() - allocated_height = self.get_widget_height() - h_ratio = allocated_width / self.get_previewed_width() - v_ratio = allocated_height / self.get_previewed_height() - opti = min(h_ratio, v_ratio) * 99 # Not 100 because some margin is cool - self.set_zoom_level(opti) - self.scroll_x = 0 - self.scroll_y = 0 - - def is_zoomed_surface_sharp(self): - return self.zoom_level > self.ZOOM_THRESHOLD - - def _zoom_profile(self): - """This is the 'speed' of the zoom scrolling: when between 20% and 100% - it's quite precise, but between 1000% and 2000% we prefer being as fast - as possible.""" - if self.zoom_level < 2.0: - return 3.0 - elif self.zoom_level < 4.0: - return 6.0 - elif self.zoom_level < 10.0: - return 10.0 - else: - return 20.0 - - ############################################################################ + __gtype_name__ = 'DrImage' + + _drawing_area = Gtk.Template.Child() + _h_scrollbar = Gtk.Template.Child() + _v_scrollbar = Gtk.Template.Child() + reload_info_bar = Gtk.Template.Child() + reload_label = Gtk.Template.Child() + + # HiDPI scale factor + SCALE_FACTOR = 1.0 # XXX doesn't work well enough to be anything else + + # Threshold between normal rendering and crisp rendering + ZOOM_THRESHOLD = 4.0 + + # Maximal level of zoom (crisp rendering only) + ZOOM_MAX = 2000 + + def __init__(self, window, **kwargs): + super().__init__(**kwargs) + self.window = window + + self.gfile = None + self.filename = None + self._gfile_monitor = None + self.enable_monitoring() + self._update_can_reload_action() + + # Closing the info bar + self.reload_info_bar.connect('close', self.hide_reload_message) + self.reload_info_bar.connect('response', self.hide_reload_message) + + # Framerate limit + self._rendering_is_locked = False + self._framerate_hint = 0 + + self._ctrl_pressed = False + + if self.window.devel_mode: + # Framerate tracking (debug only) + self._skipped_frames = 0 + self._fps_counter = 0 + if self.window.should_track_framerate: + self.reset_fps_counter() + + self._init_drawing_area() + + self._update_background_color() + self.window.gsettings.connect('changed::ui-background-rgba', \ + self._update_background_color) + + self._update_zoom_behavior() + self.window.gsettings.connect('changed::ctrl-zoom', \ + self._update_zoom_behavior) + + def _init_drawing_area(self): + self._drawing_area.add_events( \ + Gdk.EventMask.BUTTON_PRESS_MASK | \ + Gdk.EventMask.BUTTON_RELEASE_MASK | \ + Gdk.EventMask.POINTER_MOTION_MASK | \ + Gdk.EventMask.SMOOTH_SCROLL_MASK | \ + Gdk.EventMask.ENTER_NOTIFY_MASK | \ + Gdk.EventMask.LEAVE_NOTIFY_MASK) + # Using BUTTON_MOTION_MASK instead of POINTER_MOTION_MASK would be less + # algorithmically complex but not "powerful" enough. + + # For displaying things on the widget + self._drawing_area.connect('draw', self.on_draw) + + # For drawing with tools + self._drawing_area.connect('motion-notify-event', self.on_motion_on_area) + self._drawing_area.connect('button-press-event', self.on_press_on_area) + self._drawing_area.connect('button-release-event', self.on_release_on_area) + + # For scrolling + self._drawing_area.connect('scroll-event', self.on_scroll_on_area) + self._h_scrollbar.connect('value-changed', self.on_scrollbar_value_change) + self._v_scrollbar.connect('value-changed', self.on_scrollbar_value_change) + + # For the cursor + self._drawing_area.connect('enter-notify-event', self.on_enter_image) + self._drawing_area.connect('leave-notify-event', self.on_leave_image) + + def _update_background_color(self, *args): + rgba = self.window.gsettings.get_strv('ui-background-rgba') + self._bg_rgba = (float(rgba[0]), float(rgba[1]), \ + float(rgba[2]), float(rgba[3])) + # We remember this data here for performance: it will eb used by the + # `on_draw` method which is called a lot, and reading a gsettings costs + # a lot. + + def _update_zoom_behavior(self, *args): + self._ctrl_to_zoom = self.window.gsettings.get_boolean('ctrl-zoom') + + ############################################################################ + # Image initialization ##################################################### + + def _init_image_common(self): + """Part of the initialization common to both a new blank image and an + opened image.""" + self._is_pressed = False + + # Zoom and scroll initialization + self.scroll_x = 0 + self.scroll_y = 0 + self.zoom_level = 1.0 + self.motion_behavior = DrMotionBehavior.HOVER + self._slip_press_x = 0.0 + self._slip_press_y = 0.0 + self._slip_init_x = 0.0 + self._slip_init_y = 0.0 + + # Selection initialization + self.selection = DrSelectionManager(self) + + # History initialization + self._history = DrHistoryManager(self) + self.set_action_sensitivity('undo', False) + self.set_action_sensitivity('redo', False) + + def init_background(self, width, height, background_rgba): + self._init_image_common() + self._history.set_initial_operation(background_rgba, None, width, height) + self.restore_last_state() + + def try_load_pixbuf(self, pixbuf): + self._init_image_common() + self._load_pixbuf_common(pixbuf) + self.restore_last_state() + self.update_title() + + def _new_blank_pixbuf(self, w, h): + return GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, w, h) + + def restore_last_state(self): + """Set the last saved pixbuf from the history as the main_pixbuf. This + is used to rebuild the picture from its history.""" + last_saved_pixbuf_op = self._history.get_last_saved_state() + self._apply_state(last_saved_pixbuf_op) + + def reset_to_initial_pixbuf(self): + self._apply_state(self._history.initial_operation) + self._history.rewind_history() + + def _apply_state(self, state_op): + # restore the state found in the history + pixbuf = state_op['pixbuf'] + width = state_op['width'] + height = state_op['height'] + self.set_temp_pixbuf(self._new_blank_pixbuf(1, 1)) + self.selection.init_pixbuf() + self.surface = cairo.ImageSurface(cairo.Format.ARGB32, width, height) + if pixbuf is None: + # no pixbuf in the operation: the restored state is a blank one + rgba = state_op['rgba'] + r = rgba.red + g = rgba.green + b = rgba.blue + a = rgba.alpha + self.set_main_pixbuf(self._new_blank_pixbuf(width, height)) + cairo_context = cairo.Context(self.surface) + cairo_context.set_source_rgba(r, g, b, a) + cairo_context.paint() + self.update() + self.set_surface_as_stable_pixbuf() + else: + self.set_main_pixbuf(state_op['pixbuf'].copy()) + self.use_stable_pixbuf() + + ############################################################################ + # (re)loading the pixbuf of a given file ################################### + + def _load_pixbuf_common(self, pixbuf): + if not pixbuf.get_has_alpha(): + pixbuf = pixbuf.add_alpha(False, 255, 255, 255) + background_rgba = self.window.gsettings.get_strv('default-rgba') + self._history.set_initial_operation(background_rgba, pixbuf, \ + pixbuf.get_width(), pixbuf.get_height()) + self.set_main_pixbuf(pixbuf) + + def reload_from_disk(self): + """Safely reloads the image from the disk.""" + if self.gfile is None: + # the action shouldn't be active in the first place + return + disk_pixbuf = GdkPixbuf.Pixbuf.new_from_file(self.get_file_path()) + self._load_pixbuf_common(disk_pixbuf) + self.use_stable_pixbuf() + self.update() + self.remember_current_state() + self.window.update_picture_title() + + def try_load_file(self, gfile): + try: + self.gfile = gfile + pixbuf = GdkPixbuf.Pixbuf.new_from_file(self.get_file_path()) + self._connect_gfile_monitoring() + except Exception as ex: + if not ex.message: + ex.message = "[exception without a valid message]" + ex = InvalidFileFormatException(ex.message, gfile.get_path()) + self.window.reveal_action_report(ex.message) + self.gfile = None + pixbuf = self._new_blank_pixbuf(100, 100) + # XXX dans l'idéal on devrait ne rien ouvrir non ? ou si besoin (si + # ya pas de fenêtre) ouvrir un truc respectant les settings, plutôt + # qu'un petit pixbuf corrompu + self.try_load_pixbuf(pixbuf) + self._update_can_reload_action() + + def _connect_gfile_monitoring(self): + if self._gfile_monitor: + # Probably no need to reconnect it again. + # XXX est-il vraiment bon ? on devrait le déconnecter, le refaire ? + # Tout cela n'est pas débuggable parce que le fichier que je traque + # est le proxy de la sandbox (/run/user/1000/...) + self._gfile_monitor = None + flags = Gio.FileMonitorFlags.WATCH_MOUNTS + self._gfile_monitor = self.gfile.monitor(flags) + self._gfile_monitor.connect('changed', self.reveal_reload_message) + + def disable_monitoring(self): + self._monitoring_disabled = True + + def enable_monitoring(self): + """This should only be called initially, or asynchronously, or in case + of an exception""" + self._monitoring_disabled = False + + def reveal_reload_message(self, *args): + """This method is called when the file changed, which is async: we can't + enable or disable the monitoring in the DrSavingManager's method because + saving the pixbuf takes longer to trigger the 'changed' signal on the + monitor than it takes to run the end of the saving process.""" + # I'm not sure this lock is 100% correct because i'm monitoring the + # portal proxy file when testing with flatpak. + if self._monitoring_disabled: + # The idea is that the message banner is temporarily disabled until + # the monitor sends its next "changed" event, which we expect (it's + # the one from our own saving process). + if args[3] != Gio.FileMonitorEvent.CHANGED: + try: + self.reload_from_disk() + except Exception as ex: + print(e) + # Context: an error message + self._window.reveal_message(_("Failed to reload %s") % \ + self.gfile.get_path()) + self.enable_monitoring() + return + self._update_can_reload_action() + self.reload_label.set_visible(self.window.get_allocated_width() > 500) + self.reload_info_bar.set_visible(True) + + def hide_reload_message(self, *args): + self.reload_info_bar.set_visible(False) + + def _update_can_reload_action(self): + self.set_action_sensitivity('reload_file', self.gfile is not None) + + ############################################################################ + # Image title and tab management ########################################### + + def build_tab_widget(self): + """Build the GTK widget displayed as the tab title.""" + # The tab can be closed with a button. + btn = Gtk.Button.new_from_icon_name('window-close-symbolic', Gtk.IconSize.BUTTON) + btn.set_relief(Gtk.ReliefStyle.NONE) + btn.connect('clicked', self.try_close_tab) + # The title is a label. Middle-clicking on it closes the tab too. + self.tab_label = Gtk.Label(label=self.get_filename_for_display()) + self.tab_label.set_ellipsize(Pango.EllipsizeMode.END) + event_box = Gtk.EventBox() + event_box.add(self.tab_label) + event_box.connect('button-press-event', self.on_tab_title_clicked) + # These widgets are packed in a regular box, which is returned. + tab_title = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, expand=True) + if self.window.deco_layout == 'he': + tab_title.pack_start(btn, expand=False, fill=False, padding=0) + tab_title.pack_end(event_box, expand=True, fill=True, padding=0) + else: + tab_title.pack_start(event_box, expand=True, fill=True, padding=0) + tab_title.pack_end(btn, expand=False, fill=False, padding=0) + tab_title.show_all() + return tab_title + + def on_tab_title_clicked(self, widget, event_button): + if event_button.type == Gdk.EventType.BUTTON_PRESS \ + and event_button.button == Gdk.BUTTON_MIDDLE: + self.try_close_tab() + return True + return False # This callback HAS TO return a boolean + + def update_title(self): + main_title = self.get_filename_for_display() + if not self.is_saved(): + main_title = "*" + main_title + self.set_tab_label(main_title) + return main_title + + def get_filename_for_display(self): + if self.get_file_path() is None: + unsaved_file_name = _("Unsaved file") + else: + unsaved_file_name = self.get_file_path().split('/')[-1] + return unsaved_file_name + + def try_close_tab(self, *args): + """Ask the window to close the image/tab. Then unallocate widgets and + pixbufs.""" + if self.window.close_tab(self): + self.destroy() + self.selection.reset(False) + self.main_pixbuf = None + self.temp_pixbuf = None + self._history.empty_history() + return True + else: + return False + + def set_tab_label(self, title_text): + self.tab_label.set_label(title_text) + + def get_file_path(self): + if self.gfile is None: + return None + else: + return self.gfile.get_path() + + def show_properties(self): + DrPropertiesDialog(self.window, self) + + def update_image_wide_actions(self): + self.update_history_sensitivity() + self._update_can_reload_action() + + ############################################################################ + # History management ####################################################### + + def try_undo(self): + self._history.try_undo() + + def try_redo(self): + self._history.try_redo() + + def is_saved(self): + return self._history.get_saved() + + def remember_current_state(self): + self._history.add_state(self.main_pixbuf.copy()) + + def update_history_sensitivity(self): + self.set_action_sensitivity('undo', self._history.can_undo()) + self.set_action_sensitivity('redo', self._history.can_redo()) + # self.update_history_actions_labels() + + def add_to_history(self, operation): + self._history.add_operation(operation) + + def should_replace(self): + if self._history.can_undo(): + return False + return not self._history.has_initial_pixbuf() + + def get_initial_rgba(self): + return self._history.initial_operation['rgba'] + + ############################################################################ + # Misc ? ################################################################### + + def set_action_sensitivity(self, action_name, state): + self.window.lookup_action(action_name).set_enabled(state) + + def update_actions_state(self): + # XXX shouldn't it be done by the selection_manager? + state = self.selection.is_active + self.set_action_sensitivity('unselect', state) + self.set_action_sensitivity('select_all', not state) + self.set_action_sensitivity('selection_cut', state) + self.set_action_sensitivity('selection_copy', state) + self.set_action_sensitivity('selection_delete', state) + self.set_action_sensitivity('selection_export', state) + self.set_action_sensitivity('new_tab_selection', state) + self.set_action_sensitivity('selection-replace-canvas', state) + self.set_action_sensitivity('selection-expand-canvas', state) + self.active_tool().update_actions_state() + + def active_tool(self): + return self.window.active_tool() + + def get_mouse_is_pressed(self): + return self._is_pressed + + ############################################################################ + # Drawing area, main pixbuf, and surface management ######################## + + def on_draw(self, area, cairo_context): + """Signal callback. Executed when self._drawing_area is redrawn.""" + if self.window.devel_mode: + self._fps_counter += 1 + + # Background color + cairo_context.set_source_rgba(*self._bg_rgba) + cairo_context.paint() + + # Zoom level + cairo_context.scale(self.zoom_level, self.zoom_level) + + # Image (with scroll position) + cairo_context.set_source_surface(self.get_surface(), \ + -1 * self.scroll_x, -1 * self.scroll_y) + if self.is_zoomed_surface_sharp(): + cairo_context.get_source().set_filter(cairo.FILTER_NEAREST) + cairo_context.paint() + + # What the tool shows on the canvas, upon what it paints, for example an + # overlay to imply how to interact with a previewed operation. + self.active_tool().on_draw_above(area, cairo_context) + + # Limit of the canvas (for readability) + utilities_generic_canvas_outline(cairo_context, self.zoom_level, \ + self.get_pixbuf_width() - self.scroll_x, \ + self.get_pixbuf_height() - self.scroll_y) + + def on_press_on_area(self, area, event): + """Signal callback. Executed when a mouse button is pressed on + self._drawing_area, if the button is the mouse wheel the colors are + exchanged, otherwise the signal is transmitted to the selected tool.""" + if self._is_pressed: + # reject attempts to draw with a given button if an other one is + # already doing something + return + event_x, event_y = self.get_event_coords(event) + if event.button == 2: + self.motion_behavior = DrMotionBehavior.SLIP + self._slip_press_x = event.x + self._slip_press_y = event.y + self._slip_init_x = self.scroll_x + self._slip_init_y = self.scroll_y + return + self.motion_behavior = DrMotionBehavior.DRAW + self._is_pressed = True + self.window.set_window_subtitles() + # subtitles must be generated *before* calling the tool, otherwise any + # property changed by a modifier would be reset by `get_editing_tips` + self.active_tool().on_press_on_area(event, self.surface, event_x, event_y) + + def on_motion_on_area(self, area, event): + """Signal callback. Executed when the mouse pointer moves upon + self._drawing_area, the signal is transmitted to the selected tool. + If a button (not the mouse wheel) is pressed, the tool's method should + have an effect on the image, otherwise it shouldn't change anything + except the mouse cursor icon for example.""" + event_x, event_y = self.get_event_coords(event) + + if self.motion_behavior == DrMotionBehavior.HOVER: + # Some tools need the coords in the image, others need the coords on + # the widget, so the entire event is given. + self.active_tool().on_unclicked_motion_on_area(event, self.surface) + + elif self.motion_behavior == DrMotionBehavior.DRAW: + # implicitly impossible if not self._is_pressed + self.active_tool().on_motion_on_area(event, self.surface, event_x, \ + event_y, self._rendering_is_locked) + if self._rendering_is_locked: + if self.window.devel_mode: + self._skipped_frames += 1 + return + self._rendering_is_locked = True + self.update() + GLib.timeout_add(self._framerate_hint, self._async_unlock, {}) + + else: # self.motion_behavior == DrMotionBehavior.SLIP: + self.scroll_x = self._slip_init_x + self.scroll_y = self._slip_init_y + delta_x = self._slip_press_x - event.x + delta_y = self._slip_press_y - event.y + self.add_deltas(delta_x, delta_y, 1 / self.zoom_level) + + # If is pressed, a tooltip displaying contextual information is + # shown: by default, it contains at least the pointer coordinates. + if (event.state & Gdk.ModifierType.CONTROL_MASK) == Gdk.ModifierType.CONTROL_MASK: + self._ctrl_pressed = True + if self._ctrl_pressed: + full_tooltip_text = str(event_x) + ", " + str(event_y) + tool_tooltip = self._get_tool_tooltip(event_x, event_y) + if tool_tooltip is not None: + full_tooltip_text += "\n" + tool_tooltip + self._drawing_area.set_tooltip_text(full_tooltip_text) + else: + self._drawing_area.set_tooltip_text(None) + + def on_release_on_area(self, area, event): + """Signal callback. Executed when a mouse button is released on + self._drawing_area, if the button is not the signal is transmitted to + the selected tool.""" + self._ctrl_pressed = False + if self.motion_behavior == DrMotionBehavior.SLIP: + if not self._is_slip_moving(): + self.window.on_middle_click() + self.motion_behavior = DrMotionBehavior.HOVER + return + self.motion_behavior = DrMotionBehavior.HOVER + event_x, event_y = self.get_event_coords(event) + self.active_tool().on_release_on_area(event, self.surface, event_x, event_y) + self._is_pressed = False + self.window.update_picture_title() # just to add the star + self.window.set_window_subtitles() # the tool's state changed + + def _is_slip_moving(self): + """Tells if the pointer moved while the middle button of the mouse is + pressed, depending on a constant hardcoded limit.""" + mx = abs(self._slip_init_x - self.scroll_x) > DrMotionBehavior._LIMIT + my = abs(self._slip_init_y - self.scroll_y) > DrMotionBehavior._LIMIT + return mx or my + + def _get_tool_tooltip(self, ev_x, ev_y): + """Generates the part of the tooltip which is specific to the active + tool (it can return None!) to show when Ctrl is pressed.""" + return self.active_tool().get_tooltip(ev_x, ev_y ,self.motion_behavior) + + def update(self): + # print('image.py: _drawing_area.queue_draw') + self._drawing_area.queue_draw() + + def _async_unlock(self, content_params={}): + """This is used as a GSourceFunc so it should return False.""" + self._rendering_is_locked = False + return False + + def get_surface(self): + return self.surface + + def on_enter_image(self, *args): + self.window.set_cursor(True) + + def on_leave_image(self, *args): + self.window.set_cursor(False) + + def pre_save(self, gfile): + """The gfile is set before saving, because reveal_reload_message expects + self.gfile to be correct.""" + self.gfile = gfile + self._connect_gfile_monitoring() + + def set_surface_as_stable_pixbuf(self): + w = self.surface.get_width() + h = self.surface.get_height() + self.main_pixbuf = Gdk.pixbuf_get_from_surface(self.surface, 0, 0, w, h) + self._framerate_hint = math.sqrt(w * h) - 1000 + self._framerate_hint = int(self._framerate_hint * 0.2) + # between 500 and 33ms (= between 2 and 30 fps) + self._framerate_hint = max(33, min(500, self._framerate_hint)) + # print("image.py: hint =", self._framerate_hint) + + def use_stable_pixbuf(self): + """This is called by tools' `restore_pixbuf`, so at the beginning of + each operation (even unapplied).""" + # maybe the "scale" parameter should be 1 instead of 0 + self.surface = Gdk.cairo_surface_create_from_pixbuf(self.main_pixbuf, 0, None) + # print('image.py: use_stable_pixbuf') + self.surface.set_device_scale(self.SCALE_FACTOR, self.SCALE_FACTOR) + + def get_pixbuf_width(self): + return self.main_pixbuf.get_width() + + def get_pixbuf_height(self): + return self.main_pixbuf.get_height() + + def set_main_pixbuf(self, new_pixbuf): + """Safely set a pixbuf as the main one (not used everywhere internally + in image.py, but it's normal).""" + if new_pixbuf is None: + raise NoPixbufNoChangeException('main_pixbuf') + else: + self.main_pixbuf = new_pixbuf + + ############################################################################ + # Temporary pixbuf management ############################################## + + def set_temp_pixbuf(self, new_pixbuf): + if new_pixbuf is None: + raise NoPixbufNoChangeException('temp_pixbuf') + else: + self.temp_pixbuf = new_pixbuf + + def reset_temp(self): + self.set_temp_pixbuf(self._new_blank_pixbuf(1, 1)) + self.use_stable_pixbuf() + self.update() + + ############################################################################ + # Framerate tracking (debug only) ########################################## + + def reset_fps_counter(self, async_cb_data={}): + """Development only: live-display the evolution of the framerate of the + drawing area. The max should be around 60, but many tools don't require + so many redraws. + This is used as a GSourceFunc so it should return False.""" + if self.window.should_track_framerate: + # Context: this is a debug information that users will never see + msg = _("%s frames per second") % self._fps_counter + msg += " (" + str(self._skipped_frames) + " motion inputs skipped)" + self.window.reveal_message(msg) + self._fps_counter = 0 + self._skipped_frames = 0 + GLib.timeout_add(1000, self.reset_fps_counter, {}) + elif self.window.info_bar.get_visible(): + self.window.reveal_message("Tracking stopped.", True) + return False + + ############################################################################ + # Interaction with the minimap ############################################# + + def get_widget_width(self): + return self._drawing_area.get_allocated_width() + + def get_widget_height(self): + return self._drawing_area.get_allocated_height() + + def generate_mini_pixbuf(self, preview_size): + mpb_width = self.get_pixbuf_width() + mpb_height = self.get_pixbuf_height() + if mpb_height > mpb_width: + mw = preview_size * (mpb_width/mpb_height) + mh = preview_size + else: + mw = preview_size + mh = preview_size * (mpb_height/mpb_width) + return self.main_pixbuf.scale_simple(mw, mh, GdkPixbuf.InterpType.TILES) + + def get_minimap_need_overlay(self): + mpb_width = self.get_pixbuf_width() + mpb_height = self.get_pixbuf_height() + show_x = self.get_widget_width() < mpb_width * self.zoom_level + show_y = self.get_widget_height() < mpb_height * self.zoom_level + show_overlay = show_x or show_y + return show_overlay + + def get_minimap_ratio(self, mini_width): + return mini_width/self.get_pixbuf_width() + + def get_visible_size(self): + visible_width = int(self.get_widget_width() / self.zoom_level) + visible_height = int(self.get_widget_height() / self.zoom_level) + return visible_width, visible_height + + ############################################################################ + # Scroll and zoom levels ################################################### + + def get_previewed_width(self): + # Indirect way to know if it's a transform tool + if self.active_tool().menu_id == 1: + if not self.active_tool().apply_to_selection: + return self.temp_pixbuf.get_width() + 12 + return self.get_pixbuf_width() + + def get_previewed_height(self): + # Indirect way to know if it's a transform tool + if self.active_tool().menu_id == 1: + if not self.active_tool().apply_to_selection: + return self.temp_pixbuf.get_height() + 12 + return self.get_pixbuf_height() + + def fake_scrollbar_update(self): + self.add_deltas(0, 0, 0) + + def get_event_coords(self, event, as_integers=True): + event_x = self.scroll_x + (event.x / self.zoom_level) + event_y = self.scroll_y + (event.y / self.zoom_level) + if as_integers: + # `int()` will truncate to the lower integer so we need this to get + # an accurate behavior when doing pixel-art for example + event_x += 0.5 + event_y += 0.5 + return int(event_x), int(event_y) + else: + return event_x, event_y + + def get_corrected_coords(self, x1, x2, y1, y2, with_selection, with_zoom): + """Do whatever coordinates conversions are needed by tools like `crop` + and `scale` to display things (selection pixbuf, mouse cursors on hover, + etc.) correctly enough.""" + w = x2 - x1 + h = y2 - y1 + x1 = x1 - self.scroll_x + y1 = y1 - self.scroll_y + if with_selection: + x1 += self.selection.selection_x + y1 += self.selection.selection_y + x2 = x1 + w + y2 = y1 + h + if with_zoom: + x1 *= self.zoom_level + x2 *= self.zoom_level + y1 *= self.zoom_level + y2 *= self.zoom_level + return x1, x2, y1, y2 + + def get_nineths_sizes(self, apply_to_selection, x1, y1): + """Returns the sizes of the 'nineths' of the image used for example by + 'scale' or 'crop' to decide the cursor they'll show.""" + height = self.temp_pixbuf.get_height() + width = self.temp_pixbuf.get_width() + if not apply_to_selection: + x1 = 0 + y1 = 0 + # width_left, width_right, height_top, height_bottom + wl, wr, ht, hb = self.get_corrected_coords(int(x1), width, int(y1), \ + height, apply_to_selection, True) + # XXX using local deltas this way "works" but isn't mathematically + # correct: scaled selections have a "null" and excentred central ninth + # ^ c'est toujours vrai ça ?? + wl += 0.4 * width * self.zoom_level + wr -= 0.4 * width * self.zoom_level + ht += 0.4 * height * self.zoom_level + hb -= 0.4 * height * self.zoom_level + return {'wl': wl, 'wr': wr, 'ht': ht, 'hb': hb} + + def on_scroll_on_area(self, area, event): + # TODO https://lazka.github.io/pgi-docs/index.html#Gdk-3.0/classes/EventScroll.html#Gdk.EventScroll + ctrl_is_used = (event.state & Gdk.ModifierType.CONTROL_MASK) == Gdk.ModifierType.CONTROL_MASK + if ctrl_is_used == self._ctrl_to_zoom: + self._zoom_to_point(event) + else: + acceleration = 20 / self._zoom_profile() + self.add_deltas(event.delta_x, event.delta_y, acceleration) + + def on_scrollbar_value_change(self, scrollbar): + self.correct_coords(self._h_scrollbar.get_value(), self._v_scrollbar.get_value()) + self.update() # allowing imperfect framerate would likely be useless + + def reset_deltas(self, delta_x, delta_y): + if delta_x > 0: + wanted_x = self.get_previewed_width() + elif delta_x < 0: + wanted_x = 0 + else: + wanted_x = self.scroll_x + + if delta_y > 0: + wanted_y = self.get_previewed_height() + elif delta_y < 0: + wanted_y = 0 + else: + wanted_y = self.scroll_y + + self.correct_coords(wanted_x, wanted_y) + self.window.minimap.update_overlay() + + def add_deltas(self, delta_x, delta_y, factor): + wanted_x = self.scroll_x + int(delta_x * factor) + wanted_y = self.scroll_y + int(delta_y * factor) + self.correct_coords(wanted_x, wanted_y) + self.window.minimap.update_overlay() + + def correct_coords(self, wanted_x, wanted_y): + available_w = self.get_widget_width() + available_h = self.get_widget_height() + if available_w < 2: + return # could be better handled + + # Update the horizontal scrollbar + mpb_width = self.get_previewed_width() + wanted_x = min(wanted_x, self.get_max_coord(mpb_width, available_w)) + wanted_x = max(wanted_x, 0) + self.update_scrollbar(False, available_w, int(mpb_width), int(wanted_x)) + + # Update the vertical scrollbar + mpb_height = self.get_previewed_height() + wanted_y = min(wanted_y, self.get_max_coord(mpb_height, available_h)) + wanted_y = max(wanted_y, 0) + self.update_scrollbar(True, available_h, int(mpb_height), int(wanted_y)) + + def get_max_coord(self, mpb_size, available_size): + max_coord = mpb_size - (available_size / self.zoom_level) + return max_coord + + def update_scrollbar(self, is_vertical, allocated_size, pixbuf_size, coord): + if is_vertical: + scrollbar = self._v_scrollbar + else: + scrollbar = self._h_scrollbar + scrollbar.set_visible(allocated_size / self.zoom_level < pixbuf_size) + scrollbar.set_range(0, pixbuf_size) + scrollbar.get_adjustment().set_page_size(allocated_size / self.zoom_level) + scrollbar.set_value(coord) + if is_vertical: + self.scroll_y = int(scrollbar.get_value()) + else: + self.scroll_x = int(scrollbar.get_value()) + + def _zoom_to_point(self, event): + """Zoom in or out the image in a way such that the point under the + pointer stays as much as possible under the pointer.""" + event_x, event_y = self.get_event_coords(event) + + # Updating the zoom level + zoom_delta = (event.delta_x + event.delta_y) * -1 * self._zoom_profile() + self.inc_zoom_level(zoom_delta) + + new_event_x, new_event_y = self.get_event_coords(event) + delta_correction_x = event_x - new_event_x + delta_correction_y = event_y - new_event_y + + # Updating the scroll position based on the values previously found + self.add_deltas(delta_correction_x, delta_correction_y, 1) + + def inc_zoom_level(self, delta): + self.set_zoom_level((self.zoom_level * 100) + delta) + + def set_zoom_level(self, level): + normalized_zoom_level = max(min(level, self.ZOOM_MAX), 20) + self.zoom_level = (int(normalized_zoom_level)/100) + self.window.minimap.update_zoom_scale(self.zoom_level) + if self.is_zoomed_surface_sharp(): + self.window.minimap.set_zoom_label(self.zoom_level * 100) + self.fake_scrollbar_update() + self.update() + + def set_opti_zoom_level(self, *args): + allocated_width = self.get_widget_width() + allocated_height = self.get_widget_height() + h_ratio = allocated_width / self.get_previewed_width() + v_ratio = allocated_height / self.get_previewed_height() + opti = min(h_ratio, v_ratio) * 99 # Not 100 because some margin is cool + self.set_zoom_level(opti) + self.scroll_x = 0 + self.scroll_y = 0 + + def is_zoomed_surface_sharp(self): + return self.zoom_level > self.ZOOM_THRESHOLD + + def _zoom_profile(self): + """This is the 'speed' of the zoom scrolling: when between 20% and 100% + it's quite precise, but between 1000% and 2000% we prefer being as fast + as possible.""" + if self.zoom_level < 2.0: + return 3.0 + elif self.zoom_level < 4.0: + return 6.0 + elif self.zoom_level < 10.0: + return 10.0 + else: + return 20.0 + + ############################################################################ ################################################################################ diff --git a/src/main.py b/src/main.py index ab3d966c..7a75fb01 100644 --- a/src/main.py +++ b/src/main.py @@ -24,378 +24,378 @@ from .utilities_files import utilities_gfile_is_image def main(version): - app = Application(version) - return app.run(sys.argv) + app = Application(version) + return app.run(sys.argv) ################################################################################ class Application(Gtk.Application): - shortcuts_window = None - prefs_window = None - - APP_ID = 'com.github.maoschanz.drawing' - APP_PATH = '/com/github/maoschanz/drawing' - BUG_REPORT_URL = 'https://github.com/maoschanz/drawing/issues' - FLATPAK_BINARY_PATH = '/app/bin/drawing' - CURRENT_BINARY_PATH = '' - - ############################################################################ - # Initialization ########################################################### - - def __init__(self, version): - super().__init__(application_id=self.APP_ID, - flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) - - GLib.set_application_name(_("Drawing")) - GLib.set_prgname(self.APP_ID) - self._version = version - self.has_tools_in_menubar = False - self.runs_in_sandbox = False - - self.connect('startup', self.on_startup) - self.register(None) - self.connect('activate', self.on_activate) - self.connect('command-line', self.on_cli) - - self.add_main_option('version', b'v', GLib.OptionFlags.NONE, - # Description of a command line option - GLib.OptionArg.NONE, _("Show the app version"), None) - self.add_main_option('new-window', b'n', GLib.OptionFlags.NONE, - # Description of a command line option - GLib.OptionArg.NONE, _("Open a new window"), None) - self.add_main_option('new-tab', b't', GLib.OptionFlags.NONE, - # Description of a command line option - GLib.OptionArg.NONE, _("Open a new tab"), None) - self.add_main_option('edit-clipboard', b'c', GLib.OptionFlags.NONE, - # Description of a command line option - GLib.OptionArg.NONE, _("Edit the clipboard content"), None) - - icon_theme = Gtk.IconTheme.get_default() - icon_theme.add_resource_path(self.APP_PATH + '/icons') - icon_theme.add_resource_path(self.APP_PATH + '/tools/icons') - - def on_startup(self, *args): - """Called only once, add app-wide menus and actions, and all accels.""" - self._build_actions() - builder = Gtk.Builder.new_from_resource(self.APP_PATH + '/ui/app-menus.ui') - menubar_model = builder.get_object('menu-bar') - self.set_menubar(menubar_model) - - def _build_actions(self): - """Add all app-wide actions.""" - self.add_action_simple('new_window', self.on_new_window, ['n']) - self.add_action_simple('settings', self.on_prefs, ['comma']) - - current_date = datetime.datetime.now() - if current_date.month == 4 and current_date.day == 1: - self.add_action_simple('april-fools', self.on_april_fools) - - self.add_action_simple('help', self.on_help_index, ['F1']) - self.add_action_simple('help_main', self.on_help_main) - self.add_action_simple('help_zoom', self.on_help_zoom) - self.add_action_simple('help_fullscreen', self.on_help_fullscreen) - self.add_action_simple('help_tools', self.on_help_tools) - self.add_action_simple('help_colors', self.on_help_colors) - self.add_action_simple('help_transform', self.on_help_transform) - self.add_action_simple('help_selection', self.on_help_selection) - self.add_action_simple('help_prefs', self.on_help_prefs) - self.add_action_simple('help_whats_new', self.on_help_whats_new) - - self.add_action_simple('report-issue', self.on_report) - # we don't need an action for the shortcuts because #563 - self.add_action_simple('about', self.on_about, ['F1']) - self.add_action_simple('quit', self.on_quit, ['q']) - - ############################################################################ - # Opening windows & CLI handling ########################################### - - def open_window_with_content(self, gfile, get_cb=False, check_duplicates=True): - """Open a new window with an optional Gio.File as an argument. If get_cb - is true, the Gio.File is ignored and the picture is built from the - clipboard content.""" - if gfile is not None and check_duplicates: - w, already_opened_index = self.has_image_opened(gfile.get_path()) - if w is not None: - if not w.confirm_open_twice(gfile): - w.notebook.set_current_page(already_opened_index) - return - - win = DrWindow(application=self) - win.present() - - content_params = {'gfile': gfile, 'get_cb': get_cb} - # Parameters are: time in milliseconds, method, data # XXX todo? - # GLib.timeout_add(10, win.init_window_content_async, content_params) - win.init_window_content_async(content_params) - return win - - def on_activate(self, *args): - """I don't know if this is ever called from the 'activate' signal, but - it's called anyways.""" - win = self.props.active_window - if not win: - self.on_new_window() - else: - win.present() - - def on_cli(self, gio_app, gio_command_line): - """Main handler, managing options and CLI arguments. - Arguments are a `Gio.Application` and a `Gio.ApplicationCommandLine`.""" - - # This is the list of files given by the command line. If there is none, - # this will be ['/app/bin/drawing'] which has a length of 1. - arguments = gio_command_line.get_arguments() - self.CURRENT_BINARY_PATH = arguments[0] - if self.CURRENT_BINARY_PATH == self.FLATPAK_BINARY_PATH: - self.runs_in_sandbox = True - - # Possible options are 'version', 'edit-clipboard', 'new-tab', and - # 'new-window', in this order: only one option can be applied, '-ntvc' - # will be understood as '-v'. - options = gio_command_line.get_options_dict() - - if options.contains('version'): - print(_("Drawing") + ' ' + self._version) - if self.is_beta(): - print(_("This version isn't stable!")) - print() - print(_("Report bugs or ideas") + " 👉️ " + self.BUG_REPORT_URL) - - elif options.contains('edit-clipboard'): - win = self.props.active_window - if not win: - self.open_window_with_content(None, True) - else: - win.present() - win.build_image_from_clipboard() - - elif options.contains('new-tab') and len(arguments) == 1: - # If '-t' but no file given as argument - win = self.props.active_window - if not win: - self.on_new_window() - else: - win.present() - win.build_blank_image() - - elif options.contains('new-window'): - # it opens one new window per file given as argument, or just one - # new window if no argument is a valid enough file. - windows_counter = 0 - for fpath in arguments[1:]: - f = self._get_valid_file(gio_command_line, fpath) - # here, f can be a GioFile or a boolean. True would mean the app - # should open a new blank image. - if f == False: - continue - f = None if f == True else f - self.open_window_with_content(f) - windows_counter = windows_counter + 1 - if windows_counter == 0: - self.on_new_window() - - elif len(arguments) == 1: - self.on_activate() - - else: - # giving files without '-n' is equivalent to giving files with '-t' - for fpath in arguments[1:]: - f = self._get_valid_file(gio_command_line, fpath) - # here f can be a Gio.File or a boolean: True would mean the app - # should open a new blank image. - if f == False: - continue - win = self.props.active_window - if not win: - f = None if f == True else f - self.open_window_with_content(f) - else: - win.present() - if f == True: - win.build_blank_image() - else: - win.build_new_from_file(gfile=f) - - # I don't even know if i should return something - return 0 - - ############################################################################ - # Actions callbacks ######################################################## - - def on_new_window(self, *args): - """Action callback, opening a new window with an empty canvas.""" - return self.open_window_with_content(None) - - def on_report(self, *args): - """Action callback, opening a page to the Github issue tracker.""" - win = self.props.active_window - Gtk.show_uri_on_window(win, self.BUG_REPORT_URL, Gdk.CURRENT_TIME) - - def on_prefs(self, *args): - """Action callback, showing the preferences window.""" - if self.prefs_window is not None: - self.prefs_window.destroy() - wants_csd = 'h' in self.props.active_window.deco_layout - self.prefs_window = DrPrefsWindow(self.is_beta(), wants_csd, \ - application=self) - self.prefs_window.present() - - def on_april_fools(self, *args): - """Action callback, rickrolling the user.""" - Gtk.show_uri_on_window( - self.props.active_window, \ - 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',\ - Gdk.CURRENT_TIME \ - ) - - def on_help_index(self, *args): - """Action callback, showing the index of user help manual.""" - self._show_help_page('') - - def on_help_main(self, *args): - self._show_help_page('/main_features') - - def on_help_zoom(self, *args): - self._show_help_page('/zoom_preview') - - def on_help_fullscreen(self, *args): - self._show_help_page('/fullscreen') - - def on_help_tools(self, *args): - self._show_help_page('/tools_classic') - - def on_help_colors(self, *args): - self._show_help_page('/tools_classic_colors') - - def on_help_transform(self, *args): - self._show_help_page('/tools_transform') - - def on_help_selection(self, *args): - self._show_help_page('/tools_selection') - - def on_help_prefs(self, *args): - self._show_help_page('/preferences') - - def on_help_whats_new(self, *args): - self._show_help_page('/whats_new') - - def on_about(self, *args): - """Action callback, showing the "about" dialog.""" - about_dialog = Gtk.AboutDialog(transient_for=self.props.active_window, - copyright="© 2018-2023 Romain F. T.", - authors=["Romain F. T.", "Fábio Colacio", "Alexis Lozano", "Arak @ARAKHN1D"], - # To translators: "translate" this by a list of your names (one name - # per line), they will be displayed in the "about" dialog - translator_credits=_("translator-credits"), - artists=["Tobias Bernard", "Romain F. T.", - # To translators: this is credits for the icons, consider that "Art - # Libre" is proper name - _("GNOME's \"Art Libre\" icon set authors")], - comments=_("Simple image editor for Linux"), - license_type=Gtk.License.GPL_3_0, - logo_icon_name=self.APP_ID, version=str(self._version), - website='https://maoschanz.github.io/drawing/', - website_label=_("Official webpage")) - bug_report_btn = Gtk.LinkButton(halign=Gtk.Align.CENTER, visible=True, \ - label=_("Report bugs or ideas"), uri=self.BUG_REPORT_URL) - # about_dialog.get_content_area().add(bug_report_btn) # should i? - about_dialog.set_icon_name('com.github.maoschanz.drawing') - - about_dialog.run() - about_dialog.destroy() - - def on_quit(self, *args): - """Action callback, quitting the entire app.""" - if self.shortcuts_window is not None: - self.shortcuts_window.destroy() - if self.prefs_window is not None: - self.prefs_window.destroy() - - can_quit = True - # Try (= ask confirmation) to quit the main window(s) - main_windows = self.get_windows() - for w in main_windows: - if w.on_close(): - # User clicked on "cancel" - can_quit = False - else: - w.close() - w.destroy() - - # The expected behavior, but now theoretically useless, since closing all - # appwindows should quit automatically. It's too violent to be left - # without a guard clause. - if can_quit: - self.quit() - - ############################################################################ - # Utilities ################################################################ - - def is_beta(self): - """Tells is the app version is even or odd, odd versions being considered - as unstable versions. This affects available options and the style of - the headerbar.""" - return (int(self._version.split('.')[1]) * 5) % 10 == 5 - - def get_current_version(self): - return self._version - - def add_action_simple(self, action_name, callback, shortcuts=[]): - action = Gio.SimpleAction.new(action_name, None) - action.connect('activate', callback) - self.add_action(action) - self.set_accels_for_action('app.' + action_name, shortcuts) - - def add_action_boolean(self, action_name, default, callback): - action = Gio.SimpleAction().new_stateful(action_name, None, \ - GLib.Variant.new_boolean(default)) - action.connect('change-state', callback) - self.add_action(action) - - def _show_help_page(self, suffix): - win = self.props.active_window - Gtk.show_uri_on_window(win, 'help:drawing' + suffix, Gdk.CURRENT_TIME) - - def has_image_opened(self, file_path): - """Returns the window in which the given file is opened, and the index - of the tab where it is in the window's notebook. - Or `None, None` otherwise.""" - for win in self.get_windows(): - position_in_window = win.has_image_opened(file_path) - if position_in_window is not None: - return win, position_in_window - return None, None - - def _get_valid_file(self, app, path): - """Creates a GioFile object if the path corresponds to an image. If no - GioFile can be created, it returns a boolean telling whether or not a - window should be opened anyway.""" - if path == self.CURRENT_BINARY_PATH: - # when it's CURRENT_BINARY_PATH, the situation is normal (no error) - # and nothing to open. - return False - - err = _("Error opening this file.") + ' ' - try: - gfile = app.create_file_for_arg(path) - except Exception as excp: - if self.runs_in_sandbox: - command = "\n\tflatpak run --file-forwarding {0} @@ {1} @@\n" - command = command.format(self.APP_ID, path) - # This is an error message, %s is a better command suggestion - err = err + _("Did you mean %s ?") % command - else: - err = err + excp.message - print(err) # TODO show that message in an empty window - return False - - is_image, err = utilities_gfile_is_image(gfile, err) - if is_image: - return gfile - else: - print(err) # TODO show that message in an empty window - return True - - ############################################################################ + shortcuts_window = None + prefs_window = None + + APP_ID = 'com.github.maoschanz.drawing' + APP_PATH = '/com/github/maoschanz/drawing' + BUG_REPORT_URL = 'https://github.com/maoschanz/drawing/issues' + FLATPAK_BINARY_PATH = '/app/bin/drawing' + CURRENT_BINARY_PATH = '' + + ############################################################################ + # Initialization ########################################################### + + def __init__(self, version): + super().__init__(application_id=self.APP_ID, + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + + GLib.set_application_name(_("Drawing")) + GLib.set_prgname(self.APP_ID) + self._version = version + self.has_tools_in_menubar = False + self.runs_in_sandbox = False + + self.connect('startup', self.on_startup) + self.register(None) + self.connect('activate', self.on_activate) + self.connect('command-line', self.on_cli) + + self.add_main_option('version', b'v', GLib.OptionFlags.NONE, + # Description of a command line option + GLib.OptionArg.NONE, _("Show the app version"), None) + self.add_main_option('new-window', b'n', GLib.OptionFlags.NONE, + # Description of a command line option + GLib.OptionArg.NONE, _("Open a new window"), None) + self.add_main_option('new-tab', b't', GLib.OptionFlags.NONE, + # Description of a command line option + GLib.OptionArg.NONE, _("Open a new tab"), None) + self.add_main_option('edit-clipboard', b'c', GLib.OptionFlags.NONE, + # Description of a command line option + GLib.OptionArg.NONE, _("Edit the clipboard content"), None) + + icon_theme = Gtk.IconTheme.get_default() + icon_theme.add_resource_path(self.APP_PATH + '/icons') + icon_theme.add_resource_path(self.APP_PATH + '/tools/icons') + + def on_startup(self, *args): + """Called only once, add app-wide menus and actions, and all accels.""" + self._build_actions() + builder = Gtk.Builder.new_from_resource(self.APP_PATH + '/ui/app-menus.ui') + menubar_model = builder.get_object('menu-bar') + self.set_menubar(menubar_model) + + def _build_actions(self): + """Add all app-wide actions.""" + self.add_action_simple('new_window', self.on_new_window, ['n']) + self.add_action_simple('settings', self.on_prefs, ['comma']) + + current_date = datetime.datetime.now() + if current_date.month == 4 and current_date.day == 1: + self.add_action_simple('april-fools', self.on_april_fools) + + self.add_action_simple('help', self.on_help_index, ['F1']) + self.add_action_simple('help_main', self.on_help_main) + self.add_action_simple('help_zoom', self.on_help_zoom) + self.add_action_simple('help_fullscreen', self.on_help_fullscreen) + self.add_action_simple('help_tools', self.on_help_tools) + self.add_action_simple('help_colors', self.on_help_colors) + self.add_action_simple('help_transform', self.on_help_transform) + self.add_action_simple('help_selection', self.on_help_selection) + self.add_action_simple('help_prefs', self.on_help_prefs) + self.add_action_simple('help_whats_new', self.on_help_whats_new) + + self.add_action_simple('report-issue', self.on_report) + # we don't need an action for the shortcuts because #563 + self.add_action_simple('about', self.on_about, ['F1']) + self.add_action_simple('quit', self.on_quit, ['q']) + + ############################################################################ + # Opening windows & CLI handling ########################################### + + def open_window_with_content(self, gfile, get_cb=False, check_duplicates=True): + """Open a new window with an optional Gio.File as an argument. If get_cb + is true, the Gio.File is ignored and the picture is built from the + clipboard content.""" + if gfile is not None and check_duplicates: + w, already_opened_index = self.has_image_opened(gfile.get_path()) + if w is not None: + if not w.confirm_open_twice(gfile): + w.notebook.set_current_page(already_opened_index) + return + + win = DrWindow(application=self) + win.present() + + content_params = {'gfile': gfile, 'get_cb': get_cb} + # Parameters are: time in milliseconds, method, data # XXX todo? + # GLib.timeout_add(10, win.init_window_content_async, content_params) + win.init_window_content_async(content_params) + return win + + def on_activate(self, *args): + """I don't know if this is ever called from the 'activate' signal, but + it's called anyways.""" + win = self.props.active_window + if not win: + self.on_new_window() + else: + win.present() + + def on_cli(self, gio_app, gio_command_line): + """Main handler, managing options and CLI arguments. + Arguments are a `Gio.Application` and a `Gio.ApplicationCommandLine`.""" + + # This is the list of files given by the command line. If there is none, + # this will be ['/app/bin/drawing'] which has a length of 1. + arguments = gio_command_line.get_arguments() + self.CURRENT_BINARY_PATH = arguments[0] + if self.CURRENT_BINARY_PATH == self.FLATPAK_BINARY_PATH: + self.runs_in_sandbox = True + + # Possible options are 'version', 'edit-clipboard', 'new-tab', and + # 'new-window', in this order: only one option can be applied, '-ntvc' + # will be understood as '-v'. + options = gio_command_line.get_options_dict() + + if options.contains('version'): + print(_("Drawing") + ' ' + self._version) + if self.is_beta(): + print(_("This version isn't stable!")) + print() + print(_("Report bugs or ideas") + " 👉️ " + self.BUG_REPORT_URL) + + elif options.contains('edit-clipboard'): + win = self.props.active_window + if not win: + self.open_window_with_content(None, True) + else: + win.present() + win.build_image_from_clipboard() + + elif options.contains('new-tab') and len(arguments) == 1: + # If '-t' but no file given as argument + win = self.props.active_window + if not win: + self.on_new_window() + else: + win.present() + win.build_blank_image() + + elif options.contains('new-window'): + # it opens one new window per file given as argument, or just one + # new window if no argument is a valid enough file. + windows_counter = 0 + for fpath in arguments[1:]: + f = self._get_valid_file(gio_command_line, fpath) + # here, f can be a GioFile or a boolean. True would mean the app + # should open a new blank image. + if f == False: + continue + f = None if f == True else f + self.open_window_with_content(f) + windows_counter = windows_counter + 1 + if windows_counter == 0: + self.on_new_window() + + elif len(arguments) == 1: + self.on_activate() + + else: + # giving files without '-n' is equivalent to giving files with '-t' + for fpath in arguments[1:]: + f = self._get_valid_file(gio_command_line, fpath) + # here f can be a Gio.File or a boolean: True would mean the app + # should open a new blank image. + if f == False: + continue + win = self.props.active_window + if not win: + f = None if f == True else f + self.open_window_with_content(f) + else: + win.present() + if f == True: + win.build_blank_image() + else: + win.build_new_from_file(gfile=f) + + # I don't even know if i should return something + return 0 + + ############################################################################ + # Actions callbacks ######################################################## + + def on_new_window(self, *args): + """Action callback, opening a new window with an empty canvas.""" + return self.open_window_with_content(None) + + def on_report(self, *args): + """Action callback, opening a page to the Github issue tracker.""" + win = self.props.active_window + Gtk.show_uri_on_window(win, self.BUG_REPORT_URL, Gdk.CURRENT_TIME) + + def on_prefs(self, *args): + """Action callback, showing the preferences window.""" + if self.prefs_window is not None: + self.prefs_window.destroy() + wants_csd = 'h' in self.props.active_window.deco_layout + self.prefs_window = DrPrefsWindow(self.is_beta(), wants_csd, \ + application=self) + self.prefs_window.present() + + def on_april_fools(self, *args): + """Action callback, rickrolling the user.""" + Gtk.show_uri_on_window( + self.props.active_window, \ + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',\ + Gdk.CURRENT_TIME \ + ) + + def on_help_index(self, *args): + """Action callback, showing the index of user help manual.""" + self._show_help_page('') + + def on_help_main(self, *args): + self._show_help_page('/main_features') + + def on_help_zoom(self, *args): + self._show_help_page('/zoom_preview') + + def on_help_fullscreen(self, *args): + self._show_help_page('/fullscreen') + + def on_help_tools(self, *args): + self._show_help_page('/tools_classic') + + def on_help_colors(self, *args): + self._show_help_page('/tools_classic_colors') + + def on_help_transform(self, *args): + self._show_help_page('/tools_transform') + + def on_help_selection(self, *args): + self._show_help_page('/tools_selection') + + def on_help_prefs(self, *args): + self._show_help_page('/preferences') + + def on_help_whats_new(self, *args): + self._show_help_page('/whats_new') + + def on_about(self, *args): + """Action callback, showing the "about" dialog.""" + about_dialog = Gtk.AboutDialog(transient_for=self.props.active_window, + copyright="© 2018-2023 Romain F. T.", + authors=["Romain F. T.", "Fábio Colacio", "Alexis Lozano", "Arak @ARAKHN1D"], + # To translators: "translate" this by a list of your names (one name + # per line), they will be displayed in the "about" dialog + translator_credits=_("translator-credits"), + artists=["Tobias Bernard", "Romain F. T.", + # To translators: this is credits for the icons, consider that "Art + # Libre" is proper name + _("GNOME's \"Art Libre\" icon set authors")], + comments=_("Simple image editor for Linux"), + license_type=Gtk.License.GPL_3_0, + logo_icon_name=self.APP_ID, version=str(self._version), + website='https://maoschanz.github.io/drawing/', + website_label=_("Official webpage")) + bug_report_btn = Gtk.LinkButton(halign=Gtk.Align.CENTER, visible=True, \ + label=_("Report bugs or ideas"), uri=self.BUG_REPORT_URL) + # about_dialog.get_content_area().add(bug_report_btn) # should i? + about_dialog.set_icon_name('com.github.maoschanz.drawing') + + about_dialog.run() + about_dialog.destroy() + + def on_quit(self, *args): + """Action callback, quitting the entire app.""" + if self.shortcuts_window is not None: + self.shortcuts_window.destroy() + if self.prefs_window is not None: + self.prefs_window.destroy() + + can_quit = True + # Try (= ask confirmation) to quit the main window(s) + main_windows = self.get_windows() + for w in main_windows: + if w.on_close(): + # User clicked on "cancel" + can_quit = False + else: + w.close() + w.destroy() + + # The expected behavior, but now theoretically useless, since closing all + # appwindows should quit automatically. It's too violent to be left + # without a guard clause. + if can_quit: + self.quit() + + ############################################################################ + # Utilities ################################################################ + + def is_beta(self): + """Tells is the app version is even or odd, odd versions being considered + as unstable versions. This affects available options and the style of + the headerbar.""" + return (int(self._version.split('.')[1]) * 5) % 10 == 5 + + def get_current_version(self): + return self._version + + def add_action_simple(self, action_name, callback, shortcuts=[]): + action = Gio.SimpleAction.new(action_name, None) + action.connect('activate', callback) + self.add_action(action) + self.set_accels_for_action('app.' + action_name, shortcuts) + + def add_action_boolean(self, action_name, default, callback): + action = Gio.SimpleAction().new_stateful(action_name, None, \ + GLib.Variant.new_boolean(default)) + action.connect('change-state', callback) + self.add_action(action) + + def _show_help_page(self, suffix): + win = self.props.active_window + Gtk.show_uri_on_window(win, 'help:drawing' + suffix, Gdk.CURRENT_TIME) + + def has_image_opened(self, file_path): + """Returns the window in which the given file is opened, and the index + of the tab where it is in the window's notebook. + Or `None, None` otherwise.""" + for win in self.get_windows(): + position_in_window = win.has_image_opened(file_path) + if position_in_window is not None: + return win, position_in_window + return None, None + + def _get_valid_file(self, app, path): + """Creates a GioFile object if the path corresponds to an image. If no + GioFile can be created, it returns a boolean telling whether or not a + window should be opened anyway.""" + if path == self.CURRENT_BINARY_PATH: + # when it's CURRENT_BINARY_PATH, the situation is normal (no error) + # and nothing to open. + return False + + err = _("Error opening this file.") + ' ' + try: + gfile = app.create_file_for_arg(path) + except Exception as excp: + if self.runs_in_sandbox: + command = "\n\tflatpak run --file-forwarding {0} @@ {1} @@\n" + command = command.format(self.APP_ID, path) + # This is an error message, %s is a better command suggestion + err = err + _("Did you mean %s ?") % command + else: + err = err + excp.message + print(err) # TODO show that message in an empty window + return False + + is_image, err = utilities_gfile_is_image(gfile, err) + if is_image: + return gfile + else: + print(err) # TODO show that message in an empty window + return True + + ############################################################################ ################################################################################ diff --git a/src/tools/classic_tools/brushes/brush_simple.py b/src/tools/classic_tools/brushes/brush_simple.py index 6ca32cbd..a33548f0 100644 --- a/src/tools/classic_tools/brushes/brush_simple.py +++ b/src/tools/classic_tools/brushes/brush_simple.py @@ -6,139 +6,139 @@ from .utilities_paths import utilities_smooth_path class BrushSimple(AbstractBrush): - __gtype_name__ = 'BrushSimple' - - def _get_tips(self, use_pressure, brush_direction): - label = _("Simple brush") + " - " - if use_pressure: - label += _("Width depends on the stylus pressure") - else: - label += _("Width depends on the mouse speed") - return [label] - - def draw_preview(self, operation, cairo_context): - cairo_context.set_line_cap(cairo.LineCap.ROUND) - cairo_context.set_line_join(cairo.LineJoin.ROUND) - cairo_context.set_source_rgba(*operation['rgba']) - super().draw_preview(operation, cairo_context) - - ############################################################################ - - def do_brush_operation(self, cairo_context, operation): - """Brush with dynamic width, where the variation of width is drawn by a - succession of segments. If pressure is detected, the width is pressure- - sensitive, otherwise it's speed-sensitive (with a heavy ponderation to - make it less ugly).""" - - if operation['is_preview']: # Previewing helps performance & debug - operation['line_width'] = max(1, int(operation['line_width'] * 0.8)) - return self.draw_preview(operation, cairo_context) - - if len(operation['path']) < 3: - # XXX minimum 3 points to get minimum 2 segments to avoid "list - # index out of range" errors when running the for loops - return - - self.operation_on_mask(operation, cairo_context) - - def do_masked_brush_op(self, cairo_context, operation): - cairo_context.set_line_cap(cairo.LineCap.ROUND) - cairo_context.set_line_join(cairo.LineJoin.ROUND) - - # Build a raw path with lines between the points - cairo_context.new_path() - for pt in operation['path']: - cairo_context.line_to(pt['x'], pt['y']) - raw_path = cairo_context.copy_path() - - if operation['smooth']: - # When the zoom is less than 400% (no great precision required by - # the user), this "raw" path is smoothed. - cairo_context.new_path() - utilities_smooth_path(cairo_context, raw_path) - smoothed_path = cairo_context.copy_path() - else: - smoothed_path = raw_path - - # Build an array with all the widths for each segment - widths = self._build_widths(operation['path'], operation['line_width']) - - # Run through the path to manually draw each segment with its width - i = 0 - cairo_context.new_path() - for segment in smoothed_path: - i = i + 1 - ok, future_x, future_y = self._future_point(segment) - if not ok: - cairo_context.move_to(future_x, future_y) - continue - current_x, current_y = cairo_context.get_current_point() - cairo_context.set_line_width(widths[i - 1]) - self._add_segment(cairo_context, segment) - cairo_context.stroke() - cairo_context.move_to(future_x, future_y) - - ############################################################################ - # Private methods ########################################################## - - def _build_widths(self, manual_path, base_width): - """Build an array of widths from the raw data, either using the value of - the pressure or based on the estimated speed of the movement.""" - widths = [] - dists = [] - p2 = None - for pt in manual_path: - if pt['p'] is None: - # No data about pressure - if p2 is not None: - dists.append(self._get_dist(pt['x'], pt['y'], p2['x'], p2['y'])) - else: - # There are data about pressure - if p2 is not None: - if p2['p'] == 0 or pt['p'] == 0: - seg_width = 0 - else: - seg_width = (p2['p'] + pt['p']) / 2 - # A segment whose 2 points have a 50% pressure shall have a - # width of "100%" of the base_width, so "base * mean * 2" - widths.append(base_width * seg_width * 2) - p2 = pt - - # If nothing in widths, it has to be filled from dists - if len(widths) == 0: - min_dist = min(dists) - max_dist = max(dists) - temp_width = 0 - for dist in dists: - new_width = 1 + int(base_width / max(1, 0.05 * dist)) - if temp_width == 0: - temp_width = (new_width + base_width) / 2 - else: - temp_width = (new_width + temp_width + temp_width) / 3 - width = max(1, int(temp_width)) - widths.append(width) - - return widths - - def _add_segment(self, cairo_context, pts): - if pts[0] == cairo.PathDataType.CURVE_TO: - cairo_context.curve_to(pts[1][0], pts[1][1], pts[1][2], pts[1][3], \ - pts[1][4], pts[1][5]) - elif pts[0] == cairo.PathDataType.LINE_TO: - cairo_context.line_to(pts[1][0], pts[1][1]) - - def _future_point(self, pts): - if pts[0] == cairo.PathDataType.CURVE_TO: - return True, pts[1][4], pts[1][5] - elif pts[0] == cairo.PathDataType.LINE_TO: - return True, pts[1][0], pts[1][1] - else: # all paths start with a cairo.PathDataType.MOVE_TO - return False, pts[1][0], pts[1][1] - - def _get_dist(self, x1, y1, x2, y2): - dist2 = (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2) - return math.sqrt(dist2) - - ############################################################################ + __gtype_name__ = 'BrushSimple' + + def _get_tips(self, use_pressure, brush_direction): + label = _("Simple brush") + " - " + if use_pressure: + label += _("Width depends on the stylus pressure") + else: + label += _("Width depends on the mouse speed") + return [label] + + def draw_preview(self, operation, cairo_context): + cairo_context.set_line_cap(cairo.LineCap.ROUND) + cairo_context.set_line_join(cairo.LineJoin.ROUND) + cairo_context.set_source_rgba(*operation['rgba']) + super().draw_preview(operation, cairo_context) + + ############################################################################ + + def do_brush_operation(self, cairo_context, operation): + """Brush with dynamic width, where the variation of width is drawn by a + succession of segments. If pressure is detected, the width is pressure- + sensitive, otherwise it's speed-sensitive (with a heavy ponderation to + make it less ugly).""" + + if operation['is_preview']: # Previewing helps performance & debug + operation['line_width'] = max(1, int(operation['line_width'] * 0.8)) + return self.draw_preview(operation, cairo_context) + + if len(operation['path']) < 3: + # XXX minimum 3 points to get minimum 2 segments to avoid "list + # index out of range" errors when running the for loops + return + + self.operation_on_mask(operation, cairo_context) + + def do_masked_brush_op(self, cairo_context, operation): + cairo_context.set_line_cap(cairo.LineCap.ROUND) + cairo_context.set_line_join(cairo.LineJoin.ROUND) + + # Build a raw path with lines between the points + cairo_context.new_path() + for pt in operation['path']: + cairo_context.line_to(pt['x'], pt['y']) + raw_path = cairo_context.copy_path() + + if operation['smooth']: + # When the zoom is less than 400% (no great precision required by + # the user), this "raw" path is smoothed. + cairo_context.new_path() + utilities_smooth_path(cairo_context, raw_path) + smoothed_path = cairo_context.copy_path() + else: + smoothed_path = raw_path + + # Build an array with all the widths for each segment + widths = self._build_widths(operation['path'], operation['line_width']) + + # Run through the path to manually draw each segment with its width + i = 0 + cairo_context.new_path() + for segment in smoothed_path: + i = i + 1 + ok, future_x, future_y = self._future_point(segment) + if not ok: + cairo_context.move_to(future_x, future_y) + continue + current_x, current_y = cairo_context.get_current_point() + cairo_context.set_line_width(widths[i - 1]) + self._add_segment(cairo_context, segment) + cairo_context.stroke() + cairo_context.move_to(future_x, future_y) + + ############################################################################ + # Private methods ########################################################## + + def _build_widths(self, manual_path, base_width): + """Build an array of widths from the raw data, either using the value of + the pressure or based on the estimated speed of the movement.""" + widths = [] + dists = [] + p2 = None + for pt in manual_path: + if pt['p'] is None: + # No data about pressure + if p2 is not None: + dists.append(self._get_dist(pt['x'], pt['y'], p2['x'], p2['y'])) + else: + # There are data about pressure + if p2 is not None: + if p2['p'] == 0 or pt['p'] == 0: + seg_width = 0 + else: + seg_width = (p2['p'] + pt['p']) / 2 + # A segment whose 2 points have a 50% pressure shall have a + # width of "100%" of the base_width, so "base * mean * 2" + widths.append(base_width * seg_width * 2) + p2 = pt + + # If nothing in widths, it has to be filled from dists + if len(widths) == 0: + min_dist = min(dists) + max_dist = max(dists) + temp_width = 0 + for dist in dists: + new_width = 1 + int(base_width / max(1, 0.05 * dist)) + if temp_width == 0: + temp_width = (new_width + base_width) / 2 + else: + temp_width = (new_width + temp_width + temp_width) / 3 + width = max(1, int(temp_width)) + widths.append(width) + + return widths + + def _add_segment(self, cairo_context, pts): + if pts[0] == cairo.PathDataType.CURVE_TO: + cairo_context.curve_to(pts[1][0], pts[1][1], pts[1][2], pts[1][3], \ + pts[1][4], pts[1][5]) + elif pts[0] == cairo.PathDataType.LINE_TO: + cairo_context.line_to(pts[1][0], pts[1][1]) + + def _future_point(self, pts): + if pts[0] == cairo.PathDataType.CURVE_TO: + return True, pts[1][4], pts[1][5] + elif pts[0] == cairo.PathDataType.LINE_TO: + return True, pts[1][0], pts[1][1] + else: # all paths start with a cairo.PathDataType.MOVE_TO + return False, pts[1][0], pts[1][1] + + def _get_dist(self, x1, y1, x2, y2): + dist2 = (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2) + return math.sqrt(dist2) + + ############################################################################ ################################################################################ diff --git a/src/tools/classic_tools/tool_brush.py b/src/tools/classic_tools/tool_brush.py index 1a3ff80d..80173e09 100644 --- a/src/tools/classic_tools/tool_brush.py +++ b/src/tools/classic_tools/tool_brush.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from gi.repository import Gdk +from gi.repository import Gdk, Gtk from .abstract_classic_tool import AbstractClassicTool from .brush_simple import BrushSimple @@ -23,119 +23,137 @@ from .brush_nib import BrushNib from .brush_hairy import BrushHairy + class ToolBrush(AbstractClassicTool): - __gtype_name__ = 'ToolBrush' - - def __init__(self, window, **kwargs): - super().__init__('brush', _("Brush"), 'tool-brush-symbolic', window) - self.use_operator = True - self._used_pressure = False - - self._brushes_dict = { - 'simple': BrushSimple('simple', self), - 'airbrush': BrushAirbrush('airbrush', self), - 'calligraphic': BrushNib('calligraphic', self), - 'hairy': BrushHairy('hairy', self), - } - - self._brush_type = 'simple' - self._brush_dir = 'right' - self.add_tool_action_enum('brush-type', self._brush_type) - self.add_tool_action_enum('brush-dir', self._brush_dir) - - def get_options_label(self): - return _("Brush options") - - def get_editing_tips(self): - active_brush = self._brushes_dict[self._brush_type] - return active_brush._get_tips(self._used_pressure, self._brush_dir) - - def on_options_changed(self): - super().on_options_changed() - self._brush_type = self.get_option_value('brush-type') - self._brush_dir = self.get_option_value('brush-dir') - - enable_direction = self._brush_type == 'calligraphic' - self.set_action_sensitivity('brush-dir', enable_direction) - # refreshing the rendered operation isn't pertinent - - ############################################################################ - - def on_press_on_area(self, event, surface, event_x, event_y): - self.set_common_values(event.button, event_x, event_y) - self._manual_path = [] - self._add_pressured_point(event_x, event_y, event) - self._used_pressure = self._manual_path[0]['p'] is not None - - def on_motion_on_area(self, event, surface, event_x, event_y, render=True): - self._add_pressured_point(event_x, event_y, event) - if render: - operation = self.build_operation() - self.do_tool_operation(operation) - - def on_release_on_area(self, event, surface, event_x, event_y): - self._add_pressured_point(event_x, event_y, event) - operation = self.build_operation() - operation['is_preview'] = False - self.apply_operation(operation) - - ############################################################################ - - def _add_pressured_point(self, event_x, event_y, event): - new_point = { - 'x': event_x, - 'y': event_y, - 'p': self._get_pressure(event) - } - self._manual_path.append(new_point) - - def _get_pressure(self, event): - device = event.get_source_device() - # print(device) - if device is None: - return None - # source = device.get_source() - # print(source) # J'ignore s'il faut faire quelque chose de cette info - - tool = event.get_device_tool() - # print(tool) # ça indique qu'on a ici un appareil dédié au dessin (vaut - # `None` si c'est pas le cas). Autrement on peut avoir des valeurs comme - # Gdk.DeviceToolType.PEN, .ERASER, .BRUSH, .PENCIL, ou .AIRBRUSH, et - # aussi (même si jsuis pas sûr ce soit pertinent) .UNKNOWN, .MOUSE et - # .LENS, on pourrait adapter le comportement (couleur/opérateur/etc.) - # à cette information à l'avenir. - - pressure = event.get_axis(Gdk.AxisUse.PRESSURE) - # print(pressure) - if pressure is None: - return None - return pressure - - ############################################################################ - - def build_operation(self): - operation = { - 'tool_id': self.id, - 'brush_id': self._brush_type, - 'nib_dir': self._brush_dir, - 'rgba': self.main_color, - 'operator': self._operator, - 'line_width': self.tool_width, - 'antialias': self._use_antialias, - 'is_preview': True, - 'smooth': not self.get_image().is_zoomed_surface_sharp(), - 'path': self._manual_path - } - return operation - - def do_tool_operation(self, operation): - if operation['path'] is None or len(operation['path']) < 1: - return - cairo_context = self.start_tool_operation(operation) - - active_brush = self._brushes_dict[operation['brush_id']] - active_brush.do_brush_operation(cairo_context, operation) - - ############################################################################ + __gtype_name__ = 'ToolBrush' + + def __init__(self, window, **kwargs): + super().__init__('brush', _("Brush"), 'tool-brush-symbolic', window) + self.use_operator = True + self._used_pressure = False + + self._brushes_dict = { + 'simple': BrushSimple('simple', self), + 'airbrush': BrushAirbrush('airbrush', self), + 'calligraphic': BrushNib('calligraphic', self), + 'hairy': BrushHairy('hairy', self), + } + + self._brush_type = 'simple' + self._brush_dir = 'right' + self.add_tool_action_enum('brush-type', self._brush_type) + self.add_tool_action_enum('brush-dir', self._brush_dir) + + + def get_options_label(self): + return _("Brush options") + + def get_editing_tips(self): + active_brush = self._brushes_dict[self._brush_type] + return active_brush._get_tips(self._used_pressure, self._brush_dir) + + def on_options_changed(self): + super().on_options_changed() + self._brush_type = self.get_option_value('brush-type') + self._brush_dir = self.get_option_value('brush-dir') + + enable_direction = self._brush_type == 'calligraphic' + self.set_action_sensitivity('brush-dir', enable_direction) + # refreshing the rendered operation isn't pertinent + + ############################################################################ + + def on_press_on_area(self, event, surface, event_x, event_y): + self.set_common_values(event.button, event_x, event_y) + self._manual_path = [] + self._add_pressured_point(event_x, event_y, event) + self._used_pressure = self._manual_path[0]['p'] is not None + + def on_motion_on_area(self, event, surface, event_x, event_y, render=True): + self._add_pressured_point(event_x, event_y, event) + if render: + operation = self.build_operation() + self.do_tool_operation(operation) + + def on_release_on_area(self, event, surface, event_x, event_y): + self._add_pressured_point(event_x, event_y, event) + operation = self.build_operation() + operation['is_preview'] = False + self.apply_operation(operation) + + ############################################################################ + + def _add_pressured_point(self, event_x, event_y, event): + new_point = { + 'x': event_x, + 'y': event_y, + 'p': self._get_pressure(event) + } + self._manual_path.append(new_point) + + def _get_pressure(self, event): + device = event.get_source_device() + #print(device) + if device is None: + #print("Device is None.") + return None + #name = device.get_name() + #print(f"name:{name}") + + tool = event.get_device_tool() + # print(tool) # ça indique qu'on a ici un appareil dédié au dessin (vaut + # `None` si c'est pas le cas). Autrement on peut avoir des valeurs comme + # Gdk.DeviceToolType.PEN, .ERASER, .BRUSH, .PENCIL, ou .AIRBRUSH, et + # aussi (même si jsuis pas sûr ce soit pertinent) .UNKNOWN, .MOUSE et + # .LENS, on pourrait adapter le comportement (couleur/opérateur/etc.) + # à cette information à l'avenir. + + #pressure = event.get_axis(Gdk.AxisUse.PRESSURE) + # It reports device does not have get_axis method. + # The original code uses Gdk.Event, which is a Union (not a class). + # The return type for Gdk.Event.get_axis can be either NoneType or + # float; and this is not a tuple. + # print(pressure) + source = device.get_source() + # print(source) # J'ignore s'il faut faire quelque chose de cette info + # This makes event.get_axis work! + # When the event is from a pen, there is a chance to get PRESSURE. + if source: # Make sure there is a source, before matching value. + if source == Gdk.InputSource.PEN: + pressure = event.get_axis(Gdk.AxisUse.PRESSURE) + else: + pressure = None + + # If not None, then the pressure value is between 0.0 and 1.0. + if pressure is None: + return None + return pressure + + ############################################################################ + + def build_operation(self): + operation = { + 'tool_id': self.id, + 'brush_id': self._brush_type, + 'nib_dir': self._brush_dir, + 'rgba': self.main_color, + 'operator': self._operator, + 'line_width': self.tool_width, + 'antialias': self._use_antialias, + 'is_preview': True, + 'smooth': not self.get_image().is_zoomed_surface_sharp(), + 'path': self._manual_path + } + return operation + + def do_tool_operation(self, operation): + if operation['path'] is None or len(operation['path']) < 1: + return + cairo_context = self.start_tool_operation(operation) + + active_brush = self._brushes_dict[operation['brush_id']] + active_brush.do_brush_operation(cairo_context, operation) + + ############################################################################ ################################################################################