This commit is contained in:
2026-03-02 03:00:40 +09:00
commit e38539c33f
6 changed files with 827 additions and 0 deletions

15
Makefile Normal file
View File

@@ -0,0 +1,15 @@
UUID = cursor-overlay@local
EXT_DIR = $(HOME)/.local/share/gnome-shell/extensions/$(UUID)
SRC_FILES = extension.js xcursor.js prefs.js metadata.json
install: uninstall
mkdir -p $(EXT_DIR)/schemas
cp $(SRC_FILES) $(EXT_DIR)/
cp schemas/*.xml $(EXT_DIR)/schemas/
glib-compile-schemas $(EXT_DIR)/schemas/
uninstall:
rm -rf $(EXT_DIR)
test: install
dbus-run-session -- gnome-shell --devkit --wayland

258
extension.js Normal file
View File

@@ -0,0 +1,258 @@
'use strict';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import St from 'gi://St';
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
import {findCursorDir, loadCursorPng, getCursorTheme} from './xcursor.js';
const CAIRO_OPERATOR_CLEAR = 0;
const CAIRO_OPERATOR_OVER = 2;
function parseColor(hex) {
return [
parseInt(hex.slice(1, 3), 16) / 255,
parseInt(hex.slice(3, 5), 16) / 255,
parseInt(hex.slice(5, 7), 16) / 255,
];
}
export default class CursorOverlayExtension extends Extension {
enable() {
this._settings = this.getSettings();
this._lastX = null;
this._lastY = null;
this._mode = null;
this._offsetX = 0;
this._offsetY = 0;
this._positionInvalidatedId = null;
this._timerId = null;
this._monitorChangedId = null;
this._connectorMap = new Map();
this._disabledSet = new Set(this._settings.get_strv('disabled-monitors'));
this._lastMonitorIdx = -1;
this._lastMonitorDisabled = false;
this._buildMonitorMap();
this._setupOverlay();
this._startTracking();
this._settingsChangedId = this._settings.connect('changed', () => {
this._stopTracking();
this._teardownOverlay();
this._disabledSet = new Set(this._settings.get_strv('disabled-monitors'));
this._lastMonitorIdx = -1;
this._lastMonitorDisabled = false;
this._setupOverlay();
this._startTracking();
});
try {
const mm = global.backend.get_monitor_manager();
this._monitorChangedId = mm.connect('monitors-changed', () => {
this._buildMonitorMap();
});
} catch { /* unavailable */ }
}
disable() {
if (this._settingsChangedId) {
this._settings.disconnect(this._settingsChangedId);
this._settingsChangedId = null;
}
if (this._monitorChangedId) {
try { global.backend.get_monitor_manager().disconnect(this._monitorChangedId); }
catch { /* ignore */ }
this._monitorChangedId = null;
}
this._stopTracking();
this._teardownOverlay();
this._settings = null;
this._connectorMap = null;
this._disabledSet = null;
}
_setupOverlay() {
this._mode = this._settings.get_string('overlay-mode');
switch (this._mode) {
case 'circle': this._setupCircle(); break;
case 'cursor': this._setupCursor(); break;
case 'image': this._setupImage(); break;
default: this._setupCircle(); break;
}
}
_teardownOverlay() {
if (this._overlay) {
const parent = this._overlay.get_parent();
if (parent)
parent.remove_child(this._overlay);
this._overlay.destroy();
this._overlay = null;
}
this._lastX = null;
this._lastY = null;
}
_buildMonitorMap() {
this._connectorMap = new Map();
try {
const mm = global.backend.get_monitor_manager();
for (const monitor of mm.get_monitors()) {
const connector = monitor.get_connector();
const idx = mm.get_monitor_for_connector(connector);
if (idx >= 0)
this._connectorMap.set(idx, connector);
}
} catch { /* unavailable */ }
}
_startTracking() {
try {
const tracker = global.backend.get_cursor_tracker();
this._positionInvalidatedId = tracker.connect(
'position-invalidated', () => this._updatePosition()
);
this._updatePosition();
return;
} catch { /* fall back to polling */ }
const pollMs = Math.round(1000 / this._settings.get_int('poll-rate'));
this._timerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, pollMs, () => {
this._updatePosition();
return GLib.SOURCE_CONTINUE;
});
}
_stopTracking() {
if (this._positionInvalidatedId) {
try { global.backend.get_cursor_tracker().disconnect(this._positionInvalidatedId); }
catch { /* ignore */ }
this._positionInvalidatedId = null;
}
if (this._timerId) {
GLib.source_remove(this._timerId);
this._timerId = null;
}
}
_updatePosition() {
if (!this._overlay)
return;
const [mx, my] = global.get_pointer();
if (this._disabledSet.size > 0 && this._connectorMap.size > 0) {
const monIdx = global.display.get_current_monitor();
if (monIdx !== this._lastMonitorIdx) {
this._lastMonitorIdx = monIdx;
const connector = this._connectorMap.get(monIdx);
this._lastMonitorDisabled = connector != null && this._disabledSet.has(connector);
}
if (this._lastMonitorDisabled) {
this._overlay.hide();
return;
}
this._overlay.show();
}
const cx = mx - this._offsetX;
const cy = my - this._offsetY;
if (this._lastX !== cx || this._lastY !== cy) {
this._overlay.set_position(cx, cy);
this._lastX = cx;
this._lastY = cy;
global.stage.set_child_above_sibling(this._overlay, null);
}
}
_setupCircle() {
const radius = this._settings.get_int('circle-radius');
const stroke = this._settings.get_int('circle-stroke-width');
const [cr, cg, cb] = parseColor(this._settings.get_string('circle-color'));
const alpha = this._settings.get_int('circle-opacity') / 100;
const size = (radius + stroke) * 2;
this._offsetX = radius + stroke;
this._offsetY = radius + stroke;
this._overlay = new St.DrawingArea({
width: size, height: size,
reactive: false, can_focus: false, track_hover: false,
});
this._overlay.set_style('background-color: transparent;');
this._overlay.connect('repaint', area => {
const ctx = area.get_context();
const [w, h] = area.get_surface_size();
ctx.setOperator(CAIRO_OPERATOR_CLEAR);
ctx.paint();
ctx.setOperator(CAIRO_OPERATOR_OVER);
ctx.setSourceRGBA(cr, cg, cb, alpha);
ctx.setLineWidth(stroke);
ctx.arc(w / 2, h / 2, radius, 0, 2 * Math.PI);
ctx.stroke();
ctx.$dispose();
});
global.stage.add_child(this._overlay);
global.stage.set_child_above_sibling(this._overlay, null);
}
_setupCursor() {
const cursorSize = this._settings.get_int('cursor-size');
const colorHex = this._settings.get_string('cursor-color');
const opacity = this._settings.get_int('cursor-opacity');
const {theme} = getCursorTheme();
const cursorDir = findCursorDir(theme);
if (!cursorDir) { this._setupCircle(); return; }
const info = loadCursorPng(cursorDir, 'default', cursorSize, colorHex);
if (!info) { this._setupCircle(); return; }
this._overlay = new St.Icon({
gicon: Gio.icon_new_for_string(info.path),
icon_size: info.width,
reactive: false, can_focus: false, track_hover: false,
opacity: Math.round(opacity * 2.55),
});
this._offsetX = info.xhot;
this._offsetY = info.yhot;
global.stage.add_child(this._overlay);
global.stage.set_child_above_sibling(this._overlay, null);
}
_setupImage() {
const imagePath = this._settings.get_string('image-path');
if (!imagePath || !GLib.file_test(imagePath, GLib.FileTest.EXISTS)) {
this._setupCircle();
return;
}
const imageSize = this._settings.get_int('image-size');
const opacity = this._settings.get_int('image-opacity');
this._overlay = new St.Icon({
gicon: Gio.icon_new_for_string(imagePath),
icon_size: imageSize,
reactive: false, can_focus: false, track_hover: false,
opacity: Math.round(opacity * 2.55),
});
this._offsetX = imageSize / 2;
this._offsetY = imageSize / 2;
global.stage.add_child(this._overlay);
global.stage.set_child_above_sibling(this._overlay, null);
}
}

