diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6f9a44 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.vscode/settings.json diff --git a/extension.js b/extension.js index 71b5718..80e699a 100644 --- a/extension.js +++ b/extension.js @@ -1,188 +1,183 @@ -"use strict"; +// extension.js - GNOME 46 compatible version +'use strict'; -const { Shell, Meta, Gio, GObject } = imports.gi; -const Main = imports.ui.main; -const ExtensionUtils = imports.misc.extensionUtils; -const Me = ExtensionUtils.getCurrentExtension(); +import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import Shell from 'gi://Shell'; +import Meta from 'gi://Meta'; +import Gio from 'gi://Gio'; +import GObject from 'gi://GObject'; -const SETTINGS_ID = "org.gnome.shell.extensions.focus-window"; -const SETTINGS_KEY = "app-settings"; -const SETTINGS_VARIANT = "aa{sv}"; +const SETTINGS_ID = 'org.gnome.shell.extensions.focus-window'; +const SETTINGS_KEY = 'app-settings'; +const SETTINGS_VARIANT = 'aa{sv}'; const appSys = Shell.AppSystem.get_default(); const appWin = Shell.WindowTracker.get_default(); const KeyboardShortcuts = GObject.registerClass( - {}, - class KeyboardShortcuts extends GObject.Object { - constructor(params = {}) { - super(params); - this.shortcuts = {}; - - this.displayConnection = global.display.connect( - "accelerator-activated", - (__, action) => { - const grabber = this.shortcuts[action]; - if (grabber) grabber.callback(); + {}, + class KeyboardShortcuts extends GObject.Object { + constructor(params = {}) { + super(params); + this.shortcuts = {}; + + this.displayConnection = global.display.connect( + 'accelerator-activated', + (__, action) => { + const grabber = this.shortcuts[action]; + if (grabber) grabber.callback(); + } + ); } - ); - } - reset() { - for (let action in this.shortcuts) { - this.unbind(action); - } - } + reset() { + for (let action in this.shortcuts) { + this.unbind(action); + } + } - destroy() { - global.display.disconnect(this.displayConnection); + destroy() { + if (this.displayConnection) + global.display.disconnect(this.displayConnection); - for (let action in this.shortcuts) { - this.unbind(action); - } + for (let action in this.shortcuts) { + this.unbind(action); + } - this.shortcuts = {}; - this.displayConnection = null; - } + this.shortcuts = {}; + this.displayConnection = null; + } - bind(accelerator, callback) { - const action = global.display.grab_accelerator( - accelerator, - Meta.KeyBindingFlags.NONE - ); + bind(accelerator, callback) { + const action = global.display.grab_accelerator( + accelerator, + Meta.KeyBindingFlags.NONE + ); - if (action === Meta.KeyBindingAction.NONE) return; + if (action === Meta.KeyBindingAction.NONE) return; - const name = Meta.external_binding_name_for_action(action); - Main.wm.allowKeybinding(name, Shell.ActionMode.ALL); + const name = Meta.external_binding_name_for_action(action); + Main.wm.allowKeybinding(name, Shell.ActionMode.ALL); - this.shortcuts[action] = { name, accelerator, callback }; - } + this.shortcuts[action] = { name, accelerator, callback }; + } - unbind(action) { - const grabber = this.shortcuts[action]; + unbind(action) { + const grabber = this.shortcuts[action]; - if (grabber) { - global.display.ungrab_accelerator(action); - Main.wm.allowKeybinding(grabber.name, Shell.ActionMode.NONE); - delete this.shortcuts[action]; - } + if (grabber) { + global.display.ungrab_accelerator(action); + Main.wm.allowKeybinding(grabber.name, Shell.ActionMode.NONE); + delete this.shortcuts[action]; + } + } } - } ); -class Extension { - constructor() { - this.shortcuts = null; - this.settingsListener = null; - this.settings = null; - } +export default class FocusWindowExtension extends Extension { + enable() { + this.shortcuts = new KeyboardShortcuts(); - enable() { - this.shortcuts = new KeyboardShortcuts(); + this.settings = this.getSettings(SETTINGS_ID); - this.settings = ExtensionUtils.getSettings(SETTINGS_ID); + this.settingsListener = this.settings.connect( + `changed::${SETTINGS_KEY}`, + () => { + this.setupShortcuts( + this.settings.get_value(SETTINGS_KEY).recursiveUnpack() + ); + } + ); - this.settingsListener = this.settings.connect( - `changed::${SETTINGS_KEY}`, - () => { this.setupShortcuts( - this.settings.get_value(SETTINGS_KEY).recursiveUnpack() + this.settings.get_value(SETTINGS_KEY).recursiveUnpack() ); - } - ); - - this.setupShortcuts( - this.settings.get_value(SETTINGS_KEY).recursiveUnpack() - ); - } - - setupShortcuts(settings) { - this.shortcuts.reset(); - - settings.forEach((setting) => { - if (setting.keyboardShortcut && setting.applicationToFocus) { - this.shortcuts.bind(setting.keyboardShortcut, () => { - try { - // get application - const application = appSys.lookup_app(setting.applicationToFocus); - if (!application) return false; - - // get application windows and filter appropriately - const appWindows = application.get_windows().filter((window) => { - if (!setting.titleToMatch) return true; - if (setting.exactTitleMatch) - return window.get_title() === setting.titleToMatch; - - if (typeof window.get_title() !== "string") return false; - - return window - .get_title() - .toLowerCase() - .includes(setting.titleToMatch.toLowerCase()); - }); - - // get the currently focused window - const focusedWindow = global.display.get_focus_window().get_id(); - - // launch the application - if (!appWindows.length && setting.launchApplication) { - // launch the application normally - if (!setting.commandLineArguments) { - return application.open_new_window(-1); - } - - // launch the application with the overriden command line arguments - const context = global.create_app_launch_context(0, -1); - const newApplication = Gio.AppInfo.create_from_commandline( - application.get_app_info().get_executable() + - " " + - setting.commandLineArguments, - null, - Gio.AppInfoCreateFlags.NONE - ); - - newApplication.launch([], context); - } - - // cycle through open windows if there are multiple - if (appWindows.length > 1) { - return Main.activateWindow(appWindows[appWindows.length - 1]); - } + } - // Minimize window if it is already focused and there is only 1 window - if ( - appWindows.length === 1 && - focusedWindow === appWindows[0].get_id() - ) { - return appWindows[0].minimize(); + setupShortcuts(settings) { + this.shortcuts.reset(); + + settings.forEach((setting) => { + if (setting.keyboardShortcut && setting.applicationToFocus) { + this.shortcuts.bind(setting.keyboardShortcut, () => { + try { + const application = appSys.lookup_app(setting.applicationToFocus); + if (!application) return false; + + const appWindows = application.get_windows().filter((window) => { + if (!setting.titleToMatch) return true; + if (setting.exactTitleMatch) + return window.get_title() === setting.titleToMatch; + + if (typeof window.get_title() !== 'string') return false; + + return window + .get_title() + .toLowerCase() + .includes(setting.titleToMatch.toLowerCase()); + }); + + const focused = global.display.get_focus_window(); + const focusedWindow = focused ? focused.get_id() : null; + + if (!appWindows.length && setting.launchApplication) { + if (!setting.commandLineArguments) { + return application.open_new_window(-1); + } + + const context = global.create_app_launch_context(0, -1); + const newApplication = Gio.AppInfo.create_from_commandline( + application.get_app_info().get_executable() + + ' ' + + setting.commandLineArguments, + null, + Gio.AppInfoCreateFlags.NONE + ); + + newApplication.launch([], context); + return true; + } + + if (appWindows.length > 1) { + return Main.activateWindow(appWindows[appWindows.length - 1]); + } + + if ( + appWindows.length === 1 && + focusedWindow !== null && + focusedWindow === appWindows[0].get_id() + ) { + return appWindows[0].minimize(); + } + + if (appWindows.length === 1) { + return Main.activateWindow(appWindows[0]); + } + + return false; + } catch (error) { + console.log('setting trigger failed: '); + console.log(error); + } + }); } + }); + } - // Draw focus to the window if it is not already focused - if (appWindows.length === 1) { - return Main.activateWindow(appWindows[0]); - } + disable() { + if (this.shortcuts) { + this.shortcuts.destroy(); + } - return false; - } catch (error) { - log("setting trigger failed: "); - log(error); - } - }); - } - }); - } - - disable() { - this.shortcuts.destroy(); - this.settings.disconnect(this.settingsListener); - - this.settingsListener = null; - this.settings = null; - this.shortcuts = null; - } -} + if (this.settings && this.settingsListener) { + try { + this.settings.disconnect(this.settingsListener); + } catch (e) {} + } -function init() { - return new Extension(); + this.settingsListener = null; + this.settings = null; + this.shortcuts = null; + } } diff --git a/metadata.json b/metadata.json index dc11aa2..e707d75 100644 --- a/metadata.json +++ b/metadata.json @@ -1,10 +1,8 @@ { - "name": "Focus Window", - "description": "This extension allows one to create various shortcuts for applications, enabling the ability to have one shortcut that triggers both the launch and focus of an application window.", - "uuid": "focus-window@chris.al", - "shell-version": [ - "42" - ], - "url": "https://github.com/pcbowers/focus-window", - "version": 1 + "name": "Focus Window", + "description": "This extension allows one to create various shortcuts for applications, enabling the ability to have one shortcut that triggers both the launch and focus of an application window.", + "uuid": "focus-window@chris.al", + "shell-version": ["46"], + "url": "https://github.com/pcbowers/focus-window", + "version": 1 } diff --git a/prefs.js b/prefs.js index bf0eba5..80fa686 100644 --- a/prefs.js +++ b/prefs.js @@ -1,459 +1,399 @@ -const { GObject, GLib, Gio, Adw, Gtk, Gdk } = imports.gi; +// prefs.js - GNOME 46 programmatic UI (fixed off-by-one) +'use strict'; -const ExtensionUtils = imports.misc.extensionUtils; -const Me = ExtensionUtils.getCurrentExtension(); +import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; +import GObject from 'gi://GObject'; +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; +import Adw from 'gi://Adw'; +import Gtk from 'gi://Gtk'; +import Gdk from 'gi://Gdk'; -const SETTINGS_ID = "org.gnome.shell.extensions.focus-window"; -const SETTINGS_KEY = "app-settings"; -const SETTINGS_VARIANT = "aa{sv}"; +const SETTINGS_ID = 'org.gnome.shell.extensions.focus-window'; +const SETTINGS_KEY = 'app-settings'; +const SETTINGS_VARIANT = 'aa{sv}'; -// Helper Functions - -// creates a simple unique ID based on date function createId() { - return Date.now().toString(36) + Math.random().toString(36).substring(2); + return Date.now().toString(36) + Math.random().toString(36).substring(2); } -// converts to GVariant function convertToVariant(arr) { - return arr.map((obj) => - Object.keys(obj).reduce((acc, key) => { - if (typeof obj[key] === "string") - acc[key] = new GLib.Variant("s", obj[key]); - if (typeof obj[key] === "boolean") - acc[key] = new GLib.Variant("b", obj[key]); - if (typeof obj[key] === "number") - acc[key] = new GLib.Variant("u", obj[key]); - return acc; - }, {}) - ); + return arr.map((obj) => + Object.keys(obj).reduce((acc, key) => { + if (typeof obj[key] === 'string') + acc[key] = new GLib.Variant('s', obj[key]); + if (typeof obj[key] === 'boolean') + acc[key] = new GLib.Variant('b', obj[key]); + if (typeof obj[key] === 'number') + acc[key] = new GLib.Variant('u', obj[key]); + return acc; + }, {}) + ); } function generateSettings(settings) { - // gets all the settings - const getAllSettings = () => - settings.get_value(SETTINGS_KEY).recursiveUnpack(); + const getAllSettings = () => + settings.get_value(SETTINGS_KEY).recursiveUnpack(); - // sets all the settings - const setAllSettings = (data) => { - settings.set_value(SETTINGS_KEY, new GLib.Variant(SETTINGS_VARIANT, data)); - settings.apply(); - }; + const setAllSettings = (data) => { + settings.set_value( + SETTINGS_KEY, + new GLib.Variant(SETTINGS_VARIANT, data) + ); + settings.apply(); + }; - // gets a specific setting based on its id - const getSettings = (id) => () => getAllSettings().find((s) => s.id === id); + const getSettings = (id) => () => getAllSettings().find((s) => s.id === id); - // sets a setting based on its id - const setSettings = (id) => (data) => { - const oldSettings = getAllSettings(); - const curSettings = getSettings(id)(); + const setSettings = (id) => (data) => { + const oldSettings = getAllSettings(); + const curSettings = getSettings(id)(); - let newSettings; + let newSettings; - // if it already exists, replace it - if (curSettings !== undefined && data) { - newSettings = oldSettings.map((item) => (item.id === id ? data : item)); - } + if (curSettings !== undefined && data) { + newSettings = oldSettings.map((item) => + item.id === id ? data : item + ); + } - // if it already exists but is empty, remove it - if (curSettings !== undefined && !data) { - newSettings = oldSettings.filter((item) => item.id !== id); - } + if (curSettings !== undefined && !data) { + newSettings = oldSettings.filter((item) => item.id !== id); + } - // if it doesn't exist, add it - if (!curSettings && data) { - newSettings = [...oldSettings, data]; - } + if (!curSettings && data) { + newSettings = [...oldSettings, data]; + } - // if it doesn't exist and is empty, use the previous settings - if (!curSettings && !data) { - newSettings = oldSettings; - } + if (!curSettings && !data) { + newSettings = oldSettings; + } - // should never run but just in case, set it to something - if (newSettings === undefined) newSettings = oldSettings; + if (newSettings === undefined) newSettings = oldSettings; - // if it is empty, reset the settings - if (newSettings.length === 0) return settings.reset(SETTINGS_KEY); + if (newSettings.length === 0) return settings.reset(SETTINGS_KEY); - // convert to appropriate variants prior to applying new settings - return setAllSettings(convertToVariant(newSettings)); - }; + return setAllSettings(convertToVariant(newSettings)); + }; - return { - getAllSettings, - setAllSettings, - getSettings, - setSettings, - }; + return { + getAllSettings, + setAllSettings, + getSettings, + setSettings, + }; } -// Begin Preference Creation - -function init() {} - -const FocusWidget = GObject.registerClass( - { - GTypeName: "FocusWidget", - Template: Me.dir.get_child("prefs.ui").get_uri(), - InternalChildren: [ - "application_to_focus", - "application_list", - "title_to_match", - "exact_title_match", - "launch_application", - "command_line_arguments", - "keyboard_shortcut_row", - "keyboard_shortcut", - ], - }, - class FocusWidget extends Adw.ExpanderRow { - constructor( - adwPreferences = {}, - setSettings = () => {}, - onDelete = () => {}, - id = createId(), - settings = { - id, - applicationToFocus: "", - titleToMatch: "", - exactTitleMatch: false, - launchApplication: true, - commandLineArguments: "", - keyboardShortcut: "", - }, - setOnNew = true - ) { - super(adwPreferences); - - this.setSettings = setSettings; - this.onDelete = onDelete; - this.settings = settings; - - // remap all widgets - this.applicationList = this._application_list; - this.applicationToFocus = this._application_to_focus; - this.titleToMatch = this._title_to_match; - this.exactTitleMatch = this._exact_title_match; - this.launchApplication = this._launch_application; - this.commandLineArguments = this._command_line_arguments; - this.keyboardShortcutRow = this._keyboard_shortcut_row; - this.keyboardShortcut = this._keyboard_shortcut; - - // used for shortcuts - this.keyboardIsGrabbed = false; - this.lastAccelerator = ""; - - if (setOnNew) setSettings(this.settings); - - this.populateApplications(); - this.addDeleteButton(); - this.createShortcutListener(); - this.updateTitleAndSubtitle(); - - // set all values based on the widget settings - this.applicationToFocus.set_selected( - this.getAppPositionFromId(this.settings.applicationToFocus) - ); - this.titleToMatch.set_text(this.settings.titleToMatch); - this.exactTitleMatch.set_active(this.settings.exactTitleMatch); - this.launchApplication.set_active(this.settings.launchApplication); - this.commandLineArguments.set_text(this.settings.commandLineArguments); - this.keyboardShortcut.set_accelerator(this.settings.keyboardShortcut); - } +function createFocusRow(setSettings, onDelete, id, initialSettings, setOnNew) { + const settings = { ...initialSettings, id }; + if (setOnNew) setSettings(settings); - populateApplications() { - // get all possible applications to choose from - this.allApplications = Gio.AppInfo.get_all() - .filter((a) => a.should_show()) - .sort((a, b) => a.get_name().localeCompare(b.get_name())) - .map((a, index) => ({ - name: a.get_name(), - id: a.get_id(), - position: index + 1, - })); - - // make them choosable - this.allApplications.forEach((a) => this.applicationList.append(a.name)); - } + const row = new Adw.ExpanderRow({ title: 'Application Not Selected', subtitle: 'Not Bound' }); - addDeleteButton() { - const button = new Gtk.Button({ - has_frame: false, - valign: Gtk.Align.CENTER, - }); - const buttonContent = new Adw.ButtonContent({ - "icon-name": "app-remove-symbolic", - label: "", - }); - buttonContent.add_css_class("error"); - button.set_child(buttonContent); - this.add_action(button); - - button.connect("clicked", () => this.onApplicationDelete()); - } + // Get all applications + const allApps = Gio.AppInfo.get_all() + .filter(a => a.should_show()) + .sort((a, b) => a.get_name().localeCompare(b.get_name())); - // get the position of the app in the list based on its ID - getAppPositionFromId(id) { - const search = this.allApplications.find((a) => a.id === id); - if (search && search.position) return search.position; - return 0; - } + // Create app list with a placeholder at index 0 + const appList = new Gtk.StringList(); + appList.append('No App Selected'); + allApps.forEach(a => appList.append(a.get_name())); - // get the name of the app in the list based on its ID - getAppNameFromId(id) { - const search = this.allApplications.find((a) => a.id === id); - if (search && search.name) return search.name; - return "Application Not Selected"; - } + // Map real apps to indices (1-based) + const appMap = new Map(); + allApps.forEach((a, i) => appMap.set(i + 1, { id: a.get_id(), name: a.get_name() })); - // get the app ID based on its position in the list - getAppIdFromPosition(position) { - const search = this.allApplications.find((a) => a.position === position); - if (search && search.id) return search.id; - return ""; - } + const appCombo = new Adw.ComboRow({ + title: 'Application to Focus', + subtitle: 'The application that should be focused', + model: appList, + }); - createShortcutListener() { - // create controller to listen for shortcut input - const keyController = new Gtk.EventControllerKey(); - keyController.connect("key-pressed", (c, key, keycode, state) => { - if (this.keyboardIsGrabbed) { - const mods = state & Gtk.accelerator_get_default_mod_mask(); - - // Adapted from: https://github.com/Schneegans/Fly-Pie - if (key === Gdk.KEY_Escape) { - this.cancelKeyboardGrab(); - } else if (key === Gdk.KEY_BackSpace) { - this.lastAccelerator = ""; - this.onKeyboardShortcutSelect(""); - this.cancelKeyboardGrab(); - } else if ( - Gtk.accelerator_valid(key, mods) || - key === Gdk.KEY_Tab || - key === Gdk.KEY_ISO_Left_Tab || - key === Gdk.KEY_KP_Tab - ) { - const accelerator = Gtk.accelerator_name(key, mods); - this.onKeyboardShortcutSelect(accelerator); - this.lastAccelerator = accelerator; - this.cancelKeyboardGrab(); - } - - return true; + // Title Entry + const titleEntry = new Gtk.Entry({ + placeholder_text: 'Window Title', + valign: Gtk.Align.CENTER, + }); + const titleRow = new Adw.ActionRow({ + title: 'Title to Match', + subtitle: 'An optional title to filter application windows', + activatable_widget: titleEntry, + }); + titleRow.add_suffix(titleEntry); + titleEntry.set_hexpand(true); + + // Exact Title Switch + const exactSwitch = new Gtk.Switch({ valign: Gtk.Align.CENTER, active: false }); + const exactRow = new Adw.ActionRow({ + title: 'Exact Title Match', + subtitle: 'Toggle this on if an exact title match is desired', + activatable_widget: exactSwitch, + }); + exactRow.add_suffix(exactSwitch); + + // Launch Application Switch + const launchSwitch = new Gtk.Switch({ valign: Gtk.Align.CENTER, active: true }); + const launchRow = new Adw.ActionRow({ + title: 'Launch Application', + subtitle: 'Toggle this on if the application should be launched when no windows are found', + activatable_widget: launchSwitch, + }); + launchRow.add_suffix(launchSwitch); + + // Keyboard Shortcut + const shortcutLabel = new Gtk.ShortcutLabel({ + valign: Gtk.Align.CENTER, + disabled_text: 'Not Bound', + accelerator: '', + }); + const shortcutRow = new Adw.ActionRow({ + title: 'Keyboard Shortcut', + subtitle: 'The keyboard shortcut that focuses the application.\nPress Esc to cancel or Backspace to unbind the shortcut.', + activatable_widget: shortcutLabel, + }); + shortcutRow.add_suffix(shortcutLabel); + + // Add all children to the expander row + row.add_row(appCombo); + row.add_row(titleRow); + row.add_row(exactRow); + row.add_row(launchRow); + row.add_row(shortcutRow); + + // Delete button + const deleteButton = new Gtk.Button({ + has_frame: false, + valign: Gtk.Align.CENTER, + child: new Adw.ButtonContent({ + 'icon-name': 'app-remove-symbolic', + label: '', + }), + }); + deleteButton.get_child().add_css_class('error'); + row.add_action(deleteButton); + + // State + let keyboardIsGrabbed = false; + let lastAccelerator = ''; + + // Set initial values + const savedAppId = settings.applicationToFocus; + const savedAppIndex = savedAppId ? allApps.findIndex(a => a.get_id() === savedAppId) : -1; + // savedAppIndex is 0-based among real apps; add 1 to skip placeholder + appCombo.set_selected(savedAppIndex !== -1 ? savedAppIndex + 1 : 0); + + titleEntry.set_text(settings.titleToMatch || ''); + exactSwitch.set_active(!!settings.exactTitleMatch); + launchSwitch.set_active(settings.launchApplication !== false); + shortcutLabel.set_accelerator(settings.keyboardShortcut || ''); + + // Update row title/subtitle based on current settings + function updateRowDisplay() { + const appId = settings.applicationToFocus; + const app = allApps.find(a => a.get_id() === appId); + row.set_title(app ? app.get_name() : 'Application Not Selected'); + row.set_subtitle(settings.keyboardShortcut || 'Not Bound'); + if (app && settings.keyboardShortcut) { + row.remove_css_class('warning'); + } else { + row.add_css_class('warning'); } - - return false; - }); - this.keyboardShortcutRow.add_controller(keyController); - - // create controller to listen for an unfocus event to stop listening - const focusController = new Gtk.EventControllerFocus(); - focusController.connect("leave", () => { - this.cancelKeyboardGrab(); - }); - this.keyboardShortcutRow.add_controller(focusController); } - // ensures all key combinations are passed by the widget - grabKeyboard() { - this.keyboardShortcut - .get_root() - .get_surface() - .inhibit_system_shortcuts(null); - this.keyboardIsGrabbed = true; - this.lastAccelerator = this.keyboardShortcut.get_accelerator(); - this.keyboardShortcut.set_accelerator(""); - this.keyboardShortcut.set_disabled_text("Listening For Shortcut..."); + function saveSettings() { + setSettings(settings); + updateRowDisplay(); } - // stops the widget from listening to shortcuts - cancelKeyboardGrab() { - this.keyboardShortcut.get_root().get_surface().restore_system_shortcuts(); - this.keyboardIsGrabbed = false; - this.keyboardShortcut.set_accelerator(this.lastAccelerator); - this.keyboardShortcutRow.parent.unselect_all(); - this.keyboardShortcut.set_disabled_text("Not Bound"); - } - - // begins listen for keyboard shortcut - // bound by signal in UI - onKeyboardShortcutClicked() { - if (this.keyboardIsGrabbed) { - this.cancelKeyboardGrab(); - } else { - this.grabKeyboard(); - } - } - - // saves keyboard shortcut - onKeyboardShortcutSelect(accelerator) { - this.settings.keyboardShortcut = accelerator; - this.saveSettings(); - } - - // saves selected application - // bound by signal in UI - onApplicationSelected(row) { - const position = row.get_selected(); - this.settings.applicationToFocus = this.getAppIdFromPosition(position); - this.saveSettings(); - } - - // saves written title - // bound by signal in UI - onTitleChanged(entry) { - const text = entry.get_text(); - this.settings.titleToMatch = text || ""; - this.saveSettings(); - } - - // saves exact title state - // bound by signal in UI - onExactTitleToggled(swtch) { - const active = swtch.get_active(); - this.settings.exactTitleMatch = !!active; - this.saveSettings(); - } - - // saves launch application state - // bound by signal in UI - onLaunchApplicationToggled(swtch) { - const active = swtch.get_active(); - this.settings.launchApplication = !!active; - this.saveSettings(); + // Keyboard shortcut handling + const keyController = new Gtk.EventControllerKey(); + keyController.connect('key-pressed', (c, key, keycode, state) => { + if (keyboardIsGrabbed) { + const mods = state & Gtk.accelerator_get_default_mod_mask(); + + if (key === Gdk.KEY_Escape) { + cancelKeyboardGrab(); + } else if (key === Gdk.KEY_BackSpace) { + lastAccelerator = ''; + settings.keyboardShortcut = ''; + shortcutLabel.set_accelerator(''); + saveSettings(); + cancelKeyboardGrab(); + } else if ( + Gtk.accelerator_valid(key, mods) || + key === Gdk.KEY_Tab || + key === Gdk.KEY_ISO_Left_Tab || + key === Gdk.KEY_KP_Tab + ) { + const accelerator = Gtk.accelerator_name(key, mods); + lastAccelerator = accelerator; + settings.keyboardShortcut = accelerator; + shortcutLabel.set_accelerator(accelerator); + saveSettings(); + cancelKeyboardGrab(); + } + return true; + } + return false; + }); + shortcutRow.add_controller(keyController); + + const focusController = new Gtk.EventControllerFocus(); + focusController.connect('leave', () => { + cancelKeyboardGrab(); + }); + shortcutRow.add_controller(focusController); + + function grabKeyboard() { + shortcutLabel.get_root().get_surface().inhibit_system_shortcuts(null); + keyboardIsGrabbed = true; + lastAccelerator = shortcutLabel.get_accelerator(); + shortcutLabel.set_accelerator(''); + shortcutLabel.set_disabled_text('Listening For Shortcut...'); } - // saves written command lines - // bound by signal in UI - onCommandLineArgumentsChanged(entry) { - const text = entry.get_text(); - this.settings.commandLineArguments = text || ""; - this.saveSettings(); + function cancelKeyboardGrab() { + shortcutLabel.get_root().get_surface().restore_system_shortcuts(); + keyboardIsGrabbed = false; + shortcutLabel.set_accelerator(lastAccelerator); + shortcutLabel.set_disabled_text('Not Bound'); + shortcutRow.get_parent()?.unselect_all(); } - // escape string - htmlEntities(str) { - return String(str) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); - } + // Signal connections + appCombo.connect('notify::selected', () => { + const pos = appCombo.get_selected(); + if (pos === 0) { + settings.applicationToFocus = ''; + } else { + const app = appMap.get(pos); + settings.applicationToFocus = app ? app.id : ''; + } + saveSettings(); + }); + + titleEntry.connect('notify::text', () => { + settings.titleToMatch = titleEntry.get_text() || ''; + saveSettings(); + }); + + exactSwitch.connect('notify::active', () => { + settings.exactTitleMatch = exactSwitch.get_active(); + saveSettings(); + }); + + launchSwitch.connect('notify::active', () => { + settings.launchApplication = launchSwitch.get_active(); + saveSettings(); + }); + + shortcutRow.connect('activated', () => { + if (keyboardIsGrabbed) { + cancelKeyboardGrab(); + } else { + grabKeyboard(); + } + }); - updateTitleAndSubtitle() { - this.set_title(this.getAppNameFromId(this.settings.applicationToFocus)); - this.set_subtitle( - this.htmlEntities(this.settings.keyboardShortcut || "Not Bound") - ); - - const appPosition = this.getAppPositionFromId( - this.settings.applicationToFocus - ); - if (appPosition && this.settings.keyboardShortcut) { - this.remove_css_class("warning"); - } else { - this.add_css_class("warning"); - } - } + deleteButton.connect('clicked', () => { + setSettings(); // remove from settings + onDelete(); + }); - // saves the widget settings and make updates where need be - saveSettings() { - this.setSettings(this.settings); - this.updateTitleAndSubtitle(); - } + updateRowDisplay(); + return row; +} - // will remove widget and call the onDelete callback when deleted - // bound by signal in UI - onApplicationDelete() { - this.setSettings(); - this.onDelete(); +export default class FocusWindowPreferences extends ExtensionPreferences { + fillPreferencesWindow(window) { + const focusWidgets = []; + + const settings = this.getSettings(SETTINGS_ID); + const { getAllSettings, setSettings } = generateSettings(settings); + + const page = new Adw.PreferencesPage(); + const group = new Adw.PreferencesGroup(); + page.add(group); + window.add(page); + window.set_margin_bottom(10); + window.set_margin_top(10); + window.set_margin_start(5); + window.set_margin_end(5); + + const addButton = new Gtk.Button({ valign: Gtk.Align.CENTER }); + const addContent = new Adw.ButtonContent({ + 'icon-name': 'list-add-symbolic', + label: 'Add Application', + }); + addButton.add_css_class('suggested-action'); + group.set_header_suffix(addButton); + addButton.set_child(addContent); + + const setTitleAndDescription = () => { + group.set_title( + `${focusWidgets.length} Shortcut${ + focusWidgets.length === 1 ? '' : 's Created' + }` + ); + + const configured = focusWidgets.filter( + (item) => + item.settings?.applicationToFocus && + item.settings?.keyboardShortcut + ).length; + + group.set_description( + `${ + configured === focusWidgets.length ? 'All' : configured + } of which are fully configured` + ); + }; + + const onDelete = (id) => () => { + const index = focusWidgets.findIndex((i) => i.id === id); + if (index < 0) return; + + group.remove(focusWidgets[index].row); + focusWidgets.splice(index, 1); + setTitleAndDescription(); + }; + + getAllSettings().forEach((savedSettings) => { + // Only create row if it has an ID (should always be true, but safety) + if (!savedSettings.id) return; + const row = createFocusRow( + setSettings(savedSettings.id), + onDelete(savedSettings.id), + savedSettings.id, + savedSettings, + false + ); + focusWidgets.push({ id: savedSettings.id, row, settings: savedSettings }); + group.add(row); + }); + + setTitleAndDescription(); + + addButton.connect('clicked', () => { + const id = createId(); + const row = createFocusRow( + setSettings(id), + onDelete(id), + id, + { + id, + applicationToFocus: '', + titleToMatch: '', + exactTitleMatch: false, + launchApplication: true, + keyboardShortcut: '', + }, + true + ); + focusWidgets.push({ id, row, settings: { id } }); + group.add(row); + setTitleAndDescription(); + }); } - } -); - -function fillPreferencesWindow(window) { - // stores all focusWidgets - const focusWidgets = []; - - // get settings - const extensionSettings = ExtensionUtils.getSettings(SETTINGS_ID); - const { getAllSettings, setSettings } = generateSettings(extensionSettings); - - // create preference pages - const page = new Adw.PreferencesPage(); - const group = new Adw.PreferencesGroup(); - page.add(group); - window.add(page); - window.set_margin_bottom(10); - window.set_margin_top(10); - window.set_margin_start(5); - window.set_margin_end(5); - - // generate 'Add Application' button - const button = new Gtk.Button({ valign: Gtk.Align.CENTER }); - const buttonContent = new Adw.ButtonContent({ - "icon-name": "list-add-symbolic", - label: "Add Application", - }); - button.add_css_class("suggested-action"); - group.set_header_suffix(button); - button.set_child(buttonContent); - - // sets the title and description of group - const setTitleAndDescription = () => { - group.set_title( - `${focusWidgets.length} Shortcut${ - focusWidgets.length === 1 ? "" : "s Created" - }` - ); - - const configured = focusWidgets.filter( - (item) => - item.widget.settings && - item.widget.settings.applicationToFocus && - item.widget.settings.keyboardShortcut - ).length; - - group.set_description( - `${ - configured === focusWidgets.length ? "All" : configured - } of which are fully configured` - ); - }; - - // removes focus widget from page and memory, updates count label - const onDelete = (id) => () => { - const index = focusWidgets.findIndex((i) => i.id === id); - if (index < 0) return; - - group.remove(focusWidgets[index].widget); - focusWidgets.splice(index, 1); - setTitleAndDescription(); - }; - - // add focus widgets from settings - getAllSettings().forEach((settings) => { - const newWidget = new FocusWidget( - { "margin-top": 0 }, - setSettings(settings.id), - onDelete(settings.id), - settings.id, - settings, - false - ); - focusWidgets.push({ id: settings.id, widget: newWidget }); - group.add(newWidget); - }); - - setTitleAndDescription(); - - // add focus widgets when 'Add Application' button is clicked - button.connect("clicked", () => { - const id = createId(); - const newWidget = new FocusWidget({}, setSettings(id), onDelete(id), id); - focusWidgets.push({ id, widget: newWidget }); - group.add(newWidget); - setTitleAndDescription(); - }); }