1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437
|
# -*- coding: utf-8 -*-
# This file is part of MyPaint.
# Copyright (C) 2014-2019 by the MyPaint Development Team
# 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 __future__ import division, print_function
import weakref
import os.path
import os
import gui.document # noqa
from lib.gettext import gettext as _
from lib.gettext import C_
from lib.layer.core import LayerBase # noqa
from lib.gibindings import Gio
from lib.gibindings import Pango
from lib.gibindings import Gtk
import lib.xml
import logging
logger = logging.getLogger(__name__)
## UI string consts
# TRANSLATORS: Statusbar message when "Edit in External App"
# TRANSLATORS: (for a layer) is successfully initiated.
_LAUNCH_SUCCESS_MSG = _(u"Launched {app_name} to edit layer “{layer_name}”")
# 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.
Relies on the existence of an environment variable named
``MYPAINT_ENV_CLEAN``; if it is undefined, this is a no-op.
If it is defined, it should contain a string of K=V statements
separated by newlines.
Currently this is only used by (and necessary for) appimages.
:param ctx: a launch context
"""
clean = os.environ.get(_MYP_ENV_NAME, None)
if clean is None:
return
dirty = ctx.get_environment()
# Unfortunately there is no function for replacing the env in one go,
# so we loop through and unset/set each name/pair instead.
# If startup time needs to be reduced by (at most) a few ms:s,
# the dirty/clean list/dict should both be safe to cache.
for varname, __ in (s.split('=', 1) for s in dirty):
ctx.unsetenv(varname)
for varname, value in (s.split('=', 1) for s in clean.split('\n')):
ctx.setenv(varname, value)
## Class definitions
class OpenWithDialog (Gtk.Dialog):
"""Choose an app from those recommended for a type"""
ICON_SIZE = Gtk.IconSize.DIALOG
SPECIFIC_FILE_MSG = _(
u"MyPaint needs to edit a file of type \u201c{type_name}\u201d "
u"({content_type}). What application should it use?"
)
GENERIC_MSG = _(
u"What application should MyPaint use for editing files of "
u"type \u201c{type_name}\u201d ({content_type})?"
)
def __init__(self, content_type, specific_file=False):
Gtk.Dialog.__init__(self)
self.set_title(_(u"Open With…"))
self.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
self.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK)
self.set_default_response(Gtk.ResponseType.CANCEL)
self.set_response_sensitive(Gtk.ResponseType.OK, False)
self.connect("show", self._show_cb)
content_box = self.get_content_area()
content_box.set_border_width(12)
content_box.set_spacing(12)
msg_template = self.GENERIC_MSG
if specific_file:
msg_template = self.SPECIFIC_FILE_MSG
msg_text = msg_template.format(
content_type=content_type,
type_name=Gio.content_type_get_description(content_type),
)
msg_label = Gtk.Label(label=msg_text)
msg_label.set_single_line_mode(False)
msg_label.set_line_wrap(True)
msg_label.set_alignment(0.0, 0.5)
content_box.pack_start(msg_label, False, False, 0)
default_app = Gio.AppInfo.get_default_for_type(content_type, False)
default_iter = None
app_list_store = Gtk.ListStore(object)
apps = Gio.AppInfo.get_all_for_type(content_type)
for app in apps:
if not app.should_show():
continue
row_iter = app_list_store.append([app])
if default_iter is not None:
continue
if default_app and Gio.AppInfo.equal(app, default_app):
default_iter = row_iter
# TreeView to show available apps for this content type
view = Gtk.TreeView()
view.set_model(app_list_store)
view.set_headers_clickable(False)
view.set_headers_visible(False)
view.connect("row-activated", self._row_activated_cb)
view_scrolls = Gtk.ScrolledWindow()
view_scrolls.set_shadow_type(Gtk.ShadowType.IN)
view_scrolls.add(view)
view_scrolls.set_size_request(375, 225)
content_box.pack_start(view_scrolls, True, True, 0)
# Column 0: application icon
cell = Gtk.CellRendererPixbuf()
col = Gtk.TreeViewColumn(_("Icon"), cell)
col.set_cell_data_func(cell, self._app_icon_datafunc)
icon_size_ok, icon_w, icon_h = Gtk.icon_size_lookup(self.ICON_SIZE)
if icon_size_ok:
col.set_min_width(icon_w)
col.set_expand(False)
col.set_resizable(False)
view.append_column(col)
# Column 1: application name
cell = Gtk.CellRendererText()
cell.set_property("ellipsize", Pango.EllipsizeMode.END)
cell.set_property("editable", False)
col = Gtk.TreeViewColumn(_("Name"), cell)
col.set_cell_data_func(cell, self._app_name_datafunc)
col.set_expand(True)
col.set_min_width(150)
view.append_column(col)
# Selection: mode and initial value
selection = view.get_selection()
selection.set_mode(Gtk.SelectionMode.SINGLE)
if default_iter:
selection.select_iter(default_iter)
self.set_default_response(Gtk.ResponseType.OK)
self.set_response_sensitive(Gtk.ResponseType.OK, True)
selection.connect("changed", self._selection_changed_cb)
# Results go here
self.selected_appinfo = default_app #: The app the user chose
def _show_cb(self, dialog):
content_box = self.get_content_area()
content_box.show_all()
def _app_name_datafunc(self, col, cell, model, it, data):
app = model.get_value(it, 0)
name = app.get_display_name()
desc = app.get_description()
if desc is not None:
markup_template = "<b>{name}</b>\n{description}"
else:
markup_template = "<b>{name}</b>\n<i>({description})</i>"
desc = _("no description")
markup = markup_template.format(
name=lib.xml.escape(name),
description=lib.xml.escape(desc),
)
cell.set_property("markup", markup)
def _app_icon_datafunc(self, col, cell, model, it, data):
app = model.get_value(it, 0)
icon = app.get_icon()
cell.set_property("gicon", icon)
cell.set_property("stock-size", self.ICON_SIZE)
def _row_activated_cb(self, view, treepath, column):
model = view.get_model()
treeiter = model.get_iter(treepath)
if treeiter:
appinfo = model.get_value(treeiter, 0)
self.selected_appinfo = appinfo
self.response(Gtk.ResponseType.OK)
def _selection_changed_cb(self, selection):
model, selected_iter = selection.get_selected()
if selected_iter:
appinfo = model.get_value(selected_iter, 0)
self.selected_appinfo = appinfo
self.set_response_sensitive(Gtk.ResponseType.OK, True)
self.set_default_response(Gtk.ResponseType.OK)
else:
self.selected_appinfo = None
self.set_response_sensitive(Gtk.ResponseType.OK, False)
self.set_default_response(Gtk.ResponseType.CANCEL)
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 LayerBase layer: Layer to start editing
This starts the edit procedure by launching a chosen
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.
"""
logger.info("Starting external edit for %r...", layer.name)
try:
new_edit_tempfile = layer.new_external_edit_tempfile
except AttributeError:
return
file_path = new_edit_tempfile()
if os.name == 'nt':
self._begin_file_edit_using_startfile(file_path, layer)
# Avoid segfault: https://github.com/mypaint/mypaint/issues/531
# Upstream: https://bugzilla.gnome.org/show_bug.cgi?id=758248
else:
self._begin_file_edit_using_gio(file_path, layer)
self._begin_file_monitoring_using_gio(file_path, layer)
def _begin_file_edit_using_startfile(self, file_path, layer):
logger.info("Using os.startfile() to edit %r", file_path)
os.startfile(file_path, "edit")
self._doc.app.show_transient_message(
_LAUNCH_SUCCESS_MSG.format(
app_name = "(unknown Win32 app)", # FIXME: needs i18n
layer_name = layer.name,
))
def _begin_file_edit_using_gio(self, file_path, layer):
logger.info("Using OpenWithDialog and GIO to open %r", file_path)
logger.debug("Querying file path for info")
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)
logger.debug("Creating and launching external layer edit dialog")
dialog = OpenWithDialog(file_type, specific_file=True)
dialog.set_modal(True)
dialog.set_transient_for(self._doc.app.drawWindow)
dialog.set_position(Gtk.WindowPosition.CENTER)
response = dialog.run()
dialog.destroy()
if response != Gtk.ResponseType.OK:
self._doc.app.show_transient_message(
_LAUNCH_CANCELLED_MSG.format(
layer_name=layer.name,
))
return
appinfo = dialog.selected_appinfo
assert appinfo is not None
disp = self._doc.tdw.get_display()
launch_ctx = disp.get_app_launch_context()
restore_env(launch_ctx)
logger.debug(
"Launching %r with %r (user-chosen 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,
))
def _begin_file_monitoring_using_gio(self, file_path, layer):
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
file_basename = os.path.basename(file_path)
try:
model.update_layer_from_external_edit_tempfile(
layer,
file_path,
)
except Exception as ex:
logger.error(
"Loading tempfile for %r (%r) failed: %r",
layer,
file_path,
str(ex),
)
status_msg = _LAYER_UPDATE_FAILED_MSG.format(
file_basename = file_basename,
layer_name = layer.name,
)
else:
status_msg = _LAYER_UPDATED_MSG.format(
file_basename = file_basename,
layer_name = layer.name,
)
self._doc.app.show_transient_message(status_msg)
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] = []
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG)
dialog = OpenWithDialog("image/svg+xml")
# dialog = OpenWithDialog("text/plain")
# dialog = OpenWithDialog("image/jpeg")
# dialog = OpenWithDialog("application/xml")
response = dialog.run()
if response == Gtk.ResponseType.OK:
app_name = dialog.selected_appinfo.get_name()
logger.debug("AppInfo chosen: %r", app_name)
else:
logger.debug("Dialog was cancelled")
|