From 600b9212468d2a74cab2459c31946369c8b3c775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20M=C3=BCllner?= Date: Mon, 8 Aug 2022 12:08:26 +0200 Subject: [PATCH] status/bluetooth: Add device menu The new quick toggle gives us a good place for exposing connected and connectable devices. This was part of the original mockups, but didn't make the cut for GNOME 43 due to time constraints. https://gitlab.gnome.org/Teams/Design/os-mockups/-/issues/178 Part-of: --- .../widgets/_quick-settings.scss | 11 + js/ui/status/bluetooth.js | 190 +++++++++++++++++- 2 files changed, 196 insertions(+), 5 deletions(-) diff --git a/data/theme/gnome-shell-sass/widgets/_quick-settings.scss b/data/theme/gnome-shell-sass/widgets/_quick-settings.scss index 89312b3d0..0064aeed3 100644 --- a/data/theme/gnome-shell-sass/widgets/_quick-settings.scss +++ b/data/theme/gnome-shell-sass/widgets/_quick-settings.scss @@ -137,4 +137,15 @@ .wireless-secure-icon { icon-size: 0.5 * $base_icon_size; } } +.bt-device-item { + .popup-menu-icon { -st-icon-style: symbolic; } +} + +.bt-menu-placeholder { + @extend %title_4; + text-align: center; + + padding: 2em 4em; +} + .device-subtitle { color: transparentize($fg_color, 0.5); } diff --git a/js/ui/status/bluetooth.js b/js/ui/status/bluetooth.js index 2851f2d27..ca250dd45 100644 --- a/js/ui/status/bluetooth.js +++ b/js/ui/status/bluetooth.js @@ -1,9 +1,11 @@ // -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- /* exported Indicator */ -const {Gio, GLib, GnomeBluetooth, GObject} = imports.gi; +const {Gio, GLib, GnomeBluetooth, GObject, Pango, St} = imports.gi; -const {QuickToggle, SystemIndicator} = imports.ui.quickSettings; +const {Spinner} = imports.ui.animation; +const PopupMenu = imports.ui.popupMenu; +const {QuickMenuToggle, SystemIndicator} = imports.ui.quickSettings; const {loadInterfaceXML} = imports.misc.fileUtils; @@ -15,6 +17,8 @@ const OBJECT_PATH = '/org/gnome/SettingsDaemon/Rfkill'; const RfkillManagerInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Rfkill'); const rfkillManagerInfo = Gio.DBusInterfaceInfo.new_for_xml(RfkillManagerInterface); +Gio._promisify(GnomeBluetooth.Client.prototype, 'connect_service'); + const BtClient = GObject.registerClass({ Properties: { 'available': GObject.ParamSpec.boolean('available', '', '', @@ -29,6 +33,7 @@ const BtClient = GObject.registerClass({ }, Signals: { 'devices-changed': {}, + 'device-removed': {param_types: [GObject.TYPE_STRING]}, }, }, class BtClient extends GObject.Object { _init() { @@ -79,6 +84,7 @@ const BtClient = GObject.registerClass({ this._client.connect('device-removed', (c, path) => { this._deviceNotifyConnected.delete(path); + this.emit('device-removed', path); this.emit('devices-changed'); }); this._client.connect('device-added', (c, device) => { @@ -109,6 +115,24 @@ const BtClient = GObject.registerClass({ this._client.default_adapter_powered = true; } + async toggleDevice(device) { + const connect = !device.connected; + console.debug(`${connect + ? 'Connect' : 'Disconnect'} device "${device.name}"`); + + try { + await this._client.connect_service( + device.get_object_path(), + connect, + null); + console.debug(`Device "${device.name}" ${ + connect ? 'connected' : 'disconnected'}`); + } catch (e) { + console.error(`Failed to ${connect + ? 'connect' : 'disconnect'} device "${device.name}": ${e.message}`); + } + } + *getDevices() { const deviceStore = this._client.get_devices(); @@ -145,11 +169,96 @@ const BtClient = GObject.registerClass({ } }); +const BluetoothDeviceItem = GObject.registerClass( +class BluetoothDeviceItem extends PopupMenu.PopupBaseMenuItem { + constructor(device, client) { + super({ + style_class: 'bt-device-item', + }); + + this._device = device; + this._client = client; + + this._icon = new St.Icon({ + style_class: 'popup-menu-icon', + }); + this.add_child(this._icon); + + this._label = new St.Label({ + x_expand: true, + }); + this.add_child(this._label); + + this._subtitle = new St.Label({ + style_class: 'device-subtitle', + }); + this.add_child(this._subtitle); + + this._spinner = new Spinner(16, {hideOnStop: true}); + this.add_child(this._spinner); + + this._spinner.bind_property('visible', + this._subtitle, 'visible', + GObject.BindingFlags.SYNC_CREATE | + GObject.BindingFlags.INVERT_BOOLEAN); + + this._device.bind_property('connectable', + this, 'visible', + GObject.BindingFlags.SYNC_CREATE); + this._device.bind_property('icon', + this._icon, 'icon-name', + GObject.BindingFlags.SYNC_CREATE); + this._device.bind_property('name', + this._label, 'text', + GObject.BindingFlags.SYNC_CREATE); + this._device.bind_property_full('connected', + this._subtitle, 'text', + GObject.BindingFlags.SYNC_CREATE, + (bind, source) => [true, source ? _('Disconnect') : _('Connect')], + null); + + this.connect('destroy', () => (this._spinner = null)); + this.connect('activate', () => this._toggleConnected().catch(logError)); + } + + async _toggleConnected() { + this._spinner.play(); + await this._client.toggleDevice(this._device); + this._spinner?.stop(); + } +}); + const BluetoothToggle = GObject.registerClass( -class BluetoothToggle extends QuickToggle { +class BluetoothToggle extends QuickMenuToggle { _init(client) { super._init({title: _('Bluetooth')}); + this.menu.setHeader('bluetooth-active-symbolic', _('Bluetooth')); + + this._deviceItems = new Map(); + this._deviceSection = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(this._deviceSection); + + this._placeholderItem = new PopupMenu.PopupMenuItem('', { + style_class: 'bt-menu-placeholder', + reactive: false, + can_focus: false, + }); + this._placeholderItem.label.clutter_text.set({ + ellipsize: Pango.EllipsizeMode.NONE, + line_wrap: true, + }); + this.menu.addMenuItem(this._placeholderItem); + + this._deviceSection.actor.bind_property('visible', + this._placeholderItem, 'visible', + GObject.BindingFlags.SYNC_CREATE | + GObject.BindingFlags.INVERT_BOOLEAN); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this.menu.addSettingsAction(_('Bluetooth Settings'), + 'gnome-bluetooth-panel.desktop'); + this._client = client; this._client.bind_property('available', @@ -165,17 +274,86 @@ class BluetoothToggle extends QuickToggle { null); this._client.connectObject( + 'notify::active', () => this._onActiveChanged(), 'devices-changed', () => this._sync(), + 'device-removed', (c, path) => this._removeDevice(path), this); + this.menu.connect('open-state-changed', isOpen => { + // We don't reorder the list while the menu is open, + // so do it now to start with the proper order + if (isOpen) + this._reorderDeviceItems(); + }); + this.connect('clicked', () => this._client.toggleActive()); + this._updatePlaceholder(); + this._sync(); + } + + _onActiveChanged() { + this._updatePlaceholder(); + + this._deviceItems.forEach(item => item.destroy()); + this._deviceItems.clear(); + this._sync(); } + _updatePlaceholder() { + this._placeholderItem.label.text = this._client.active + ? _('No available or connected devices') + : _('Turn on Bluetooth to connect to devices'); + } + + _updateDeviceVisibility() { + this._deviceSection.actor.visible = + [...this._deviceItems.values()].some(item => item.visible); + } + + _getSortedDevices() { + return [...this._client.getDevices()].sort((dev1, dev2) => { + if (dev1.connected !== dev2.connected) + return dev2.connected - dev1.connected; + return dev1.alias.localeCompare(dev2.alias); + }); + } + + _removeDevice(path) { + this._deviceItems.get(path)?.destroy(); + this._deviceItems.delete(path); + + this._updateDeviceVisibility(); + } + + _reorderDeviceItems() { + const devices = this._getSortedDevices(); + for (const [i, dev] of devices.entries()) { + const item = this._deviceItems.get(dev.get_object_path()); + if (!item) + continue; + + this._deviceSection.moveMenuItem(item, i); + } + } + _sync() { - const connectedDevices = [...this._client.getDevices()] - .filter(dev => dev.connected); + const devices = this._getSortedDevices(); + + for (const dev of devices) { + const path = dev.get_object_path(); + if (this._deviceItems.has(path)) + continue; + + const item = new BluetoothDeviceItem(dev, this._client); + item.connect('notify::visible', () => this._updateDeviceVisibility()); + + this._deviceSection.addMenuItem(item); + this._deviceItems.set(path, item); + } + + const connectedDevices = devices.filter(dev => dev.connected); const nConnected = connectedDevices.length; if (nConnected > 1) @@ -185,6 +363,8 @@ class BluetoothToggle extends QuickToggle { this.subtitle = connectedDevices[0].alias; else this.subtitle = null; + + this._updateDeviceVisibility(); } _getIconNameFromState(state) {