mirror of
https://github.com/morgan9e/gnome-cursor-overlay
synced 2026-04-13 16:04:08 +09:00
232 lines
7.3 KiB
JavaScript
232 lines
7.3 KiB
JavaScript
'use strict';
|
|
|
|
import GdkPixbuf from 'gi://GdkPixbuf';
|
|
import Gio from 'gi://Gio';
|
|
import GLib from 'gi://GLib';
|
|
|
|
const XCURSOR_MAGIC = 0x72756358;
|
|
const XCURSOR_IMAGE_TYPE = 0xfffd0002;
|
|
|
|
function findCursorDir(theme) {
|
|
const paths = [
|
|
`${GLib.get_home_dir()}/.local/share/icons/${theme}/cursors`,
|
|
`${GLib.get_home_dir()}/.icons/${theme}/cursors`,
|
|
`/usr/share/icons/${theme}/cursors`,
|
|
`/usr/local/share/icons/${theme}/cursors`,
|
|
];
|
|
|
|
for (const p of paths) {
|
|
if (GLib.file_test(p, GLib.FileTest.IS_DIR))
|
|
return p;
|
|
}
|
|
|
|
if (theme !== 'default' && theme !== 'Adwaita') {
|
|
for (const fallback of ['Adwaita', 'default']) {
|
|
const dir = findCursorDir(fallback);
|
|
if (dir) return dir;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseXcursor(filePath, targetSize) {
|
|
const file = Gio.File.new_for_path(filePath);
|
|
if (!file.query_exists(null))
|
|
return null;
|
|
|
|
let bytes;
|
|
try {
|
|
const [ok, contents] = file.load_contents(null);
|
|
if (!ok) return null;
|
|
bytes = contents;
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
if (bytes.length < 16)
|
|
return null;
|
|
|
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
|
|
const magic = view.getUint32(0, true);
|
|
if (magic !== XCURSOR_MAGIC)
|
|
return null;
|
|
|
|
const headerSize = view.getUint32(4, true);
|
|
const ntoc = view.getUint32(12, true);
|
|
|
|
const images = [];
|
|
for (let i = 0; i < ntoc; i++) {
|
|
const tocOffset = headerSize + i * 12;
|
|
if (tocOffset + 12 > bytes.length) break;
|
|
|
|
const type = view.getUint32(tocOffset, true);
|
|
const nominalSize = view.getUint32(tocOffset + 4, true);
|
|
const filePos = view.getUint32(tocOffset + 8, true);
|
|
|
|
if (type === XCURSOR_IMAGE_TYPE)
|
|
images.push({nominalSize, filePos});
|
|
}
|
|
|
|
if (images.length === 0)
|
|
return null;
|
|
|
|
let best = images[0];
|
|
let bestDiff = Math.abs(best.nominalSize - targetSize);
|
|
for (let i = 1; i < images.length; i++) {
|
|
const diff = Math.abs(images[i].nominalSize - targetSize);
|
|
if (diff < bestDiff || (diff === bestDiff && images[i].nominalSize > best.nominalSize)) {
|
|
best = images[i];
|
|
bestDiff = diff;
|
|
}
|
|
}
|
|
|
|
const pos = best.filePos;
|
|
if (pos + 36 > bytes.length)
|
|
return null;
|
|
|
|
const width = view.getUint32(pos + 16, true);
|
|
const height = view.getUint32(pos + 20, true);
|
|
const xhot = view.getUint32(pos + 24, true);
|
|
const yhot = view.getUint32(pos + 28, true);
|
|
|
|
const pixelDataOffset = pos + 36;
|
|
const pixelDataSize = width * height * 4;
|
|
if (pixelDataOffset + pixelDataSize > bytes.length)
|
|
return null;
|
|
|
|
const pixels = new Uint8Array(bytes.buffer, bytes.byteOffset + pixelDataOffset, pixelDataSize);
|
|
return {width, height, xhot, yhot, pixels: new Uint8Array(pixels)};
|
|
}
|
|
|
|
// Colorize: tint * luminance (ARGB32 LE premultiplied, in-memory BGRA)
|
|
function tintPixels(pixels, colorHex) {
|
|
const tr = parseInt(colorHex.slice(1, 3), 16);
|
|
const tg = parseInt(colorHex.slice(3, 5), 16);
|
|
const tb = parseInt(colorHex.slice(5, 7), 16);
|
|
|
|
const out = new Uint8Array(pixels.length);
|
|
for (let i = 0; i < pixels.length; i += 4) {
|
|
const a = pixels[i + 3];
|
|
if (a === 0)
|
|
continue;
|
|
const srcR = pixels[i + 2] * 255 / a;
|
|
const srcG = pixels[i + 1] * 255 / a;
|
|
const srcB = pixels[i + 0] * 255 / a;
|
|
const lum = (0.299 * srcR + 0.587 * srcG + 0.114 * srcB) / 255;
|
|
out[i + 0] = Math.round(tb * lum * a / 255);
|
|
out[i + 1] = Math.round(tg * lum * a / 255);
|
|
out[i + 2] = Math.round(tr * lum * a / 255);
|
|
out[i + 3] = a;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const CURSOR_NAMES = {
|
|
'default': ['default', 'left_ptr'],
|
|
'context-menu': ['context-menu', 'left_ptr'],
|
|
'help': ['help', 'left_ptr'],
|
|
'pointer': ['pointer', 'hand2'],
|
|
'progress': ['progress', 'left_ptr_watch'],
|
|
'wait': ['wait', 'watch'],
|
|
'cell': ['cell', 'plus'],
|
|
'crosshair': ['crosshair', 'cross'],
|
|
'text': ['text', 'xterm'],
|
|
'vertical-text': ['vertical-text'],
|
|
'alias': ['alias'],
|
|
'copy': ['copy'],
|
|
'move': ['move', 'fleur'],
|
|
'no-drop': ['no-drop'],
|
|
'not-allowed': ['not-allowed'],
|
|
'grab': ['grab', 'hand1'],
|
|
'grabbing': ['grabbing'],
|
|
'e-resize': ['e-resize', 'right_side'],
|
|
'n-resize': ['n-resize', 'top_side'],
|
|
'ne-resize': ['ne-resize', 'top_right_corner'],
|
|
'nw-resize': ['nw-resize', 'top_left_corner'],
|
|
's-resize': ['s-resize', 'bottom_side'],
|
|
'se-resize': ['se-resize', 'bottom_right_corner'],
|
|
'sw-resize': ['sw-resize', 'bottom_left_corner'],
|
|
'w-resize': ['w-resize', 'left_side'],
|
|
'ew-resize': ['ew-resize', 'sb_h_double_arrow'],
|
|
'ns-resize': ['ns-resize', 'sb_v_double_arrow'],
|
|
'nesw-resize': ['nesw-resize'],
|
|
'nwse-resize': ['nwse-resize'],
|
|
'col-resize': ['col-resize', 'sb_h_double_arrow'],
|
|
'row-resize': ['row-resize', 'sb_v_double_arrow'],
|
|
'all-scroll': ['all-scroll', 'fleur'],
|
|
'zoom-in': ['zoom-in'],
|
|
'zoom-out': ['zoom-out'],
|
|
};
|
|
|
|
// ARGB32 premultiplied LE (BGRA) -> RGBA straight alpha
|
|
function argbPreToRgba(pixels, width, height) {
|
|
const rgba = new Uint8Array(width * height * 4);
|
|
for (let i = 0; i < width * height; i++) {
|
|
const si = i * 4;
|
|
const a = pixels[si + 3];
|
|
if (a === 0) continue;
|
|
rgba[si + 0] = Math.min(255, Math.round(pixels[si + 2] * 255 / a));
|
|
rgba[si + 1] = Math.min(255, Math.round(pixels[si + 1] * 255 / a));
|
|
rgba[si + 2] = Math.min(255, Math.round(pixels[si + 0] * 255 / a));
|
|
rgba[si + 3] = a;
|
|
}
|
|
return rgba;
|
|
}
|
|
|
|
function getCacheDir() {
|
|
const dir = `${GLib.get_user_cache_dir()}/cursor-overlay`;
|
|
if (!GLib.file_test(dir, GLib.FileTest.IS_DIR))
|
|
GLib.mkdir_with_parents(dir, 0o755);
|
|
return dir;
|
|
}
|
|
|
|
export function loadCursorPng(cursorDir, cursorName, targetSize, colorHex) {
|
|
const cacheDir = getCacheDir();
|
|
const cacheKey = `${cursorName}_${colorHex.replace('#', '')}_${targetSize}.png`;
|
|
const cachePath = `${cacheDir}/${cacheKey}`;
|
|
|
|
const names = CURSOR_NAMES[cursorName] || [cursorName, 'default', 'left_ptr'];
|
|
|
|
let parsed = null;
|
|
for (const name of names) {
|
|
parsed = parseXcursor(`${cursorDir}/${name}`, targetSize);
|
|
if (parsed) break;
|
|
}
|
|
|
|
if (!parsed)
|
|
return null;
|
|
|
|
if (!GLib.file_test(cachePath, GLib.FileTest.EXISTS)) {
|
|
const tinted = tintPixels(parsed.pixels, colorHex);
|
|
const rgba = argbPreToRgba(tinted, parsed.width, parsed.height);
|
|
|
|
try {
|
|
const pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(
|
|
GLib.Bytes.new(rgba),
|
|
GdkPixbuf.Colorspace.RGB, true, 8,
|
|
parsed.width, parsed.height, parsed.width * 4
|
|
);
|
|
pixbuf.savev(cachePath, 'png', [], []);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return {
|
|
path: cachePath,
|
|
xhot: parsed.xhot,
|
|
yhot: parsed.yhot,
|
|
width: parsed.width,
|
|
height: parsed.height,
|
|
};
|
|
}
|
|
|
|
export function getCursorTheme() {
|
|
const s = new Gio.Settings({schema_id: 'org.gnome.desktop.interface'});
|
|
return {theme: s.get_string('cursor-theme'), size: s.get_int('cursor-size')};
|
|
}
|
|
|
|
export {findCursorDir};
|