diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-10 21:38:38 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-10 21:38:38 +0000 |
commit | 2e2851dc13d73352530dd4495c7e05603b2e520d (patch) | |
tree | 622b9cd8e5d32091c9aa9e4937b533975a40356c /deluge/ui/gtk3/common.py | |
parent | Initial commit. (diff) | |
download | deluge-ba36a3baaa52c6e8dc58a724548d75d3db6e8ef9.tar.xz deluge-ba36a3baaa52c6e8dc58a724548d75d3db6e8ef9.zip |
Adding upstream version 2.1.2~dev0+20240219.upstream/2.1.2_dev0+20240219upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'deluge/ui/gtk3/common.py')
-rw-r--r-- | deluge/ui/gtk3/common.py | 435 |
1 files changed, 435 insertions, 0 deletions
diff --git a/deluge/ui/gtk3/common.py b/deluge/ui/gtk3/common.py new file mode 100644 index 0000000..42a14b4 --- /dev/null +++ b/deluge/ui/gtk3/common.py @@ -0,0 +1,435 @@ +# +# Copyright (C) 2008 Marcos Mobley ('markybob') <markybob@gmail.com> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# +"""Common functions for various parts of gtkui to use.""" +import contextlib +import logging +import os +import pickle +import shutil +import sys + +from gi.repository.Gdk import SELECTION_CLIPBOARD, SELECTION_PRIMARY, Display +from gi.repository.GdkPixbuf import Colorspace, Pixbuf +from gi.repository.GLib import GError +from gi.repository.Gtk import ( + Clipboard, + IconTheme, + Menu, + MenuItem, + RadioMenuItem, + SeparatorMenuItem, + SortType, +) + +from deluge.common import get_pixmap, is_ip, osx_check, windows_check + +log = logging.getLogger(__name__) + + +def cmp(x, y): + """Replacement for built-in function cmp that was removed in Python 3. + + Compare the two objects x and y and return an integer according to + the outcome. The return value is negative if x < y, zero if x == y + and strictly positive if x > y. + """ + + try: + return (x > y) - (x < y) + except TypeError: + # Handle NoneType comparison + if x is None: + if y is None: + return 0 + return -1 + elif y is None: + return 1 + else: + raise + + +def create_blank_pixbuf(size=16): + pix = Pixbuf.new(Colorspace.RGB, True, 8, size, size) + pix.fill(0x0) + return pix + + +def get_pixbuf(filename: str, size: int = 0) -> Pixbuf: + """Creates a new pixbuf by loading an image from file + + Args: + filename: An image file to load + size: Specify a size constraint (equal aspect ratio) + + Returns: + A newly created pixbuf + + """ + # Skip ico and gif that cause Pixbuf crash on Windows + # https://dev.deluge-torrent.org/ticket/3501 + if windows_check() and filename.endswith(('.ico', '.gif')): + return create_blank_pixbuf(size) + + if not os.path.isabs(filename): + filename = get_pixmap(filename) + + pixbuf = None + try: + if size: + pixbuf = Pixbuf.new_from_file_at_size(filename, size, size) + else: + pixbuf = Pixbuf.new_from_file(filename) + except GError as ex: + # Failed to load the pixbuf (Bad image file), so return a blank pixbuf. + log.warning(ex) + + return pixbuf or create_blank_pixbuf(size or 16) + + +# Status icons.. Create them from file only once to avoid constantly re-creating them. +icon_downloading = get_pixbuf('downloading16.png') +icon_seeding = get_pixbuf('seeding16.png') +icon_inactive = get_pixbuf('inactive16.png') +icon_alert = get_pixbuf('alert16.png') +icon_queued = get_pixbuf('queued16.png') +icon_checking = get_pixbuf('checking16.png') + + +def get_logo(size): + """A Deluge logo. + + Params: + size (int): Size of logo in pixels + + Returns: + Pixbuf: deluge logo + """ + filename = 'deluge.svg' + if windows_check(): + filename = 'deluge.png' + return get_pixbuf(filename, size) + + +def build_menu_radio_list( + value_list, + callback, + pref_value=None, + suffix=None, + show_notset=False, + notset_label='∞', + notset_lessthan=0, + show_other=False, +): + """Build a menu with radio menu items from a list and connect them to the callback. + + Params: + value_list [list]: List of values to build into a menu. + callback (function): The function to call when menu item is clicked. + pref_value (int): A preferred value to insert into value_list + suffix (str): Append a suffix the the menu items in value_list. + show_notset (bool): Show the unlimited menu item. + notset_label (str): The text for the unlimited menu item. + notset_lessthan (int): Activates the unlimited menu item if pref_value is less than this. + show_other (bool): Show the `Other` menu item. + + The pref_value is what you would like to test for the default active radio item. + + Returns: + Menu: The menu radio + """ + menu = Menu() + # Create menuitem to prevent unwanted toggled callback when creating menu. + menuitem = RadioMenuItem() + group = menuitem.get_group() + + if pref_value > -1 and pref_value not in value_list: + value_list.pop() + value_list.append(pref_value) + + for value in sorted(value_list): + item_text = str(value) + if suffix: + item_text += ' ' + suffix + menuitem = RadioMenuItem.new_with_label(group, item_text) + if pref_value and value == pref_value: + menuitem.set_active(True) + if callback: + menuitem.connect('toggled', callback) + menu.append(menuitem) + + if show_notset: + menuitem = RadioMenuItem.new_with_label(group, notset_label) + menuitem.set_name('unlimited') + if pref_value and pref_value < notset_lessthan: + menuitem.set_active(True) + menuitem.connect('toggled', callback) + menu.append(menuitem) + + if show_other: + menuitem = SeparatorMenuItem() + menu.append(menuitem) + menuitem = MenuItem.new_with_label(_('Other...')) + menuitem.set_name('other') + menuitem.connect('activate', callback) + menu.append(menuitem) + + return menu + + +def reparent_iter(treestore, itr, parent, move_siblings=False): + """ + This effectively moves itr plus it's children to be a child of parent in treestore + + Params: + treestore (gtkTreeStore): the treestore + itr (gtkTreeIter): the iter to move + parent (gtkTreeIter): the new parent for itr + move_siblings (bool): if True, it will move all itr's siblings to parent + """ + src = itr + + def move_children(i, dest): + while i: + n_cols = treestore.append( + dest, treestore.get(i, *range(treestore.get_n_columns())) + ) + to_remove = i + if treestore.iter_children(i): + move_children(treestore.iter_children(i), n_cols) + if not move_siblings and i == src: + i = None + else: + i = treestore.iter_next(i) + + treestore.remove(to_remove) + + move_children(itr, parent) + + +def get_deluge_icon(): + """The deluge icon for use in dialogs. + + It will first attempt to get the icon from the theme and will fallback to using an image + that is distributed with the package. + + Returns: + Pixbuf: the deluge icon + """ + if windows_check(): + return get_logo(32) + else: + try: + icon_theme = IconTheme.get_default() + return icon_theme.load_icon('deluge', 64, 0) + except GError: + return get_logo(64) + + +def associate_magnet_links(overwrite=False): + """ + Associates magnet links to Deluge. + + Params: + overwrite (bool): if this is True, the current setting will be overwritten + + Returns: + bool: True if association was set + """ + + if windows_check(): + import winreg + + try: + hkey = winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, 'Magnet') + except OSError: + overwrite = True + else: + winreg.CloseKey(hkey) + + if overwrite: + deluge_exe = os.path.join(os.path.dirname(sys.executable), 'deluge.exe') + try: + magnet_key = winreg.CreateKey(winreg.HKEY_CLASSES_ROOT, 'Magnet') + except OSError: + # Could not create for all users, falling back to current user + magnet_key = winreg.CreateKey( + winreg.HKEY_CURRENT_USER, 'Software\\Classes\\Magnet' + ) + + winreg.SetValue(magnet_key, '', winreg.REG_SZ, 'URL:Magnet Protocol') + winreg.SetValueEx(magnet_key, 'URL Protocol', 0, winreg.REG_SZ, '') + winreg.SetValueEx(magnet_key, 'BrowserFlags', 0, winreg.REG_DWORD, 0x8) + winreg.SetValue(magnet_key, 'DefaultIcon', winreg.REG_SZ, f'{deluge_exe},0') + winreg.SetValue( + magnet_key, + r'shell\open\command', + winreg.REG_SZ, + f'"{deluge_exe}" "%1"', + ) + winreg.CloseKey(magnet_key) + + # Don't try associate magnet on OSX see: #2420 + elif not osx_check(): + # gconf method is only available in a GNOME environment + try: + import gi + + gi.require_version('GConf', '2.0') + from gi.repository import GConf + except ValueError: + log.debug( + 'gconf not available, so will not attempt to register magnet URI handler' + ) + return False + else: + key = '/desktop/gnome/url-handlers/magnet/command' + gconf_client = GConf.Client.get_default() + if (gconf_client.get(key) and overwrite) or not gconf_client.get(key): + # We are either going to overwrite the key, or do it if it hasn't been set yet + if gconf_client.set_string(key, 'deluge "%s"'): + gconf_client.set_bool( + '/desktop/gnome/url-handlers/magnet/needs_terminal', False + ) + gconf_client.set_bool( + '/desktop/gnome/url-handlers/magnet/enabled', True + ) + log.info('Deluge registered as default magnet URI handler!') + return True + else: + log.error( + 'Unable to register Deluge as default magnet URI handler.' + ) + return False + return False + + +def save_pickled_state_file(filename, state): + """Save a file in the config directory and creates a backup + + Params: + filename (str): Filename to be saved to config + state (state): The data to be pickled and written to file + """ + from deluge.configmanager import get_config_dir + + filepath = os.path.join(get_config_dir(), 'gtk3ui_state', filename) + filepath_bak = filepath + '.bak' + filepath_tmp = filepath + '.tmp' + + try: + if os.path.isfile(filepath): + log.debug('Creating backup of %s at: %s', filename, filepath_bak) + shutil.copy2(filepath, filepath_bak) + except OSError as ex: + log.error('Unable to backup %s to %s: %s', filepath, filepath_bak, ex) + else: + log.info('Saving the %s at: %s', filename, filepath) + try: + with open(filepath_tmp, 'wb') as _file: + # Pickle the state object + pickle.dump(state, _file, protocol=2) + _file.flush() + os.fsync(_file.fileno()) + shutil.move(filepath_tmp, filepath) + except (OSError, EOFError, pickle.PicklingError) as ex: + log.error('Unable to save %s: %s', filename, ex) + if os.path.isfile(filepath_bak): + log.info('Restoring backup of %s from: %s', filename, filepath_bak) + shutil.move(filepath_bak, filepath) + + +def load_pickled_state_file(filename): + """Loads a file from the config directory, attempting backup if original fails to load. + + Params: + filename (str): Filename to be loaded from config + + Returns: + state: the unpickled state + """ + from deluge.configmanager import get_config_dir + + filepath = os.path.join(get_config_dir(), 'gtk3ui_state', filename) + filepath_bak = filepath + '.bak' + + for _filepath in (filepath, filepath_bak): + log.info('Opening %s for load: %s', filename, _filepath) + try: + with open(_filepath, 'rb') as _file: + state = pickle.load(_file, encoding='utf8') + except (OSError, pickle.UnpicklingError) as ex: + log.warning('Unable to load %s: %s', _filepath, ex) + else: + log.info('Successfully loaded %s: %s', filename, _filepath) + return state + + +@contextlib.contextmanager +def listview_replace_treestore(listview): + """Prepare a listview's treestore to be entirely replaced. + + Params: + listview: a listview backed by a treestore + """ + # From http://faq.pygtk.org/index.py?req=show&file=faq13.043.htp + # "tips for improving performance when adding many rows to a Treeview" + listview.freeze_child_notify() + treestore = listview.get_model() + listview.set_model(None) + treestore.clear() + treestore.set_default_sort_func(lambda *args: 0) + original_sort = treestore.get_sort_column_id() + treestore.set_sort_column_id(-1, SortType.ASCENDING) + + yield + + if original_sort != (None, None): + treestore.set_sort_column_id(*original_sort) + + listview.set_model(treestore) + listview.thaw_child_notify() + + +def get_clipboard_text(): + text = ( + Clipboard.get(SELECTION_CLIPBOARD).wait_for_text() + or Clipboard.get(SELECTION_PRIMARY).wait_for_text() + ) + if text: + return text.strip() + + +def windowing(like): + return like.lower() in str(type(Display.get_default())).lower() + + +def parse_ip_port(text): + """Return an IP and port from text. + + Parses both IPv4 and IPv6. + + Params: + text (str): Text to be parsed for IP and port. + + Returns: + tuple: (ip (str), port (int)) + + """ + if '.' in text: + # ipv4 + ip, __, port = text.rpartition(':') + elif '[' in text: + # ipv6 + ip, __, port = text.partition('[')[2].partition(']:') + else: + return None, None + + if ip and is_ip(ip) and port.isdigit(): + return ip, int(port) + else: + return None, None |