summaryrefslogtreecommitdiffstats
path: root/share/extensions/inkex/gui
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 18:24:48 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 18:24:48 +0000
commitcca66b9ec4e494c1d919bff0f71a820d8afab1fa (patch)
tree146f39ded1c938019e1ed42d30923c2ac9e86789 /share/extensions/inkex/gui
parentInitial commit. (diff)
downloadinkscape-upstream.tar.xz
inkscape-upstream.zip
Adding upstream version 1.2.2.upstream/1.2.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'share/extensions/inkex/gui')
-rw-r--r--share/extensions/inkex/gui/README.md15
-rw-r--r--share/extensions/inkex/gui/__init__.py50
-rw-r--r--share/extensions/inkex/gui/app.py176
-rw-r--r--share/extensions/inkex/gui/asyncme.py330
-rw-r--r--share/extensions/inkex/gui/listview.py562
-rw-r--r--share/extensions/inkex/gui/pixmap.py346
-rw-r--r--share/extensions/inkex/gui/tester.py78
-rw-r--r--share/extensions/inkex/gui/window.py201
8 files changed, 1758 insertions, 0 deletions
diff --git a/share/extensions/inkex/gui/README.md b/share/extensions/inkex/gui/README.md
new file mode 100644
index 0000000..fb176da
--- /dev/null
+++ b/share/extensions/inkex/gui/README.md
@@ -0,0 +1,15 @@
+# What is inkex.gui
+
+This module is a Gtk based GUI creator. It helps extensions launch their own user interfaces and can help make sure those interfaces will work on all platforms that inkscape ships with.
+
+# How do I use it
+
+You can create custom user interfaces by using the Gnome glade builder program. Once you have a layout of all th widgets you want, you then make a GtkApp and Window classes inside your python program, when the GtkApp is run, th windows will be shown to the user and all signals specified for the widgets will call functions on your window class.
+
+Please see the existing code for examples of how to do this.
+
+# This is a fork
+
+This code was originally part of the package 'gtkme' which contained some part we didn't want to ship. Such as ubuntu indicators and internet pixmaps. To avoid conflicts, our stripped down version of the gtkme module is renamed and placed inside of inkscape's inkex module.
+
+
diff --git a/share/extensions/inkex/gui/__init__.py b/share/extensions/inkex/gui/__init__.py
new file mode 100644
index 0000000..27e1b0b
--- /dev/null
+++ b/share/extensions/inkex/gui/__init__.py
@@ -0,0 +1,50 @@
+#
+# Copyright 2011-2022 Martin Owens <doctormo@geek-2.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 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>
+#
+# pylint: disable=wrong-import-position
+"""
+This a wrapper layer to make interacting with Gtk a little less painful.
+The main issues with Gtk is that it expects an aweful lot of the developer,
+code which is repeated over and over and patterns which every single developer
+will use are not given easy to use convience functions.
+
+This makes Gtk programming WET, unattractive and error prone. This module steps
+inbetween and adds in all those missing bits. It's not meant to replace Gtk and
+certainly it's possible to use Gtk and threading directly.
+
+.. versionadded:: 1.2
+"""
+
+import threading
+import os
+import logging
+
+from ..utils import DependencyError
+
+try:
+ import gi
+
+ gi.require_version("Gtk", "3.0")
+except ImportError: # pragma: no cover
+ raise DependencyError(
+ "You are missing the required libraries for Gtk."
+ " Please report this problem to the Inkscape developers."
+ )
+
+from .app import GtkApp
+from .window import Window, ChildWindow, FakeWidget
+from .listview import TreeView, IconView, ViewColumn, ViewSort, Separator
+from .pixmap import PixmapManager
diff --git a/share/extensions/inkex/gui/app.py b/share/extensions/inkex/gui/app.py
new file mode 100644
index 0000000..6b2a0ab
--- /dev/null
+++ b/share/extensions/inkex/gui/app.py
@@ -0,0 +1,176 @@
+# coding=utf-8
+#
+# Copyright 2011-2022 Martin Owens <doctormo@geek-2.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 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>
+#
+"""
+Gtk Application base classes, providing a way to load a GtkBuilder
+with a specific glade/ui file conaining windows, and building
+a usable pythonic interface from them.
+"""
+import os
+import signal
+import logging
+
+from gi.repository import Gtk, GLib
+
+
+class GtkApp:
+ """
+ This wraps gtk builder and allows for some extra functionality with
+ windows, especially the management of gtk main loops.
+
+ Args:
+ start_loop (bool, optional): If set to true will start a new gtk main loop.
+ Defaults to False.
+ start_gui (bool, optional): Used as local propertes if unset and passed to
+ primary window when loaded. Defaults to True.
+ """
+
+ @property
+ def prefix(self):
+ """Folder prefix added to ui_dir"""
+ return self.kwargs.get("prefix", "")
+
+ @property
+ def windows(self):
+ """Returns a list of windows for this app"""
+ return self.kwargs.get("windows", [])
+
+ @property
+ def ui_dir(self):
+ """This is often the local directory"""
+ return self.kwargs.get("ui_dir", "./")
+
+ @property
+ def ui_file(self):
+ """If a single file is used for multiple windows"""
+ return self.kwargs.get("ui_file", None)
+
+ @property
+ def app_name(self):
+ """Set this variable in your class"""
+ try:
+ return self.kwargs["app_name"]
+ except KeyError:
+ raise NotImplementedError(
+ "App name is not set, pass in or set 'app_name' in class."
+ )
+
+ @property
+ def window(self):
+ """Return the primary window"""
+ return self._primary
+
+ def __init__(self, start_loop=False, start_gui=True, **kwargs):
+ """Creates a new GtkApp."""
+ self.kwargs = kwargs
+ self._loaded = {}
+ self._initial = {}
+ self._primary = None
+
+ self.main_loop = GLib.main_depth()
+
+ # Start with creating all the defined windows.
+ if start_gui:
+ self.init_gui()
+ # Start up a gtk main loop when requested
+ if start_loop:
+ self.run()
+
+ def run(self):
+ """Run the gtk mainloop with ctrl+C and keyboard interupt additions"""
+ if not Gtk.init_check()[0]: # pragma: no cover
+ raise RuntimeError(
+ "Gtk failed to start." " Make sure $DISPLAY variable is set.\n"
+ )
+ try:
+ # Add a signal to force quit on Ctrl+C (just like the old days)
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
+ Gtk.main()
+ except KeyboardInterrupt: # pragma: no cover
+ logging.info("User Interputed")
+ logging.debug("Exiting %s", self.app_name)
+
+ def get_ui_file(self, window):
+ """Load any given gtk builder file from a standard location."""
+ paths = [
+ os.path.join(self.ui_dir, self.prefix, f"{window}.ui"),
+ os.path.join(self.ui_dir, self.prefix, f"{self.ui_file}.ui"),
+ ]
+ for path in paths:
+ if os.path.isfile(path):
+ return path
+ raise FileNotFoundError(f"Gtk Builder file is missing: {paths}")
+
+ def init_gui(self):
+ """Initalise all of our windows and load their signals"""
+ if self.windows:
+ for cls in self.windows:
+ window = cls
+ logging.debug("Adding window %s to GtkApp", window.name)
+ self._initial[window.name] = window
+ for window in self._initial.values():
+ if window.primary:
+ if not self._primary:
+ self._primary = self.load_window(window.name)
+ if not self.windows or not self._primary:
+ raise KeyError(f"No primary window found for '{self.app_name}' app.")
+
+ def load_window(self, name, *args, **kwargs):
+ """Load a specific window from our group of windows"""
+ window = self.proto_window(name)
+ window.init(*args, **kwargs)
+ return window
+
+ def load_window_extract(self, name, **kwargs):
+ """Load a child window as a widget container"""
+ window = self.proto_window(name)
+ window.load_widgets(**kwargs)
+ return window.extract()
+
+ def proto_window(self, name):
+ """
+ Loads a glade window as a window without initialisation, used for
+ extracting widgets from windows without loading them as windows.
+ """
+ logging.debug("Loading '%s' from %s", name, self._initial)
+ if name in self._initial:
+ # Create a new instance of this window
+ window = self._initial[name](self)
+ # Save the window object linked against the gtk window instance
+ self._loaded[window.wid] = window
+ return window
+ raise KeyError(f"Can't load window '{name}', class not found.")
+
+ def remove_window(self, window):
+ """Remove the window from the list and exit if none remain"""
+ if window.wid in self._loaded:
+ self._loaded.pop(window.wid)
+ else:
+ logging.warning("Missing window '%s' on exit.", window.name)
+ logging.debug("Loaded windows: %s", self._loaded)
+ if not self._loaded:
+ self.exit()
+
+ def exit(self):
+ """Exit our gtk application and kill gtk main if we have to"""
+ if self.main_loop < GLib.main_depth():
+ # Quit Gtk loop if we started one.
+ tag = self._primary.name if self._primary else "program"
+ logging.debug("Quit '%s' Main Loop.", tag)
+ Gtk.main_quit()
+ # You have to return in order for the loop to exit
+ return 0
diff --git a/share/extensions/inkex/gui/asyncme.py b/share/extensions/inkex/gui/asyncme.py
new file mode 100644
index 0000000..5011c17
--- /dev/null
+++ b/share/extensions/inkex/gui/asyncme.py
@@ -0,0 +1,330 @@
+#
+# Copyright 2015 Ian Denhardt <ian@zenhack.net>
+#
+# 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 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>
+#
+"""Convienience library for concurrency
+
+GUI apps frequently need concurrency, for example to avoid blocking UI while
+doing some long running computation. This module provides helpers for doing
+this kind of thing.
+
+The functions/methods here which spawn callables asynchronously
+don't supply a direct way to provide arguments. Instead, the user is
+expected to use a lambda, e.g::
+
+ holding(lck, lambda: do_stuff(1,2,3, x='hello'))
+
+This is because the calling function may have additional arguments which
+could obscure the user's ability to pass arguments expected by the called
+function. For example, in the call::
+
+ holding(lck, lambda: run_task(blocking=True), blocking=False)
+
+the blocking argument to holding might otherwise conflict with the
+blocking argument to run_task.
+"""
+import time
+import threading
+from datetime import datetime, timedelta
+
+from functools import wraps
+from typing import Any, Tuple
+from gi.repository import Gdk, GLib
+
+
+class Future:
+ """A deferred result
+
+ A `Future` is a result-to-be; it can be used to deliver a result
+ asynchronously. Typical usage:
+
+ >>> def background_task(task):
+ ... ret = Future()
+ ... def _task(x):
+ ... return x - 4 + 2
+ ... thread = threading.Thread(target=lambda: ret.run(lambda: _task(7)))
+ ... thread.start()
+ ... return ret
+ >>> # Do other stuff
+ >>> print(ret.wait())
+ 5
+
+ :func:`run` will also propogate exceptions; see it's docstring for details.
+ """
+
+ def __init__(self):
+ self._lock = threading.Lock()
+ self._value = None
+ self._exception = None
+ self._lock.acquire()
+
+ def is_ready(self):
+ """Return whether the result is ready"""
+ result = self._lock.acquire(False)
+ if result:
+ self._lock.release()
+ return result
+
+ def wait(self):
+ """Wait for the result.
+
+ `wait` blocks until the result is ready (either :func:`result` or
+ :func:`exception` has been called), and then returns it (in the case
+ of :func:`result`), or raises it (in the case of :func:`exception`).
+ """
+ with self._lock:
+ if self._exception is None:
+ return self._value
+ else:
+ raise self._exception # pylint: disable=raising-bad-type
+
+ def result(self, value):
+ """Supply the result as a return value.
+
+ ``value`` is the result to supply; it will be returned when
+ :func:`wait` is called.
+ """
+ self._value = value
+ self._lock.release()
+
+ def exception(self, err):
+ """Supply an exception as the result.
+
+ Args:
+ err (Exception): an exception, which will be raised when :func:`wait`
+ is called.
+ """
+ self._exception = err
+ self._lock.release()
+
+ def run(self, task):
+ """Calls task(), and supplies the result.
+
+ If ``task`` raises an exception, pass it to :func:`exception`.
+ Otherwise, pass the return value to :func:`result`.
+ """
+ try:
+ self.result(task())
+ except Exception as err: # pylint: disable=broad-except
+ self.exception(err)
+
+
+class DebouncedSyncVar:
+ """A synchronized variable, which debounces its value
+
+ :class:`DebouncedSyncVar` supports three operations: put, replace, and get.
+ get will only retrieve a value once it has "settled," i.e. at least
+ a certain amount of time has passed since the last time the value
+ was modified.
+ """
+
+ def __init__(self, delay_seconds=0):
+ """Create a new dsv with the supplied delay, and no initial value."""
+ self._cv = threading.Condition()
+ self._delay = timedelta(seconds=delay_seconds)
+
+ self._deadline = None
+ self._value = None
+
+ self._have_value = False
+
+ def set_delay(self, delay_seconds):
+ """Set the delay in seconds of the debounce."""
+ with self._cv:
+ self._delay = timedelta(seconds=delay_seconds)
+
+ def get(self, blocking=True, remove=True) -> Tuple[Any, bool]:
+ """Retrieve a value.
+
+ Args:
+ blocking (bool, optional): if True, block until (1) the dsv has a value
+ and (2) the value has been unchanged for an amount of time greater
+ than or equal to the dsv's delay. Otherwise, if these conditions
+ are not met, return ``(None, False)`` immediately. Defaults to True.
+ remove (bool, optional): if True, remove the value when returning it.
+ Otherwise, leave it where it is.. Defaults to True.
+
+ Returns:
+ Tuple[Any, bool]: Tuple (value, ok). ``value`` is the value of the variable
+ (if successful, see above), and ok indicates whether or not a value was
+ successfully retrieved.
+ """
+ while True:
+ with self._cv:
+
+ # If there's no value, either wait for one or return
+ # failure.
+ while not self._have_value:
+ if blocking:
+ self._cv.wait()
+ else:
+ return None, False # pragma: no cover
+
+ now = datetime.now()
+ deadline = self._deadline
+ value = self._value
+ if deadline <= now:
+ # Okay, we're good. Remove the value if necessary, and
+ # return it.
+ if remove:
+ self._have_value = False
+ self._value = None
+ self._cv.notify()
+ return value, True
+
+ # Deadline hasn't passed yet. Either wait or return failure.
+ if blocking:
+ time.sleep((deadline - now).total_seconds())
+ else:
+ return None, False # pragma: no cover
+
+ def replace(self, value):
+ """Replace the current value of the dsv (if any) with ``value``.
+
+ replace never blocks (except briefly to aquire the lock). It does not
+ wait for any unit of time to pass (though it does reset the timer on
+ completion), nor does it wait for the dsv's value to appear or
+ disappear.
+ """
+ with self._cv:
+ self._replace(value)
+
+ def put(self, value):
+ """Set the dsv's value to ``value``.
+
+ If the dsv already has a value, this blocks until the value is removed.
+ Upon completion, this resets the timer.
+ """
+ with self._cv:
+ while self._have_value:
+ self._cv.wait()
+ self._replace(value)
+
+ def _replace(self, value):
+ self._have_value = True
+ self._value = value
+ self._deadline = datetime.now() + self._delay
+ self._cv.notify()
+
+
+def spawn_thread(func):
+ """Call ``func()`` in a separate thread
+
+ Returns the corresponding :class:`threading.Thread` object.
+ """
+ thread = threading.Thread(target=func)
+ thread.start()
+ return thread
+
+
+def in_mainloop(func):
+ """Run f() in the gtk main loop
+
+ Returns a :class:`Future` object which can be used to retrieve the return
+ value of the function call.
+
+ :func:`in_mainloop` exists because Gtk isn't threadsafe, and therefore cannot be
+ manipulated except in the thread running the Gtk main loop. :func:`in_mainloop`
+ can be used by other threads to manipulate Gtk safely.
+ """
+ future = Future()
+
+ def handler(*_args, **_kwargs):
+ """Function to be called in the future"""
+ future.run(func)
+
+ Gdk.threads_add_idle(0, handler, None)
+ return future
+
+
+def mainloop_only(f):
+ """A decorator which forces a function to only be run in Gtk's main loop.
+
+ Invoking a decorated function as ``f(*args, **kwargs)`` is equivalent to
+ using the undecorated function (from a thread other than the one running
+ the Gtk main loop) as::
+
+ in_mainloop(lambda: f(*args, **kwargs)).wait()
+
+ :func:`mainloop_only` should be used to decorate functions which are unsafe
+ to run outside of the Gtk main loop.
+ """
+
+ @wraps(f)
+ def wrapper(*args, **kwargs):
+ if GLib.main_depth():
+ # Already in a mainloop, so just run it.
+ return f(*args, **kwargs)
+ return in_mainloop(lambda: f(*args, **kwargs)).wait()
+
+ return wrapper
+
+
+def holding(lock, task, blocking=True):
+ """Run task() while holding ``lock``.
+
+ Args:
+ blocking (bool, optional): if True, wait for the lock before running.
+ Otherwise, if the lock is busy, return None immediately, and don't
+ spawn `task`. Defaults to True.
+
+ Returns:
+ Union[Future, None]: The return value is a future which can be used to retrieve
+ the result of running task (or None if the task was not run).
+ """
+ if not lock.acquire(False):
+ return None
+ ret = Future()
+
+ def _target():
+ ret.run(task)
+ if ret._exception: # pragma: no cover
+ ret.wait()
+ lock.release()
+
+ threading.Thread(target=_target).start()
+ return ret
+
+
+def run_or_wait(func):
+ """A decorator which runs the function using :func:`holding`
+
+ This function creates a single lock for this function and
+ waits for the lock to release before returning.
+
+ See :func:`holding` above, with ``blocking=True``
+ """
+ lock = threading.Lock()
+
+ def _inner(*args, **kwargs):
+ return holding(lock, lambda: func(*args, **kwargs), blocking=True)
+
+ return _inner
+
+
+def run_or_none(func):
+ """A decorator which runs the function using :func:`holding`
+
+ This function creates a single lock for this function and
+ returns None if the process is already running (locked)
+
+ See :func:`holding` above with ``blocking=True``
+ """
+ lock = threading.Lock()
+
+ def _inner(*args, **kwargs):
+ return holding(lock, lambda: func(*args, **kwargs), blocking=False)
+
+ return _inner
diff --git a/share/extensions/inkex/gui/listview.py b/share/extensions/inkex/gui/listview.py
new file mode 100644
index 0000000..56939e7
--- /dev/null
+++ b/share/extensions/inkex/gui/listview.py
@@ -0,0 +1,562 @@
+#
+# Copyright 2011-2022 Martin Owens <doctormo@geek-2.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 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>
+#
+"""
+Wraps the gtk treeview and iconview in something a little nicer.
+"""
+
+import logging
+
+from typing import Tuple, Type, Optional
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, Pango
+
+from .pixmap import PixmapManager, SizeFilter
+
+GOBJ = GObject.TYPE_PYOBJECT
+
+
+def default(item, attr, d=None):
+ """Python logic to choose an attribute, call it if required and return"""
+ if hasattr(item, attr):
+ prop = getattr(item, attr)
+ if callable(prop):
+ prop = prop()
+ return prop
+ return d
+
+
+def cmp(a, b):
+ """Compare two objects"""
+ return (a > b) - (a < b)
+
+
+def item_property(name, d=None):
+ def inside(item):
+ return default(item, name, d)
+
+ return inside
+
+
+def label(obj):
+ if isinstance(obj, tuple):
+ return " or ".join([label(o) for o in obj])
+ if not isinstance(obj, type):
+ obj = type(obj)
+ return obj.__name__
+
+
+class BaseView:
+ """Controls for tree and icon views, a base class"""
+
+ widget_type: Optional[Type[Gtk.Widget]] = None
+
+ def __init__(self, widget, liststore=None, **kwargs):
+ if not isinstance(widget, self.widget_type):
+ lbl1 = label(self.widget_type)
+ lbl2 = label(widget)
+ raise TypeError(f"Wrong widget type: Expected {lbl1} got {lbl2}")
+
+ self.selected_signal = kwargs.get("selected", None)
+ self._iids = []
+ self._list = widget
+ self.args = kwargs
+ self.selected = None
+ self._data = None
+ self.no_dupes = True
+ self._model = self.create_model(liststore or widget.get_model())
+ self._list.set_model(self._model)
+ self.setup()
+
+ self._list.connect(self.changed_signal, self.item_selected_signal)
+
+ def get_model(self):
+ """Returns the current data store model"""
+ return self._model
+
+ def create_model(self, liststore):
+ """Setup the model and list"""
+ if not isinstance(liststore, (Gtk.ListStore, Gtk.TreeStore)):
+ lbl = label(liststore)
+ raise TypeError(f"Expected List or TreeStore, got {lbl}")
+ return liststore
+
+ def refresh(self):
+ """Attempt to refresh the listview"""
+ self._list.queue_draw()
+
+ def setup(self):
+ """Setup columns, views, sorting etc"""
+ pass
+
+ def get_item_id(self, item):
+ """
+ Return an id set against this item.
+
+ If item.get_id() is set then duplicates will be ignored.
+ """
+ if hasattr(item, "get_id"):
+ return item.get_id()
+ return None
+
+ def replace(self, new_item, item_iter=None):
+ """Replace all items, or a single item with object"""
+ if item_iter:
+ self.remove_item(item_iter)
+ self.add_item(new_item)
+ else:
+ self.clear()
+ self._data = new_item
+ self.add_item(new_item)
+
+ def item_selected(self, item=None, *others):
+ """Base method result, called as an item is selected"""
+ if self.selected != item:
+ self.selected = item
+ if self.selected_signal and item:
+ self.selected_signal(item)
+
+ def remove_item(self, item=None):
+ """Remove an item from this view"""
+ return self._model.remove(self.get_iter(item))
+
+ def check_item_id(self, item):
+ """Item id is recorded to guard against duplicates"""
+ iid = self.get_item_id(item)
+ if iid in self._iids and self.no_dupes:
+ raise ValueError(f"Will not add duplicate row {iid}")
+ if iid:
+ self._iids.append(iid)
+
+ def __iter__(self):
+ ret = []
+
+ def collect_all(store, treepath, treeiter):
+ ret.append((self.get_item(treeiter), treepath, treeiter))
+
+ self._model.foreach(collect_all)
+ return ret.__iter__()
+
+ def set_sensitive(self, sen=True):
+ """Proxy the GTK property for sensitivity"""
+ self._list.set_sensitive(sen)
+
+ def clear(self):
+ """Clear all items from this treeview"""
+ self._iids = []
+ self._model.clear()
+
+ def item_double_clicked(self, *items):
+ """What happens when you double click an item"""
+ return items # Nothing
+
+ def get_item(self, item_iter):
+ """Return the object of attention from an iter"""
+ return self._model[self.get_iter(item_iter)][0]
+
+ def get_iter(self, item, path=False):
+ """Return the iter given the item"""
+ if isinstance(item, Gtk.TreePath):
+ return item if path else self._model.get_iter(item)
+ if isinstance(item, Gtk.TreeIter):
+ return self._model.get_path(item) if path else item
+ for src_item, src_path, src_iter in self:
+ if item == src_item:
+ return src_path if path else src_iter
+ return None
+
+
+class TreeView(BaseView):
+ """Controls and operates a tree view."""
+
+ column_size = 16
+ widget_type = Gtk.TreeView
+ changed_signal = "cursor_changed"
+
+ def setup(self):
+ """Setup the treeview"""
+ self._sel = self._list.get_selection()
+ self._sel.set_mode(Gtk.SelectionMode.MULTIPLE)
+ self._list.connect("button-press-event", self.item_selected_signal)
+ # Separators should do something
+ self._list.set_row_separator_func(TreeView.is_separator, None)
+ super().setup()
+
+ @staticmethod
+ def is_separator(model, item_iter, data):
+ """Internal function for seperator checking"""
+ return isinstance(model.get_value(item_iter, 0), Separator)
+
+ def get_selected_items(self):
+ """Return a list of selected item objects"""
+ return [self.get_item(row) for row in self._sel.get_selected_rows()[1]]
+
+ def set_selected_items(self, *items):
+ """Select the given items"""
+ self._sel.unselect_all()
+ for item in items:
+ path_item = self.get_iter(item, path=True)
+ if path_item is not None:
+ self._sel.select_path(path_item)
+
+ def is_selected(self, item):
+ """Return true if the item is selected"""
+ return self._sel.iter_is_selected(self.get_iter(item))
+
+ def add(self, target, parent=None):
+ """Add all items from the target to the treeview"""
+ for item in target:
+ self.add_item(item, parent=parent)
+
+ def add_item(self, item, parent=None):
+ """Add a single item image to the control, returns the TreePath"""
+ if item is not None:
+ self.check_item_id(item)
+ return self._add_item([item], self.get_iter(parent))
+ raise ValueError("Item can not be None.")
+
+ def _add_item(self, item, parent):
+ return self.get_iter(self._model.append(parent, item), path=True)
+
+ def item_selected_signal(self, *args, **kwargs):
+ """Signal for selecting an item"""
+ return self.item_selected(*self.get_selected_items())
+
+ def item_button_clicked(self, _, event):
+ """Signal for mouse button click"""
+ if event is None or event.type == Gdk.EventType._2BUTTON_PRESS:
+ self.item_double_clicked(*self.get_selected_items())
+
+ def expand_item(self, item, expand=True):
+ """Expand one of our nodes"""
+ self._list.expand_row(self.get_iter(item, path=True), expand)
+
+ def create_model(self, liststore=None):
+ """Set up an icon view for showing gallery images"""
+ if liststore is None:
+ liststore = Gtk.TreeStore(GOBJ)
+ return super().create_model(liststore)
+
+ def create_column(self, name, expand=True):
+ """
+ Create and pack a new column to this list.
+
+ name - Label in the column header
+ expand - Should the column expand
+ """
+ return ViewColumn(self._list, name, expand=expand)
+
+ def create_sort(self, *args, **kwargs):
+ """
+ Create and attach a sorting view to this list.
+
+ see ViewSort arguments for details.
+ """
+ return ViewSort(self._list, *args, **kwargs)
+
+
+class ComboBox(TreeView):
+ """Controls and operates a combo box list."""
+
+ widget_type = Gtk.ComboBox
+ changed_signal = "changed"
+
+ def setup(self):
+ pass
+
+ def get_selected_item(self):
+ """Return the selected item of this combo box"""
+ return self.get_item(self._list.get_active_iter())
+
+ def set_selected_item(self, item):
+ """Set the given item as the selected item"""
+ self._list.set_active_iter(self.get_iter(item))
+
+ def is_selected(self, item):
+ """Returns true if this item is the selected item"""
+ return self.get_selected_item() == item
+
+ def get_selected_items(self):
+ """Return a list of selected items (one)"""
+ return [self.get_selected_item()]
+
+
+class IconView(BaseView):
+ """Allows a simpler IconView for DBus List Objects"""
+
+ widget_type = Gtk.IconView
+ changed_signal = "selection-changed"
+
+ def __init__(self, widget, pixmaps, *args, **kwargs):
+ super().__init__(widget, *args, **kwargs)
+ self.pixmaps = pixmaps
+
+ def set_selected_item(self, item):
+ """Sets the selected item to this item"""
+ path = self.get_iter(item, path=True)
+ if path:
+ self._list.set_cursor(path, None, False)
+
+ def get_selected_items(self):
+ """Return the seleced item"""
+ return [self.get_item(path) for path in self._list.get_selected_items()]
+
+ def create_model(self, liststore):
+ """Setup the icon view control and model"""
+ if not liststore:
+ liststore = Gtk.ListStore(GOBJ, str, GdkPixbuf.Pixbuf)
+ return super().create_model(liststore)
+
+ def setup(self):
+ """Setup the columns for the iconview"""
+ self._list.set_markup_column(1)
+ self._list.set_pixbuf_column(2)
+ super().setup()
+
+ def add(self, target):
+ """Add all items from the target to the iconview"""
+ for item in target:
+ self.add_item(item)
+
+ def add_item(self, item):
+ """Add a single item image to the control"""
+ if item is not None:
+ self.check_item_id(item)
+ return self._add_item(item)
+ raise ValueError("Item can not be None.")
+
+ def get_markup(self, item):
+ """Default text return for markup."""
+ return default(item, "name", str(item))
+
+ def get_icon(self, item):
+ """Default icon return, pixbuf or gnome theme name"""
+ return default(item, "icon", None)
+
+ def _get_icon(self, item):
+ return self.pixmaps.get(self.get_icon(item), item=item)
+
+ def _add_item(self, item):
+ """
+ Each item's properties must be stuffed into the ListStore directly
+ or the IconView won't see them, but only if on auto.
+ """
+ if not isinstance(item, (tuple, list)):
+ item = [item, self.get_markup(item), self._get_icon(item)]
+ return self._model.append(item)
+
+ def item_selected_signal(self, *args, **kwargs):
+ """Item has been selected"""
+ return self.item_selected(*self.get_selected_items())
+
+
+class ViewSort(object):
+ """
+ A sorting function for use is ListViews
+
+ ascending - Boolean which direction to sort
+ contains - Contains this string
+ data - A string or function to get data from each item.
+ exact - Compare to this exact string instead.
+ """
+
+ def __init__(self, widget, data=None, ascending=False, exact=None, contains=None):
+ self.tree = None
+ self.data = data
+ self.asc = ascending
+ self.comp = exact.lower() if exact else None
+ self.cont = contains
+ self.tree = widget
+ self.resort()
+
+ def get_data(self, model, list_iter):
+ """Generate sortable data from the item"""
+ item = model.get_value(list_iter, 0)
+ if isinstance(self.data, str):
+ value = getattr(item, self.data)
+ elif callable(self.data):
+ value = self.data(item)
+ return value
+
+ def sort_func(self, model, iter1, iter2, data):
+ """Called by Gtk to sort items"""
+ value1 = self.get_data(model, iter1)
+ value2 = self.get_data(model, iter2)
+ if value1 == None or value2 == None:
+ return 0
+ if self.comp:
+ if cmp(self.comp, value1.lower()) == 0:
+ return 1
+ elif cmp(self.comp, value2.lower()) == 0:
+ return -1
+ return 0
+ elif self.cont:
+ if self.cont in value1.lower():
+ return 1
+ elif self.cont in value2.lower():
+ return -1
+ return 0
+ if value1 < value2:
+ return 1
+ if value2 < value1:
+ return -1
+ return 0
+
+ def resort(self):
+ model = self.tree.get_model()
+ model.set_sort_func(0, self.sort_func, None)
+ if self.asc:
+ model.set_sort_column_id(0, Gtk.SortType.ASCENDING)
+ else:
+ model.set_sort_column_id(0, Gtk.SortType.DESCENDING)
+
+
+class ViewColumn(object):
+ """
+ Add a column to a gtk treeview.
+
+ name - The column name used as a label.
+ expand - Set column expansion.
+ """
+
+ def __init__(self, widget, name, expand=False):
+ if isinstance(widget, Gtk.TreeView):
+ column = Gtk.TreeViewColumn((name))
+ column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
+ column.set_expand(expand)
+ self._column = column
+ widget.append_column(self._column)
+ else:
+ # Deal with possible drop down lists
+ self._column = widget
+
+ def add_renderer(self, renderer, func, expand=True):
+ """Set a custom renderer"""
+ self._column.pack_start(renderer, expand)
+ self._column.set_cell_data_func(renderer, func, None)
+ return renderer
+
+ def add_image_renderer(self, icon, pad=0, pixmaps=None, size=None):
+ """
+ Set the image renderer
+
+ icon - The function that returns the image to be dsplayed.
+ pad - The amount of padding around the image.
+ pixmaps - The pixmap manager to use to get images.
+ size - Restrict the images to this size.
+ """
+ # Manager where icons will be pulled from
+ filters = [SizeFilter] if size else []
+ pixmaps = pixmaps or PixmapManager(
+ "", pixmap_dir="./", filters=filters, size=size
+ )
+
+ renderer = Gtk.CellRendererPixbuf()
+ renderer.set_property("ypad", pad)
+ renderer.set_property("xpad", pad)
+ func = self.image_func(icon or self.default_icon, pixmaps)
+ return self.add_renderer(renderer, func, expand=False)
+
+ def add_text_renderer(self, text, wrap=None, template=None):
+ """
+ Set the text renderer.
+
+ text - the function that returns the text to be displayed.
+ wrap - The wrapping setting for this renderer.
+ template - A standard template used for this text markup.
+ """
+
+ renderer = Gtk.CellRendererText()
+ if wrap is not None:
+ renderer.props.wrap_width = wrap
+ renderer.props.wrap_mode = Pango.WrapMode.WORD
+
+ renderer.props.background_set = True
+ renderer.props.foreground_set = True
+
+ func = self.text_func(text or self.default_text, template)
+ return self.add_renderer(renderer, func, expand=True)
+
+ @classmethod
+ def clean(cls, text, markup=False):
+ """Clean text of any pango markup confusing chars"""
+ if text is None:
+ text = ""
+ if isinstance(text, (str, int, float)):
+ if markup:
+ text = str(text).replace("<", "&lt;").replace(">", "&gt;")
+ return str(text).replace("&", "&amp;")
+ elif isinstance(text, dict):
+ return dict([(k, cls.clean(v)) for k, v in text.items()])
+ elif isinstance(text, (list, tuple)):
+ return tuple([cls.clean(value) for value in text])
+ raise TypeError("Unknown value type for text: %s" % str(type(text)))
+
+ def get_callout(self, call, default=None):
+ """Returns the right kind of method"""
+ if isinstance(call, str):
+ call = item_property(call, default)
+ return call
+
+ def text_func(self, call, template=None):
+ """Wrap up our text functionality"""
+ callout = self.get_callout(call)
+
+ def internal(column, cell, model, item_iter, data):
+ if TreeView.is_separator(model, item_iter, data):
+ return
+ item = model.get_value(item_iter, 0)
+ markup = template is not None
+ text = callout(item)
+ if isinstance(template, str):
+ text = template.format(self.clean(text, markup=True))
+ else:
+ text = self.clean(text)
+ cell.set_property("markup", str(text))
+
+ return internal
+
+ def image_func(self, call, pixmaps=None):
+ """Wrap, wrap wrap the func"""
+ callout = self.get_callout(call)
+
+ def internal(column, cell, model, item_iter, data):
+ if TreeView.is_separator(model, item_iter, data):
+ return
+ item = model.get_value(item_iter, 0)
+ icon = callout(item)
+ # The or blank asks for the default icon from the pixmaps
+ if isinstance(icon or "", str) and pixmaps:
+ # Expect a Gnome theme icon
+ icon = pixmaps.get(icon)
+ elif icon:
+ icon = pixmaps.apply_filters(icon)
+
+ cell.set_property("pixbuf", icon)
+ cell.set_property("visible", True)
+
+ return internal
+
+ def default_text(self, item):
+ """Default text return for markup."""
+ return default(item, "name", str(item))
+
+ def default_icon(self, item):
+ """Default icon return, pixbuf or gnome theme name"""
+ return default(item, "icon", None)
+
+
+class Separator:
+ """Reprisentation of a separator in a list"""
diff --git a/share/extensions/inkex/gui/pixmap.py b/share/extensions/inkex/gui/pixmap.py
new file mode 100644
index 0000000..02f4ce8
--- /dev/null
+++ b/share/extensions/inkex/gui/pixmap.py
@@ -0,0 +1,346 @@
+#
+# Copyright 2011-2022 Martin Owens <doctormo@geek-2.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 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>
+#
+"""
+Provides wrappers for pixmap access.
+"""
+
+import os
+import logging
+
+from typing import List
+from collections.abc import Iterable
+from gi.repository import Gtk, GLib, GdkPixbuf
+
+ICON_THEME = Gtk.IconTheme.get_default()
+BILINEAR = GdkPixbuf.InterpType.BILINEAR
+HYPER = GdkPixbuf.InterpType.HYPER
+
+SIZE_ASPECT = 0
+SIZE_ASPECT_GROW = 1
+SIZE_ASPECT_CROP = 2
+SIZE_STRETCH = 3
+
+
+class PixmapLoadError(ValueError):
+ """Failed to load a pixmap"""
+
+
+class PixmapFilter: # pylint: disable=too-few-public-methods
+ """Base class for filtering the pixmaps in a manager's output.
+
+ required - List of values required for this filter.
+
+ Use:
+
+ class Foo(PixmapManager):
+ filters = [ PixmapFilterFoo ]
+
+ """
+
+ required: List[str] = []
+ optional: List[str] = []
+
+ def __init__(self, **kwargs):
+ self.enabled = True
+ for key in self.required:
+ if key not in kwargs:
+ self.enabled = False
+ else:
+ setattr(self, key, kwargs[key])
+
+ for key in self.optional:
+ if key in kwargs:
+ setattr(self, key, kwargs[key])
+
+ def filter(self, img, **kwargs):
+ """Run filter, replace this methodwith your own"""
+ raise NotImplementedError(
+ "Please add 'filter' method to your PixmapFilter class %s."
+ % type(self).__name__
+ )
+
+ @staticmethod
+ def to_size(dat):
+ """Tries to calculate a size that will work for the data"""
+ if isinstance(dat, (int, float)):
+ return (dat, dat)
+ if isinstance(dat, Iterable) and len(dat) >= 2:
+ return (dat[0], dat[1])
+ return None
+
+
+class OverlayFilter(PixmapFilter):
+ """Adds an overlay to output images, overlay can be any name that
+ the owning pixmap manager can find.
+
+ overlay : Name of overlay image
+ position : Location of the image:
+ 0 - Full size (1 to 1 overlay, default)
+ (x,y) - Percentage from one end to the other position 0-1
+ alpha : Blending alpha, 0 - 255
+
+ """
+
+ optional = ["position", "overlay", "alpha"]
+
+ def __init__(self, *args, **kwargs):
+ self.position = (0, 0)
+ self.overlay = None
+ self.alpha = 255
+ super().__init__(*args, **kwargs)
+ self.pad_x, self.pad_y = self.to_size(self.position)
+
+ def get_overlay(self, **kwargs):
+ if "manager" not in kwargs:
+ raise ValueError("PixmapManager must be provided when adding an overlay.")
+ return kwargs["manager"].get(
+ kwargs.get("overlay", None) or self.overlay, no_overlay=True
+ )
+
+ def filter(self, img, no_overlay=False, **kwargs):
+ # Recursion protection
+ if no_overlay:
+ return img
+
+ overlay = self.get_overlay(**kwargs)
+ if overlay:
+ img = img.copy()
+
+ (x, y, width, height) = self.set_position(overlay, img)
+ overlay.composite(
+ img, x, y, width, height, x, y, 1, 1, BILINEAR, self.alpha
+ )
+ return img
+
+ def set_position(self, overlay, img):
+ """Sets the position of img on the given width and height"""
+ img_w, img_h = img.get_width(), img.get_height()
+ ovl_w, ovl_h = overlay.get_width(), overlay.get_height()
+ return (
+ max([0, (img_w - ovl_w) * self.pad_x]),
+ max([0, (img_h - ovl_h) * self.pad_y]),
+ min([ovl_w, img_w]),
+ min([ovl_h, img_h]),
+ )
+
+
+class SizeFilter(PixmapFilter):
+ """Resizes images to a certain size:
+
+ resize_mode - Way in which the size is calculated
+ 0 - Best Aspect, don't grow
+ 1 - Best Aspect, grow
+ 2 - Cropped Aspect
+ 3 - Stretch
+ """
+
+ required = ["size"]
+ optional = ["resize_mode"]
+
+ def __init__(self, *args, **kwargs):
+ self.size = None
+ self.resize_mode = SIZE_ASPECT
+ super().__init__(*args, **kwargs)
+ self.img_w, self.img_h = self.to_size(self.size) or (0, 0)
+
+ def aspect(self, img_w, img_h):
+ """Get the aspect ratio of the image resized"""
+ if self.resize_mode == SIZE_STRETCH:
+ return (self.img_w, self.img_h)
+
+ if (
+ self.resize_mode == SIZE_ASPECT
+ and img_w < self.img_w
+ and img_h < self.img_h
+ ):
+ return (img_w, img_h)
+ (pcw, pch) = (self.img_w / img_w, self.img_h / img_h)
+ factor = (
+ max(pcw, pch) if self.resize_mode == SIZE_ASPECT_CROP else min(pcw, pch)
+ )
+ return (int(img_w * factor), int(img_h * factor))
+
+ def filter(self, img, **kwargs):
+ if self.size is not None:
+ (width, height) = self.aspect(img.get_width(), img.get_height())
+ return img.scale_simple(width, height, HYPER)
+ return img
+
+
+class PadFilter(SizeFilter):
+ """Add padding to the image to make it a standard size"""
+
+ optional = ["padding"]
+
+ def __init__(self, *args, **kwargs):
+ self.size = None
+ self.padding = 0.5
+ super().__init__(*args, **kwargs)
+ self.pad_x, self.pad_y = self.to_size(self.padding)
+
+ def filter(self, img, **kwargs):
+ (width, height) = (img.get_width(), img.get_height())
+ if width < self.img_w or height < self.img_h:
+ target = GdkPixbuf.Pixbuf.new(
+ img.get_colorspace(),
+ True,
+ img.get_bits_per_sample(),
+ max([width, self.img_w]),
+ max([height, self.img_h]),
+ )
+ target.fill(0x0) # Transparent black
+
+ x = (target.get_width() - width) * self.pad_x
+ y = (target.get_height() - height) * self.pad_y
+
+ img.composite(target, x, y, width, height, x, y, 1, 1, BILINEAR, 255)
+ return target
+ return img
+
+
+class PixmapManager:
+ """Manage a set of cached pixmaps, returns the default image
+ if it can't find one or the missing image if that's available."""
+
+ missing_image = "image-missing"
+ default_image = "application-default-icon"
+ icon_theme = ICON_THEME
+ theme_size = 32
+ filters: List[type] = []
+ pixmap_dir = None
+
+ def __init__(self, location="", **kwargs):
+ self.location = location
+ if self.pixmap_dir and not os.path.isabs(location):
+ self.location = os.path.join(self.pixmap_dir, location)
+
+ self.loader_size = PixmapFilter.to_size(kwargs.pop("load_size", None))
+
+ # Add any instance specified filters first
+ self._filters = []
+ for item in kwargs.get("filters", []) + self.filters:
+ if isinstance(item, PixmapFilter):
+ self._filters.append(item)
+ elif callable(item):
+ # Now add any class specified filters with optional kwargs
+ self._filters.append(item(**kwargs))
+
+ self.cache = {}
+ self.get_pixmap(self.default_image)
+
+ def get(self, *args, **kwargs):
+ """Get a pixmap of any kind"""
+ return self.get_pixmap(*args, **kwargs)
+
+ def get_missing_image(self):
+ """Get a missing image when other images aren't found"""
+ return self.get(self.missing_image)
+
+ @staticmethod
+ def data_is_file(data):
+ """Test the file to see if it's a filename or not"""
+ return isinstance(data, str) and "<svg" not in data
+
+ def get_pixmap(self, data, **kwargs):
+ """
+ There are three types of images this might return.
+
+ 1. A named gtk-image such as "gtk-stop"
+ 2. A file on the disk such as "/tmp/a.png"
+ 3. Data as either svg or binary png
+
+ All pixmaps are cached for multiple use.
+ """
+ if "manager" not in kwargs:
+ kwargs["manager"] = self
+
+ if not data:
+ if not self.default_image:
+ return None
+ data = self.default_image
+
+ key = data[-30:] # bytes or string
+ if not key in self.cache:
+ # load the image from data or a filename/theme icon
+ img = None
+ try:
+ if self.data_is_file(data):
+ img = self.load_from_name(data)
+ else:
+ img = self.load_from_data(data)
+ except PixmapLoadError as err:
+ logging.warning(str(err))
+ return self.get_missing_image()
+
+ if img is not None:
+ self.cache[key] = self.apply_filters(img, **kwargs)
+
+ return self.cache[key]
+
+ def apply_filters(self, img, **kwargs):
+ """Apply all the filters to the given image"""
+ for lens in self._filters:
+ if lens.enabled:
+ img = lens.filter(img, **kwargs)
+ return img
+
+ def load_from_data(self, data):
+ """Load in memory picture file (jpeg etc)"""
+ # This doesn't work yet, returns None *shrug*
+ loader = GdkPixbuf.PixbufLoader()
+ if self.loader_size:
+ loader.set_size(*self.loader_size)
+ try:
+ if isinstance(data, str):
+ data = data.encode("utf-8")
+ loader.write(data)
+ loader.close()
+ except GLib.GError as err:
+ raise PixmapLoadError(f"Faled to load pixbuf from data: {err}")
+ return loader.get_pixbuf()
+
+ def load_from_name(self, name):
+ """Load a pixbuf from a name, filename or theme icon name"""
+ pixmap_path = self.pixmap_path(name)
+ if os.path.exists(pixmap_path):
+ try:
+ return GdkPixbuf.Pixbuf.new_from_file(pixmap_path)
+ except RuntimeError as msg:
+ raise PixmapLoadError(f"Faild to load pixmap '{pixmap_path}', {msg}")
+ elif (
+ self.icon_theme and "/" not in name and "." not in name and "<" not in name
+ ):
+ return self.theme_pixmap(name, size=self.theme_size)
+ raise PixmapLoadError(f"Failed to find pixmap '{name}' in {self.location}")
+
+ def theme_pixmap(self, name, size=32):
+ """Internal user: get image from gnome theme"""
+ size = size or 32
+ if not self.icon_theme.has_icon(name):
+ name = "image-missing"
+ return self.icon_theme.load_icon(name, size, 0)
+
+ def pixmap_path(self, name):
+ """Returns the pixmap path based on stored location"""
+ for filename in (
+ name,
+ os.path.join(self.location, f"{name}.svg"),
+ os.path.join(self.location, f"{name}.png"),
+ ):
+ if os.path.exists(filename) and os.path.isfile(filename):
+ return name
+ return os.path.join(self.location, name)
diff --git a/share/extensions/inkex/gui/tester.py b/share/extensions/inkex/gui/tester.py
new file mode 100644
index 0000000..c8ce5e7
--- /dev/null
+++ b/share/extensions/inkex/gui/tester.py
@@ -0,0 +1,78 @@
+# coding=utf-8
+#
+# Copyright 2022 Martin Owens <doctormo@geek-2.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 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>
+#
+"""
+Structures for consistant testing of Gtk GUI programs.
+"""
+
+import sys
+from gi.repository import Gtk, GLib
+
+
+class MainLoopProtection:
+ """
+ This protection class provides a way to launch the Gtk mainloop in a test
+ friendly way.
+
+ Exception handling hooks provide a way to see errors that happen
+ inside the main loop, raising them back to the caller.
+ A full timeout in seconds stops the gtk mainloop from operating
+ beyond a set time, acting as a kill switch in the event something
+ has gone horribly wrong.
+
+ Use:
+ with MainLoopProtection(timeout=10s):
+ app.run()
+ """
+
+ def __init__(self, timeout=10):
+ self.timeout = timeout * 1000
+ self._hooked = None
+ self._old_excepthook = None
+
+ def __enter__(self):
+ # replace sys.excepthook with our own and remember hooked raised error
+ self._old_excepthook = sys.excepthook
+ sys.excepthook = self.excepthook
+ # Remove mainloop by force if it doesn't die within 10 seconds
+ self._timeout = GLib.timeout_add(self.timeout, self.idle_exit)
+
+ def __exit__(self, exc, value, traceback): # pragma: no cover
+ """Put the except handler back, cancel the timer and raise if needed"""
+ if self._old_excepthook:
+ sys.excepthook = self._old_excepthook
+ # Remove the timeout, so we don't accidentally kill later mainloops
+ if self._timeout:
+ GLib.source_remove(self._timeout)
+ # Raise an exception if one happened during the test run
+ if self._hooked:
+ exc, value, traceback = self._hooked
+ if value and traceback:
+ raise value.with_traceback(traceback)
+
+ def idle_exit(self): # pragma: no cover
+ """Try to going to kill any running mainloop."""
+ GLib.idle_add(Gtk.main_quit)
+
+ def excepthook(self, ex_type, ex_value, traceback): # pragma: no cover
+ """Catch errors thrown by the Gtk mainloop"""
+ self.idle_exit()
+ # Remember the exception data for raising inside the test context
+ if ex_value is not None:
+ self._hooked = [ex_type, ex_value, traceback]
+ # Fallback and double print the exception (remove if double printing is problematic)
+ return self._old_excepthook(ex_type, ex_value, traceback)
diff --git a/share/extensions/inkex/gui/window.py b/share/extensions/inkex/gui/window.py
new file mode 100644
index 0000000..a5c1ef6
--- /dev/null
+++ b/share/extensions/inkex/gui/window.py
@@ -0,0 +1,201 @@
+#
+# Copyright 2012-2022 Martin Owens <doctormo@geek-2.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 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>
+#
+# pylint: disable=too-many-instance-attributes
+"""
+Wraps the gtk windows with something a little nicer.
+"""
+import logging
+
+from gi.repository import Gtk
+
+PROPS = {
+ "Box": ["expand", "fill", "padding", "pack-type"],
+ "Grid": ["top-attach", "left-attach", "height", "width"],
+ "Table": ["top-attach", "left-attach", "bottom-attach", "right-attach"],
+}
+
+
+def protect(cls, *methods):
+ """Simple check for protecting an inherrited class from having
+ certain methods over-ridden"""
+ if not isinstance(cls, type):
+ cls = type(cls)
+ for method in methods:
+ if method in cls.__dict__: # pragma: no cover
+ raise RuntimeError(
+ f"{cls.__name__} in {cls.__module__} has" f" protected def {method}()"
+ )
+
+
+class Window:
+ """
+ This wraps gtk windows and allows for having parent windows
+
+ name = 'name-of-the-window'
+
+ Should the window be the first loaded and end gtk when closed:
+
+ primary = True/False
+ """
+
+ primary = True
+ name = None
+
+ def __init__(self, gapp):
+ self.gapp = gapp
+ self.dead = False
+ self.parent = None
+ self.args = ()
+ ui_file = gapp.get_ui_file(self.name)
+
+ # Setup the gtk app connection
+ self.w_tree = Gtk.Builder()
+ self.widget = self.w_tree.get_object
+ self.w_tree.set_translation_domain(gapp.app_name)
+ self.w_tree.add_from_file(ui_file)
+
+ # Setup the gtk builder window
+ self.window = self.widget(self.name)
+ if not self.window: # pragma: no cover
+ raise KeyError(f"Missing window widget '{self.name}' from '{ui_file}'")
+
+ # Give us a window id to track this window
+ self.wid = str(hash(self.window))
+
+ def extract(self):
+ """Extract this window's container for use in other apps"""
+ for child in self.window.get_children():
+ self.window.remove(child)
+ return child
+
+ def init(self, parent=None, **kwargs):
+ """Initialise the window within the GtkApp"""
+ if "replace" not in kwargs:
+ protect(self, "destroy", "exit", "load_window", "proto_window")
+ self.args = kwargs
+ # Set object defaults
+ self.parent = parent
+
+ self.w_tree.connect_signals(self)
+
+ # These are some generic convience signals
+ self.window.connect("destroy", self.exit)
+
+ # If we have a parent window, then we expect not to quit
+ if self.parent:
+ self.window.set_transient_for(self.parent)
+ self.parent.set_sensitive(False)
+
+ # We may have some more gtk widgets to setup
+ self.load_widgets(**self.args)
+ self.window.show()
+
+ def load_window(self, name, *args, **kwargs):
+ """Load child window, automatically sets parent"""
+ kwargs["parent"] = self.window
+ return self.gapp.load_window(name, *args, **kwargs)
+
+ def load_widgets(self):
+ """Child class should use this to create widgets"""
+
+ def destroy(self, widget=None): # pylint: disable=unused-argument
+ """Destroy the window"""
+ logging.debug("Destroying Window '%s'", self.name)
+ self.window.destroy()
+ # We don't need to call self.exit(), handeled by window event.
+
+ def pre_exit(self):
+ """Internal method for what to do when the window has died"""
+
+ def exit(self, widget=None):
+ """Called when the window needs to exit."""
+ # Is the signal called by the window or by something else?
+ if not widget or not isinstance(widget, Gtk.Window):
+ self.destroy()
+ # Clean up any required processes
+ self.pre_exit()
+ if self.parent:
+ # We assume the parent didn't load another gtk loop
+ self.parent.set_sensitive(True)
+ # Exit our entire app if this is the primary window
+ # Or just remove from parent window list, which may still exit.
+ if self.primary:
+ logging.debug("Exiting the application")
+ self.gapp.exit()
+ else:
+ logging.debug("Removing Window %s from parent", self.name)
+ self.gapp.remove_window(self)
+ # Now finish up what ever is left to do now the window is dead.
+ self.dead = True
+ self.post_exit()
+ return widget
+
+ def post_exit(self):
+ """Called after we've killed the window"""
+
+ def if_widget(self, name):
+ """
+ Attempt to get the widget from gtk, but if not return a fake that won't
+ cause any trouble if we don't further check if it's real.
+ """
+ return self.widget(name) or FakeWidget(name)
+
+ def replace(self, old, new):
+ """Replace the old widget with the new widget"""
+ if isinstance(old, str):
+ old = self.widget(old)
+ if isinstance(new, str):
+ new = self.widget(new)
+ target = old.get_parent()
+ source = new.get_parent()
+ if target is not None:
+ if source is not None:
+ source.remove(new)
+ target.remove(old)
+ target.add(new)
+
+ @staticmethod
+ def get_widget_name(obj):
+ """Return the widget's name in the builder file"""
+ return Gtk.Buildable.get_name(obj)
+
+
+class ChildWindow(Window):
+ """
+ Base class for child window objects, these child windows are typically
+ window objects in the same gtk builder file as their parents. If you just want
+ to make a window that interacts with a parent window, use the normal
+ Window class and call with the optional parent attribute.
+ """
+
+ primary = False
+
+
+class FakeWidget:
+ """A fake widget class that can take calls"""
+
+ def __init__(self, name):
+ self._name = name
+
+ def __getattr__(self, name):
+ def _fake(*args, **kwargs):
+ logging.info("Calling fake method: %s:%s", args, kwargs)
+
+ return _fake
+
+ def __bool__(self):
+ return False