def fmt_time_period_abbr(t): """Get a localized abbreviated minutes+seconds string :param int t: A positive number of seconds :returns: short localized string :rtype: unicode The result looks like like "<minutes>m<seconds>s", or just "<seconds>s". """ if t < 0: raise ValueError("Parameter t cannot be negative") days = int(t / (24 * 60 * 60)) hours = int(t - days * 24 * 60 * 60) / (60 * 60) minutes = int(t - hours * 60 * 60) / 60 seconds = int(t - minutes * 60) # TRANSLATORS: I'm assuming that time periods in places where # TRANSLATORS: abbreviations make sense don't need ngettext() if t > 24 * 60 * 60: template = C_("Time period abbreviations", u"{days}d{hours}h") elif t > 60 * 60: template = C_("Time period abbreviations", u"{hours}h{minutes}m") elif t > 60: template = C_("Time period abbreviation", u"{minutes}m{seconds}s") else: template = C_("Time period abbreviation", u"{seconds}s") return template.format( days = days, hours = hours, minutes = minutes, seconds = seconds, )
def _query_tooltip_cb(self, da, x, y, keyboard_mode, tooltip): s = self._TOOLTIP_ICON_SIZE scaled_pixbuf = self._get_scaled_pixbuf(s) tooltip.set_icon(scaled_pixbuf) template_params = {"brush_name": escape(self._brush_name)} markup_template = C_( "current brush indicator: tooltip (no-description case)", u"<b>{brush_name}</b>", ) if self._brush_desc: markup_template = C_( "current brush indicator: tooltip (description case)", u"<b>{brush_name}</b>\n{brush_desc}", ) template_params["brush_desc"] = escape(self._brush_desc) markup = markup_template.format(**template_params) tooltip.set_markup(markup) # TODO: summarize changes? return True
def _motion_notify_cb(self, widget, event): x, y = event.x, event.y i = self.get_index_at_pos(x, y) # Set the tooltip. # Passing the tooltip through a value of None is necessary for its # position on the screen to be updated to where the pointer is. Setting # it to None, and then to the desired value must happen in two separate # events for the tooltip window position update to be honoured. if i is None: # Not over a color, so use the static default if self._tooltip_index not in (-1, -2): # First such event: reset the tooltip. self._tooltip_index = -1 self.set_has_tooltip(False) self.set_tooltip_text("") elif self._tooltip_index != -2: # Second event over a non-color: set the tooltip text. self._tooltip_index = -2 self.set_has_tooltip(True) self.set_tooltip_text(self.STATIC_TOOLTIP_TEXT) elif self._tooltip_index != i: # Mouse pointer has moved to a different color, or away # from the two states above. if self._tooltip_index is not None: # First event for this i: reset the tooltip. self._tooltip_index = None self.set_has_tooltip(False) self.set_tooltip_text("") else: # Second event for this i: set the desired tooltip text. self._tooltip_index = i mgr = self.get_color_manager() tip = mgr.palette.get_color_name(i) color = mgr.palette.get_color(i) if color is None: tip = C_( "palette view", "Empty palette slot (drag a color here)", ) elif tip is None or tip.strip() == "": tip = "" # Anonymous colors don't get tooltips self.set_has_tooltip(True) self.set_tooltip_text(tip)
def _query_tooltip_cb(self, da, x, y, keyboard_mode, tooltip): s = self._TOOLTIP_ICON_SIZE scaled_pixbuf = self._get_scaled_pixbuf(s) tooltip.set_icon(scaled_pixbuf) brush_name = self._brush_name if not brush_name: brush_name = self._DEFAULT_BRUSH_DISPLAY_NAME # Rare cases, see https://github.com/mypaint/mypaint/issues/402. # Probably just after init. template_params = {"brush_name": lib.xml.escape(brush_name)} markup_template = C_("current brush indicator: tooltip (no-description case)", u"<b>{brush_name}</b>") if self._brush_desc: markup_template = C_( "current brush indicator: tooltip (description case)", u"<b>{brush_name}</b>\n{brush_desc}" ) template_params["brush_desc"] = lib.xml.escape(self._brush_desc) markup = markup_template.format(**template_params) tooltip.set_markup(markup) # TODO: summarize changes? return True
def _populate_settings_treestore(self): # Populate the treestore store = self._builder.get_object("settings_treestore") root_iter = store.get_iter_first() self._treestore = store # Editable string fields # Columns: [cname, displayname, is_selectable, font_weight] row_data = [ None, C_("brush settings list: brush metadata texts group", "About"), True, Pango.Weight.NORMAL, ] group_iter = store.append(root_iter, row_data) # Groups for settings. groups = [{ 'id': 'experimental', 'title': C_( 'brush settings list: setting group', 'Experimental', ), 'settings': [], }, { 'id': 'basic', 'title': C_( 'brush settings list: setting group', 'Basic', ), 'settings': [ 'radius_logarithmic', 'radius_by_random', 'hardness', 'snap_to_pixel', 'anti_aliasing', 'eraser', 'offset_by_random', 'elliptical_dab_angle', 'elliptical_dab_ratio', 'direction_filter', 'pressure_gain_log', ], }, { 'id': 'opacity', 'title': C_( 'brush settings list: setting group', 'Opacity', ), 'settings': [ 'opaque', 'opaque_multiply', 'opaque_linearize', 'lock_alpha', ], }, { 'id': 'dabs', 'title': C_( 'brush settings list: setting group', 'Dabs', ), 'settings': [ 'dabs_per_basic_radius', 'dabs_per_actual_radius', 'dabs_per_second', ], }, { 'id': 'smudge', 'title': C_( 'brush settings list: setting group', 'Smudge', ), 'settings': [ 'smudge', 'smudge_length', 'smudge_radius_log', ], }, { 'id': 'speed', 'title': C_( 'brush settings list: setting group', 'Speed', ), 'settings': [ 'speed1_slowness', 'speed2_slowness', 'speed1_gamma', 'speed2_gamma', 'offset_by_speed', 'offset_by_speed_slowness', ], }, { 'id': 'tracking', 'title': C_( 'brush settings list: setting group', 'Tracking', ), 'settings': [ 'slow_tracking', 'slow_tracking_per_dab', 'tracking_noise', ], }, { 'id': 'stroke', 'title': C_( 'brush settings list: setting group', 'Stroke', ), 'settings': [ 'stroke_threshold', 'stroke_duration_logarithmic', 'stroke_holdtime', ], }, { 'id': 'color', 'title': C_( 'brush settings list: setting group', 'Color', ), 'settings': [ 'change_color_h', 'change_color_l', 'change_color_hsl_s', 'change_color_v', 'change_color_hsv_s', 'restore_color', 'colorize', ], }, { 'id': 'custom', 'title': C_( 'brush settings list: setting group', 'Custom', ), 'settings': ['custom_input', 'custom_input_slowness'], }] hidden_settings = ['color_h', 'color_s', 'color_v'] # Add new settings to the "experimental" group grouped_settings = set(hidden_settings) for g in groups: grouped_settings.update(g['settings']) for s in brushsettings.settings: n = s.cname if n not in grouped_settings: groups[0]['settings'].append(n) logger.warning('Setting %r should be added to a group', n) # Hide experimental group if empty if not groups[0]['settings']: groups.pop(0) # Groups to open by default open_paths = [] open_ids = set(["experimental", "basic"]) # Add groups to the treestore for group_num, group in enumerate(groups): group_id = group["id"] # Columns: [cname, displayname, is_selectable, font_weight] row_data = [None, group["title"], False, Pango.Weight.NORMAL] group_iter = store.append(root_iter, row_data) group_path = store.get_path(group_iter) self._group_treepath[group_id] = group_path for i, cname in enumerate(group['settings']): self._setting_group[cname] = group_id s = brushsettings.settings_dict[cname] row_data = [cname, s.name, True, Pango.Weight.NORMAL] setting_iter = store.append(group_iter, row_data) setting_path = store.get_path(setting_iter) self._setting_treepath[cname] = setting_path if group_id in open_ids: open_paths.append([group_num + 1]) # Connect signals and handler functions v = self._builder.get_object("settings_treeview") sel = v.get_selection() sel.set_select_function(self._settings_treeview_selectfunc, None) # Select the first (description) sel.select_iter(store.get_iter_first()) # Process the paths-to-open for path in open_paths: v.expand_to_path(Gtk.TreePath(path))
class HCYMaskEditorWheel(HCYHueChromaWheel): """HCY wheel specialized for mask editing.""" ## Instance vars __last_cursor = None # previously set cursor (determines some actions) # Objects which are active or being manipulated __tmp_new_ctrlpoint = None # new control-point color __active_ctrlpoint = None # active point in active_void __active_shape = None # list of colors or None # Drag state __drag_func = None __drag_start_pos = None ## Class-level constants and variables # Specialized cursors for different actions __add_cursor = None __move_cursor = None __move_point_cursor = None __rotate_cursor = None # Constrain the range of allowable lumas __MAX_LUMA = 0.75 __MIN_LUMA = 0.25 # Drawing constraints and activity proximities __ctrlpoint_radius = 2.5 __ctrlpoint_grab_radius = 10 __max_num_shapes = 6 # how many shapes are allowed # Tooltip text. Is here a better way of explaining this? It obscures the # editor quite a lot. STATIC_TOOLTIP_TEXT = C_( "HCY Mask Editor Wheel: tooltip", u"Gamut mask editor. Click in the middle to create " u"or manipulate shapes, or rotate the mask using " u"the edges of the disc.", ) def __init__(self): """Instantiate, and connect the editor events. """ super(HCYMaskEditorWheel, self).__init__() self.connect("realize", self._realize_cb) self.connect("button-press-event", self.__button_press_cb) self.connect("button-release-event", self.__button_release_cb) self.connect("motion-notify-event", self.__motion_cb) self.connect("leave-notify-event", self.__leave_cb) self.add_events(Gdk.EventMask.POINTER_MOTION_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK) def _realize_cb(self, widget): display = self.get_window().get_display() self.__add_cursor = Gdk.Cursor.new_for_display(display, Gdk.CursorType.PLUS) self.__move_cursor = Gdk.Cursor.new_for_display( display, Gdk.CursorType.FLEUR) self.__move_point_cursor = Gdk.Cursor.new_for_display( display, Gdk.CursorType.CROSSHAIR) self.__rotate_cursor = Gdk.Cursor.new_for_display( display, Gdk.CursorType.EXCHANGE) def __leave_cb(self, widget, event): # Reset the active objects when the pointer leaves. if self.__drag_func is not None: return self.__active_shape = None self.__active_ctrlpoint = None self.__tmp_new_ctrlpoint = None self.queue_draw() self.__set_cursor(None) def __set_cursor(self, cursor): # Sets the window cursor, retaining a record. if cursor != self.__last_cursor: self.get_window().set_cursor(cursor) self.__last_cursor = cursor def __update_active_objects(self, x, y): # Decides what a click or a drag at (x, y) would do, and updates the # mouse cursor and draw state to match. assert self.__drag_func is None self.__active_shape = None self.__active_ctrlpoint = None self.__tmp_new_ctrlpoint = None self.queue_draw() # yes, always # Possible mask void manipulations mask = self.get_mask() for mask_idx in xrange(len(mask)): colors = mask[mask_idx] if len(colors) < 3: continue # If the pointer is near an existing control point, clicking and # dragging will move it. void = [] for col_idx in xrange(len(colors)): col = colors[col_idx] px, py = self.get_pos_for_color(col) dp = math.sqrt((x - px)**2 + (y - py)**2) if dp <= self.__ctrlpoint_grab_radius: mask.remove(colors) mask.insert(0, colors) self.__active_shape = colors self.__active_ctrlpoint = col_idx self.__set_cursor(None) return void.append((px, py)) # If within a certain distance of an edge, dragging will create and # then move a new control point. void = geom.convex_hull(void) for p1, p2 in geom.pairwise(void): isect = geom.nearest_point_in_segment(p1, p2, (x, y)) if isect is not None: ix, iy = isect di = math.sqrt((ix - x)**2 + (iy - y)**2) if di <= self.__ctrlpoint_grab_radius: newcol = self.get_color_at_position(ix, iy) self.__tmp_new_ctrlpoint = newcol mask.remove(colors) mask.insert(0, colors) self.__active_shape = colors self.__set_cursor(None) return # If the mouse is within a mask void, then dragging would move that # shape around within the mask. if geom.point_in_convex_poly((x, y), void): mask.remove(colors) mask.insert(0, colors) self.__active_shape = colors self.__set_cursor(None) return # Away from shapes, clicks and drags manipulate the entire mask: adding # cutout voids to it, or rotating the whole mask around its central # axis. alloc = self.get_allocation() cx, cy = self.get_center(alloc=alloc) radius = self.get_radius(alloc=alloc) dx, dy = x - cx, y - cy r = math.sqrt(dx**2 + dy**2) if r < radius * (1.0 - self.min_shape_size): if len(mask) < self.__max_num_shapes: d = self.__dist_to_nearest_shape(x, y) minsize = radius * self.min_shape_size if d is None or d > minsize: # Clicking will result in a new void self.__set_cursor(self.__add_cursor) else: # Click-drag to rotate the entire mask self.__set_cursor(self.__rotate_cursor) def __drag_active_shape(self, px, py): # Updates the position of the active shape during drags. x0, y0 = self.__drag_start_pos dx = px - x0 dy = py - y0 self.__active_shape[:] = [] for col in self.__active_shape_predrag: cx, cy = self.get_pos_for_color(col) cx += dx cy += dy col2 = super(HCYMaskEditorWheel, self).get_color_at_position(cx, cy, ignore_mask=True) self.__active_shape.append(col2) def __drag_active_ctrlpoint(self, px, py): # Moves the highlighted control point during drags. x0, y0 = self.__drag_start_pos dx = px - x0 dy = py - y0 col = self.__active_ctrlpoint_predrag cx, cy = self.get_pos_for_color(col) cx += dx cy += dy col = super(HCYMaskEditorWheel, self).get_color_at_position(cx, cy, ignore_mask=True) self.__active_shape[self.__active_ctrlpoint] = col def __rotate_mask(self, px, py): # Rotates the entire mask around the grey axis during drags. cx, cy = self.get_center() x0, y0 = self.__drag_start_pos theta0 = math.atan2(x0 - cx, y0 - cy) theta = math.atan2(px - cx, py - cy) dntheta = (theta0 - theta) / (2 * math.pi) while dntheta <= 0: dntheta += 1.0 if self.__mask_predrag is None: self.__mask_predrag = [] for shape in self.get_mask(): shape_hcy = [HCYColor(color=c) for c in shape] self.__mask_predrag.append(shape_hcy) mgr = self.get_color_manager() newmask = [] for shape in self.__mask_predrag: shape_rot = [] for col in shape: col_r = HCYColor(color=col) h = mgr.distort_hue(col_r.h) h += dntheta h %= 1.0 col_r.h = mgr.undistort_hue(h) shape_rot.append(col_r) newmask.append(shape_rot) self.set_mask(newmask) def __button_press_cb(self, widget, event): # Begins drags. if self.__drag_func is None: self.__update_active_objects(event.x, event.y) self.__drag_start_pos = event.x, event.y if self.__tmp_new_ctrlpoint is not None: self.__active_ctrlpoint = len(self.__active_shape) self.__active_shape.append(self.__tmp_new_ctrlpoint) self.__tmp_new_ctrlpoint = None if self.__active_ctrlpoint is not None: self.__active_shape_predrag = self.__active_shape[:] ctrlpt = self.__active_shape[self.__active_ctrlpoint] self.__active_ctrlpoint_predrag = ctrlpt self.__drag_func = self.__drag_active_ctrlpoint self.__set_cursor(self.__move_point_cursor) elif self.__active_shape is not None: self.__active_shape_predrag = self.__active_shape[:] self.__drag_func = self.__drag_active_shape self.__set_cursor(self.__move_cursor) elif self.__last_cursor is self.__rotate_cursor: self.__mask_predrag = None self.__drag_func = self.__rotate_mask def __button_release_cb(self, widget, event): # Ends the current drag & cleans up, or handle other clicks. if self.__drag_func is None: # Clicking when not in a drag adds a new shape if self.__last_cursor is self.__add_cursor: self.__add_void(event.x, event.y) else: # Cleanup when dragging ends self.__drag_func = None self.__drag_start_pos = None self.__cleanup_mask() self.__update_active_objects(event.x, event.y) def __motion_cb(self, widget, event): # Fire the current drag function if one's active. if self.__drag_func is not None: self.__drag_func(event.x, event.y) self.queue_draw() else: self.__update_active_objects(event.x, event.y) def __cleanup_mask(self): mask = self.get_mask() # Drop points from all shapes which are not part of the convex hulls. for shape in mask: if len(shape) <= 3: continue points = [self.get_pos_for_color(c) for c in shape] edge_points = geom.convex_hull(points) for col, point in zip(shape, points): if point in edge_points: continue shape.remove(col) # Drop shapes smaller than the minimum size. newmask = [] min_size = self.get_radius() * self.min_shape_size for shape in mask: points = [self.get_pos_for_color(c) for c in shape] void = geom.convex_hull(points) size = self._get_void_size(void) if size >= min_size: newmask.append(shape) mask = newmask # Drop shapes whose points entirely lie within other shapes newmask = [] maskvoids = [ (shape, geom.convex_hull([self.get_pos_for_color(c) for c in shape])) for shape in mask ] for shape1, void1 in maskvoids: shape1_subsumed = True for p1 in void1: p1_subsumed = False for shape2, void2 in maskvoids: if shape1 is shape2: continue if geom.point_in_convex_poly(p1, void2): p1_subsumed = True break if not p1_subsumed: shape1_subsumed = False break if not shape1_subsumed: newmask.append(shape1) mask = newmask self.set_mask(mask) self.queue_draw() def __dist_to_nearest_shape(self, x, y): # Distance from `x`, `y` to the nearest edge or vertex of any shape. dists = [] for hull in self.get_mask_voids(): # cx, cy = geom.poly_centroid(hull) for p1, p2 in geom.pairwise(hull): nearest_point = geom.nearest_point_in_segment(p1, p2, (x, y)) if nearest_point is not None: nx, ny = nearest_point d = math.sqrt((x - nx)**2 + (y - ny)**2) dists.append(d) # Segment end too d = math.sqrt((p1[0] - x)**2 + (p1[1] - y)**2) dists.append(d) if not dists: return None dists.sort() return dists[0] def __add_void(self, x, y): # Adds a new shape into the empty space centred at `x`, `y`. self.queue_draw() # Pick a nice size for the new shape, taking care not to # overlap any other shapes, at least initially. alloc = self.get_allocation() cx, cy = self.get_center(alloc=alloc) radius = self.get_radius(alloc=alloc) dx, dy = x - cx, y - cy r = math.sqrt(dx**2 + dy**2) d = self.__dist_to_nearest_shape(x, y) if d is None: d = radius size = min((radius - r), d) * 0.95 minsize = radius * self.min_shape_size if size < minsize: return # Create a regular polygon with one of its edges facing the # middle of the wheel. shape = [] nsides = 3 + len(self.get_mask()) psi = math.atan2(dy, dx) + (math.pi / nsides) psi += math.pi for i in xrange(nsides): theta = 2.0 * math.pi * i / nsides theta += psi px = int(x + size * math.cos(theta)) py = int(y + size * math.sin(theta)) col = self.get_color_at_position(px, py, ignore_mask=True) shape.append(col) mask = self.get_mask() mask.append(shape) self.set_mask(mask) def draw_mask_control_points(self, cr, wd, ht): # Draw active and inactive control points on the active shape. if self.__active_shape is None: return cr.save() active_rgb = 1, 1, 1 normal_rgb = 0, 0, 0 delete_rgb = 1, 0, 0 cr.set_line_width(1.0) void = self.colors_to_mask_void(self.__active_shape) # Highlight the objects that would be directly or indirectly affected # if the shape were dragged, and how. min_size = self.get_radius(wd=wd, ht=ht) * self.min_shape_size void_rgb = normal_rgb if self._get_void_size(void) < min_size: # Shape will be deleted void_rgb = delete_rgb elif ((self.__active_ctrlpoint is None) and (self.__tmp_new_ctrlpoint is None)): # The entire shape would be moved void_rgb = active_rgb # Outline the current shape cr.set_source_rgb(*void_rgb) for p_idx, p in enumerate(void): if p_idx == 0: cr.move_to(*p) else: cr.line_to(*p) cr.close_path() cr.stroke() # Control points colors = self.__active_shape for col_idx, col in enumerate(colors): px, py = self.get_pos_for_color(col) if (px, py) not in void: # not in convex hull (is it worth doing this fragile test?) continue point_rgb = void_rgb if col_idx == self.__active_ctrlpoint: point_rgb = active_rgb cr.set_source_rgb(*point_rgb) cr.arc(px, py, self.__ctrlpoint_radius, 0, 2 * math.pi) cr.fill() if self.__tmp_new_ctrlpoint: px, py = self.get_pos_for_color(self.__tmp_new_ctrlpoint) cr.set_source_rgb(*active_rgb) cr.arc(px, py, self.__ctrlpoint_radius, 0, 2 * math.pi) cr.fill() # Centroid cr.set_source_rgb(*void_rgb) cx, cy = geom.poly_centroid(void) cr.save() cr.set_line_cap(cairo.LINE_CAP_SQUARE) cr.set_line_width(0.5) cr.translate(int(cx) + 0.5, int(cy) + 0.5) cr.move_to(-2, 0) cr.line_to(2, 0) cr.stroke() cr.move_to(0, -2) cr.line_to(0, 2) cr.stroke() cr.restore() def paint_foreground_cb(self, cr, wd, ht): """Foreground drawing override. """ self.draw_mask(cr, wd, ht) self.draw_mask_control_points(cr, wd, ht) def get_managed_color(self): """Override, with a limited range or returned luma. """ col = super(HCYMaskEditorWheel, self).get_managed_color() col = HCYColor(color=col) col.y = clamp(col.y, self.__MIN_LUMA, self.__MAX_LUMA) return col def set_managed_color(self, color): """Override, limiting the luma range. """ col = HCYColor(color=color) col.y = clamp(col.y, self.__MIN_LUMA, self.__MAX_LUMA) super(HCYMaskEditorWheel, self).set_managed_color(col)
def get_page_title(cls): return C_( "HCY Wheel color adjuster page: title for tooltips etc.", u"HCY Wheel", )
def __init__(self, parent, target): super(HCYMaskPropertiesDialog, self).__init__( title=C_( "HCY Gamut Mask Editor dialog: window title", u"Gamut Mask Editor", ), transient_for=parent, modal=True, destroy_with_parent=True, window_position=Gtk.WindowPosition.CENTER_ON_PARENT, buttons=( Gtk.STOCK_CANCEL, Gtk.ResponseType.REJECT, Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT, ), ) self.target = target ed = HCYMaskEditorWheel() target_mgr = target.get_color_manager() prefs_ro = deepcopy(target_mgr.get_prefs()) datapath = target_mgr.get_data_path() ed_mgr = ColorManager(prefs=prefs_ro, datapath=datapath) ed.set_color_manager(ed_mgr) self.editor = ed ed.set_size_request(300, 300) ed.mask_toggle.set_active(True) self.mask_toggle_ctrl = Gtk.CheckButton( C_( "HCY Gamut Mask Editor dialog: mask-is-active checkbox", u"Active", ), use_underline=False, ) self.mask_toggle_ctrl.set_tooltip_text(ed.mask_toggle.get_tooltip()) ed.mask_observers.append(self.__mask_changed_cb) hbox = Gtk.HBox() hbox.set_spacing(3) # Sidebar buttonbox # On the right and packed to the top. This places its secondary # control, a mask toggle button, next to the "OK" button so it's less # likely to be missed. bbox = Gtk.VButtonBox() new_btn = self.__new_button = Gtk.Button(stock=Gtk.STOCK_NEW) load_btn = self.__load_button = Gtk.Button(stock=Gtk.STOCK_OPEN) save_btn = self.__save_button = Gtk.Button(stock=Gtk.STOCK_SAVE) clear_btn = self.__clear_button = Gtk.Button(stock=Gtk.STOCK_CLEAR) help_btn = self.__help_button = Gtk.LinkButton.new_with_label( uri=MASK_EDITOR_HELP_URI, label=C_( "HCY Mask Editor: action button labels", u"Help…", ), ) new_btn.set_tooltip_text( C_("HCY Mask Editor: action button tooltips", u"Create mask from template."), ) load_btn.set_tooltip_text( C_("HCY Mask Editor: action button tooltips", u"Load mask from a GIMP palette file."), ) save_btn.set_tooltip_text( C_("HCY Mask Editor: action button tooltips", u"Save mask to a GIMP palette file."), ) clear_btn.set_tooltip_text( C_("HCY Mask Editor: action button tooltips", u"Erase the mask."), ) help_btn.set_tooltip_text( C_("HCY Mask Editor: action button tooltips", u"Open the online help for this dialog in a web browser."), ) new_btn.connect("clicked", self.__new_clicked) save_btn.connect("clicked", self.__save_clicked) load_btn.connect("clicked", self.__load_clicked) clear_btn.connect("clicked", self.__clear_clicked) bbox.pack_start(new_btn, True, True, 0) bbox.pack_start(load_btn, True, True, 0) bbox.pack_start(save_btn, True, True, 0) bbox.pack_start(clear_btn, True, True, 0) action_area = self.get_action_area() if isinstance(action_area, Gtk.ButtonBox): action_area.pack_start(help_btn, True, True, 0) action_area.set_child_secondary(help_btn, True) action_area.set_child_non_homogeneous(help_btn, True) bbox.pack_start(self.mask_toggle_ctrl, True, True, 0) bbox.set_child_secondary(self.mask_toggle_ctrl, True) else: bbox.pack_start(self.mask_toggle_ctrl, True, True, 0) bbox.pack_start(help_btn, True, True, 0) bbox.set_child_secondary(help_btn, True) bbox.set_layout(Gtk.ButtonBoxStyle.START) hbox.pack_start(ed, True, True, 0) hbox.pack_start(bbox, False, False, 0) hbox.set_border_width(9) self.vbox.pack_start(hbox, True, True, 0) self.connect("response", self.__response_cb) self.connect("show", self.__show_cb) for w in self.vbox: w.show_all()
def picking_status_text(self): """The statusbar text to use during the grab.""" return C_( "color picker: statusbar text during grab", u"Pick color…", )
# TRANSLATORS: Statusbar message shown when the app chosen for # TRANSLATORS: "Edit in External App" (for a layer) failed to open. _LAUNCH_FAILED_MSG = _(u"Error: failed to launch {app_name} to edit " u"layer “{layer_name}”") # TRANSLATORS: Statusbar message for when an "Edit in External App" # TRANSLATORS: operation is cancelled by the user before it starts. _LAUNCH_CANCELLED_MSG = _(u"Editing cancelled. You can still edit " u"“{layer_name}” from the Layers menu.") # TRANSLATORS: This is a statusbar message shown when # TRANSLATORS: a layer is updated from an external edit _LAYER_UPDATED_MSG = _(u"Updated layer “{layer_name}” with external edits") _LAYER_UPDATE_FAILED_MSG = C_( "Edit in External App (statusbar message)", u"Failed to update layer with external edits from “{file_basename}”.", ) # Restoration of environment variables for launch context _MYP_ENV_NAME = 'MYPAINT_ENV_CLEAN' def restore_env(ctx): """Clear existing envvars in context & use the given envvars instead This is used to restore the original environment when starting external editing with an environment that may lead to e.g. incompatible or non-existing versions of libraries being linked by the external application, instead of the correct ones.
class SymmetryEditOptionsWidget (Gtk.Alignment): _POSITION_LABEL_TEXT = C_( "symmetry axis options panel: labels", u"Position:", ) _POSITION_BUTTON_TEXT_INACTIVE = C_( "symmetry axis options panel: position button: no axis pos.", u"None", ) _POSITION_BUTTON_TEXT_TEMPLATE = C_( "symmetry axis options panel: position button: axis pos. in pixels", u"%d px", ) _ALPHA_LABEL_TEXT = C_( "symmetry axis options panel: labels", u"Alpha:", ) def __init__(self): super(SymmetryEditOptionsWidget, self).__init__( xalign=0.5, yalign=0.5, xscale=1.0, yscale=1.0, ) self._axis_pos_dialog = None self._axis_pos_button = None from application import get_app self.app = get_app() rootstack = self.app.doc.model.layer_stack self._axis_pos_adj = Gtk.Adjustment( rootstack.symmetry_axis, upper=32000, lower=-32000, step_incr=1, page_incr=100, ) self._axis_pos_adj.connect( 'value-changed', self._axis_pos_adj_changed, ) self._init_ui() rootstack.symmetry_state_changed += self._symmetry_state_changed_cb self._update_axis_pos_button_label(rootstack.symmetry_axis) def _init_ui(self): app = self.app # Dialog for showing and editing the axis value directly buttons = (Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT) dialog = gui.windowing.Dialog( app, C_( "symmetry axis options panel: axis position dialog: window title", u"Axis Position", ), app.drawWindow, buttons=buttons, ) dialog.connect('response', self._axis_pos_dialog_response_cb) grid = Gtk.Grid() grid.set_border_width(gui.widgets.SPACING_LOOSE) grid.set_column_spacing(gui.widgets.SPACING) grid.set_row_spacing(gui.widgets.SPACING) label = Gtk.Label(self._POSITION_LABEL_TEXT) label.set_hexpand(False) label.set_vexpand(False) grid.attach(label, 0, 0, 1, 1) entry = Gtk.SpinButton( adjustment=self._axis_pos_adj, climb_rate=0.25, digits=0 ) entry.set_hexpand(True) entry.set_vexpand(False) grid.attach(entry, 1, 0, 1, 1) dialog_content_box = dialog.get_content_area() dialog_content_box.pack_start(grid, True, True, 0) self._axis_pos_dialog = dialog # Layout grid row = 0 grid = Gtk.Grid() grid.set_border_width(gui.widgets.SPACING_CRAMPED) grid.set_row_spacing(gui.widgets.SPACING_CRAMPED) grid.set_column_spacing(gui.widgets.SPACING_CRAMPED) self.add(grid) row += 1 label = Gtk.Label(self._ALPHA_LABEL_TEXT) label.set_hexpand(False) label.set_halign(Gtk.Align.START) grid.attach(label, 0, row, 1, 1) scale = Gtk.Scale.new_with_range( orientation = Gtk.Orientation.HORIZONTAL, min = 0, max = 1, step = 0.1, ) scale.set_draw_value(False) line_alpha = self.app.preferences.get(_ALPHA_PREFS_KEY, _DEFAULT_ALPHA) scale.set_value(line_alpha) scale.set_hexpand(True) scale.set_vexpand(False) scale.connect("value-changed", self._scale_value_changed_cb) grid.attach(scale, 1, row, 1, 1) row += 1 label = Gtk.Label(self._POSITION_LABEL_TEXT) label.set_hexpand(False) label.set_halign(Gtk.Align.START) button = Gtk.Button(self._POSITION_BUTTON_TEXT_INACTIVE) button.set_vexpand(False) button.connect("clicked", self._axis_pos_button_clicked_cb) button.set_hexpand(True) button.set_vexpand(False) grid.attach(label, 0, row, 1, 1) grid.attach(button, 1, row, 1, 1) self._axis_pos_button = button row += 1 button = Gtk.CheckButton() toggle_action = self.app.find_action("SymmetryActive") button.set_related_action(toggle_action) button.set_label(C_( "symmetry axis options panel: axis active checkbox", u'Enabled', )) button.set_hexpand(True) button.set_vexpand(False) grid.attach(button, 1, row, 2, 1) self._axis_active_button = button def _symmetry_state_changed_cb(self, rootstack, active, x): self._update_axis_pos_button_label(x) dialog = self._axis_pos_dialog dialog_content_box = dialog.get_content_area() if x is None: dialog_content_box.set_sensitive(False) else: dialog_content_box.set_sensitive(True) adj = self._axis_pos_adj adj_pos = int(adj.get_value()) model_pos = int(x) if adj_pos != model_pos: adj.set_value(model_pos) def _update_axis_pos_button_label(self, x): if x is None: text = self._POSITION_BUTTON_TEXT_INACTIVE else: text = self._POSITION_BUTTON_TEXT_TEMPLATE % (x,) self._axis_pos_button.set_label(text) def _axis_pos_adj_changed(self, adj): rootstack = self.app.doc.model.layer_stack model_pos = int(rootstack.symmetry_axis) adj_pos = int(adj.get_value()) if adj_pos != model_pos: rootstack.symmetry_axis = adj_pos def _axis_pos_button_clicked_cb(self, button): self._axis_pos_dialog.show_all() def _axis_pos_dialog_response_cb(self, dialog, response_id): if response_id == Gtk.ResponseType.ACCEPT: dialog.hide() def _scale_value_changed_cb(self, scale): alpha = scale.get_value() prefs = self.app.preferences prefs[_ALPHA_PREFS_KEY] = alpha for tdw in self._tdws_with_symmetry_overlays(): tdw.queue_draw() @staticmethod def _tdws_with_symmetry_overlays(): for tdw in gui.tileddrawwidget.TiledDrawWidget.get_visible_tdws(): for ov in tdw.display_overlays: if isinstance(ov, SymmetryOverlay): yield tdw
class SymmetryEditMode (gui.mode.ScrollableModeMixin, gui.mode.DragMode): """Tool/mode for editing the axis of symmetry used when painting""" # Class-level config ACTION_NAME = 'SymmetryEditMode' pointer_behavior = gui.mode.Behavior.EDIT_OBJECTS scroll_behavior = gui.mode.Behavior.CHANGE_VIEW # These will be overridden on enter() inactive_cursor = None active_cursor = None unmodified_persist = True permitted_switch_actions = { 'ShowPopupMenu', 'RotateViewMode', 'ZoomViewMode', 'PanViewMode', } # Statusbar stuff _STATUSBAR_CONTEXT = 'symmetry-mode' _STATUSBAR_CREATE_AXIS_MSG = C_( "symmetry axis edit mode: instructions shown in statusbar", u"Place axis", ) _STATUSBAR_MOVE_AXIS_MSG = C_( "symmetry axis edit mode: instructions shown in statusbar", u"Move axis", ) _STATUSBAR_DELETE_AXIS_MSG = C_( "symmetry axis edit mode: instructions shown in statusbar", u"Remove axis", ) # Options widget singleton _OPTIONS_WIDGET = None # Info strings @classmethod def get_name(cls): return C_( "symmetry axis edit mode: mode name (tooltips)", u"Edit Symmetry Axis", ) def get_usage(self): return C_( "symmetry axis edit mode: mode description (tooltips)", u"Adjust the painting symmetry axis.", ) # Initization and mode interface def __init__(self, **kwds): """Initialize.""" super(SymmetryEditMode, self).__init__(**kwds) from gui.application import get_app app = get_app() self.app = app # The overlay is always present and stores the information required to # draw the axes, as well as information about what the active zone is. self._overlay = [ o for o in app.doc.tdw.display_overlays if isinstance(o, SymmetryOverlay)][0] statusbar_cid = app.statusbar.get_context_id(self._STATUSBAR_CONTEXT) self._statusbar_context_id = statusbar_cid self._last_msg_zone = None self._zone = None # Symmetry center location at the beginning of the drag self._drag_start_pos = None self._drag_prev_pos = None self._active_axis_points = None self._drag_factors = None self._click_info = None self._entered_before = False self._move_item = None self._move_timeout_id = None # Initialize/fetch cursors self.cursor_remove = self._get_cursor(gui.cursor.Name.ARROW) self.cursor_add = self._get_cursor(gui.cursor.Name.ADD) self.cursor_normal = self._get_cursor(gui.cursor.Name.ARROW) self.cursor_movable = self._get_cursor(gui.cursor.Name.HAND_OPEN) self.cursor_moving = self._get_cursor(gui.cursor.Name.HAND_CLOSED) def _get_cursor(self, name): return self.app.cursors.get_action_cursor(self.ACTION_NAME, name) def enter(self, doc, **kwds): """Enter the mode""" super(SymmetryEditMode, self).enter(doc, **kwds) # Set overlay to draw edit controls (center point, disable button) self._overlay.enable_edit_mode() # Turn on the axis, if it happens to be off right now if not self._entered_before: self.app.find_action("SymmetryActive").set_active(True) self._entered_before = True def popped(self): # Set overlay to draw normally, without controls self._overlay.disable_edit_mode() super(SymmetryEditMode, self).popped() def _update_statusbar(self, zone): if self.in_drag: return if self._last_msg_zone == zone: return self._last_msg_zone = zone statusbar = self.app.statusbar statusbar_cid = self._statusbar_context_id statusbar.remove_all(statusbar_cid) msgs = { _EditZone.CREATE_AXIS: self._STATUSBAR_CREATE_AXIS_MSG, _EditZone.MOVE_AXIS: self._STATUSBAR_MOVE_AXIS_MSG, _EditZone.MOVE_CENTER: self._STATUSBAR_MOVE_AXIS_MSG, _EditZone.DISABLE: self._STATUSBAR_DELETE_AXIS_MSG, } msg = msgs.get(zone, None) if msg: statusbar.push(statusbar_cid, msg) def get_options_widget(self): """Get the (class singleton) options widget""" cls = self.__class__ if cls._OPTIONS_WIDGET is None: widget = SymmetryEditOptionsWidget() cls._OPTIONS_WIDGET = widget return cls._OPTIONS_WIDGET # Events and internals def button_press_cb(self, tdw, event): if self._zone in (_EditZone.CREATE_AXIS, _EditZone.DISABLE): button = event.button if button == 1 and event.type == Gdk.EventType.BUTTON_PRESS: self._click_info = (button, self._zone) return False return super(SymmetryEditMode, self).button_press_cb(tdw, event) def button_release_cb(self, tdw, event): # If the corresponding press was not on a clickable zone, or the cursor # was moved away from it before the button was released. info = self._click_info if not info or (event.button, self._zone) != info: return super(SymmetryEditMode, self).button_release_cb(tdw, event) _, zone_pressed = info self._click_info = None layer_stack = tdw.doc.layer_stack # Disable button clicked if zone_pressed == _EditZone.DISABLE: layer_stack.symmetry_active = False # Symmetry was inactive - create axis based on cursor position elif zone_pressed == _EditZone.CREATE_AXIS: new_center = tuple( int(round(c)) for c in tdw.display_to_model(event.x, event.y)) if layer_stack.symmetry_unset: layer_stack.symmetry_unset = False layer_stack.set_symmetry_state(True, center=new_center) self._update_zone_and_cursor(event.x, event.y) return False def _update_zone_and_cursor(self, x, y): """Update UI & some internal zone flags from pointer position :param x: cursor x position :param y: cursor y position See also: `SymmetryOverlay`. """ if self.in_drag: return zone_cursors = { # Active and inactive cursors respectively _EditZone.CREATE_AXIS: (self.cursor_add, self.cursor_add), _EditZone.MOVE_CENTER: (self.cursor_moving, self.cursor_movable), _EditZone.DISABLE: (self.cursor_remove, self.cursor_remove), _EditZone.NONE: (self.cursor_normal, self.cursor_normal), } changed, zone, data = self._overlay.update_zone_data(x, y) self._zone = zone if changed: self._update_statusbar(zone) if zone in zone_cursors: self.active_cursor, self.inactive_cursor = zone_cursors[zone] elif data: active, inactive, axis_points = data self._active_axis_points = axis_points self.active_cursor = self._get_cursor(active) self.inactive_cursor = self._get_cursor(inactive) def motion_notify_cb(self, tdw, event): if not self.in_drag: self._update_zone_and_cursor(event.x, event.y) tdw.set_override_cursor(self.inactive_cursor) return super(SymmetryEditMode, self).motion_notify_cb(tdw, event) def drag_start_cb(self, tdw, event): tdw.renderer.defer_hq_rendering(10) if self._zone in (_EditZone.MOVE_AXIS, _EditZone.MOVE_CENTER): self._drag_start_pos = tdw.doc.layer_stack.symmetry_center if self._zone == _EditZone.MOVE_AXIS: p1, p2 = self._active_axis_points # Calculate how the pixel offsets (display coordinates) relate # to the symmetry center offsets (model coordinates). # Sloppy way to do it, but it isn't done often. offs = 1000.0 dx, dy = tdw.model_to_display(*p1) # Horizontal and vertical display offsets -> model coordinates xrefx, xrefy = tdw.display_to_model(dx - offs, dy) yrefx, yrefy = tdw.display_to_model(dx, dy + offs) # Display x offsets -> model x,y offsets xc, yc = lib.alg.nearest_point_on_line(p1, p2, (xrefx, xrefy)) xx, xy = (xc - xrefx) / offs, (yc - xrefy) / offs # Display y offsets -> model x,y offsets xc, yc = lib.alg.nearest_point_on_line(p1, p2, (yrefx, yrefy)) yx, yy = (yrefx - xc) / offs, (yrefy - yc) / offs self._drag_factors = xx, xy, yx, yy else: self._update_zone_and_cursor(self.start_x, self.start_y) return super(SymmetryEditMode, self).drag_start_cb(tdw, event) def drag_update_cb(self, tdw, event, ev_x, ev_y, dx, dy): zone = self._zone if zone == _EditZone.MOVE_CENTER: self._queue_movement(zone, (ev_x, ev_y, tdw)) elif zone == _EditZone.MOVE_AXIS: self._queue_movement( zone, (ev_x - self.start_x, ev_y - self.start_y, tdw)) def _queue_movement(self, zone, args): self._move_item = (zone, args) if not self._move_timeout_id: self._move_timeout_id = GLib.timeout_add( interval=16.66, # 60 fps cap function=self._do_move, ) def _do_move(self): if self._move_item: zone, args = self._move_item self._move_item = None if zone == _EditZone.MOVE_AXIS: dx, dy, tdw = args self._move_axis(dx, dy, tdw.doc.layer_stack) elif zone == _EditZone.MOVE_CENTER: x, y, tdw = args self._move_center(x, y, tdw) self._move_timeout_id = None def _move_center(self, x, y, tdw): xm, ym = tdw.display_to_model(x, y) tdw.doc.layer_stack.symmetry_center = (xm, ym) def _move_axis(self, dx_full, dy_full, stack): xs, ys = self._drag_start_pos xx, xy, yx, yy = self._drag_factors xm = round(xs + (dx_full * xx + dy_full * yx)) ym = round(ys + (dx_full * xy + dy_full * yy)) new_pos = xm, ym if self._drag_prev_pos != new_pos: self._drag_prev_pos = new_pos stack.symmetry_center = new_pos def drag_stop_cb(self, tdw): if self._move_item and not self._move_timeout_id: self._do_move() tdw.renderer.defer_hq_rendering(0) return super(SymmetryEditMode, self).drag_stop_cb(tdw)
def _init_ui(self): # Layout grid row = 0 grid = Gtk.Grid() grid.set_border_width(gui.widgets.SPACING_CRAMPED) grid.set_row_spacing(gui.widgets.SPACING_CRAMPED) grid.set_column_spacing(gui.widgets.SPACING_CRAMPED) self.add(grid) row += 1 label = Gtk.Label(label=self._ALPHA_LABEL_TEXT) label.set_hexpand(False) label.set_halign(Gtk.Align.START) grid.attach(label, 0, row, 1, 1) scale = InputSlider() scale.set_range(0, 1) scale.set_round_digits(1) scale.set_draw_value(False) line_alpha = self.app.preferences.get(_ALPHA_PREFS_KEY, _DEFAULT_ALPHA) scale.set_value(line_alpha) scale.set_hexpand(True) scale.set_vexpand(False) scale.scale.connect("value-changed", self._scale_value_changed_cb) grid.attach(scale, 1, row, 1, 1) row += 1 store = Gtk.ListStore(int, str) rootstack = self.app.doc.model.layer_stack for _type in lib.tiledsurface.SYMMETRY_TYPES: store.append([_type, lib.tiledsurface.SYMMETRY_STRINGS[_type]]) self._symmetry_type_combo = Gtk.ComboBox() self._symmetry_type_combo.set_model(store) self._symmetry_type_combo.set_active(rootstack.symmetry_type) self._symmetry_type_combo.set_hexpand(True) cell = Gtk.CellRendererText() self._symmetry_type_combo.pack_start(cell, True) self._symmetry_type_combo.add_attribute(cell, "text", 1) self._type_cb_id = self._symmetry_type_combo.connect( 'changed', self._symmetry_type_combo_changed_cb ) label = Gtk.Label(label=self._SYMMETRY_TYPE_TEXT) label.set_hexpand(False) label.set_halign(Gtk.Align.START) grid.attach(label, 0, row, 1, 1) grid.attach(self._symmetry_type_combo, 1, row, 1, 1) row += 1 label = Gtk.Label(label=self._SYMMETRY_ROT_LINES_TEXT) label.set_hexpand(False) label.set_halign(Gtk.Align.START) self._axis_sym_lines_entry = Gtk.SpinButton( adjustment=self._axis_symmetry_lines, climb_rate=0.25 ) self._update_num_lines_sensitivity(rootstack.symmetry_type) grid.attach(label, 0, row, 1, 1) grid.attach(self._axis_sym_lines_entry, 1, row, 1, 1) row += 1 label = Gtk.Label(label=self._POSITION_LABEL_X_TEXT) label.set_hexpand(False) label.set_halign(Gtk.Align.START) entry = Gtk.SpinButton( adjustment=self._axis_pos_adj_x, climb_rate=0.25, digits=0 ) entry.set_hexpand(True) entry.set_vexpand(False) grid.attach(label, 0, row, 1, 1) grid.attach(entry, 1, row, 1, 1) row += 1 label = Gtk.Label(label=self._POSITION_LABEL_Y_TEXT) label.set_hexpand(False) label.set_halign(Gtk.Align.START) entry = Gtk.SpinButton( adjustment=self._axis_pos_adj_y, climb_rate=0.25, digits=0 ) entry.set_hexpand(True) entry.set_vexpand(False) grid.attach(label, 0, row, 1, 1) grid.attach(entry, 1, row, 1, 1) row += 1 label = Gtk.Label() label.set_hexpand(False) label.set_halign(Gtk.Align.START) self._angle_label = label self._update_angle_label() grid.attach(label, 0, row, 1, 1) scale = InputSlider(self._axis_angle) scale.set_draw_value(False) scale.set_hexpand(True) scale.set_vexpand(False) grid.attach(scale, 1, row, 1, 1) row += 1 button = Gtk.CheckButton() toggle_action = self.app.find_action("SymmetryActive") button.set_related_action(toggle_action) button.set_label(C_( "symmetry axis options panel: axis active checkbox", u'Enabled', )) button.set_hexpand(True) button.set_vexpand(False) grid.attach(button, 1, row, 2, 1)
class SymmetryEditOptionsWidget (Gtk.Alignment): _POSITION_LABEL_X_TEXT = C_( "symmetry axis options panel: labels", u"X Position:", ) _POSITION_LABEL_Y_TEXT = C_( "symmetry axis options panel: labels", u"Y Position:", ) _ANGLE_LABEL_TEXT = C_( "symmetry axis options panel: labels", u"Angle: %.2f°", ) _POSITION_BUTTON_TEXT_INACTIVE = C_( "symmetry axis options panel: position button: no axis pos.", u"None", ) _ALPHA_LABEL_TEXT = C_( "symmetry axis options panel: labels", u"Alpha:", ) _SYMMETRY_TYPE_TEXT = C_( "symmetry axis options panel: labels", u"Symmetry Type:", ) _SYMMETRY_ROT_LINES_TEXT = C_( "symmetry axis options panel: labels", u"Rotational lines:", ) def __init__(self): super(SymmetryEditOptionsWidget, self).__init__( xalign=0.5, yalign=0.5, xscale=1.0, yscale=1.0, ) self._symmetry_type_combo = None self._axis_sym_lines_entry = None from gui.application import get_app self.app = get_app() rootstack = self.app.doc.model.layer_stack x, y = rootstack.symmetry_center def pos_adj(start_val): return Gtk.Adjustment( value=start_val, upper=32000, lower=-32000, step_increment=1, page_increment=100, ) self._axis_pos_adj_x = pos_adj(x) self._xpos_cb_id = self._axis_pos_adj_x.connect( 'value-changed', self._axis_pos_adj_x_changed, ) self._axis_pos_adj_y = pos_adj(y) self._ypos_cb_id = self._axis_pos_adj_y.connect( 'value-changed', self._axis_pos_adj_y_changed, ) self._axis_angle = Gtk.Adjustment( value=rootstack.symmetry_angle, upper=180, lower=0, step_increment=1, page_increment=15, ) self._angle_cb_id = self._axis_angle.connect( "value-changed", self._angle_value_changed) self._axis_symmetry_lines = Gtk.Adjustment( value=rootstack.symmetry_lines, upper=50, lower=2, step_increment=1, page_increment=3, ) self._lines_cb_id = self._axis_symmetry_lines.connect( 'value-changed', self._axis_rot_symmetry_lines_changed, ) self._init_ui() rootstack.symmetry_state_changed += self._symmetry_state_changed_cb def _init_ui(self): # Layout grid row = 0 grid = Gtk.Grid() grid.set_border_width(gui.widgets.SPACING_CRAMPED) grid.set_row_spacing(gui.widgets.SPACING_CRAMPED) grid.set_column_spacing(gui.widgets.SPACING_CRAMPED) self.add(grid) row += 1 label = Gtk.Label(label=self._ALPHA_LABEL_TEXT) label.set_hexpand(False) label.set_halign(Gtk.Align.START) grid.attach(label, 0, row, 1, 1) scale = InputSlider() scale.set_range(0, 1) scale.set_round_digits(1) scale.set_draw_value(False) line_alpha = self.app.preferences.get(_ALPHA_PREFS_KEY, _DEFAULT_ALPHA) scale.set_value(line_alpha) scale.set_hexpand(True) scale.set_vexpand(False) scale.scale.connect("value-changed", self._scale_value_changed_cb) grid.attach(scale, 1, row, 1, 1) row += 1 store = Gtk.ListStore(int, str) rootstack = self.app.doc.model.layer_stack for _type in lib.tiledsurface.SYMMETRY_TYPES: store.append([_type, lib.tiledsurface.SYMMETRY_STRINGS[_type]]) self._symmetry_type_combo = Gtk.ComboBox() self._symmetry_type_combo.set_model(store) self._symmetry_type_combo.set_active(rootstack.symmetry_type) self._symmetry_type_combo.set_hexpand(True) cell = Gtk.CellRendererText() self._symmetry_type_combo.pack_start(cell, True) self._symmetry_type_combo.add_attribute(cell, "text", 1) self._type_cb_id = self._symmetry_type_combo.connect( 'changed', self._symmetry_type_combo_changed_cb ) label = Gtk.Label(label=self._SYMMETRY_TYPE_TEXT) label.set_hexpand(False) label.set_halign(Gtk.Align.START) grid.attach(label, 0, row, 1, 1) grid.attach(self._symmetry_type_combo, 1, row, 1, 1) row += 1 label = Gtk.Label(label=self._SYMMETRY_ROT_LINES_TEXT) label.set_hexpand(False) label.set_halign(Gtk.Align.START) self._axis_sym_lines_entry = Gtk.SpinButton( adjustment=self._axis_symmetry_lines, climb_rate=0.25 ) self._update_num_lines_sensitivity(rootstack.symmetry_type) grid.attach(label, 0, row, 1, 1) grid.attach(self._axis_sym_lines_entry, 1, row, 1, 1) row += 1 label = Gtk.Label(label=self._POSITION_LABEL_X_TEXT) label.set_hexpand(False) label.set_halign(Gtk.Align.START) entry = Gtk.SpinButton( adjustment=self._axis_pos_adj_x, climb_rate=0.25, digits=0 ) entry.set_hexpand(True) entry.set_vexpand(False) grid.attach(label, 0, row, 1, 1) grid.attach(entry, 1, row, 1, 1) row += 1 label = Gtk.Label(label=self._POSITION_LABEL_Y_TEXT) label.set_hexpand(False) label.set_halign(Gtk.Align.START) entry = Gtk.SpinButton( adjustment=self._axis_pos_adj_y, climb_rate=0.25, digits=0 ) entry.set_hexpand(True) entry.set_vexpand(False) grid.attach(label, 0, row, 1, 1) grid.attach(entry, 1, row, 1, 1) row += 1 label = Gtk.Label() label.set_hexpand(False) label.set_halign(Gtk.Align.START) self._angle_label = label self._update_angle_label() grid.attach(label, 0, row, 1, 1) scale = InputSlider(self._axis_angle) scale.set_draw_value(False) scale.set_hexpand(True) scale.set_vexpand(False) grid.attach(scale, 1, row, 1, 1) row += 1 button = Gtk.CheckButton() toggle_action = self.app.find_action("SymmetryActive") button.set_related_action(toggle_action) button.set_label(C_( "symmetry axis options panel: axis active checkbox", u'Enabled', )) button.set_hexpand(True) button.set_vexpand(False) grid.attach(button, 1, row, 2, 1) def _update_angle_label(self): self._angle_label.set_text( self._ANGLE_LABEL_TEXT % self._axis_angle.get_value() ) def _symmetry_state_changed_cb( self, stack, active, center, sym_type, sym_lines, sym_angle): if center: cx, cy = center with self._axis_pos_adj_x.handler_block(self._xpos_cb_id): self._axis_pos_adj_x.set_value(cx) with self._axis_pos_adj_y.handler_block(self._ypos_cb_id): self._axis_pos_adj_y.set_value(cy) if sym_type is not None: with self._symmetry_type_combo.handler_block(self._type_cb_id): self._symmetry_type_combo.set_active(sym_type) self._update_num_lines_sensitivity(sym_type) if sym_lines is not None: with self._axis_symmetry_lines.handler_block(self._lines_cb_id): self._axis_symmetry_lines.set_value(sym_lines) if sym_angle is not None: with self._axis_angle.handler_block(self._angle_cb_id): self._axis_angle.set_value(sym_angle) self._update_angle_label() def _update_num_lines_sensitivity(self, sym_type): self._axis_sym_lines_entry.set_sensitive( sym_type in (SymmetryRotational, SymmetrySnowflake) ) def _axis_pos_adj_x_changed(self, adj): self.app.doc.model.layer_stack.symmetry_x = int(adj.get_value()) def _axis_rot_symmetry_lines_changed(self, adj): self.app.doc.model.layer_stack.symmetry_lines = int(adj.get_value()) def _axis_pos_adj_y_changed(self, adj): self.app.doc.model.layer_stack.symmetry_y = int(adj.get_value()) def _angle_value_changed(self, adj): self._update_angle_label() self.app.doc.model.layer_stack.symmetry_angle = adj.get_value() def _symmetry_type_combo_changed_cb(self, combo): sym_type = combo.get_model()[combo.get_active()][0] self.app.doc.model.layer_stack.symmetry_type = sym_type def _scale_value_changed_cb(self, alpha_scale): self.app.preferences[_ALPHA_PREFS_KEY] = alpha_scale.get_value() for overlay in self._symmetry_overlays(): overlay.set_line_alpha(alpha_scale.get_value()) @staticmethod def _symmetry_overlays(): for tdw in gui.tileddrawwidget.TiledDrawWidget.get_visible_tdws(): for ov in tdw.display_overlays: if isinstance(ov, SymmetryOverlay): yield ov
def get_usage(self): return C_( "symmetry axis edit mode: mode description (tooltips)", u"Adjust the painting symmetry axis.", )
class ModeOptionsTool(workspace.SizedVBoxToolWidget): """Dockable panel showing options for the current mode This panel has a title and an icon reflecting the current mode, and displays its options widget if it has one: define an object method named ``get_options_widget()`` returning an arbitrary GTK widget. Singletons work well here, and are encouraged. ``get_options_widget()`` can also return `None` if the mode is a temporary mode which has no sensible options. In this case, any widget already displayed will not be replaced, which is particularly appropriate for modes which only persist for the length of time the mouse button is held, and stack on top of other modes. """ ## Class constants SIZED_VBOX_NATURAL_HEIGHT = workspace.TOOL_WIDGET_NATURAL_HEIGHT_SHORT tool_widget_icon_name = "mypaint-options-symbolic" tool_widget_title = C_( "options panel: tab tooltip: title", "Tool Options", ) tool_widget_description = C_( "options panel: tab tooltip: description", "Specialized settings for the current editing tool", ) __gtype_name__ = 'MyPaintModeOptionsTool' OPTIONS_MARKUP = C_( "options panel: header", "<b>{mode_name}</b>", ) NO_OPTIONS_MARKUP = C_( "options panel: body", "<i>No options available</i>", ) ## Method defs def __init__(self): """Construct, and connect internal signals & callbacks""" workspace.SizedVBoxToolWidget.__init__(self) from application import get_app self._app = get_app() self._app.doc.modes.changed += self._modestack_changed_cb self.set_border_width(3) self.set_spacing(6) # Placeholder in case a mode has no options label = Gtk.Label() label.set_markup(self.NO_OPTIONS_MARKUP) self._no_options_label = label # Container for an options widget exposed by the current mode self._mode_icon = Gtk.Image() label = Gtk.Label() label.set_text("<options-label>") self._options_label = label label.set_alignment(0.0, 0.5) label_hbox = Gtk.HBox() label_hbox.set_spacing(3) label_hbox.set_border_width(3) label_hbox.pack_start(self._mode_icon, False, False, 0) label_hbox.pack_start(self._options_label, True, True, 0) align = Gtk.Alignment.new(0.5, 0.5, 1.0, 1.0) align.set_padding(0, 0, 0, 0) align.set_border_width(3) self._options_bin = align self.pack_start(label_hbox, False, False, 0) self.pack_start(align, True, True, 0) self.connect("show", lambda *a: self._update_ui()) # Fallback self._update_ui_with_options_widget( self._no_options_label, self.tool_widget_title, self.tool_widget_icon_name, ) def _modestack_changed_cb(self, modestack, old, new): """Update the UI when the mode changes""" self._update_ui() def _update_ui(self): """Update the UI to show the options widget of the current mode""" mode = self._app.doc.modes.top self._update_ui_for_mode(mode) def _update_ui_for_mode(self, mode): # Get the new options widget try: get_options_widget = mode.get_options_widget except AttributeError: get_options_widget = None if get_options_widget: new_options = get_options_widget() else: new_options = self._no_options_label if not new_options: # Leave existing widget as-is, even if it's the default. # XXX maybe we should be doing something stack-based here? return icon_name = mode.get_icon_name() name = mode.get_name() self._update_ui_with_options_widget(new_options, name, icon_name) def _update_ui_with_options_widget(self, new_options, name, icon_name): old_options = self._options_bin.get_child() logger.debug("name: %r, icon name: %r", name, icon_name) if name: markup = self.OPTIONS_MARKUP.format( mode_name=lib.xml.escape(name), ) self._options_label.set_markup(markup) if icon_name: self._mode_icon.set_from_icon_name( icon_name, Gtk.IconSize.SMALL_TOOLBAR, ) # Options widget: only update if there's a change if new_options is not old_options: if old_options: old_options.hide() self._options_bin.remove(old_options) self._options_bin.add(new_options) new_options.show() self._options_bin.show_all()
def get_name(cls): return C_( "symmetry axis edit mode: mode name (tooltips)", u"Edit Symmetry Axis", )
class SymmetryEditMode (gui.mode.ScrollableModeMixin, gui.mode.DragMode): """Tool/mode for editing the axis of symmetry used when painting""" ## Class-level config ACTION_NAME = 'SymmetryEditMode' pointer_behavior = gui.mode.Behavior.EDIT_OBJECTS scroll_behavior = gui.mode.Behavior.CHANGE_VIEW # These will be overridden on enter() inactive_cursor = None active_cursor = None unmodified_persist = True permitted_switch_actions = set([ 'ShowPopupMenu', 'RotateViewMode', 'ZoomViewMode', 'PanViewMode', ]) _GRAB_SENSITIVITY = 8 # pixels # Statusbar stuff _STATUSBAR_CONTEXT = 'symmetry-mode' _STATUSBAR_CREATE_AXIS_MSG = C_( "symmetry axis edit mode: instructions shown in statusbar", u"Place axis", ) _STATUSBAR_MOVE_AXIS_MSG = C_( "symmetry axis edit mode: instructions shown in statusbar", u"Move axis", ) _STATUSBAR_DELETE_AXIS_MSG = C_( "symmetry axis edit mode: instructions shown in statusbar", u"Remove axis", ) # Options widget singleton _OPTIONS_WIDGET = None ## Info strings @classmethod def get_name(cls): return C_( "symmetry axis edit mode: mode name (tooltips)", u"Edit Symmetry Axis", ) def get_usage(self): return C_( "symmetry axis edit mode: mode description (tooltips)", u"Adjust the painting symmetry axis.", ) ## Initization and mode interface def __init__(self, **kwds): """Initialize.""" super(SymmetryEditMode, self).__init__(**kwds) from application import get_app app = get_app() self.app = app statusbar_cid = app.statusbar.get_context_id(self._STATUSBAR_CONTEXT) self._statusbar_context_id = statusbar_cid self._drag_start_axis = None self._drag_start_model_x = None self.zone = _EditZone.UNKNOWN self._last_msg_zone = None self._click_info = None self.button_pos = None self._entered_before = False self.line_alphafrac = 0.0 def enter(self, doc, **kwds): """Enter the mode""" super(SymmetryEditMode, self).enter(doc, **kwds) # Initialize/fetch cursors mkcursor = lambda name: doc.app.cursors.get_action_cursor( self.ACTION_NAME, name, ) self._move_cursors = {} self.cursor_remove = mkcursor(gui.cursor.Name.ARROW) self.cursor_add = mkcursor(gui.cursor.Name.ADD) self.cursor_normal = mkcursor(gui.cursor.Name.ARROW) # Turn on the axis, if it happens to be off right now if not self._entered_before: action = self.app.find_action("SymmetryActive") action.set_active(True) self._entered_before = True def _update_statusbar(self): if self.in_drag: return if self._last_msg_zone == self.zone: return statusbar = self.app.statusbar statusbar_cid = self._statusbar_context_id statusbar.remove_all(statusbar_cid) msgs = { _EditZone.CREATE_AXIS: self._STATUSBAR_CREATE_AXIS_MSG, _EditZone.MOVE_AXIS: self._STATUSBAR_MOVE_AXIS_MSG, _EditZone.DELETE_AXIS: self._STATUSBAR_DELETE_AXIS_MSG, } msg = msgs.get(self.zone, None) if msg: statusbar.push(statusbar_cid, msg) self._last_msg_zone = self.zone def get_options_widget(self): """Get the (class singleton) options widget""" cls = self.__class__ if cls._OPTIONS_WIDGET is None: widget = SymmetryEditOptionsWidget() cls._OPTIONS_WIDGET = widget return cls._OPTIONS_WIDGET ## Events and internals def button_press_cb(self, tdw, event): if self.zone in (_EditZone.CREATE_AXIS, _EditZone.DELETE_AXIS): button = event.button if button == 1 and event.type == Gdk.EventType.BUTTON_PRESS: self._click_info = (button, self.zone) return False return super(SymmetryEditMode, self).button_press_cb(tdw, event) def button_release_cb(self, tdw, event): if self._click_info is not None: button0, zone0 = self._click_info if event.button == button0: if self.zone == zone0: model = tdw.doc layer_stack = model.layer_stack if zone0 == _EditZone.DELETE_AXIS: layer_stack.symmetry_active = False elif zone0 == _EditZone.CREATE_AXIS: x, y = tdw.display_to_model(event.x, event.y) layer_stack.symmetry_axis = x layer_stack.symmetry_active = True self._click_info = None self._update_zone_and_cursor(tdw, event.x, event.y) return False return super(SymmetryEditMode, self).button_release_cb(tdw, event) def _update_zone_and_cursor(self, tdw, x, y): """Update UI & some internal zone flags from pointer position :param tdw: canvas widget :param x: cursor x position :param y: cursor y position See also: `SymmetryOverlay`. """ if self.in_drag: return old_zone = self.zone new_zone = None new_alphafrac = self.line_alphafrac xm, ym = tdw.display_to_model(x, y) model = tdw.doc layer_stack = model.layer_stack axis = layer_stack.symmetry_axis if not layer_stack.symmetry_active: self.active_cursor = self.cursor_add self.inactive_cursor = self.cursor_add new_zone = _EditZone.CREATE_AXIS # Button hits. # NOTE: the position is calculated by the related overlay, # in its paint() method. if new_zone is None and self.button_pos: bx, by = self.button_pos d = math.hypot(bx-x, by-y) if d <= gui.style.FLOATING_BUTTON_RADIUS: self.active_cursor = self.cursor_remove self.inactive_cursor = self.cursor_remove new_zone = _EditZone.DELETE_AXIS if new_zone is None: move_cursor_name, perp_dist = tdw.get_move_cursor_name_for_edge( (x, y), (axis, 0), (axis, 1000), tolerance=self._GRAB_SENSITIVITY, finite=False, ) if move_cursor_name: move_cursor = self._move_cursors.get(move_cursor_name) if not move_cursor: move_cursor = self.doc.app.cursors.get_action_cursor( self.ACTION_NAME, move_cursor_name, ) self._move_cursors[move_cursor_name] = move_cursor self.active_cursor = move_cursor self.inactive_cursor = move_cursor new_zone = _EditZone.MOVE_AXIS dfrac = lib.helpers.clamp( perp_dist / (10.0 * self._GRAB_SENSITIVITY), 0.0, 1.0, ) new_alphafrac = 1.0 - dfrac if new_zone is None: new_zone = _EditZone.UNKNOWN self.active_cursor = self.cursor_normal self.inactive_cursor = self.cursor_normal if new_zone != old_zone: self.zone = new_zone self._update_statusbar() tdw.queue_draw() elif new_alphafrac != self.line_alphafrac: tdw.queue_draw() self.line_alphafrac = new_alphafrac def motion_notify_cb(self, tdw, event): if not self.in_drag: self._update_zone_and_cursor(tdw, event.x, event.y) tdw.set_override_cursor(self.inactive_cursor) return super(SymmetryEditMode, self).motion_notify_cb(tdw, event) def drag_start_cb(self, tdw, event): model = tdw.doc layer_stack = model.layer_stack self._update_zone_and_cursor(tdw, event.x, event.y) if self.zone == _EditZone.MOVE_AXIS: x0, y0 = self.start_x, self.start_y self._drag_start_axis = int(round(layer_stack.symmetry_axis)) x0_m, y0_m = tdw.display_to_model(x0, y0) self._drag_start_model_x = x0_m return super(SymmetryEditMode, self).drag_start_cb(tdw, event) def drag_update_cb(self, tdw, event, dx, dy): if self.zone == _EditZone.MOVE_AXIS: x_m, y_m = tdw.display_to_model(event.x, event.y) axis = self._drag_start_axis + x_m - self._drag_start_model_x axis = int(round(axis)) if axis != self._drag_start_axis: model = tdw.doc layer_stack = model.layer_stack layer_stack.symmetry_axis = axis return super(SymmetryEditMode, self).drag_update_cb(tdw, event, dx, dy) def drag_stop_cb(self, tdw): if self.zone == _EditZone.MOVE_AXIS: tdw.queue_draw() return super(SymmetryEditMode, self).drag_stop_cb(tdw)
def confirm_destructive_action(self, title=None, confirm=None, offer_save=True): """Asks the user to confirm an action that might lose work. :param unicode title: Short question to ask the user. :param unicode confirm: Imperative verb for the "do it" button. :param bool offer_save: Set False to turn off the save checkbox. :rtype: bool :returns: True if the user allows the destructive action Phrase the title question tersely. In English/source, use title case for it, and with a question mark. Good examples are “Really Quit?”, or “Delete Everything?”. The title should always tell the user what destructive action is about to take place. If it is not specified, a default title is used. Use a single, specific, imperative verb for the confirm string. It should reflect the title question. This is used for the primary confirmation button, if specified. See the GNOME HIG for further guidelines on what to use here. This method doesn't bother asking if there's less than a handful of seconds of unsaved work. By default, that's 1 second. The build-time and runtime debugging flags make this period longer to allow more convenient development and testing. Ref: https://developer.gnome.org/hig/stable/dialogs.html.en """ if title is None: title = C_( "Destructive action confirm dialog: " "fallback title (normally overridden)", "Really Continue?") # Get an accurate assesment of how much change is unsaved. self.doc.model.sync_pending_changes() t = self.doc.model.unsaved_painting_time # This used to be 30, but see https://gna.org/bugs/?17955 # Then 8 by default, but Twitter users hate that too. t_bother = 1 if mypaintlib.heavy_debug: t_bother += 7 if os.environ.get("MYPAINT_DEBUG", False): t_bother += 7 logger.debug("Destructive action don't-bother period is %ds", t_bother) if t < t_bother: return True # Custom response codes. # The default ones are all negative ints. continue_response_code = 1 # Dialog setup. d = Gtk.MessageDialog( title=title, parent=self.app.drawWindow, type=Gtk.MessageType.QUESTION, flags=Gtk.DialogFlags.MODAL, ) # Translated strings for things cancel_btn_text = C_( "Destructive action confirm dialog: cancel button", u"_Cancel", ) save_to_scraps_first_text = C_( "Destructive action confirm dialog: save checkbox", u"_Save to Scraps first", ) if not confirm: continue_btn_text = C_( "Destructive action confirm dialog: " "fallback continue button (normally overridden)", u"Co_ntinue", ) else: continue_btn_text = confirm # Button setup. Cancel first, continue at end. d.add_button(cancel_btn_text, Gtk.ResponseType.CANCEL) d.add_button(continue_btn_text, continue_response_code) # Explanatory message. if self.filename: file_basename = os.path.basename(self.filename) else: file_basename = None warning_msg_tmpl = C_( "Destructive action confirm dialog: warning message", u"You risk losing {abbreviated_time} of unsaved painting. ") markup_tmpl = warning_msg_tmpl d.set_markup( markup_tmpl.format( abbreviated_time=lib.xml.escape( helpers.fmt_time_period_abbr(t)), current_file_name=lib.xml.escape(file_basename), )) # Checkbox for saving if offer_save: save1st_text = save_to_scraps_first_text save1st_cb = Gtk.CheckButton.new_with_mnemonic(save1st_text) save1st_cb.set_hexpand(False) save1st_cb.set_halign(Gtk.Align.END) save1st_cb.set_vexpand(False) save1st_cb.set_margin_top(12) save1st_cb.set_margin_bottom(12) save1st_cb.set_margin_start(12) save1st_cb.set_margin_end(12) save1st_cb.set_can_focus(False) # set back again in show handler d.connect( "show", self._destructive_action_dialog_show_cb, save1st_cb, ) save1st_cb.connect( "toggled", self._destructive_action_dialog_save1st_toggled_cb, d, ) vbox = d.get_content_area() vbox.set_spacing(0) vbox.set_margin_top(12) vbox.pack_start(save1st_cb, False, True, 0) # Get a response and handle it. d.set_default_response(Gtk.ResponseType.CANCEL) response_code = d.run() d.destroy() if response_code == continue_response_code: logger.debug("Destructive action confirmed") if offer_save and save1st_cb.get_active(): logger.info("Saving current canvas as a new scrap") self.save_scrap_cb(None) return True else: logger.debug("Destructive action cancelled") return False
class LayerBase (Renderable): """Base class defining the layer API Layers support the Renderable interface, and are rendered with the "render_*()" methods of their root layer stack. Layers are minimally aware of the tree structure they reside in, in that they contain a reference to the root of their tree for signalling purposes. Updates to the tree structure and to layers' graphical contents are announced via the RootLayerStack object representing the base of the tree. """ ## Class constants #: Forms the default name, may be suffixed per lib.naming consts. DEFAULT_NAME = C_( "layer default names", u"Layer", ) #: A string for the layer type. TYPE_DESCRIPTION = None PERMITTED_MODES = set(STANDARD_MODES) INITIAL_MODE = DEFAULT_MODE ## Construction, loading, other lifecycle stuff def __init__(self, name=None, **kwargs): """Construct a new layer :param name: The name for the new layer. :param **kwargs: Ignored. All layer subclasses must permit construction without parameters. """ super(LayerBase, self).__init__() # Defaults for the notifiable properties self._opacity = 1.0 self._name = name self._visible = True self._locked = False self._mode = self.INITIAL_MODE self._group_ref = None self._root_ref = None self._thumbnail = None #: True if the layer was marked as selected when loaded. self.initially_selected = False @classmethod def new_from_openraster(cls, orazip, elem, cache_dir, progress, root, x=0, y=0, **kwargs): """Reads and returns a layer from an OpenRaster zipfile This implementation just creates a new instance of its class and calls `load_from_openraster()` on it. This should suffice for all subclasses which support parameterless construction. """ layer = cls() layer.load_from_openraster( orazip, elem, cache_dir, progress, x=x, y=y, **kwargs ) return layer @classmethod def new_from_openraster_dir(cls, oradir, elem, cache_dir, progress, root, x=0, y=0, **kwargs): """Reads and returns a layer from an OpenRaster-like folder This implementation just creates a new instance of its class and calls `load_from_openraster_dir()` on it. This should suffice for all subclasses which support parameterless construction. """ layer = cls() layer.load_from_openraster_dir( oradir, elem, cache_dir, progress, x=x, y=y, **kwargs ) return layer def load_from_openraster(self, orazip, elem, cache_dir, progress, x=0, y=0, **kwargs): """Loads layer data from an open OpenRaster zipfile :param orazip: An OpenRaster zipfile, opened for extracting :type orazip: zipfile.ZipFile :param elem: <layer/> or <stack/> element to load (stack.xml) :type elem: xml.etree.ElementTree.Element :param cache_dir: Cache root dir for this document :param progress: Provides feedback to the user. :type progress: lib.feedback.Progress or None :param x: X offset of the top-left point for image data :param y: Y offset of the top-left point for image data :param **kwargs: Extensibility The base implementation loads the common layer flags from a `<layer/>` or `<stack/>` element, but does nothing more than that. Loading layer data from the zipfile or recursing into stack contents is deferred to subclasses. """ self._load_common_flags_from_ora_elem(elem) def load_from_openraster_dir(self, oradir, elem, cache_dir, progress, x=0, y=0, **kwargs): """Loads layer data from an OpenRaster-style folder. Parameters are the same as for load_from_openraster, with the following exception (replacing ``orazip``): :param unicode/str oradir: Folder with a .ORA-like tree structure. """ self._load_common_flags_from_ora_elem(elem) def _load_common_flags_from_ora_elem(self, elem): attrs = elem.attrib self.name = unicode(attrs.get('name', '')) compop = str(attrs.get('composite-op', '')) self.mode = ORA_MODES_BY_OPNAME.get(compop, DEFAULT_MODE) self.opacity = helpers.clamp(float(attrs.get('opacity', '1.0')), 0.0, 1.0) visible = attrs.get('visibility', 'visible').lower() self.visible = (visible != "hidden") locked = attrs.get("edit-locked", 'false').lower() self.locked = lib.xml.xsd2bool(locked) selected = attrs.get("selected", 'false').lower() self.initially_selected = lib.xml.xsd2bool(selected) def __deepcopy__(self, memo): """Returns an independent copy of the layer, for Duplicate Layer >>> from copy import deepcopy >>> orig = _StubLayerBase() >>> dup = deepcopy(orig) Everything about the returned layer must be a completely independent copy of the original layer. If the copy can be worked on, working on it must leave the original unaffected. This base implementation can be reused/extended by subclasses if they support zero-argument construction. It will use the derived class's snapshotting implementation (see `save_snapshot()` and `load_snapshot()`) to populate the copy. """ layer = self.__class__() layer.load_snapshot(self.save_snapshot()) return layer def clear(self): """Clears the layer""" pass ## Properties @property def group(self): """The group of the current layer. Returns None if the layer is not in a group. >>> from . import group >>> outer = group.LayerStack() >>> inner = group.LayerStack() >>> scribble = _StubLayerBase() >>> outer.append(inner) >>> inner.append(scribble) >>> outer.group is None True >>> inner.group == outer True >>> scribble.group == inner True """ if self._group_ref is not None: return self._group_ref() return None @group.setter def group(self, group): if group is None: self._group_ref = None else: self._group_ref = weakref.ref(group) @property def root(self): """The root of the layer tree structure Only RootLayerStack instances or None are permitted. You won't normally need to adjust this unless you're doing something fancy: it's automatically maintained by intermediate and root `LayerStack` elements in the tree whenever layers are added or removed from a rooted tree structure. >>> from . import tree >>> root = tree.RootLayerStack(doc=None) >>> layer = _StubLayerBase() >>> root.append(layer) >>> layer.root #doctest: +ELLIPSIS <RootLayerStack...> >>> layer.root is root True """ if self._root_ref is not None: return self._root_ref() return None @root.setter def root(self, newroot): if newroot is None: self._root_ref = None else: self._root_ref = weakref.ref(newroot) @property def opacity(self): """Opacity multiplier for the layer. Values must permit conversion to a `float` in [0, 1]. Changing this property issues ``layer_properties_changed`` and appropriate ``layer_content_changed`` notifications via the root layer stack if the layer is within a tree structure. Layers with a `mode` of `PASS_THROUGH_MODE` have immutable opacities: the value is always 100%. This restriction only applies to `LayerStack`s - i.e. layer groups - because those are the only kinds of layer which can be put into pass-through mode. """ return self._opacity @opacity.setter def opacity(self, opacity): opacity = helpers.clamp(float(opacity), 0.0, 1.0) if opacity == self._opacity: return if self.mode == PASS_THROUGH_MODE: warn("Cannot change the change the opacity multiplier " "of a layer group in PASS_THROUGH_MODE", RuntimeWarning, stacklevel=2) return self._opacity = opacity self._properties_changed(["opacity"]) # Note: not the full_redraw_bbox here. # Changing a layer's opacity multiplier alone cannot change the # calculated alpha of an outlying empty tile in the layer. # Those are always zero. Even if the layer has a fancy masking # mode, that won't affect redraws arising from mere opacity # multiplier updates. bbox = tuple(self.get_bbox()) self._content_changed(*bbox) @property def name(self): """The layer's name, for display purposes Values must permit conversion to a unicode string. If the layer is part of a tree structure, ``layer_properties_changed`` notifications will be issued via the root layer stack. In addition, assigned names may be corrected to be unique within the tree. """ return self._name @name.setter def name(self, name): if name is not None: name = unicode(name) else: name = self.DEFAULT_NAME oldname = self._name self._name = name root = self.root if root is not None: self._name = root.get_unique_name(self) if self._name != oldname: self._properties_changed(["name"]) @property def visible(self): """Whether the layer has a visible effect on its backdrop. Some layer modes normally have an effect even if the calculated alpha of a pixel is zero. This switch turns that off too. Values must permit conversion to a `bool`. Changing this property issues ``layer_properties_changed`` and appropriate ``layer_content_changed`` notifications via the root layer stack if the layer is within a tree structure. """ return self._visible @visible.setter def visible(self, visible): visible = bool(visible) if visible == self._visible: return self._visible = visible self._properties_changed(["visible"]) # Toggling the visibility flag always causes the mode to stop # or start having its normal effect. Need the full redraw bbox # so that outlying empty tiles will be updated properly. bbox = tuple(self.get_full_redraw_bbox()) self._content_changed(*bbox) @property def branch_visible(self): """Check whether the layer's branch is visible. Returns True if the layer's group and all of its parents are visible, False otherwise. Returns True if the layer is not in a group. >>> from . import group >>> outer = group.LayerStack() >>> inner = group.LayerStack() >>> scribble = _StubLayerBase() >>> outer.append(inner) >>> inner.append(scribble) >>> outer.branch_visible True >>> inner.branch_visible True >>> scribble.branch_visible True >>> outer.visible = False >>> outer.branch_visible True >>> inner.branch_visible False >>> scribble.branch_visible False """ group = self.group if group is None: return True return group.visible and group.branch_visible @property def locked(self): """Whether the layer is locked (immutable). Values must permit conversion to a `bool`. Changing this property issues `layer_properties_changed` via the root layer stack if the layer is within a tree structure. """ return self._locked @locked.setter def locked(self, locked): locked = bool(locked) if locked != self._locked: self._locked = locked self._properties_changed(["locked"]) @property def branch_locked(self): """Check whether the layer's branch is locked. Returns True if the layer's group or at least one of its parents is locked, False otherwise. Returns False if the layer is not in a group. >>> from . import group >>> outer = group.LayerStack() >>> inner = group.LayerStack() >>> scribble = _StubLayerBase() >>> outer.append(inner) >>> inner.append(scribble) >>> outer.branch_locked False >>> inner.branch_locked False >>> scribble.branch_locked False >>> outer.locked = True >>> outer.branch_locked False >>> inner.branch_locked True >>> scribble.branch_locked True """ group = self.group if group is None: return False return group.locked or group.branch_locked @property def mode(self): """How this layer combines with its backdrop. Values must permit conversion to an int, and must be permitted for the mode's class. Changing this property issues ``layer_properties_changed`` and appropriate ``layer_content_changed`` notifications via the root layer stack if the layer is within a tree structure. In addition to the modes supported by the base implementation, layer groups permit `lib.modes.PASS_THROUGH_MODE`, an additional mode where group contents are rendered as if their group were not present. Setting the mode to this value also sets the opacity to 100%. For layer groups, "Normal" mode implies group isolation internally. These semantics differ from those of OpenRaster and the W3C, but saving and loading applies the appropriate transformation. See also: PERMITTED_MODES. """ return self._mode @mode.setter def mode(self, mode): mode = int(mode) if mode not in self.PERMITTED_MODES: mode = DEFAULT_MODE if mode == self._mode: return # Forcing the opacity for layer groups here allows a redraw to # be subsumed. Only layer groups permit PASS_THROUGH_MODE. propchanges = [] if mode == PASS_THROUGH_MODE: self._opacity = 1.0 propchanges.append("opacity") # When changing the mode, the before and after states may have # different treatments of outlying empty tiles. Need the full # redraw bboxes of both states to ensure correct redraws. redraws = [self.get_full_redraw_bbox()] self._mode = mode redraws.append(self.get_full_redraw_bbox()) self._content_changed(*tuple(combine_redraws(redraws))) propchanges.append("mode") self._properties_changed(propchanges) ## Notifications def _content_changed(self, *args): """Notifies the root's content observers If this layer's root stack is defined, i.e. if it is part of a tree structure, the root's `layer_content_changed()` event method will be invoked with this layer and the supplied arguments. This reflects a region of pixels in the document changing. """ root = self.root if root is not None: root.layer_content_changed(self, *args) def _properties_changed(self, properties): """Notifies the root's layer properties observers If this layer's root stack is defined, i.e. if it is part of a tree structure, the root's `layer_properties_changed()` event method will be invoked with the layer and the supplied arguments. This reflects details about the layer like its name or its locked status changing. """ root = self.root if root is not None: root._notify_layer_properties_changed(self, set(properties)) ## Info methods def get_icon_name(self): """The name of the icon to display for the layer Ideally symbolic. A value of `None` means that no icon should be displayed. """ return None @property def effective_opacity(self): """The opacity used when rendering a layer: zero if invisible This must match the appearance produced by the layer's Renderable.get_render_ops() implementation when it is called with no explicit "layers" specification. The base class's effective opacity is zero because the base get_render_ops() is unimplemented. """ return 0.0 def get_alpha(self, x, y, radius): """Gets the average alpha within a certain radius at a point :param x: model X coordinate :param y: model Y coordinate :param radius: radius over which to average :rtype: float The return value is not affected by the layer opacity, effective or otherwise. This is used by `Document.pick_layer()` and friends to test whether there's anything significant present at a particular point. The default alpha at a point is zero. """ return 0.0 def get_bbox(self): """Returns the inherent (data) bounding box of the layer :rtype: lib.helpers.Rect The returned rectangle is generally tile-aligned, but isn't required to be. In this base implementation, the returned bbox is a zero-size default Rect, which is also how a full redraw is signalled. Subclasses should override this with a better implementation. The data bounding box is used for certain classes of redraws. See also get_full_redraw_bbox(). """ return helpers.Rect() def get_full_redraw_bbox(self): """Gets the full update notification bounding box of the layer :rtype: lib.helpers.Rect This is the appropriate bounding box for redraws if a layer-wide property like visibility or combining mode changes. Normally this is the layer's inherent data bounding box, which allows the GUI to skip outlying empty tiles when redrawing the layer stack. If instead the layer's compositing mode dictates that a calculated pixel alpha of zero would affect the backdrop regardless - something that's true of certain masking modes - then the returned bbox is a zero-size rectangle, which is the signal for a full redraw. See also get_bbox(). """ if self.mode in MODES_EFFECTIVE_AT_ZERO_ALPHA: return helpers.Rect() else: return self.get_bbox() def is_empty(self): """Tests whether the surface is empty Always true in the base implementation. """ return True def get_paintable(self): """True if this layer currently accepts painting brushstrokes Always false in the base implementation. """ return False def get_fillable(self): """True if this layer currently accepts flood fill Always false in the base implementation. """ return False def get_stroke_info_at(self, x, y): """Return the brushstroke at a given point :param x: X coordinate to pick from, in model space. :param y: Y coordinate to pick from, in model space. :rtype: lib.strokemap.StrokeShape or None Returns None for the base class. """ return None def get_last_stroke_info(self): """Return the most recently painted stroke :rtype lib.strokemap.StrokeShape or None Returns None for the base class. """ return None def get_mode_normalizable(self): """True if this layer can be normalized""" unsupported = set(MODES_EFFECTIVE_AT_ZERO_ALPHA) # Normalizing would have to make an infinite number of tiles unsupported.update(MODES_DECREASING_BACKDROP_ALPHA) # Normal mode cannot decrease the bg's alpha return self.mode not in unsupported def get_trimmable(self): """True if this layer currently accepts trim()""" return False def has_interesting_name(self): """True if the layer looks as if it has a user-assigned name Interesting means non-blank, and not the default name or a numbered version of it. This is used when merging layers: Merge Down is used on temporary layers a lot, and those probably have boring names. """ name = self._name if name is None or name.strip() == '': return False if name == self.DEFAULT_NAME: return False match = lib.naming.UNIQUE_NAME_REGEX.match(name) if match is not None: base = unicode(match.group("name")) if base == self.DEFAULT_NAME: return False return True ## Flood fill def flood_fill(self, x, y, color, bbox, tolerance, dst_layer=None): """Fills a point on the surface with a color See PaintingLayer.flood_fill() for parameters and semantics. The base implementation does nothing. """ pass ## Rendering def get_tile_coords(self): """Returns all data tiles in this layer :returns: All tiles with data :rtype: sequence This method should return a sequence listing the coordinates for all tiles with data in this layer. It is used when computing layer merges. Tile coordinates must be returned as ``(tx, ty)`` pairs. The base implementation returns an empty sequence. """ return [] ## Translation def get_move(self, x, y): """Get a translation/move object for this layer :param x: Model X position of the start of the move :param y: Model X position of the start of the move :returns: A move object """ raise NotImplementedError def translate(self, dx, dy): """Translate a layer non-interactively :param dx: Horizontal offset in model coordinates :param dy: Vertical offset in model coordinates :returns: full redraw bboxes for the move: ``[before, after]`` :rtype: list The base implementation uses `get_move()` and the object it returns. """ update_bboxes = [self.get_full_redraw_bbox()] move = self.get_move(0, 0) move.update(dx, dy) move.process(n=-1) move.cleanup() update_bboxes.append(self.get_full_redraw_bbox()) return update_bboxes ## Standard stuff def __repr__(self): """Simplified repr() of a layer""" if self.name: return "<%s %r>" % (self.__class__.__name__, self.name) else: return "<%s>" % (self.__class__.__name__) def __nonzero__(self): """Layers are never false in Py2.""" return self.__bool__() def __bool__(self): """Layers are never false in Py3. >>> sample = _StubLayerBase() >>> bool(sample) True """ return True def __eq__(self, layer): """Two layers are only equal if they are the same object This is meaningful during layer repositions in the GUI, where shallow copies are used. """ return self is layer def __hash__(self): """Return a hash for the layer (identity only)""" return id(self) ## Saving def save_as_png(self, filename, *rect, **kwargs): """Save to a named PNG file :param filename: filename to save to :param *rect: rectangle to save, as a 4-tuple :param **kwargs: passthrough opts for underlying implementations :rtype: Gdk.Pixbuf The base implementation does nothing. """ pass def save_to_openraster(self, orazip, tmpdir, path, canvas_bbox, frame_bbox, **kwargs): """Saves the layer's data into an open OpenRaster ZipFile :param orazip: a `zipfile.ZipFile` open for write :param tmpdir: path to a temp dir, removed after the save :param path: Unique path of the layer, for encoding in filenames :type path: tuple of ints :param canvas_bbox: Bounding box of all layers, absolute coords :type canvas_bbox: tuple :param frame_bbox: Bounding box of the image being saved :type frame_bbox: tuple :param **kwargs: Keyword args used by the save implementation :returns: element describing data written :rtype: xml.etree.ElementTree.Element There are three bounding boxes which need to considered. The inherent bbox of the layer as returned by `get_bbox()` is always tile aligned and refers to absolute model coordinates, as is `canvas_bbox`. All of the above bbox's coordinates are defined relative to the canvas origin. However, when saving, the data written must be translated so that `frame_bbox`'s top left corner defines the origin (0, 0), of the saved OpenRaster file. The width and height of `frame_bbox` determine the saved image's dimensions. More than one file may be written to the zipfile. The etree element returned should describe everything that was written. Paths must be unique sequences of ints, but are not necessarily valid RootLayerStack paths. It's faked for the normally unaddressable background layer right now, for example. """ raise NotImplementedError def _get_stackxml_element(self, tag, x=None, y=None): """Internal: get a basic etree Element for .ora saving""" elem = ET.Element(tag) attrs = elem.attrib if self.name: attrs["name"] = str(self.name) if x is not None: attrs["x"] = str(x) if y is not None: attrs["y"] = str(y) attrs["opacity"] = str(self.opacity) if self.initially_selected: attrs["selected"] = "true" if self.locked: attrs["edit-locked"] = "true" if self.visible: attrs["visibility"] = "visible" else: attrs["visibility"] = "hidden" # NOTE: This *will* be wrong for the PASS_THROUGH_MODE case. # NOTE: LayerStack will need to override this attr. mode_info = lib.mypaintlib.combine_mode_get_info(self.mode) if mode_info is not None: compop = mode_info.get("name") if compop is not None: attrs["composite-op"] = str(compop) return elem ## Painting symmetry axis def set_symmetry_state(self, active, center_x, center_y, symmetry_type, rot_symmetry_lines): """Set the surface's painting symmetry axis and active flag. :param bool active: Whether painting should be symmetrical. :param int center_x: X coord of the axis of symmetry. :param int center_y: Y coord of the axis of symmetry. :param int symmetry_type: symmetry type that will be applied if active :param int rot_symmetry_lines: number of rotational symmetry lines for angle dependent symmetry modes. The symmetry axis is only meaningful to paintable layers. Received strokes are reflected along the line ``x=center_x`` when symmetrical painting is active. This method is used by RootLayerStack only, propagating a central shared flag and value to all layers. The base implementation does nothing. """ pass ## Snapshot def save_snapshot(self): """Snapshots the state of the layer, for undo purposes The returned data should be considered opaque, useful only as a memento to be restored with load_snapshot(). """ return LayerBaseSnapshot(self) def load_snapshot(self, sshot): """Restores the layer from snapshot data""" sshot.restore_to_layer(self) ## Thumbnails @property def thumbnail(self): """The layer's cached preview thumbnail. :rtype: GdkPixbuf.Pixbuf or None Thumbnail pixbufs are always 256x256 pixels, and correspond to the data bounding box of the layer only. See also: render_thumbnail(). """ return self._thumbnail def update_thumbnail(self): """Safely updates the cached preview thumbnail. This method updates self.thumbnail using render_thumbnail() and the data bounding box, and eats any NotImplementedErrors. This is used by the layer stack to keep the preview thumbnail up to date. It is called automatically after layer data is changed and stable for a bit, so there is normally no need to call it in client code. """ try: self._thumbnail = self.render_thumbnail( self.get_bbox(), alpha=True, ) except NotImplementedError: self._thumbnail = None def render_thumbnail(self, bbox, **options): """Renders a 256x256 thumb of the layer in an arbitrary bbox. :param tuple bbox: Bounding box to make a thumbnail of. :param **options: Passed to RootLayerStack.render_layer_preview(). :rtype: GtkPixbuf or None Use the thumbnail property if you just want a reasonably up-to-date preview thumbnail for a single layer. See also: RootLayerStack.render_layer_preview(). """ root = self.root if root is None: return None return root.render_layer_preview(self, bbox=bbox, **options) ## Trimming def trim(self, rect): """Trim the layer to a rectangle, discarding data outside it :param rect: A trimming rectangle in model coordinates :type rect: tuple (x, y, w, h) The base implementation does nothing. """ pass
def _call_doc_load_method(self, method, arg, argdesc, is_import): """Internal: common GUI aspects of loading or importing files. Calls a document model loader method (on lib.document.Document) with the given argument. Catches common loading exceptions and shows appropriate error messages. """ prefs = self.app.preferences display_colorspace_setting = prefs["display.colorspace"] statusbar = self.app.statusbar statusbar_cid = self._statusbar_context_id statusbar.remove_all(statusbar_cid) if is_import: message = C_( "file handling: import layers: during file loads (statusbar)", u"Importing layers from {files_summary}…").format( files_summary=argdesc, ) else: message = C_("file handling: open: during load (statusbar)", u"Loading “{file_basename}”…").format( file_basename=argdesc, ) statusbar.push(statusbar_cid, message) try: method( arg, feedback_cb=self.gtk_main_tick, convert_to_srgb=(display_colorspace_setting == "srgb"), ) except (FileHandlingError, AllocationError, MemoryError) as e: statusbar.remove_all(statusbar_cid) if is_import: self.app.show_transient_message( C_( "file handling: import layers failed (statusbar)", u"Could not load {files_summary}.", ).format(files_summary=argdesc, )) else: self.app.show_transient_message( C_( "file handling: open failed (statusbar)", u"Could not load “{file_basename}”.", ).format(file_basename=argdesc, )) self.app.message_dialog(unicode(e), type=Gtk.MessageType.ERROR) return False else: statusbar.remove_all(statusbar_cid) if is_import: self.app.show_transient_message( C_( "file handling: import layers success (statusbar)", u"Imported layers from {files_summary}.", ).format(files_summary=argdesc, )) else: self.app.show_transient_message( C_( "file handling: open success (statusbar)", u"Loaded “{file_basename}”.", ).format(file_basename=argdesc, )) return True
def picking_status_text(self): """The statusbar text to use during the grab.""" return C_( "context picker: statusbar text during grab", u"Pick brushstroke settings, stroke color, and layer…", )
def _save_doc_to_file(self, filename, doc, export=False, statusmsg=True, **options): """Saves a document to one or more files :param filename: The base filename to save :param gui.document.Document doc: Controller for the document to save :param bool export: True if exporting :param **options: Pass-through options This method handles logging, statusbar messages, and alerting the user to when the save failed. See also: `lib.document.Document.save()`. """ thumbnail_pixbuf = None prefs = self.app.preferences display_colorspace_setting = prefs["display.colorspace"] options['save_srgb_chunks'] = (display_colorspace_setting == "srgb") if statusmsg: statusbar = self.app.statusbar statusbar_cid = self._statusbar_context_id statusbar.remove_all(statusbar_cid) file_basename = os.path.basename(filename) if export: during_tmpl = C_("file handling: during export (statusbar)", u"Exporting to “{file_basename}”…") else: during_tmpl = C_("file handling: during save (statusbar)", u"Saving “{file_basename}”…") statusbar.push(statusbar_cid, during_tmpl.format(file_basename=file_basename, )) try: x, y, w, h = doc.model.get_bbox() if w == 0 and h == 0: w, h = tiledsurface.N, tiledsurface.N # TODO: Add support for other sizes thumbnail_pixbuf = doc.model.save(filename, feedback_cb=self.gtk_main_tick, **options) self.lastsavefailed = False except (FileHandlingError, AllocationError, MemoryError) as e: if statusmsg: statusbar.remove_all(statusbar_cid) if export: failed_tmpl = C_( "file handling: export failure (statusbar)", u"Failed to export to “{file_basename}”.", ) else: failed_tmpl = C_( "file handling: save failure (statusbar)", u"Failed to save “{file_basename}”.", ) self.app.show_transient_message( failed_tmpl.format(file_basename=file_basename, )) self.lastsavefailed = True self.app.message_dialog(unicode(e), type=Gtk.MessageType.ERROR) else: if statusmsg: statusbar.remove_all(statusbar_cid) file_location = os.path.abspath(filename) multifile_info = '' if "multifile" in options: multifile_info = " (basis; used multiple .XXX.ext names)" if not export: logger.info('Saved to %r%s', file_location, multifile_info) else: logger.info('Exported to %r%s', file_location, multifile_info) if statusmsg: if export: success_tmpl = C_( "file handling: export success (statusbar)", u"Exported to “{file_basename}” successfully.", ) else: success_tmpl = C_( "file handling: save success (statusbar)", u"Saved “{file_basename}” successfully.", ) self.app.show_transient_message( success_tmpl.format(file_basename=file_basename, )) return thumbnail_pixbuf
from lib.color import HCYColor from lib.color import HSVColor import gui.uicolor from .util import clamp from lib.palette import Palette import lib.alg as geom from .paletteview import palette_load_via_dialog from .paletteview import palette_save_via_dialog from lib.gettext import C_ from lib.pycompat import xrange PREFS_MASK_KEY = "colors.hcywheel.mask.gamuts" PREFS_ACTIVE_KEY = "colors.hcywheel.mask.active" MASK_EDITOR_HELP_URI = C_( "Online help pages", u"https://github.com/mypaint/mypaint/wiki/" u"v1.2-HCY-Wheel-and-Gamut-Mask-Editor") class MaskableWheelMixin(object): """Provides wheel widgets with maskable areas. For use with implementations of `HueSaturationWheelAdjusterMixin`. Concrete implementations can be masked so that they ignore clicks outside certain color areas. If the mask is active, clicks inside the mask shapes are treated as normal, but clicks outside them are remapped to a point on the nearest edge of the nearest shape. This can be useful for artists who wish to plan the color gamut of their artwork in advance. http://gurneyjourney.blogspot.com/2011/09/part-1-gamut-masking-method.html http://gurneyjourney.blogspot.com/2008/01/color-wheel-masking-part-1.html
from lib.layer.data import BackgroundLayer from lib.meta import Compatibility, PREREL, MYPAINT_VERSION from lib.modes import MODE_STRINGS, set_default_mode from lib.mypaintlib import CombineNormal, CombineSpectralWGM from lib.mypaintlib import combine_mode_get_info from lib.gettext import C_ logger = getLogger(__name__) FILE_WARNINGS = { Compatibility.INCOMPATIBLE: 'ui.file_compat_warning_severe', Compatibility.PARTIALLY: 'ui.file_compat_warning_mild', } _FILE_OPEN_OPTIONS = [ ('', C_("File Load Compat Options", "Based on file")), (C1X, C_("Prefs Dialog|Compatibility", "1.x")), (C2X, C_("Prefs Dialog|Compatibility", "2.x")), ] FILE_WARNING_MSGS = { Compatibility.INCOMPATIBLE: C_( "file compatibility warning", # TRANSLATORS: This is probably a rare warning, and it will not # TRANSLATORS: really be shown at all before the release of 3.0 u"“{filename}” was saved with <b>MyPaint {new_version}</b>." " It may be <b>incompatible</b> with <b>MyPaint {current_version}</b>." "\n\n" "Editing this file with this version of MyPaint is not guaranteed" " to work, and may even result in crashes." "\n\n"
def get_properties_description(cls): return C_( "HCY Wheel color adjuster page: properties tooltip.", u"Set gamut mask.", )
def incompatible_ora_warning_dialog( comp_type, prerel, filename, target_version, app): # Skip the dialog if the user has disabled the warning # for this level of incompatibility warn = app.preferences.get(FILE_WARNINGS[comp_type], True) if not warn: return True # Toggle allowing users to disable future warnings directly # in the dialog, this is configurable in the settings too. # The checkbutton code is pretty much copied from the filehandling # save-to-scrap checkbutton; a lot of duplication. skip_warning_text = C_( "Version compat warning toggle", u"Don't show this warning again" ) skip_warning_button = Gtk.CheckButton.new() skip_warning_button.set_label(skip_warning_text) skip_warning_button.set_hexpand(False) skip_warning_button.set_vexpand(False) skip_warning_button.set_halign(Gtk.Align.END) skip_warning_button.set_margin_top(12) skip_warning_button.set_margin_bottom(12) skip_warning_button.set_margin_start(12) skip_warning_button.set_margin_end(12) skip_warning_button.set_can_focus(False) def skip_warning_toggled(checkbut): app.preferences[FILE_WARNINGS[comp_type]] = not checkbut.get_active() app.preferences_window.compat_preferences.update_ui() skip_warning_button.connect("toggled", skip_warning_toggled) def_msg = "Invalid key, report this! key={key}".format(key=comp_type) msg_markup = FILE_WARNING_MSGS.get(comp_type, def_msg).format( filename=filename, new_version=target_version, current_version=MYPAINT_VERSION ) + "\n\n" + OPEN_ANYWAY d = Gtk.MessageDialog( transient_for=app.drawWindow, buttons=Gtk.ButtonsType.NONE, modal=True, message_type=Gtk.MessageType.WARNING, ) d.set_markup(msg_markup) vbox = d.get_content_area() vbox.set_spacing(0) vbox.set_margin_top(12) vbox.pack_start(skip_warning_button, False, True, 0) d.add_button(Gtk.STOCK_NO, Gtk.ResponseType.REJECT) d.add_button(Gtk.STOCK_YES, Gtk.ResponseType.ACCEPT) d.set_default_response(Gtk.ResponseType.REJECT) # Without this, the check button takes initial focus def show_checkbut(*args): skip_warning_button.show() skip_warning_button.set_can_focus(True) d.connect("show", show_checkbut) response = d.run() d.destroy() return response == Gtk.ResponseType.ACCEPT
def get_page_description(cls): return C_( "HCY Wheel color adjuster page: description for tooltips etc.", u"Set the color using cylindrical hue/chroma/luma space. " u"The circular slices are equiluminant.", )
class CompatFileBehavior(config.CompatFileBehaviorConfig): """ Holds data and functions related to per-file choice of compat mode """ _CFBC = config.CompatFileBehaviorConfig _OPTIONS = [ _CFBC.ALWAYS_1X, _CFBC.ALWAYS_2X, _CFBC.UNLESS_PIGMENT_LAYER_1X, ] _LABELS = { _CFBC.ALWAYS_1X: ( C_( "Prefs Dialog|Compatibility", # TRANSLATORS: One of the options for the # TRANSLATORS: "When Not Specified in File" # TRANSLATORS: compatibility setting. "Always open in 1.x mode" ) ), _CFBC.ALWAYS_2X: ( C_( "Prefs Dialog|Compatibility", # TRANSLATORS: One of the options for the # TRANSLATORS: "When Not Specified in File" # TRANSLATORS: compatibility setting. "Always open in 2.x mode" ) ), _CFBC.UNLESS_PIGMENT_LAYER_1X: ( C_( "Prefs Dialog|Compatibility", # TRANSLATORS: One of the options for the # TRANSLATORS: "When Not Specified in File" # TRANSLATORS: compatibility setting. "Open in 1.x mode unless file contains pigment layers" ) ), } def __init__(self, combobox, prefs): self.combo = combobox self.prefs = prefs options_store = Gtk.ListStore() options_store.set_column_types((str, str)) for option in self._OPTIONS: options_store.append((option, self._LABELS[option])) combobox.set_model(options_store) cell = Gtk.CellRendererText() combobox.pack_start(cell, True) combobox.add_attribute(cell, 'text', 1) self.update_ui() combobox.connect('changed', self.changed_cb) def update_ui(self): self.combo.set_active_id(self.prefs[self.SETTING]) def changed_cb(self, combo): active_id = self.combo.get_active_id() self.prefs[self.SETTING] = active_id @staticmethod def get_compat_mode(setting, root_elem, default): """ Get the compat mode to use for a file The decision is based on the given file behavior setting and the layer stack xml. """ # If more options are added, rewrite to use separate classes. if setting == CompatFileBehavior.ALWAYS_1X: return C1X elif setting == CompatFileBehavior.ALWAYS_2X: return C2X elif setting == CompatFileBehavior.UNLESS_PIGMENT_LAYER_1X: if has_pigment_layers(root_elem): logger.info("Pigment layer found!") return C2X else: return C1X else: msg = "Unknown file compat setting: {setting}, using default mode." logger.warning(msg.format(setting=setting)) return default
def __templates(self): Y = 0.5 H = 1 - 0.05 # Reusable shapes... atmos_triad = [(H, 0.95, Y), ((H + 0.275) % 1, 0.55, Y), ((1 + H - 0.275) % 1, 0.55, Y)] def __coffin(h): # Hexagonal coffin shape with the foot end at the centre # of the wheel. shape = [] shape.append(((h + 0.25) % 1, 0.03, Y)) shape.append(((h + 1 - 0.25) % 1, 0.03, Y)) shape.append(((h + 0.01) % 1, 0.95, Y)) shape.append(((h + 1 - 0.01) % 1, 0.95, Y)) shape.append(((h + 0.04) % 1, 0.70, Y)) shape.append(((h + 1 - 0.04) % 1, 0.70, Y)) return shape def __complement_blob(h): # Small pentagonal blob at the given hue, used for an organic- # looking dab of a complementary hue. shape = [] shape.append(((h + 0.015) % 1, 0.94, Y)) shape.append(((h + 0.985) % 1, 0.94, Y)) shape.append(((h + 0.035) % 1, 0.71, Y)) shape.append(((h + 0.965) % 1, 0.71, Y)) shape.append(((h) % 1, 0.54, Y)) return shape templates = [] templates.append( (C_( "HCY Gamut Mask template name", u"Atmospheric Triad", ), C_( "HCY Gamut Mask template description", "Moody and subjective, defined by one dominant primary " "and two primaries which are less intense.", ), [deepcopy(atmos_triad)])) templates.append( (C_( "HCY Gamut Mask template name", u"Shifted Triad", ), C_( "HCY Gamut Mask template description", u"Weighted more strongly towards the dominant color.", ), [[(H, 0.95, Y), ((H + 0.35) % 1, 0.4, Y), ((1 + H - 0.35) % 1, 0.4, Y)]])) templates.append((C_( "HCY Gamut Mask template name", u"Complementary", ), C_( "HCY Gamut Mask template description", u"Contrasting opposites, " u"balanced by having central neutrals " u"between them on the color wheel.", ), [[((H + 0.005) % 1, 0.9, Y), ((H + 0.995) % 1, 0.9, Y), ((H + 0.250) % 1, 0.1, Y), ((H + 0.750) % 1, 0.1, Y), ((H + 0.505) % 1, 0.9, Y), ((H + 0.495) % 1, 0.9, Y)]])) templates.append( (C_( "HCY Gamut Mask template name", u"Mood and Accent", ), C_( "HCY Gamut Mask template description", u"One main range of colors, " u"with a complementary accent for " u"variation and highlights.", ), [deepcopy(atmos_triad), __complement_blob(H + 0.5)])) templates.append( (C_( "HCY Gamut Mask template name", u"Split Complementary", ), C_( "HCY Gamut Mask template description", u"Two analogous colors and a complement to them, " u"with no secondary colors between them.", ), [__coffin(H + 0.5), __coffin(1 + H - 0.1), __coffin(H + 0.1)])) return templates
def _layer_description_markup(layer): """GMarkup text description of a layer, used in the list.""" name_markup = None description = None if layer is None: name_markup = escape(lib.layer.PlaceholderLayer.DEFAULT_NAME) description = C_( "Layers: description: no layer (\"never happens\" condition!)", u"?layer", ) elif layer.name is None: name_markup = escape(layer.DEFAULT_NAME) else: name_markup = escape(layer.name) if layer is not None: desc_parts = [] if isinstance(layer, lib.layer.LayerStack): name_markup = "<i>{}</i>".format(name_markup) # Mode (if it's interesting) if layer.mode in lib.modes.MODE_STRINGS: if layer.mode != lib.modes.DEFAULT_MODE: s, d = lib.modes.MODE_STRINGS[layer.mode] desc_parts.append(s) else: desc_parts.append(C_( "Layers: description parts: unknown mode (fallback str!)", u"?mode", )) # Visibility and opacity (if interesting) if not layer.visible: desc_parts.append(C_( "Layers: description parts: layer hidden", u"Hidden", )) elif layer.opacity < 1.0: desc_parts.append(C_( "Layers: description parts: opacity percentage", u"%d%% opaque" % (round(layer.opacity * 100),) )) # Locked flag (locked is interesting) if layer.locked: desc_parts.append(C_( "Layers dockable: description parts: layer locked flag", u"Locked", )) # Description of the layer's type. # Currently always used, for visual rhythm reasons, but it goes # on the end since it's perhaps the least interesting info. if layer.TYPE_DESCRIPTION is not None: desc_parts.append(layer.TYPE_DESCRIPTION) else: desc_parts.append(C_( "Layers: description parts: unknown type (fallback str!)", u"?type", )) # Stitch it all together if desc_parts: description = C_( "Layers dockable: description parts joiner text", u", ", ).join(desc_parts) else: description = None if description is None: markup_template = C_( "Layers dockable: markup for a layer with no description", u"{layer_name}", ) else: markup_template = C_( "Layers dockable: markup for a layer with a description", '<span size="smaller">{layer_name}\n' '<span size="smaller" alpha="50%">{layer_description}</span>' '</span>' ) markup = markup_template.format( layer_name=name_markup, layer_description=escape(description), ) return markup
# the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. """Stuff for making names and keeping them unique.""" import re from lib.gettext import C_ from lib.pycompat import unicode # TRANSLATORS: UNIQUE_NAME_TEMPLATE. Must match its regex. # TRANSLATORS: Leave this untranslated if you are unsure. # TRANSLATORS: Change only if your lang *REQUIRES* a different order or # TRANSLATORS: if raw digits and a space aren't enough. UNIQUE_NAME_TEMPLATE = C_( "unique names: serial number needed: template", u'{name} {number}', ) # TRANSLATORS: UNIQUE_NAME_REGEX. Must match its template. # TRANSLATORS: Leave this untranslated if you are unsure. UNIQUE_NAME_REGEX = re.compile(C_( "unique names: regex matching a string with a serial number", u'^(?P<name>.*?)\\s+(?P<number>\\d+)$', )) def make_unique_name(name, existing, start=1, always_number=None): """Ensures that a name is unique. :param unicode name: Name to be made unique. :param existing: An existing list or set of names.
) _TASK_SUPPORT = C_( "About dialog: credits: tasks: user support", u"support" ) _TASK_OUTREACH = C_( "About dialog: credits: tasks: outreach (social media, ads?)", u"outreach" ) _TASK_COMMUNITY = C_( "About dialog: credits: tasks: running or building a community", u"community" ) _TASK_COMMA = C_( "About dialog: credits: tasks: joiner punctuation", u", ", ) # List contributors in order of their appearance. # The author's name is always written in their native script, # and is not marked for translation. It may also have: # transcriptions (Latin, English-ish) in brackets following, and/or # a quoted ’nym in Latin script. # For <given(s)> <surname(s)> combinations, # a quoted publicly-known alias may go after the given name. # TODO: Simplify/unify how the dialog is built. # - This should really be built from a giant matrix. # - Each task type should determine a tab of the about dialog # - Contributors will still appear on multiple tabs, # - but that'd be automatic now
import os import platform from gi.repository import Gtk from gi.repository import GdkPixbuf from gi.repository import GLib import cairo from lib.gettext import C_ import lib.meta from lib.xml import escape ## Program-related string constants COPYRIGHT_STRING = C_( "About dialog: copyright statement", u"Copyright (C) 2005-2016\n" u"Martin Renold and the MyPaint Development Team") WEBSITE_URI = "http://mypaint.org" LICENSE_SUMMARY = C_( "About dialog: license summary", u"This program is free software; you can redistribute it and/or modify " u"it under the terms of the GNU General Public License as published by " u"the Free Software Foundation; either version 2 of the License, or " u"(at your option) any later version.\n" u"\n" u"This program is distributed in the hope that it will be useful, " u"but WITHOUT ANY WARRANTY. See the COPYING file for more details.") ## Credits-related string constants # Strings for specific tasks, all translated
def _init_ui(self): app = self.app # Dialog for showing and editing the axis value directly buttons = (Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT) dialog = gui.windowing.Dialog( app, C_( "symmetry axis options panel: axis position dialog: window title", u"Axis Position", ), app.drawWindow, buttons=buttons, ) dialog.connect('response', self._axis_pos_dialog_response_cb) grid = Gtk.Grid() grid.set_border_width(gui.widgets.SPACING_LOOSE) grid.set_column_spacing(gui.widgets.SPACING) grid.set_row_spacing(gui.widgets.SPACING) label = Gtk.Label(self._POSITION_LABEL_TEXT) label.set_hexpand(False) label.set_vexpand(False) grid.attach(label, 0, 0, 1, 1) entry = Gtk.SpinButton( adjustment=self._axis_pos_adj, climb_rate=0.25, digits=0 ) entry.set_hexpand(True) entry.set_vexpand(False) grid.attach(entry, 1, 0, 1, 1) dialog_content_box = dialog.get_content_area() dialog_content_box.pack_start(grid, True, True, 0) self._axis_pos_dialog = dialog # Layout grid row = 0 grid = Gtk.Grid() grid.set_border_width(gui.widgets.SPACING_CRAMPED) grid.set_row_spacing(gui.widgets.SPACING_CRAMPED) grid.set_column_spacing(gui.widgets.SPACING_CRAMPED) self.add(grid) row += 1 label = Gtk.Label(self._ALPHA_LABEL_TEXT) label.set_hexpand(False) label.set_halign(Gtk.Align.START) grid.attach(label, 0, row, 1, 1) scale = Gtk.Scale.new_with_range( orientation = Gtk.Orientation.HORIZONTAL, min = 0, max = 1, step = 0.1, ) scale.set_draw_value(False) line_alpha = self.app.preferences.get(_ALPHA_PREFS_KEY, _DEFAULT_ALPHA) scale.set_value(line_alpha) scale.set_hexpand(True) scale.set_vexpand(False) scale.connect("value-changed", self._scale_value_changed_cb) grid.attach(scale, 1, row, 1, 1) row += 1 label = Gtk.Label(self._POSITION_LABEL_TEXT) label.set_hexpand(False) label.set_halign(Gtk.Align.START) button = Gtk.Button(self._POSITION_BUTTON_TEXT_INACTIVE) button.set_vexpand(False) button.connect("clicked", self._axis_pos_button_clicked_cb) button.set_hexpand(True) button.set_vexpand(False) grid.attach(label, 0, row, 1, 1) grid.attach(button, 1, row, 1, 1) self._axis_pos_button = button row += 1 button = Gtk.CheckButton() toggle_action = self.app.find_action("SymmetryActive") button.set_related_action(toggle_action) button.set_label(C_( "symmetry axis options panel: axis active checkbox", u'Enabled', )) button.set_hexpand(True) button.set_vexpand(False) grid.attach(button, 1, row, 2, 1) self._axis_active_button = button
def rename_button_clicked_cb(self, button): """Rename the current brush; user is prompted for a new name""" bm = self.app.brushmanager src_brush = bm.selected_brush if not src_brush.name: dialogs.error( self, C_( 'brush settings editor: rename brush: error message', 'No brush selected!', )) return src_name_pp = src_brush.name.replace('_', ' ') dst_name = dialogs.ask_for_name( self, C_( "brush settings editor: rename brush: dialog title", "Rename Brush", ), src_name_pp, ) if not dst_name: return dst_name = dst_name.replace(' ', '_') # ensure we don't overwrite an existing brush by accident dst_deleted = None for group, brushes in bm.groups.iteritems(): for b2 in brushes: if b2.name == dst_name: if group == brushmanager.DELETED_BRUSH_GROUP: dst_deleted = b2 else: msg = C_( 'brush settings editor: ' 'rename brush: error message', 'A brush with this name already exists!', ) dialogs.error(self, msg) return logger.info("Renaming brush %r -> %r", src_brush.name, dst_name) if dst_deleted: deleted_group = brushmanager.DELETED_BRUSH_GROUP deleted_brushes = bm.get_group_brushes(deleted_group) deleted_brushes.remove(dst_deleted) bm.brushes_changed(deleted_brushes) # save src as dst src_name = src_brush.name src_brush.name = dst_name src_brush.save() src_brush.name = src_name # load dst dst_brush = brushmanager.ManagedBrush(bm, dst_name, persistent=True) dst_brush.load() # Replace src with dst, but keep src in the deleted list if it # is a stock brush self._delete_brush(src_brush, replacement=dst_brush) bm.select_brush(dst_brush)
def setup_locale_combobox(locale_combo): # Set up locales liststore locale_liststore = Gtk.ListStore() default_loc = C_( "Language preferences menu - default option", # TRANSLATORS: This option means that MyPaint will try to use # TRANSLATORS: the language the system tells it to use. "System Language") # Default value - use the system locale locale_liststore.set_column_types((str, str, str)) locale_liststore.append((None, default_loc, default_loc)) loc_names = lib.localecodes.LOCALE_DICT # Base language - US english base_locale = "en_US" locale_liststore.append((base_locale, loc_names[base_locale][0], "")) # Separator locale_liststore.append((None, None, None)) supported_locales = lib.config.supported_locales def tuplify(loc): if loc in loc_names: name_en, name_native = loc_names[loc] return loc, name_en, name_native else: logger.warning("Locale name not found: (%s)", loc) return loc, loc, loc # Sort alphabetically on english name of language for i in sorted(map(tuplify, supported_locales), key=lambda a: a[1]): locale_liststore.append(i) def sep_func(model, it): return model[it][1] is None def render_language_names(_, name_cell, model, it): locale, lang_en, lang_nat = model[it][:3] # Mark default with bold font if locale is None: name_cell.set_property("markup", "<b>{lang_en}</b>".format(lang_en=lang_en)) # If a language does not have its native spelling # available, only show its name in english. elif lang_en == lang_nat or lang_nat == "": name_cell.set_property("text", lang_en) else: name_cell.set_property( "text", C_( "Prefs Dialog|View|Interface - menu entries", # TRANSLATORS: lang_en is the english name of the language # TRANSLATORS: lang_nat is the native name of the language # TRANSLATORS: in that _same language_. # TRANSLATORS: This can just be copied most of the time. "{lang_en} - ({lang_nat})").format(lang_en=lang_en, lang_nat=lang_nat)) # Remove the existing cell renderer locale_combo.clear() locale_combo.set_row_separator_func(sep_func) cell = Gtk.CellRendererText() locale_combo.pack_start(cell, True) locale_combo.set_cell_data_func(cell, render_language_names) locale_combo.set_model(locale_liststore)
def _save_doc_to_file(self, filename, doc, export=False, statusmsg=True, **options): """Saves a document to one or more files :param filename: The base filename to save :param gui.document.Document doc: Controller for the document to save :param bool export: True if exporting :param **options: Pass-through options This method handles logging, statusbar messages, and alerting the user to when the save failed. See also: `lib.document.Document.save()`. """ thumbnail_pixbuf = None prefs = self.app.preferences display_colorspace_setting = prefs["display.colorspace"] options['save_srgb_chunks'] = (display_colorspace_setting == "srgb") if statusmsg: statusbar = self.app.statusbar statusbar_cid = self._statusbar_context_id statusbar.remove_all(statusbar_cid) file_basename = os.path.basename(filename) if export: during_tmpl = C_( "file handling: during export (statusbar)", u"Exporting to “{file_basename}”…" ) else: during_tmpl = C_( "file handling: during save (statusbar)", u"Saving “{file_basename}”…" ) statusbar.push(statusbar_cid, during_tmpl.format( file_basename = file_basename, )) try: x, y, w, h = doc.model.get_bbox() if w == 0 and h == 0: w, h = tiledsurface.N, tiledsurface.N # TODO: Add support for other sizes thumbnail_pixbuf = doc.model.save( filename, feedback_cb=self.gtk_main_tick, **options ) self.lastsavefailed = False except (FileHandlingError, AllocationError, MemoryError) as e: if statusmsg: statusbar.remove_all(statusbar_cid) if export: failed_tmpl = C_( "file handling: export failure (statusbar)", u"Failed to export to “{file_basename}”.", ) else: failed_tmpl = C_( "file handling: save failure (statusbar)", u"Failed to save “{file_basename}”.", ) self.app.show_transient_message(failed_tmpl.format( file_basename = file_basename, )) self.lastsavefailed = True self.app.message_dialog(unicode(e), type=Gtk.MessageType.ERROR) else: if statusmsg: statusbar.remove_all(statusbar_cid) file_location = os.path.abspath(filename) multifile_info = '' if "multifile" in options: multifile_info = " (basis; used multiple .XXX.ext names)" if not export: logger.info('Saved to %r%s', file_location, multifile_info) else: logger.info('Exported to %r%s', file_location, multifile_info) if statusmsg: if export: success_tmpl = C_( "file handling: export success (statusbar)", u"Exported to “{file_basename}” successfully.", ) else: success_tmpl = C_( "file handling: save success (statusbar)", u"Saved “{file_basename}” successfully.", ) self.app.show_transient_message(success_tmpl.format( file_basename = file_basename, )) return thumbnail_pixbuf
) _TASK_SUPPORT = C_( "About dialog: credits: tasks: user support", u"support" ) _TASK_OUTREACH = C_( "About dialog: credits: tasks: outreach (social media, ads?)", u"outreach" ) _TASK_COMMUNITY = C_( "About dialog: credits: tasks: running or building a community", u"community" ) _TASK_COMMA = C_( "About dialog: credits: tasks: joiner punctuation", u", ", ) # List contributors in order of their appearance. # The author's name is always written in their native script, # and is not marked for translation. It may also have: # transcriptions (Latin, English-ish) in brackets following, and/or # a quoted ’nym in Latin script. # For <given(s)> <surname(s)> combinations, # a quoted publicly-known alias may go after the given name. _AUTHOR_CREDITS = [ u"Martin Renold (%s)" % _TASK_PROGRAMMING, u"Yves Combe (%s)" % _TASK_PORTING, u"Popolon (%s)" % _TASK_PROGRAMMING, u"Clement Skau (%s)" % _TASK_PROGRAMMING,