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