# -*- coding: utf-8 -*- # Copyright © 2013-2016 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 . """ GTK keyboard widget """ from __future__ import division, print_function, unicode_literals import cairo from Onboard.Version import require_gi_versions require_gi_versions() from gi.repository import Gdk, Gtk, Pango, PangoCairo from Onboard.utils import Rect, roundrect_arc, Version from Onboard.Timer import Timer from Onboard.WindowUtils import limit_window_position, \ get_monitor_rects, \ canvas_to_root_window_rect, \ get_monitor_dimensions, \ WindowRectTracker, \ gtk_has_resize_grip_support from Onboard.TouchInput import TouchInput from Onboard import KeyCommon from Onboard.Layout import LayoutRoot, LayoutPanel from Onboard.LayoutView import LayoutView from Onboard.KeyGtk import RectKey from Onboard.KeyCommon import ImageSlot import Onboard.osk as osk ### Logging ### import logging _logger = logging.getLogger(__name__) ############### ### Config Singleton ### from Onboard.Config import Config config = Config() ######################## # prepare mask for faster access BUTTON123_MASK = Gdk.ModifierType.BUTTON1_MASK | \ Gdk.ModifierType.BUTTON2_MASK | \ Gdk.ModifierType.BUTTON3_MASK class TouchFeedback: """ Display magnified labels as touch feedback """ def __init__(self): self._key_feedback_popup_pool = [] self._visible_key_feedback_popups = {} def show(self, key, view): if not key in self._visible_key_feedback_popups: # not already shown? r = key.get_canvas_border_rect() root_rect = canvas_to_root_window_rect(view, r) toplevel = view.get_toplevel() popup = self._get_free_key_feedback_popup() if popup is None: popup = LabelPopup() popup.set_transient_for(toplevel) self._key_feedback_popup_pool.append(popup) popup.realize() # Set window size w, h = self._get_popup_size(popup) popup.set_default_size(w, h) popup.resize(w, h) popup.set_key(key) popup.position_at(root_rect.x + root_rect.w * 0.5, root_rect.y, 0.5, 1) popup.supports_alpha = view.supports_alpha if popup.supports_alpha: popup.set_opacity(toplevel.get_opacity()) popup.show_all() self._visible_key_feedback_popups[key] = popup def hide(self, key = None): keys = [key] if key else list(self._visible_key_feedback_popups.keys()) for _key in keys: popup = self._visible_key_feedback_popups.get(_key) if popup: popup.hide() popup.set_key(None) del self._visible_key_feedback_popups[_key] def raise_all(self): for key, popup in self._visible_key_feedback_popups.items(): win = popup.get_window() if win: win.raise_() def _get_free_key_feedback_popup(self): """ Get a currently unused one from the pool of popups. """ for popup in self._key_feedback_popup_pool: if not popup.get_key(): return popup return None def _get_popup_size(self, window): DEFAULT_POPUP_SIZE_MM = 18.0 MAX_POPUP_SIZE_PX = 120.0 # fall-back if phys. monitor size unavail. gdk_win = window.get_window() w = config.keyboard.touch_feedback_size if w == 0: sz, sz_mm = get_monitor_dimensions(window) if sz[0] > 0 and sz_mm[0] > 0: default_size_mm = DEFAULT_POPUP_SIZE_MM # scale for hires displays if gdk_win: try: default_size_mm *= gdk_win.get_scale_factor() # from Gdk 3.10 except AttributeError: pass w = sz[0] * default_size_mm / sz_mm[0] elif sz[0] > 0: w = min(sz[0] / 12.0, MAX_POPUP_SIZE_PX) else: # LP #1633284 w = MAX_POPUP_SIZE_PX return w, w * (1.0 + LabelPopup.ARROW_HEIGHT) class KeyboardPopup(WindowRectTracker, Gtk.Window): """ Abstract base class for popups. """ def __init__(self): self._opacity = 1.0 WindowRectTracker.__init__(self) args = { "skip_taskbar_hint" : True, "skip_pager_hint" : True, "urgency_hint" : False, "decorated" : False, "accept_focus" : False, "opacity" : 1.0, "app_paintable" : True, } if gtk_has_resize_grip_support(): args["has_resize_grip"] = False Gtk.Window.__init__(self, **args) self.set_keep_above(True) # In Precise, directly drawing on the top level window has no effect. # The Cairo target surface is correctly rendered, but somehow it # doesn't become visible. Compositing or not doesn't matter. # It's most likely an old issue with Gtk/Gdk. Later releases like # Trusty, Vivid are unaffected. # -> Create a widget we can successfully draw on anywhere. self.drawing_area = Gtk.DrawingArea() self.add(self.drawing_area) self.drawing_area.connect("draw", self.on_draw) # use transparency if available screen = Gdk.Screen.get_default() visual = screen.get_rgba_visual() self.supports_alpha = False if visual: self.set_visual(visual) self.drawing_area.set_visual(visual) # Somehow Gtk 3.4 still needs these now deprecated calls # for LayoutPopups, even though IconPalette and LabelPopups # don't. Otherwise there will be a white background. gtk_version = Version(Gtk.MAJOR_VERSION, Gtk.MINOR_VERSION) if gtk_version < Version(3, 18): # Xenial doesn't need them self.override_background_color( Gtk.StateFlags.NORMAL, Gdk.RGBA(0, 0, 0, 0)) self.drawing_area.override_background_color( Gtk.StateFlags.NORMAL, Gdk.RGBA(0, 0, 0, 0)) self.supports_alpha = True def set_opacity(self, opacity): """ Override deprecated Gtk function of the same name """ self._opacity = opacity def get_opacity(self): """ Override deprecated Gtk function of the same name """ return self._opacity def on_draw(self, widget, context): raise NotImplementedError() def position_at(self, x, y, x_align, y_align): """ Align the window with the given point. x, y in root window coordinates. """ rect = Rect.from_position_size(self.get_position(), self.get_size()) rect = rect.align_at_point(x, y, x_align, y_align) rect = self.limit_to_workarea(rect) x, y = rect.get_position() self.move(x, y) def limit_to_workarea(self, rect): visible_rect = Rect(0, 0, rect.w, rect.h) x, y = limit_window_position(rect.x, rect.y, visible_rect, get_monitor_rects(self.get_screen())) return Rect(x, y, rect.w, rect.h) class LabelPopup(KeyboardPopup): """ Ephemeral popup displaying a key label without user interaction. """ ARROW_HEIGHT = 0.13 ARROW_WIDTH = 0.3 LABEL_MARGIN = 0.1 _pango_layout = None _osk_util = osk.Util() def __init__(self): KeyboardPopup.__init__(self) self._key = None self.connect("realize", self._on_realize_event) def _on_realize_event(self, user_data): self.set_override_redirect(True) # set minimal input shape for the popup to become click-through win = self.get_window() self._osk_util.set_input_rect(win, 0, 0, 1, 1) def on_draw(self, widget, context): if not LabelPopup._pango_layout: LabelPopup._pango_layout = Pango.Layout(context=Gdk.pango_context_get()) rect = Rect(0, 0, self.get_allocated_width(), self.get_allocated_height()) content_rect = Rect(rect.x, rect.y, rect.w, rect.h - rect.h * self.ARROW_HEIGHT) arrow_rect = Rect(rect.x, content_rect.bottom(), rect.w, rect.h * self.ARROW_HEIGHT) \ .deflate((rect.w - rect.w * self.ARROW_WIDTH) / 2.0, 0) label_rect = content_rect.deflate(rect.w * self.LABEL_MARGIN) # background fill = self._key.get_fill_color() context.save() context.set_operator(cairo.OPERATOR_CLEAR) context.paint() context.restore() context.push_group() context.set_source_rgba(*fill) roundrect_arc(context, content_rect, config.CORNER_RADIUS) context.fill() l, t, r, b = arrow_rect.to_extents() t -= 1 context.move_to(l, t) context.line_to(r, t) context.line_to((l+r) / 2, b) context.fill() # draw label/image label_color = self._key.get_label_color() pixbuf = self._key.get_image(label_rect.w, label_rect.h) if pixbuf: pixbuf.draw(context, label_rect, label_color) else: label = self._key.get_label() if label: if label == " ": label = "␣" self._draw_text(context, label, label_rect, label_color) context.pop_group_to_source() context.paint_with_alpha(self._opacity) def _draw_text(self, context, text, rect, rgba): layout = self._pango_layout layout.set_text(text, -1) # find text extents font_description = Pango.FontDescription( \ config.theme_settings.key_label_font) base_extents = self._calc_base_layout_extents(layout, font_description) # scale label to the available rect font_size = self._calc_font_size(rect, base_extents) font_description.set_size(max(1, font_size)) layout.set_font_description(font_description) # center w, h = layout.get_size() w /= Pango.SCALE h /= Pango.SCALE offset = rect.align_rect(Rect(0, 0, w, h)).get_position() # draw context.move_to(*offset) context.set_source_rgba(*rgba) PangoCairo.show_layout(context, layout) @staticmethod def _calc_font_size(rect, base_extents): size_for_maximum_width = rect.w / base_extents[0] size_for_maximum_height = rect.h / base_extents[1] if size_for_maximum_width < size_for_maximum_height: return int(size_for_maximum_width) else: return int(size_for_maximum_height) @staticmethod def _calc_base_layout_extents(layout, font_description): BASE_FONTDESCRIPTION_SIZE = 10000000 font_description.set_size(BASE_FONTDESCRIPTION_SIZE) layout.set_font_description(font_description) w, h = layout.get_size() # In Pango units w = w or 1.0 h = h or 1.0 extents = (w / (Pango.SCALE * BASE_FONTDESCRIPTION_SIZE), h / (Pango.SCALE * BASE_FONTDESCRIPTION_SIZE)) return extents def get_key(self): return self._key def set_key(self, key): self._key = key class LayoutPopup(KeyboardPopup, LayoutView, TouchInput): """ Popup showing a (sub-)layout tree. """ IDLE_CLOSE_DELAY = 0 # seconds of inactivity until window closes def __init__(self, keyboard, notify_done_callback): self._layout = None self._notify_done_callback = notify_done_callback self._drag_selected = False # grazed by the pointer? KeyboardPopup.__init__(self) LayoutView.__init__(self, keyboard) TouchInput.__init__(self) self.connect("destroy", self._on_destroy_event) self._close_timer = Timer() self.start_close_timer() def cleanup(self): self.stop_close_timer() # fix label popup staying visible on double click self.keyboard.hide_touch_feedback() LayoutView.cleanup(self) # deregister from keyboard def get_toplevel(self): return self def set_layout(self, layout, frame_width): self._layout = layout self._frame_width = frame_width self.update_labels() # set window size layout_canvas_rect = layout.get_canvas_border_rect() canvas_rect = layout_canvas_rect.inflate(frame_width) w, h = canvas_rect.get_size() self.set_default_size(w + 1, h + 1) def get_layout(self): return self._layout def get_frame_width(self): return self._frame_width def got_motion(self): """ Has the pointer ever entered the popup? """ return self._drag_selected def handle_realize_event(self): self.set_override_redirect(True) super(LayoutPopup, self).handle_realize_event() def _on_destroy_event(self, user_data): self.cleanup() def on_enter_notify(self, widget, event): self.stop_close_timer() def on_leave_notify(self, widget, event): self.start_close_timer() def on_input_sequence_begin(self, sequence): self.stop_close_timer() key = self.get_key_at_location(sequence.point) if key: sequence.active_key = key self.keyboard.key_down(key, self, sequence) def on_input_sequence_update(self, sequence): if sequence.state & BUTTON123_MASK: key = self.get_key_at_location(sequence.point) # drag-select new active key active_key = sequence.active_key if not active_key is key and \ (active_key is None or not active_key.activated): sequence.active_key = key self.keyboard.key_up(active_key, self, sequence, False) self.keyboard.key_down(key, self, sequence, False) self._drag_selected = True def on_input_sequence_end(self, sequence): key = sequence.active_key if key: keyboard = self.keyboard keyboard.key_up(key, self, sequence) if key and \ not self._drag_selected: Timer(config.UNPRESS_DELAY, self.close_window) else: self.close_window() def on_draw(self, widget, context): context.push_group() LayoutView.draw(self, widget, context) context.pop_group_to_source() context.paint_with_alpha(self._opacity) def draw_window_frame(self, context, lod): corner_radius = config.CORNER_RADIUS border_rgba = self.get_popup_window_rgba("border") alpha = border_rgba[3] colors = [ [[0.5, 0.5, 0.5, alpha], 0 , 1], [border_rgba, 1.5, 2.0], ] rect = Rect(0, 0, self.get_allocated_width(), self.get_allocated_height()) for rgba, pos, width in colors: r = rect.deflate(width) roundrect_arc(context, r, corner_radius) context.set_line_width(width) context.set_source_rgba(*rgba) context.stroke() def close_window(self): self._notify_done_callback() def start_close_timer(self): if self.IDLE_CLOSE_DELAY: self._close_timer.start(self.IDLE_CLOSE_DELAY, self.close_window) def stop_close_timer(self): self._close_timer.stop() class LayoutBuilder: @staticmethod def build(source_key, color_scheme, layout): context = source_key.context frame_width = LayoutBuilder._calc_frame_width(context) layout = LayoutRoot(layout) layout.update_log_rect() log_rect = layout.get_border_rect() canvas_rect = Rect(frame_width, frame_width, log_rect.w * context.scale_log_to_canvas_x(1.0), log_rect.h * context.scale_log_to_canvas_y(1.0)) layout.fit_inside_canvas(canvas_rect) return layout, frame_width @staticmethod def _calc_frame_width(context): # calculate border around the layout canvas_border = context.scale_log_to_canvas((1, 1)) return config.POPUP_FRAME_WIDTH + min(canvas_border) class LayoutBuilderKeySequence(LayoutBuilder): MAX_KEY_COLUMNS = 8 # max number of keys in one row @staticmethod def build(source_key, color_scheme, key_sequence): # parse sequence into lines lines, ncolumns = \ LayoutBuilderKeySequence ._layout_sequence(key_sequence) return LayoutBuilderKeySequence._create_layout(source_key, color_scheme, lines, ncolumns) @staticmethod def _create_layout(source_key, color_scheme, lines, ncolumns): context = source_key.context frame_width = LayoutBuilderKeySequence._calc_frame_width(context) nrows = len(lines) spacing = (1, 1) # calc canvas size rect = source_key.get_canvas_border_rect() layout_canvas_rect = Rect(frame_width, frame_width, rect.w * ncolumns + spacing[0] * (ncolumns - 1), rect.h * nrows + spacing[1] * (nrows - 1)) # subdivide into logical rectangles for the keys layout_rect = context.canvas_to_log_rect(layout_canvas_rect) key_rects = layout_rect.subdivide(ncolumns, nrows, *spacing) # create keys, slots for empty labels are skipped keys = [] for i, line in enumerate(lines): for j, item in enumerate(line): slot = i * ncolumns + j if item: # control item? key = item key.set_border_rect(key_rects[slot]) key.group = "alternatives" key.color_scheme = color_scheme keys.append(key) item = LayoutPanel() item.border = 0 item.set_items(keys) layout = LayoutRoot(item) layout.fit_inside_canvas(layout_canvas_rect) return layout, frame_width @staticmethod def _layout_sequence(sequence): """ Split sequence into lines. """ max_columns = LayoutBuilderAlternatives.MAX_KEY_COLUMNS min_columns = max_columns // 2 add_close = False fill_gaps = True # find the number of columns with the best packing, # i.e. the least number of empty slots. n = len(sequence) if add_close: n += 1 # +1 for close button max_mod = 0 ncolumns = max_columns for i in range(max_columns, min_columns, -1): m = n % i if m == 0: max_mod = m ncolumns = i break if max_mod < m: max_mod = m ncolumns = i # limit to len for the single row case ncolumns = min(n, ncolumns) # cut the input into lines of the newly found optimal length lines = [] line = [] column = 0 for item in sequence: line.append(item) column += 1 if column >= ncolumns: lines.append(line) line = [] column = 0 # append close button if add_close: n = len(line) line.extend([None]*(ncolumns - (n+1))) key = RectKey("_close_") key.labels = {} key.image_filenames = {ImageSlot.NORMAL : "close.svg"} key.type = KeyCommon.BUTTON_TYPE line.append(key) # fill empty slots with dummy buttons if fill_gaps: n = len(line) if n: for i in range(ncolumns - n): key = RectKey("_dummy_") key.sensitive = False line.append(key) if line: lines.append(line) return lines, ncolumns class LayoutBuilderAlternatives(LayoutBuilderKeySequence): @staticmethod def build(source_key, color_scheme, alternatives): key_sequence = [] for i, label in enumerate(alternatives): key = RectKey("_alternative" + str(i)) key.type = KeyCommon.CHAR_TYPE key.labels = {0: label} key.code = label[0] key_sequence.append(key) return LayoutBuilderKeySequence.build(source_key, color_scheme, key_sequence) class PendingSeparatorPopup(KeyboardPopup): """ Ephemeral popup displaying the pending word separator. """ _osk_util = osk.Util() def __init__(self): KeyboardPopup.__init__(self) self.connect("realize", self._on_realize_event) self._visible = False def show_at(self, view, character_rect): toplevel = view.get_toplevel() self.set_transient_for(toplevel) self.realize() x, y, w, h = character_rect self.set_default_size(w, h) self.resize(w, h) self.move(x, y) self.supports_alpha = view.supports_alpha if self.supports_alpha: self.set_opacity(toplevel.get_opacity()) self.show_all() self._visible = True def hide(self): KeyboardPopup.hide(self) self._visible = False def is_visible(self): return self._visible def _on_realize_event(self, user_data): self.set_override_redirect(True) # set minimal input shape for the popup to become click-through win = self.get_window() self._osk_util.set_input_rect(win, 0, 0, 1, 1) def on_draw(self, widget, cr): rect = Rect(0, 0, self.get_allocated_width(), self.get_allocated_height()) fill_rgba = (1.0, 0.5, 0.5, 0.2) stroke_rgba = (1.0, 0.5, 0.5, 0.7) cr.set_source_rgba(*fill_rgba) cr.rectangle(*rect) cr.fill() r = rect top = r.y + r.h * 0.75 bottom = r.bottom() cr.set_source_rgba(*stroke_rgba) cr.set_line_width(max(1, r.h / 6)) cr.move_to(r.left(), top) cr.line_to(r.left(), bottom) cr.line_to(r.right(), bottom) cr.line_to(r.right(), top) cr.stroke()