| # Copyright (C) 2020 The Android Open Source Project |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http:#www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| """Utilities for GTK. |
| |
| To work around various environment problems, import GTK modules |
| from here.""" |
| |
| import logging |
| import os |
| from os.path import dirname, join as pjoin |
| from functools import cmp_to_key |
| from contextlib import contextmanager |
| |
| from .util import ( |
| envvar_set, |
| ) |
| |
| mydir = dirname(__file__) # pylint: disable=invalid-name |
| |
| def add_ui_template(widget_class, children=()): |
| """Wire up a widget class to support XML UI definition""" |
| with open(pjoin(mydir, widget_class.__name__) + ".ui", "rb") as ui_file: |
| widget_class.set_template(GLib.Bytes(ui_file.read())) |
| for child in children: |
| widget_class.bind_template_child_full(child, False, 0) |
| |
| @contextmanager |
| def custom_gir_set(): |
| """Context manager that prepends pwd to GI_TYPELIB_PATH""" |
| gi_typelib_path = os.environ.get("GI_TYPELIB_PATH") |
| if gi_typelib_path: |
| gi_typelib_path = mydir + ":" + gi_typelib_path |
| else: |
| gi_typelib_path = mydir |
| with envvar_set("GI_TYPELIB_PATH", gi_typelib_path): |
| yield |
| |
| with custom_gir_set(): |
| # pylint: disable=no-name-in-module,unused-import,ungrouped-imports |
| import gi |
| gi.require_version("Gtk", "3.0") |
| gi.require_foreign("cairo") |
| from gi.repository import Gtk, Gdk, Gio, GLib, GObject |
| import cairo |
| gi.require_version("DctvGtkHack", "1.0") |
| from gi.repository import DctvGtkHack |
| gi.require_version("DctvGObjectHack", "1.0") |
| from gi.repository import DctvGObjectHack |
| |
| log = logging.getLogger(__name__) |
| |
| @contextmanager |
| def saved_cr(cairo_ctx): |
| """Context manager that saves and restores Cairo stage""" |
| cairo_ctx.save() |
| try: |
| yield |
| finally: |
| cairo_ctx.restore() |
| |
| @contextmanager |
| def saved_style(style): |
| """Context manager that saves and restores style""" |
| style.save() |
| try: |
| yield |
| finally: |
| style.restore() |
| |
| def _to_int(value): |
| int_value = int(value) |
| assert int_value == value |
| return int_value |
| |
| def gen_region_rectangles(region): |
| """Generate rectangles in Cairo region REGION""" |
| for i in range(region.num_rectangles()): |
| yield region.get_rectangle(i) |
| |
| def desc_adjustment(adj): |
| """Describe the state of a Gtk.Adjustment""" |
| return "<Adjustment {}>".format( |
| ", ".join("{}={!r}".format(k, adj.get_property(k)) |
| for k in ("lower", "page-increment", |
| "page-size", "step-increment", |
| "upper", "value"))) |
| |
| def desc_rect(area): |
| """Return a string description of GDK rectangle AREA""" |
| return "<Rect x={} y={} w={} h={}>".format( |
| area.x, area.y, area.width, area.height) |
| |
| def desc_region(region): |
| """Return a string description of Cairo region REGION""" |
| return "<Rgn {}>".format( |
| ", ".join(desc_rect(r) for r in gen_region_rectangles(region))) |
| |
| def get_clip_as_region(cr): |
| """Return the region corresponding to the current clip region""" |
| clip_region = cairo.Region() |
| for clip_rect in cr.copy_clip_rectangle_list(): |
| x, y, width, height = clip_rect |
| clip_region.union( |
| cairo.RectangleInt(_to_int(x), |
| _to_int(y), |
| _to_int(width), |
| _to_int(height))) |
| return clip_region |
| |
| def _clear_region(rgn): |
| rgn.xor(rgn) |
| |
| class ScrollOptimizer(object): |
| """Helps objects optimize scrolling |
| |
| Wraps widget drawing in code that caches and reuses rendered frames. |
| """ |
| |
| # pylint: disable=too-many-locals |
| |
| def __init__(self, owner): |
| assert isinstance(owner, Gtk.Widget) |
| owner.connect("unmap", self.__on_invalidation_event) |
| owner.connect("unrealize", self.__on_invalidation_event) |
| owner.connect("destroy", self.__on_destroy) |
| self.__owner = owner |
| self.__saved_state = None |
| self.__enabled = True |
| |
| def invalidate(self): |
| """Drop any cached rendered pixels""" |
| if self.__saved_state: |
| _clear_region(self.__saved_state[-1]) |
| |
| @contextmanager |
| def cached_render(self, window, cr_widget, offset_x, offset_y): |
| """Render with cached output to make scrolling fast. |
| |
| WINDOW is the window to which we're rendering, which must be our |
| owner's window or one of its children. |
| |
| CR_WIDGET is the draw-call Cairo context from GTK. OFFSET_X and |
| OFFSET_Y are the current scrolling offsets. |
| |
| This function yields a Cairo context that should be used to render |
| widget content. Note that the clip region in the new context may |
| differ from the clip region in the CR_WIDGET passed to this |
| function, and so callers shouldn't cache clipping bounds across a |
| call to cached_render. |
| |
| The function ensures that the cairo state in CR_WIDGET is |
| unchanged upon return. |
| """ |
| assert isinstance(offset_x, int) |
| assert isinstance(offset_y, int) |
| with saved_cr(cr_widget): |
| Gtk.cairo_transform_to_window(cr_widget, self.__owner, window) |
| if not self.__enabled: |
| yield cr_widget, get_clip_as_region(cr_widget) |
| return |
| saved_state = self.__saved_state |
| _w_x, _w_y, w_w, w_h = window.get_geometry() |
| if not saved_state: |
| surface = self.__owner.get_window().create_similar_surface( |
| cairo.CONTENT_COLOR, w_w, w_h) |
| prev_offset_x, prev_offset_y = offset_x, offset_y |
| valid_region = cairo.Region() |
| else: |
| surface, prev_offset_x, prev_offset_y, valid_region = saved_state |
| if surface.get_width() != w_w or surface.get_height() != w_h: |
| new_surface = self.__owner.get_window().create_similar_surface( |
| cairo.CONTENT_COLOR, w_w, w_h) |
| cr_new = cairo.Context(new_surface) |
| cr_new.set_operator(cairo.OPERATOR_SOURCE) |
| cr_new.set_source_surface(surface, 0, 0) |
| cr_new.paint() |
| surface = new_surface |
| del cr_new |
| |
| displacement_x = offset_x - prev_offset_x |
| displacement_y = offset_y - prev_offset_y |
| |
| if max(abs(displacement_x), abs(displacement_y)) > 100000: |
| # Avoid giving huge coordinate values to Cairo |
| log.debug("ignoring scrolling pixel cache after big jump") |
| displacement_x = 0 |
| displacement_y = 0 |
| _clear_region(valid_region) |
| |
| cr_surface = cairo.Context(surface) |
| valid_region.translate(-displacement_x, -displacement_y) |
| allocation = cairo.RectangleInt(0, 0, w_w, w_h) |
| valid_region.intersect(allocation) |
| |
| with saved_cr(cr_surface): # Self-copy |
| cr_surface.set_operator(cairo.OPERATOR_SOURCE) |
| Gdk.cairo_region(cr_surface, valid_region) |
| cr_surface.clip() |
| cr_surface.push_group() |
| cr_surface.set_source_surface(surface, |
| -displacement_x, |
| -displacement_y) |
| cr_surface.paint() |
| cr_surface.pop_group_to_source() |
| cr_surface.paint() |
| with saved_cr(cr_surface): |
| clip_region = get_clip_as_region(cr_widget) |
| clip_region.subtract(valid_region) |
| clip_region.intersect(allocation) |
| Gdk.cairo_region(cr_surface, clip_region) |
| cr_surface.clip() |
| yield cr_surface, clip_region.copy() |
| with saved_cr(cr_widget): |
| cr_widget.set_source_surface(surface, 0, 0) |
| cr_widget.paint() |
| valid_region.union(clip_region) |
| self.__saved_state = surface, offset_x, offset_y, valid_region |
| |
| def __on_invalidation_event(self, *_ign): |
| self.__saved_state = None |
| |
| def __on_destroy(self, _data): |
| assert not self.__saved_state |
| self.__owner = None # Make sure references get broken |
| |
| def distribute_natural_allocation(total_space, requests): |
| """Translation of GTK's distribute_natural_allocation, which we can't call""" |
| # The binding doesn't understand that the function accepts an |
| # _array_ of objects, so the bound function is useless for us. |
| min_sizes = [] |
| nat_sizes = [] |
| for request_min, request_nat in requests: |
| min_sizes.append(request_min) |
| nat_sizes.append(request_nat) |
| extra_space = max(0, total_space - sum(min_sizes)) |
| n_requested_sizes = len(min_sizes) |
| spreading = list(range(n_requested_sizes)) |
| def _compare_gap(c1, c2): |
| d1 = max(nat_sizes[c1] - min_sizes[c1], 0) |
| d2 = max(nat_sizes[c2] - min_sizes[c2], 0) |
| delta = d2 - d1 |
| return delta or c2 - c1 |
| spreading.sort(key=cmp_to_key(_compare_gap)) |
| i = n_requested_sizes - 1 |
| while extra_space > 0 and i >= 0: # pylint: disable=compare-to-zero |
| glue = (extra_space + i) // (i + 1) |
| gap = nat_sizes[spreading[i]] - min_sizes[spreading[i]] |
| extra = min(glue, gap) |
| min_sizes[spreading[i]] += extra |
| extra_space -= extra |
| i -= 1 |
| return min_sizes, extra_space |
| |
| def safe_destroy(widget): |
| """Refcount-safe destroy call""" |
| # pylint: disable=protected-access |
| if widget: |
| widget._ref() |
| try: |
| widget.destroy() |
| except: |
| # In the success case, destroy dropped its own refcount |
| widget._unref() |
| raise |
| |
| def make_menu_item(*, callback=None, cls=Gtk.MenuItem, **kwargs): |
| """Make a Gtk.MenuItem attached to a callback""" |
| menu_item = cls(**kwargs) |
| if callback: |
| def _activate_thunk(_menu_item): |
| callback() |
| menu_item.connect("activate", _activate_thunk) |
| return menu_item |
| |
| _NO_VALUE = object() |
| def make_gdk_window( |
| *, |
| parent=None, |
| title=_NO_VALUE, |
| event_mask=0, |
| x=_NO_VALUE, |
| y=_NO_VALUE, |
| width, |
| height, |
| wclass=Gdk.WindowWindowClass.INPUT_OUTPUT, |
| visual=_NO_VALUE, |
| window_type=Gdk.WindowType.CHILD, |
| cursor=_NO_VALUE, |
| override_redirect=_NO_VALUE, |
| type_hint=_NO_VALUE |
| ): |
| """Create a new GDK window using given parameters""" |
| attr_mask = 0 |
| attr = Gdk.WindowAttr() |
| if title is not _NO_VALUE: |
| attr_mask |= Gdk.WindowAttributesType.TITLE |
| attr.title = title |
| attr.event_mask = event_mask |
| if x is not _NO_VALUE: |
| attr_mask |= Gdk.WindowAttributesType.X |
| attr.x = x |
| if y is not _NO_VALUE: |
| attr_mask |= Gdk.WindowAttributesType.Y |
| attr.y = y |
| attr.width = width |
| attr.height = height |
| attr.wclass = wclass |
| if visual is not _NO_VALUE: |
| attr_mask |= Gdk.WindowAttributesType.VISUAL |
| attr.visual = visual |
| attr.window_type = window_type |
| if cursor is not _NO_VALUE: |
| attr_mask |= Gdk.WindowAttributesType.CURSOR |
| attr.cursor = cursor |
| if override_redirect is not _NO_VALUE: |
| attr_mask |= Gdk.WindowAttributesType.NOREDIR |
| attr.override_redirect = override_redirect |
| if type_hint is not _NO_VALUE: |
| attr_mask |= Gdk.WindowAttributesType.TYPE_HINT |
| attr.type_hint = type_hint |
| return Gdk.Window(parent, attr, Gdk.WindowAttributesType(attr_mask)) |
| |
| def make_gdk_rect(x, y, width, height): |
| """Make a GDK rectangle object""" |
| rect = Gdk.Rectangle() |
| rect.x = x |
| rect.y = y |
| rect.width = width |
| rect.height = height |
| return rect |
| |
| def window_ancestor_p(ancestor, descendant): |
| """Determine whether DESCENDANT is a descendant of ANCESTOR""" |
| while descendant: |
| if descendant is ancestor: |
| return True |
| descendant = descendant.get_parent() |
| return False |
| |
| def work_around_style_bug(widget): |
| """Clear cached widget path, working around GTK bug""" |
| # Work around https://gitlab.gnome.org/GNOME/gtk/issues/116 by |
| # manually clearing the cached widget path, forcing GTK to update |
| # the path to incorporate any style changes. |
| gtk_widget_path = GLib.quark_from_string("gtk-widget-path") |
| DctvGObjectHack.g_object_set_qdata(widget, gtk_widget_path, None) |
| |
| def gtk_noop(): |
| """Special NOOP for pylint.py""" |
| |
| def gobject_property_object(fn): |
| """Wrapper for making pylint happy""" |
| return GObject.Property(type=object)(fn) # pylint: disable=not-callable |
| |
| def gobject_property_str(fn): |
| """Wrapper for making pylint happy""" |
| return GObject.Property(type=str)(fn) # pylint: disable=not-callable |