8
metadata.json Normal file
View File

@@ -0,0 +1,8 @@
{
"name": "Cursor Overlay",
"description": "Draws a visible overlay on the cursor",
"uuid": "cursor-overlay@local",
"shell-version": ["49", "50"],
"settings-schema": "org.gnome.shell.extensions.cursor-overlay",
"url": ""
}

211
prefs.js Normal file
View File

@@ -0,0 +1,211 @@
'use strict';
import Adw from 'gi://Adw';
import Gdk from 'gi://Gdk';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import {ExtensionPreferences} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
function newColorButton(settings, key) {
const btn = new Gtk.ColorDialogButton({
dialog: new Gtk.ColorDialog({modal: true, with_alpha: false}),
valign: Gtk.Align.CENTER,
});
const rgba = btn.get_rgba();
rgba.parse(settings.get_string(key));
btn.set_rgba(rgba);
btn.connect('notify::rgba', widget => {
const rgb = widget.get_rgba().to_string();
const hex = '#' + rgb
.replace(/^rgb\(|\s+|\)$/g, '')
.split(',')
.map(s => parseInt(s).toString(16).padStart(2, '0'))
.join('');
settings.set_string(key, hex);
});
return btn;
}
const MODE_LABELS = ['Circle', 'Cursor', 'Image'];
const MODE_VALUES = ['circle', 'cursor', 'image'];
const OverlayPage = GObject.registerClass(
class OverlayPage extends Adw.PreferencesPage {
constructor(extensionObject) {
super({title: 'Overlay', icon_name: 'input-mouse-symbolic'});
const settings = extensionObject.getSettings();
// Mode
const modeGroup = new Adw.PreferencesGroup({title: 'Mode'});
this.add(modeGroup);
const modeRow = new Adw.ComboRow({
title: 'Overlay Mode',
subtitle: 'Circle ring, tinted cursor, or custom image',
model: Gtk.StringList.new(MODE_LABELS),
});
modeRow.set_selected(Math.max(0, MODE_VALUES.indexOf(settings.get_string('overlay-mode'))));
modeRow.connect('notify::selected', w => {
settings.set_string('overlay-mode', MODE_VALUES[w.selected] || 'circle');
});
modeGroup.add(modeRow);
// Circle
const circleGroup = new Adw.PreferencesGroup({title: 'Circle Mode'});
this.add(circleGroup);
const radiusRow = new Adw.SpinRow({
title: 'Radius',
adjustment: new Gtk.Adjustment({lower: 4, upper: 128, step_increment: 2}),
value: settings.get_int('circle-radius'),
});
radiusRow.adjustment.connect('value-changed', w => settings.set_int('circle-radius', w.value));
circleGroup.add(radiusRow);
const strokeRow = new Adw.SpinRow({
title: 'Stroke Width',
adjustment: new Gtk.Adjustment({lower: 1, upper: 16, step_increment: 1}),
value: settings.get_int('circle-stroke-width'),
});
strokeRow.adjustment.connect('value-changed', w => settings.set_int('circle-stroke-width', w.value));
circleGroup.add(strokeRow);
const circleColorRow = new Adw.ActionRow({title: 'Color'});
circleColorRow.add_suffix(newColorButton(settings, 'circle-color'));
circleGroup.add(circleColorRow);
const circleOpacityRow = new Adw.SpinRow({
title: 'Opacity',
adjustment: new Gtk.Adjustment({lower: 0, upper: 100, step_increment: 5}),
value: settings.get_int('circle-opacity'),
});
circleOpacityRow.adjustment.connect('value-changed', w => settings.set_int('circle-opacity', w.value));
circleGroup.add(circleOpacityRow);
// Cursor
const cursorGroup = new Adw.PreferencesGroup({title: 'Cursor Mode'});
this.add(cursorGroup);
const cursorSizeRow = new Adw.SpinRow({
title: 'Size',
subtitle: 'Xcursor size',
adjustment: new Gtk.Adjustment({lower: 16, upper: 256, step_increment: 8}),
value: settings.get_int('cursor-size'),
});
cursorSizeRow.adjustment.connect('value-changed', w => settings.set_int('cursor-size', w.value));
cursorGroup.add(cursorSizeRow);
const cursorColorRow = new Adw.ActionRow({title: 'Tint Color'});
cursorColorRow.add_suffix(newColorButton(settings, 'cursor-color'));
cursorGroup.add(cursorColorRow);
const cursorOpacityRow = new Adw.SpinRow({
title: 'Opacity',
adjustment: new Gtk.Adjustment({lower: 0, upper: 100, step_increment: 5}),
value: settings.get_int('cursor-opacity'),
});
cursorOpacityRow.adjustment.connect('value-changed', w => settings.set_int('cursor-opacity', w.value));
cursorGroup.add(cursorOpacityRow);
// Image
const imageGroup = new Adw.PreferencesGroup({title: 'Image Mode'});
this.add(imageGroup);
const imagePathRow = new Adw.ActionRow({
title: 'Image File',
subtitle: settings.get_string('image-path') || 'No file selected',
});
const browseBtn = new Gtk.Button({label: 'Browse', valign: Gtk.Align.CENTER});
browseBtn.connect('clicked', () => {
const dialog = new Gtk.FileDialog({title: 'Select Overlay Image', modal: true});
const filter = new Gtk.FileFilter();
filter.set_name('Images');
for (const t of ['image/png', 'image/bmp', 'image/svg+xml', 'image/jpeg', 'image/gif'])
filter.add_mime_type(t);
const store = new Gio.ListStore({item_type: Gtk.FileFilter});
store.append(filter);
dialog.set_filters(store);
dialog.open(this.get_root(), null, (dlg, result) => {
try {
const file = dlg.open_finish(result);
if (file) {
const path = file.get_path();
settings.set_string('image-path', path);
imagePathRow.set_subtitle(path);
}
} catch { /* cancelled */ }
});
});
imagePathRow.add_suffix(browseBtn);
imageGroup.add(imagePathRow);
const imageSizeRow = new Adw.SpinRow({
title: 'Size',
adjustment: new Gtk.Adjustment({lower: 8, upper: 512, step_increment: 8}),
value: settings.get_int('image-size'),
});
imageSizeRow.adjustment.connect('value-changed', w => settings.set_int('image-size', w.value));
imageGroup.add(imageSizeRow);
const imageOpacityRow = new Adw.SpinRow({
title: 'Opacity',
adjustment: new Gtk.Adjustment({lower: 0, upper: 100, step_increment: 5}),
value: settings.get_int('image-opacity'),
});
imageOpacityRow.adjustment.connect('value-changed', w => settings.set_int('image-opacity', w.value));
imageGroup.add(imageOpacityRow);
// Per-Monitor
const monitorGroup = new Adw.PreferencesGroup({title: 'Per-Monitor'});
this.add(monitorGroup);
const monitorList = Gdk.Display.get_default().get_monitors();
const nMonitors = monitorList.get_n_items();
const disabledMonitors = settings.get_strv('disabled-monitors');
for (let i = 0; i < nMonitors; i++) {
const monitor = monitorList.get_item(i);
const connector = monitor.get_connector();
const geom = monitor.get_geometry();
const toggle = new Gtk.Switch({
active: !disabledMonitors.includes(connector),
valign: Gtk.Align.CENTER,
});
const row = new Adw.ActionRow({
title: connector || `Monitor ${i + 1}`,
subtitle: `${geom.width}\u00d7${geom.height}`,
});
row.add_suffix(toggle);
row.set_activatable_widget(toggle);
toggle.connect('notify::active', widget => {
const current = settings.get_strv('disabled-monitors');
if (widget.active) {
settings.set_strv('disabled-monitors',
current.filter(c => c !== connector));
} else {
if (!current.includes(connector))
settings.set_strv('disabled-monitors', [...current, connector]);
}
});
monitorGroup.add(row);
}
}
}
);
export default class CursorOverlayPreferences extends ExtensionPreferences {
fillPreferencesWindow(window) {
window.default_width = 460;
window.default_height = 880;
window.add(new OverlayPage(this));
}
}

