blob: fc99d8b2f49f34f1d39d4691b5f5276fae980a8d [file] [log] [blame]
# 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