mirror of
https://github.com/morgan9e/gnome-cursor-overlay
synced 2026-04-14 08:24:17 +09:00
Init
This commit is contained in:
15
Makefile
Normal file
15
Makefile
Normal 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
258
extension.js
Normal 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
8
metadata.json
Normal 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
211
prefs.js
Normal 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));
|
||||
}
|
||||
}
|
||||
104
schemas/org.gnome.shell.extensions.cursor-overlay.gschema.xml
Normal file
104
schemas/org.gnome.shell.extensions.cursor-overlay.gschema.xml
Normal 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
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