Source code for pympress.scribble

# -*- coding: utf-8 -*-
#       Copyright 2017 Cimbali <>
#       This program is free software; you can redistribute it and/or modify
#       it under the terms of the GNU General Public License as published by
#       the Free Software Foundation; either version 2 of the License, or
#       (at your option) any later version.
#       This program is distributed in the hope that it will be useful,
#       but WITHOUT ANY WARRANTY; without even the implied warranty of
#       GNU General Public License for more details.
#       You should have received a copy of the GNU General Public License
#       along with this program; if not, write to the Free Software
#       Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
#       MA 02110-1301, USA.
:mod:`pympress.scribble` -- Manage user drawings on the current slide

import logging
logger = logging.getLogger(__name__)

import math

import gi
import cairo
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, Gdk, GLib

from pympress import builder, extras, util

[docs] class Scribbler(builder.Builder): """ UI that allows to draw free-hand on top of the current slide. Args: config (:class:`~pympress.config.Config`): A config object containing preferences builder (:class:`~pympress.builder.Builder`): A builder from which to load widgets notes_mode (`bool`): The current notes mode, i.e. whether we display the notes on second slide """ #: Whether we are displaying the interface to scribble on screen and the overlays containing said scribbles scribbling_mode = False #: `list` of scribbles to be drawn, as tuples of color :class:`~Gdk.RGBA`, width `int`, a `list` of points, #: and a `list` of pressure values. scribble_list = [] #: `list` of undone scribbles to possibly redo scribble_redo_list = [] #: Whether the current mouse movements are drawing strokes or should be ignored scribble_drawing = False #: :class:`~Gdk.RGBA` current color of the scribbling tool scribble_color = Gdk.RGBA() #: `int` current stroke width of the scribbling tool scribble_width = 1 #: :class:`~Gtk.HBox` that replaces normal panes when scribbling is on, contains buttons and scribble drawing area. scribble_overlay = None #: :class:`~Gtk.DrawingArea` for the scribbles in the Presenter window. Actually redraws the slide. scribble_p_da = None #: :class:`~Gtk.EventBox` for the scribbling in the Content window, captures freehand drawing scribble_c_eb = None #: :class:`~Gtk.EventBox` for the scribbling in the Presenter window, captures freehand drawing scribble_p_eb = None #: :class:`~Gtk.AspectFrame` for the slide in the Presenter's highlight mode scribble_p_frame = None #: The :class:`~Gtk.DrawingArea` in the content window c_da = None #: The :class:`~Gtk.ColorButton` selecting the color of the pen scribble_color_selector = None #: The :class:`~Gtk.Scale` selecting the size of the pen scribble_width_selector = None #: The `list` containing the radio buttons :class:`~Gtk.ModelButton` scribble_preset_buttons = [] #: The position of the mouse on the slide as `tuple` of `float` mouse_pos = None #: A :class:`~cairo.Surface` to hold drawn highlights scribble_cache = None #: The next scribble to render (i.e. that is not rendered in cache) next_render = 0 #: :class:`~Gtk.Button` for removing the last drawn scribble scribble_undo = None #: :class:`~Gtk.Button` for drawing the last removed scribble scribble_redo = None #: :class:`~Gtk.Button` for removing all drawn scribbles scribble_clear = None #: A :class:`~Gtk.OffscreenWindow` where we render the scribbling interface when it's not shown scribble_off_render = None #: :class:`~Gtk.Box` in the Presenter window, where we insert scribbling. p_central = None #: :class:`~Gtk.Button` that is clicked to stop zooming, unsensitive when there is no zooming zoom_stop_button = None #: callback, to be connected to :func:`~pympress.surfacecache.SurfaceCache.resize_widget` resize_cache = lambda *args: None #: callback, to be connected to :func:`~pympress.ui.UI.on_draw` on_draw = lambda *args: None #: callback, to be connected to :func:`~pympress.ui.UI.track_motions` track_motions = lambda *args: None #: callback, to be connected to :func:`~pympress.ui.UI.track_clicks` track_clicks = lambda *args: None #: callback, to be connected to :func:`~pympress.ui.UI.load_layout` load_layout = lambda *args: None #: callback, to be connected to :func:`~pympress.ui.UI.redraw_current_slide` redraw_current_slide = lambda *args: None #: callback, to be connected to :func:`~pympress.extras.Zoom.get_slide_point` get_slide_point = lambda *args: None #: callback, to be connected to :func:`~pympress.extras.Zoom.start_zooming` start_zooming = lambda *args: None #: callback, to be connected to :func:`~pympress.extras.Zoom.stop_zooming` stop_zooming = lambda *args: None #: `int` that is the currently selected element active_preset = -1 #: `int` to remember the previously selected element, before holding “eraser” previous_preset = -1 #: `list` that contains the modifiers which, when held on scribble start, toggle the eraser toggle_erase_modifiers = [] #: `list` that contains the non-modifier shortcuts which, when held on scribble start, toggle the eraser toggle_erase_shortcuts = [] #: `str` or `None` that indicates whether a modifier + click or a held shortcut is toggling the eraser toggle_erase_source = None #: The :class:`~Gio.Action` that contains the currently selected pen pen_action = None #: `str` which is the mode for scribbling, one of 3 possile values: # global means one set of scribbles for the whole document # single-page means we manage a single page of scribbles, and clear everything on page change (historical behaviour) # per-page means we manage a set of scribbles per document page, and clear or restore them on page change # per-label means we manage a set of scribbles per document page, but defined by label and not page number highlight_mode = 'single-page' #: `bool` indicating whether we exit highlighting mode on page change page_change_exits = True #: `dict` of scribbles per page remembered_scribbles = {} #: `tuple` of (`int`, `str`) indicating the current page number and label current_page = (None, None) #: `str` indicating the current layout of the highlight toolbar tools_orientation = 'vertical' #: :class:`~Gtk.Box` containing the presets preset_toolbar = None #: :class:`~Gtk.Box` containing the scribble buttons scribble_toolbar = None #: :class:`~Gtk.Box` containing the scribble color and width selectors scribble_color_toolbox = None def __init__(self, config, builder, notes_mode): super(Scribbler, self).__init__() self.load_ui('highlight') builder.load_widgets(self) self.get_application().add_window(self.scribble_off_render) self.on_draw = builder.get_callback_handler('on_draw') self.track_motions = builder.get_callback_handler('track_motions') self.track_clicks = builder.get_callback_handler('track_clicks') self.load_layout = builder.get_callback_handler('load_layout') self.redraw_current_slide = builder.get_callback_handler('redraw_current_slide') self.resize_cache = builder.get_callback_handler('cache.resize_widget') self.get_slide_point = builder.get_callback_handler('zoom.get_slide_point') self.start_zooming = builder.get_callback_handler('zoom.start_zooming') self.stop_zooming = builder.get_callback_handler('zoom.stop_zooming') self.connect_signals(self) self.config = config # Prepare cairo surfaces for markers, with 3 different marker sizes, and for eraser ms = [1, 2, 3] icons = [cairo.ImageSurface.create_from_png(util.get_icon_path('marker_{}.png'.format(n))) for n in ms] masks = [cairo.ImageSurface.create_from_png(util.get_icon_path('marker_fill_{}.png'.format(n))) for n in ms] self.marker_surfaces = list(zip(icons, masks)) self.eraser_surface = cairo.ImageSurface.create_from_png(str(util.get_icon_path('eraser.png'))) # Load color and active pen preferences. Pen 0 is the eraser. self.color_width = [(Gdk.RGBA(0, 0, 0, 0), config.getfloat('highlight', 'width_eraser'))] + list(zip( [self.parse_color(config.get('highlight', 'color_{}'.format(pen))) for pen in range(1, 10)], [config.getfloat('highlight', 'width_{}'.format(pen)) for pen in range(1, 10)], )) self.scribble_preset_buttons = [ self.get_object('pen_preset_{}'.format(pen) if pen else 'eraser') for pen in range(10) ] self.tools_orientation = self.config.get('layout', 'highlight_tools') self.adjust_tools_orientation() active_pen = config.get('highlight', 'active_pen') self.page_change_exits = config.getboolean('highlight', 'page_change_exits') self.setup_actions({ 'highlight': dict(activate=self.switch_scribbling, state=False), 'highlight-use-pen': dict(activate=self.load_preset, state=active_pen, parameter_type=str, enabled=False), 'highlight-clear': dict(activate=self.clear_scribble), 'highlight-redo': dict(activate=self.redo_scribble), 'highlight-undo': dict(activate=self.pop_scribble), 'highlight-mode': dict(activate=self.set_mode, state=self.highlight_mode, parameter_type=str), 'highlight-page-exit': dict(activate=self.page_change_action, state=self.page_change_exits), 'highlight-tools-orientation': dict(activate=self.set_tools_orientation, state=self.tools_orientation, parameter_type=str), }) hold_erase = [Gtk.accelerator_parse(keys) for keys in config.shortcuts.get('highlight-hold-to-erase', [])] self.toggle_erase_modifiers = [mod for keycode, mod in hold_erase if not keycode] self.toggle_erase_shortcuts = [(keycode, mod) for keycode, mod in hold_erase if keycode] self.pen_action = self.get_application().lookup_action('highlight-use-pen') self.load_preset(self.pen_action, int(active_pen) if active_pen.isnumeric() else 0) self.set_mode(None, GLib.Variant.new_string(config.get('highlight', 'mode')))
[docs] def page_change_action(self, gaction, param): """ Change whether we exit or stay in highlighting mode on page changes Args: gaction (:class:`~Gio.Action`): the action triggering the call param (:class:`~GLib.Variant`): the new mode as a string wrapped in a GLib.Variant """ self.page_change_exits = not gaction.get_state().get_boolean() self.config.set('highlight', 'page_change_exits', 'on' if self.page_change_exits else 'off') gaction.change_state(GLib.Variant.new_boolean(self.page_change_exits)) return True
[docs] def set_mode(self, gaction, param): """ Change the mode of clearing and restoring highlights Args: gaction (:class:`~Gio.Action`): the action triggering the call param (:class:`~GLib.Variant`): the new mode as a string wrapped in a GLib.Variant """ new_mode = param.get_string() if new_mode not in {'single-page', 'global', 'per-page', 'per-label'}: return False self.get_application().lookup_action('highlight-mode').change_state(GLib.Variant.new_string(new_mode)) self.highlight_mode = new_mode self.config.set('highlight', 'mode', self.highlight_mode) self.remembered_scribbles.clear() return True
[docs] def try_cancel(self): """ Cancel scribbling, if it is enabled. Returns: `bool`: `True` if scribbling got cancelled, `False` if it was already disabled. """ if not self.scribbling_mode: return False self.disable_scribbling() return True
[docs] @staticmethod def parse_color(text): """ Transform a string to a Gdk object in a single function call Args: text (`str`): A string describing a color Returns: :class:`~Gdk.RGBA`: A new color object parsed from the string """ color = Gdk.RGBA() color.parse(text) return color
[docs] def points_to_curves(self, points): """ Transform a list of points from scribbles to bezier curves Returns: `list`: control points of a bezier curves to draw """ curves = [] if len(points) <= 2: return curves for (ax, ay), (bx, by), (cx, cy), (dx, dy) in zip( [points[0], *points[:-2]], points[:-1], points[1:], [*points[2:], points[-1]] ): curves.append((bx, by, bx + (cx - ax) / 4, by + (cy - ay) / 4, cx + (bx - dx) / 4, cy + (by - dy) / 4, cx, cy)) return curves
[docs] def track_scribble(self, widget, event): """ Draw the scribble following the mouse's moves. Args: widget (:class:`~Gtk.Widget`): the widget which has received the event. event (:class:`~Gdk.Event`): the GTK event. Returns: `bool`: whether the event was consumed """ pos = self.get_slide_point(widget, event) + () if self.scribble_drawing: self.scribble_list[-1][-2].append(pos) pressure = event.get_axis(Gdk.AxisUse.PRESSURE) self.scribble_list[-1][-1].append(1. if pressure is None else pressure) self.scribble_redo_list.clear() self.adjust_buttons() self.mouse_pos = pos self.redraw_current_slide() return self.scribble_drawing
[docs] def key_event(self, widget, event): """ Handle key events to activate the eraser while the shortcut is held Args: widget (:class:`~Gtk.Widget`): the widget which has received the event. event (:class:`~Gdk.Event`): the GTK event. Returns: `bool`: whether the event was consumed """ if not self.scribbling_mode: return False elif event.type != Gdk.EventType.KEY_PRESS and event.type != Gdk.EventType.KEY_RELEASE: return False elif not (*event.get_keyval()[1:], event.get_state()) in self.toggle_erase_shortcuts: return False if event.type == Gdk.EventType.KEY_PRESS and self.active_preset and self.toggle_erase_source is None: self.previous_preset = self.active_preset self.toggle_erase_source = 'shortcut' self.load_preset(target=0) elif event.type == Gdk.EventType.KEY_RELEASE and self.toggle_erase_source == 'shortcut' \ and self.previous_preset and not self.active_preset: self.load_preset(target=self.previous_preset) self.previous_preset = 0 self.toggle_erase_source = None else: return False return True
[docs] def toggle_scribble(self, widget, event): """ Start/stop drawing scribbles. Args: widget (:class:`~Gtk.Widget`): the widget which has received the event. event (:class:`~Gdk.Event`): the GTK event. Returns: `bool`: whether the event was consumed """ if not self.scribbling_mode: return False if event.get_event_type() == Gdk.EventType.BUTTON_PRESS: eraser_button = event.get_source_device().get_source() == Gdk.InputSource.ERASER eraser_modifier = any(mod & event.get_state() == mod for mod in self.toggle_erase_modifiers) if (eraser_button or eraser_modifier) and self.active_preset and self.toggle_erase_source is None: self.previous_preset = self.active_preset self.toggle_erase_source = 'modifier' self.load_preset(target=0) self.scribble_list.append((self.scribble_color, self.scribble_width, [], [])) self.scribble_drawing = True return self.track_scribble(widget, event) elif event.get_event_type() == Gdk.EventType.BUTTON_RELEASE: self.scribble_drawing = False self.prerender() if not self.active_preset and self.previous_preset and self.toggle_erase_source == 'modifier': self.load_preset(target=self.previous_preset) self.previous_preset = 0 self.toggle_erase_source = None return True return False
[docs] def reset_scribble_cache(self): """ Clear the cached scribbles. """ window = self.c_da.get_window() if window is None: logger.error('Cannot initialize scribble cache without drawing area window') return scale = window.get_scale_factor() ww, wh = self.c_da.get_allocated_width() * scale, self.c_da.get_allocated_height() * scale try: self.scribble_cache = window.create_similar_image_surface(cairo.Format.ARGB32, ww, wh, scale) except ValueError as e: if e.args == ('invalid enum value: 5',): # Just try again as this error seems to always happen at the first call but not later on # This is the gi introspection of cairo not accepting the value Format.RGB30 (???) try: self.scribble_cache = window.create_similar_image_surface(cairo.Format.ARGB32, ww, wh, scale) except ValueError: logger.exception('Error creating highlight cache') else: self.next_render = 0 else: logger.exception('Error creating highlight cache') except cairo.Error: logger.warning('Failed creating an ARGB32 surface sized {}x{} scale {} for highlight cache' .format(ww, wh, scale), exc_info=True) else: self.next_render = 0
[docs] def prerender(self): """ Commit scribbles to cache so they are faster to draw on the slide """ if self.scribble_cache is None: self.reset_scribble_cache() if self.scribble_cache is None: self.next_render = 0 return scale = self.c_da.get_window().get_scale_factor() ww, wh = self.scribble_cache.get_width() / scale, self.scribble_cache.get_height() / scale pen_scale_factor = max(ww / 900, wh / 900) # or sqrt of product cairo_context = cairo.Context(self.scribble_cache) cairo_context.set_line_cap(cairo.LINE_CAP_ROUND) draw = slice(self.next_render, -1 if self.scribble_drawing else None) for color, width, points, pressure in self.scribble_list[draw]: self.render_scribble(cairo_context, color, width * pen_scale_factor, [(x * ww, y * wh) for x, y in points], pressure) del cairo_context self.next_render = draw.indices(len(self.scribble_list))[1]
[docs] def render_scribble(self, cairo_context, color, width, points, pressures): """ Draw a single scribble, i.e. a bezier curve, on the cairo context Args: cairo_context (:class:`~cairo.Context`): The canvas on which to render the drawings color (:class:`~Gdk.RGBA`): The color of the scribble width (`float`): The width of the curve points (`list`): The control points of the curve, scaled to the surface. pressures (`list`): The relative line width at each point as `float` values in 0..1 """ if not points: return # Draw every stroke on a separate surface, then merge them all into the scribble cache # Erasers do not have their own group as they are meant to interfere with strokes below if color.alpha: cairo_context.push_group() cairo_context.set_operator(cairo.OPERATOR_SOURCE) else: # alpha == 0 -> Eraser mode cairo_context.set_operator(cairo.OPERATOR_CLEAR) cairo_context.set_source_rgba(*color) curves = self.points_to_curves(points) curve_widths = [(a + b) / 2 for a, b in zip(pressures[:-1], pressures[1:])] for curve, relwidth in zip(curves, curve_widths): cairo_context.move_to(*curve[:2]) cairo_context.set_line_width(width * relwidth) cairo_context.curve_to(*curve[2:]) cairo_context.stroke() # Draw from last uneven-indexed point to last point cairo_context.move_to(*points[-2 if len(points) % 2 and len(points) > 1 else -1]) cairo_context.set_line_width(width * (curve_widths[-1] if curve_widths else pressures[-1])) cairo_context.line_to(*points[-1]) cairo_context.stroke() if color.alpha: cairo_context.pop_group_to_source() cairo_context.paint()
[docs] def draw_scribble(self, widget, cairo_context): """ Perform the drawings by user. Args: widget (:class:`~Gtk.DrawingArea`): The widget where to draw the scribbles. cairo_context (:class:`~cairo.Context`): The canvas on which to render the drawings """ scale = widget.get_window().get_scale_factor() ww, wh = widget.get_allocated_width(), widget.get_allocated_height() cw, ch = self.scribble_cache.get_width(), self.scribble_cache.get_height() cairo_context.push_group() cairo_context.scale(ww * scale / cw, wh * scale / ch) cairo_context.set_source_surface(self.scribble_cache) cairo_context.paint() cairo_context.restore() pen_scale_factor = max(ww / 900, wh / 900) # or sqrt of product if self.scribble_drawing: cairo_context.set_line_cap(cairo.LINE_CAP_ROUND) color, width, points, pressure = self.scribble_list[-1] self.render_scribble(cairo_context, color, width * pen_scale_factor, [(x * ww, y * wh) for x, y in points], pressure) cairo_context.pop_group_to_source() cairo_context.paint() if widget.get_name() == 'scribble_p_da' and self.mouse_pos is not None: cairo_context.set_source_rgba(0, 0, 0, 1) cairo_context.set_line_width(1) mx, my = self.mouse_pos cairo_context.arc(mx * ww, my * wh, self.scribble_width * pen_scale_factor / 2, 0, 2 * math.pi) cairo_context.stroke_preserve() cairo_context.set_source_rgba(*list(self.scribble_color)[:3], self.scribble_color.alpha * .5) cairo_context.close_path() cairo_context.fill()
[docs] def update_color(self, widget): """ Callback for the color chooser button, to set scribbling color. Args: widget (:class:`~Gtk.ColorButton`): the clicked button to trigger this event, if any """ self.scribble_color = widget.get_rgba() self.update_active_color_width()
[docs] def update_width(self, widget, event, value): """ Callback for the width chooser slider, to set scribbling width. Args: widget (:class:`~Gtk.Scale`): The slider control used to select the scribble width event (:class:`~Gdk.Event`): the GTK event triggering this update. value (`int`): the width of the scribbles to be drawn """ self.scribble_width = max(1, min(100, 10 ** value if value < 1 else 10 + (value - 1) * 90)) self.update_active_color_width()
[docs] def update_active_color_width(self): """ Update modifications to the active scribble color and width, on the pen button and config object """ self.color_width[self.active_preset] = self.scribble_color, self.scribble_width if self.active_preset: self.scribble_preset_buttons[self.active_preset].queue_draw() pen = self.active_preset if self.active_preset else 'eraser' if self.active_preset: self.config.set('highlight', 'color_{}'.format(pen), self.scribble_color.to_string()) self.config.set('highlight', 'width_{}'.format(pen), str(self.scribble_width))
[docs] def adjust_buttons(self): """ Properly enable and disable buttons based on scribblings lists. """ self.scribble_undo.set_sensitive(bool(self.scribble_list)) self.scribble_clear.set_sensitive(bool(self.scribble_list)) self.scribble_redo.set_sensitive(bool(self.scribble_redo_list))
[docs] def clear_scribble(self, *args): """ Callback for the scribble clear button, to remove all scribbles. """ self.scribble_list.clear() self.reset_scribble_cache() self.redraw_current_slide() self.adjust_buttons()
[docs] def page_change(self, page_number, page_label): """ Called when we change pages, to clear or restore scribbles Args: page_number (`int`): The number of the new page page_label (`str`): The label of the new page """ if self.highlight_mode == 'per-page': current_page = self.current_page[0] new_page = page_number elif self.highlight_mode == 'per-label': current_page = self.current_page[1] new_page = page_label # Remember whatever the current mode, to facilitate switching modes self.current_page = (page_number, page_label) if self.highlight_mode == 'global': return elif self.highlight_mode == 'single-page': return self.clear_scribble() else: # Now optionally save the current scribbles if current_page is not None and self.scribble_list: self.remembered_scribbles[current_page] = self.scribble_list.copy() self.scribble_list = self.remembered_scribbles.pop(new_page, []) self.reset_scribble_cache() self.adjust_buttons() self.prerender() self.redraw_current_slide()
[docs] def pop_scribble(self, *args): """ Callback for the scribble undo button, to undo the last scribble. """ if self.scribble_list: self.scribble_redo_list.append(self.scribble_list.pop()) self.adjust_buttons() self.reset_scribble_cache() self.prerender() self.redraw_current_slide()
[docs] def redo_scribble(self, *args): """ Callback for the scribble undo button, to undo the last scribble. """ if self.scribble_redo_list: self.scribble_list.append(self.scribble_redo_list.pop()) self.adjust_buttons() self.prerender() self.redraw_current_slide()
[docs] def on_configure_da(self, widget, event): """ Transfer configure resize to the cache. Args: widget (:class:`~Gtk.Widget`): the widget which has been resized event (:class:`~Gdk.Event`): the GTK event, which contains the new dimensions of the widget """ # Don't trust those if not event.send_event: return self.resize_cache(widget.get_name(), event.width, event.height)
[docs] def set_tools_orientation(self, gaction, target): """ Changes the orientation of the highlighting tool box. Args: gaction (:class:`~Gio.Action`): the action triggering the call target (:class:`~GLib.Variant`): the new orientation to set, as a string wrapped in a GLib.Variant Returns: `bool`: whether the preset was loaded """ orientation = target.get_string() if orientation == self.tools_orientation: return False elif orientation not in ['horizontal', 'vertical']: logger.error('Unexpected highlight-tools orientation {}'.format(orientation)) return False self.tools_orientation = orientation self.adjust_tools_orientation() gaction.change_state(target) self.config.set('layout', 'highlight_tools', self.tools_orientation)
[docs] def adjust_tools_orientation(self): """ Actually change the highlight tool elements orientations according to self.tools_orientation """ orientation = Gtk.Orientation.VERTICAL if self.tools_orientation == 'vertical' else Gtk.Orientation.HORIZONTAL self.preset_toolbar.set_orientation(orientation) self.scribble_toolbar.set_orientation(orientation) self.scribble_color_toolbox.set_orientation(orientation) self.scribble_width_selector.set_orientation(orientation) w, h = sorted(self.scribble_width_selector.get_size_request(), reverse=self.tools_orientation != 'vertical') self.scribble_width_selector.set_size_request(w, h) # NB the parent container is layed out perpendicularly to its contents self.scribble_overlay.set_orientation(Gtk.Orientation.HORIZONTAL if self.tools_orientation == 'vertical' else Gtk.Orientation.VERTICAL)
[docs] def switch_scribbling(self, gaction, target=None): """ Starts the mode where one can read on top of the screen. Args: Returns: `bool`: whether the event was consumed """ if target is not None and target == self.scribbling_mode: return False # Perform the state toggle if self.scribbling_mode: return self.disable_scribbling() else: return self.enable_scribbling()
[docs] def enable_scribbling(self): """ Enable the scribbling mode. Returns: `bool`: whether it was possible to enable (thus if it was not enabled already) """ if self.scribbling_mode: return False self.scribble_off_render.remove(self.scribble_overlay) self.load_layout('highlight') self.p_central.queue_draw() self.scribble_overlay.queue_draw() # Get frequent events for smooth drawing self.p_central.get_window().set_event_compression(False) self.scribbling_mode = True self.get_application().lookup_action('highlight').change_state(GLib.Variant.new_boolean(self.scribbling_mode)) self.pen_action.set_enabled(self.scribbling_mode) self.p_central.queue_draw() extras.Cursor.set_cursor(self.scribble_p_da, 'invisible') return True
[docs] def disable_scribbling(self): """ Disable the scribbling mode. Returns: `bool`: whether it was possible to disable (thus if it was not disabled already) """ if not self.scribbling_mode: return False self.scribbling_mode = False extras.Cursor.set_cursor(self.scribble_p_da, 'default') self.load_layout(None) self.scribble_off_render.add(self.scribble_overlay) window = self.p_central.get_window() if window: window.set_event_compression(True) self.get_application().lookup_action('highlight').change_state(GLib.Variant.new_boolean(self.scribbling_mode)) self.pen_action.set_enabled(self.scribbling_mode) self.p_central.queue_draw() extras.Cursor.set_cursor(self.p_central) self.mouse_pos = None return True
[docs] def load_preset(self, gaction=None, target=None): """ Loads the preset color of a given number or designed by a given widget, as an event handler. Args: gaction (:class:`~Gio.Action`): the action triggering the call target (:class:`~GLib.Variant`): the new preset to load, as a string wrapped in a GLib.Variant Returns: `bool`: whether the preset was loaded """ if isinstance(target, int): self.active_preset = target else: self.active_preset = int(target.get_string()) if target.get_string() != 'eraser' else 0 target = str(self.active_preset) if self.active_preset else 'eraser' self.config.set('highlight', 'active_pen', target) self.pen_action.change_state(GLib.Variant.new_string(target)) self.scribble_color, self.scribble_width = self.color_width[self.active_preset] # Presenter-side setup self.scribble_color_selector.set_rgba(self.scribble_color) self.scribble_width_selector.set_value(math.log10(self.scribble_width) if self.scribble_width < 10 else 1 + (self.scribble_width - 10) / 90) self.scribble_color_selector.set_sensitive(target != 'eraser') # Re-draw the eraser self.scribble_p_da.queue_draw() self.c_da.queue_draw() return True
[docs] def on_eraser_button_draw(self, widget, cairo_context): """ Handle drawing the eraser button. Args: widget (:class:`~Gtk.Widget`): the widget to update cairo_context (:class:`~cairo.Context`): the Cairo context (or `None` if called directly) """ cairo_context.push_group() scale = widget.get_allocated_height() / self.eraser_surface.get_height() cairo_context.scale(scale, scale) cairo_context.set_source_surface(self.eraser_surface) cairo_context.paint() cairo_context.pop_group_to_source() cairo_context.paint()
[docs] def on_preset_button_draw(self, widget, cairo_context): """ Handle drawing the marker/pencil buttons, with appropriate thickness and color. Args: widget (:class:`~Gtk.Widget`): the widget to update cairo_context (:class:`~cairo.Context`): the Cairo context (or `None` if called directly) """ button_number = int(widget.get_name().split('_')[-1]) color, width = self.color_width[button_number] icon, mask = self.marker_surfaces[int(width >= 2) + int(width >= 40)] ww, wh = widget.get_allocated_width(), widget.get_allocated_height() scale = wh / icon.get_height() dw, dh = self.scribble_p_da.get_allocated_width(), self.scribble_p_da.get_allocated_height() pen_scale_factor = max(dw / 900, dh / 900) # or sqrt of product width *= pen_scale_factor cairo_context.push_group() # A line demonstrating the scribble style cairo_context.set_source_rgba(*color) cairo_context.set_line_width(width) cairo_context.move_to(0, wh - width / 2) cairo_context.line_to(ww, wh - width / 2) cairo_context.stroke() cairo_context.set_operator(cairo.OPERATOR_DEST_OUT) # Clip the line to the lower triangle cairo_context.set_source_rgba(0, 0, 0, 1) cairo_context.set_line_width(0) cairo_context.move_to(0, 0) cairo_context.line_to(0, wh) cairo_context.line_to(ww, 0) cairo_context.close_path() cairo_context.fill() # Also clip the colored part of the marker cairo_context.scale(scale, scale) cairo_context.set_source_surface(mask) cairo_context.paint() cairo_context.pop_group_to_source() cairo_context.paint() cairo_context.push_group() # Fill with desired color cairo_context.set_source_rgba(*color) cairo_context.rectangle(0, 0, ww, wh) cairo_context.fill() # Transform for surfaces cairo_context.scale(scale, scale) # Clip color to the mask cairo_context.set_operator(cairo.OPERATOR_DEST_IN) cairo_context.set_source_surface(mask) cairo_context.paint() # Add the rest of the marker cairo_context.set_operator(cairo.OPERATOR_OVER) cairo_context.set_source_surface(icon) cairo_context.paint() cairo_context.pop_group_to_source() cairo_context.paint()