Skip to content

Commit

Permalink
Support for vector layers (SVG, edited externally)
Browse files Browse the repository at this point in the history
Adds the ability to create vector layers and edit them in Inkscape (if
that's your default SVG file opener). Some polishing may be needed, such as
offering a choice from the DE's recommended SVG apps, but for now this
should suffice and allow testing.

Fixes #57.

There seems to be a consensus on the CREATE mailing list that arbitrary
image-like (or otherwise) layer file data is welcome in OpenRaster
containers, and that this is the correct interpretation of the current
spec. SVG is one of the more useful of these formats, since we gain useful
text and graphics layers.

To use this feature, create a new vector layer, then edit it with your
favourite external app via the menu. When you save the temporary file, a
new entry is created on the MyPaint undo stack.

Rendering in MyPaint is ultimately via librsvg, meaning you may need to
simplify Inkscape SVG files which rely on Inkscape-specific extensions
quite a lot. If nothing displays at all, make sure your librsvg install
ships a libpixbufloader-svg.so and that gdk-pixbuf-query-loaders lists SVG.
  • Loading branch information
achadwick committed Aug 6, 2014
2 parents 1b78f8d + 5d50cdc commit ae34386
Show file tree
Hide file tree
Showing 10 changed files with 598 additions and 150 deletions.
37 changes: 28 additions & 9 deletions gui/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import GdkPixbuf
from gi.repository import GLib

import lib.document
from lib import brush
Expand Down Expand Up @@ -299,6 +300,13 @@ def __init__(self, filenames, app_datapath, app_extradatapath,
}
self._subwindows = {}

# Statusbar init
statusbar = self.builder.get_object("app_statusbar")
self.statusbar = statusbar
context_id = statusbar.get_context_id("transient-message")
self._transient_msg_context_id = context_id
self._transient_msg_remove_timeout_id = None

# Show main UI.
self.drawWindow.show_all()
GObject.idle_add(self._at_application_start, filenames, fullscreen)
Expand Down Expand Up @@ -563,6 +571,26 @@ def message_dialog(self, text, type=Gtk.MessageType.INFO, flags=0,
d.run()
d.destroy()

def show_transient_message(self, text, seconds=5):
"""Display a brief, impermanent status message"""
context_id = self._transient_msg_context_id
self.statusbar.remove_all(context_id)
self.statusbar.push(context_id, text)
timeout_id = self._transient_msg_remove_timeout_id
if timeout_id is not None:
GLib.source_remove(timeout_id)
timeout_id = GLib.timeout_add_seconds(
interval=seconds,
function=self._transient_msg_remove_timer_cb,
)
self._transient_msg_remove_timeout_id = timeout_id

def _transient_msg_remove_timer_cb(self, *_ignored):
context_id = self._transient_msg_context_id
self.statusbar.remove_all(context_id)
self._transient_msg_remove_timeout_id = None
return False

def pick_color_at_pointer(self, widget, size=3):
"""Set the brush colour from the current pointer position on screen.
Expand Down Expand Up @@ -646,15 +674,6 @@ def _subwindow_hide_cb(self, subwindow):
if action and action.get_active():
action.set_active(False)


## Special UI areas

@property
def statusbar(self):
"""Returns the application statusbar."""
return self.builder.get_object("app_statusbar")


## Workspace callbacks

def _floating_window_created_cb(self, workspace, floatwin):
Expand Down
51 changes: 45 additions & 6 deletions gui/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from gtk import gdk
from gtk import keysyms
from gettext import gettext as _
from gi.repository import Gio

import lib.layer
from lib.helpers import clamp
Expand All @@ -39,6 +40,7 @@
import colorpicker # purely for registration
import gui.freehand
import gui.buttonmap
import gui.externalapp


## Class definitions
Expand Down Expand Up @@ -319,6 +321,8 @@ def __init__(self, app, tdw, model, leader=None):
# Brush settings observers
self.app.brush.observers.append(self._brush_settings_changed_cb)

# External file edit requests
self._layer_edit_manager = gui.externalapp.LayerEditManager(self)

def _init_actions(self):
"""Internal: initializes action groups & state reflection"""
Expand Down Expand Up @@ -382,6 +386,9 @@ def _init_actions(self):
self._update_current_layer_actions: [
layerstack.current_path_updated,
],
self._update_external_layer_edit_actions: [
layerstack.current_path_updated,
],
}
for observer_method, events in observed_events.items():
for event in events:
Expand Down Expand Up @@ -958,7 +965,7 @@ def _update_current_layer_actions(self, *_ignored):
"RemoveLayer",
"ClearLayer",
"DuplicateLayer",
"NewLayerBG", # but not FG so the button still works
"NewPaintingLayerAbove", # but not below so the button still works
"LayerMode", # the modes submenu
"RenameLayer",
"LayerVisibleToggle",
Expand Down Expand Up @@ -1105,21 +1112,26 @@ def _update_layer_bubble_actions(self, *_ignored):
## Simple (non-toggle) layer commands

def new_layer_cb(self, action):
"""New layer GtkAction callback
"""Callback: new layer
Where the new layer is created, and the layer's type, depends on
the action's name.
Invoked for ``NewLayerFG`` and ``NewLayerBG``: where the new
layer is created depends on the action's name.
"""
layers = self.model.layer_stack
path = layers.current_path
vector = "Vector" in action.get_name()
if not path:
path = (-1,)
elif action.get_name() == 'NewLayerFG':
elif 'Above' in action.get_name():
path = layers.path_above(path, insert=True)
else:
path = layers.path_below(path, insert=True)
assert path is not None
self.model.add_layer(path)
x, y = None, None
if vector:
x, y = self.tdw.get_center_model_coords()
self.model.add_layer(path, vector=vector, x=x, y=y)
self.layerblink_state.activate(action)

def merge_layer_down_cb(self, action):
Expand Down Expand Up @@ -1829,3 +1841,30 @@ def mode_stack_changed_cb(self, mode):
if not action.get_active():
action.set_active(True)

## External layer editing support

def begin_external_layer_edit_cb(self, action):
"""Callback: edit the current layer in an external app"""
layer = self.model.layer_stack.current
self._layer_edit_manager.begin(layer)

def commit_external_layer_edit_cb(self, action):
"""Callback: Commit the current layer's ongoing external edit
Exposed as an extra action just in case automatic monitoring
fails on a particular platform. Normally the manager commits
saved changes automatically.
"""
layer = self.model.layer_stack.current
self._layer_edit_manager.commit(layer)

def _update_external_layer_edit_actions(self, *_ignored):
"""Update the External Layer Edit actions' sensitivities"""
app = self.app
rootstack = self.model.layer_stack
current = rootstack.current
can_begin = hasattr(current, "new_external_edit_tempfile")
can_commit = hasattr(current, "load_from_external_edit_tempfile")
app.find_action("BeginExternalLayerEdit").set_sensitive(can_commit)
app.find_action("CommitExternalLayerEdit").set_sensitive(can_commit)
171 changes: 171 additions & 0 deletions gui/externalapp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# -*- encoding: utf-8 -*-
# This file is part of MyPaint.
# Copyright (C) 2014 by Andrew Chadwick <a.t.chadwick@gmail.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.

"""External application launching and monitoring"""


## Imports

from logging import getLogger
logger = getLogger(__name__)
import weakref

from gettext import gettext as _

from gi.repository import Gio


## UI string consts

_LAUNCH_SUCCESS_MSG = _(u"Launched {app_name} to edit layer “{layer_name}”")
_LAUNCH_FAILED_MSG = _(u"Error: failed to launch {app_name} to edit "
u"layer “{layer_name}”")
_LAYER_UPDATED_MSG = _(u"Updated layer “{layer_name}” with external edits")


## Class definitions

class LayerEditManager (object):
"""Launch external apps to edit layers, monitoring file changes"""

def __init__(self, doc):
"""Initialize, attached to a document controller
:param gui.document.Document doc: Owning controller
"""
super(LayerEditManager, self).__init__()
self._doc = doc
self._active_edits = []

def begin(self, layer):
"""Begin editing a layer in an external application
:param lib.layer.LayerBase layer: Layer to start editing
This starts the edit procedure by launching the default
application for a tempfile requested from the layer. The file is
monitored for changes, which are loaded back into the associated
layer automatically.
Each invocation of this callback from ``EditLayerExternally``
creates a new tempfile for editing the layer, and launches a new
instance of the external app. Previous tempfiles are removed
from monitoring in favour of the new one.
"""

try:
new_edit_tempfile = layer.new_external_edit_tempfile
except AttributeError:
return
file_path = new_edit_tempfile()
file = Gio.File.new_for_path(file_path)
flags = Gio.FileQueryInfoFlags.NONE
attr = Gio.FILE_ATTRIBUTE_STANDARD_FAST_CONTENT_TYPE
file_info = file.query_info(attr, flags, None)
file_type = file_info.get_attribute_string(attr)
appinfo = Gio.AppInfo.get_default_for_type(file_type, False)
if not appinfo:
logger.error("No default app registered for %r", file_type)
return

disp = self._doc.tdw.get_display()
launch_ctx = disp.get_app_launch_context()

if not appinfo.supports_files():
logger.error(
"The default handler for %r, %r, only supports "
"opening files by URI",
appinfo.get_name(),
file_path,
)
return

logger.debug(
"Launching %r with %r (default app for %r)",
appinfo.get_name(),
file_path,
file_type,
)
launched_app = appinfo.launch([file], launch_ctx)
if not launched_app:
self._doc.app.show_transient_message(
_LAUNCH_FAILED_MSG.format(
app_name=appinfo.get_name(),
layer_name=layer.name,
))
logger.error(
"Failed to launch %r with %r",
appinfo.get_name(),
file_path,
)
return
self._doc.app.show_transient_message(
_LAUNCH_SUCCESS_MSG.format(
app_name=appinfo.get_name(),
layer_name=layer.name,
))
self._cleanup_stale_monitors(added_layer=layer)
logger.debug("Begin monitoring %r for changes (layer=%r)",
file_path, layer)
file = Gio.File.new_for_path(file_path)
file_mon = file.monitor_file(Gio.FileMonitorFlags.NONE, None)
file_mon.connect("changed", self._file_changed_cb)
edit_info = (file_mon, weakref.ref(layer), file, file_path)
self._active_edits.append(edit_info)

def commit(self, layer):
"""Commit a layer's ongoing external edit"""
logger.debug("Commit %r's current tempfile",
layer)
self._cleanup_stale_monitors()
for mon, layer_ref, file, file_path in self._active_edits:
if layer_ref() is not layer:
continue
model = self._doc.model
self._doc.app.show_transient_message(
_LAYER_UPDATED_MSG.format(
layer_name=layer.name,
))
model.update_layer_from_external_edit_tempfile(layer, file_path)
return

def _file_changed_cb(self, mon, file1, file2, event_type):
self._cleanup_stale_monitors()
if event_type == Gio.FileMonitorEvent.DELETED:
logger.debug("File %r was deleted", file1.get_path())
self._cleanup_stale_monitors(deleted_file=file1)
return
if event_type == Gio.FileMonitorEvent.CHANGES_DONE_HINT:
logger.debug("File %r was changed", file1.get_path())
for a_mon, layer_ref, file, file_path in self._active_edits:
if a_mon is mon:
layer = layer_ref()
self.commit(layer)
return

def _cleanup_stale_monitors(self, added_layer=None, deleted_file=None):
for i in reversed(range(len(self._active_edits))):
mon, layer_ref, file, file_path = self._active_edits[i]
layer = layer_ref()
stale = False
if layer is None:
logger.info("Removing monitor for garbage-collected layer")
stale = True
elif layer is added_layer:
logger.info("Replacing monitor for already-tracked layer")
stale = True
if file is deleted_file:
logger.info("Removing monitor for deleted file")
stale = True
if stale:
mon.cancel()
logger.info("File %r is no longer monitored", file.get_path())
self._active_edits[i:i+1] = []
Loading

2 comments on commit ae34386

@blurymind
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is absolutely fantastic!!!! :D

I can use inkscape to do smooth inking!!!

You are awesome.

@blurymind
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be even more amazing if we could convert a vector layer into bitmap.. you can do it actually by using "merge visible"

Please sign in to comment.