# -*- coding: utf-8 -*- # Copyright © 2012-2017 marmuta # # This file is part of Onboard. # # Onboard 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. # # Onboard 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 . """ Keyboard layout view """ from __future__ import division, print_function, unicode_literals import time from math import pi import cairo from Onboard.Version import require_gi_versions require_gi_versions() from gi.repository import Gtk, Gdk, GdkPixbuf from Onboard.utils import Rect, \ roundrect_arc, roundrect_curve, \ gradient_line, brighten, \ unicode_str from Onboard.WindowUtils import get_monitor_dimensions from Onboard.KeyGtk import Key from Onboard.KeyCommon import LOD from Onboard.definitions import UIMask ### Logging ### import logging _logger = logging.getLogger("LayoutView") ############### ### Config Singleton ### from Onboard.Config import Config config = Config() ######################## class LayoutView: """ Viewer for a tree of layout items. """ def __init__(self, keyboard): self.keyboard = keyboard self.supports_alpha = False self._lod = LOD.FULL self._font_sizes_valid = False self._shadow_quality_valid = False self._last_canvas_shadow_rect = Rect() self._starting_up = True self._keys_pre_rendered = False self.keyboard.register_view(self) def cleanup(self): self.keyboard.deregister_view(self) # free xserver memory self.invalidate_keys() self.invalidate_shadows() def handle_realize_event(self): self.update_touch_input_mode() self.update_input_event_source() def on_layout_loaded(self): """ Layout has been loaded. """ self.invalidate_shadow_quality() def get_layout(self): return self.keyboard.layout def get_color_scheme(self): return self.keyboard.color_scheme def invalidate_for_resize(self, lod=LOD.FULL): self.invalidate_keys() if self._lod == LOD.FULL: self.invalidate_shadows() self.invalidate_font_sizes() # self.invalidate_label_extents() self.keyboard.invalidate_for_resize() def invalidate_font_sizes(self): """ Update font_sizes at the next possible chance. """ self._font_sizes_valid = False def invalidate_keys(self): """ Clear cached key surfaces, e.g. after resizing, change of theme settings. """ layout = self.get_layout() if layout: for item in layout.iter_keys(): item.invalidate_key() def invalidate_images(self): """ Clear cached images, e.g. after changing window_scaling_factor. """ layout = self.get_layout() if layout: for item in layout.iter_keys(): item.invalidate_image() def invalidate_shadows(self): """ Clear cached shadow surfaces, e.g. after resizing, change of theme settings. """ layout = self.get_layout() if layout: for item in layout.iter_keys(): item.invalidate_shadow() def invalidate_shadow_quality(self): self._shadow_quality_valid = False def invalidate_label_extents(self): """ Clear cached resolution independent label extents, e.g. after changes to the systems font dpi setting (gtk-xft-dpi). """ layout = self.get_layout() if layout: for item in layout.iter_keys(): item.invalidate_label_extents() def reset_lod(self): """ Reset to full level of detail """ if self._lod != LOD.FULL: self._lod = LOD.FULL self.invalidate_for_resize() self.keyboard.invalidate_context_ui() self.keyboard.invalidate_canvas() self.keyboard.commit_ui_updates() def is_visible(self): return None def set_visible(self, visible): pass def toggle_visible(self): pass def raise_to_top(self): pass def redraw(self, items=None, invalidate=True): """ Queue redrawing for individual keys or the whole keyboard. """ if items is None: self.queue_draw() elif len(items) == 0: pass else: area = None for item in items: rect = item.get_canvas_border_rect() area = area.union(rect) if area else rect # assume keys need to be refreshed when actively redrawn # e.g. for pressed state changes, dwell progress updates... if invalidate and \ item.is_key(): item.invalidate_key() # account for stroke width, anti-aliasing if self.get_layout(): extra_size = items[0].get_extra_render_size() area = area.inflate(*extra_size) self.queue_draw_area(*area) def redraw_labels(self, invalidate=True): self.redraw(self.update_labels(), invalidate) def update_transparency(self): pass def update_input_event_source(self): self.register_input_events(True, config.is_event_source_gtk()) def update_touch_input_mode(self): self.set_touch_input_mode(config.keyboard.touch_input) def can_delay_sequence_begin(self, sequence): """ Veto gesture delay for move buttons. Have the keyboard start moving right away and not lag behind the pointer. """ layout = self.get_layout() if layout: for item in layout.find_ids(["move"]): if item.is_path_visible() and \ item.is_point_within(sequence.point): return False return True def show_touch_handles(self, show, auto_hide): pass def apply_ui_updates(self, mask): if mask & UIMask.SIZE: self.invalidate_for_resize() def update_layout(self): pass def process_updates(self): """ Draw now, synchronously. """ window = self.get_window() if window: window.process_updates(True) def render(self, context): """ Pre-render key surfaces for instant initial drawing. """ # lazily update font sizes and labels if not self._font_sizes_valid: self.update_labels() layout = self.get_layout() if not layout: return self._auto_select_shadow_quality(context) # run through all visible layout items for item in layout.iter_visible_items(): if item.is_key(): item.draw_shadow_cached(context) item.draw_cached(context) self._keys_pre_rendered = True def _can_draw_cached(self, lod): """ Draw cached key surfaces? On first startup draw cached only if keys were pre-rendered, i.e. the time to render keys was hidden before the window was shown. We can't easily pre-render keys in xembed mode because the window size is unknown in advance. Draw there once uncached instead (faster). """ return (lod == LOD.FULL) and \ (not self._starting_up or self._keys_pre_rendered) def draw(self, widget, context): if not Gtk.cairo_should_draw_window(context, widget.get_window()): return lod = self._lod draw_cached = self._can_draw_cached(lod) # lazily update font sizes and labels if not self._font_sizes_valid: self.update_labels(lod) draw_rect = self.get_damage_rect(context) # draw background decorated = self._draw_background(context, lod) layout = self.get_layout() if not layout: return # draw layer 0 and None-layer background layer_ids = layout.get_layer_ids() if config.window.transparent_background: alpha = 0.0 elif decorated: alpha = self.get_background_rgba()[3] else: alpha = 1.0 self._draw_layer_key_background(context, alpha, None, None, lod) if layer_ids: self._draw_layer_key_background(context, alpha, None, layer_ids[0], lod) # run through all visible layout items for item in layout.iter_visible_items(): if item.layer_id: self._draw_layer_background(context, item, layer_ids, decorated) # draw key if item.is_key() and \ draw_rect.intersects(item.get_canvas_border_rect()): if draw_cached: item.draw_cached(context) else: item.draw(context, lod) self._starting_up = False return decorated def _draw_background(self, context, lod): """ Draw keyboard background """ transparent_bg = False plain_bg = False if config.is_keep_xembed_frame_aspect_ratio_enabled(): if self.supports_alpha: self._clear_xembed_background(context) transparent_bg = True else: plain_bg = True elif config.xid_mode: # xembed mode # Disable transparency in lightdm and g-s-s for now. # There are too many issues and there is no real # visual improvement. plain_bg = True elif config.has_window_decoration(): # decorated window if self.supports_alpha and \ config.window.transparent_background: self._clear_background(context) else: plain_bg = True else: # undecorated window if self.supports_alpha: self._clear_background(context) if not config.window.transparent_background: transparent_bg = True else: plain_bg = True if plain_bg: self._draw_plain_background(context) if transparent_bg: self._draw_transparent_background(context, lod) return transparent_bg def _clear_background(self, context): """ Clear the whole gtk background. Makes the whole strut transparent in xembed mode. """ context.save() context.set_operator(cairo.OPERATOR_CLEAR) context.paint() context.restore() def _clear_xembed_background(self, context): """ fill with plain layer 0 color; no alpha support required """ rect = Rect(0, 0, self.get_allocated_width(), self.get_allocated_height()) # draw background image if config.get_xembed_background_image_enabled(): pixbuf = self._get_xembed_background_image() if pixbuf: src_size = (pixbuf.get_width(), pixbuf.get_height()) x, y = 0, rect.bottom() - src_size[1] Gdk.cairo_set_source_pixbuf(context, pixbuf, x, y) context.paint() # draw solid colored bar on top (with transparency, usually) rgba = config.get_xembed_background_rgba() if rgba is None: rgba = self.get_background_rgba() rgba[3] = 0.5 context.set_source_rgba(*rgba) context.rectangle(*rect) context.fill() def _get_xembed_background_image(self): """ load the desktop background image in Unity """ try: pixbuf = self._xid_background_image except AttributeError: size, size_mm = get_monitor_dimensions(self) filename = config.get_desktop_background_filename() if not filename or \ size[0] <= 0 or size[1] <= 0: pixbuf = None else: try: # load image pixbuf = GdkPixbuf.Pixbuf.new_from_file(filename) # Scale image to mimic the behavior of gnome-screen-saver. # Take the largest, aspect correct, centered rectangle # that fits on the monitor. rm = Rect(0, 0, size[0], size[1]) rp = Rect(0, 0, pixbuf.get_width(), pixbuf.get_height()) ra = rm.inscribe_with_aspect(rp) pixbuf = pixbuf.new_subpixbuf(*ra) pixbuf = pixbuf.scale_simple(size[0], size[1], GdkPixbuf.InterpType.BILINEAR) except Exception as ex: # private exception gi._glib.GError when # librsvg2-common wasn't installed _logger.error("_get_xembed_background_image(): " + \ unicode_str(ex)) pixbuf = None self._xid_background_image = pixbuf return pixbuf def _draw_transparent_background(self, context, lod): """ fill with the transparent background color """ corner_radius = config.CORNER_RADIUS rect = self.get_keyboard_frame_rect() fill = self.get_background_rgba() if self.can_draw_sidebars(): self._draw_side_bars(context) fill_gradient = config.theme_settings.background_gradient if lod == LOD.MINIMAL or \ fill_gradient == 0: context.set_source_rgba(*fill) else: fill_gradient /= 100.0 direction = config.theme_settings.key_gradient_direction alpha = -pi/2.0 + pi * direction / 180.0 gline = gradient_line(rect, alpha) pat = cairo.LinearGradient (*gline) rgba = brighten(+fill_gradient*.5, *fill) pat.add_color_stop_rgba(0, *rgba) rgba = brighten(-fill_gradient*.5, *fill) pat.add_color_stop_rgba(1, *rgba) context.set_source (pat) if config.xid_mode: frame = False else: frame = self.can_draw_frame() if frame: roundrect_arc(context, rect, corner_radius) else: context.rectangle(*rect) context.fill() if frame: self.draw_window_frame(context, lod) self.draw_keyboard_frame(context, lod) def _draw_side_bars(self, context): """ Transparent bars left and right of the aspect corrected keyboard frame. """ rgba = self.get_background_rgba() rgba[3] = 0.5 rwin = Rect(0, 0, self.get_allocated_width(), self.get_allocated_height()) rframe = self.get_keyboard_frame_rect() if rwin.w > rframe.w: r = rframe.copy() context.set_source_rgba(*rgba) context.set_line_width(0) r.x = rwin.left() r.w = rframe.left() - rwin.left() context.rectangle(*r) context.fill() r.x = rframe.right() r.w = rwin.right() - rframe.right() context.rectangle(*r) context.fill() def can_draw_frame(self): """ overloaded in KeyboardWidget """ return True def can_draw_sidebars(self): """ overloaded in KeyboardWidget """ return False def draw_window_frame(self, context, lod): pass def draw_keyboard_frame(self, context, lod): """ draw frame around the (potentially aspect corrected) keyboard """ corner_radius = config.CORNER_RADIUS rect = self.get_keyboard_frame_rect() fill = self.get_background_rgba() # inner decoration line line_rect = rect.deflate(1) roundrect_arc(context, line_rect, corner_radius) context.stroke() def _draw_plain_background(self, context, layer_index = 0): """ fill with plain layer 0 color; no alpha support required """ rgba = self._get_layer_fill_rgba(layer_index) context.set_source_rgba(*rgba) context.paint() def _draw_layer_background(self, context, item, layer_ids, decorated): # layer background layer_index = layer_ids.index(item.layer_id) parent = item.parent if parent and \ layer_index != 0: rect = parent.get_canvas_rect() context.rectangle(*rect.inflate(1)) color_scheme = self.get_color_scheme() if color_scheme: rgba = color_scheme.get_layer_fill_rgba(layer_index) else: rgba = [0.5, 0.5, 0.5, 0.9] context.set_source_rgba(*rgba) context.fill() # per-layer key background self._draw_layer_key_background(context, 1.0, item, item.layer_id) def _draw_layer_key_background(self, context, alpha = 1.0, item = None, layer_id = None, lod = LOD.FULL): self._draw_dish_key_background(context, alpha, item, layer_id) self._draw_shadows(context, layer_id, lod) def _draw_dish_key_background(self, context, alpha = 1.0, item = None, layer_id = None): """ Black background following the contours of key clusters to simulate the opening in the keyboard plane. """ if config.theme_settings.key_style == "dish": layout = self.get_layout() context.push_group() context.set_source_rgba(0, 0, 0, 1) enlargement = layout.context.scale_log_to_canvas((0.8, 0.8)) corner_radius = layout.context.scale_log_to_canvas_x(2.4) if item is None: item = layout for key in item.iter_layer_keys(layer_id): rect = key.get_canvas_fullsize_rect() rect = rect.inflate(*enlargement) roundrect_curve(context, rect, corner_radius) context.fill() context.pop_group_to_source() context.paint_with_alpha(alpha); def _draw_shadows(self, context, layer_id, lod): """ Draw drop shadows for all keys. """ # Shadows are drawn at odd positions when resizing while # docked and extended with side bars visible. # -> Turn them off while resizing. Improves rendering speed a bit too. if lod < LOD.FULL: return if not config.theme_settings.key_shadow_strength: return self._auto_select_shadow_quality(context) context.save() self.set_shadow_scale(context, lod) draw_rect = self.get_damage_rect(context) layout = self.get_layout() for item in layout.iter_layer_keys(layer_id): if draw_rect.intersects(item.get_canvas_border_rect()): item.draw_shadow_cached(context) context.restore() def _auto_select_shadow_quality(self, context): """ auto-select shadow quality """ if not self._shadow_quality_valid: quality = self._probe_shadow_performance(context) Key.set_shadow_quality(quality) self._shadow_quality_valid = True def _probe_shadow_performance(self, context): """ Determine shadow quality based on the estimated render time of the first layer's shadows. """ probe_begin = time.time() quality = None layout = self.get_layout() max_total_time = 0.03 # upper limit refreshing all key's shadows [s] max_probe_keys = 10 keys = None for layer_id in layout.get_layer_ids(): layer_keys = list(layout.iter_layer_keys(layer_id)) num_first_layer_keys = len(layer_keys) keys = layer_keys[:max_probe_keys] break if keys: for quality, (steps, alpha) in enumerate(Key._shadow_presets): begin = time.time() for key in keys: key.create_shadow_surface(context, steps, 0.1) elapsed = time.time() - begin estimate = elapsed / len(keys) * num_first_layer_keys _logger.debug("Probing shadow performance: " "estimated full refresh time {:6.1f}ms " "at quality {}, {} steps." \ .format(estimate * 1000, quality, steps)) if estimate > max_total_time: break _logger.info("Probing shadow performance took {:.1f}ms. " "Selecting quality {}." \ .format((time.time() - probe_begin) * 1000, quality)) return quality def set_shadow_scale(self, context, lod): """ Shadows aren't normally refreshed while resizing. -> scale the cached ones to fit the new canvas size. Occasionally refresh them anyway if scaling becomes noticeable. """ r = self.get_keyboard_frame_rect() if lod < LOD.FULL: rl = self._last_canvas_shadow_rect scale_x = r.w / rl.w scale_y = r.h / rl.h # scale in a reasonable range? -> draw stretched shadows smin = 0.8 smax = 1.2 if smax > scale_x > smin and \ smax > scale_y > smin: context.scale(scale_x, scale_y) else: # else scale is too far out -> refresh shadows self.invalidate_shadows() self._last_canvas_shadow_rect = r else: self._last_canvas_shadow_rect = r def _get_layer_fill_rgba(self, layer_index): color_scheme = self.get_color_scheme() if color_scheme: return color_scheme.get_layer_fill_rgba(layer_index) else: return [0.5, 0.5, 0.5, 1.0] def get_background_rgba(self): """ layer 0 color * background_transparency """ layer0_rgba = self._get_layer_fill_rgba(0) background_alpha = config.window.get_background_opacity() background_alpha *= layer0_rgba[3] return layer0_rgba[:3] + [background_alpha] def get_popup_window_rgba(self, element = "border"): color_scheme = self.get_color_scheme() if color_scheme: rgba = color_scheme.get_window_rgba("key-popup", element) else: rgba = [0.8, 0.8, 0.8, 1.0] background_alpha = config.window.get_background_opacity() background_alpha *= rgba[3] return rgba[:3] + [background_alpha] def get_damage_rect(self, context): clip_rect = Rect.from_extents(*context.clip_extents()) # Draw a little more than just the clip_rect. # Prevents glitches around pressed keys in at least classic theme. layout = self.get_layout() if layout: extra_size = layout.context.scale_log_to_canvas((2.0, 2.0)) else: extra_size = 0, 0 return clip_rect.inflate(*extra_size) def get_keyboard_frame_rect(self): """ Rectangle of the potentially aspect-corrected frame around the layout. """ layout = self.get_layout() if layout: rect = layout.get_canvas_border_rect() rect = rect.inflate(self.get_frame_width()) else: rect = Rect(0, 0, self.get_allocated_width(), self.get_allocated_height()) return rect.int() def is_docking_expanded(self): return self.window.docking_enabled and self.window.docking_expanded def update_labels(self, lod = LOD.FULL): """ Iterate through all key groups and set each key's label font size to the maximum possible for that group. """ changed_keys = set() layout = self.get_layout() mod_mask = self.keyboard.get_mod_mask() if layout: if lod == LOD.FULL: # no label changes necessary while dragging for key in layout.iter_keys(): old_label = key.get_label() key.configure_label(mod_mask) if key.get_label() != old_label: changed_keys.add(key) for keys in layout.get_key_groups().values(): max_size = 0 for key in keys: best_size = key.get_best_font_size(mod_mask) if best_size: if key.ignore_group: if key.font_size != best_size: key.font_size = best_size changed_keys.add(key) else: if not max_size or best_size < max_size: max_size = best_size for key in keys: if key.font_size != max_size and \ not key.ignore_group: key.font_size = max_size changed_keys.add(key) self._font_sizes_valid = True return tuple(changed_keys) def get_key_at_location(self, point): layout = self.get_layout() keyboard = self.keyboard if layout and keyboard: # may be gone on exit return layout.get_key_at(point, keyboard.active_layer) return None def get_xid(self): # Zesty, X, Gtk 3.22: XInput select_events() on self leads to # LP: #1636252. On the first call to get_xid() of a child widget, # Gtk creates a new native X Window with broken transparency. # The toplevel window ought to always have a native X window, so # we'll pick that one instead and skip on-the fly creation. # TouchInput isn't used for anything other than full client areas # yet, so in principle this shouldn't be a problem. toplevel = self.get_toplevel() if toplevel: topwin = toplevel.get_window() if topwin: return topwin.get_xid() return 0