View File

@@ -0,0 +1,104 @@
<!DOCTYPE xml>
<schemalist>
<enum id='org.gnome.shell.extensions.cursor-overlay.overlay-mode'>
<value value='0' nick='circle' />
<value value='1' nick='cursor' />
<value value='2' nick='image' />
</enum>
<schema id='org.gnome.shell.extensions.cursor-overlay'
path='/org/gnome/shell/extensions/cursor-overlay/'>
<!-- Mode -->
<key name='overlay-mode' enum='org.gnome.shell.extensions.cursor-overlay.overlay-mode'>
<default>'circle'</default>
<summary>Overlay mode</summary>
<description>Circle ring, tinted cursor, or custom image overlay.</description>
</key>
<!-- Circle mode settings -->
<key name='circle-radius' type='i'>
<default>16</default>
<summary>Circle radius</summary>
<description>Radius of the circle overlay in pixels.</description>
</key>
<key name='circle-stroke-width' type='i'>
<default>2</default>
<summary>Circle stroke width</summary>
<description>Width of the circle outline in pixels.</description>
</key>
<key name='circle-color' type='s'>
<default>'#000000'</default>
<summary>Circle color</summary>
<description>Color of the circle overlay.</description>
</key>
<key name='circle-opacity' type='i'>
<default>85</default>
<summary>Circle opacity</summary>
<description>Opacity of the circle overlay (0-100).</description>
</key>
<!-- Cursor mode settings -->
<key name='cursor-size' type='i'>
<default>48</default>
<summary>Cursor overlay size</summary>
<description>Size of the cursor overlay in pixels.</description>
</key>
<key name='cursor-color' type='s'>
<default>'#ff0000'</default>
<summary>Cursor overlay color</summary>
<description>Tint color for the cursor overlay.</description>
</key>
<key name='cursor-opacity' type='i'>
<default>80</default>
<summary>Cursor overlay opacity</summary>
<description>Opacity of the cursor overlay (0-100).</description>
</key>
<!-- Image mode settings -->
<key name='image-path' type='s'>
<default>''</default>
<summary>Custom image path</summary>
<description>Path to a custom image file (PNG, BMP, SVG, etc.) to use as overlay.</description>
</key>
<key name='image-size' type='i'>
<default>48</default>
<summary>Custom image size</summary>
<description>Display size of the custom image overlay in pixels.</description>
</key>
<key name='image-opacity' type='i'>
<default>80</default>
<summary>Custom image opacity</summary>
<description>Opacity of the custom image overlay (0-100).</description>
</key>
<!-- Per-monitor settings -->
<key name='disabled-monitors' type='as'>
<default>[]</default>
<summary>Disabled monitors</summary>
<description>List of monitor connector names where the overlay is hidden.</description>
</key>
<!-- Fallback polling (used only if cursor tracker signal unavailable) -->
<key name='poll-rate' type='i'>
<default>120</default>
<summary>Poll rate</summary>
<description>Fallback cursor position polling rate in Hz.</description>
</key>
</schema>
</schemalist>

231
xcursor.js Normal file
View 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};