diff --git a/data/theme/cinnamon-sass/widgets/_windowlist.scss b/data/theme/cinnamon-sass/widgets/_windowlist.scss index 93e401c776..c814b5e478 100644 --- a/data/theme/cinnamon-sass/widgets/_windowlist.scss +++ b/data/theme/cinnamon-sass/widgets/_windowlist.scss @@ -107,6 +107,37 @@ &-notifications-badge-label { font-size: 12px; } + + &-scroll-button { + min-width: 15px; + min-height: 20px; + background-color: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(128, 128, 128, 0.2); + margin: 0; + padding: 0; + border-radius: 0; + box-shadow: none; + + &-icon { + icon-size: 1.09em; + } + + &-left { + border-right-width: 0; + } + + &-right { + border-left-width: 0; + } + + &-top { + border-bottom-width: 0; + } + + &-down { + border-top-width: 0; + } + } } // classic window list diff --git a/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/applet.js b/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/applet.js index 1cbf7efb41..bfd30977e3 100644 --- a/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/applet.js +++ b/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/applet.js @@ -109,7 +109,7 @@ class PinnedFavs { const currentWorkspace = this.params.state.trigger('getCurrentWorkspace'); const newFavorites = []; let refActorFound = false; - currentWorkspace.actor.get_children().forEach( (actor, i) => { + currentWorkspace.container.get_children().forEach( (actor, i) => { const appGroup = currentWorkspace.appGroups.find( appGroup => appGroup.actor === actor ); if (!appGroup) return; const {app, appId, isFavoriteApp} = appGroup.groupState; @@ -799,12 +799,25 @@ class GroupedWindowListApplet extends Applet.Applet { if(this.state.dragging.posList === null){ this.state.dragging.isForeign = !(source instanceof AppGroup); this.state.dragging.posList = []; - currentWorkspace.actor.get_children().forEach( child => { + + let offset = 0; + if (this.state.isHorizontal) { + offset = currentWorkspace.container.translation_x; + if (currentWorkspace.scrollBox.startButton.visible) + offset += currentWorkspace.scrollBox.startButton.width; + } else { + offset = currentWorkspace.container.translation_y; + if (currentWorkspace.scrollBox.startButton.visible) + offset += currentWorkspace.scrollBox.startButton.height; + } + + currentWorkspace.container.get_children().forEach( child => { let childPos; + let box = child.get_allocation_box(); if(rtl_horizontal) - childPos = this.actor.width - child.get_allocation_box()['x1']; + childPos = this.actor.width - (box.x1 + offset); else - childPos = child.get_allocation_box()[axis[1]]; + childPos = box[axis[1]] + offset; this.state.dragging.posList.push(childPos); }); } @@ -833,13 +846,13 @@ class GroupedWindowListApplet extends Applet.Applet { if(this.state.dragging.isForeign) { if (this.state.dragging.dragPlaceholder) - currentWorkspace.actor.set_child_at_index(this.state.dragging.dragPlaceholder.actor, pos); + currentWorkspace.container.set_child_at_index(this.state.dragging.dragPlaceholder.actor, pos); else { const iconSize = this.getPanelIconSize() * global.ui_scale; this.state.dragging.dragPlaceholder = new DND.GenericDragPlaceholderItem(); this.state.dragging.dragPlaceholder.child.width = iconSize; this.state.dragging.dragPlaceholder.child.height = iconSize; - currentWorkspace.actor.insert_child_at_index( + currentWorkspace.container.insert_child_at_index( this.state.dragging.dragPlaceholder.actor, this.state.dragging.pos ); @@ -847,7 +860,7 @@ class GroupedWindowListApplet extends Applet.Applet { } } else - currentWorkspace.actor.set_child_at_index(source.actor, pos); + currentWorkspace.container.set_child_at_index(source.actor, pos); } if(this.state.dragging.isForeign) diff --git a/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/constants.js b/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/constants.js index 51b08467e4..65170eaf26 100644 --- a/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/constants.js +++ b/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/constants.js @@ -12,6 +12,7 @@ const constants = { FLASH_INTERVAL: 500, FLASH_MAX_COUNT: 4, RESERVE_KEYS: ['willUnmount'], + SCROLL_TO_APP_DEBOUNCE_TIME: 100, TitleDisplay: { None: 1, App: 2, diff --git a/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/scrollBox.js b/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/scrollBox.js new file mode 100644 index 0000000000..a121571389 --- /dev/null +++ b/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/scrollBox.js @@ -0,0 +1,351 @@ +const Clutter = imports.gi.Clutter; +const St = imports.gi.St; +const GLib = imports.gi.GLib; +const { SignalManager } = imports.misc.signalManager; + + +class AppGroupListScrollBox { + constructor(state, container) { + this.state = state; + this.container = container; + this.signals = new SignalManager(null); + + const managerOrientation = this.state.isHorizontal ? Clutter.Orientation.HORIZONTAL : Clutter.Orientation.VERTICAL; + + this.mainLayout = new Clutter.BoxLayout({orientation: managerOrientation}); + this.actor = new Clutter.Actor({ layout_manager: this.mainLayout, reactive: true }); + + this.startButton = new St.Bin({ + style_class: 'grouped-window-list-scroll-button', + visible: false, + reactive: true, + x_align: St.Align.MIDDLE, + y_align: St.Align.MIDDLE + }); + this.endButton = new St.Bin({ + style_class: 'grouped-window-list-scroll-button', + visible: false, + reactive: true, + x_align: St.Align.MIDDLE, + y_align: St.Align.MIDDLE + }); + this.startIcon = new St.Icon({ + icon_name: 'pan-start-symbolic', + icon_type: St.IconType.SYMBOLIC, + style_class: 'grouped-window-list-scroll-button-icon' + }); + this.endIcon = new St.Icon({ + icon_name: 'pan-end-symbolic', + icon_type: St.IconType.SYMBOLIC, + style_class: 'grouped-window-list-scroll-button-icon' + }); + + this.startButton.set_child(this.startIcon); + this.endButton.set_child(this.endIcon); + + this.signals.connect(this.startButton, 'enter-event', () => this.startSlide(-1)); + this.signals.connect(this.startButton, 'leave-event', this.stopSlide, this); + this.signals.connect(this.endButton, 'enter-event', () => this.startSlide(1)); + this.signals.connect(this.endButton, 'leave-event', this.stopSlide, this); + + this.scrollBox = new Clutter.Actor({ clip_to_allocation: true }); + this.scrollBox.add_child(this.container); + + this.actor.add_child(this.startButton); + this.actor.add_child(this.scrollBox); + this.actor.add_child(this.endButton); + + this.scrollBox.set_x_expand(true); + this.scrollBox.set_y_expand(true); + + this.slideTimerSourceId = 0; + this.updateScrollVisibilityId = 0; + + // Connect all the signals + this.signals.connect(this.actor, 'allocation-changed', this.updateScrollVisibility, this); + this.signals.connect(this.container, 'allocation-changed', this.updateScrollVisibility, this); + this.signals.connect(this.container, 'notify::translation-x', this.updateScrollVisibility, this); + this.signals.connect(this.container, 'notify::translation-y', this.updateScrollVisibility, this); + this.signals.connect(this.scrollBox, 'notify::allocation', this.updateScrollVisibility, this); + this.signals.connect(this.actor, 'scroll-event', (actor, event) => this.onScroll(event)); + + this.on_orientation_changed(this.state.orientation); + } + + on_orientation_changed(orientation) { + const managerOrientation = this.state.isHorizontal ? Clutter.Orientation.HORIZONTAL : Clutter.Orientation.VERTICAL; + + this.mainLayout.set_orientation(managerOrientation); + + if (this.state.isHorizontal) { + this.actor.set_x_align(Clutter.ActorAlign.FILL); + this.startButton.remove_style_class_name('grouped-window-list-scroll-button-top'); + this.endButton.remove_style_class_name('grouped-window-list-scroll-button-down'); + this.startButton.add_style_class_name('grouped-window-list-scroll-button-left'); + this.endButton.add_style_class_name('grouped-window-list-scroll-button-right'); + this.startIcon.set_icon_name('pan-start-symbolic'); + this.endIcon.set_icon_name('pan-end-symbolic'); + this.startButton.set_x_expand(false); + this.startButton.set_y_expand(true); + this.startButton.set_y_align(Clutter.ActorAlign.FILL); + this.endButton.set_x_expand(false); + this.endButton.set_y_expand(true); + this.endButton.set_y_align(Clutter.ActorAlign.FILL); + } else { + this.actor.set_x_align(Clutter.ActorAlign.CENTER); + this.startButton.remove_style_class_name('grouped-window-list-scroll-button-left'); + this.endButton.remove_style_class_name('grouped-window-list-scroll-button-right'); + this.startButton.add_style_class_name('grouped-window-list-scroll-button-top'); + this.endButton.add_style_class_name('grouped-window-list-scroll-button-down'); + this.startIcon.set_icon_name('pan-up-symbolic'); + this.endIcon.set_icon_name('pan-down-symbolic'); + this.startButton.set_x_expand(true); + this.startButton.set_y_expand(false); + this.startButton.set_x_align(Clutter.ActorAlign.FILL); + this.endButton.set_x_expand(true); + this.endButton.set_y_expand(false); + this.endButton.set_x_align(Clutter.ActorAlign.FILL); + } + this.updateScrollVisibility(); + } + + startSlide(direction) { + if (this.slideTimerSourceId > 0) { + GLib.source_remove(this.slideTimerSourceId); + this.slideTimerSourceId = 0; + } + + if (this.state.panelEditMode) return; + + const scrollFunc = () => { + this.scroll(direction * 5); + if (this.slideTimerSourceId === 0) return GLib.SOURCE_REMOVE; + + // Check if reached bounds to stop timer + let current, min; + if (this.state.isHorizontal) { + current = this.container.translation_x; + min = Math.min(0, this.scrollBox.width - this.container.width); + } else { + current = this.container.translation_y; + min = Math.min(0, this.scrollBox.height - this.container.height); + } + + // At start, trying to go start + if (current >= 0 && direction < 0) { + this.slideTimerSourceId = 0; + return GLib.SOURCE_REMOVE; + } + + // At end, trying to go end + if (current <= min && direction > 0) { + this.slideTimerSourceId = 0; + return GLib.SOURCE_REMOVE; + } + + return GLib.SOURCE_CONTINUE; + }; + + this.slideTimerSourceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 10, scrollFunc); + } + + stopSlide() { + if (this.slideTimerSourceId > 0) { + GLib.source_remove(this.slideTimerSourceId); + this.slideTimerSourceId = 0; + } + } + + onScroll(event) { + if (this.state.panelEditMode) return; + + if (this.state.settings.scrollBehavior !== 4) { + return Clutter.EVENT_PROPAGATE; + } + + let containerSize, scrollBoxSize; + if (this.state.isHorizontal) { + containerSize = this.container.width || this.container.get_preferred_width(-1)[1]; + scrollBoxSize = this.scrollBox.width; + } else { + containerSize = this.container.height || this.container.get_preferred_height(-1)[1]; + scrollBoxSize = this.scrollBox.height; + } + + if (containerSize <= scrollBoxSize) return Clutter.EVENT_PROPAGATE; + + const direction = event.get_scroll_direction(); + let delta = 0; + + if (direction === Clutter.ScrollDirection.SMOOTH) { + const [dx, dy] = event.get_scroll_delta(); + delta = this.state.isHorizontal ? dx : dy; + delta *= 15; // Scale smooth scroll + } else { + const step = 20; + if (direction === Clutter.ScrollDirection.UP || direction === Clutter.ScrollDirection.LEFT) { + delta = -step; + } else if (direction === Clutter.ScrollDirection.DOWN || direction === Clutter.ScrollDirection.RIGHT) { + delta = step; + } + } + + if (delta !== 0) { + this.scroll(delta); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + scroll(amount) { + let current, min, next; + if (this.state.isHorizontal) { + current = this.container.translation_x; + min = Math.min(0, this.scrollBox.width - this.container.width); + next = current - amount; + } else { + current = this.container.translation_y; + min = Math.min(0, this.scrollBox.height - this.container.height); + next = current - amount; + } + + if (next > 0) next = 0; + if (next < min) next = min; + + if (this.state.isHorizontal) this.container.translation_x = next; + else this.container.translation_y = next; + } + + updateScrollVisibility() { + if (this.updateScrollVisibilityId > 0) return; + this.updateScrollVisibilityId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => { + this._updateScrollVisibility(); + this.updateScrollVisibilityId = 0; + return GLib.SOURCE_REMOVE; + }); + } + + _updateScrollVisibility() { + if (this.state.panelEditMode) return; + + let containerSize, scrollBoxSize; + + if (this.state.isHorizontal) { + containerSize = this.container.width > 0 ? this.container.width : this.container.get_preferred_width(-1)[1]; + scrollBoxSize = this.scrollBox.width; + } else { + containerSize = this.container.height > 0 ? this.container.height : this.container.get_preferred_height(-1)[1]; + scrollBoxSize = this.scrollBox.height; + } + + let minTranslation = Math.min(0, scrollBoxSize - containerSize); + let currentTranslation = this.state.isHorizontal ? this.container.translation_x : this.container.translation_y; + + // Clamp translation if bounds have changed (resizing, etc) + if (currentTranslation < minTranslation) { + currentTranslation = minTranslation; + if (this.state.isHorizontal) this.container.translation_x = currentTranslation; + else this.container.translation_y = currentTranslation; + } + + if (containerSize > scrollBoxSize) { + // Some tolerance to avoid flickering + this.startButton.visible = currentTranslation < -0.1; + this.endButton.visible = currentTranslation > minTranslation + 0.1; + } else { + this.startButton.visible = false; + this.endButton.visible = false; + + if (currentTranslation !== 0) { + if (this.state.isHorizontal) this.container.translation_x = 0; + else this.container.translation_y = 0; + } + } + } + + scrollToChild(childActor) { + if (this.state.panelEditMode) return; + + if (!childActor || childActor.get_parent() !== this.container) return; + + const isHorizontal = this.state.isHorizontal; + + let containerSize, boxSize; + if (isHorizontal) { + containerSize = this.container.width > 0 ? this.container.width : this.container.get_preferred_width(-1)[1]; + boxSize = this.scrollBox.width; + } else { + containerSize = this.container.height > 0 ? this.container.height : this.container.get_preferred_height(-1)[1]; + boxSize = this.scrollBox.height; + } + + if (containerSize <= boxSize) return; + + let targetCenter = 0; + let allocationValid = false; + + if (childActor.has_allocation()) { + const box = childActor.get_allocation_box(); + const size = isHorizontal ? box.get_width() : box.get_height(); + + if (size > 0) { + targetCenter = (isHorizontal ? box.x1 : box.y1) + (size / 2); + allocationValid = true; + } + } + + if (!allocationValid) { + const children = this.container.get_children(); + const index = children.indexOf(childActor); + + if (index === -1) return; + + let itemPos = 0; + let itemSize = 0; + + for (let i = 0; i <= index; i++) { + const actor = children[i]; + + if (isHorizontal) { + itemSize = actor.width > 0 ? actor.width : actor.get_preferred_width(-1)[1]; + } else { + itemSize = actor.height > 0 ? actor.height : actor.get_preferred_height(-1)[1]; + } + + itemPos += itemSize; + } + + targetCenter = itemPos - (itemSize / 2); + } + + // We want targetCenter to be at boxSize / 2 + let newPos = (boxSize / 2) - targetCenter; + + const minPos = Math.min(0, boxSize - containerSize); + newPos = Math.round(Math.max(minPos, Math.min(newPos, 0)) * 100) / 100; + + if (isHorizontal) { + this.container.translation_x = newPos; + } else { + this.container.translation_y = newPos; + } + } + + destroy() { + this.signals.disconnectAllSignals(); + if (this.slideTimerSourceId > 0) { + GLib.source_remove(this.slideTimerSourceId); + this.slideTimerSourceId = 0; + } + + if (this.updateScrollVisibilityId > 0) { + GLib.source_remove(this.updateScrollVisibilityId); + this.updateScrollVisibilityId = 0; + } + + this.actor.destroy(); + } +} + +module.exports = AppGroupListScrollBox; diff --git a/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/settings-schema.json b/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/settings-schema.json index c4f1b49cc2..43fe798fee 100644 --- a/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/settings-schema.json +++ b/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/settings-schema.json @@ -98,12 +98,13 @@ }, "scroll-behavior": { "type": "combobox", - "default": 1, + "default": 4, "description": "Mouse wheel scroll action", "options": { "None": 1, "Cycle apps": 2, - "Cycle windows": 3 + "Cycle windows": 3, + "Slide app list": 4 } }, "left-click-action": { diff --git a/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/workspace.js b/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/workspace.js index 4af591ee8c..2c22f365d4 100644 --- a/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/workspace.js +++ b/files/usr/share/cinnamon/applets/grouped-window-list@cinnamon.org/workspace.js @@ -1,17 +1,27 @@ const Clutter = imports.gi.Clutter; +const GLib = imports.gi.GLib; const Main = imports.ui.main; const {SignalManager} = imports.misc.signalManager; const {unref} = imports.misc.util; const createStore = require('./state'); const AppGroup = require('./appGroup'); -const {RESERVE_KEYS} = require('./constants'); +const AppGroupListScrollBox = require('./scrollBox'); +const {RESERVE_KEYS, SCROLL_TO_APP_DEBOUNCE_TIME} = require('./constants'); + class Workspace { constructor(params) { this.state = params.state; this.state.connect({ - orientation: () => this.on_orientation_changed(false) + orientation: (state) => { + this.on_orientation_changed(state.orientation); + }, + currentWs: (state) => { + if (this.metaWorkspace && state.currentWs === this.metaWorkspace.index()) { + this.scrollToFocusedApp(); + } + } }); this.workspaceState = createStore({ workspaceIndex: params.index, @@ -28,11 +38,15 @@ class Workspace { if (this.state.willUnmount) { return; } - this.actor.remove_child(actor); + this.container.remove_child(actor); + this.scrollBox.updateScrollVisibility(); }, updateFocusState: (focusedAppId) => { this.appGroups.forEach( appGroup => { - if (focusedAppId === appGroup.groupState.appId) return; + if (focusedAppId === appGroup.groupState.appId) { + this.scrollToAppGroup(appGroup); + return; + }; appGroup.onFocusChange(false); }); } @@ -41,33 +55,50 @@ class Workspace { this.signals = new SignalManager(null); this.metaWorkspace = params.metaWorkspace; - const managerOrientation = this.state.isHorizontal ? 'HORIZONTAL' : 'VERTICAL'; - this.manager = new Clutter.BoxLayout({orientation: Clutter.Orientation[managerOrientation]}); - this.actor = new Clutter.Actor({layout_manager: this.manager}); + const managerOrientation = this.state.isHorizontal ? Clutter.Orientation.HORIZONTAL : Clutter.Orientation.VERTICAL; + + this.manager = new Clutter.BoxLayout({orientation: managerOrientation}); + this.container = new Clutter.Actor({layout_manager: this.manager}); + + this.scrollBox = new AppGroupListScrollBox(this.state, this.container); + this.actor = this.scrollBox.actor; this.appGroups = []; this.lastFocusedApp = null; + this.scrollToAppDebounceTimeoutId = 0; // Connect all the signals this.signals.connect(global.display, 'window-workspace-changed', (...args) => this.windowWorkspaceChanged(...args)); // Ugly change: refresh the removed app instances from all workspaces this.signals.connect(this.metaWorkspace, 'window-removed', (...args) => this.windowRemoved(...args)); this.signals.connect(global.window_manager, 'switch-workspace' , (...args) => this.reloadList(...args)); - this.on_orientation_changed(null, true); + + this.on_orientation_changed(this.state.orientation); } - on_orientation_changed() { + on_orientation_changed(orientation) { if (!this.manager) return; - if (!this.state.isHorizontal) { - this.manager.set_orientation(Clutter.Orientation.VERTICAL); - this.actor.set_x_align(Clutter.ActorAlign.CENTER); - } else { - this.manager.set_orientation(Clutter.Orientation.HORIZONTAL); - } + const managerOrientation = this.state.isHorizontal ? Clutter.Orientation.HORIZONTAL : Clutter.Orientation.VERTICAL; + this.manager.set_orientation(managerOrientation); + this.scrollBox.on_orientation_changed(orientation); this.refreshList(); } + scrollToAppGroup(appGroup) { + if (this.scrollToAppDebounceTimeoutId > 0) GLib.source_remove(this.scrollToAppDebounceTimeoutId); + this.scrollToAppDebounceTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, SCROLL_TO_APP_DEBOUNCE_TIME, () => { + this._scrollToAppGroup(appGroup); + this.scrollToAppDebounceTimeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + } + + _scrollToAppGroup(appGroup) { + if (!appGroup || !appGroup.actor) return; + this.scrollBox.scrollToChild(appGroup.actor); + } + getWindowCount(appId) { let windowCount = 0; this.appGroups.forEach( appGroup => { @@ -177,6 +208,7 @@ class Workspace { this.appGroups = []; this.loadFavorites(); this.refreshApps(); + this.scrollToFocusedApp(); } loadFavorites() { @@ -214,6 +246,15 @@ class Workspace { } } + scrollToFocusedApp() { + for (let appGroup of this.appGroups) { + if (appGroup.groupState.lastFocused && appGroup.groupState.lastFocused.has_focus()) { + this.scrollToAppGroup(appGroup); + return; + } + } + } + updateAttentionState(display, window) { this.appGroups.forEach( appGroup => { if (appGroup.groupState.metaWindows) { @@ -308,13 +349,14 @@ class Workspace { }); if(idx > -1) { - this.actor.insert_child_at_index(appGroup.actor, idx); + this.container.insert_child_at_index(appGroup.actor, idx); this.appGroups.splice(idx, 0, appGroup); } else { - this.actor.add_child(appGroup.actor); + this.container.add_child(appGroup.actor); this.appGroups.push(appGroup); } + this.scrollBox.updateScrollVisibility(); appGroup.windowAdded(metaWindow); }; @@ -339,7 +381,7 @@ class Workspace { updateAppGroupIndexes() { const newAppGroups = []; - this.actor.get_children().forEach( child => { + this.container.get_children().forEach( child => { const appGroup = this.appGroups.find( appGroup => appGroup.actor === child); if (appGroup) { newAppGroups.push(appGroup); @@ -403,7 +445,7 @@ class Workspace { // in edge case when multiple apps of the same program are favorited, do not move other app if(!otherAppObject.groupState.isFavoriteApp) { this.appGroups.splice(otherApp, 1); - this.actor.set_child_at_index(otherAppObject.actor, refApp); + this.container.set_child_at_index(otherAppObject.actor, refApp); this.appGroups.splice(refApp, 0, otherAppObject); // change previously unpinned app status to pinned @@ -423,8 +465,8 @@ class Workspace { this.signals.disconnectAllSignals(); this.appGroups.forEach( appGroup => appGroup.destroy() ); this.workspaceState.destroy(); + this.scrollBox.destroy(); this.manager = null; - this.actor.destroy(); unref(this, RESERVE_KEYS); } }