import GdkPixbuf from "gi://GdkPixbuf"; import Gio from "gi://Gio"; import Gtk from "gi://Gtk"; import St from "gi://St"; // We need an icons theme object, this is the only way I managed to get // pixel buffers that can be used for calculating the backlight color let themeLoader = null; // Global icon cache. Used for Unity7 styling. let iconCacheMap = new Map(); // Max number of items to store // We don't expect to ever reach this number, but let's put an hard limit to avoid // even the remote possibility of the cached items to grow indefinitely. const MAX_CACHED_ITEMS = 1000; // When the size exceed it, the oldest 'n' ones are deleted const BATCH_SIZE_TO_DELETE = 50; // The icon size used to extract the dominant color const DOMINANT_COLOR_ICON_SIZE = 64; // Compute dominant color from the app icon. // The color is cached for efficiency. export class DominantColorExtractor { 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); const 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) 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()); } else if (iconTexture instanceof Gio.ThemedIcon) { // Get the first pixel buffer available in the icon theme const iconNames = iconTexture.get_names(); const iconInfo = themeLoader.choose_icon(iconNames, DOMINANT_COLOR_ICON_SIZE, 0); if (iconInfo) return iconInfo.load_icon(); else return null; } // Use GdkPixBuf to load the pixel buffer from memory // iconTexture.load is available unless iconTexture is not an instance of Gio.LoadableIcon // this means that iconTexture is an instance of Gio.EmblemedIcon, // which may be converted to a normal icon via iconTexture.get_icon? const [iconBuffer] = iconTexture.load(DOMINANT_COLOR_ICON_SIZE, null); return GdkPixbuf.Pixbuf.new_from_stream(iconBuffer, 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()); } const pixBuf = this._getIconPixBuf(); if (!pixBuf) return null; let pixels = pixBuf.get_pixels(); let total = 0, rTotal = 0, gTotal = 0, bTotal = 0; let resampleX = 1; let resampleY = 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 // typically a multiple of it. const width = pixBuf.get_width(); const height = pixBuf.get_height(); // Resample if (height >= 2 * DOMINANT_COLOR_ICON_SIZE) resampleY = Math.floor(height / DOMINANT_COLOR_ICON_SIZE); if (width >= 2 * DOMINANT_COLOR_ICON_SIZE) resampleX = Math.floor(width / DOMINANT_COLOR_ICON_SIZE); if (resampleX !== 1 || resampleY !== 1) pixels = this._resamplePixels(pixels, resampleX, resampleY); // computing the limit outside the for (where it would be repeated at each iteration) // for performance reasons const limit = pixels.length; for (let offset = 0; offset < limit; offset += 4) { const r = pixels[offset], g = pixels[offset + 1], b = pixels[offset + 2], a = pixels[offset + 3]; const saturation = Math.max(r, g, b) - Math.min(r, g, b); const relevance = 0.1 * 255 * 255 + 0.9 * a * saturation; rTotal += r * relevance; gTotal += g * relevance; bTotal += b * relevance; total += relevance; } total *= 255; const r = rTotal / total, g = gTotal / total, b = bTotal / total; const hsv = ColorUtils.RGBtoHSV(r * 255, g * 255, b * 255); if (hsv.s > 0.15) hsv.s = 0.65; hsv.v = 0.90; const rgb = ColorUtils.HSVtoRGB(hsv.h, hsv.s, hsv.v); // Cache the result. const 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 (const key of iconCacheMap.keys()) { if (++ctr > BATCH_SIZE_TO_DELETE) break; iconCacheMap.delete(key); } } iconCacheMap.set(this._app.get_id(), backgroundColor); return backgroundColor; } /** * Downscale large icons before scanning for the backlight color to * improve performance. * * @param pixBuf * @param pixels * @param resampleX * @param resampleY * * @returns []; */ _resamplePixels(pixels, resampleX, resampleY) { const resampledPixels = []; // computing the limit outside the for (where it would be repeated at each iteration) // for performance reasons const limit = pixels.length / (resampleX * resampleY) / 4; for (let i = 0; i < limit; i++) { const 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; } } /** * Color manipulation utilities */ export class ColorUtils { // Darken or brigthen color by a fraction dlum // Each rgb value is modified by the same fraction. // Return "#rrggbb" string static ColorLuminance(r, g, b, dlum) { 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; } // Convert decimal to an hexadecimal string adding the desired padding static _decimalToHex(d, padding) { let hex = d.toString(16); while (hex.length < padding) hex = '0'+ hex; return hex; } static _hexToRgb(h) { return { r: parseInt(h.substr(1, 2), 16), g: parseInt(h.substr(3, 2), 16), b: parseInt(h.substr(5, 2), 16) } } // 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. static HSVtoRGB(h, s, v) { 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) }; } // 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. static RGBtoHSV(r, g, b) { 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 }; } };