Files
gnome-cursor-overlay/xcursor.js
2026-03-02 03:03:21 +09:00

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};