1# -*- coding: utf-8 -*-
2#
3# Caribou - text entry and UI navigation application
4#
5# Copyright (C) 2009 Eitan Isaacson <eitan@monotonous.org>
6# Copyright (C) 2010 Warp Networks S.L.
7#  * Contributor: Daniel Baeyens <dbaeyens@warp.es>
8#
9# This program is free software; you can redistribute it and/or modify it
10# under the terms of the GNU Lesser General Public License as published by the
11# Free Software Foundation; either version 2.1 of the License, or (at your
12# option) any later version.
13#
14# This program is distributed in the hope that it will be useful, but WITHOUT
15# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License
17# for more details.
18#
19# You should have received a copy of the GNU Lesser General Public License
20# along with this program; if not, write to the Free Software Foundation,
21# Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22
23import gi
24gi.require_version('Clutter', '1.0')
25from gi.repository import Gtk
26from gi.repository import Gdk
27from gi.repository import GObject
28from gi.repository import Clutter
29from .antler_settings import AntlerSettings
30from math import sqrt
31import os
32import sys
33
34
35class AnimatedWindowBase(Gtk.Window, Clutter.Animatable):
36    __gproperties__ = {
37        'antler-window-x' : (GObject.TYPE_INT, 'Window position',
38                             'Window X coordinate',
39                             GObject.G_MININT, GObject.G_MAXINT, 0,
40                             GObject.PARAM_READWRITE),
41        'antler-window-y' : (GObject.TYPE_INT, 'Window position',
42                             'Window Y coordinate',
43                             GObject.G_MININT, GObject.G_MAXINT, 0,
44                             GObject.PARAM_READWRITE)
45        }
46    def __init__(self):
47        GObject.GObject.__init__(self, type=Gtk.WindowType.POPUP)
48        Clutter.init(None)
49
50        # animation
51        self._stage = Clutter.Stage.get_default()
52        self._move_animation = None
53        self._opacity_animation = None
54
55    def do_get_property(self, property):
56        if property.name == "antler-window-x":
57            return self.get_position()[0]
58        elif property.name == "antler-window-y":
59            return self.get_position()[1]
60        else:
61            raise AttributeError('unknown property %s' % property.name)
62
63    def do_set_property(self, property, value):
64        if property.name == "antler-window-x":
65            if value is not None:
66                self.move(value, self.get_position()[1])
67        elif property.name == "antler-window-y":
68            if value is not None:
69                self.move(self.get_position()[0], value)
70        else:
71            raise AttributeError('unknown property %s' % property.name)
72
73    def do_animate_property(self, animation, prop_name, initial_value,
74                            final_value, progress, gvalue):
75        if prop_name == "antler-window-x":
76            dx = int(initial_value * progress)
77            self.move(initial_value + dx, self.get_position()[1])
78            return True
79        elif prop_name == "antler-window-y":
80            dy = int(initial_value * progress)
81            self.move(self.get_position()[0], initial_value + dy)
82            return True
83        if prop_name == "opacity":
84            opacity = initial_value + ((final_value - initial_value) * progress)
85            GObject.idle_add(lambda: self.set_opacity(opacity))
86            return True
87        else:
88            return False
89
90    def animated_move(self, x, y, mode=Clutter.AnimationMode.EASE_OUT_CUBIC):
91        self._move_animation = Clutter.Animation(object=self,
92                                            mode=mode,
93                                            duration=250)
94        self._move_animation.bind("antler-window-x", x)
95        self._move_animation.bind("antler-window-y", y)
96
97        timeline = self._move_animation.get_timeline()
98        timeline.start()
99
100        return self._move_animation
101
102    def animated_opacity(self, opacity, mode=Clutter.AnimationMode.EASE_OUT_CUBIC):
103        if opacity == self.get_opacity():
104            return None
105        if self._opacity_animation is not None:
106            if self._opacity_animation.has_property('opacity'):
107                timeline = self._opacity_animation.get_timeline()
108                timeline.pause()
109                self._opacity_animation.unbind_property('opacity')
110
111        self._opacity_animation = Clutter.Animation(object=self, mode=mode,
112                                                    duration=100)
113        self._opacity_animation.bind("opacity", opacity)
114
115        timeline = self._opacity_animation.get_timeline()
116        timeline.start()
117
118        return self._opacity_animation
119
120
121class ProximityWindowBase(AnimatedWindowBase):
122    def __init__(self):
123        AnimatedWindowBase.__init__(self)
124        self._poll_tid = 0
125        settings = AntlerSettings()
126        self.max_distance = settings.max_distance.value
127        settings.max_distance.connect("value-changed", self._on_max_dist_changed)
128        min_alpha = settings.min_alpha
129        max_alpha = settings.max_alpha
130        min_alpha.connect("value-changed",
131                                   self._on_min_alpha_changed, max_alpha)
132        max_alpha.connect("value-changed",
133                                   self._on_max_alpha_changed, min_alpha)
134        self.connect('map-event', self._onmapped, settings)
135
136    def _on_max_dist_changed(self, setting, value):
137        self.max_distance = value
138
139    def _set_min_max_alpha(self, min_alpha, max_alpha):
140        if min_alpha > max_alpha:
141            min_alpha = max_alpha
142        self.max_alpha = max_alpha
143        self.min_alpha = min_alpha
144        if self.max_alpha != self.min_alpha:
145            if self._poll_tid == 0:
146                self._poll_tid = GObject.timeout_add(100, self._proximity_check)
147        elif self._poll_tid != 0:
148            GObject.source_remove(self._poll_tid)
149
150    def _onmapped(self, obj, event, settings):
151        if self.is_composited():
152            self._set_min_max_alpha(settings.min_alpha.value,
153                                    settings.max_alpha.value)
154            self._proximity_check()
155
156    def _on_min_alpha_changed(self, setting, value, max_alpha):
157        self._set_min_max_alpha(value, max_alpha.value)
158
159    def _on_max_alpha_changed(self, setting, value, min_alpha):
160        self._set_min_max_alpha(min_alpha.value, value)
161
162    def _proximity_check(self):
163        px, py = self.get_pointer()
164
165        ww = self.get_allocated_width()
166        wh = self.get_allocated_height()
167
168        distance =  self._get_distance_to_bbox(px, py, ww, wh)
169
170        opacity = (self.max_alpha - self.min_alpha) * \
171            (1 - min(distance, self.max_distance)/self.max_distance)
172        opacity += self.min_alpha
173
174        self.animated_opacity(opacity)
175
176        if not self.props.visible:
177            self._poll_tid = 0
178            return False
179
180        return True
181
182    def _get_distance_to_bbox(self, px, py, bw, bh):
183        if px < 0:
184            x_distance = float(abs(px))
185        elif px > bw:
186            x_distance = float(px - bw)
187        else:
188            x_distance = 0.0
189
190        if py < 0:
191            y_distance = float(abs(py))
192        elif py > bh:
193            y_distance = float(py - bh)
194        else:
195            y_distance = 0.0
196
197        if y_distance == 0 and x_distance == 0:
198            return 0.0
199        elif y_distance != 0 and x_distance == 0:
200            return y_distance
201        elif y_distance == 0 and x_distance != 0:
202            return x_distance
203        else:
204            x2 = 0 if x_distance > 0 else bw
205            y2 = 0 if y_distance > 0 else bh
206            return sqrt((px - x2)**2 + (py - y2)**2)
207
208class AntlerWindow(ProximityWindowBase):
209    def __init__(self, keyboard_view_factory, placement=None,
210                 min_alpha=1.0, max_alpha=1.0, max_distance=100):
211        ProximityWindowBase.__init__(self)
212
213        self.set_name("AntlerWindow")
214
215        ctx = self.get_style_context()
216        ctx.add_class("antler-keyboard-window")
217
218        settings = AntlerSettings()
219        settings.keyboard_type.connect('value-changed', self.on_kb_type_changed)
220
221        self._vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
222        self.add(self._vbox)
223
224        self.keyboard_view_factory = keyboard_view_factory
225        self.keyboard_view = keyboard_view_factory (keyboard_type=settings.keyboard_type.value)
226
227        self._vbox.pack_start(self.keyboard_view, True, True, 0)
228
229        self.connect("size-allocate", self.on_size_allocate)
230
231        self._cursor_location = Rectangle()
232        self._entry_location = Rectangle()
233        self.placement = placement or \
234            AntlerWindowPlacement()
235
236    def on_kb_type_changed(self, setting, value):
237        self._vbox.remove(self.keyboard_view)
238        self.resize(1, 1)
239        self.keyboard_view = self.keyboard_view_factory (value)
240        self._vbox.pack_start(self.keyboard_view, True, True, 0)
241        self.keyboard_view.show_all()
242
243    def on_size_allocate(self, widget, allocation):
244        self._update_position()
245
246    def destroy(self):
247        self.keyboard.destroy()
248        super(Gtk.Window, self).destroy()
249
250    def set_cursor_location(self, x, y, w, h):
251        self._cursor_location = Rectangle(x, y, w, h)
252        self._update_position()
253
254    def set_entry_location(self, x, y, w, h):
255        self._entry_location = Rectangle(x, y, w, h)
256        self._update_position()
257
258    def set_placement(self, placement):
259        self.placement = placement
260        self._update_position()
261
262    def _get_root_bbox(self):
263        root_window = Gdk.get_default_root_window()
264        args = root_window.get_geometry()
265
266        root_bbox = Rectangle(*args)
267
268        # TODO: Do whatever we need to do to place the keyboard correctly
269        # in GNOME Shell and Unity.
270        #
271
272        return root_bbox
273
274    def _calculate_position(self, placement=None):
275        root_bbox = self._get_root_bbox()
276        placement = placement or self.placement
277
278        x = self._calculate_axis(placement.x, root_bbox)
279        y = self._calculate_axis(placement.y, root_bbox)
280
281        return x, y
282
283    def get_expected_position(self):
284        x, y = self._calculate_position()
285        origx, origy = x, y
286        root_bbox = self._get_root_bbox()
287        proposed_position = Rectangle(x, y, self.get_allocated_width(),
288                                      self.get_allocated_height())
289
290        x += self.placement.x.adjust_to_bounds(root_bbox, proposed_position)
291        y += self.placement.y.adjust_to_bounds(root_bbox, proposed_position)
292        return self.get_position() != (x, y) != y, x, y
293
294    def _update_position(self):
295        changed, x, y = self.get_expected_position()
296        if changed:
297            self.move(x, y)
298
299    def _calculate_axis(self, axis_placement, root_bbox):
300        bbox = root_bbox
301
302        if axis_placement.stickto == AntlerWindowPlacement.CURSOR:
303            bbox = self._cursor_location
304        elif axis_placement.stickto == AntlerWindowPlacement.ENTRY:
305            bbox = self._entry_location
306
307        offset = axis_placement.get_offset(bbox.x, bbox.y)
308
309        if axis_placement.align == AntlerWindowPlacement.END:
310            offset += axis_placement.get_length(bbox.width, bbox.height)
311            if axis_placement.gravitate == AntlerWindowPlacement.INSIDE:
312                offset -= axis_placement.get_length(
313                    self.get_allocated_width(),
314                    self.get_allocated_height())
315        elif axis_placement.align == AntlerWindowPlacement.START:
316            if axis_placement.gravitate == AntlerWindowPlacement.OUTSIDE:
317                offset -= axis_placement.get_length(
318                    self.get_allocated_width(),
319                    self.get_allocated_height())
320        elif axis_placement.align == AntlerWindowPlacement.CENTER:
321            offset += axis_placement.get_length(bbox.width, bbox.height)/2
322
323        return offset
324
325class AntlerWindowDocked(AntlerWindow):
326    def __init__(self, keyboard_view_factory, horizontal_roll=False):
327        placement = AntlerWindowPlacement(
328            xalign=AntlerWindowPlacement.START,
329            yalign=AntlerWindowPlacement.END,
330            xstickto=AntlerWindowPlacement.SCREEN,
331            ystickto=AntlerWindowPlacement.SCREEN,
332            xgravitate=AntlerWindowPlacement.INSIDE)
333
334        AntlerWindow.__init__(self, keyboard_view_factory, placement)
335
336        self.horizontal_roll = horizontal_roll
337        self._rolled_in = False
338
339
340    def show_all(self):
341        super(AntlerWindow, self).show_all()
342
343    def on_size_allocate(self, widget, allocation):
344        self._roll_in()
345
346    def _roll_in(self):
347        if self._rolled_in:
348            return
349        self._rolled_in = True
350
351        x, y = self._get_preroll_position()
352        self.move(x, y)
353
354        x, y = self._get_postroll_position()
355        return self.animated_move(x, y)
356
357    def _get_preroll_position(self):
358        _, x, y = self.get_expected_position()
359
360        if self.horizontal_roll:
361            newy = y
362            if self.placement.x.align == AntlerWindowPlacement.END:
363                newx = x + self.get_allocated_width()
364            else:
365                newx = x - self.get_allocated_width()
366        else:
367            newx = x
368            if self.placement.y.align == AntlerWindowPlacement.END:
369                newy = y + self.get_allocated_height()
370            else:
371                newy = y - self.get_allocated_height()
372
373        return newx, newy
374
375    def _get_postroll_position(self):
376        x, y = self.get_position()
377
378        if self.horizontal_roll:
379            newy = y
380            if self.placement.x.align != AntlerWindowPlacement.END:
381                newx = x + self.get_allocated_width()
382            else:
383                newx = x - self.get_allocated_width()
384        else:
385            newx = x
386            if self.placement.y.align != AntlerWindowPlacement.END:
387                newy = y + self.get_allocated_height()
388            else:
389                newy = y - self.get_allocated_height()
390
391        return newx, newy
392
393    def _roll_out(self):
394        if not self._rolled_in:
395            return
396        self._rolled_in = False;
397        x, y = self.get_position()
398        return self.animated_move(x + self.get_allocated_width(), y)
399
400    def hide(self):
401        animation = self._roll_out()
402        animation.connect('completed', lambda x: AntlerWindow.hide(self))
403
404class AntlerWindowEntry(AntlerWindow):
405    def __init__(self, keyboard_view_factory):
406        placement = AntlerWindowPlacement(
407            xalign=AntlerWindowPlacement.START,
408            xstickto=AntlerWindowPlacement.ENTRY,
409            ystickto=AntlerWindowPlacement.ENTRY,
410            xgravitate=AntlerWindowPlacement.INSIDE,
411            ygravitate=AntlerWindowPlacement.OUTSIDE)
412
413        AntlerWindow.__init__(self, keyboard_view_factory, placement)
414
415
416    def _calculate_axis(self, axis_placement, root_bbox):
417        offset = AntlerWindow._calculate_axis(self, axis_placement, root_bbox)
418        if axis_placement.axis == 'y':
419            if offset + self.get_allocated_height() > root_bbox.height + root_bbox.y:
420                new_axis_placement = axis_placement.copy(align=AntlerWindowPlacement.START)
421                offset = AntlerWindow._calculate_axis(self, new_axis_placement, root_bbox)
422
423        return offset
424
425class AntlerWindowPlacement(object):
426    START = 'start'
427    END = 'end'
428    CENTER = 'center'
429
430    SCREEN = 'screen'
431    ENTRY = 'entry'
432    CURSOR = 'cursor'
433
434    INSIDE = 'inside'
435    OUTSIDE = 'outside'
436
437    class _AxisPlacement(object):
438        def __init__(self, axis, align, stickto, gravitate):
439            self.axis = axis
440            self.align = align
441            self.stickto = stickto
442            self.gravitate = gravitate
443
444        def copy(self, align=None, stickto=None, gravitate=None):
445            return self.__class__(self.axis,
446                                  align or self.align,
447                                  stickto or self.stickto,
448                                  gravitate or self.gravitate)
449
450        def get_offset(self, x, y):
451            return x if self.axis == 'x' else y
452
453        def get_length(self, width, height):
454            return width if self.axis == 'x' else height
455
456        def adjust_to_bounds(self, root_bbox, child_bbox):
457            child_vector_start = self.get_offset(child_bbox.x, child_bbox.y)
458            child_vector_end = \
459                self.get_length(child_bbox.width, child_bbox.height) + \
460                child_vector_start
461            root_vector_start = self.get_offset(root_bbox.x, root_bbox.y)
462            root_vector_end = self.get_length(
463                root_bbox.width, root_bbox.height) + root_vector_start
464
465            if root_vector_end < child_vector_end:
466                return root_vector_end - child_vector_end
467
468            if root_vector_start > child_vector_start:
469                return root_vector_start - child_vector_start
470
471            return 0
472
473
474    def __init__(self,
475                 xalign=None, xstickto=None, xgravitate=None,
476                 yalign=None, ystickto=None, ygravitate=None):
477        self.x = self._AxisPlacement('x',
478                                     xalign or self.END,
479                                     xstickto or self.CURSOR,
480                                     xgravitate or self.OUTSIDE)
481        self.y = self._AxisPlacement('y',
482                                     yalign or self.END,
483                                     ystickto or self.CURSOR,
484                                     ygravitate or self.OUTSIDE)
485
486class Rectangle(object):
487    def __init__(self, x=0, y=0, width=0, height=0):
488        self.x = x
489        self.y = y
490        self.width = width
491        self.height = height
492
493if __name__ == "__main__":
494    import keyboard_view
495    import signal
496    signal.signal(signal.SIGINT, signal.SIG_DFL)
497
498    w = AntlerWindowDocked(keyboard_view.AntlerKeyboardView)
499    w.show_all()
500
501    try:
502        Gtk.main()
503    except KeyboardInterrupt:
504        Gtk.main_quit()
505