From bca2adfd2d3d706c7f0ff4a1f33918d09071880d Mon Sep 17 00:00:00 2001 From: Roland Date: Fri, 12 Dec 2025 14:44:41 +0800 Subject: [PATCH 01/13] Fixes eu.po syntax error - Syntax error of several lines that end with `".` instead of `."` --- po/eu.po | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 "" From e3a8374a8a7eb7eaa74dcefbac8b35b950672652 Mon Sep 17 00:00:00 2001 From: Roland Date: Wed, 17 Dec 2025 16:42:53 +0800 Subject: [PATCH 02/13] Gtk3 detects Aiptek pen but not pen pressure. - Different devices are reported by event.get_source_device(), depending on which device generated the event at the time. - Aiptek tablet was one of the devices reported. The device name was "Aiptek pen (0)" (same with pen or mouse). Another device reported was "SIGMACHIP usb mouse". - Device Gdk.AxisFlags.PRESSURE was confirmed for Aiptek pen. - Event Gdk.AxisUse.PRESSURE returns 0.0 for Aiptek pen. In contrast, the SIGMACHIP usb mouse returned None for that. --- src/tools/classic_tools/tool_brush.py | 272 +++++++++++++++----------- 1 file changed, 159 insertions(+), 113 deletions(-) diff --git a/src/tools/classic_tools/tool_brush.py b/src/tools/classic_tools/tool_brush.py index 1a3ff80d..98d39b31 100644 --- a/src/tools/classic_tools/tool_brush.py +++ b/src/tools/classic_tools/tool_brush.py @@ -24,118 +24,164 @@ 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() + if device is None: + return None + + print(device.get_name()) + print(device.get_vendor_id()) + print(device.get_product_id()) + print(device.get_n_axes()) + + # source = device.get_source() + # print(source) # J'ignore s'il faut faire quelque chose de cette info + axis_flags = device.get_axes() + if (Gdk.AxisFlags.X & axis_flags) == Gdk.AxisFlags.X: + print("Has X-axis") + else: + print("No X-axis") + if (Gdk.AxisFlags.Y & axis_flags) == Gdk.AxisFlags.Y: + print("Has Y-axis") + else: + print("No Y-axis") + if (Gdk.AxisFlags.PRESSURE & axis_flags) == Gdk.AxisFlags.PRESSURE: + print("Has Pressure info") + else: + print("No Pressure info") + if (Gdk.AxisFlags.WHEEL & axis_flags) == Gdk.AxisFlags.WHEEL: + print("Has Wheel info") + else: + print("No Wheel 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 = device.get_axis(axis_flags, 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. + pressure = event.get_axis(Gdk.AxisUse.PRESSURE) + """ + if isinstance(pressure, tuple): + print("Event.get_axis returns a tuple.") + else: + print(f"pressure is {type(pressure)}, not a tuple.") + print(f"Event.get_axis for pressure = {pressure}.") + """ + + # The SIGMACHIP usb mouse returns None for pressure (and for wheel). + # Aiptek pen (0) returns 0.0 for pressure (no change on pen press). + # Probably means Aiptek pen pressure value was not detected despite + # the PRESSURE axis being present. + #print(pressure) + + x_value = event.get_axis(Gdk.AxisUse.X) + print(f"Event.get_axis for x = {x_value}.") + y_value = event.get_axis(Gdk.AxisUse.Y) + print(f"Event.get_axis for y {y_value}.") + wheel = event.get_axis(Gdk.AxisUse.WHEEL) + print(f"Event.get_axis for wheel {wheel}.") + 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) + + ############################################################################ ################################################################################ From ba0425e448b988977644ec14438c4f83c5c5b0c6 Mon Sep 17 00:00:00 2001 From: Roland Date: Fri, 19 Dec 2025 10:57:50 +0800 Subject: [PATCH 03/13] Found GestureStylus unreliable - Unable to consistently get stylus pressure. Initial pressure reporting not reproducible afterwards. --- src/image.py | 1789 +++++++++++++------------ src/tools/classic_tools/tool_brush.py | 76 +- 2 files changed, 1010 insertions(+), 855 deletions(-) diff --git a/src/image.py b/src/image.py index 8d36f3b0..2e2b20a2 100644 --- a/src/image.py +++ b/src/image.py @@ -24,867 +24,948 @@ 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() + + """ + Funny strange. + Getting stylus gesture on the image drawing area *magically* allows + the brush tool's pressure axis values to be detectable (otherwise + it was a constant 0.0). + However, the window loses the ability to respond to the clicks of + the SIGMACHIP usb mouse (and persists after quitting "drawing"). + Another thing, the stylus_gesture connections do not run -- none of + the print() show. + And, it seems there are two images (one for the pen, another for the + brush tool) and the images switch depending on which tool is used. + stylus_gesture = Gtk.GestureStylus.new(widget=self.window) + stylus_gesture.connect("down", self.on_stylus_down) + stylus_gesture.connect("motion", self.on_stylus_motion) + stylus_gesture.connect("proximity", self.on_stylus_proximity) + stylus_gesture.connect("up", self.on_stylus_up) + """ + """ + Another funny strange. + This is an alternate code from AI that dispenses with 'self.stylus_gesture' + when constructing a gesture stylus. + It uses a try-except pattern to fall back to GestureMultiPress, which is + a misnomer for GestureClick. + This code *magically* allows window to detect button click from the stylus + without the fallback connection. + But it loses the pressure reading. + try: + stylus_gesture = Gtk.GestureStylus.new(widget=self._drawing_area) + stylus_gesture.connect("down", self.on_stylus_down) + print("stylus gesture connected.") + stylus_gesture.connect("motion", self.on_stylus_motion) + print("stylus gesture connected.") + except TypeError as e: + print("Error: {e}") + print("Try GestureMultiPress instead") + press_gesture = Gtk.GestureMultiPress(widget=_drawing_area) + press_gesture.connect("pressed", self.on_pointer_press) + """ + + + """ + Strange. + The following allows button click by Aiptek stylus, but no pressure + value sensing, and brush is not registered. + + stylus_gesture = Gtk.GestureStylus.new(widget=self._drawing_area) + stylus_gesture.connect("down", self.on_stylus_down) + stylus_gesture.connect("motion", self.on_stylus_motion) + stylus_gesture.connect("proximity", self.on_stylus_proximity) + stylus_gesture.connect("up", self.on_stylus_up) + """ + + + # 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 on_pointer_press(self, gesture, n_press, x, y): + print(f"Pointer pressed at ({x},{y})") + + def on_stylus_down(self, gesture, x, y): + print(f"Stylus down at ({x},{y})") + success, value = gesture.get_axis(Gdk.AxisUse.PRESSURE) + if success: + print(f"Pressure: {value:.2f}") + tool = gesture.get_device_tool() + if tool: + print(f"Tool type: {tool.get_tool_type()}") + + def on_stylus_motion(self, gesture, x, y): + print("Stylus motion.") + + def on_stylus_proximity(self, gesture, x, y): + print("Stylus proximity") + + def on_stylus_up(self, gesture, x, y): + print(f"Stylus up at ({x},{y})") + + 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) + + # Custom + self.stylus_gesture = Gtk.GestureStylus.new(self._drawing_area) + self.stylus_gesture.connect("down", self.on_stylus_down) + self.stylus_gesture.connect("motion", self.on_stylus_motion) + self.stylus_gesture.connect("proximity", self.on_stylus_proximity) + self.stylus_gesture.connect("up", self.on_stylus_up) + + 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/tools/classic_tools/tool_brush.py b/src/tools/classic_tools/tool_brush.py index 98d39b31..bb2ba6f8 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 @@ -42,6 +42,14 @@ def __init__(self, window, **kwargs): self._brush_dir = 'right' self.add_tool_action_enum('brush-type', self._brush_type) self.add_tool_action_enum('brush-dir', self._brush_dir) + # custom + self.stylus_gesture=Gtk.GestureStylus.new(window) + self.stylus_gesture.connect("down", self.on_stylus_down) + self.stylus_gesture.connect("motion", self.on_stylus_motion) + self.stylus_gesture.connect("proximity", self.on_stylus_proximity) + self.stylus_gesture.connect("up", self.on_stylus_up) + + print("Brush tool initialized") def get_options_label(self): return _("Brush options") @@ -66,6 +74,28 @@ def on_press_on_area(self, event, surface, 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_stylus_down(self, gesture, x, y): + #self.set_common_values(event.button, event_x, event_y) + print("Stylus down.") + self._manual_path = [] + print(f"Stylus down: x={x}, y={y}") + success,pressure = gesture.get_axis(Gdk.AxisUse.PRESSURE) + if success: + print(f" pressure: {pressure:.2f}") + success,x_value = gesture.get_axis(Gdk.AxisUse.X) + if success: + print(f" x: {x_value}") + success,y_value = gesture.get_axis(Gdk.AxisUse.Y) + if success: + print(f" y: {y_value}") + new_point = { + 'x': x_value, + 'y': y_value, + 'p': pressure + } + self._manual_path.append(new_point) + 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) @@ -73,12 +103,54 @@ def on_motion_on_area(self, event, surface, event_x, event_y, render=True): operation = self.build_operation() self.do_tool_operation(operation) + def on_stylus_motion(self, gesture, sequence): + success,pressure = gesture.get_axis(Gdk.AxisUse.PRESSURE) + if success: + print(f" pressure: {pressure:.2f}") + success,x_value = gesture.get_axis(Gdk.AxisUse.X) + if success: + print(f" x: {x_value}") + success,y_value = gesture.get_axis(Gdk.AxisUse.Y) + if success: + print(f" y: {y_value}") + new_point = { + 'x': x_value, + 'y': y_value, + 'p': pressure + } + self._manual_path.append(new_point) + operation = self.build_operation() + self.do_tool_operation(operation) + + def on_stylus_proximity(self, gesture, sequence): + pass + 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 on_stylus_up(self, gesture, sequence): + success,pressure = gesture.get_axis(Gdk.AxisUse.PRESSURE) + if success: + print(f" pressure: {pressure:.2f}") + success,x_value = gesture.get_axis(Gdk.AxisUse.X) + if success: + print(f" x: {x_value}") + success,y_value = gesture.get_axis(Gdk.AxisUse.Y) + if success: + print(f" y: {y_value}") + new_point = { + 'x': x_value, + 'y': y_value, + 'p': pressure + } + self._manual_path.append(new_point) + operation = self.build_operation() + operation['is_preview'] = False + self.apply_operation(operation) + ############################################################################ def _add_pressured_point(self, event_x, event_y, event): @@ -92,6 +164,7 @@ def _add_pressured_point(self, event_x, event_y, event): def _get_pressure(self, event): device = event.get_source_device() if device is None: + print("Device is None.") return None print(device.get_name()) @@ -140,6 +213,7 @@ def _get_pressure(self, event): print(f"pressure is {type(pressure)}, not a tuple.") print(f"Event.get_axis for pressure = {pressure}.") """ + print(f"Event.get_axis for pressure = {pressure}.") # The SIGMACHIP usb mouse returns None for pressure (and for wheel). # Aiptek pen (0) returns 0.0 for pressure (no change on pen press). From 9d9d9f7a1d1090aed59bbd7b4c4141ed560e67ad Mon Sep 17 00:00:00 2001 From: Roland Date: Fri, 19 Dec 2025 19:52:20 +0800 Subject: [PATCH 04/13] Added python dependencies to meson.build - Removed the TODO about how to add python dependency. --- meson.build | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/meson.build b/meson.build index eaa5e59f..cbd275f0 100644 --- a/meson.build +++ b/meson.build @@ -6,13 +6,12 @@ project( ) app_uuid = 'com.github.maoschanz.drawing' -# Dependencies ################################################################# +# Dependencies +pygobject = dependency('pygobject-3.0', version: f'>=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') From 8012807ac2feacb8f70536b52cd13861a3f3ba35 Mon Sep 17 00:00:00 2001 From: Roland Date: Fri, 19 Dec 2025 19:54:32 +0800 Subject: [PATCH 05/13] Reset code related to gesture stylus. - Main code with gesture stylus is in image.py now. - Removed or commented duplicated ones previously added elsewhere. --- src/image.py | 56 --------------------------- src/tools/classic_tools/tool_brush.py | 31 +++++++-------- 2 files changed, 14 insertions(+), 73 deletions(-) diff --git a/src/image.py b/src/image.py index 2e2b20a2..d87e17ed 100644 --- a/src/image.py +++ b/src/image.py @@ -67,59 +67,6 @@ def __init__(self, window, **kwargs): self.enable_monitoring() self._update_can_reload_action() - """ - Funny strange. - Getting stylus gesture on the image drawing area *magically* allows - the brush tool's pressure axis values to be detectable (otherwise - it was a constant 0.0). - However, the window loses the ability to respond to the clicks of - the SIGMACHIP usb mouse (and persists after quitting "drawing"). - Another thing, the stylus_gesture connections do not run -- none of - the print() show. - And, it seems there are two images (one for the pen, another for the - brush tool) and the images switch depending on which tool is used. - stylus_gesture = Gtk.GestureStylus.new(widget=self.window) - stylus_gesture.connect("down", self.on_stylus_down) - stylus_gesture.connect("motion", self.on_stylus_motion) - stylus_gesture.connect("proximity", self.on_stylus_proximity) - stylus_gesture.connect("up", self.on_stylus_up) - """ - """ - Another funny strange. - This is an alternate code from AI that dispenses with 'self.stylus_gesture' - when constructing a gesture stylus. - It uses a try-except pattern to fall back to GestureMultiPress, which is - a misnomer for GestureClick. - This code *magically* allows window to detect button click from the stylus - without the fallback connection. - But it loses the pressure reading. - try: - stylus_gesture = Gtk.GestureStylus.new(widget=self._drawing_area) - stylus_gesture.connect("down", self.on_stylus_down) - print("stylus gesture connected.") - stylus_gesture.connect("motion", self.on_stylus_motion) - print("stylus gesture connected.") - except TypeError as e: - print("Error: {e}") - print("Try GestureMultiPress instead") - press_gesture = Gtk.GestureMultiPress(widget=_drawing_area) - press_gesture.connect("pressed", self.on_pointer_press) - """ - - - """ - Strange. - The following allows button click by Aiptek stylus, but no pressure - value sensing, and brush is not registered. - - stylus_gesture = Gtk.GestureStylus.new(widget=self._drawing_area) - stylus_gesture.connect("down", self.on_stylus_down) - stylus_gesture.connect("motion", self.on_stylus_motion) - stylus_gesture.connect("proximity", self.on_stylus_proximity) - stylus_gesture.connect("up", self.on_stylus_up) - """ - - # Closing the info bar self.reload_info_bar.connect('close', self.hide_reload_message) self.reload_info_bar.connect('response', self.hide_reload_message) @@ -147,9 +94,6 @@ def __init__(self, window, **kwargs): self.window.gsettings.connect('changed::ctrl-zoom', \ self._update_zoom_behavior) - def on_pointer_press(self, gesture, n_press, x, y): - print(f"Pointer pressed at ({x},{y})") - def on_stylus_down(self, gesture, x, y): print(f"Stylus down at ({x},{y})") success, value = gesture.get_axis(Gdk.AxisUse.PRESSURE) diff --git a/src/tools/classic_tools/tool_brush.py b/src/tools/classic_tools/tool_brush.py index bb2ba6f8..a513f44e 100644 --- a/src/tools/classic_tools/tool_brush.py +++ b/src/tools/classic_tools/tool_brush.py @@ -42,12 +42,14 @@ def __init__(self, window, **kwargs): self._brush_dir = 'right' self.add_tool_action_enum('brush-type', self._brush_type) self.add_tool_action_enum('brush-dir', self._brush_dir) + """ # custom self.stylus_gesture=Gtk.GestureStylus.new(window) self.stylus_gesture.connect("down", self.on_stylus_down) self.stylus_gesture.connect("motion", self.on_stylus_motion) self.stylus_gesture.connect("proximity", self.on_stylus_proximity) self.stylus_gesture.connect("up", self.on_stylus_up) + """ print("Brush tool initialized") @@ -75,6 +77,7 @@ def on_press_on_area(self, event, surface, event_x, event_y): self._add_pressured_point(event_x, event_y, event) self._used_pressure = self._manual_path[0]['p'] is not None + """ def on_stylus_down(self, gesture, x, y): #self.set_common_values(event.button, event_x, event_y) print("Stylus down.") @@ -97,12 +100,15 @@ def on_stylus_down(self, gesture, x, y): self._manual_path.append(new_point) 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_stylus_motion(self, gesture, sequence): success,pressure = gesture.get_axis(Gdk.AxisUse.PRESSURE) if success: @@ -124,6 +130,7 @@ def on_stylus_motion(self, gesture, sequence): def on_stylus_proximity(self, gesture, sequence): pass + """ def on_release_on_area(self, event, surface, event_x, event_y): self._add_pressured_point(event_x, event_y, event) @@ -131,6 +138,7 @@ def on_release_on_area(self, event, surface, event_x, event_y): operation['is_preview'] = False self.apply_operation(operation) + """ def on_stylus_up(self, gesture, sequence): success,pressure = gesture.get_axis(Gdk.AxisUse.PRESSURE) if success: @@ -150,6 +158,7 @@ def on_stylus_up(self, gesture, sequence): operation = self.build_operation() operation['is_preview'] = False self.apply_operation(operation) + """ ############################################################################ @@ -168,12 +177,13 @@ def _get_pressure(self, event): return None print(device.get_name()) - print(device.get_vendor_id()) - print(device.get_product_id()) - print(device.get_n_axes()) + #print(device.get_vendor_id()) + #print(device.get_product_id()) + #print(device.get_n_axes()) # source = device.get_source() # print(source) # J'ignore s'il faut faire quelque chose de cette info + """ axis_flags = device.get_axes() if (Gdk.AxisFlags.X & axis_flags) == Gdk.AxisFlags.X: print("Has X-axis") @@ -191,6 +201,7 @@ def _get_pressure(self, event): print("Has Wheel info") else: print("No Wheel info") + """ tool = event.get_device_tool() # print(tool) # ça indique qu'on a ici un appareil dédié au dessin (vaut @@ -206,21 +217,7 @@ def _get_pressure(self, event): # The return type for Gdk.Event.get_axis can be either NoneType or # float; and this is not a tuple. pressure = event.get_axis(Gdk.AxisUse.PRESSURE) - """ - if isinstance(pressure, tuple): - print("Event.get_axis returns a tuple.") - else: - print(f"pressure is {type(pressure)}, not a tuple.") - print(f"Event.get_axis for pressure = {pressure}.") - """ print(f"Event.get_axis for pressure = {pressure}.") - - # The SIGMACHIP usb mouse returns None for pressure (and for wheel). - # Aiptek pen (0) returns 0.0 for pressure (no change on pen press). - # Probably means Aiptek pen pressure value was not detected despite - # the PRESSURE axis being present. - #print(pressure) - x_value = event.get_axis(Gdk.AxisUse.X) print(f"Event.get_axis for x = {x_value}.") y_value = event.get_axis(Gdk.AxisUse.Y) From bd872a74b5fda18db7af6aaa697644a76020477b Mon Sep 17 00:00:00 2001 From: Roland Date: Sun, 21 Dec 2025 23:13:38 +0800 Subject: [PATCH 06/13] Added ability to sense Aiptek pen pressure - Removed GestureStylus-related code from main.py and image.py. - Reset tool_brush.py according to maoschanz's original code. - Must install evdev (by software manager) and libevdev (pip). - Updated dependency (libevdev) on meson.build. - Added aiptek_access.py to list of install (src/meson.build). Functions in aiptek_access.py are imported into tool_brush.py. It uses evdev to read Aiptek pen pressure. - Altered tool_brush.py to use aiptek_access.py functions when the device is an Aiptek tablet product. - Now able to sense Aiptek pen pressure. But discovered this is not enough for the stylus to be useful for controlling the brush tool. - The original code senses standard usb mouse motion, but not the motion event of the stylus or the tablet mouse. - The "motion_on_area" functions are not executed when the stylus or tablet mouse moves. This explains why strokes are not seen during stylus motion, but the strokes appear after stylus is lifted. --- meson.build | 4 +- src/image.py | 25 - src/main.py | 738 +++++++++++------------ src/meson.build | 1 + src/tools/classic_tools/aiptek_access.py | 63 ++ src/tools/classic_tools/tool_brush.py | 124 +--- 6 files changed, 450 insertions(+), 505 deletions(-) create mode 100644 src/tools/classic_tools/aiptek_access.py diff --git a/meson.build b/meson.build index cbd275f0..3c1b53d5 100644 --- a/meson.build +++ b/meson.build @@ -7,12 +7,14 @@ project( app_uuid = 'com.github.maoschanz.drawing' # Dependencies -pygobject = dependency('pygobject-3.0', version: f'>=3.0.0') +pygobject = dependency('pygobject-3.0', version: '>=3.0.0') +evdev = dependency('libevdev') 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') i18n = import('i18n') diff --git a/src/image.py b/src/image.py index d87e17ed..6616a2a7 100644 --- a/src/image.py +++ b/src/image.py @@ -94,24 +94,6 @@ def __init__(self, window, **kwargs): self.window.gsettings.connect('changed::ctrl-zoom', \ self._update_zoom_behavior) - def on_stylus_down(self, gesture, x, y): - print(f"Stylus down at ({x},{y})") - success, value = gesture.get_axis(Gdk.AxisUse.PRESSURE) - if success: - print(f"Pressure: {value:.2f}") - tool = gesture.get_device_tool() - if tool: - print(f"Tool type: {tool.get_tool_type()}") - - def on_stylus_motion(self, gesture, x, y): - print("Stylus motion.") - - def on_stylus_proximity(self, gesture, x, y): - print("Stylus proximity") - - def on_stylus_up(self, gesture, x, y): - print(f"Stylus up at ({x},{y})") - def _init_drawing_area(self): self._drawing_area.add_events( \ Gdk.EventMask.BUTTON_PRESS_MASK | \ @@ -140,13 +122,6 @@ def _init_drawing_area(self): self._drawing_area.connect('enter-notify-event', self.on_enter_image) self._drawing_area.connect('leave-notify-event', self.on_leave_image) - # Custom - self.stylus_gesture = Gtk.GestureStylus.new(self._drawing_area) - self.stylus_gesture.connect("down", self.on_stylus_down) - self.stylus_gesture.connect("motion", self.on_stylus_motion) - self.stylus_gesture.connect("proximity", self.on_stylus_proximity) - self.stylus_gesture.connect("up", self.on_stylus_up) - def _update_background_color(self, *args): rgba = self.window.gsettings.get_strv('ui-background-rgba') self._bg_rgba = (float(rgba[0]), float(rgba[1]), \ 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/meson.build b/src/meson.build index 951f00b4..c75341ae 100644 --- a/src/meson.build +++ b/src/meson.build @@ -72,6 +72,7 @@ drawing_sources = [ 'tools/abstract_tool.py', 'tools/classic_tools/abstract_classic_tool.py', + 'tools/classic_tools/aiptek_access.py', 'tools/classic_tools/tool_arc.py', 'tools/classic_tools/tool_brush.py', 'tools/classic_tools/tool_eraser.py', diff --git a/src/tools/classic_tools/aiptek_access.py b/src/tools/classic_tools/aiptek_access.py new file mode 100644 index 00000000..e25d3932 --- /dev/null +++ b/src/tools/classic_tools/aiptek_access.py @@ -0,0 +1,63 @@ +from select import select +from evdev import InputDevice, list_devices, ecodes, categorize + +def previous_value(new_p=None): + """ + There is a need to store event values from evdev, + because it skips reporting if there is no change + on the event. + So, the idea is to use the previous value when + evdev skips reporting. + """ + prev_p = getattr(previous_value, 'p_press', None) + # This function either + # a) reports the previous value (no argument) + # or + # b) stores a new value (given a suitable new value) + # returns p_press + if new_p is None: + if prev_p is not None: + p_press = prev_p # use the stored value + else: + p_press = 0 # use 0 as initial value + previous_value.p_press = 0 # store it + return p_press + else: + if new_p >= 0: + previous_value.p_press = new_p + +def test(): + print("apitek_access") + pressure = previous_value() # initialise + vid=0x08ca + pid=0x0010 + devices=[InputDevice(dev) for dev in list_devices()] + for dev in devices: + if dev.info.vendor==vid and dev.info.product==pid: + print(f"Found: {dev.name} at {dev.path}") + # read list, write list, exception list + r, w, x = select( [dev], [], [], 0.008) # 0.008 seconds timeout + if dev in r: # device has readable data now + print("reading..") + for event in dev.read(): + if event.type == ecodes.EV_KEY: + pass + elif event.type == ecodes.EV_REL: + pass + elif event.type == ecodes.EV_ABS: # absolute value + if event.code == 1: + pass + elif event.code == 2: + pass + elif event.code == 24: # 24 is pressure + previous_value(new_p=event.value) + else: + print(f"EV_ABS code: {event.code}", + f"value: {event.value}") + elif event.type == ecodes.EV_SYN: # synchronization event + pressure = previous_value() + print(f"evdev: {pressure}") + return pressure + else: # timeout and no data to read + pressure = previous_value() + return pressure diff --git a/src/tools/classic_tools/tool_brush.py b/src/tools/classic_tools/tool_brush.py index a513f44e..213d2e68 100644 --- a/src/tools/classic_tools/tool_brush.py +++ b/src/tools/classic_tools/tool_brush.py @@ -23,6 +23,8 @@ from .brush_nib import BrushNib from .brush_hairy import BrushHairy +from .aiptek_access import test, previous_value + class ToolBrush(AbstractClassicTool): __gtype_name__ = 'ToolBrush' @@ -42,14 +44,6 @@ def __init__(self, window, **kwargs): self._brush_dir = 'right' self.add_tool_action_enum('brush-type', self._brush_type) self.add_tool_action_enum('brush-dir', self._brush_dir) - """ - # custom - self.stylus_gesture=Gtk.GestureStylus.new(window) - self.stylus_gesture.connect("down", self.on_stylus_down) - self.stylus_gesture.connect("motion", self.on_stylus_motion) - self.stylus_gesture.connect("proximity", self.on_stylus_proximity) - self.stylus_gesture.connect("up", self.on_stylus_up) - """ print("Brush tool initialized") @@ -76,31 +70,6 @@ def on_press_on_area(self, event, surface, 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_stylus_down(self, gesture, x, y): - #self.set_common_values(event.button, event_x, event_y) - print("Stylus down.") - self._manual_path = [] - print(f"Stylus down: x={x}, y={y}") - success,pressure = gesture.get_axis(Gdk.AxisUse.PRESSURE) - if success: - print(f" pressure: {pressure:.2f}") - success,x_value = gesture.get_axis(Gdk.AxisUse.X) - if success: - print(f" x: {x_value}") - success,y_value = gesture.get_axis(Gdk.AxisUse.Y) - if success: - print(f" y: {y_value}") - new_point = { - 'x': x_value, - 'y': y_value, - 'p': pressure - } - self._manual_path.append(new_point) - 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) @@ -108,58 +77,12 @@ def on_motion_on_area(self, event, surface, event_x, event_y, render=True): operation = self.build_operation() self.do_tool_operation(operation) - """ - def on_stylus_motion(self, gesture, sequence): - success,pressure = gesture.get_axis(Gdk.AxisUse.PRESSURE) - if success: - print(f" pressure: {pressure:.2f}") - success,x_value = gesture.get_axis(Gdk.AxisUse.X) - if success: - print(f" x: {x_value}") - success,y_value = gesture.get_axis(Gdk.AxisUse.Y) - if success: - print(f" y: {y_value}") - new_point = { - 'x': x_value, - 'y': y_value, - 'p': pressure - } - self._manual_path.append(new_point) - operation = self.build_operation() - self.do_tool_operation(operation) - - def on_stylus_proximity(self, gesture, sequence): - pass - """ - 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 on_stylus_up(self, gesture, sequence): - success,pressure = gesture.get_axis(Gdk.AxisUse.PRESSURE) - if success: - print(f" pressure: {pressure:.2f}") - success,x_value = gesture.get_axis(Gdk.AxisUse.X) - if success: - print(f" x: {x_value}") - success,y_value = gesture.get_axis(Gdk.AxisUse.Y) - if success: - print(f" y: {y_value}") - new_point = { - 'x': x_value, - 'y': y_value, - 'p': pressure - } - self._manual_path.append(new_point) - operation = self.build_operation() - operation['is_preview'] = False - self.apply_operation(operation) - """ - ############################################################################ def _add_pressured_point(self, event_x, event_y, event): @@ -172,36 +95,24 @@ def _add_pressured_point(self, event_x, event_y, event): def _get_pressure(self, event): device = event.get_source_device() + #print(device) if device is None: print("Device is None.") return None print(device.get_name()) - #print(device.get_vendor_id()) - #print(device.get_product_id()) - #print(device.get_n_axes()) + print(device.get_vendor_id()) + print(device.get_product_id()) + if device.get_vendor_id() == '08ca' and device.get_product_id() == '0010': + pressure = test() + print(f"get_pressure: {pressure}") + # Either pressure or None + if pressure is None: + return None + return pressure # source = device.get_source() # print(source) # J'ignore s'il faut faire quelque chose de cette info - """ - axis_flags = device.get_axes() - if (Gdk.AxisFlags.X & axis_flags) == Gdk.AxisFlags.X: - print("Has X-axis") - else: - print("No X-axis") - if (Gdk.AxisFlags.Y & axis_flags) == Gdk.AxisFlags.Y: - print("Has Y-axis") - else: - print("No Y-axis") - if (Gdk.AxisFlags.PRESSURE & axis_flags) == Gdk.AxisFlags.PRESSURE: - print("Has Pressure info") - else: - print("No Pressure info") - if (Gdk.AxisFlags.WHEEL & axis_flags) == Gdk.AxisFlags.WHEEL: - print("Has Wheel info") - else: - print("No Wheel info") - """ tool = event.get_device_tool() # print(tool) # ça indique qu'on a ici un appareil dédié au dessin (vaut @@ -211,19 +122,12 @@ def _get_pressure(self, event): # .LENS, on pourrait adapter le comportement (couleur/opérateur/etc.) # à cette information à l'avenir. - #pressure = device.get_axis(axis_flags, Gdk.AxisUse.PRESSURE) + 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. - pressure = event.get_axis(Gdk.AxisUse.PRESSURE) - print(f"Event.get_axis for pressure = {pressure}.") - x_value = event.get_axis(Gdk.AxisUse.X) - print(f"Event.get_axis for x = {x_value}.") - y_value = event.get_axis(Gdk.AxisUse.Y) - print(f"Event.get_axis for y {y_value}.") - wheel = event.get_axis(Gdk.AxisUse.WHEEL) - print(f"Event.get_axis for wheel {wheel}.") + # print(pressure) if pressure is None: return None return pressure From caaefa24a6ad97025a213ec12793d21c41d49887 Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 22 Dec 2025 16:23:25 +0800 Subject: [PATCH 07/13] Fixed error of stylus pressure not normalised. - Drawing app is supposed to take a pressure range 0.0-1.0. - The old Aiptek tablets likely reports a max value of 512 for stylus pressure. --- src/tools/classic_tools/aiptek_access.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tools/classic_tools/aiptek_access.py b/src/tools/classic_tools/aiptek_access.py index e25d3932..720fab52 100644 --- a/src/tools/classic_tools/aiptek_access.py +++ b/src/tools/classic_tools/aiptek_access.py @@ -50,7 +50,8 @@ def test(): elif event.code == 2: pass elif event.code == 24: # 24 is pressure - previous_value(new_p=event.value) + factored_value = event.value/512 + previous_value(new_p=factored_value) else: print(f"EV_ABS code: {event.code}", f"value: {event.value}") From f71e12f5b36fb7be5a8f963c236be9a710e70bf3 Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 22 Dec 2025 22:36:56 +0800 Subject: [PATCH 08/13] Disable preview setting in brush tool. - This allows the width variation of the line to show at the time of drawing, rather than when the pen stroke is complete. --- src/tools/classic_tools/aiptek_access.py | 6 +++--- src/tools/classic_tools/tool_brush.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/tools/classic_tools/aiptek_access.py b/src/tools/classic_tools/aiptek_access.py index 720fab52..60ef99eb 100644 --- a/src/tools/classic_tools/aiptek_access.py +++ b/src/tools/classic_tools/aiptek_access.py @@ -27,18 +27,18 @@ def previous_value(new_p=None): previous_value.p_press = new_p def test(): - print("apitek_access") + #print("apitek_access") pressure = previous_value() # initialise vid=0x08ca pid=0x0010 devices=[InputDevice(dev) for dev in list_devices()] for dev in devices: if dev.info.vendor==vid and dev.info.product==pid: - print(f"Found: {dev.name} at {dev.path}") + #print(f"Found: {dev.name} at {dev.path}") # read list, write list, exception list r, w, x = select( [dev], [], [], 0.008) # 0.008 seconds timeout if dev in r: # device has readable data now - print("reading..") + #print("reading..") for event in dev.read(): if event.type == ecodes.EV_KEY: pass diff --git a/src/tools/classic_tools/tool_brush.py b/src/tools/classic_tools/tool_brush.py index 213d2e68..57eb4e1e 100644 --- a/src/tools/classic_tools/tool_brush.py +++ b/src/tools/classic_tools/tool_brush.py @@ -45,7 +45,7 @@ def __init__(self, window, **kwargs): self.add_tool_action_enum('brush-type', self._brush_type) self.add_tool_action_enum('brush-dir', self._brush_dir) - print("Brush tool initialized") + #print("Brush tool initialized") def get_options_label(self): return _("Brush options") @@ -100,12 +100,12 @@ def _get_pressure(self, event): print("Device is None.") return None - print(device.get_name()) - print(device.get_vendor_id()) - print(device.get_product_id()) + #print(device.get_name()) + #print(device.get_vendor_id()) + #print(device.get_product_id()) if device.get_vendor_id() == '08ca' and device.get_product_id() == '0010': pressure = test() - print(f"get_pressure: {pressure}") + #print(f"get_pressure: {pressure}") # Either pressure or None if pressure is None: return None @@ -143,7 +143,7 @@ def build_operation(self): 'operator': self._operator, 'line_width': self.tool_width, 'antialias': self._use_antialias, - 'is_preview': True, + 'is_preview': False, #True, 'smooth': not self.get_image().is_zoomed_surface_sharp(), 'path': self._manual_path } From aa9613a5535825216154e0ddc3b353fbb93cbb9f Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 23 Dec 2025 23:04:13 +0800 Subject: [PATCH 09/13] Added and reverted back and retab (vim editor) - Made some changed that did not work. - Reverted to original. - Needed to do retab (on vim editor), hence different tabs and spaces compared to the original. --- .../classic_tools/brushes/brush_simple.py | 268 +++++++++--------- 1 file changed, 134 insertions(+), 134 deletions(-) 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) + + ############################################################################ ################################################################################ From 80cfddb16300f26886d710acb3bd3e484184e1ce Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 23 Dec 2025 23:07:52 +0800 Subject: [PATCH 10/13] Corrected a mistake about axis number. - Previously thought EV_ABS type has code 1 & 2 being x & y. Actually, code 0 & 1 are x & y, respectively. --- src/tools/classic_tools/aiptek_access.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/tools/classic_tools/aiptek_access.py b/src/tools/classic_tools/aiptek_access.py index 60ef99eb..606af652 100644 --- a/src/tools/classic_tools/aiptek_access.py +++ b/src/tools/classic_tools/aiptek_access.py @@ -45,13 +45,19 @@ def test(): elif event.type == ecodes.EV_REL: pass elif event.type == ecodes.EV_ABS: # absolute value - if event.code == 1: + if event.code == 0: # x-coordinate pass - elif event.code == 2: + elif event.code == 1: # y-coordinate pass - elif event.code == 24: # 24 is pressure - factored_value = event.value/512 + elif event.code == 24: # pressure + factored_value = event.value/1024 previous_value(new_p=factored_value) + elif event.code == 40: # Misc + if event.value == 33: + print("Descend detected.") + if event.value == 32: + print("Lift-off noted.") + previous_value(new_p=None) else: print(f"EV_ABS code: {event.code}", f"value: {event.value}") From c0827221dbce01aa0162f453a9b2846f54e9e7c4 Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 23 Dec 2025 23:12:12 +0800 Subject: [PATCH 11/13] Added and reverted code to calculate distance. - Wanted to stop drawing when pen motion stops. Attempted to do this by calculating the distance between the previous and the current cursor positions, and continue to process only if that passes an arbitrary threshold. - Did not work well, because of error about the index being out of range for width[i -1]. Tried to make changes to the code involving width[i - 1] but it seemed to cause more problems. - Therefore, "reverted" the code by commenting. This keeps a small record of what have been tried. --- src/image.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/image.py b/src/image.py index 6616a2a7..2894ca7f 100644 --- a/src/image.py +++ b/src/image.py @@ -109,6 +109,8 @@ def _init_drawing_area(self): self._drawing_area.connect('draw', self.on_draw) # For drawing with tools + #self.prev_x = -1 + #self.prev_y = -1 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) @@ -494,6 +496,21 @@ def on_motion_on_area(self, area, event): except the mouse cursor icon for example.""" event_x, event_y = self.get_event_coords(event) + """ + It is difficult to hold a stylus still enough to calm the 'motion' + event notifications. + Therefore, making a distance threshold to return quickly. + distance = ((event_x-self.prev_x)**2 + (event_y-self.prev_y)**2)**0.5 + if distance < 10: + # either return here + return + # or set motion behaviour to hover (TODO: only for stylus) + #self.motion_behavior == DrMotionBehavior.HOVER + + self.prev_x = event_x + self.prev_y = event_y + """ + 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. From 436c2c2f92325e5d30b3b7778b02794b2f15c64d Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 23 Dec 2025 23:59:28 +0800 Subject: [PATCH 12/13] Added correct code to get pen pressure value - Does not need evdev after all. - There remains the problem of brush continuing to draw after the pen is lifted off the tablet surface and hovering; or connecting the trace from the lift-off point when making next pen stroke. --- src/tools/classic_tools/tool_brush.py | 33 +++++++++++++-------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/tools/classic_tools/tool_brush.py b/src/tools/classic_tools/tool_brush.py index 57eb4e1e..b2773f29 100644 --- a/src/tools/classic_tools/tool_brush.py +++ b/src/tools/classic_tools/tool_brush.py @@ -23,7 +23,7 @@ from .brush_nib import BrushNib from .brush_hairy import BrushHairy -from .aiptek_access import test, previous_value +#from .aiptek_access import test, previous_value class ToolBrush(AbstractClassicTool): __gtype_name__ = 'ToolBrush' @@ -97,22 +97,10 @@ def _get_pressure(self, event): device = event.get_source_device() #print(device) if device is None: - print("Device is None.") + #print("Device is None.") return None - - #print(device.get_name()) - #print(device.get_vendor_id()) - #print(device.get_product_id()) - if device.get_vendor_id() == '08ca' and device.get_product_id() == '0010': - pressure = test() - #print(f"get_pressure: {pressure}") - # Either pressure or None - if pressure is None: - return None - return pressure - - # source = device.get_source() - # print(source) # J'ignore s'il faut faire quelque chose de cette info + #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 @@ -122,12 +110,23 @@ def _get_pressure(self, event): # .LENS, on pourrait adapter le comportement (couleur/opérateur/etc.) # à cette information à l'avenir. - pressure = event.get_axis(Gdk.AxisUse.PRESSURE) + #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 From 3b920d6132fd6b6d3b7cd3898815bc8030848a81 Mon Sep 17 00:00:00 2001 From: Roland Date: Thu, 25 Dec 2025 11:54:59 +0800 Subject: [PATCH 13/13] Pen pressure sensing now works without evdev. - Removed evdev-related python codes including aiptek_access.py --- meson.build | 1 - src/image.py | 17 ------ src/meson.build | 1 - src/tools/classic_tools/aiptek_access.py | 70 ------------------------ src/tools/classic_tools/tool_brush.py | 4 +- 5 files changed, 1 insertion(+), 92 deletions(-) delete mode 100644 src/tools/classic_tools/aiptek_access.py diff --git a/meson.build b/meson.build index 3c1b53d5..14686169 100644 --- a/meson.build +++ b/meson.build @@ -8,7 +8,6 @@ app_uuid = 'com.github.maoschanz.drawing' # Dependencies pygobject = dependency('pygobject-3.0', version: '>=3.0.0') -evdev = dependency('libevdev') py_module = import('python') py_instln = py_module.find_installation('python3') diff --git a/src/image.py b/src/image.py index 2894ca7f..6616a2a7 100644 --- a/src/image.py +++ b/src/image.py @@ -109,8 +109,6 @@ def _init_drawing_area(self): self._drawing_area.connect('draw', self.on_draw) # For drawing with tools - #self.prev_x = -1 - #self.prev_y = -1 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) @@ -496,21 +494,6 @@ def on_motion_on_area(self, area, event): except the mouse cursor icon for example.""" event_x, event_y = self.get_event_coords(event) - """ - It is difficult to hold a stylus still enough to calm the 'motion' - event notifications. - Therefore, making a distance threshold to return quickly. - distance = ((event_x-self.prev_x)**2 + (event_y-self.prev_y)**2)**0.5 - if distance < 10: - # either return here - return - # or set motion behaviour to hover (TODO: only for stylus) - #self.motion_behavior == DrMotionBehavior.HOVER - - self.prev_x = event_x - self.prev_y = event_y - """ - 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. diff --git a/src/meson.build b/src/meson.build index c75341ae..951f00b4 100644 --- a/src/meson.build +++ b/src/meson.build @@ -72,7 +72,6 @@ drawing_sources = [ 'tools/abstract_tool.py', 'tools/classic_tools/abstract_classic_tool.py', - 'tools/classic_tools/aiptek_access.py', 'tools/classic_tools/tool_arc.py', 'tools/classic_tools/tool_brush.py', 'tools/classic_tools/tool_eraser.py', diff --git a/src/tools/classic_tools/aiptek_access.py b/src/tools/classic_tools/aiptek_access.py deleted file mode 100644 index 606af652..00000000 --- a/src/tools/classic_tools/aiptek_access.py +++ /dev/null @@ -1,70 +0,0 @@ -from select import select -from evdev import InputDevice, list_devices, ecodes, categorize - -def previous_value(new_p=None): - """ - There is a need to store event values from evdev, - because it skips reporting if there is no change - on the event. - So, the idea is to use the previous value when - evdev skips reporting. - """ - prev_p = getattr(previous_value, 'p_press', None) - # This function either - # a) reports the previous value (no argument) - # or - # b) stores a new value (given a suitable new value) - # returns p_press - if new_p is None: - if prev_p is not None: - p_press = prev_p # use the stored value - else: - p_press = 0 # use 0 as initial value - previous_value.p_press = 0 # store it - return p_press - else: - if new_p >= 0: - previous_value.p_press = new_p - -def test(): - #print("apitek_access") - pressure = previous_value() # initialise - vid=0x08ca - pid=0x0010 - devices=[InputDevice(dev) for dev in list_devices()] - for dev in devices: - if dev.info.vendor==vid and dev.info.product==pid: - #print(f"Found: {dev.name} at {dev.path}") - # read list, write list, exception list - r, w, x = select( [dev], [], [], 0.008) # 0.008 seconds timeout - if dev in r: # device has readable data now - #print("reading..") - for event in dev.read(): - if event.type == ecodes.EV_KEY: - pass - elif event.type == ecodes.EV_REL: - pass - elif event.type == ecodes.EV_ABS: # absolute value - if event.code == 0: # x-coordinate - pass - elif event.code == 1: # y-coordinate - pass - elif event.code == 24: # pressure - factored_value = event.value/1024 - previous_value(new_p=factored_value) - elif event.code == 40: # Misc - if event.value == 33: - print("Descend detected.") - if event.value == 32: - print("Lift-off noted.") - previous_value(new_p=None) - else: - print(f"EV_ABS code: {event.code}", - f"value: {event.value}") - elif event.type == ecodes.EV_SYN: # synchronization event - pressure = previous_value() - print(f"evdev: {pressure}") - return pressure - else: # timeout and no data to read - pressure = previous_value() - return pressure diff --git a/src/tools/classic_tools/tool_brush.py b/src/tools/classic_tools/tool_brush.py index b2773f29..80173e09 100644 --- a/src/tools/classic_tools/tool_brush.py +++ b/src/tools/classic_tools/tool_brush.py @@ -23,7 +23,6 @@ from .brush_nib import BrushNib from .brush_hairy import BrushHairy -#from .aiptek_access import test, previous_value class ToolBrush(AbstractClassicTool): __gtype_name__ = 'ToolBrush' @@ -45,7 +44,6 @@ def __init__(self, window, **kwargs): self.add_tool_action_enum('brush-type', self._brush_type) self.add_tool_action_enum('brush-dir', self._brush_dir) - #print("Brush tool initialized") def get_options_label(self): return _("Brush options") @@ -142,7 +140,7 @@ def build_operation(self): 'operator': self._operator, 'line_width': self.tool_width, 'antialias': self._use_antialias, - 'is_preview': False, #True, + 'is_preview': True, 'smooth': not self.get_image().is_zoomed_surface_sharp(), 'path': self._manual_path }