1# Copyright (C) 2018 Marcin Mielniczuk <marmistrz.dev AT zoho.eu>
2# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com>
3#
4# This file is part of Gajim.
5#
6# Gajim is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published
8# by the Free Software Foundation; version 3 only.
9#
10# Gajim is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
17
18from typing import Any
19from typing import List
20from typing import Tuple
21from typing import Optional
22
23import sys
24import weakref
25import logging
26import math
27import textwrap
28import functools
29from importlib import import_module
30import xml.etree.ElementTree as ET
31from functools import wraps
32from functools import lru_cache
33
34try:
35    from PIL import Image
36except Exception:
37    pass
38
39from gi.repository import Gdk
40from gi.repository import Gtk
41from gi.repository import GLib
42from gi.repository import Gio
43from gi.repository import Pango
44from gi.repository import GdkPixbuf
45import nbxmpp
46import cairo
47
48from gajim.common import app
49from gajim.common import configpaths
50from gajim.common import i18n
51from gajim.common.i18n import _
52from gajim.common.helpers import URL_REGEX
53from gajim.common.const import MOODS
54from gajim.common.const import ACTIVITIES
55from gajim.common.const import LOCATION_DATA
56from gajim.common.const import Display
57from gajim.common.const import StyleAttr
58from gajim.common.nec import EventHelper as CommonEventHelper
59
60from .const import GajimIconSet
61from .const import WINDOW_MODULES
62
63_icon_theme = Gtk.IconTheme.get_default()
64if _icon_theme is not None:
65    _icon_theme.append_search_path(str(configpaths.get('ICONS')))
66
67log = logging.getLogger('gajim.gui.util')
68
69
70class NickCompletionGenerator:
71    def __init__(self, self_nick: str) -> None:
72        self.nick = self_nick
73        self.sender_list = []  # type: List[str]
74        self.attention_list = []  # type: List[str]
75
76    def change_nick(self, new_nick: str) -> None:
77        self.nick = new_nick
78
79    def record_message(self, contact: str, highlight: bool) -> None:
80        if contact == self.nick:
81            return
82
83        log.debug('Recorded a message from %s, highlight; %s', contact,
84                  highlight)
85        if highlight:
86            try:
87                self.attention_list.remove(contact)
88            except ValueError:
89                pass
90            if len(self.attention_list) > 6:
91                self.attention_list.pop(0)  # remove older
92            self.attention_list.append(contact)
93
94        # TODO implement it in a more efficient way
95        # Currently it's O(n*m + n*s), where n is the number of participants and
96        # m is the number of messages processed, s - the number of times the
97        # suggestions are requested
98        #
99        # A better way to do it would be to keep a dict: contact -> timestamp
100        # with expected O(1) insert, and sort it by timestamps in O(n log n)
101        # for each suggestion (currently generating the suggestions is O(n))
102        # this would give the expected complexity of O(m + s * n log n)
103        try:
104            self.sender_list.remove(contact)
105        except ValueError:
106            pass
107        self.sender_list.append(contact)
108
109    def contact_renamed(self, contact_old: str, contact_new: str) -> None:
110        log.debug('Contact %s renamed to %s', contact_old, contact_new)
111        for lst in (self.attention_list, self.sender_list):
112            for idx, contact in enumerate(lst):
113                if contact == contact_old:
114                    lst[idx] = contact_new
115
116
117    def generate_suggestions(self, nicks: List[str],
118                             beginning: str) -> List[str]:
119        """
120        Generate the order of suggested MUC autocompletions
121
122        `nicks` is the list of contacts currently participating in a MUC
123        `beginning` is the text already typed by the user
124        """
125        def nick_matching(nick: str) -> bool:
126            return nick != self.nick \
127                and nick.lower().startswith(beginning.lower())
128
129        if beginning == '':
130            # empty message, so just suggest recent mentions
131            potential_matches = self.attention_list
132        else:
133            # nick partially typed, try completing it
134            potential_matches = self.sender_list
135
136        potential_matches_set = set(potential_matches)
137        log.debug('Priority matches: %s', potential_matches_set)
138
139        matches = [n for n in potential_matches if nick_matching(n)]
140        # the most recent nick is the last one on the list
141        matches.reverse()
142
143        # handle people who have not posted/mentioned us
144        other_nicks = [
145            n for n in nicks
146            if nick_matching(n) and n not in potential_matches_set
147        ]
148        other_nicks.sort(key=str.lower)
149        log.debug('Other matches: %s', other_nicks)
150
151        return matches + other_nicks
152
153
154class Builder:
155    def __init__(self,
156                 filename: str,
157                 widgets: List[str] = None,
158                 domain: str = None,
159                 gettext_: Any = None) -> None:
160        self._builder = Gtk.Builder()
161
162        if domain is None:
163            domain = i18n.DOMAIN
164        self._builder.set_translation_domain(domain)
165
166        if gettext_ is None:
167            gettext_ = _
168
169        xml_text = self._load_string_from_filename(filename, gettext_)
170
171        if widgets is not None:
172            self._builder.add_objects_from_string(xml_text, widgets)
173        else:
174            self._builder.add_from_string(xml_text)
175
176    @staticmethod
177    @functools.lru_cache(maxsize=None)
178    def _load_string_from_filename(filename, gettext_):
179        file_path = str(configpaths.get('GUI') / filename)
180
181        if sys.platform == "win32":
182            # This is a workaround for non working translation on Windows
183            tree = ET.parse(file_path)
184            for node in tree.iter():
185                if 'translatable' in node.attrib and node.text is not None:
186                    node.text = gettext_(node.text)
187
188            return ET.tostring(tree.getroot(),
189                               encoding='unicode',
190                               method='xml')
191
192
193        file = Gio.File.new_for_path(file_path)
194        content = file.load_contents(None)
195        return content[1].decode()
196
197    def __getattr__(self, name):
198        try:
199            return getattr(self._builder, name)
200        except AttributeError:
201            return self._builder.get_object(name)
202
203
204def get_builder(file_name: str, widgets: List[str] = None) -> Builder:
205    return Builder(file_name, widgets)
206
207
208def set_urgency_hint(window: Any, setting: bool) -> None:
209    if app.settings.get('use_urgency_hint'):
210        window.set_urgency_hint(setting)
211
212
213def icon_exists(name: str) -> bool:
214    return _icon_theme.has_icon(name)
215
216
217def load_icon(icon_name, widget=None, size=16, pixbuf=False,
218              scale=None, flags=Gtk.IconLookupFlags.FORCE_SIZE):
219
220    if widget is not None:
221        scale = widget.get_scale_factor()
222
223    if not scale:
224        log.warning('Could not determine scale factor')
225        scale = 1
226
227    try:
228        iconinfo = _icon_theme.lookup_icon_for_scale(
229            icon_name, size, scale, flags)
230        if iconinfo is None:
231            log.info('No icon found for %s', icon_name)
232            return
233        if pixbuf:
234            return iconinfo.load_icon()
235        return iconinfo.load_surface(None)
236    except GLib.GError as error:
237        log.error('Unable to load icon %s: %s', icon_name, str(error))
238
239
240def get_app_icon_list(scale_widget):
241    pixbufs = []
242    for size in (16, 32, 48, 64, 128):
243        pixbuf = load_icon('org.gajim.Gajim', scale_widget, size, pixbuf=True)
244        if pixbuf is not None:
245            pixbufs.append(pixbuf)
246    return pixbufs
247
248
249def get_icon_name(name: str,
250                  iconset: Optional[str] = None,
251                  transport: Optional[str] = None) -> str:
252    if name == 'not in roster':
253        name = 'notinroster'
254
255    if iconset is not None:
256        return '%s-%s' % (iconset, name)
257
258    if transport is not None:
259        return '%s-%s' % (transport, name)
260
261    iconset = app.settings.get('iconset')
262    if not iconset:
263        iconset = 'dcraven'
264    return '%s-%s' % (iconset, name)
265
266
267def load_user_iconsets():
268    iconsets_path = configpaths.get('MY_ICONSETS')
269    if not iconsets_path.exists():
270        return
271
272    for path in iconsets_path.iterdir():
273        if not path.is_dir():
274            continue
275        log.info('Found iconset: %s', path.stem)
276        _icon_theme.append_search_path(str(path))
277
278
279def get_available_iconsets():
280    iconsets = []
281    for iconset in GajimIconSet:
282        iconsets.append(iconset.value)
283
284    iconsets_path = configpaths.get('MY_ICONSETS')
285    if not iconsets_path.exists():
286        return iconsets
287
288    for path in iconsets_path.iterdir():
289        if not path.is_dir():
290            continue
291        iconsets.append(path.stem)
292    return iconsets
293
294
295def get_total_screen_geometry() -> Tuple[int, int]:
296    total_width = 0
297    total_height = 0
298    display = Gdk.Display.get_default()
299    monitors = display.get_n_monitors()
300    for num in range(0, monitors):
301        monitor = display.get_monitor(num)
302        geometry = monitor.get_geometry()
303        total_width += geometry.width
304        total_height = max(total_height, geometry.height)
305    log.debug('Get screen geometry: %s %s', total_width, total_height)
306    return total_width, total_height
307
308
309def resize_window(window: Gtk.Window, width: int, height: int) -> None:
310    """
311    Resize window, but also checks if huge window or negative values
312    """
313    screen_w, screen_h = get_total_screen_geometry()
314    if not width or not height:
315        return
316    if width > screen_w:
317        width = screen_w
318    if height > screen_h:
319        height = screen_h
320    window.resize(abs(width), abs(height))
321
322
323def move_window(window: Gtk.Window, pos_x: int, pos_y: int) -> None:
324    """
325    Move the window, but also check if out of screen
326    """
327    screen_w, screen_h = get_total_screen_geometry()
328    if pos_x < 0:
329        pos_x = 0
330    if pos_y < 0:
331        pos_y = 0
332    width, height = window.get_size()
333    if pos_x + width > screen_w:
334        pos_x = screen_w - width
335    if pos_y + height > screen_h:
336        pos_y = screen_h - height
337    window.move(pos_x, pos_y)
338
339
340def restore_roster_position(window):
341    if not app.settings.get('save-roster-position'):
342        return
343    if app.is_display(Display.WAYLAND):
344        return
345    move_window(window,
346                app.settings.get('roster_x-position'),
347                app.settings.get('roster_y-position'))
348
349
350def get_completion_liststore(entry: Gtk.Entry) -> Gtk.ListStore:
351    """
352    Create a completion model for entry widget completion list consists of
353    (Pixbuf, Text) rows
354    """
355    completion = Gtk.EntryCompletion()
356    liststore = Gtk.ListStore(str, str)
357
358    render_pixbuf = Gtk.CellRendererPixbuf()
359    completion.pack_start(render_pixbuf, False)
360    completion.add_attribute(render_pixbuf, 'icon_name', 0)
361
362    render_text = Gtk.CellRendererText()
363    completion.pack_start(render_text, True)
364    completion.add_attribute(render_text, 'text', 1)
365    completion.set_property('text_column', 1)
366    completion.set_model(liststore)
367    entry.set_completion(completion)
368    return liststore
369
370
371def get_cursor(name: str) -> Gdk.Cursor:
372    display = Gdk.Display.get_default()
373    cursor = Gdk.Cursor.new_from_name(display, name)
374    if cursor is not None:
375        return cursor
376    return Gdk.Cursor.new_from_name(display, 'default')
377
378
379def scroll_to_end(widget: Gtk.ScrolledWindow) -> bool:
380    """Scrolls to the end of a GtkScrolledWindow.
381
382    Args:
383        widget (GtkScrolledWindow)
384
385    Returns:
386        bool: The return value is False so it can be used with GLib.idle_add.
387    """
388    adj_v = widget.get_vadjustment()
389    if adj_v is None:
390        # This can happen when the Widget is already destroyed when called
391        # from GLib.idle_add
392        return False
393    max_scroll_pos = adj_v.get_upper() - adj_v.get_page_size()
394    adj_v.set_value(max_scroll_pos)
395
396    adj_h = widget.get_hadjustment()
397    adj_h.set_value(0)
398    return False
399
400
401def at_the_end(widget: Gtk.ScrolledWindow) -> bool:
402    """Determines if a Scrollbar in a GtkScrolledWindow is at the end.
403
404    Args:
405        widget (GtkScrolledWindow)
406
407    Returns:
408        bool: The return value is True if at the end, False if not.
409    """
410    adj_v = widget.get_vadjustment()
411    max_scroll_pos = adj_v.get_upper() - adj_v.get_page_size()
412    return adj_v.get_value() == max_scroll_pos
413
414
415def get_image_button(icon_name, tooltip, toggle=False):
416    if toggle:
417        button = Gtk.ToggleButton()
418        image = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.MENU)
419        button.set_image(image)
420    else:
421        button = Gtk.Button.new_from_icon_name(icon_name, Gtk.IconSize.MENU)
422    button.set_tooltip_text(tooltip)
423    return button
424
425
426def get_image_from_icon_name(icon_name: str, scale: int) -> Any:
427    icon = get_icon_name(icon_name)
428    surface = _icon_theme.load_surface(icon, 16, scale, None, 0)
429    return Gtk.Image.new_from_surface(surface)
430
431
432def python_month(month: int) -> int:
433    return month + 1
434
435
436def gtk_month(month: int) -> int:
437    return month - 1
438
439
440def convert_rgb_to_hex(rgb_string: str) -> str:
441    rgb = Gdk.RGBA()
442    rgb.parse(rgb_string)
443    rgb.to_color()
444
445    red = int(rgb.red * 255)
446    green = int(rgb.green * 255)
447    blue = int(rgb.blue * 255)
448    return '#%02x%02x%02x' % (red, green, blue)
449
450
451@lru_cache(maxsize=1024)
452def convert_rgb_string_to_float(rgb_string: str) -> Tuple[float, float, float]:
453    rgba = Gdk.RGBA()
454    rgba.parse(rgb_string)
455    return (rgba.red, rgba.green, rgba.blue)
456
457
458def get_monitor_scale_factor() -> int:
459    display = Gdk.Display.get_default()
460    monitor = display.get_primary_monitor()
461    if monitor is None:
462        log.warning('Could not determine scale factor')
463        return 1
464    return monitor.get_scale_factor()
465
466
467def get_metacontact_surface(icon_name, expanded, scale):
468    icon_size = 16
469    state_surface = _icon_theme.load_surface(
470        icon_name, icon_size, scale, None, 0)
471    if 'event' in icon_name:
472        return state_surface
473
474    if expanded:
475        icon = get_icon_name('opened')
476        expanded_surface = _icon_theme.load_surface(
477            icon, icon_size, scale, None, 0)
478    else:
479        icon = get_icon_name('closed')
480        expanded_surface = _icon_theme.load_surface(
481            icon, icon_size, scale, None, 0)
482    ctx = cairo.Context(state_surface)
483    ctx.rectangle(0, 0, icon_size, icon_size)
484    ctx.set_source_surface(expanded_surface)
485    ctx.fill()
486    return state_surface
487
488
489def get_show_in_roster(event, session=None):
490    """
491    Return True if this event must be shown in roster, else False
492    """
493    if event == 'gc_message_received':
494        return True
495    if event == 'message_received':
496        if session and session.control:
497            return False
498    return True
499
500
501def get_show_in_systray(type_, account, jid):
502    """
503    Return True if this event must be shown in systray, else False
504    """
505    if type_ == 'printed_gc_msg':
506        contact = app.contacts.get_groupchat_contact(account, jid)
507        if contact is not None:
508            return contact.can_notify()
509        # it's not an highlighted message, don't show in systray
510        return False
511    return app.settings.get('trayicon_notification_on_events')
512
513
514def get_primary_accel_mod():
515    """
516    Returns the primary Gdk.ModifierType modifier.
517    cmd on osx, ctrl everywhere else.
518    """
519    return Gtk.accelerator_parse("<Primary>")[1]
520
521
522def get_hardware_key_codes(keyval):
523    keymap = Gdk.Keymap.get_for_display(Gdk.Display.get_default())
524
525    valid, key_map_keys = keymap.get_entries_for_keyval(keyval)
526    if not valid:
527        return []
528    return [key.keycode for key in key_map_keys]
529
530
531def ensure_not_destroyed(func):
532    @wraps(func)
533    def func_wrapper(self, *args, **kwargs):
534        if self._destroyed:  # pylint: disable=protected-access
535            return None
536        return func(self, *args, **kwargs)
537    return func_wrapper
538
539
540def format_mood(mood, text):
541    if mood is None:
542        return ''
543    mood = MOODS[mood]
544    markuptext = '<b>%s</b>' % GLib.markup_escape_text(mood)
545    if text is not None:
546        markuptext += ' (%s)' % GLib.markup_escape_text(text)
547    return markuptext
548
549
550def get_account_mood_icon_name(account):
551    client = app.get_client(account)
552    mood = client.get_module('UserMood').get_current_mood()
553    return f'mood-{mood.mood}' if mood is not None else mood
554
555
556def format_activity(activity, subactivity, text):
557    if subactivity in ACTIVITIES[activity]:
558        subactivity = ACTIVITIES[activity][subactivity]
559    activity = ACTIVITIES[activity]['category']
560
561    markuptext = '<b>' + GLib.markup_escape_text(activity)
562    if subactivity:
563        markuptext += ': ' + GLib.markup_escape_text(subactivity)
564    markuptext += '</b>'
565    if text:
566        markuptext += ' (%s)' % GLib.markup_escape_text(text)
567    return markuptext
568
569
570def get_activity_icon_name(activity, subactivity=None):
571    icon_name = 'activity-%s' % activity.replace('_', '-')
572    if subactivity is not None:
573        icon_name += '-%s' % subactivity.replace('_', '-')
574    return icon_name
575
576
577def get_account_activity_icon_name(account):
578    client = app.get_client(account)
579    activity = client.get_module('UserActivity').get_current_activity()
580    if activity is None:
581        return None
582    return get_activity_icon_name(activity.activity, activity.subactivity)
583
584
585def format_tune(artist, _length, _rating, source, title, _track, _uri):
586    artist = GLib.markup_escape_text(artist or _('Unknown Artist'))
587    title = GLib.markup_escape_text(title or _('Unknown Title'))
588    source = GLib.markup_escape_text(source or _('Unknown Source'))
589
590    tune_string = _('<b>"%(title)s"</b> by <i>%(artist)s</i>\n'
591                    'from <i>%(source)s</i>') % {'title': title,
592                                                 'artist': artist,
593                                                 'source': source}
594    return tune_string
595
596
597def get_account_tune_icon_name(account):
598    client = app.get_client(account)
599    tune = client.get_module('UserTune').get_current_tune()
600    return None if tune is None else 'audio-x-generic'
601
602
603def format_location(location):
604    location = location._asdict()
605    location_string = ''
606    for attr, value in location.items():
607        if value is None:
608            continue
609        text = GLib.markup_escape_text(value)
610        # Translate standard location tag
611        tag = LOCATION_DATA.get(attr)
612        if tag is None:
613            continue
614        location_string += '\n<b>%(tag)s</b>: %(text)s' % {
615            'tag': tag.capitalize(), 'text': text}
616
617    return location_string.strip()
618
619
620def get_account_location_icon_name(account):
621    client = app.get_client(account)
622    location = client.get_module('UserLocation').get_current_location()
623    return None if location is None else 'applications-internet'
624
625
626def format_fingerprint(fingerprint):
627    fplen = len(fingerprint)
628    wordsize = fplen // 8
629    buf = ''
630    for char in range(0, fplen, wordsize):
631        buf += '{0} '.format(fingerprint[char:char + wordsize])
632    buf = textwrap.fill(buf, width=36)
633    return buf.rstrip().upper()
634
635
636def find_widget(name, container):
637    for child in container.get_children():
638        if Gtk.Buildable.get_name(child) == name:
639            return child
640        if isinstance(child, Gtk.Box):
641            return find_widget(name, child)
642    return None
643
644
645class MultiLineLabel(Gtk.Label):
646    def __init__(self, *args, **kwargs):
647        Gtk.Label.__init__(self, *args, **kwargs)
648        self.set_line_wrap(True)
649        self.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR)
650        self.set_single_line_mode(False)
651
652
653class MaxWidthComboBoxText(Gtk.ComboBoxText):
654    def __init__(self, *args, **kwargs):
655        Gtk.ComboBoxText.__init__(self, *args, **kwargs)
656        self._max_width = 100
657        text_renderer = self.get_cells()[0]
658        text_renderer.set_property('ellipsize', Pango.EllipsizeMode.END)
659
660    def set_max_size(self, size):
661        self._max_width = size
662
663    def do_get_preferred_width(self):
664        minimum_width, natural_width = Gtk.ComboBoxText.do_get_preferred_width(
665            self)
666
667        if natural_width > self._max_width:
668            natural_width = self._max_width
669        if minimum_width > self._max_width:
670            minimum_width = self._max_width
671        return minimum_width, natural_width
672
673
674def text_to_color(text):
675    if app.css_config.prefer_dark:
676        background = (0, 0, 0)  # RGB (0, 0, 0) black
677    else:
678        background = (1, 1, 1)  # RGB (255, 255, 255) white
679    return nbxmpp.util.text_to_color(text, background)
680
681
682def get_color_for_account(account: str) -> str:
683    col_r, col_g, col_b = text_to_color(account)
684    rgba = Gdk.RGBA(red=col_r, green=col_g, blue=col_b)
685    return rgba.to_string()
686
687
688def generate_account_badge(account):
689    account_label = app.get_account_label(account)
690    badge = Gtk.Label(label=account_label)
691    badge.set_ellipsize(Pango.EllipsizeMode.END)
692    badge.set_max_width_chars(12)
693    badge.set_size_request(50, -1)
694    account_class = app.css_config.get_dynamic_class(account)
695    badge_context = badge.get_style_context()
696    badge_context.add_class(account_class)
697    badge_context.add_class('badge')
698    return badge
699
700
701@lru_cache(maxsize=16)
702def get_css_show_class(show):
703    if show in ('online', 'chat'):
704        return '.gajim-status-online'
705    if show == 'away':
706        return '.gajim-status-away'
707    if show in ('dnd', 'xa'):
708        return '.gajim-status-dnd'
709    # 'offline', 'not in roster', 'requested'
710    return '.gajim-status-offline'
711
712
713def scale_with_ratio(size, width, height):
714    if height == width:
715        return size, size
716    if height > width:
717        ratio = height / float(width)
718        return int(size / ratio), size
719
720    ratio = width / float(height)
721    return size, int(size / ratio)
722
723
724def load_pixbuf(path, size=None):
725    try:
726        if size is None:
727            return GdkPixbuf.Pixbuf.new_from_file(str(path))
728        return GdkPixbuf.Pixbuf.new_from_file_at_scale(
729            str(path), size, size, True)
730
731    except GLib.GError:
732        try:
733            with open(path, 'rb') as im_handle:
734                img = Image.open(im_handle)
735                avatar = img.convert("RGBA")
736        except (NameError, OSError):
737            log.warning('Pillow convert failed: %s', path)
738            log.debug('Error', exc_info=True)
739            return None
740
741        array = GLib.Bytes.new(avatar.tobytes())
742        width, height = avatar.size
743        pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(
744            array, GdkPixbuf.Colorspace.RGB, True,
745            8, width, height, width * 4)
746        if size is not None:
747            width, height = scale_with_ratio(size, width, height)
748            return pixbuf.scale_simple(width,
749                                       height,
750                                       GdkPixbuf.InterpType.BILINEAR)
751        return pixbuf
752
753    except RuntimeError as error:
754        log.warning('Loading pixbuf failed: %s', error)
755        return None
756
757
758def get_thumbnail_size(pixbuf, size):
759    # Calculates the new thumbnail size while preserving the aspect ratio
760    image_width = pixbuf.get_width()
761    image_height = pixbuf.get_height()
762
763    if image_width > image_height:
764        if image_width > size:
765            image_height = math.ceil(
766                (size / float(image_width) * image_height))
767            image_width = int(size)
768    else:
769        if image_height > size:
770            image_width = math.ceil(
771                (size / float(image_height) * image_width))
772            image_height = int(size)
773
774    return image_width, image_height
775
776
777def make_href_markup(string):
778    url_color = app.css_config.get_value('.gajim-url', StyleAttr.COLOR)
779    color = convert_rgb_to_hex(url_color)
780
781    def _to_href(match):
782        url = match.group()
783        if '://' not in url:
784            url = 'https://' + url
785        return '<a href="%s"><span foreground="%s">%s</span></a>' % (
786            url, color, match.group())
787
788    return URL_REGEX.sub(_to_href, string)
789
790
791def get_app_windows(account):
792    windows = []
793    for win in app.app.get_windows():
794        if hasattr(win, 'account'):
795            if win.account == account:
796                windows.append(win)
797    return windows
798
799
800def get_app_window(name, account=None, jid=None):
801    for win in app.app.get_windows():
802        if type(win).__name__ != name:
803            continue
804
805        if account is not None:
806            if account != win.account:
807                continue
808
809        if jid is not None:
810            if jid != win.jid:
811                continue
812        return win
813    return None
814
815
816def open_window(name, **kwargs):
817    window = get_app_window(name,
818                            kwargs.get('account'),
819                            kwargs.get('jid'))
820    if window is None:
821        module = import_module(WINDOW_MODULES[name])
822        window_cls = getattr(module, name)
823        window = window_cls(**kwargs)
824    else:
825        window.present()
826    return window
827
828
829class EventHelper(CommonEventHelper):
830    def __init__(self):
831        CommonEventHelper.__init__(self)
832        self.connect('destroy', self.__on_destroy)  # pylint: disable=no-member
833
834    def __on_destroy(self, *args):
835        self.unregister_events()
836
837
838def check_destroy(widget):
839    def _destroy(*args):
840        print('DESTROYED', args)
841    widget.connect('destroy', _destroy)
842
843
844def check_finalize(obj, name):
845    weakref.finalize(obj, print, f'{name} has been finalized')
846