mirror of
https://github.com/morgan9e/gnome-cursor-overlay
synced 2026-04-14 16:34:19 +09:00
Init
This commit is contained in:
231
xcursor.js
Normal file
231
xcursor.js
Normal file
@@ -0,0 +1,231 @@
|
||||
'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};
|
||||
Reference in New Issue
Block a user