/* DING: Desktop Icons New Generation for GNOME Shell * * Adw/Gtk4 Port Copyright (C) 2025 Sundeep Mediratta (smedius@gmail.com) * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, version 3 of the License. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import {Adw, Gdk, Gio, GLib, GObject, Gtk, Pango} from '../dependencies/gi.js'; import {_} from '../dependencies/gettext.js'; import {DefaultShortcuts} from '../dependencies/localFiles.js'; import {GlobalShortcuts} from '../dependencies/localFiles.js'; export {ShortcutManager}; const DisplayShortcutRow = GObject.registerClass( class DisplayShortcutRow extends Adw.ActionRow { constructor({actionname, actionmap, readaccel}) { super({}); this.actionNamed = actionname; this.actionMap = actionmap; this.readaccel = readaccel; this._defaultShortcuts = DefaultShortcuts; this.accelLabel = new Gtk.Label({ label: '', xalign: 1, css_classes: ['monospace'], ellipsize: Pango.EllipsizeMode.END, max_width_chars: 28, width_chars: 16, halign: Gtk.Align.END, hexpand: false, single_line_mode: true, }); this.add_suffix(this.accelLabel); this.updateRow(); } updateRow() { const accels = this.readaccel(this.actionNamed); let accelList = []; if (Array.isArray(accels)) accelList = accels; else if (typeof accels === 'string' && accels.length) accelList = accels.split(','); this.accelText = _('None'); if (accelList.length) this.accelText = accelList.map(a => a.trim()).join(', '); this.accelLabel.set_label(this.accelText); this.accelLabel.set_tooltip_text(this.accelText); this.description = this._defaultShortcuts[this.actionNamed].Hint || this._prettify(this.actionNamed); this.set_title(this.description); } _prettify(name) { const prettyName = name.charAt(0).toUpperCase() + name.slice(1).replace(/[-_]/g, ' '); return prettyName; } } ); const EditableShortcutRow = GObject.registerClass( class EditableShortcutRow extends DisplayShortcutRow { constructor({actionname, actionmap, readaccel, writeaccel}) { super({actionname, actionmap, readaccel}); this.writeaccel = writeaccel; if (Adw.get_minor_version() > 2) this.set_subtitle_selectable(false); this.addEditor(); } updateRow() { super.updateRow(); this.use_markup = false; this.defaultAccel = this._defaultShortcuts[this.actionNamed].Accel; const subtitlestring = _('Default Shortcut:'); const subtitle = this.defaultAccel ? this.defaultAccel : _('None'); this.set_subtitle(`${subtitlestring} ${subtitle}`); } addEditor() { this.editIcon = Gtk.Image.new_from_icon_name('xapp-edit-symbolic'); this.editIcon.margin_start = 10; this.add_suffix(this.editIcon); this.set_activatable_widget(this.editIcon); this.makeActive(); } makeActive() { this.activatable = true; this.set_sensitive = true; this.connect('activated', () => this.setShortcut()); } setShortcut() { if (this.changingKey) return; this.changingKey = true; this.accelLabel.set_label(_('Type new...')); this.resetIcon = Gtk.Image.new_from_icon_name('revert'); this.resetIcon.margin_start = 10; this.clearIcon = Gtk.Image.new_from_icon_name('no'); this.clearIcon.margin_start = 10; const shortcutEditor = new Gtk.Entry({ editable: false, hexpand: false, vexpand: false, halign: Gtk.Align.END, valign: Gtk.Align.CENTER, xalign: 0, // Right-align placeholder_text: _('Modifier + Key (e.g. Ctrl + Alt + D)'), width_chars: 30, can_focus: true, has_frame: true, primary_icon_name: 'edit-undo-symbolic', primary_icon_tooltip_text: _('Reset to Default'), primary_icon_sensitive: this.defaultAccel !== this.accelText, primary_icon_activatable: true, secondary_icon_name: 'ding-edit-delete-symbolic', secondary_icon_tooltip_text: _('No Accelerator'), secondary_icon_sensitive: true, secondary_icon_activatable: true, }); const keyController = new Gtk.EventControllerKey(); shortcutEditor.add_controller(keyController); let popover = new Gtk.Popover({ has_arrow: false, autohide: true, child: shortcutEditor, }); popover.set_parent(this.accelLabel); popover.set_position(Gtk.PositionType.BOTTOM); popover.popup(); shortcutEditor.grab_focus_without_selecting(); const finishEditing = () => { this.changingKey = false; this.updateRow(); }; shortcutEditor.connect('activate', () => { const newaccelstring = ''; shortcutEditor.set_text(''); this.writeaccel(this.actionNamed, newaccelstring); popover.popdown(); }); // on Enter shortcutEditor.connect('icon-press', (entry, position) => { switch (position) { case Gtk.EntryIconPosition.PRIMARY: this.writeaccel(this.actionNamed, this.defaultAccel); popover.hide(); break; case Gtk.EntryIconPosition.SECONDARY: shortcutEditor.emit('activate'); break; } }); // On popover close (via outside click) popover.connect('hide', () => { finishEditing(); popover.unparent(); popover = null; }); keyController.connect( 'key-pressed', (actor, keyval, keycode, state) => { let newaccelstring; if (keyval === Gdk.KEY_Escape) popover.popdown(); if (state && keyval !== Gdk.KEY_Shift_L && keyval !== Gdk.KEY_Shift_R && keyval !== Gdk.KEY_Control_L && keyval !== Gdk.KEY_Control_R && keyval !== Gdk.KEY_Alt_L && keyval !== Gdk.KEY_Alt_R && keyval !== Gdk.KEY_Meta_L && keyval !== Gdk.KEY_Meta_R && keyval !== Gdk.KEY_Super_L && keyval !== Gdk.KEY_Super_R && keyval !== Gdk.KEY_Caps_Lock && keyval !== Gdk.KEY_Num_Lock && keyval !== Gdk.KEY_AltGr_L && keyval !== Gdk.KEY_AltGr_R && keyval !== Gdk.KEY_ISO_Level3_Shift && keyval !== Gdk.KEY_ISO_Level3_Lock && keyval !== Gdk.KEY_ISO_Level5_Shift && keyval !== Gdk.KEY_ISO_Level5_Lock ) { const mask = state & Gtk.accelerator_get_default_mod_mask(); newaccelstring = Gtk.accelerator_name(keyval, mask); shortcutEditor.set_text(newaccelstring); const oldaccelstring = this.readaccel(this.actionNamed); if (oldaccelstring !== newaccelstring) this.writeaccel(this.actionNamed, newaccelstring); popover.hide(); } return true; }); } } ); const ShortcutViewer = GObject.registerClass( class ShortcutViewer extends Adw.PreferencesGroup { constructor(params = {}) { super({}); this._shortcutManager = params.manager; this._actionMap = this._shortcutManager._mainApp; this._localShortcuts = this._shortcutManager._localShortcuts; this.readaccel = this._shortcutManager.readActionShortcut .bind(this._shortcutManager); this.set_title(_('System Shortcuts')); this.set_description(_('Common System Defined Keyboard Shortcuts')); this._addLocalShortcuts(); } _addLocalShortcuts() { if (!this._actionMap) return; const actions = this._actionMap.list_actions() .sort((a, b) => { return a .localeCompare( b, { sensitivity: 'accent', numeric: 'true', localeMatcher: 'lookup', } ); }); for (const action of actions) { if (this._localShortcuts[action]?.Edit || this._localShortcuts[action]?.Global || !this._localShortcuts[action]?.Accel ) continue; const actionRow = new DisplayShortcutRow({ 'actionname': action, 'actionmap': this._actionMap, 'readaccel': this.readaccel.bind(this), }); this.add(actionRow); } } }); const LocalShortcutEditor = GObject.registerClass( class LocalShortcutEditor extends Adw.PreferencesGroup { constructor(params = {}) { super({}); this._shortcutManager = params.manager; this._actionMap = this._shortcutManager._mainApp; this._localShortcuts = this._shortcutManager._localShortcuts; this._rows = []; this.readaccel = this._shortcutManager.readActionShortcut .bind(this._shortcutManager); this.writeaccel = this._shortcutManager.writeActionShortcut .bind(this._shortcutManager); this.set_title(_('Local Shortcuts')); this.set_description(_('Application Keyboard Shortcuts')); this._addLocalShortcuts(); } _addLocalShortcuts() { if (!this._actionMap) return; const actions = this._actionMap.list_actions() .sort((a, b) => { return a .localeCompare( b, { sensitivity: 'accent', numeric: 'true', localeMatcher: 'lookup', } ); }); for (const action of actions) { if (!this._localShortcuts[action]?.Edit) continue; const actionRow = new EditableShortcutRow({ 'actionname': action, 'actionmap': this._actionMap, 'readaccel': this.readaccel.bind(this), 'writeaccel': this.writeaccel.bind(this), }); this.add(actionRow); this._rows.push(actionRow); } } update() { this._rows.forEach(row => row.updateRow()); } }); const GlobalShortcutEditor = GObject.registerClass( class GlobalShortcutEditor extends Adw.PreferencesGroup { constructor(params = {}) { super({}); this._shortcutManager = params.manager; this._actionMap = this._shortcutManager._mainApp; this._globalShortcuts = this._shortcutManager._globalShortcuts; this._rows = []; this.readaccel = this._shortcutManager.readGlobalActionShortcut .bind(this._shortcutManager); this.writeaccel = this._shortcutManager.writeGlobalActionShortcut .bind(this._shortcutManager); this.set_title(_('Global Shortcuts')); this.set_description(_('System Keyboard Shortcuts')); this._addGlobalShortcuts(); } _addGlobalShortcuts() { if (!this._actionMap) return; const actions = this._actionMap.list_actions() .sort((a, b) => { return a .localeCompare( b, { sensitivity: 'accent', numeric: 'true', localeMatcher: 'lookup', } ); }); for (const action of actions) { if (!this._globalShortcuts[action]?.Global) continue; const actionRow = new EditableShortcutRow({ 'actionname': action, 'actionmap': this._actionMap, 'readaccel': this.readaccel.bind(this), 'writeaccel': this.writeaccel.bind(this), }); this.add(actionRow); this._rows.push(actionRow); } } update() { this._rows.forEach(row => row.updateRow()); } } ); const ShortcutManager = class { constructor(desktopManager) { this._desktopManager = desktopManager; this._desktopSettings = desktopManager.Prefs.desktopSettings; this._mainApp = desktopManager.mainApp; this._globalShortcuts = GlobalShortcuts; this._localShortcuts = DefaultShortcuts; this._overRideMap = new Map(); this._initializeOurShortcuts(); this._monitorUserShortcuts(); this._refreshUserShortcuts(); this._addTextEntryActions(); this._mainApp.connect( 'action-added', (_app, name) => this._setAccel(name) ); this._mainApp.connect( 'action-enabled-changed', (_app, name, _enabled) => this._setAccel(name) ); // Global shortcuts are automatically monitored and set by the // extension from settings } _addTextEntryActions() { const textEntryOn = Gio.SimpleAction.new('textEntryOn', null); textEntryOn.connect('activate', this._textEntryAccelsTurnOn.bind(this)); this._mainApp.add_action(textEntryOn); const textEntryOff = Gio.SimpleAction.new('textEntryOff', null); textEntryOff.connect('activate', this._textEntryAccelsTurnOff.bind(this)); this._mainApp.add_action(textEntryOff); } // this function is not used, but is another way of setting action // descriptions. _setStateHints() { for (const [actionName, {Hint}] of Object.entries(this._localShortcuts) ) { const action = this._mainApp.lookup_action(actionName); if (action) { action.set_state_hint( GLib.Variant.new_string(Hint) ); } } } _monitorUserShortcuts() { this._userShortcutMonitor = this._desktopSettings.connect( 'changed', (obj, key) => { if (key === 'shortcutoverrides') this._refreshUserShortcuts(); } ); } _refreshUserShortcuts() { this._readUserShortcuts(); this._setAllAccels(); } _readUserShortcuts() { const value = this._desktopSettings.get_value('shortcutoverrides') .deep_unpack(); this._overRideMap = new Map(Object.entries(value)); } _writeUserShortcuts() { const value = Object.fromEntries(this._overRideMap); const variant = new GLib.Variant('a{ss}', value); this._desktopSettings.set_value('shortcutoverrides', variant); } _setAllAccels() { for (const actionName of Object.keys(this._localShortcuts)) this._setAccel(actionName); } _setAccel(actionName) { const action = this._mainApp.lookup_action(actionName); if (!action) return; const accel = this._readOverRideActionShortcut(actionName); const accelarray = accel.length ? accel.split(',') : []; this._mainApp.set_accels_for_action( `app.${actionName}`, accelarray ); } _readOverRideActionShortcut(actionName) { const defaultShortCut = this._localShortcuts[actionName].Accel ?? ''; const userShortcut = this._overRideMap.get(actionName); const overrideShortCut = this._overRideMap.has(actionName) ? userShortcut : defaultShortCut; return overrideShortCut; } readActionShortcut(actionName) { return this._mainApp.get_accels_for_action(`app.${actionName}`); } writeActionShortcut(actionName, accel) { if (accel.length || accel === '') this._overRideMap.set(actionName, accel); else this._overRideMap.delete(actionName); this._desktopSettings.block_signal_handler(this._userShortcutMonitor); this._writeUserShortcuts(); this._setAccel(actionName); this._desktopSettings.unblock_signal_handler(this._userShortcutMonitor); } readGlobalActionShortcut(actionName) { return this._desktopSettings.get_strv(actionName.toLowerCase()); } writeGlobalActionShortcut(actionName, accel) { let accelArray = []; if (Array.isArray(accel)) accelArray = accel; else if (accel.length) accelArray = accel.split(','); this._desktopSettings.set_strv(actionName.toLowerCase(), accelArray); } _initializeOurShortcuts() { const showShortcutViewer = Gio.SimpleAction.new('showShortcutViewer', null); showShortcutViewer.connect('activate', () => { this._showShortcutViewer(); }); this._mainApp.add_action(showShortcutViewer); const textEntryAccelsTurnOn = Gio.SimpleAction.new('textEntryAccelsTurnOn', null); textEntryAccelsTurnOn.connect('activate', () => { this._textEntryAccelsTurnOn(); }); this._mainApp.add_action(textEntryAccelsTurnOn); const textEntryAccelsTurnOff = Gio.SimpleAction.new('textEntryAccelsTurnOff', null); textEntryAccelsTurnOff.connect('activate', () => { this._textEntryAccelsTurnOff(); }); this._mainApp.add_action(textEntryAccelsTurnOff); } _textEntryAccelsTurnOn() { this._mainApp.set_accels_for_action( 'app.previewAction', this._localShortcuts.previewAction.Accel.split(',') ); this._mainApp.set_accels_for_action( 'app.unselectAll', this._localShortcuts.unselectAll.Accel.split(',') ); this._mainApp.set_accels_for_action( 'app.openOneFileAction', this._localShortcuts.openOneFileAction.Accel.split(',') ); this._mainApp.set_accels_for_action( 'app.movetotrash', this._localShortcuts.movetotrash.Accel.split(',') ); this._mainApp.set_accels_for_action( 'app.chooseIconLeft', this._localShortcuts.chooseIconLeft.Accel.split(',') ); this._mainApp.set_accels_for_action( 'app.chooseIconRight', this._localShortcuts.chooseIconRight.Accel.split(',') ); this._mainApp.set_accels_for_action( 'app.chooseIconUp', this._localShortcuts.chooseIconUp.Accel.split(',') ); this._mainApp.set_accels_for_action( 'app.chooseIconDown', this._localShortcuts.chooseIconDown.Accel.split(',') ); this._mainApp.set_accels_for_action( 'app.menuKeyPressed', this._localShortcuts.menuKeyPressed.Accel.split(',') ); this._mainApp.set_accels_for_action( 'app.findFiles', this._localShortcuts.findFiles.Accel.split(',') ); this._mainApp.set_accels_for_action( 'app.toggleKeyboardSelection', this._localShortcuts.toggleKeyboardSelection.Accel.split(',') ); } _textEntryAccelsTurnOff() { this._mainApp.set_accels_for_action('app.previewAction', ['']); this._mainApp.set_accels_for_action('app.unselectAll', ['']); this._mainApp.set_accels_for_action('app.openOneFileAction', ['']); this._mainApp.set_accels_for_action('app.movetotrash', ['']); this._mainApp.set_accels_for_action('app.chooseIconLeft', ['']); this._mainApp.set_accels_for_action('app.chooseIconRight', ['']); this._mainApp.set_accels_for_action('app.chooseIconUp', ['']); this._mainApp.set_accels_for_action('app.chooseIconDown', ['']); this._mainApp.set_accels_for_action('app.menuKeyPressed', ['']); this._mainApp.set_accels_for_action('app.findFiles', ['']); this._mainApp.set_accels_for_action( 'app.toggleKeyboardSelection', [''] ); } _resetGlobalShortcuts() { Object.keys(this._globalShortcuts).forEach(actionKey => { const defaultAccel = this._globalShortcuts[actionKey]?.Accel; this.writeGlobalActionShortcut(actionKey, defaultAccel); }); this.globalShortcutGroup?.update(); } _resetLocalShortcuts() { this._overRideMap = new Map(); this._writeUserShortcuts(); this._refreshUserShortcuts(); this.localShortcutGroup?.update(); } _resetAllShortcuts() { this._resetGlobalShortcuts(); this._resetLocalShortcuts(); console.log('All Shortcuts reset to Defaults!'); } _showShortcutViewer() { if (this._shortCutsWindow) return; const shortcutsWindow = new Adw.PreferencesWindow(); shortcutsWindow.set_can_navigate_back(true); shortcutsWindow.set_search_enabled(true); shortcutsWindow.set_application(this._mainApp); shortcutsWindow.set_default_size(400, 600); shortcutsWindow.set_decorated(true); shortcutsWindow.set_deletable(true); shortcutsWindow.set_name('shortcutsWindow'); shortcutsWindow.set_title('Shortcuts'); shortcutsWindow.set_default_size(600, 650); // Do not make modal or skip-taskbar as we have a .desktop icon // showing up in the dock for the window to assist navigation. // const modal = true; // this._DesktopIconsUtil.windowHidePagerTaskbarModal( // shortcutsWindow, modal); const shortcutsFrame = Adw.PreferencesPage.new(); shortcutsFrame.set_name(_('Keyboard Shortcuts')); const systemShortcutGroup = new ShortcutViewer({manager: this}); shortcutsFrame.add(systemShortcutGroup); this.globalShortcutGroup = new GlobalShortcutEditor({manager: this}); shortcutsFrame.add(this.globalShortcutGroup); this.localShortcutGroup = new LocalShortcutEditor({manager: this}); shortcutsFrame.add(this.localShortcutGroup); const resetGroup = new Adw.PreferencesGroup({ title: _('Reset Shortcuts'), description: _('Reset all shortcuts to Defaults'), }); const resetButton = new Adw.ActionRow({ title: _('Reset All...'), }); const icon = Gtk.Image.new_from_icon_name('edit-undo-symbolic'); resetButton.add_suffix(icon); resetButton.set_activatable_widget(icon); resetButton.connect('activated', this._resetAllShortcuts.bind(this)); resetButton.get_style_context().add_class('destructive-action'); resetGroup.add(resetButton); shortcutsFrame.add(resetGroup); shortcutsWindow.add(shortcutsFrame); this._shortCutsWindow = shortcutsWindow; shortcutsWindow.connect('close-request', () => { this._shortCutsWindow = null; this.globalShortcutGroup = null; this.localShortcutGroup = null; }); shortcutsWindow.show(); } };