%PDF- %PDF-
| Direktori : /usr/share/gnome-shell/extensions/tiling-assistant@ubuntu.com/src/extension/ |
| Current File : //usr/share/gnome-shell/extensions/tiling-assistant@ubuntu.com/src/extension/layoutsManager.js |
import { Clutter, Gio, GObject, Meta, Shell, St } from '../dependencies/gi.js';
import {
_,
Extension,
Main,
PanelMenu,
PopupMenu
} from '../dependencies/shell.js';
import { Layout, Settings } from '../common.js';
import { Rect, Util } from './utility.js';
import { TilingWindowManager as Twm } from './tilingWindowManager.js';
/**
* Here are the classes to handle PopupLayouts on the shell / extension side.
* See src/prefs/layoutsPrefs.js for more details and general info about layouts.
* In summary, a Layout is an array of LayoutItems. A LayoutItem is a JS Object
* and has a rect, an appId and a loopType. Only the rect is mandatory. AppId may
* be null or a String. Same for the LoopType. If a layout is activated, we will
* loop / step through each LayoutItem and spawn a Tiling Popup one after the
* other for the rects and offer to tile a window to that rect. If an appId is
* defined, instead of calling the Tiling Popup, we tile (a new Instance of)
* the app to the rect. If a LoopType is defined, instead of going to the next
* item / rect, we spawn a Tiling Popup on the same item / rect and all the
* tiled windows will share that spot evenly (a la 'Master and Stack').
*
* Additionally, there the user can select a 'favorite' layout among the
* PopupLayouts. That layout will then be used as an fixed alternative mode to
* the Edge Tiling.
*/
export default class TilingLayoutsManager {
constructor() {
// this._items is an array of LayoutItems (see explanation above).
// this._currItem is 1 LayoutItem. A LayoutItem's rect only hold ratios
// from 0 - 1. this._currRect is a Rect scaled to the workArea.
this._items = [];
this._currItem = null;
this._currRect = null;
// Preview to show where the window will tile to, similar
// to the tile preview when dnding to the screen edges
this._rectPreview = null;
// Keep track of the windows which were already tiled with the current
// layout and the remaining windows. Special-case windows, which were tiled
// within a loop since they need to be re-adjusted for each new window
// tiled to the same spot. The looped array is cleared after each 'step' /
// LayoutItem change.
this._tiledWithLayout = [];
this._tiledWithLoop = [];
this._remainingWindows = [];
// Bind the keyboard shortcuts for each layout and the layout searchers
this._keyBindings = [];
for (let i = 0; i < 20; i++) {
this._keyBindings.push(`activate-layout${i}`);
Main.wm.addKeybinding(
`activate-layout${i}`,
Settings.getGioObject(),
Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
Shell.ActionMode.NORMAL,
this.startLayouting.bind(this, i)
);
}
this._keyBindings.push('search-popup-layout');
Main.wm.addKeybinding(
'search-popup-layout',
Settings.getGioObject(),
Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
Shell.ActionMode.NORMAL,
this.openPopupSearch.bind(this)
);
// Add panel indicator
this._panelIndicator = new PanelIndicator();
Main.panel.addToStatusArea(
'tiling-assistant@leleat-on-github',
this._panelIndicator);
this._settingsId = Settings.changed(Settings.SHOW_LAYOUT_INDICATOR, () => {
this._panelIndicator.visible = Settings.getBoolean(Settings.SHOW_LAYOUT_INDICATOR);
});
this._panelIndicator.visible = Settings.getBoolean(Settings.SHOW_LAYOUT_INDICATOR);
this._panelIndicator.connect('layout-activated', (src, idx) => this.startLayouting(idx));
}
destroy() {
Settings.disconnect(this._settingsId);
this._finishLayouting();
this._keyBindings.forEach(key => Main.wm.removeKeybinding(key));
this._panelIndicator.destroy();
this._panelIndicator = null;
}
/**
* Opens a popup window so the user can activate a layout by name
* instead of the keyboard shortcut.
*/
openPopupSearch() {
const layouts = Util.getLayouts();
if (!layouts.length) {
Main.notify('Tiling Assistant', _('No valid layouts defined.'));
return;
}
const search = new LayoutSearch(layouts);
search.connect('item-activated', (s, index) => this.startLayouting(index));
}
/**
* Starts tiling to a Popup Layout.
*
* @param {number} index the index of the layout we start tiling to.
*/
startLayouting(index) {
const layout = Util.getLayouts()?.[index];
if (!layout)
return;
const allWs = Settings.getBoolean(Settings.POPUP_ALL_WORKSPACES);
this._remainingWindows = Twm.getWindows(allWs);
this._items = new Layout(layout).getItems();
this._currItem = null;
const activeWs = global.workspace_manager.get_active_workspace();
const monitor = global.display.get_current_monitor();
const workArea = activeWs.get_work_area_for_monitor(monitor);
this._rectPreview?.destroy();
this._rectPreview = new St.Widget({
style_class: 'tile-preview',
opacity: 0,
x: workArea.x + workArea.width / 2,
y: workArea.y + workArea.height / 2
});
Main.layoutManager.addChrome(this._rectPreview);
this._step();
}
_finishLayouting() {
this._items = [];
this._currItem = null;
this._currRect = null;
this._rectPreview?.destroy();
this._rectPreview = null;
this._tiledWithLayout = [];
this._tiledWithLoop = [];
this._remainingWindows = [];
}
_step(loopType = null) {
// If we aren't looping on the current item, we need to prepare for the
// step by getting the next item / rect. If we are looping, we stay on
// the current item / rect and open a new Tiling Popup for that rect.
if (!loopType) {
// We're at the last item and not looping, so there are no more items.
if (this._currItem === this._items.at(-1)) {
this._finishLayouting();
return;
}
const currIdx = this._items.indexOf(this._currItem);
this._currItem = this._items[currIdx + 1];
// Scale the item's rect to the workArea
const activeWs = global.workspace_manager.get_active_workspace();
const monitor = global.display.get_current_monitor();
const workArea = new Rect(activeWs.get_work_area_for_monitor(monitor));
const rectRatios = this._currItem.rect;
this._currRect = new Rect(
workArea.x + Math.floor(rectRatios.x * workArea.width),
workArea.y + Math.floor(rectRatios.y * workArea.height),
Math.ceil(rectRatios.width * workArea.width),
Math.ceil(rectRatios.height * workArea.height)
);
// Try to compensate possible rounding errors when scaling up the
// rect by aligning it with the rects, which were already tiled
// using this layout and the workArea.
this._tiledWithLayout.forEach(w => this._currRect.tryAlignWith(w.tiledRect));
this._currRect.tryAlignWith(workArea);
}
const appId = this._currItem.appId;
appId ? this._openAppTiled(appId) : this._openTilingPopup();
}
_openAppTiled(appId) {
const app = Shell.AppSystem.get_default().lookup_app(appId);
if (!app) {
Main.notify('Tiling Assistant', _('Popup Layouts: App not found.'));
this._finishLayouting();
return;
}
const winTracker = Shell.WindowTracker.get_default();
const idx = this._remainingWindows.findIndex(w => winTracker.get_window_app(w) === app);
const window = this._remainingWindows[idx];
idx !== -1 && this._remainingWindows.splice(idx, 1);
if (window) {
Twm.tile(window, this._currRect, {
openTilingPopup: false,
skipAnim: true
});
} else if (app.can_open_new_window()) {
Twm.openAppTiled(app, this._currRect);
}
this._step();
}
async _openTilingPopup() {
// There are no open windows left to tile using the Tiling Popup.
// However there may be items with appIds, which we want to open.
// So continue...
if (!this._remainingWindows.length) {
this._step();
return;
}
// Animate the rect preview
this._rectPreview.ease({
x: this._currRect.x,
y: this._currRect.y,
width: this._currRect.width,
height: this._currRect.height,
opacity: 255,
duration: 200,
mode: Clutter.AnimationMode.EASE_OUT_QUAD
});
// Create the Tiling Popup
const TilingPopup = await import('./tilingPopup.js');
const popup = new TilingPopup.TilingSwitcherPopup(
this._remainingWindows,
this._currRect,
// If this._currItem is the last item and we don't loop over it,
// allow the Tiling Popup itself to spawn another instance of
// a Tiling Popup, if there is free screen space.
this._currItem === this._items.at(-1) && !this._currItem.loopType,
true
);
const stacked = global.display.sort_windows_by_stacking(this._tiledWithLayout);
const tileGroup = stacked.reverse();
if (!popup.show(tileGroup)) {
popup.destroy();
this._finishLayouting();
return;
}
popup.connect('closed', this._onTilingPopupClosed.bind(this));
}
_onTilingPopupClosed(tilingPopup, canceled) {
if (canceled) {
if (this._currItem.loopType) {
this._tiledWithLoop = [];
this._step();
} else {
this._finishLayouting();
}
} else {
const tiledWindow = tilingPopup.tiledWindow;
this._tiledWithLayout.push(tiledWindow);
const i = this._remainingWindows.indexOf(tiledWindow);
this._remainingWindows.splice(i, 1);
// Make all windows, which were tiled during the current loop,
// share the current rect evenly -> like the 'Stack' part of a
// 'Master and Stack'
if (this._currItem.loopType) {
this._tiledWithLoop.push(tiledWindow);
this._tiledWithLoop.forEach((w, idx) => {
const rect = this._currRect.copy();
const [pos, dimension] = this._currItem.loopType === 'h'
? ['y', 'height']
: ['x', 'width'];
rect[dimension] /= this._tiledWithLoop.length;
rect[pos] += idx * rect[dimension];
Twm.tile(w, rect, { openTilingPopup: false, skipAnim: true });
});
}
this._step(this._currItem.loopType);
}
}
}
/**
* The GUI class for the Layout search.
*/
const LayoutSearch = GObject.registerClass({
Signals: { 'item-activated': { param_types: [GObject.TYPE_INT] } }
}, class TilingLayoutsSearch extends St.Widget {
_init(layouts) {
const activeWs = global.workspace_manager.get_active_workspace();
super._init({
reactive: true,
x: Main.uiGroup.x,
y: Main.uiGroup.y,
width: Main.uiGroup.width,
height: Main.uiGroup.height
});
Main.uiGroup.add_child(this);
const grab = Main.pushModal(this);
// We expect at least a keyboard grab here
if ((grab.get_seat_state() & Clutter.GrabState.KEYBOARD) === 0) {
Main.popModal(grab);
return false;
}
this._grab = grab;
this._haveModal = true;
this._focused = -1;
this._items = [];
this.connect('button-press-event', () => this.destroy());
const popup = new St.BoxLayout({
style_class: 'switcher-list',
vertical: true,
width: 500
});
this.add_child(popup);
const fontSize = 16;
const entry = new St.Entry({
style: `font-size: ${fontSize}px;\
border-radius: 16px;
margin-bottom: 12px;`,
// The cursor overlaps the text, so add some spaces at the beginning
hint_text: ` ${_('Type to search...')}`
});
const entryClutterText = entry.get_clutter_text();
entryClutterText.connect('key-press-event', this._onKeyPressed.bind(this));
entryClutterText.connect('text-changed', this._onTextChanged.bind(this));
popup.add_child(entry);
this._items = layouts.map(layout => {
const item = new SearchItem(layout._name, fontSize);
item.connect('button-press-event', this._onItemClicked.bind(this));
popup.add_child(item);
return item;
});
if (!this._items.length) {
this.destroy();
return;
}
const monitor = global.display.get_current_monitor();
const workArea = activeWs.get_work_area_for_monitor(monitor);
popup.set_position(workArea.x + workArea.width / 2 - popup.width / 2,
workArea.y + workArea.height / 2 - popup.height / 2);
entry.grab_key_focus();
this._focus(0);
}
destroy() {
if (this._haveModal) {
Main.popModal(this._grab);
this._haveModal = false;
}
super.destroy();
}
_onKeyPressed(clutterText, event) {
const keySym = event.get_key_symbol();
if (keySym === Clutter.KEY_Escape) {
this.destroy();
return Clutter.EVENT_STOP;
} else if (keySym === Clutter.KEY_Return ||
keySym === Clutter.KEY_KP_Enter ||
keySym === Clutter.KEY_ISO_Enter) {
this._activate();
return Clutter.EVENT_STOP;
} else if (keySym === Clutter.KEY_Down) {
this._focusNext();
return Clutter.EVENT_STOP;
} else if (keySym === Clutter.KEY_Up) {
this._focusPrev();
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
}
_onTextChanged(clutterText) {
const filterText = clutterText.get_text();
this._items.forEach(item => {
item.text.toLowerCase().includes(filterText.toLowerCase())
? item.show()
: item.hide();
});
const nextVisibleIdx = this._items.findIndex(item => item.visible);
this._focus(nextVisibleIdx);
}
_onItemClicked(item) {
this._focused = this._items.indexOf(item);
this._activate();
}
_focusPrev() {
this._focus((this._focused + this._items.length - 1) % this._items.length);
}
_focusNext() {
this._focus((this._focused + 1) % this._items.length);
}
_focus(newIdx) {
const prevItem = this._items[this._focused];
const newItem = this._items[newIdx];
this._focused = newIdx;
prevItem?.remove_style_class_name('tiling-layout-search-highlight');
newItem?.add_style_class_name('tiling-layout-search-highlight');
}
_activate() {
this._focused !== -1 && this.emit('item-activated', this._focused);
this.destroy();
}
});
/**
* An Item representing a Layout within the Popup Layout search.
*/
const SearchItem = GObject.registerClass(
class TilingLayoutsSearchItem extends St.Label {
_init(text, fontSize) {
super._init({
// Add some spaces to the beginning to align it better
// with the rounded corners
text: ` ${text || _('Nameless layout...')}`,
style: `font-size: ${fontSize}px;\
text-align: left;\
padding: 8px\
margin-bottom: 2px`,
reactive: true
});
}
});
/**
* A panel indicator to activate and favoritize a layout.
*/
const PanelIndicator = GObject.registerClass({
Signals: { 'layout-activated': { param_types: [GObject.TYPE_INT] } }
}, class PanelIndicator extends PanelMenu.Button {
_init() {
super._init(0.0, 'Layout Indicator (Tiling Assistant)');
const path = Extension.lookupByURL(import.meta.url)
.dir
.get_child('media/preferences-desktop-apps-symbolic.svg')
.get_path();
const gicon = new Gio.FileIcon({ file: Gio.File.new_for_path(path) });
this.add_child(new St.Icon({
gicon,
style_class: 'system-status-icon'
}));
const menuAlignment = 0.0;
this.setMenu(new PopupMenu.PopupMenu(this, menuAlignment, St.Side.TOP));
}
vfunc_event(event) {
if (this.menu &&
(event.type() === Clutter.EventType.TOUCH_BEGIN ||
event.type() === Clutter.EventType.BUTTON_PRESS)
) {
this._updateItems();
this.menu.toggle();
}
return Clutter.EVENT_PROPAGATE;
}
_updateItems() {
this.menu.removeAll();
const layouts = Util.getLayouts();
if (!layouts.length) {
const item = new PopupMenu.PopupMenuItem(_('No valid layouts defined.'));
item.setSensitive(false);
this.menu.addMenuItem(item);
} else {
// Update favorites with monitor count and fill with '-1', if necessary
const tmp = Settings.getStrv(Settings.FAVORITE_LAYOUTS);
const count = Math.max(Main.layoutManager.monitors.length, tmp.length);
const favorites = [...new Array(count)].map((m, monitorIndex) => {
return tmp[monitorIndex] ?? '-1';
});
Settings.setStrv(Settings.FAVORITE_LAYOUTS, favorites);
// Create popup menu items
layouts.forEach((layout, idx) => {
const name = layout._name || `Layout ${idx + 1}`;
const item = new PopupFavoriteMenuItem(name, idx);
item.connect('activate', () => {
Main.overview.hide();
this.emit('layout-activated', idx);
});
item.connect('favorite-changed', this._updateItems.bind(this));
this.menu.addMenuItem(item);
});
}
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
const settingsButton = new PopupMenu.PopupImageMenuItem('Preferences', 'emblem-system-symbolic');
// Center button without changing the size (for the hover highlight)
settingsButton._icon.set_x_expand(true);
settingsButton.label.set_x_expand(true);
settingsButton.connect('activate',
() => Extension.lookupByURL(import.meta.url).openPreferences());
this.menu.addMenuItem(settingsButton);
}
});
/**
* A PopupMenuItem for the PopupMenu of the PanelIndicator.
*/
const PopupFavoriteMenuItem = GObject.registerClass({
Signals: { 'favorite-changed': { param_types: [GObject.TYPE_INT] } }
}, class PopupFavoriteMenuItem extends PopupMenu.PopupBaseMenuItem {
_init(text, layoutIndex) {
super._init();
this.add_child(new St.Label({
text,
x_expand: true
}));
const favorites = Settings.getStrv(Settings.FAVORITE_LAYOUTS);
Main.layoutManager.monitors.forEach((m, monitorIndex) => {
const favoriteButton = new St.Button({
child: new St.Icon({
icon_name: favorites[monitorIndex] === `${layoutIndex}` ? 'starred-symbolic' : 'non-starred-symbolic',
style_class: 'popup-menu-icon'
})
});
this.add_child(favoriteButton);
// Update gSetting with new Favorite (act as a toggle button)
favoriteButton.connect('clicked', () => {
const currFavorites = Settings.getStrv(Settings.FAVORITE_LAYOUTS);
currFavorites[monitorIndex] = currFavorites[monitorIndex] === `${layoutIndex}` ? '-1' : `${layoutIndex}`;
Settings.setStrv(Settings.FAVORITE_LAYOUTS, currFavorites);
this.emit('favorite-changed', monitorIndex);
});
});
}
});