bundle: update (2026-01-16)

This commit is contained in:
2026-01-16 00:15:37 +00:00
parent 5398165072
commit 94d3219d57
169 changed files with 39901 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
# 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](https://gitlab.gnome.org/GNOME/glade). Once you have a layout of all the widgets you want, you then make a GtkApp and Window classes inside your Python program, when the GtkApp is run, the 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 parts 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.

View File

@@ -0,0 +1,58 @@
#
# 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 is a wrapper layer to make interacting with Gtk a little less painful.
The main issues with Gtk is that it expects an awful 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 convenience 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 os
import sys
import logging
import threading
from ..utils import DependencyError
try:
import gi
gi.require_version("Gtk", "4.0")
# Importing while covering stderr because pygobject has broken
# warnings support and will force import warnings on our users.
tmp, sys.stderr = sys.stderr, None # type: ignore
from gi.repository import Gtk, GLib
sys.stderr = tmp # type: ignore
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
from .listview import TreeView, IconView, ViewColumn, ViewSort, Separator
from .pixmap import PixmapManager

View File

@@ -0,0 +1,62 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright 2011-2022 Martin Owens <doctormo@geek-2.com>
"""
Wraps Gtk.Application, providing a way to load a Gtk.Builder
with a specific ui file containing windows, and building
a usable pythonic interface from them.
"""
import os
import logging
from gi.repository import Gtk, Gdk, Gio
class GtkApp(Gtk.Application):
"""A very thin wrapper around Gtk.Application"""
app_name = None
"""The application id"""
prefix = ""
"""Folder prefix added to ui_dir"""
ui_dir = "./"
"""This is often the local directory"""
ui_file = None
"""If a single file is used for multiple windows"""
def __init__(self, **kwargs):
"""Creates a new GtkApp."""
self.kwargs = kwargs
super().__init__(
application_id=self.app_name, flags=Gio.ApplicationFlags.NON_UNIQUE
)
self.connect("activate", lambda _: self.on_activate())
if self.ui_dir:
icontheme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default())
icontheme.add_search_path(self.ui_dir)
def run_wrapped(self):
"""Calls self.run() but catches keyboard interrupts"""
try:
self.run()
except KeyboardInterrupt: # pragma: no cover
logging.info("User Interrupted")
def on_activate(self):
"""Called when the application is activated"""
raise NotImplementedError("Must override on_activate in subclass")
def get_ui_file(self, window):
"""Load any given ui 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 ui file is missing: {paths}")

View File

@@ -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/>
#
"""Convenience 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 propagate exceptions; see its 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 acquire 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)
GLib.idle_add(handler, None, 0)
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

View File

@@ -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("cursor-changed", 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"""

View File

@@ -0,0 +1,360 @@
#
# 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, Gdk, GLib, GdkPixbuf
import cairo
ICON_THEME = Gtk.IconTheme.get_for_display(Gdk.Display.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 isinstance(img, Gtk.IconPaintable):
# Temporary porting hack: rasterise iconpaintable to pixbuf
# https://discourse.gnome.org/t/convert-symbolic-icon-to-gdktexture-gdkpixbuf/29324/3
w = img.get_intrinsic_width()
h = img.get_intrinsic_height()
snapshot = Gtk.Snapshot()
img.snapshot(snapshot, w, h)
node = snapshot.to_node()
surface = cairo.ImageSurface(cairo.Format.ARGB32, w, h)
ctx = cairo.Context(surface)
node.draw(ctx)
img = Gdk.pixbuf_get_from_surface(surface, 0, 0, w, h)
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"Failed 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"Failed 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.lookup_icon(name, [], size, 1, Gtk.TextDirection.NONE, 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)

View File

@@ -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 Gio, 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.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 exit(self): # pragma: no cover
"""Try to going to kill any running mainloop."""
Gio.Application.get_default().quit()
def excepthook(self, ex_type, ex_value, traceback): # pragma: no cover
"""Catch errors thrown by the Gtk mainloop"""
self.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)

View File

@@ -0,0 +1,38 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright 2012-2022 Martin Owens <doctormo@geek-2.com>
"""
Wraps Gtk.Window with something a little nicer.
"""
from gi.repository import Gtk
class Window:
"""A very thin wrapper around Gtk.Window"""
name = None
"""The name of the window to load from the ui file"""
def __init__(self, gapp):
"""Create a new Window and load its Gtk.Window from a ui file"""
self.gapp = gapp
ui_file = gapp.get_ui_file(self.name)
# Set up the builder and load ui
self.builder = Gtk.Builder(self)
self.builder.set_translation_domain(gapp.app_name)
self.builder.add_from_file(ui_file)
self.widget = self.builder.get_object
# Find the window in the ui
self.window = self.widget(self.name)
if not self.window: # pragma: no cover
raise KeyError(f"Missing window widget '{self.name}' from '{ui_file}'")
# Keep application alive
self.window.set_application(self.gapp)
def close(self, widget=None):
"""Closes the window. Can be connected to signals."""
self.window.close()