1#!/usr/bin/env python3
2#
3#gtkPopupNotify.py
4#
5# Copyright 2009 Daniel Woodhouse
6# Copyright 2013-2021 Antoine Martin <antoine@xpra.org>
7#
8#This program is free software: you can redistribute it and/or modify
9#it under the terms of the GNU Lesser General Public License as published by
10#the Free Software Foundation, either version 3 of the License, or
11#(at your option) any later version.
12#
13#This program is distributed in the hope that it will be useful,
14#but WITHOUT ANY WARRANTY; without even the implied warranty of
15#MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16#GNU Lesser General Public License for more details.
17#
18#You should have received a copy of the GNU Lesser General Public License
19#along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
21import gi
22gi.require_version("Gtk", "3.0")
23gi.require_version("Gdk", "3.0")
24from gi.repository import GLib, Gtk, Gdk, GdkPixbuf
25
26from xpra.os_util import OSX
27from xpra.util import u
28from xpra.gtk_common.gtk_util import (
29    add_close_accel, color_parse,
30    get_icon_pixbuf,
31    )
32from xpra.notifications.notifier_base import NotifierBase, log
33
34DEFAULT_FG_COLOUR = None
35DEFAULT_BG_COLOUR = None
36if OSX:
37    #black on white fits better with osx
38    DEFAULT_FG_COLOUR = color_parse("black")
39    DEFAULT_BG_COLOUR = color_parse("#f2f2f2")
40DEFAULT_WIDTH = 340
41DEFAULT_HEIGHT = 100
42
43
44class GTK_Notifier(NotifierBase):
45
46    def __init__(self, closed_cb=None, action_cb=None, size_x=DEFAULT_WIDTH, size_y=DEFAULT_HEIGHT, timeout=5):
47        super().__init__(closed_cb, action_cb)
48        self.handles_actions = True
49        """
50        Create a new notification stack.  The recommended way to create Popup instances.
51          Parameters:
52            `size_x` : The desired width of the notifications.
53            `size_y` : The desired minimum height of the notifications. If the text is
54            longer it will be expanded to fit.
55            `timeout` : Popup instance will disappear after this timeout if there
56            is no human intervention. This can be overridden temporarily by passing
57            a new timout to the new_popup method.
58        """
59        self.size_x = size_x
60        self.size_y = size_y
61        self.timeout = timeout
62        """
63        Other parameters:
64        These will take effect for every popup created after the change.
65            `max_popups` : The maximum number of popups to be shown on the screen
66            at one time.
67            `bg_color` : if None default is used (usually grey). set with a gdk.Color.
68            `fg_color` : if None default is used (usually black). set with a gdk.Color.
69            `show_timeout : if True, a countdown till destruction will be displayed.
70
71        """
72        self.max_popups = 5
73        self.fg_color = DEFAULT_FG_COLOUR
74        self.bg_color = DEFAULT_BG_COLOUR
75        self.show_timeout = False
76
77        self._notify_stack = []
78        self._offset = 0
79
80        display = Gdk.Display.get_default()
81        n = display.get_n_monitors()
82        log("monitors=%s", n)
83        #if n<2:
84        monitor = display.get_monitor(0)
85        geom = monitor.get_geometry()
86        self.max_width = geom.width
87        self.max_height = geom.height
88        log("first monitor dimensions: %dx%d", self.max_width, self.max_height)
89        self.x = self.max_width - 20        #keep away from the edge
90        self.y = self.max_height - 64        #space for a panel
91        log("our reduced dimensions: %dx%d", self.x, self.y)
92
93    def cleanup(self):
94        popups = tuple(self._notify_stack)
95        self._notify_stack = []
96        for x in popups:
97            x.hide_notification()
98        super().cleanup()
99
100
101    def get_origin_x(self):
102        return self.x
103
104    def get_origin_y(self):
105        return self.y
106
107
108    def close_notify(self, nid):
109        for x in self._notify_stack:
110            if x.nid==nid:
111                x.hide_notification()
112
113    def show_notify(self, dbus_id, tray, nid,
114                    app_name, replaces_nid, app_icon,
115                    summary, body, actions, hints, timeout, icon):
116        GLib.idle_add(self.new_popup, nid, summary, body, actions, icon, timeout, 0<timeout<=600)
117
118    def new_popup(self, nid, summary, body, actions, icon, timeout=10*1000, show_timeout=False):
119        """Create a new Popup instance, or update an existing one """
120        existing = [p for p in self._notify_stack if p.nid==nid]
121        if existing:
122            existing[0].set_content(summary, body, actions, icon)
123            return
124        if len(self._notify_stack) == self.max_popups:
125            oldest = self._notify_stack[0]
126            oldest.hide_notification()
127            self.popup_closed(oldest.nid, 4)
128        image = None
129        if icon and icon[0]=="png":
130            img_data = icon[3]
131            loader = GdkPixbuf.PixbufLoader()
132            loader.write(img_data)
133            loader.close()
134            image = loader.get_pixbuf()
135        popup = Popup(self, nid, summary, body, actions, image=image, timeout=timeout//1000, show_timeout=show_timeout)
136        self._notify_stack.append(popup)
137        self._offset += self._notify_stack[-1].h
138        return False
139
140    def destroy_popup_cb(self, popup):
141        if popup in self._notify_stack:
142            self._notify_stack.remove(popup)
143            #move popups down if required
144            offset = 0
145            for note in self._notify_stack:
146                offset = note.reposition(offset, self)
147            self._offset = offset
148
149    def popup_closed(self, nid, reason, text=""):
150        if self.closed_cb:
151            self.closed_cb(nid, reason, text)
152
153    def popup_action(self, nid, action_id):
154        if self.action_cb:
155            self.action_cb(nid, action_id)
156
157
158
159class Popup(Gtk.Window):
160    def __init__(self, stack, nid, title, message, actions, image, timeout=5, show_timeout=False):
161        log("Popup%s", (stack, nid, title, message, actions, image, timeout, show_timeout))
162        self.stack = stack
163        self.nid = nid
164        super().__init__()
165
166        self.set_accept_focus(False)
167        self.set_focus_on_map(False)
168        self.set_size_request(stack.size_x, -1)
169        self.set_decorated(False)
170        self.set_deletable(False)
171        self.set_property("skip-pager-hint", True)
172        self.set_property("skip-taskbar-hint", True)
173        self.connect("enter-notify-event", self.on_hover, True)
174        self.connect("leave-notify-event", self.on_hover, False)
175        self.set_opacity(0.2)
176        self.set_keep_above(True)
177        self.destroy_cb = stack.destroy_popup_cb
178        self.popup_closed = stack.popup_closed
179        self.action_cb = stack.popup_action
180
181        main_box = Gtk.VBox()
182        header_box = Gtk.HBox()
183        self.header = Gtk.Label()
184        self.header.set_padding(3, 3)
185        self.header.set_alignment(0, 0)
186        header_box.pack_start(self.header, True, True, 5)
187        icon = get_icon_pixbuf("close.png")
188        if icon:
189            close_button = Gtk.Image()
190            close_button.set_from_pixbuf(icon)
191            close_button.set_padding(3, 3)
192            close_window = Gtk.EventBox()
193            close_window.set_visible_window(False)
194            close_window.connect("button-press-event", self.user_closed)
195            close_window.add(close_button)
196            close_window.set_size_request(icon.get_width(), icon.get_height())
197            header_box.pack_end(close_window, False, False, 0)
198        main_box.pack_start(header_box)
199
200        body_box = Gtk.HBox()
201        self.image = Gtk.Image()
202        self.image.set_size_request(70, 70)
203        self.image.set_alignment(0, 0)
204        body_box.pack_start(self.image, False, False, 5)
205        self.message = Gtk.Label()
206        self.message.set_max_width_chars(80)
207        self.message.set_size_request(stack.size_x - 90, -1)
208        self.message.set_line_wrap(True)
209        self.message.set_alignment(0, 0)
210        self.message.set_padding(5, 10)
211        self.counter = Gtk.Label()
212        self.counter.set_alignment(1, 1)
213        self.counter.set_padding(3, 3)
214        self.timeout = timeout
215
216        body_box.pack_start(self.message, True, False, 5)
217        body_box.pack_end(self.counter, False, False, 5)
218        main_box.pack_start(body_box, False, False, 5)
219
220        self.buttons_box = Gtk.HBox(homogeneous=True)
221        alignment = Gtk.Alignment(xalign=1.0, yalign=0.5, xscale=0.0, yscale=0.0)
222        alignment.add(self.buttons_box)
223        main_box.pack_start(alignment)
224        self.add(main_box)
225        if stack.bg_color is not None:
226            self.modify_bg(Gtk.StateType.NORMAL, stack.bg_color)
227        if stack.fg_color is not None:
228            self.message.modify_fg(Gtk.StateType.NORMAL, stack.fg_color)
229            self.header.modify_fg(Gtk.StateType.NORMAL, stack.fg_color)
230            self.counter.modify_fg(Gtk.StateType.NORMAL, stack.fg_color)
231        self.show_timeout = show_timeout
232        self.hover = False
233        self.show_all()
234        self.w = self.get_preferred_width()[0]
235        self.h = self.get_preferred_height()[0]
236        self.move(self.get_x(self.w), self.get_y(self.h))
237        self.wait_timer = None
238        self.fade_out_timer = None
239        self.fade_in_timer = GLib.timeout_add(100, self.fade_in)
240        #populate the window:
241        self.set_content(title, message, actions, image)
242        #ensure we dont show it in the taskbar:
243        self.realize()
244        self.get_window().set_skip_taskbar_hint(True)
245        self.get_window().set_skip_pager_hint(True)
246        add_close_accel(self, self.user_closed)
247
248    def set_content(self, title, message, actions=(), image=None):
249        self.header.set_markup("<b>%s</b>" % title)
250        self.message.set_text(message)
251        #remove any existing actions:
252        for w in tuple(self.buttons_box.get_children()):
253            self.buttons_box.remove(w)
254        while len(actions)>=2:
255            action_id, action_text = actions[:2]
256            actions = actions[2:]
257            button = self.action_button(action_id, action_text)
258            self.buttons_box.add(button)
259        self.buttons_box.show_all()
260        if image:
261            self.image.show()
262            self.image.set_from_pixbuf(image)
263        else:
264            self.image.hide()
265
266
267    def action_button(self, action_id, action_text):
268        button = Gtk.Button(u(action_text))
269        button.set_relief(Gtk.ReliefStyle.NORMAL)
270        def popup_cb_clicked(*args):
271            self.hide_notification()
272            log("popup_cb_clicked%s for action_id=%s, action_text=%s", args, action_id, action_text)
273            self.action_cb(self.nid, action_id)
274        button.connect("clicked", popup_cb_clicked)
275        return button
276
277    def get_x(self, w):
278        x = self.stack.get_origin_x() - w//2
279        if (x + w) >= self.stack.max_width:    #dont overflow on the right
280            x = self.stack.max_width - w
281        if x <= 0:                                #or on the left
282            x = 0
283        log("get_x(%s)=%s", w, x)
284        return    x
285
286    def get_y(self, h):
287        y = self.stack.get_origin_y()
288        if y >= (self.stack.max_height//2):        #if near bottom, substract window height
289            y = y - h
290        if (y + h) >= self.stack.max_height:
291            y = self.stack.max_height - h
292        if y<= 0:
293            y = 0
294        log("get_y(%s)=%s", h, y)
295        return    y
296
297    def reposition(self, offset, stack):
298        """Move the notification window down, when an older notification is removed"""
299        log("reposition(%s, %s)", offset, stack)
300        new_offset = self.h + offset
301        GLib.idle_add(self.move, self.get_x(self.w), self.get_y(new_offset))
302        return new_offset
303
304    def fade_in(self):
305        opacity = self.get_opacity()
306        opacity += 0.15
307        if opacity >= 1:
308            self.wait_timer = GLib.timeout_add(1000, self.wait)
309            self.fade_in_timer = None
310            return False
311        self.set_opacity(opacity)
312        return True
313
314    def wait(self):
315        if not self.hover:
316            self.timeout -= 1
317        if self.show_timeout:
318            self.counter.set_markup(str("<b>%s</b>" % max(0, self.timeout)))
319        if self.timeout <= 0:
320            self.fade_out_timer = GLib.timeout_add(100, self.fade_out)
321            self.wait_timer = None
322            return False
323        return True
324
325    def fade_out(self):
326        opacity = self.get_opacity()
327        opacity -= 0.10
328        if opacity <= 0:
329            self.in_progress = False
330            self.hide_notification()
331            self.fade_out_timer = None  #redundant
332            self.popup_closed(self.nid, 1)
333            return False
334        self.set_opacity(opacity)
335        return True
336
337    def on_hover(self, _window, _event, hover):
338        """Starts/Stops the notification timer on a mouse in/out event"""
339        self.hover = hover
340
341    def user_closed(self, *_args):
342        self.hide_notification()
343        self.popup_closed(self.nid, 2)
344
345    def hide_notification(self):
346        """Destroys the notification and tells the stack to move the
347        remaining notification windows"""
348        log("hide_notification()")
349        for timer in ("fade_in_timer", "fade_out_timer", "wait_timer"):
350            v = getattr(self, timer)
351            if v:
352                setattr(self, timer, None)
353                GLib.source_remove(v)
354        #destroy window from the UI thread:
355        GLib.idle_add(self.destroy)
356        self.destroy()
357        self.destroy_cb(self)
358
359
360
361def main():
362    #example usage
363    import random
364    color_combos = (("red", "white"), ("white", "blue"), ("green", "black"))
365    messages = [
366        (1, "Hello", "This is a popup", ()),
367        (2, "Actions", "This notification has 3 actions", (1, "Action 1", 2, "Action 2", 3, "Action 3")),
368        (3, "Some Latin", "Quidquid latine dictum sit, altum sonatur.", ()),
369        (4, "A long message", "The quick brown fox jumped over the lazy dog. " * 6, ()),
370        (1, "Hello Again", "Replacing the first notification", ()),
371        (2, "Actions Again", "Replacing with just 1 action", (999, "Action 999")),
372        ]
373    #images = ("logo1_64.png", None)
374    def notify_factory():
375        color = random.choice(color_combos)
376        nid, title, message, actions = messages.pop(0)
377        icon = None #random.choice(images)
378        notifier.bg_color = color_parse(color[0])
379        notifier.fg_color = color_parse(color[1])
380        notifier.show_timeout = random.choice((True, False))
381        notifier.new_popup(nid, title, message, actions, icon)
382        return len(messages)
383    def gtk_main_quit():
384        print("quitting")
385        Gtk.main_quit()
386
387    notifier = GTK_Notifier(timeout=6)
388    GLib.timeout_add(4000, notify_factory)
389    GLib.timeout_add(30000, gtk_main_quit)
390    Gtk.main()
391
392if __name__ == "__main__":
393    main()
394