// Documentation: https://github.com/Rayzeq/libpanel/wiki // Useful links: // - Drag & Drop example: https://gitlab.com/justperfection.channel/how-to-create-a-gnome-shell-extension/-/blob/master/example11%40example11.com/extension.js import Clutter from 'gi://Clutter'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import St from 'gi://St'; import { BoxPointer } from 'resource:///org/gnome/shell/ui/boxpointer.js'; import * as DND from 'resource:///org/gnome/shell/ui/dnd.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import { PopupMenu } from 'resource:///org/gnome/shell/ui/popupMenu.js'; import { QuickSettingsMenu } from 'resource:///org/gnome/shell/ui/quickSettings.js'; import { Patcher } from './patcher.js'; import { add_named_connections, array_insert, array_remove, find_panel, get_extension_uuid, get_settings, rsplit, set_style, split } from './utils.js'; const MenuManager = Main.panel.menuManager; const QuickSettings = Main.panel.statusArea.quickSettings; const QuickSettingsLayout = QuickSettings.menu._grid.layout_manager.constructor; const VERSION = 1; // The spacing between elements of the grid, in pixels. const GRID_SPACING = 5; function registerClass(metadata, klass) { if (klass === undefined) { klass = metadata; metadata = {}; } metadata.GTypeName = `${metadata.GTypeName || `LibPanel_${klass.name}`}_${get_extension_uuid().replace(/[^A-Za-z_-]/g, '-')}`; return GObject.registerClass(metadata, klass); } const AutoHidable = superclass => { // We need to cache the created classes or else we would register the same class name multiple times if (AutoHidable.cache === undefined) AutoHidable.cache = {}; if (AutoHidable.cache[superclass.name] !== undefined) return AutoHidable.cache[superclass.name]; const klass = registerClass({ GTypeName: `LibPanel_AutoHidable_${superclass.name}`, }, class extends superclass { constructor(...args) { const container = args.at(-1).container; delete args.at(-1).container; super(...args); // We need to accept `null` as valid value here // which is why we don't do `container || this` this.container = container === undefined ? this : container; } get container() { return this._lpah_container; } set container(value) { if (this._lpah_container !== undefined) this.disconnect_named(this._lpah_container); if (value !== null) { this._lpah_container = value; this.connect_named(this._lpah_container, 'actor-added', (_container, children) => { this.connect_named(children, 'notify::visible', this._update_visibility.bind(this)); this._update_visibility(); }); this.connect_named(this._lpah_container, 'actor-removed', (_container, children) => { this.disconnect_named(children); this._update_visibility(); }); this._update_visibility(); } } _get_ah_children() { return this._lpah_container.get_children(); } _update_visibility() { for (const child of this._get_ah_children()) { if (child.visible) { this.show(); return; } } this.hide(); // Force the widget to take no space when hidden (this fixes some bugs but I don't know why) this.queue_relayout(); } }); AutoHidable.cache[superclass.name] = klass; return klass; }; const Semitransparent = superclass => { // We need to cache the created classes or else we would register the same class name multiple times if (Semitransparent.cache === undefined) Semitransparent.cache = {}; if (Semitransparent.cache[superclass.name] !== undefined) return Semitransparent.cache[superclass.name]; const klass = registerClass({ GTypeName: `LibPanel_Semitransparent_${superclass.name}`, Properties: { 'transparent': GObject.ParamSpec.boolean( 'transparent', 'Transparent', 'Whether this widget is transparent to pointer events', GObject.ParamFlags.READWRITE, true ), }, }, class extends superclass { get transparent() { if (this._transparent === undefined) this._transparent = true; return this._transparent; } set transparent(value) { this._transparent = value; this.notify('transparent'); } vfunc_pick(context) { if (!this.transparent) { super.vfunc_pick(context); } for (const child of this.get_children()) { child.pick(context); } } }); Semitransparent.cache[superclass.name] = klass; return klass; }; const GridItem = superclass => { // We need to cache the created classes or else we would register the same class name multiple times if (GridItem.cache === undefined) GridItem.cache = {}; if (GridItem.cache[superclass.name] !== undefined) return GridItem.cache[superclass.name]; const klass = registerClass({ GTypeName: `LibPanel_GridItem_${superclass.name}`, Properties: { 'draggable': GObject.ParamSpec.boolean( 'draggable', 'draggable', 'Whether this widget can be dragged', GObject.ParamFlags.READWRITE, true ), }, }, class extends superclass { constructor(panel_name, ...args) { super(...args); this.is_grid_item = true; this.panel_name = panel_name; this._drag_handle = DND.makeDraggable(this); this.connect_named(this._drag_handle, 'drag-begin', () => { QuickSettings.menu.transparent = false; // Prevent the first column from disapearing if it only contains `this` const column = this.get_parent(); this._source_column = column; if (column.get_next_sibling() === null && column.get_children().length === 1) { column._width_constraint.source = this; column._inhibit_constraint_update = true; } this._dnd_placeholder?.destroy(); this._dnd_placeholder = new DropZone(this); this._drag_monitor = { dragMotion: this._on_drag_motion.bind(this), }; DND.addDragMonitor(this._drag_monitor); this._drag_orig_index = this.get_parent().get_children().indexOf(this); // dirty fix for Catppuccin theme (because it relys on CSS inheriting) // this may not work with custom grid items this.add_style_class_name?.("popup-menu"); }); // This is emited BEFORE drag-end, which means that this._dnd_placeholder is still available this.connect_named(this._drag_handle, 'drag-cancelled', () => { // This stop the dnd system from doing anything with `this`, we want to manage ourselves what to do. this._drag_handle._dragState = DND.DragState.CANCELLED; if (this._dnd_placeholder.get_parent() !== null) { this._dnd_placeholder.acceptDrop(this); } else { // We manually reset the position of the panel because the dnd system will set it at the end of the column this.get_parent().remove_child(this); this._drag_handle._dragOrigParent.insert_child_at_index(this, this._drag_orig_index); } }); // This is called when the drag ends with a drop and when it's cancelled this.connect_named(this._drag_handle, 'drag-end', (_drag_handle, _time, _cancelled) => { QuickSettings.menu.transparent = true; if (this._drag_monitor !== undefined) { DND.removeDragMonitor(this._drag_monitor); this._drag_monitor = undefined; } this._dnd_placeholder?.destroy(); this._dnd_placeholder = null; const column = this._source_column; if (!column._is_destroyed && column._width_constraint.source == this) { column._width_constraint.source = column.get_next_sibling(); column._inhibit_constraint_update = false; } // Something, somewhere is setting a forced width & height for this actor, // so we undo that this.width = -1; this.height = -1; this.remove_style_class_name?.("popup-menu"); }); this.connect_named(this, 'destroy', () => { if (this._drag_monitor !== undefined) { DND.removeDragMonitor(this._drag_monitor); this._drag_monitor = undefined; } }); } get draggable() { return this._drag_handle._disabled || false; } set draggable(value) { this._drag_handle._disabled = value; this.notify('draggable'); } _on_drag_motion(event) { if (event.source !== this) return DND.DragMotionResult.CONTINUE; if (event.targetActor === this._dnd_placeholder) return DND.DragMotionResult.COPY_DROP; const panel = find_panel(event.targetActor); const previous_sibling = panel?.get_previous_sibling(); const target_pos = panel?.get_transformed_position(); const self_size = this.get_transformed_size(); this._dnd_placeholder.get_parent()?.remove_child(this._dnd_placeholder); if (event.targetActor.is_panel_column) { event.targetActor.add_child(this._dnd_placeholder); } else if (panel !== undefined) { const column = panel.get_parent(); if (previous_sibling === this._dnd_placeholder || event.y > (target_pos[1] + self_size[1])) { column.insert_child_above(this._dnd_placeholder, panel); } else { column.insert_child_below(this._dnd_placeholder, panel); } } return DND.DragMotionResult.NO_DROP; } }); GridItem.cache[superclass.name] = klass; return klass; }; const DropZone = registerClass(class LibPanel_DropZone extends St.Widget { constructor(source) { super({ style_class: source._drag_actor?.style_class || source.style_class, opacity: 127 }); this._delegate = this; this._height_constraint = new Clutter.BindConstraint({ coordinate: Clutter.BindCoordinate.WIDTH, source: source, }); this._width_constraint = new Clutter.BindConstraint({ coordinate: Clutter.BindCoordinate.HEIGHT, source: source, }); this.add_constraint(this._height_constraint); this.add_constraint(this._width_constraint); } acceptDrop(source, _actor, _x, _y, _time) { if (!source.is_grid_item) return false; source.get_parent().remove_child(source); const column = this.get_parent(); column.replace_child(this, source); column.get_parent()._delegate._cleanup(); LibPanel.get_instance()._save_layout(); return true; } }); class PanelGrid extends PopupMenu { constructor(sourceActor) { super(sourceActor, 0, St.Side.TOP); // ==== We replace the BoxPointer with our own because we want to make it transparent ==== global.focus_manager.remove_group(this._boxPointer); this._boxPointer.bin.set_child(null); // prevent `this.box` from being destroyed this._boxPointer.destroy(); // The majority of this code has been copied from here: // https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/js/ui/popupMenu.js#L801 // We want to make the actor transparent this._boxPointer = new (Semitransparent(BoxPointer))(this._arrowSide); this.actor = this._boxPointer; this.actor._delegate = this; this.actor.style_class = 'popup-menu-boxpointer'; // Force the popup to take all the screen to allow drag and drop to empty spaces this.actor.connect_after('parent-set', () => { if (this._height_constraint) this.actor.remove_constraint(this._height_constraint); const parent = this.actor.get_parent(); if (parent === null) { this._height_constraint = undefined; } else { this._height_constraint = new Clutter.BindConstraint({ coordinate: Clutter.BindCoordinate.HEIGHT, source: parent, }); this.actor.add_constraint(this._height_constraint); } }); // And manually add the bottom margin. This is useless as the grid is invisible, // but in case something make it visible it looks nice this.actor.connect_after('stage-views-changed', () => { if (this.actor.get_stage() === null || this._height_constraint === undefined) return; this._height_constraint.offset = -this.actor.getArrowHeight(); }); this._boxPointer.bin.set_child(this.box); this.actor.add_style_class_name('popup-menu'); this.actor.add_style_class_name('QSAP-panel-grid'); global.focus_manager.add_group(this.actor); this.actor.reactive = true; // ======================================================================================= this.box._delegate = this; // this used so columns can get `this` using `column.get_parent()._delegate` this.box.vertical = false; this._panel_style_class = this.box.style_class; // we save the style class that's used to make a nice panel this.box.style_class = ''; // and we remove it so it's invisible this.box.style = `spacing: ${GRID_SPACING}px`; this.actor.connect_after('notify::allocation', () => { // The `setTimeout` fixes the following warning: // Can't update stage views actor ... is on because it needs an allocation. if (this.actor.x > 0) this._timeout_id = setTimeout(() => { this._timeout_id = null; this._add_column(); }, 0); }); this.actor.connect('destroy', () => { if (this._timeout_id) clearTimeout(this._timeout_id); }); } get transparent() { return this.actor.transparent; } set transparent(value) { this.actor.transparent = value; } close(animate) { for (const column of this.box.get_children()) { column._close(animate); } super.close(animate); } _add_panel(panel) { if (this.box.get_children().length === 0) { this._add_column()._add_panel(panel); return; } for (const column of this.box.get_children()) { if (column._panel_layout.indexOf(panel.panel_name) > -1) { column._add_panel(panel); return; } } // Everything here is really approximated because we can't have the allocations boxes at this point // Most notably, `max_height` will be wrong const max_height = this._height_constraint?.source?.height || this.actor.height; let column; for (const children of this.box.get_children().reverse()) { if (this._get_column_height(children) < max_height) { column = children; break; } } if (!column) column = this.box.first_child; if (this._get_column_height(column) > max_height) { column = this._add_column(); } column._add_panel(panel); } _get_column_height(column) { return column.get_children().reduce((acc, widget) => acc + widget.height, 0); } _add_column(layout = []) { const column = new PanelColumn(layout); this.actor.bind_property('transparent', column, 'transparent', GObject.BindingFlags.SYNC_CREATE); this.box.insert_child_at_index(column, 0); return column; } _get_panel_layout() { return this.box.get_children().map(column => column._panel_layout); } _cleanup() { while (this.box.last_child.get_children().length === 0) this.box.last_child.destroy(); } _get_panels() { return this.box.get_children().map(column => column.get_children()).flat(); } } const PanelColumn = registerClass(class LibPanel_PanelColumn extends Semitransparent(St.BoxLayout) { constructor(layout = []) { super({ vertical: true, style: `spacing: ${GRID_SPACING}px` }); this.is_panel_column = true; // since we can't use instanceof, we use this attribute this._panel_layout = layout; this._inhibit_constraint_update = false; this._width_constraint = new Clutter.BindConstraint({ coordinate: Clutter.BindCoordinate.WIDTH, source: null, }); this.add_constraint(this._width_constraint); this.connect_after_named(this, 'actor-added', (_self, actor) => { if (this.get_children().length === 1) this.remove_constraint(this._width_constraint); if (!actor.is_grid_item) return; const prev_index = this._panel_layout.indexOf(actor.get_previous_sibling()?.panel_name); const index = this._panel_layout.indexOf(actor.panel_name); const next_index = this._panel_layout.indexOf(actor.get_next_sibling()?.panel_name); // `actor` is in the layout but is misplaced if (index > -1 && ((prev_index > -1 && index < prev_index) || (next_index > -1 && next_index < index))) { array_remove(this._panel_layout, actor.panel_name); index = -1; } if (index < 0) { // `actor` is not in the layout if (prev_index > -1) array_insert(this._panel_layout, prev_index + 1, actor.panel_name); else if (next_index > 0) array_insert(this._panel_layout, next_index - 1, actor.panel_name); else array_insert(this._panel_layout, 0, actor.panel_name); } }); this.connect_after_named(this, 'actor-removed', (_self, actor) => { if (this.get_children().length === 0) this.add_constraint(this._width_constraint); if (actor._keep_layout || !actor.is_grid_item) return; array_remove(this._panel_layout, actor.panel_name); }); this.connect('destroy', () => this._is_destroyed = true); this.connect_after_named(this, 'parent-set', (_self, old_parent) => { if (old_parent !== null) this.disconnect_named(old_parent); const parent = this.get_parent(); if (parent === null) return; const update_source = (_parent, _actor) => { // clutter is being dumb and emit this signal even though `_parent` and `this` are destroyed // this fix it if (this._is_destroyed || this._inhibit_constraint_update) return; this._width_constraint.source = this.get_next_sibling(); }; this.connect_after_named(parent, 'actor-added', update_source); this.connect_after_named(parent, 'actor-removed', update_source); update_source(); }); } _close(animate) { for (const panel of this.get_children()) { panel._close(animate); } } _add_panel(panel) { const index = this._panel_layout.indexOf(panel.panel_name); if (index > -1) { const panels = this.get_children().map(children => children.panel_name); for (const panel_name of this._panel_layout.slice(0, index).reverse()) { const children_index = panels.indexOf(panel_name); if (children_index > -1) { this.insert_child_at_index(panel, children_index + 1); return; } } this.insert_child_at_index(panel, 0); } else { this.add_child(panel); } } }); export var Panel = registerClass(class LibPanel_Panel extends GridItem(AutoHidable(St.Widget)) { constructor(panel_name, nColumns = 2) { super(`${get_extension_uuid()}/${panel_name}`, { // I have no idea why, but sometimes, a panel (not all of them) gets allocated too much space (behavior similar to `y-expand`) // This prevent it from taking all available space y_align: Clutter.ActorAlign.START, // Enable this so the menu block any click event from propagating through reactive: true, // We want to set this later container: null, }); this._delegate = this; // Overlay layer that will hold sub-popups this._overlay = new Clutter.Actor({ layout_manager: new Clutter.BinLayout() }); // Placeholder to make empty space when opening a sub-popup const placeholder = new Clutter.Actor({ // The placeholder have the same height as the overlay, which means // it have the same height as the opened sub-popup constraints: new Clutter.BindConstraint({ coordinate: Clutter.BindCoordinate.HEIGHT, source: this._overlay, }), }); // The grid holding every element this._grid = new St.Widget({ style_class: LibPanel.get_instance()._panel_grid._panel_style_class + ' quick-settings quick-settings-grid', layout_manager: new QuickSettingsLayout(placeholder, { nColumns }), }); // Force the grid to take up all the available width. I'm using a constraint because x_expand don't work this._grid.add_constraint(new Clutter.BindConstraint({ coordinate: Clutter.BindCoordinate.WIDTH, source: this, })); this.add_child(this._grid); this.container = this._grid; this._drag_actor = this._grid; this._grid.add_child(placeholder); this._dimEffect = new Clutter.BrightnessContrastEffect({ enabled: false }); this._grid.add_effect_with_name('dim', this._dimEffect); this._overlay.add_constraint(new Clutter.BindConstraint({ coordinate: Clutter.BindCoordinate.WIDTH, source: this._grid, })); this.add_child(this._overlay); } getItems() { // Every child except the placeholder return this._grid.get_children().filter(item => item != this._grid.layout_manager._overlay); } getFirstItem() { return this.getItems[0]; } addItem(item, colSpan = 1) { this._grid.add_child(item); this._completeAddItem(item, colSpan); } insertItemBefore(item, sibling, colSpan = 1) { this._grid.insert_child_below(item, sibling); this._completeAddItem(item, colSpan); } _completeAddItem(item, colSpan) { this.setColumnSpan(item, colSpan); if (item.menu) { this._overlay.add_child(item.menu.actor); this.connect_named(item.menu, 'open-state-changed', (_, isOpen) => { this._setDimmed(isOpen); this._activeMenu = isOpen ? item.menu : null; // The sub-popup for the power menu is too high. // I don't know if it's the real source of the issue, but I suspect that the constraint that fixes its y position // isn't accounting for the padding of the grid, so we add it to the offset manually // Later: I added the name check because it breaks on the audio panel // so I'm almost certain that this is not a proper fix if (isOpen && this.getItems().indexOf(item) == 0 && this.panel_name == "gnome@main") { const constraint = item.menu.actor.get_constraints()[0]; constraint.offset = // the offset is normally bound to the height of the source constraint.source.height + this._grid.get_theme_node().get_padding(St.Side.TOP); // note: we don't reset this property when the item is removed from this panel because // we hope that it will reset itself (because it's bound to the height of the source), // which in the case in my tests, but maybe some issue will arise because of this } }); } if (item._menuButton) { item._menuButton._libpanel_y_expand_backup = item._menuButton.y_expand; item._menuButton.y_expand = false; } } removeItem(item) { if (!this._grid.get_children().includes(item)) console.error(`[LibPanel] ${get_extension_uuid()} tried to remove an item not in the panel`); item.get_parent().remove_child(item); if (item.menu) { this.disconnect_named(item.menu); item.menu.actor.get_parent().remove_child(item.menu.actor); } if (item._menuButton) { item._menuButton.y_expand = item._menuButton._libpanel_y_expand_backup; item._menuButton._libpanel_y_expand_backup = undefined; } } getColumnSpan(item) { if (!this._grid.get_children().includes(item)) console.error(`[LibPanel] ${get_extension_uuid()} tried to get the column span of an item not in the panel`); const value = new GObject.Value(); this._grid.layout_manager.child_get_property(this._grid, item, 'column-span', value); const column_span = value.get_int(); value.unset(); return column_span; } setColumnSpan(item, colSpan) { if (!this._grid.get_children().includes(item)) console.error(`[LibPanel] ${get_extension_uuid()} tried to set the column span of an item not in the panel`); this._grid.layout_manager.child_set_property(this._grid, item, 'column-span', colSpan); } _close(animate) { this._activeMenu?.close(animate); } _get_ah_children() { return this.getItems(); } _setDimmed(dim) { // copied from https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/js/ui/quickSettings.js const DIM_BRIGHTNESS = -0.4; const POPUP_ANIMATION_TIME = 400; const val = 127 * (1 + (dim ? 1 : 0) * DIM_BRIGHTNESS); const color = Clutter.Color.new(val, val, val, 255); this._grid.ease_property('@effects.dim.brightness', color, { mode: Clutter.AnimationMode.LINEAR, duration: POPUP_ANIMATION_TIME, onStopped: () => (this._dimEffect.enabled = dim), }); this._dimEffect.enabled = true; } }); // Patching the default to menu to have the exact same api as the one from `Panel`. // This way, extensions can use them the same way. QuickSettingsMenu.prototype.getItems = function () { return this._grid.get_children().filter(item => item != this._grid.layout_manager._overlay); }; QuickSettingsMenu.prototype.removeItem = function (item) { this._grid.remove_child(item); if (item.menu) { // it seems that some menus don't have _signalConnectionsByName (probably custom menus) // we check it exists before using it if (item.menu._signalConnectionsByName) { // Manually remove the connection since we don't have its id. for (const id of item.menu._signalConnectionsByName["open-state-changed"]) { if (item.menu._signalConnections[id].callback.toString().includes("this._setDimmed")) { item.menu.disconnect(id); } } } this._overlay.remove_child(item.menu.actor); } }; QuickSettingsMenu.prototype.getColumnSpan = function (item) { const value = new GObject.Value(); this._grid.layout_manager.child_get_property(this._grid, item, 'column-span', value); const column_span = value.get_int(); value.unset(); return column_span; }; QuickSettingsMenu.prototype.setColumnSpan = function (item, colSpan) { this._grid.layout_manager.child_set_property(this._grid, item, 'column-span', colSpan); }; export class LibPanel { static _AutoHidable = AutoHidable; static _Semitransparent = Semitransparent; static _GridItem = GridItem; static _DropZone = DropZone; static _PanelGrid = PanelGrid; static _PanelColumn = PanelColumn; static get_instance() { return Main.panel._libpanel; } static get VERSION() { return LibPanel.get_instance()?.VERSION || VERSION; } // make the main panel available whether it's the gnome one or the libpanel one static get main_panel() { return LibPanel.get_instance()?._main_panel || QuickSettings.menu; } static get enabled() { return LibPanel.enablers.length !== 0; } static get enablers() { return LibPanel.get_instance()?._enablers || []; } static enable() { let instance = LibPanel.get_instance(); if (!instance) { instance = Main.panel._libpanel = new LibPanel(); instance._enable(); }; if (instance.constructor.VERSION != VERSION) console.warn(`[LibPanel] ${get_extension_uuid()} depends on libpanel ${VERSION} but libpanel ${instance.constructor.VERSION} is loaded`); const uuid = get_extension_uuid(); if (instance._enablers.indexOf(uuid) < 0) instance._enablers.push(uuid); } static disable() { const instance = LibPanel.get_instance(); if (!instance) return; const index = instance._enablers.indexOf(get_extension_uuid()); if (index > -1) instance._enablers.splice(index, 1); if (instance._enablers.length === 0) { instance._disable(); Main.panel._libpanel = undefined; }; } static addPanel(panel) { const instance = LibPanel.get_instance(); if (!instance) console.error(`[LibPanel] ${get_extension_uuid()} tried to add a panel, but the library is disabled.`); if (instance._settings.get_boolean('padding-enabled')) set_style(panel._grid, 'padding', `${instance._settings.get_int('padding')}px`); if (instance._settings.get_boolean('row-spacing-enabled')) set_style(panel._grid, 'spacing-rows', `${instance._settings.get_int('row-spacing')}px`); if (instance._settings.get_boolean('column-spacing-enabled')) set_style(panel._grid, 'spacing-columns', `${instance._settings.get_int('column-spacing')}px`); instance._panel_grid._add_panel(panel); instance._save_layout(); } static removePanel(panel) { panel._keep_layout = true; panel.get_parent()?.remove_child(panel); panel._keep_layout = undefined; } constructor() { this._enablers = []; this._patcher = null; this._settings = null; this._panel_grid = null; this._old_menu = null; } _enable() { const this_path = '/' + split(rsplit(import.meta.url, '/', 1)[0], '/', 3)[3];; this._settings = get_settings(`${this_path}/org.gnome.shell.extensions.libpanel.gschema.xml`); // ======================== Patching ======================== this._patcher = new Patcher(); // Permit disabling widget dragging const _Draggable = DND.makeDraggable(new St.Widget()).constructor; this._patcher.replace_method(_Draggable, function _grabActor(wrapped, device, touchSequence) { if (this._disabled) return; wrapped(device, touchSequence); }); // Add named connections to objects add_named_connections(this._patcher, GObject.Object); // =================== Replacing the popup ================== this._panel_grid = new PanelGrid(QuickSettings); for (const column of this._settings.get_value("layout").recursiveUnpack().reverse()) { this._panel_grid._add_column(column); } this._old_menu = this._replace_menu(this._panel_grid); const new_menu = new Panel('', 2); // we do that to prevent the name being this: `quick-settings-audio-panel@rayzeq.github.io/gnome@main` new_menu.panel_name = 'gnome@main'; this._move_quick_settings(this._old_menu, new_menu); LibPanel.addPanel(new_menu); this._main_panel = new_menu; // =================== Compatibility code =================== //this._panel_grid.box = new_menu.box; // this would override existing properties //this._panel_grid.actor = = new_menu.actor; this._panel_grid._dimEffect = new_menu._dimEffect; this._panel_grid._grid = new_menu._grid; this._panel_grid._overlay = new_menu._overlay; this._panel_grid._setDimmed = new_menu._setDimmed.bind(new_menu); this._panel_grid.getFirstItem = new_menu.getFirstItem.bind(new_menu); this._panel_grid.addItem = new_menu.addItem.bind(new_menu); this._panel_grid.insertItemBefore = new_menu.insertItemBefore.bind(new_menu); this._panel_grid._completeAddItem = new_menu._completeAddItem.bind(new_menu); // ================== Visual customization ================== const set_style_for_panels = (name, value) => { for (const panel of this._panel_grid._get_panels()) { set_style(panel._grid, name, value); } }; this._settings.connect('changed::padding-enabled', () => { if (this._settings.get_boolean('padding-enabled')) set_style_for_panels('padding', `${this._settings.get_int('padding')}px`); else set_style_for_panels('padding', null); }); this._settings.connect('changed::padding', () => { if (!this._settings.get_boolean('padding-enabled')) return; set_style_for_panels('padding', `${this._settings.get_int('padding')}px`); }); this._settings.connect('changed::row-spacing-enabled', () => { if (this._settings.get_boolean('row-spacing-enabled')) set_style_for_panels('spacing-rows', `${this._settings.get_int('row-spacing')}px`); else set_style_for_panels('spacing-rows', null); }); this._settings.connect('changed::row-spacing', () => { if (!this._settings.get_boolean('row-spacing-enabled')) return; set_style_for_panels('spacing-rows', `${this._settings.get_int('row-spacing')}px`); }); this._settings.connect('changed::column-spacing-enabled', () => { if (this._settings.get_boolean('column-spacing-enabled')) set_style_for_panels('spacing-columns', `${this._settings.get_int('column-spacing')}px`); else set_style_for_panels('spacing-columns', null); }); this._settings.connect('changed::column-spacing', () => { if (!this._settings.get_boolean('column-spacing-enabled')) return; set_style_for_panels('spacing-columns', `${this._settings.get_int('column-spacing')}px`); }); // https://gjs-docs.gnome.org/gio20~2.0/gio.settings#signal-changed // "Note that @settings only emits this signal if you have read key at // least once while a signal handler was already connected for key." this._settings.get_boolean('padding-enabled'); this._settings.get_boolean('row-spacing-enabled'); this._settings.get_boolean('column-spacing-enabled'); this._settings.get_int('padding'); this._settings.get_int('row-spacing'); this._settings.get_int('column-spacing'); }; _disable() { this._move_quick_settings(this._main_panel, this._old_menu); this._replace_menu(this._old_menu); this._old_menu = null; this._panel_grid.destroy(); this._panel_grid = null; this._settings = null; this._patcher.unpatch_all(); this._patcher = null; } _replace_menu(new_menu) { const old_menu = QuickSettings.menu; MenuManager.removeMenu(old_menu); Main.layoutManager.disconnectObject(old_menu); QuickSettings.menu = null; // prevent old_menu from being destroyed QuickSettings.setMenu(new_menu); old_menu.actor.get_parent().remove_child(old_menu.actor); MenuManager.addMenu(new_menu); Main.layoutManager.connectObject('system-modal-opened', () => new_menu.close(), new_menu); return old_menu; } _move_quick_settings(old_menu, new_menu) { for (const item of old_menu.getItems()) { const column_span = old_menu.getColumnSpan(item); const visible = item.visible; old_menu.removeItem(item); new_menu.addItem(item, column_span); item.visible = visible; // force reset of visibility } } _save_layout() { const layout = this._panel_grid._get_panel_layout(); // Remove leading empty columns while (layout[0]?.length === 0) layout.shift(); this._settings.set_value( "layout", GLib.Variant.new_array( GLib.VariantType.new('as'), layout.map(column => GLib.Variant.new_strv(column)) ) ); } };