diff --git a/data/theme/gnome-shell-sass/widgets/_panel.scss b/data/theme/gnome-shell-sass/widgets/_panel.scss index d36e3dc3c..f23aa4b81 100644 --- a/data/theme/gnome-shell-sass/widgets/_panel.scss +++ b/data/theme/gnome-shell-sass/widgets/_panel.scss @@ -44,7 +44,18 @@ $panel_transition_duration: 250ms; // same as the overview transition duration } &#panelActivities { - -natural-hpadding: $base_padding * 3; + -natural-hpadding: $base_padding * 2.5; + + & StBoxLayout { + spacing: 5px; + } + + & .workspace-dot { + border-radius: 999px; + min-width: 8px; + min-height: 8px; + background-color: $panel_fg_color; + } } // screen activity indicators @@ -90,6 +101,10 @@ $panel_transition_duration: 250ms; // same as the overview transition duration .panel-button { @include panel_button($panel_system_fg_color, $fg:$panel_system_fg_color); + &#panelActivities .workspace-dot { + background-color: $panel_system_fg_color; + } + // clock &.clock-display { @include panel_button($panel_system_fg_color, $fg:$panel_system_fg_color, $highlighted_child: true, $child_class:".clock"); diff --git a/js/ui/panel.js b/js/ui/panel.js index 590078608..fe16fab46 100644 --- a/js/ui/panel.js +++ b/js/ui/panel.js @@ -4,6 +4,7 @@ import Atk from 'gi://Atk'; import Clutter from 'gi://Clutter'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; +import Graphene from 'gi://Graphene'; import Meta from 'gi://Meta'; import Shell from 'gi://Shell'; import St from 'gi://St'; @@ -18,6 +19,7 @@ import * as PopupMenu from './popupMenu.js'; import * as PanelMenu from './panelMenu.js'; import {QuickSettingsMenu, SystemIndicator} from './quickSettings.js'; import * as Main from './main.js'; +import * as Util from '../misc/util.js'; import * as RemoteAccessStatus from './status/remoteAccess.js'; import * as PowerProfileStatus from './status/powerProfiles.js'; @@ -47,6 +49,8 @@ const BUTTON_DND_ACTIVATION_TIMEOUT = 250; const N_QUICK_SETTINGS_COLUMNS = 2; +const INACTIVE_WORKSPACE_DOT_SCALE = 0.75; + /** * AppMenuButton: * @@ -256,23 +260,172 @@ const AppMenuButton = GObject.registerClass({ } }); +const WorkspaceDot = GObject.registerClass({ + Properties: { + 'expansion': GObject.ParamSpec.double('expansion', '', '', + GObject.ParamFlags.READWRITE, + 0.0, 1.0, 0.0), + 'width-multiplier': GObject.ParamSpec.double( + 'width-multiplier', '', '', + GObject.ParamFlags.READWRITE, + 1.0, 10.0, 1.0), + }, +}, class WorkspaceDot extends Clutter.Actor { + constructor(params = {}) { + super({ + pivot_point: new Graphene.Point({x: 0.5, y: 0.5}), + ...params, + }); + + this._dot = new St.Widget({ + style_class: 'workspace-dot', + y_align: Clutter.ActorAlign.CENTER, + pivot_point: new Graphene.Point({x: 0.5, y: 0.5}), + request_mode: Clutter.RequestMode.WIDTH_FOR_HEIGHT, + }); + this.add_child(this._dot); + + this.connect('notify::width-multiplier', () => this.queue_relayout()); + this.connect('notify::expansion', () => { + this._updateVisuals(); + this.queue_relayout(); + }); + this._updateVisuals(); + + this._destroying = false; + } + + _updateVisuals() { + const {expansion} = this; + + this._dot.set({ + opacity: Util.lerp(0.50, 1.0, expansion) * 255, + scaleX: Util.lerp(INACTIVE_WORKSPACE_DOT_SCALE, 1.0, expansion), + scaleY: Util.lerp(INACTIVE_WORKSPACE_DOT_SCALE, 1.0, expansion), + }); + } + + vfunc_get_preferred_width(forHeight) { + const factor = Util.lerp(1.0, this.widthMultiplier, this.expansion); + return this._dot.get_preferred_width(forHeight).map(v => Math.round(v * factor)); + } + + vfunc_get_preferred_height(forWidth) { + return this._dot.get_preferred_height(forWidth); + } + + vfunc_allocate(box) { + this.set_allocation(box); + + box.set_origin(0, 0); + this._dot.allocate(box); + } + + scaleIn() { + this.set({ + scale_x: 0, + scale_y: 0, + }); + + this.ease({ + duration: 500, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + scale_x: 1.0, + scale_y: 1.0, + }); + } + + scaleOutAndDestroy() { + this._destroying = true; + + this.ease({ + duration: 500, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + scale_x: 0.0, + scale_y: 0.0, + onComplete: () => this.destroy(), + }); + } + + get destroying() { + return this._destroying; + } +}); + +const WorkspaceIndicators = GObject.registerClass( +class WorkspaceIndicators extends St.BoxLayout { + constructor() { + super(); + + this._workspacesAdjustment = Main.createWorkspacesAdjustment(this); + this._workspacesAdjustment.connectObject( + 'notify::value', () => this._updateExpansion(), + 'notify::upper', () => this._recalculateDots(), + this); + + for (let i = 0; i < this._workspacesAdjustment.upper; i++) + this.insert_child_at_index(new WorkspaceDot(), i); + this._updateExpansion(); + } + + _getActiveIndicators() { + return [...this].filter(i => !i.destroying); + } + + _recalculateDots() { + const activeIndicators = this._getActiveIndicators(); + const nIndicators = activeIndicators.length; + const targetIndicators = this._workspacesAdjustment.upper; + + let remaining = Math.abs(nIndicators - targetIndicators); + while (remaining--) { + if (nIndicators < targetIndicators) { + const indicator = new WorkspaceDot(); + this.add_child(indicator); + indicator.scaleIn(); + } else { + const indicator = activeIndicators[nIndicators - remaining - 1]; + indicator.scaleOutAndDestroy(); + } + } + + this._updateExpansion(); + } + + _updateExpansion() { + const nIndicators = this._getActiveIndicators().length; + const activeWorkspace = this._workspacesAdjustment.value; + + let widthMultiplier; + if (nIndicators <= 2) + widthMultiplier = 3.625; + else if (nIndicators <= 5) + widthMultiplier = 3.25; + else + widthMultiplier = 2.75; + + this.get_children().forEach((indicator, index) => { + const distance = Math.abs(index - activeWorkspace); + indicator.expansion = Math.clamp(1 - distance, 0, 1); + indicator.widthMultiplier = widthMultiplier; + }); + } +}); + const ActivitiesButton = GObject.registerClass( class ActivitiesButton extends PanelMenu.Button { _init() { super._init(0.0, null, true); - this.accessible_role = Atk.Role.TOGGLE_BUTTON; - this.name = 'panelActivities'; - - /* Translators: If there is no suitable word for "Activities" - in your language, you can use the word for "Overview". */ - this._label = new St.Label({ - text: _('Activities'), - y_align: Clutter.ActorAlign.CENTER, + this.set({ + name: 'panelActivities', + accessible_role: Atk.Role.TOGGLE_BUTTON, + /* Translators: If there is no suitable word for "Activities" + in your language, you can use the word for "Overview". */ + accessible_name: _('Activities'), }); - this.add_actor(this._label); - this.label_actor = this._label; + this.add_child(new WorkspaceIndicators()); Main.overview.connect('showing', () => { this.add_style_pseudo_class('checked');