311 lines
8.2 KiB
JavaScript
311 lines
8.2 KiB
JavaScript
|
|
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
|
|
};
|
|
}
|
|
};
|