/* * This file is part of the Dash-To-Panel extension for Gnome 3 * * 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, see . * * * Credits: * This file is based on code from the Dash to Dock extension by micheleg * and code from the Taskbar extension by Zorin OS * Some code was also adapted from the upstream Gnome Shell source code. */ import Clutter from 'gi://Clutter'; import GdkPixbuf from 'gi://GdkPixbuf'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import Graphene from 'gi://Graphene'; import Meta from 'gi://Meta'; import Shell from 'gi://Shell'; import St from 'gi://St'; import * as Util from 'resource:///org/gnome/shell/misc/util.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js'; const SCROLL_TIME = Util.SCROLL_TIME / (Util.SCROLL_TIME > 1 ? 1000 : 1); // simplify global signals and function injections handling // abstract class export const BasicHandler = class { constructor() { this._storage = new Object(); } add(/*unlimited 3-long array arguments*/){ // convert arguments object to array, concatenate with generic let args = [].concat('generic', [].slice.call(arguments)); // call addWithLabel with ags as if they were passed arguments this.addWithLabel.apply(this, args); } destroy() { for( let label in this._storage ) this.removeWithLabel(label); } addWithLabel( label /* plus unlimited 3-long array arguments*/) { if(this._storage[label] == undefined) this._storage[label] = new Array(); // skip first element of the arguments for( let i = 1; i < arguments.length; i++ ) { let item = this._storage[label]; let handlers = this._create(arguments[i]); for (let j = 0, l = handlers.length; j < l; ++j) { item.push(handlers[j]); } } } removeWithLabel(label){ if(this._storage[label]) { for( let i = 0; i < this._storage[label].length; i++ ) { this._remove(this._storage[label][i]); } delete this._storage[label]; } } /* Virtual methods to be implemented by subclass */ // create single element to be stored in the storage structure _create(item){ throw new Error('no implementation of _create in ' + this); } // correctly delete single element _remove(item){ throw new Error('no implementation of _remove in ' + this); } } // Manage global signals export const GlobalSignalsHandler = class extends BasicHandler { _create(item) { let handlers = []; item[1] = [].concat(item[1]); for (let i = 0, l = item[1].length; i < l; ++i) { let object = item[0]; let event = item[1][i]; let callback = item[2] try { let id = object.connect(event, callback); handlers.push([object, id]); } catch (e) { } } return handlers; } _remove(item){ item[0].disconnect(item[1]); } }; /** * Manage function injection: both instances and prototype can be overridden * and restored */ export const InjectionsHandler = class extends BasicHandler { _create(item) { let object = item[0]; let name = item[1]; let injectedFunction = item[2]; let original = object[name]; object[name] = injectedFunction; return [[object, name, injectedFunction, original]]; } _remove(item) { let object = item[0]; let name = item[1]; let original = item[3]; object[name] = original; } }; /** * Manage timeouts: the added timeouts have their id reset on completion */ export const TimeoutsHandler = class extends BasicHandler { _create(item) { let name = item[0]; let delay = item[1]; let timeoutHandler = item[2]; this._remove(item); this[name] = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, () => { this[name] = 0; timeoutHandler(); return GLib.SOURCE_REMOVE; }); return [[name]]; } remove(name) { this._remove([name]) } _remove(item) { let name = item[0]; if (this[name]) { GLib.Source.remove(this[name]); this[name] = 0; } } getId(name) { return this[name] ? this[name] : 0; } }; // This is wrapper to maintain compatibility with GNOME-Shell 3.30+ as well as // previous versions. export const DisplayWrapper = { getScreen() { return global.screen || global.display; }, getWorkspaceManager() { return global.screen || global.workspace_manager; }, getMonitorManager() { return global.screen || global.backend.get_monitor_manager(); } }; let unredirectEnabled = true export const setDisplayUnredirect = (enable) => { if (enable && !unredirectEnabled) Meta.enable_unredirect_for_display(global.display); else if (!enable && unredirectEnabled) Meta.disable_unredirect_for_display(global.display); unredirectEnabled = enable; }; export const getSystemMenuInfo = function() { return { name: 'quickSettings', constructor: Main.panel.statusArea.quickSettings.constructor }; } export const getCurrentWorkspace = function() { return DisplayWrapper.getWorkspaceManager().get_active_workspace(); }; export const getWorkspaceByIndex = function(index) { return DisplayWrapper.getWorkspaceManager().get_workspace_by_index(index); }; export const getWorkspaceCount = function() { return DisplayWrapper.getWorkspaceManager().n_workspaces; }; export const getStageTheme = function() { return St.ThemeContext.get_for_stage(global.stage); }; export const getScaleFactor = function() { return getStageTheme().scale_factor || 1; }; export const findIndex = function(array, predicate) { if (array) { if (Array.prototype.findIndex) { return array.findIndex(predicate); } for (let i = 0, l = array.length; i < l; ++i) { if (predicate(array[i])) { return i; } } } return -1; }; export const find = function(array, predicate) { let index = findIndex(array, predicate); if (index > -1) { return array[index]; } }; export const mergeObjects = function(main, bck) { for (const prop in bck) { if (!main.hasOwnProperty(prop) && bck.hasOwnProperty(prop)) { main[prop] = bck[prop]; } } return main; }; export const getTrackedActorData = (actor) => { let trackedIndex = Main.layoutManager._findActor(actor); if (trackedIndex >= 0) return Main.layoutManager._trackedActors[trackedIndex] } export const getTransformedAllocation = function(actor) { let extents = actor.get_transformed_extents(); let topLeft = extents.get_top_left(); let bottomRight = extents.get_bottom_right(); return { x1: topLeft.x, x2: bottomRight.x, y1: topLeft.y, y2: bottomRight.y }; }; export const setClip = function(actor, x, y, width, height) { actor.set_clip(0, 0, width, height); actor.set_position(x, y); actor.set_size(width, height); }; export const addKeybinding = function(key, settings, handler, modes) { if (!Main.wm._allowedKeybindings[key]) { Main.wm.addKeybinding( key, settings, Meta.KeyBindingFlags.NONE, modes || (Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW), handler ); } }; export const removeKeybinding = function(key) { if (Main.wm._allowedKeybindings[key]) { Main.wm.removeKeybinding(key); } }; export const getrgbColor = function(color) { color = typeof color === 'string' ? Clutter.color_from_string(color)[1] : color; return { red: color.red, green: color.green, blue: color.blue }; }; export const getrgbaColor = function(color, alpha, offset) { if (alpha <= 0) { return 'transparent; '; } let rgb = getrgbColor(color); if (offset) { ['red', 'green', 'blue'].forEach(k => { rgb[k] = Math.min(255, Math.max(0, rgb[k] + offset)); if (rgb[k] == color[k]) { rgb[k] = Math.min(255, Math.max(0, rgb[k] - offset)); } }); } return 'rgba(' + rgb.red + ',' + rgb.green + ',' + rgb.blue + ',' + (Math.floor(alpha * 100) * 0.01) + '); ' ; }; export const checkIfColorIsBright = function(color) { let rgb = getrgbColor(color); let brightness = 0.2126 * rgb.red + 0.7152 * rgb.green + 0.0722 * rgb.blue; return brightness > 128; }; export const getMouseScrollDirection = function(event) { let direction; switch (event.get_scroll_direction()) { case Clutter.ScrollDirection.UP: case Clutter.ScrollDirection.LEFT: direction = 'up'; break; case Clutter.ScrollDirection.DOWN: case Clutter.ScrollDirection.RIGHT: direction = 'down'; break; } return direction; }; export const checkIfWindowHasTransient = function(window) { let hasTransient; window.foreach_transient(t => !(hasTransient = true)); return hasTransient; }; export const activateSiblingWindow = function(windows, direction, startWindow) { let windowIndex = windows.indexOf(global.display.focus_window); let nextWindowIndex = windowIndex < 0 ? startWindow ? windows.indexOf(startWindow) : 0 : windowIndex + (direction == 'up' ? -1 : 1); if (nextWindowIndex == windows.length) { nextWindowIndex = 0; } else if (nextWindowIndex < 0) { nextWindowIndex = windows.length - 1; } if (windowIndex != nextWindowIndex) { Main.activateWindow(windows[nextWindowIndex]); } }; export const animateWindowOpacity = function(window, tweenOpts) { //there currently is a mutter bug with the windowactor opacity, starting with 3.34 //https://gitlab.gnome.org/GNOME/mutter/issues/836 //since 3.36, a workaround is to use the windowactor's child for the fade animation //this leaves a "shadow" on the desktop, so the windowactor needs to be hidden //when the animation is complete let visible = tweenOpts.opacity > 0; let windowActor = window; let initialOpacity = window.opacity; window = windowActor.get_first_child() || windowActor; if (!windowActor.visible && visible) { window.opacity = 0; windowActor.visible = visible; tweenOpts.opacity = Math.min(initialOpacity, tweenOpts.opacity); } if (!visible) { tweenOpts.onComplete = () => { windowActor.visible = visible; window.opacity = initialOpacity; }; } animate(window, tweenOpts); }; export const animate = function(actor, options) { //the original animations used Tweener instead of Clutter animations, so we //use "time" and "delay" properties defined in seconds, as opposed to Clutter //animations "duration" and "delay" which are defined in milliseconds if (options.delay) { options.delay = options.delay * 1000; } options.duration = options.time * 1000; delete options.time; if (options.transition) { //map Tweener easing equations to Clutter animation modes options.mode = { 'easeInCubic': Clutter.AnimationMode.EASE_IN_CUBIC, 'easeInOutCubic': Clutter.AnimationMode.EASE_IN_OUT_CUBIC, 'easeInOutQuad': Clutter.AnimationMode.EASE_IN_OUT_QUAD, 'easeOutQuad': Clutter.AnimationMode.EASE_OUT_QUAD }[options.transition] || Clutter.AnimationMode.LINEAR; delete options.transition; } let params = [options]; if ('value' in options && actor instanceof St.Adjustment) { params.unshift(options.value); delete options.value; } actor.ease.apply(actor, params); } export const isAnimating = function(actor, prop) { return !!actor.get_transition(prop); } export const stopAnimations = function(actor) { actor.remove_all_transitions(); } export const getIndicators = function(delegate) { if (delegate instanceof St.BoxLayout) { return delegate; } return delegate.indicators; } export const getPoint = function(coords) { return new Graphene.Point(coords); } export const notify = function(text, iconName, action, isTransient) { let source = new MessageTray.SystemNotificationSource(); let notification = new MessageTray.Notification(source, 'Dash to Panel', text); let notifyFunc = source.showNotification || source.notify; if (iconName) { source.createIcon = function() { return new St.Icon({ icon_name: iconName }); }; } if (action) { if (!(action instanceof Array)) { action = [action]; } action.forEach(a => notification.addAction(a.text, a.func)); } Main.messageTray.add(source); notification.setTransient(isTransient); notifyFunc.call(source, notification); }; /* * This is a copy of the same function in utils.js, but also adjust horizontal scrolling * and perform few further cheks on the current value to avoid changing the values when * it would be clamp to the current one in any case. * Return the amount of shift applied */ export const ensureActorVisibleInScrollView = function(scrollView, actor, fadeSize, onComplete) { let vadjustment = scrollView.vscroll.adjustment; let hadjustment = scrollView.hscroll.adjustment; let [vvalue, vlower, vupper, vstepIncrement, vpageIncrement, vpageSize] = vadjustment.get_values(); let [hvalue, hlower, hupper, hstepIncrement, hpageIncrement, hpageSize] = hadjustment.get_values(); let [hvalue0, vvalue0] = [hvalue, vvalue]; let voffset = fadeSize; let hoffset = fadeSize; let box = actor.get_allocation_box(); let y1 = box.y1, y2 = box.y2, x1 = box.x1, x2 = box.x2; let parent = actor.get_parent(); while (parent != scrollView) { if (!parent) throw new Error("actor not in scroll view"); let box = parent.get_allocation_box(); y1 += box.y1; y2 += box.y1; x1 += box.x1; x2 += box.x1; parent = parent.get_parent(); } if (y1 < vvalue + voffset) vvalue = Math.max(0, y1 - voffset); else if (vvalue < vupper - vpageSize && y2 > vvalue + vpageSize - voffset) vvalue = Math.min(vupper -vpageSize, y2 + voffset - vpageSize); if (x1 < hvalue + hoffset) hvalue = Math.max(0, x1 - hoffset); else if (hvalue < hupper - hpageSize && x2 > hvalue + hpageSize - hoffset) hvalue = Math.min(hupper - hpageSize, x2 + hoffset - hpageSize); let tweenOpts = { time: SCROLL_TIME, onComplete: onComplete || (() => {}), transition: 'easeOutQuad' }; if (vvalue !== vvalue0) { animate(vadjustment, mergeObjects(tweenOpts, { value: vvalue })); } if (hvalue !== hvalue0) { animate(hadjustment, mergeObjects(tweenOpts, { value: hvalue })); } return [hvalue- hvalue0, vvalue - vvalue0]; } /** * ColorUtils is adapted from https://github.com/micheleg/dash-to-dock */ export const ColorUtils = { colorLuminance(r, g, b, dlum) { // Darken or brighten color by a fraction dlum // Each rgb value is modified by the same fraction. // Return "#rrggbb" strin let rgbString = '#'; rgbString += ColorUtils._decimalToHex(Math.round(Math.min(Math.max(r*(1+dlum), 0), 255)), 2); rgbString += ColorUtils._decimalToHex(Math.round(Math.min(Math.max(g*(1+dlum), 0), 255)), 2); rgbString += ColorUtils._decimalToHex(Math.round(Math.min(Math.max(b*(1+dlum), 0), 255)), 2); return rgbString; }, _decimalToHex(d, padding) { // Convert decimal to an hexadecimal string adding the desired padding let hex = d.toString(16); while (hex.length < padding) hex = '0'+ hex; return hex; }, HSVtoRGB(h, s, v) { // Convert hsv ([0-1, 0-1, 0-1]) to rgb ([0-255, 0-255, 0-255]). // Following algorithm in https://en.wikipedia.org/wiki/HSL_and_HSV // here with h = [0,1] instead of [0, 360] // Accept either (h,s,v) independently or {h:h, s:s, v:v} object. // Return {r:r, g:g, b:b} object. if (arguments.length === 1) { s = h.s; v = h.v; h = h.h; } let r,g,b; let c = v*s; let h1 = h*6; let x = c*(1 - Math.abs(h1 % 2 - 1)); let m = v - c; if (h1 <=1) r = c + m, g = x + m, b = m; else if (h1 <=2) r = x + m, g = c + m, b = m; else if (h1 <=3) r = m, g = c + m, b = x + m; else if (h1 <=4) r = m, g = x + m, b = c + m; else if (h1 <=5) r = x + m, g = m, b = c + m; else r = c + m, g = m, b = x + m; return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }; }, RGBtoHSV(r, g, b) { // Convert rgb ([0-255, 0-255, 0-255]) to hsv ([0-1, 0-1, 0-1]). // Following algorithm in https://en.wikipedia.org/wiki/HSL_and_HSV // here with h = [0,1] instead of [0, 360] // Accept either (r,g,b) independently or {r:r, g:g, b:b} object. // Return {h:h, s:s, v:v} object. if (arguments.length === 1) { r = r.r; g = r.g; b = r.b; } let h,s,v; let M = Math.max(r, g, b); let m = Math.min(r, g, b); let c = M - m; if (c == 0) h = 0; else if (M == r) h = ((g-b)/c) % 6; else if (M == g) h = (b-r)/c + 2; else h = (r-g)/c + 4; h = h/6; v = M/255; if (M !== 0) s = c/M; else s = 0; return {h: h, s: s, v: v}; } }; /** * DominantColorExtractor is adapted from https://github.com/micheleg/dash-to-dock */ let themeLoader = null; let iconCacheMap = new Map(); const MAX_CACHED_ITEMS = 1000; const BATCH_SIZE_TO_DELETE = 50; const DOMINANT_COLOR_ICON_SIZE = 64; export const DominantColorExtractor = class { constructor(app){ this._app = app; } /** * Try to get the pixel buffer for the current icon, if not fail gracefully */ _getIconPixBuf() { let iconTexture = this._app.create_icon_texture(16); if (themeLoader === null) { themeLoader = new St.IconTheme(); } // Unable to load the icon texture, use fallback if (iconTexture instanceof St.Icon === false) { return null; } iconTexture = iconTexture.get_gicon(); // Unable to load the icon texture, use fallback if (iconTexture === null) { return null; } if (iconTexture instanceof Gio.FileIcon) { // Use GdkPixBuf to load the pixel buffer from the provided file path return GdkPixbuf.Pixbuf.new_from_file(iconTexture.get_file().get_path()); } // Get the pixel buffer from the icon theme if (iconTexture instanceof Gio.ThemedIcon) { let icon_info = themeLoader.lookup_icon(iconTexture.get_names()[0], DOMINANT_COLOR_ICON_SIZE, 0); if (icon_info !== null) { return icon_info.load_icon(); } } return null; } /** * The backlight color choosing algorithm was mostly ported to javascript from the * Unity7 C++ source of Canonicals: * https://bazaar.launchpad.net/~unity-team/unity/trunk/view/head:/launcher/LauncherIcon.cpp * so it more or less works the same way. */ _getColorPalette() { if (iconCacheMap.get(this._app.get_id())) { // We already know the answer return iconCacheMap.get(this._app.get_id()); } let pixBuf = this._getIconPixBuf(); if (pixBuf == null) return null; let pixels = pixBuf.get_pixels(), offset = 0; let total = 0, rTotal = 0, gTotal = 0, bTotal = 0; let resample_y = 1, resample_x = 1; // Resampling of large icons // We resample icons larger than twice the desired size, as the resampling // to a size s // DOMINANT_COLOR_ICON_SIZE < s < 2*DOMINANT_COLOR_ICON_SIZE, // most of the case exactly DOMINANT_COLOR_ICON_SIZE as the icon size is tipycally // a multiple of it. let width = pixBuf.get_width(); let height = pixBuf.get_height(); // Resample if (height >= 2* DOMINANT_COLOR_ICON_SIZE) resample_y = Math.floor(height/DOMINANT_COLOR_ICON_SIZE); if (width >= 2* DOMINANT_COLOR_ICON_SIZE) resample_x = Math.floor(width/DOMINANT_COLOR_ICON_SIZE); if (resample_x !==1 || resample_y !== 1) pixels = this._resamplePixels(pixels, resample_x, resample_y); // computing the limit outside the for (where it would be repeated at each iteration) // for performance reasons let limit = pixels.length; for (let offset = 0; offset < limit; offset+=4) { let r = pixels[offset], g = pixels[offset + 1], b = pixels[offset + 2], a = pixels[offset + 3]; let saturation = (Math.max(r,g, b) - Math.min(r,g, b)); let relevance = 0.1 * 255 * 255 + 0.9 * a * saturation; rTotal += r * relevance; gTotal += g * relevance; bTotal += b * relevance; total += relevance; } total = total * 255; let r = rTotal / total, g = gTotal / total, b = bTotal / total; let hsv = ColorUtils.RGBtoHSV(r * 255, g * 255, b * 255); if (hsv.s > 0.15) hsv.s = 0.65; hsv.v = 0.90; let rgb = ColorUtils.HSVtoRGB(hsv.h, hsv.s, hsv.v); // Cache the result. let backgroundColor = { lighter: ColorUtils.colorLuminance(rgb.r, rgb.g, rgb.b, 0.2), original: ColorUtils.colorLuminance(rgb.r, rgb.g, rgb.b, 0), darker: ColorUtils.colorLuminance(rgb.r, rgb.g, rgb.b, -0.5) }; if (iconCacheMap.size >= MAX_CACHED_ITEMS) { //delete oldest cached values (which are in order of insertions) let ctr=0; for (let key of iconCacheMap.keys()) { if (++ctr > BATCH_SIZE_TO_DELETE) break; iconCacheMap.delete(key); } } iconCacheMap.set(this._app.get_id(), backgroundColor); return backgroundColor; } /** * Downsample large icons before scanning for the backlight color to * improve performance. * * @param pixBuf * @param pixels * @param resampleX * @param resampleY * * @return []; */ _resamplePixels(pixels, resampleX, resampleY) { let resampledPixels = []; // computing the limit outside the for (where it would be repeated at each iteration) // for performance reasons let limit = pixels.length / (resampleX * resampleY) / 4; for (let i = 0; i < limit; i++) { let pixel = i * resampleX * resampleY; resampledPixels.push(pixels[pixel * 4]); resampledPixels.push(pixels[pixel * 4 + 1]); resampledPixels.push(pixels[pixel * 4 + 2]); resampledPixels.push(pixels[pixel * 4 + 3]); } return resampledPixels; } }; export const drawRoundedLine = function(cr, x, y, width, height, isRoundLeft, isRoundRight, stroke, fill) { if (height > width) { y += Math.floor((height - width) / 2.0); height = width; } height = 2.0 * Math.floor(height / 2.0); const leftRadius = isRoundLeft ? height / 2.0 : 0.0; const rightRadius = isRoundRight ? height / 2.0 : 0.0; cr.moveTo(x + width - rightRadius, y); cr.lineTo(x + leftRadius, y); if (isRoundLeft) cr.arcNegative(x + leftRadius, y + leftRadius, leftRadius, -Math.PI/2, Math.PI/2); else cr.lineTo(x, y + height); cr.lineTo(x + width - rightRadius, y + height); if (isRoundRight) cr.arcNegative(x + width - rightRadius, y + rightRadius, rightRadius, Math.PI/2, -Math.PI/2); else cr.lineTo(x + width, y); cr.closePath(); if (fill != null) { cr.setSource(fill); cr.fillPreserve(); } if (stroke != null) cr.setSource(stroke); cr.stroke(); }