From e38539c33f2f091b0c57b5e427ab1ce1efb1dd67 Mon Sep 17 00:00:00 2001 From: Morgan Date: Mon, 2 Mar 2026 03:00:40 +0900 Subject: [PATCH] Init --- Makefile | 15 + extension.js | 258 ++++++++++++++++++ metadata.json | 8 + prefs.js | 211 ++++++++++++++ ...hell.extensions.cursor-overlay.gschema.xml | 104 +++++++ xcursor.js | 231 ++++++++++++++++ 6 files changed, 827 insertions(+) create mode 100644 Makefile create mode 100644 extension.js create mode 100644 metadata.json create mode 100644 prefs.js create mode 100644 schemas/org.gnome.shell.extensions.cursor-overlay.gschema.xml create mode 100644 xcursor.js diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0dcb380 --- /dev/null +++ b/Makefile @@ -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 diff --git a/extension.js b/extension.js new file mode 100644 index 0000000..fd0a759 --- /dev/null +++ b/extension.js @@ -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); + } +} diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..e1c7c24 --- /dev/null +++ b/metadata.json @@ -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": "" +} diff --git a/prefs.js b/prefs.js new file mode 100644 index 0000000..790b938 --- /dev/null +++ b/prefs.js @@ -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)); + } +} diff --git a/schemas/org.gnome.shell.extensions.cursor-overlay.gschema.xml b/schemas/org.gnome.shell.extensions.cursor-overlay.gschema.xml new file mode 100644 index 0000000..81ac49d --- /dev/null +++ b/schemas/org.gnome.shell.extensions.cursor-overlay.gschema.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + 'circle' + Overlay mode + Circle ring, tinted cursor, or custom image overlay. + + + + + + 16 + Circle radius + Radius of the circle overlay in pixels. + + + + 2 + Circle stroke width + Width of the circle outline in pixels. + + + + '#000000' + Circle color + Color of the circle overlay. + + + + 85 + Circle opacity + Opacity of the circle overlay (0-100). + + + + + + 48 + Cursor overlay size + Size of the cursor overlay in pixels. + + + + '#ff0000' + Cursor overlay color + Tint color for the cursor overlay. + + + + 80 + Cursor overlay opacity + Opacity of the cursor overlay (0-100). + + + + + + '' + Custom image path + Path to a custom image file (PNG, BMP, SVG, etc.) to use as overlay. + + + + 48 + Custom image size + Display size of the custom image overlay in pixels. + + + + 80 + Custom image opacity + Opacity of the custom image overlay (0-100). + + + + + + [] + Disabled monitors + List of monitor connector names where the overlay is hidden. + + + + + + 120 + Poll rate + Fallback cursor position polling rate in Hz. + + + + diff --git a/xcursor.js b/xcursor.js new file mode 100644 index 0000000..b484d52 --- /dev/null +++ b/xcursor.js @@ -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};