#!/usr/local/bin/python3.8 """Provide functionality relating to an app in a dock. Allow info relating to a running app (e.g. icon, command line, .desktop file location, running processes, open windows etc) to be obtained from the information that libWnck provides Provide a surface on which the application's icon and the running indicator can be drawn Ensure that the app's icon and indicator are always drawn correctly according to the size and orientation of the panel Provide visual feedback to the user when an app is launched by pulsating the app's icon Draw a highlight around the app's icon if it is the foreground app Maintain a list of all of the app's running processes and their windows Ensure that the application's windows visually minimise to the application's icon on the dock """ # # Copyright (C) 1997-2003 Free Software Foundation, Inc. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License as # published by the Free Software Foundation; either version 2 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA # 02110-1301, USA. # # Author: # Robin Thompson # do not change the value of this variable - it will be set during build # according to the value of the --with-gtk3 option used with .configure build_gtk2 = False import gi if build_gtk2: gi.require_version("Gtk", "2.0") gi.require_version("Wnck", "1.0") else: gi.require_version("Gtk", "3.0") gi.require_version("Wnck", "3.0") gi.require_version("MatePanelApplet", "4.0") gi.require_version("Bamf", "3") from gi.repository import Gtk from gi.repository import MatePanelApplet from gi.repository import Gdk from gi.repository import GdkPixbuf from gi.repository import Wnck from gi.repository import Gio from gi.repository import GObject from gi.repository import GLib from gi.repository import Bamf import cairo import math import xdg.DesktopEntry as DesktopEntry import xdg.BaseDirectory as BaseDirectory import os import os.path import subprocess import re import colorsys from collections import namedtuple import dock_prefs from docked_app_helpers import * import window_control from log_it import log_it as log_it ColorTup = namedtuple('ColorTup', ['r', 'g', 'b']) def get_backlight_color(pixbuf): """ Read all of the pixel values in a pixbuf and calculate an appropriate colour to use as an icon backlight Code adapated from Unity desktop (https://code.launchpad.net/~unity-team/unity/trunk) specifically from LauncherIcon::ColorForIcon in LauncherIcon.cpp Args: pixbuf : a pixbuf object containing the image Returns: a tuple of r,g,b value (0-255) """ width = pixbuf.props.width rowstride = pixbuf.props.rowstride height = pixbuf.props.height num_channels = pixbuf.get_n_channels() has_alpha = pixbuf.get_has_alpha() img = pixbuf.get_pixels() r_total = g_total = b_total = 0 total = 0.0 for w_count in range(width): for h_count in range(height): pix_index = (h_count * rowstride + w_count * num_channels) pix_r = img[pix_index] pix_g = img[pix_index + 1] pix_b = img[pix_index + 2] if has_alpha: pix_a = img[pix_index + 3] else: pix_a = 255 saturation = float(max(pix_r, max(pix_g, pix_b)) - min(pix_r, min(pix_g, pix_b))) / 255.0 relevance = .1 + .9 * (float(pix_a) / 255) * saturation r_total += (pix_r * relevance) % 256 g_total += (pix_g * relevance) % 256 b_total += (pix_b * relevance) % 256 total += relevance * 255.0 r = r_total / total g = g_total / total b = b_total / total h, s, v = colorsys.rgb_to_hsv(r, g, b) if s > 0.15: s = 0.65 v = 0.6666 # Note: Unty uses v = 0.9, but this produces a very bright value which # is reduced elsewhere. We use 0.6666 to reduce it here br, bg, bb = colorsys.hsv_to_rgb(h, s, v) v = 1.0 gr, gg, gb = colorsys.hsv_to_rgb(h, s, v) return int(br * 255), int(bg * 255), int(bb * 255) def get_avg_color(pixbuf): """calculate the average colour of a pixbuf. Read all of the pixel values in a pixbuf (excluding those which are below a certain alpha value) and calculate the average colour of all the contained colours Args: pixbuf : a pixbuf object containing the image Returns: a tuple of r,g,b values (0-255) """ width = pixbuf.props.width rowstride = pixbuf.props.rowstride height = pixbuf.props.height has_alpha = pixbuf.get_has_alpha() pixels = pixbuf.get_pixels() nchannels = pixbuf.get_n_channels() # convert the pixels into an rgb array with alpha channel data = [] for y_count in range(height - 1): x_count = 0 while x_count < (width * nchannels): pix_red = pixels[x_count + (rowstride * y_count)] pix_green = pixels[x_count + 1 + (rowstride * y_count)] pix_blue = pixels[x_count + 2 + (rowstride * y_count)] if has_alpha: pix_alpha = pixels[x_count + 3 + (rowstride * y_count)] else: pix_alpha = 255 data.append([pix_red, pix_green, pix_blue, pix_alpha]) x_count += nchannels red = 0 green = 0 blue = 0 num_counted = 0 for pixels in range(len(data)): if data[pixels][3] > 200: # only count pixel if alpha above this # level red += data[pixels][0] green += data[pixels][1] blue += data[pixels][2] num_counted += 1 if num_counted > 0: ravg = int(red / num_counted) gavg = int(green / num_counted) bavg = int(blue / num_counted) else: # in case of a bad icon assume a grey average colour # this should fix a division by zero error that occurred at this point # in the code, but which I've only seen once and never been able to # duplicate ravg = gavg = bavg = 128 return ravg, gavg, bavg CONST_PULSE_STEPS = 20 CONST_PULSE_DELAY = 40 class ScrollType: """ Class to define the ways in which the docked apps may scroll in the dock""" SCROLL_NONE = 0 SCROLL_UP = 1 # for horizontal applets 'up' equates to 'left' SCROLL_DOWN = 2 # or, for horizontal applets, right... class PulseTimer(object): """Class to help provide feedback when a user launches an app from the dock. Instantiates a timer which periodically redraws an application in the dock at various transparency levels until the timer has been run a certain number of times Attributes: app = the DockedApp object which we want to pulsate timer_id = the id of the timer that is instantiated """ def __init__(self, app, once_only=False): """Init for the PulseTimer class. Sets everything up by creating the timer, setting a reference to the DockedApp and telling the app that it is pulsing Arguments: app : the DockedApp object once_only : do only one iteration of pulsing, then stop """ self.timer_count = 0 self.app = app self.app.pulse_step = 0 self.app.is_pulsing = True self.once_only = once_only self.timer_id = GObject.timeout_add(CONST_PULSE_DELAY, self.do_timer) def do_timer(self): """The timer function. Increments the number of times the time function has been called. If it hasn't reached the maximum number, increment the app's pulse counter. If the maximum number has been reached, stop the app pulsing and delete the timer. Redraw the app's icon """ # the docked app may indicate it no longer wants to pulse... if not self.app.is_pulsing: self.remove_timer() self.app.queue_draw() return False self.timer_count += 1 if self.timer_count / int(1000 / CONST_PULSE_DELAY) == 45: # we've been pulsing for long enough, the user will be getting a headache self.remove_timer() self.app.queue_draw() return False if self.app.pulse_step != CONST_PULSE_STEPS: self.app.pulse_step += 1 else: # if we're starting the app for the first time (with startup notification) # and it still hasn't finished loading, carry on pulsing if (self.app.startup_id is not None) and not self.once_only: self.app.pulse_step = 0 else: # we only want the icon to pulse once # if we have a startup_id the notification needs to be cancelled if self.app.startup_id is not None: self.app.cancel_startup_notification() self.remove_timer() self.app.queue_draw() return True def remove_timer(self): """ Cancel the timer and stop the app icon pulsing... """ self.app.is_pulsing = False GObject.source_remove(self.timer_id) CONST_BLINK_DELAY = 330 class AttentionTimer(object): """Class to help provide visual feedback when an app requries user attention. Instantiates a timer which periodically checks whether or not the app still needs attention. If the app is blinking, it toggles the blink state on and off until the app no longer needs attention Attributes: app = the DockedApp object that needs attentions timer_id = the id of the timer that is instantiated """ def __init__(self, app): """Init for the AttentionTimer class. Sets everything up by creating the timer, setting a reference to the DockedApp and setting the inital flash state to off Arguments: app : the DockedApp object """ self.app = app self.app.needs_attention = True self.app.attention_blink_on = False self.timer_id = GObject.timeout_add(CONST_BLINK_DELAY, self.do_timer) # make the app redraw itself app.queue_draw() def do_timer(self): """The timer function. If the app no longer needs attention, stop it flashing and delete the timer. Otherwise, invert the flash. Finally,Redraw the app's icon """ if self.app.needs_attention: self.app.attention_blink_on = not self.app.attention_blink_on else: GObject.source_remove(self.timer_id) self.app.queue_draw() return True class DockedApp(object): """Provide a docked app class Attributes: bamf_app : the Bamf.Applications related to the running app app_name : e.g. Google Chrome, used for tooltips and the applet right click menu etc rc_actions : A list of strings containing the names of the additional application actions suppported by the app cmd_line : the command line and arguments used to start the app icon_name : name of the app icon icon_filename : the filename of the app icon desktop_file : the filename of the app's .desktop file desktop_ai : a Gio.GDesktopAppInfo read from the .desktop file startup_id : id used for startup notifications applet_win : the Gdk.Window of the panel applet applet : the panel applet applet_orient : the applet orientation drawing_area: Gtk.Label- provides a surface on which the app icon can be drawn drawing_area_size : the base size in pixels (height AND width) that we have to draw in - note that some indicators require more and must specify this... is_pulsing : boolean - True if the app is pulsing pulse_step : a count of how far we are through the pulse animation app_pb : a pixbuf of the app's icon app_surface : a surface of the app's icon highlight_colour : ColorTup of the colours used to highlight the app when it is foreground is_active : boolean - True = the app is the foreground app has_mouse : boolean - True = the mouse is over the app's icon is_pinned : boolean - Whether or not the app is pinned to the dock indicator : the type of indictor (e.g. light or dark) to draw under running apps active_bg : the type of background (e.g. gradient or solid fill) to be drawn when the app is the active app ind_ws : wnck_workspace or None - if set, indicators are to be drawn for windows on the specified workspace last_active_win : the Bamf.Window of the app's last active window is_dragee : boolean - indicates whether or not the app's icon is being dragged to a new position on the dock show_progress : boolean - indicates whether or not to display a progress indicator on the app's icon progress_val : the progress value( 0 to 1.0) show_count : boolean - indicates whether or not to display a count value on the app's icon count_val : the value of the count needs_attention: whether or not the app needs the user's attention attention_type : how the docked app indicates to the user that the app needs attention attention_blink_on : when an app blinks when it needs attention, this specfies the state scroll_dir : indicates the way that the dock may be scrolled (if any) if the mouse hovers over this app. Also used to draw the app icon in such a way as to indicate that scrolling is available """ def __init__(self): """ Init for the DockApplet class. Create a surface to draw the app icon on Set detault values """ super().__init__() self.bamf_app = None self.app_info = [] self.app_name = "" self.rc_actions = [] self.cmd_line = "" self.icon_name = "" self.icon_filename = "" self.desktop_file = "" self.desktop_ai = None self.icon_geometry_set = False self.applet_win = None self.applet_orient = None self.ind_ws = None self.startup_id = None self.applet = None # all drawing is done to a Gtk.Label rather than e.g. a drawing area # or event box this allows panel transparency/custom backgrounds to be # honoured # However, the downside of this is that mouse events cannot be handled # by this class and instead have to be done by the applet itself self.drawing_area = Gtk.Label() self.drawing_area.set_app_paintable(True) self.drawing_area_size = 0 self.is_pulsing = False self.pulse_step = 0 self.needs_attention = False self.attention_blink_on = False self.app_pb = None self.app_surface = None self.highlight_color = ColorTup(r=0.0, g=0.0, b=0.0) self.is_active = False self.has_mouse = False self.is_pinned = False # set defaults self.indicator = IndicatorType.LIGHT # light indicator self.multi_ind = False # single indicator self.active_bg = IconBgType.GRADIENT self.attention_type = dock_prefs.AttentionType.BLINK self.last_active_win = None # set up event handler for the draw/expose event if build_gtk2: self.drawing_area.connect("expose-event", self.do_expose_event) else: self.drawing_area.connect("draw", self.do_expose_event) self.is_dragee = False self.show_progress = False self.progress_val = 0.0 self.show_count = False self.count_val = 0 self.scroll_dir = ScrollType.SCROLL_NONE def set_bamf_app(self, b_app): """ Sets the Bamf.Application related to this docked app Params: b_app : the Bamf.Application to be added """ self.bamf_app = b_app def clear_bamf_app(self): """ Unsets the Bamf.Application related to this docked app Params: b_app : the Bamf.Application to removed """ self.bamf_app = None def has_bamf_app(self, b_app): """ Returns True if b_app is associated with this docked_app, False otherwise Params: b_app - a Bamf.Application """ return b_app == self.bamf_app def get_windows(self): """ Convenience function to return a list of the app's Bamf.Windows Returns : an empty list if the app is not running or if self.bamf_app is None, otherwise the window list """ ret_val = [] if (self.bamf_app is not None) and (self.bamf_app.is_running() or self.bamf_app.is_starting()): ret_val = self.bamf_app.get_windows() return ret_val def get_first_normal_win(self): """ Returns the app's first 'normal' window i.e. a window or dialog Returns: a Bamf.Window, or None """ if (self.bamf_app is not None) and (self.bamf_app.is_running()): for win in self.get_windows(): win_type = win.get_window_type() if win_type in [Bamf.WindowType.NORMAL, Bamf.WindowType.DIALOG] or win.is_user_visible(): return win return None def has_wnck_app(self, wnck_app): """ see if this app has a process with the specified wnck_app Returns True if the wnck_app is found, False otherwise """ ret_val = False for aai in self.app_info: if aai.app == wnck_app: ret_val = True break return ret_val def has_bamf_window(self, win): """ Checks to see if a window belongs to the app Params: win : the Bamf.Window we're interested in Returns: True if the window belongs to the app, False otherwise """ windows = self.get_windows() return win in windows def setup_from_bamf(self, app_match_list): """ Setup an already running app using info from self.bamf_app This is only called when bamf cannot match an app with it's .desktop file, so we can also do some extra checking from the list of hard to match .desktop files """ # get a list of all the possible locations of .desktop files data_dirs = BaseDirectory.xdg_data_dirs app_dirs = [] for dir in data_dirs: app_dirs.append(os.path.join(dir, "applications/")) # search for a match in for app in app_match_list: if self.bamf_app.get_name() == app[0]: for dir in app_dirs: desktop_file = os.path.join(dir, app[2]) if os.path.exists(desktop_file): self.desktop_file = desktop_file if self.read_info_from_desktop_file(): return # no match, so just get basic info self.app_name = self.bamf_app.get_name() self.icon_name = "wnck" # indicate we want to get the app icon from wnck def set_app_name(self, app_name): """sets the app name. Stores the entire name, which may or may not also contain a document title or other app specific info. This will need to be parsed when necessary to obtain the actual app name Args: The app name """ self.app_name = app_name def set_urgency(self, urgent): """ Sets the app's urgency state Params : urgent - bool, whether or not the app is signalling urgency """ if urgent: if not self.needs_attention: self.needs_attention = True self.attention_blink_on = False # initial blink state = off timer = AttentionTimer(self) if not self.is_visible(): self.show_icon() else: if self.needs_attention: # we need to turn flashing off self.needs_attention = False self.queue_draw() # the timer will handle the rest .... # hiding the icon (if necessary) will be taken care of next # time the user changes workspace def get_cmdline_from_pid(self, pid): """ Find the command line and arguments used to launch the app Use the ps command to return the command line and arguments for the specified pid Set self.path to the full command line Args: pid - a process id """ cmdstr = "xargs -0 < /proc/%d/cmdline" % pid cmd = subprocess.Popen(cmdstr, shell=True, stdout=subprocess.PIPE) for line in cmd.stdout: pass if line is not None: self.cmd_line = line.decode("utf-8") def has_windows_on_workspace(self, wnck_workspace): """ test whether the app has at least one window open on a specified workspace Args: wnck_workspace - the workspace to check for Returns: boolean """ for win in self.get_windows(): wnck_win = Wnck.Window.get(win.get_xid()) if wnck_win is not None: win_ws = wnck_win.get_workspace() if win_ws == wnck_workspace: return True return False def has_unminimized_windows(self): """ test whether the app has at least one unminimized window Returns: boolean """ win_list = self.get_windows() for win in win_list: wnck_win = Wnck.Window.get(win.get_xid()) if (wnck_win is not None) and (not wnck_win.is_minimized()): return True return False def hide_icon(self): """ Hides the app's icon""" self.drawing_area.set_visible(False) def show_icon(self): """ Shows the app's icon""" self.drawing_area.set_visible(True) def is_visible(self): """ Method which returns whether or not the app's icon is visible Returns: boolean """ return self.drawing_area.get_visible() def get_desktop_from_custom_launcher(self, srch_dir): """ Search the custom launchers in a specified directory for one where the Exec field is found within self.cmd_line If a match is found found, self.desktop_file is set accordingly Note: All custom launchers .desktop filenames start with "mda_" Args: srch_dir : the directory to search Returns: True if a match was found, False otherwise """ # TODO: replace DesktopEntry with Gio.DesktopAppInfo... and then # if the search dir doesn't exist, don't do anything if os.path.isdir(srch_dir) is False: return False for the_file in os.listdir(srch_dir): if (the_file.startswith("mda_")) and \ (the_file.endswith(".desktop")): the_de = DesktopEntry.DesktopEntry(srch_dir + the_file) # remove command line args from the Exec field of the .desktop de_exec = the_de.getExec().split(None, 1)[0] if self.cmd_line.find(de_exec) != -1: self.desktop_file = srch_dir + the_file return True def set_all_windows_icon_geometry(self, x, y, width, height): """Set the location on screen where all of the app's windows will be minimised to. Args: x : The X position in root window coordinates y : The Y position in root window coordinates width: the width of the minimise location height: the height of the minimise location """ for win in self.get_windows(): window_control.set_minimise_target(win, x, y, width, height) return True def get_allocation(self): """ Returns the allocated position and size of the app's icon within the applet """ alloc = self.drawing_area.get_allocation() return alloc.x, alloc.y, alloc.width, alloc.height def set_drawing_area_size(self, size): """Set the size request of the app's drawing area. Args : size : the size in pixels we need, although extra can be applied if required by indicators """ self.drawing_area_size = size extra_s = ind_extra_s(self.indicator) if extra_s == 0: self.drawing_area.set_size_request(size, size) else: # we need to allocate extra space in the x or y dimension depending on applet orientation if (self.applet_orient == MatePanelApplet.AppletOrient.DOWN) or \ (self.applet_orient == MatePanelApplet.AppletOrient.UP): self.drawing_area.set_size_request(size + extra_s, size) else: self.drawing_area.set_size_request(size, size + extra_s) def queue_draw(self): """Queue the app's icon to be redrawn. """ self.drawing_area.queue_draw() def set_indicator(self, indicator): """Set the running indicator type to the value specified Args: indicator - the indicator type """ self.indicator = indicator def set_active_bg(self, active_bg): """Set the active background type to the value specified Args: active_bg - the background type """ self.active_bg = active_bg def set_multi_ind(self, multi_ind): """ Set whether to use an indicator for each open window Args: multi_ind - boolean """ self.multi_ind = multi_ind def set_attention_type(self, attention_type): """Set the attention type to the value specified Args: indicator - the indicator type """ self.attention_type = attention_type def is_running(self): """ Is the app running ? Returns: True if the app is running, False if not """ if self.bamf_app is None: return False return self.bamf_app.is_running() def has_desktop_file(self): """ Does the app have a .desktop file? Returns: True if there is a desktop file, False otherwise """ return self.desktop_file is not None def read_info_from_desktop_file(self): """Attempt to read from read the app's desktop file. Will try to read the icon name and app name from the desktop file Will also get the executeable path if we don't already have this Will read the details of any right click menu options the .desktop file defines Returns: True if successful, False otherwise """ if self.desktop_file: if os.path.isabs(self.desktop_file): self.desktop_ai = Gio.DesktopAppInfo.new_from_filename(self.desktop_file) else: self.desktop_ai = Gio.DesktopAppInfo.new(self.desktop_file) self.app_name = self.desktop_ai.get_locale_string("Name") self.icon_name = self.desktop_ai.get_string("Icon") # if the desktop file does not specify an icon name, use the app # name instead if (self.icon_name is None) or (self.icon_name == ""): self.icon_name = self.app_name.lower() # hack for the MATE application browser app, where the # .desktop file on Ubuntu does not specify an icon if self.icon_name == "application browser": self.icon_name = "computer" # get the command specified in the .desktop file used to launch the app self.cmd_line = self.desktop_ai.get_string("Exec") # get the list of addtional application actions (to be activated by right # clicking the app's dock icon) self.rc_actions = self.desktop_ai.list_actions() return True return False def app_has_custom_launcher(self): """ Determines whether the docked app has a custom launcher Examine the .desktop filename. If it starts with "~/.local/share/applications/mda_" the app has a custom launcher Returns : True if the app has a custom launcher, False otherwise """ cl_start = os.path.expanduser("~/.local/share/applications/mda_") return os.path.expanduser(self.desktop_file).beginswith(cl_start) def win_state_changed(self, wnck_win, changed_mask, new_state): """Handler for the wnck_window state-changed event If the app needs attention and we're not already flashing the icon start it flashing. If the app icon is not visible, make it visible If the app doesn't need attention and its icon is flashing, stop it flashing """ if ((new_state & Wnck.WindowState.DEMANDS_ATTENTION) != 0) or\ ((new_state & Wnck.WindowState.URGENT) != 0): if not self.needs_attention: self.needs_attention = True self.attention_blink_on = False # initial blink state = off timer = AttentionTimer(self) if not self.is_visible(): self.show_icon() else: if self.needs_attention: # we need to turn flashing off self.needs_attention = False self.queue_draw() # the timer will handle the rest .... # hiding the icon (if necessary) will be taken care of next # time the user changes workspace def get_num_windows(self, cur_ws=None): """ Get the number of normal and dialog windows the app has open. If cur_ws is specfied, then only windows on the specified workspace are counted Params: cur_ws - an int representing the workspace number: Returns: an int """ num_win = 0 if self.bamf_app is not None: for win in self.get_windows(): win_type = win.get_window_type() if win_type in [Bamf.WindowType.NORMAL, Bamf.WindowType.DIALOG] and win.is_user_visible(): if cur_ws is None: num_win += 1 else: xid = win.get_xid() wnck_win = Wnck.Window.get(xid) if (wnck_win is not None) and wnck_win.is_on_workspace(cur_ws): num_win += 1 return num_win def do_expose_event(self, drawing_area, event): """The main drawing event for the docked app. Does the following: draw the app icon if the mouse is over the app icon, highlight the icon if the is running draw the app running indicators(according to the applet orientation) if the app is the foreground app, highlight the background with a gradient fill if the app is pulsing, draw the icon with a variable level of transparency according to the pulse count if the app is flashing, draw the icon either fully opaque or completely transparent according to its flash state if the app is being dragged to a new position on the dock, draw a completely transparent background Args: drawing_area : the drawing area that related to the event. Will always be the same as self.drawing area event : in Gtk2 the event arguments, in Gtk3 a cairo context to draw on """ # there are lots of drawing operations to be done, so do them to an # offscreen surface and when all is finished copy this to the docked # app if self.applet_orient == MatePanelApplet.AppletOrient.DOWN or \ self.applet_orient == MatePanelApplet.AppletOrient.UP: oss_w = self.drawing_area_size + ind_extra_s(self.indicator) oss_h = self.drawing_area_size else: oss_w = self.drawing_area_size oss_h = self.drawing_area_size + ind_extra_s(self.indicator) offscreen_surface = cairo.Surface.create_similar(self.app_surface, cairo.CONTENT_COLOR_ALPHA, oss_w, oss_h) ctx = cairo.Context(offscreen_surface) if self.is_dragee is False: # convert the highlight values to their cairo equivalents red = self.highlight_color.r / 255 green = self.highlight_color.g / 255 blue = self.highlight_color.b / 255 dbgd = None if self.applet_win is not None: scale_factor = self.applet_win.get_scale_factor() else: scale_factor = 1 if self.active_bg == IconBgType.UNITY_FLAT: dbgd = UnityFlatBackgroundDrawer(ctx, self.drawing_area_size, self.applet_orient, red, green, blue, self.is_running(), scale_factor) elif self.active_bg == IconBgType.UNITY: dbgd = UnityBackgroundDrawer(ctx, self.drawing_area_size, self.applet_orient, red, green, blue, self.is_running(), scale_factor) elif self.is_active: if self.active_bg == IconBgType.GRADIENT: dbgd = DefaultBackgroundDrawer(ctx, self.drawing_area_size, self.applet_orient, red, green, blue) else: dbgd = AlphaFillBackgroundDrawer(ctx, self.drawing_area_size, self.applet_orient, red, green, blue, 0.5) if dbgd is not None: dbgd.draw() # draw the app icon if self.active_bg in [IconBgType.UNITY_FLAT, IconBgType.UNITY]: pb_size = (self.drawing_area_size) * 0.75 offset = self.drawing_area_size / 2 - pb_size / 2 ctx.set_source_surface(self.app_surface, offset, offset) else: ctx.set_source_surface(self.app_surface, 3, 3) if self.is_pulsing: # draw the icon semi-transparently according to how far through the # animation we are half_way = int(CONST_PULSE_STEPS / 2) if self.pulse_step <= half_way: alpha = 1.0 - (self.pulse_step / half_way) else: alpha = 0.0 + (self.pulse_step - half_way) / half_way ctx.paint_with_alpha(alpha) elif self.needs_attention and self.attention_type == dock_prefs.AttentionType.BLINK: if self.attention_blink_on: ctx.paint() # draw normally if in the flash on state elif self.is_dragee: ctx.paint_with_alpha(0.0) else: ctx.paint() ctx.save() if self.active_bg == IconBgType.UNITY_FLAT: if self.is_running(): dbgd.draw_shine() elif self.active_bg == IconBgType.UNITY: dbgd.draw_shine() ctx.restore() if (self.has_mouse is True) and ((self.is_dragee is False) and (self.scroll_dir == ScrollType.SCROLL_NONE)): # lighten the icon ctx.set_operator(cairo.OPERATOR_ADD) ctx.paint_with_alpha(0.2) ctx.set_operator(cairo.OPERATOR_OVER) elif (self.has_mouse is True) and (self.scroll_dir != ScrollType.SCROLL_NONE): # this app indicates the dock can scroll, so we darken it ctx.set_operator(cairo.OPERATOR_DEST_OUT) ctx.paint_with_alpha(0.5) ctx.set_operator(cairo.OPERATOR_OVER) # draw the app running indicators if (self.is_running()) and \ (self.indicator != IndicatorType.NONE) and \ (self.is_dragee is False): # work out how many indicators to draw - either a single one or # one for each open window up to a maximum of 4, and take into # account the fact that we might only be showing indicators from # the current workspace # get the number of indicators to show... if self.multi_ind is False and self.indicator != IndicatorType.SUBWAY: num_ind = 1 else: num_ind = self.get_num_windows(self.ind_ws) if num_ind > 4: num_ind = 4 ind = None if self.indicator == IndicatorType.LIGHT: ind = DefaultLightInd(ctx, self.drawing_area_size, self.applet_orient, num_ind) elif self.indicator == IndicatorType.DARK: ind = DefaultDarkInd(ctx, self.drawing_area_size, self.applet_orient, num_ind) elif self.indicator == IndicatorType.TBAR: ind = ThemeBarInd(ctx, self.drawing_area_size, self.applet_orient, self.applet) elif self.indicator == IndicatorType.TCIRC: ind = ThemeCircleInd(ctx, self.drawing_area_size, self.applet_orient, self.applet, num_ind) elif self.indicator == IndicatorType.TSQUARE: ind = ThemeSquareInd(ctx, self.drawing_area_size, self.applet_orient, self.applet, num_ind) elif self.indicator == IndicatorType.TTRI: ind = ThemeTriInd(ctx, self.drawing_area_size, self.applet_orient, self.applet, num_ind) elif self.indicator == IndicatorType.TDIA: ind = ThemeDiaInd(ctx, self.drawing_area_size, self.applet_orient, self.applet, num_ind) elif self.indicator == IndicatorType.SUBWAY: ind = SubwayInd(ctx, self.drawing_area_size, self.applet_orient, self.applet, num_ind, offscreen_surface, self.is_active) if ind is not None: ind.draw() # do we need a count? if self.show_count: self.draw_count(ctx) if self.show_progress: self.draw_progress(ctx) if self.needs_attention and self.attention_type == dock_prefs.AttentionType.SHOW_BADGE: self.draw_attention_badge(ctx) if not build_gtk2: # scrolling only available in GTK3 if self.has_mouse and (self.scroll_dir != ScrollType.SCROLL_NONE): if self.scroll_dir == ScrollType.SCROLL_UP: self.draw_scroll_up(ctx) elif self.scroll_dir == ScrollType.SCROLL_DOWN: self.draw_scroll_down(ctx) # now draw to the screen if build_gtk2: screen_ctx = self.drawing_area.window.cairo_create() screen_ctx.rectangle(event.area.x, event.area.y, event.area.width, event.area.height) screen_ctx.clip() alloc = self.drawing_area.get_allocation() if (self.applet_orient == MatePanelApplet.AppletOrient.UP) or \ (self.applet_orient == MatePanelApplet.AppletOrient.DOWN): screen_ctx.set_source_surface(offscreen_surface, alloc.x, 0) else: screen_ctx.set_source_surface(offscreen_surface, 0, alloc.y) screen_ctx.paint() screen_ctx = None else: event.set_source_surface(offscreen_surface, 0, 0) event.paint() alloc = self.drawing_area.get_allocation() ctx = None def draw_count(self, ctx): """ Draw the app's counter value Args: ctx - the cairo context where the counter is to be drawn """ # drawing is done at a notional size 64x64 px, and then scaled # appropriately according to self.drawing_area_size draw_size = 64.0 # height of the counter = 2 pix border top and bottom + 16 pix # internal height height = 20 # work out the appropriate font size to use - has to fit within the # borders and provide some space above and below the count_val reqd_font_height = height - 8 # find a font size where the count can be shown with the required height ctx.select_font_face("", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) ctext = "%d" % self.count_val for fsize in range(24, 2, -1): ctx.set_font_size(fsize) extents = ctx.text_extents(ctext) if extents[3] < reqd_font_height: font_size = fsize break # work out an appropriate width for the counter inset = height / 2 radius = inset - 1 if int(extents[2] + extents[0]) > int(radius): width = extents[2] + extents[0] + radius else: width = height + inset ctx.save() # the background color of the count is the app's highlight colour # convert the highlight values to their cairo equivalents bred = self.highlight_color.r / 255 bgreen = self.highlight_color.g / 255 bblue = self.highlight_color.b / 255 # set an appropriate text and border color if bred + bgreen + bblue > 1.5: # mid-level grey tred = tgreen = tblue = 0.0 else: tred = tgreen = tblue = 1.0 # the count is placed in the upper right of the drawing area, and we need # to calculate it's position based on the notional DA_SIZE # adj = 2 left = draw_size - width + inset - adj # do the drawing - attribution for the drawing code: # https://bazaar.launchpad.net/~unity-team/unity/trunk/view/head:/launcher/LauncherIcon.cpp ctx.scale(self.drawing_area_size / draw_size, self.drawing_area_size / draw_size) ctx.move_to(left, height - 1 + adj) ctx.arc(left, inset + adj, radius, 0.5 * math.pi, 1.5 * math.pi) ctx.arc(draw_size - inset - adj, inset + adj, radius, 1.5 * math.pi, 0.5 * math.pi) ctx.line_to(left, height - 1 + adj) ctx.set_source_rgb(bred, bgreen, bblue) ctx.fill_preserve() ctx.set_source_rgb(tred, tgreen, tblue) ctx.set_line_width(2) ctx.stroke() # draw the text ctx.move_to(left - inset + width / 2 - (extents[0] + extents[2] / 2), (height / 2) + adj + extents[3] / 2) ctx.set_source_rgb(tred, tgreen, tblue) ctx.show_text(ctext) ctx.restore() def draw_progress(self, ctx): """ Draw a progress bar to show the app's progress value Args: ctx - the cairo context where the counter is to be drawn """ def rounded_rectangle(cr, x, y, w, h, r=20): """ Convenience function to draw a rounded rectangle # Attribution: # https://stackoverflow.com/questions/2384374/rounded-rectangle-in-pygtk # This is just one of the samples from # http://www.cairographics.org/cookbook/roundedrectangles/ # A****BQ # H C # * * # G D # F****E """ cr.move_to(x + r, y) # Move to A cr.line_to(x + w - r, y) # Straight line to B cr.curve_to(x + w, y, x + w, y, x + w, y + r) # Curve to C, Control points are both at Q cr.line_to(x + w, y + h - r) # Move to D cr.curve_to(x + w, y + h, x + w, y + h, x + w - r, y + h) # Curve to E cr.line_to(x + r, y + h) # Line to F cr.curve_to(x, y + h, x, y + h, x, y + h - r) # Curve to G cr.line_to(x, y + r) # Line to H cr.curve_to(x, y, x, y, x + r, y) # Curve to A # drawing is done on a to scale of 64x64 pixels and then scaled # down to the fit the app's drawing area draw_size = 64.0 # the foreground colour of the progress is the app's highlight colour # convert the highlight values to their cairo equivalents fred = self.highlight_color.r / 255 fgreen = self.highlight_color.g / 255 fblue = self.highlight_color.b / 255 # set an appropriate border color and also a background colour for # the progress bar, based on the highlight colour if fred + fgreen + fblue > 1.5: # mid-level grey brd_red = brd_green = brd_blue = 0.0 bk_red = bk_green = bk_blue = 1.0 else: brd_red = brd_green = brd_blue = 1.0 bk_red = bk_green = bk_blue = 0.0 height = 8 # total height of the progress bar line_width = 2 # border line width int_height = height - line_width * 2 # interior height left = 8.5 width = draw_size - left * 2 # width of the progress bar top = (draw_size / 8) * 5 + 0.5 ctx.save() ctx.scale(self.drawing_area_size / draw_size, self.drawing_area_size / draw_size) # fill the interior with the background colour ctx.set_line_width(1) ctx.set_source_rgb(bk_red, bk_green, bk_blue) rounded_rectangle(ctx, left, top, width, height, 7) ctx.stroke_preserve() ctx.fill() # fill part of the interior with a different colour, depending on # the progress value ctx.set_source_rgb(fred, fgreen, fblue) rounded_rectangle(ctx, left + line_width - 1, top, (width - (line_width - 1) * 2) * self.progress_val, height, 7) ctx.fill() # draw exterior of the progress bar ctx.set_source_rgb(brd_red, brd_green, brd_blue) ctx.set_line_width(2) rounded_rectangle(ctx, left, top, width, height, 7) ctx.stroke() ctx.restore() def draw_attention_badge(self, ctx): """ Draw a badge on the app icon to indicate the app requires attention Basically a copy and paste of draw_count... Args: ctx - the cairo context where the counter is to be drawn """ # drawing is done at a notional size 64x64 px, and then scaled # appropriately according to self.drawing_area_size draw_size = 64.0 # height of the exaclamation mark = 2 pix border top and bottom + 16 pix # internal height height = 20 # work out the appropriate font size to use - has to fit within the # borders and provide some space above and below the exclamation mark reqd_font_height = height - 8 # find a font size where the count can be shown with the required height ctx.select_font_face("", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) ctext = "!" for fsize in range(24, 2, -1): ctx.set_font_size(fsize) extents = ctx.text_extents(ctext) if extents[3] < reqd_font_height: font_size = fsize break # work out an appropriate width for the badge inset = height / 2 radius = inset - 1 if int(extents[2] + extents[0]) > int(radius): width = extents[2] + extents[0] + radius else: width = height # width = height + inset width = extents[2] + extents[0] + radius ctx.save() # the background color of the badge is the app's highlight colour # convert the highlight values to their cairo equivalents bred = self.highlight_color.r / 255 bgreen = self.highlight_color.g / 255 bblue = self.highlight_color.b / 255 # set an appropriate text and border color if bred + bgreen + bblue > 1.5: # mid-level grey tred = tgreen = tblue = 0.0 else: tred = tgreen = tblue = 1.0 # the badge is placed in the upper left of the drawing area adj = 2 left = inset # do the drawing - attribution for the drawing code: # https://bazaar.launchpad.net/~unity-team/unity/trunk/view/head:/launcher/LauncherIcon.cpp ctx.scale(self.drawing_area_size / draw_size, self.drawing_area_size / draw_size) ctx.move_to(left, height - 1 + adj) ctx.arc(left, inset + adj, radius, 0.5 * math.pi, 1.5 * math.pi) ctx.arc(left + width - inset - adj, inset + adj, radius, 1.5 * math.pi, 0.5 * math.pi) ctx.line_to(left, height - 1 + adj) ctx.set_source_rgb(bred, bgreen, bblue) ctx.fill_preserve() ctx.set_source_rgb(tred, tgreen, tblue) ctx.set_line_width(2) ctx.stroke() # draw the text ctx.move_to(left + inset - (extents[0] + extents[2] / 2) - radius, (height / 2) + adj + extents[3] / 2) ctx.set_source_rgb(tred, tgreen, tblue) ctx.show_text(ctext) ctx.restore() def draw_scroll_up(self, ctx): """ To indicate that the docked app can scroll up (or left on horizontal panels) draw an up (or left) arrow on the icon Params : context : the docked app's cairo context for us to draw on size : the size of the context, in pixels orient : the orientation of the dock applet """ if self.drawing_area_size > 48: icon_size = Gtk.IconSize.DND icon_pix = 24 else: icon_size = Gtk.IconSize.LARGE_TOOLBAR icon_pix = 16 if self.applet_orient in [MatePanelApplet.AppletOrient.UP, MatePanelApplet.AppletOrient.DOWN]: arrow_pb = self.drawing_area.render_icon(Gtk.STOCK_GO_BACK, icon_size, None) arrow_pb = arrow_pb.scale_simple(self.drawing_area_size / 2, self.drawing_area_size / 2, GdkPixbuf.InterpType.BILINEAR) Gdk.cairo_set_source_pixbuf(ctx, arrow_pb, 0, self.drawing_area_size / 4) else: arrow_pb = self.drawing_area.render_icon(Gtk.STOCK_GO_UP, icon_size, None) arrow_pb = arrow_pb.scale_simple(self.drawing_area_size / 2, self.drawing_area_size / 2, GdkPixbuf.InterpType.BILINEAR) Gdk.cairo_set_source_pixbuf(ctx, arrow_pb, self.drawing_area_size / 4, 0) ctx.paint() def draw_scroll_down(self, ctx): """ To indicate that the docked app can scroll up (or left on horizontal panels) draw an up (or left) arrow on the icon Params : context : the docked app's cairo context for us to draw on size : the size of the context, in pixels orient : the orientation of the dock applet """ if self.drawing_area_size > 48: icon_size = Gtk.IconSize.DND icon_pix = 24 else: icon_size = Gtk.IconSize.LARGE_TOOLBAR icon_pix = 16 if self.applet_orient in [MatePanelApplet.AppletOrient.UP, MatePanelApplet.AppletOrient.DOWN]: arrow_pb = self.drawing_area.render_icon(Gtk.STOCK_GO_FORWARD, icon_size, None) arrow_pb = arrow_pb.scale_simple(self.drawing_area_size / 2, self.drawing_area_size / 2, GdkPixbuf.InterpType.BILINEAR) Gdk.cairo_set_source_pixbuf(ctx, arrow_pb, self.drawing_area_size / 2, self.drawing_area_size / 4) else: arrow_pb = self.drawing_area.render_icon(Gtk.STOCK_GO_DOWN, icon_size, None) arrow_pb = arrow_pb.scale_simple(self.drawing_area_size / 2, self.drawing_area_size / 2, GdkPixbuf.InterpType.BILINEAR) Gdk.cairo_set_source_pixbuf(ctx, arrow_pb, self.drawing_area_size / 4, self.drawing_area_size / 2) ctx.paint() def set_pixbuf(self, pixbuf): """Set the app pixbuf and calculate its average colour. """ self.app_pb = pixbuf rht, ght, bht = self.highlight_color = get_backlight_color(pixbuf) self.highlight_color = ColorTup(r=rht, g=ght, b=bht) def set_surface(self, surface): """Set the app surface """ self.app_surface = surface def start_app(self): """Start the app or open a new window if it's already running Use Gio.DesktopAppinfo as it supports startup notfication """ # start the app try: run_it = self.desktop_ai.get_string("Exec") except: run_it = None if run_it is not None: # hack for Linux Mint: # Mint has several shortcuts for starting caja so that it can # be started in a specific directory e.g. home, /, etc # However, the main caja.desktop is responsible for starting the # user's desktop and this is the .desktop file the applet finds # first. # When the caja icon on the applet is clicked, caja is run as a # desktop window and no new file browser appears. # To get around this, we can simply check the command that is going # to be run and change it so that a caja window opens in the user's # home directory, which is the behaviour they'll probably be # expecting.... if run_it == "/usr/bin/startcaja": run_it = "caja" self.run_cmd_line(run_it) return if self.desktop_file is not None: gdai = Gio.DesktopAppInfo.new_from_filename(self.desktop_file) else: gdai = None disp = Gdk.Display.get_default() if build_gtk2: alc = Gdk.AppLaunchContext() else: alc = disp.get_app_launch_context() alc.set_desktop(-1) # use default screen & desktop alc.set_timestamp(Gtk.get_current_event_time()) alc.connect("launch-failed", self.launch_failed) # indicate we want startup notification if gdai is not None: self.startup_id = alc.get_startup_notify_id(gdai, []) gdai.launch_uris_as_manager([], alc, GLib.SpawnFlags.SEARCH_PATH, None, None, None, None) # make the app's icon pulse if not self.is_running(): throbber = PulseTimer(self) else: # if the app is already running, we want the icon to pulse at most once only # For apps which don't open a new window, the pulse timer will end up cancelling # the unneeded startup notification throbber = PulseTimer(self, True) def cancel_startup_notification(self): """ Cancel any startup notification """ if build_gtk2: Gdk.notify_startup_complete_with_id(self.startup_id) else: display = Gdk.Display.get_default() display.notify_startup_complete(self.startup_id) self.startup_id = None def launch_failed(self, app_launch_context, startup_id): """Handler for app launch failure events Cancel the startup notification Args: app_launch_context : the Gdk.AppLaunchContext that failed startup_id : the startup notification id """ self.cancel_startup_notification() display = Gdk.Display.get_default() display.notify_startup_complete(startup_id) def run_cmd_line(self, cmd_line): """Run a command line. Args: cmd_line - the command to run """ # TODO: this is old code and needs to be removed # the command line may contain escape sequences, so unescape them.... cmd_line = bytearray(cmd_line, "UTF-8") cmd_line = cmd_line.decode("unicode-escape") # if an environment variable is specified, extract its name an value # Note: the .desktop file specification at # https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s06.html # does not mention this. Both Ubuntu # https://help.ubuntu.com/community/EnvironmentVariables#Launching_desktop_application_with_an_environment_variable # and Arch linux # https://wiki.archlinux.org/index.php/Desktop_entries#Modify_environment_variables # seem to indicate that only a single variable can be set and that # there are no spaces between the variable name, the '=' character and # variable's value ..... # so, if cmd_line begins with "env" it specifies an environment variable # to set, follwed by the app e.g. env LANG=he_IL.UTF-8 /usr/bin/pluma # if cmd_line.startswith("env"): cmd_parts = cmd_line.split(" ", 2) var_parts = cmd_parts[1].split("=") var_name = var_parts[0] var_value = var_parts[1] # now we need to get the app path and args and carry on... cmd_line = cmd_parts[2] else: var_name = None var_value = None # if any of the directories in cmd_line contain a " ", they need to be # escaped head, tail = os.path.split(cmd_line) if " " in head: head = head.replace(" ", "\\ ") cmd_line = head + "/" + tail app_info = Gio.AppInfo.create_from_commandline(cmd_line, None, Gio.AppInfoCreateFlags.SUPPORTS_STARTUP_NOTIFICATION) alc = Gdk.AppLaunchContext() alc.set_desktop(-1) # use default screen & desktop alc.set_timestamp(Gtk.get_current_event_time()) # if the .desktop specfied an environment variable, set it if (var_name is not None) and (var_value is not None): alc.setenv(var_name, var_value) file_list = GLib.List() # app_info.launch(None, alc) self.startup_id = alc.get_startup_notify_id(app_info, []) throbber = PulseTimer(self) def run_rc_action(self, act_no): """ run the right click action specified by act_no Args: act_no - integer, the action number to run """ if len(self.rc_actions) >= act_no: if build_gtk2: alc = Gdk.AppLaunchContext() else: disp = Gdk.Display.get_default() alc = disp.get_app_launch_context() alc.set_desktop(-1) # use default screen & desktop alc.set_timestamp(Gtk.get_current_event_time()) alc.connect("launch-failed", self.launch_failed) # indicate we want startup notification self.startup_id = alc.get_startup_notify_id(self.desktop_ai, []) self.desktop_ai.launch_action(self.rc_actions[act_no - 1], alc) self.start_pulsing() def get_rc_action(self, act_no): """ return a specified right click action's details Args: act_no - integer, the specified action number Returns: bool - True if the action exists, False otherwise string - the name of the action (i.e. the text to appear in the right click menu) """ if len(self.rc_actions) >= act_no: return True, self.desktop_ai.get_action_name(self.rc_actions[act_no - 1]) else: return False, "" def start_pulsing(self): """ start the dock icon pulsing """ throbber = PulseTimer(self) def pulse_once(self): """ Make the dock icon pulse once""" throbber = PulseTimer(self, True) def set_dragee(self, is_dragee): """ Set the flag which indicates whether or not this app is being dragged to a new position on the dock Set the value of the self.is_dragee flag and redraw the app icon """ self.is_dragee = is_dragee self.queue_draw() def set_progress_visible(self, is_visible): """ Update the progress visibility and cause the app's icon to be redrawn Args: is_visible : whether the progress is to be displayed """ if is_visible != self.show_progress: self.show_progress = bool(is_visible) self.queue_draw() def set_progress_value(self, val): """ Update the progress value and cause the app's icon to be redrawn Args: val : the counter value """ # if the new progressvalue is the same as the old, then there's no need # to do anything... if val != self.progress_val: self.progress_val = val self.queue_draw() def set_counter_visible(self, is_visible): """ Update the counter visibility and cause the app's icon to be redrawn Args: is_visible : whether the counter is to be displayed """ # if the new value is the same as the old, then there's no need # to do anything... if is_visible != self.show_count: self.show_count = bool(is_visible) self.queue_draw() def set_counter_value(self, val): """ Update the counter value and cause the app's icon to be redrawn Args: val : the counter value """ # if the new counter value is the same as the old, then there's no need # to do anything... if val != self.count_val: self.count_val = val self.queue_draw() def set_scroll_dir(self, scroll_dir): """ Sets the app's scroll direction Param: scroll_dir - a docked_app_helpers.ScrollType """ self.scroll_dir = scroll_dir def main(): """Main function. Debugging code can go here """ pass if __name__ == "__main__": main()