From 9025f29f55dce7648c38ddf55eb0ae8d3b9a69c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20M=C3=BCllner?= Date: Wed, 3 Jun 2026 14:54:16 +0200 Subject: [PATCH] Add window-grouper extension Resolves: https://redhat.atlassian.net/browse/RHEL-193195 --- ...ns-0006-Add-window-grouper-extension.patch | 422 ++++++++++++++++++ gnome-shell-extensions.spec | 16 + 2 files changed, 438 insertions(+) create mode 100644 extra-extensions-0006-Add-window-grouper-extension.patch diff --git a/extra-extensions-0006-Add-window-grouper-extension.patch b/extra-extensions-0006-Add-window-grouper-extension.patch new file mode 100644 index 0000000..4047742 --- /dev/null +++ b/extra-extensions-0006-Add-window-grouper-extension.patch @@ -0,0 +1,422 @@ +From 44302f6c5799172e0fa61b75cc101c68e2f57ece Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Florian=20M=C3=BCllner?= +Date: Tue, 26 Mar 2019 19:44:43 +0100 +Subject: [PATCH] Add window-grouper extension + +--- + extensions/window-grouper/extension.js | 99 ++++++++ + extensions/window-grouper/meson.build | 8 + + extensions/window-grouper/metadata.json.in | 11 + + ...hell.extensions.window-grouper.gschema.xml | 9 + + extensions/window-grouper/prefs.js | 222 ++++++++++++++++++ + extensions/window-grouper/stylesheet.css | 1 + + meson.build | 1 + + 7 files changed, 351 insertions(+) + create mode 100644 extensions/window-grouper/extension.js + create mode 100644 extensions/window-grouper/meson.build + create mode 100644 extensions/window-grouper/metadata.json.in + create mode 100644 extensions/window-grouper/org.gnome.shell.extensions.window-grouper.gschema.xml + create mode 100644 extensions/window-grouper/prefs.js + create mode 100644 extensions/window-grouper/stylesheet.css + +diff --git a/extensions/window-grouper/extension.js b/extensions/window-grouper/extension.js +new file mode 100644 +index 00000000..24bbc132 +--- /dev/null ++++ b/extensions/window-grouper/extension.js +@@ -0,0 +1,99 @@ ++// SPDX-FileCopyrightText: Florian Müllner ++// ++// SPDX-License-Identifier: GPL-2.0-or-later ++ ++import Shell from 'gi://Shell'; ++ ++import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; ++import * as Main from 'resource:///org/gnome/shell/ui/main.js'; ++ ++class WindowMover { ++ constructor(settings) { ++ this._settings = settings; ++ this._appSystem = Shell.AppSystem.get_default(); ++ this._appConfigs = new Set(); ++ this._appData = new Map(); ++ ++ this._appSystem.connectObject('installed-changed', ++ this._updateAppData.bind(this), this); ++ ++ this._settings.connectObject('changed', ++ this._updateAppConfigs.bind(this), this); ++ this._updateAppConfigs(); ++ } ++ ++ _updateAppConfigs() { ++ this._appConfigs.clear(); ++ ++ this._settings.get_strv('application-list').forEach(appId => { ++ this._appConfigs.add(appId); ++ }); ++ ++ this._updateAppData(); ++ } ++ ++ _updateAppData() { ++ let ids = [...this._appConfigs.values()]; ++ let removedApps = [...this._appData.keys()].filter( ++ a => !ids.includes(a.id) ++ ); ++ removedApps.forEach(app => { ++ app.disconnect(this._appData.get(app).windowsChangedId); ++ this._appData.delete(app); ++ }); ++ ++ let addedApps = ids.map(id => this._appSystem.lookup_app(id)).filter( ++ app => app != null && !this._appData.has(app) ++ ); ++ addedApps.forEach(app => { ++ let data = { ++ windows: app.get_windows(), ++ windowsChangedId: app.connect( ++ 'windows-changed', this._appWindowsChanged.bind(this)) ++ }; ++ this._appData.set(app, data); ++ }); ++ } ++ ++ destroy() { ++ this._appSystem.disconnectObject(this); ++ this._appSystem = null; ++ ++ this._settings.disconnectObject(this); ++ this._settings = null; ++ ++ this._appConfigs.clear(); ++ this._updateAppData(); ++ } ++ ++ _appWindowsChanged(app) { ++ let data = this._appData.get(app); ++ let windows = app.get_windows(); ++ ++ // If get_compositor_private() returns non-NULL on a removed windows, ++ // the window still exists and is just moved to a different workspace ++ // or something; assume it'll be added back immediately, so keep it ++ // to avoid moving it again ++ windows.push(...data.windows.filter( ++ w => !windows.includes(w) && w.get_compositor_private() != null ++ )); ++ ++ windows.filter(w => !data.windows.includes(w)).forEach(window => { ++ let leader = data.windows.find(w => w.get_pid() == window.get_pid()); ++ if (leader) ++ window.change_workspace(leader.get_workspace()); ++ }); ++ data.windows = windows; ++ } ++} ++ ++export default class WindowGrouperExtension extends Extension { ++ enable() { ++ this._winMover = new WindowMover(this.getSettings()); ++ } ++ ++ disable() { ++ this._winMover.destroy(); ++ this._winMover = null; ++ } ++} +diff --git a/extensions/window-grouper/meson.build b/extensions/window-grouper/meson.build +new file mode 100644 +index 00000000..c55a7830 +--- /dev/null ++++ b/extensions/window-grouper/meson.build +@@ -0,0 +1,8 @@ ++extension_data += configure_file( ++ input: metadata_name + '.in', ++ output: metadata_name, ++ configuration: metadata_conf ++) ++ ++extension_sources += files('prefs.js') ++extension_schemas += files(metadata_conf.get('gschemaname') + '.gschema.xml') +diff --git a/extensions/window-grouper/metadata.json.in b/extensions/window-grouper/metadata.json.in +new file mode 100644 +index 00000000..aa202c8f +--- /dev/null ++++ b/extensions/window-grouper/metadata.json.in +@@ -0,0 +1,11 @@ ++{ ++ "extension-id": "@extension_id@", ++ "uuid": "@uuid@", ++ "settings-schema": "@gschemaname@", ++ "gettext-domain": "@gettext_domain@", ++ "name": "Window grouper", ++ "description": "Keep windows that belong to the same process on the same workspace.", ++ "shell-version": [ "@shell_current@" ], ++ "original-authors": [ "fmuellner@redhat.com" ], ++ "url": "@url@" ++} +diff --git a/extensions/window-grouper/org.gnome.shell.extensions.window-grouper.gschema.xml b/extensions/window-grouper/org.gnome.shell.extensions.window-grouper.gschema.xml +new file mode 100644 +index 00000000..ee052a6e +--- /dev/null ++++ b/extensions/window-grouper/org.gnome.shell.extensions.window-grouper.gschema.xml +@@ -0,0 +1,9 @@ ++ ++ ++ ++ [ ] ++ Application that should be grouped ++ A list of application ids ++ ++ ++ +diff --git a/extensions/window-grouper/prefs.js b/extensions/window-grouper/prefs.js +new file mode 100644 +index 00000000..2eceeff2 +--- /dev/null ++++ b/extensions/window-grouper/prefs.js +@@ -0,0 +1,222 @@ ++// SPDX-FileCopyrightText: Florian Müllner ++// ++// SPDX-License-Identifier: GPL-2.0-or-later ++ ++import Adw from 'gi://Adw'; ++import Gio from 'gi://Gio'; ++import GLib from 'gi://GLib'; ++import GObject from 'gi://GObject'; ++import Gtk from 'gi://Gtk'; ++ ++import {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; ++ ++const SETTINGS_KEY = 'application-list'; ++ ++class NewItem extends GObject.Object {} ++GObject.registerClass(NewItem); ++ ++class NewItemModel extends GObject.Object { ++ static [GObject.interfaces] = [Gio.ListModel]; ++ static { ++ GObject.registerClass(this); ++ } ++ ++ #item = new NewItem(); ++ ++ vfunc_get_item_type() { ++ return NewItem; ++ } ++ ++ vfunc_get_n_items() { ++ return 1; ++ } ++ ++ vfunc_get_item(_pos) { ++ return this.#item; ++ } ++} ++ ++class AppsList extends GObject.Object { ++ static [GObject.interfaces] = [Gio.ListModel]; ++ static { ++ GObject.registerClass(this); ++ } ++ ++ #settings; ++ #apps = []; ++ #changedId; ++ ++ constructor(settings) { ++ super(); ++ ++ this.#settings = settings; ++ this.#changedId = ++ this.#settings.connect(`changed::${SETTINGS_KEY}`, ++ () => this.#sync()); ++ this.#sync(); ++ } ++ ++ append(appInfo) { ++ const pos = this.#apps.length; ++ ++ this.#apps.push(appInfo); ++ this.#saveApps(); ++ ++ this.items_changed(pos, 0, 1); ++ } ++ ++ remove(id) { ++ const pos = this.#apps.findIndex(a => a.get_id() === id); ++ if (pos < 0) ++ return; ++ ++ this.#apps.splice(pos, 1); ++ this.#saveApps(); ++ ++ this.items_changed(pos, 1, 0); ++ } ++ ++ #saveApps() { ++ this.#settings.block_signal_handler(this.#changedId); ++ this.#settings.set_strv(SETTINGS_KEY, ++ this.#apps.map(a => a.get_id())); ++ this.#settings.unblock_signal_handler(this.#changedId); ++ } ++ ++ #sync() { ++ const removed = this.#apps.length; ++ ++ this.#apps = []; ++ for (const id of this.#settings.get_strv(SETTINGS_KEY)) { ++ const appInfo = Gio.DesktopAppInfo.new(id); ++ if (appInfo) ++ this.#apps.push(appInfo); ++ else ++ console.log(`Invalid ID ${id}`); ++ } ++ this.items_changed(0, removed, this.#apps.length); ++ } ++ ++ vfunc_get_item_type() { ++ return Gio.AppInfo; ++ } ++ ++ vfunc_get_n_items() { ++ return this.#apps.length; ++ } ++ ++ vfunc_get_item(pos) { ++ return this.#apps[pos] ?? null; ++ } ++} ++ ++class WindowGrouperSettingsWidget extends Adw.PreferencesGroup { ++ static { ++ GObject.registerClass(this); ++ ++ this.install_action('apps.add', null, self => self._addNewApp()); ++ this.install_action('apps.remove', 's', ++ (self, name, param) => self._apps.remove(param.unpack())); ++ } ++ ++ constructor(settings) { ++ super({ ++ title: _('Grouped Apps'), ++ }); ++ ++ this._settings = settings; ++ this._apps = new AppsList(this._settings); ++ ++ const store = new Gio.ListStore({item_type: Gio.ListModel}); ++ const listModel = new Gtk.FlattenListModel({model: store}); ++ store.append(this._apps); ++ store.append(new NewItemModel()); ++ ++ this._list = new Gtk.ListBox({ ++ selection_mode: Gtk.SelectionMode.NONE, ++ css_classes: ['boxed-list'], ++ }); ++ this.add(this._list); ++ ++ this._list.bind_model(listModel, item => { ++ return item instanceof NewItem ++ ? new NewAppRow() ++ : new AppRow(item); ++ }); ++ } ++ ++ _addNewApp() { ++ const dialog = new Gtk.AppChooserDialog({ ++ heading: _('Select an application for which grouping should apply'), ++ transient_for: this.get_root(), ++ modal: true ++ }); ++ ++ dialog.get_widget().show_all = true; ++ ++ dialog.connect('response', (dialog, id) => { ++ const appInfo = id === Gtk.ResponseType.OK ++ ? dialog.get_app_info() : null; ++ ++ if (appInfo) ++ this._apps.append(appInfo); ++ dialog.destroy(); ++ }); ++ dialog.show(); ++ } ++} ++ ++class AppRow extends Adw.ActionRow { ++ static { ++ GObject.registerClass(this); ++ } ++ ++ constructor(appInfo) { ++ super({ ++ activatable: false, ++ title: appInfo.get_display_name(), ++ }); ++ ++ const icon = new Gtk.Image({ ++ css_classes: ['icon-dropshadow'], ++ gicon: appInfo.get_icon(), ++ pixel_size: 32, ++ }); ++ this.add_prefix(icon); ++ ++ const button = new Gtk.Button({ ++ action_name: 'apps.remove', ++ action_target: new GLib.Variant('s', appInfo.get_id()), ++ icon_name: 'edit-delete-symbolic', ++ has_frame: false, ++ valign: Gtk.Align.CENTER, ++ }); ++ this.add_suffix(button); ++ } ++} ++ ++const NewAppRow = GObject.registerClass( ++class NewAppRow extends Gtk.ListBoxRow { ++ _init() { ++ super._init({ ++ action_name: 'apps.add', ++ child: new Gtk.Image({ ++ icon_name: 'list-add-symbolic', ++ pixel_size: 16, ++ margin_top: 12, ++ margin_bottom: 12, ++ margin_start: 12, ++ margin_end: 12, ++ }), ++ }); ++ ++ this.update_property( ++ [Gtk.AccessibleProperty.LABEL], [_('Add App')]); ++ } ++}); ++ ++export default class WindowGrouperPrefs extends ExtensionPreferences { ++ getPreferencesWidget() { ++ return new WindowGrouperSettingsWidget(this.getSettings()); ++ } ++} +diff --git a/extensions/window-grouper/stylesheet.css b/extensions/window-grouper/stylesheet.css +new file mode 100644 +index 00000000..25134b65 +--- /dev/null ++++ b/extensions/window-grouper/stylesheet.css +@@ -0,0 +1 @@ ++/* This extensions requires no special styling */ +diff --git a/meson.build b/meson.build +index 29ac83bb..d72a01ae 100644 +--- a/meson.build ++++ b/meson.build +@@ -61,6 +61,7 @@ all_extensions += [ + 'gesture-inhibitor', + 'native-window-placement', + 'user-theme', ++ 'window-grouper', + ] + + enabled_extensions = get_option('enable_extensions') +-- +2.55.0 + diff --git a/gnome-shell-extensions.spec b/gnome-shell-extensions.spec index 52a73b9..79604c7 100644 --- a/gnome-shell-extensions.spec +++ b/gnome-shell-extensions.spec @@ -33,6 +33,7 @@ Patch: extra-extensions-0002-Add-classification-banner.patch Patch: extra-extensions-0003-Add-heads-up-display.patch Patch: extra-extensions-0004-Add-custom-menu-extension.patch Patch: extra-extensions-0005-Add-desktop-icons-extension.patch +Patch: extra-extensions-0006-Add-window-grouper-extension.patch Patch: 0001-Include-status-icons-in-classic-session.patch @@ -63,6 +64,7 @@ Enabled extensions: * status-icons * system-monitor * user-theme + * window-grouper * window-list * windowsNavigator * workspace-indicator @@ -269,6 +271,15 @@ This GNOME Shell extension enables loading a GNOME Shell theme from ~/.themes//gnome-shell/. +%package -n %{pkg_prefix}-window-grouper +Summary: Keep windows that belong to the same process on the same workspace +License: GPLv2+ +Requires: %{pkg_prefix}-common = %{version}-%{release} + +%description -n %{pkg_prefix}-window-grouper +This GNOME Shell extension keeps windows that belong to the same process on the same workspace. + + %package -n %{pkg_prefix}-window-list Summary: Display a window list at the bottom of the screen in GNOME Shell License: GPL-2.0-or-later @@ -408,6 +419,11 @@ rm -rf %{buildroot}/%{_datadir}/xsessions %{_datadir}/gnome-shell/extensions/user-theme*/ +%files -n %{pkg_prefix}-window-grouper +%{_datadir}/gnome-shell/extensions/window-grouper*/ +%{_datadir}/glib-2.0/schemas/org.gnome.shell.extensions.window-grouper.gschema.xml + + %files -n %{pkg_prefix}-window-list %{_datadir}/gnome-shell/extensions/window-list*/ %{_datadir}/glib-2.0/schemas/org.gnome.shell.extensions.window-list.gschema.xml