// -*- mode: js2; indent-tabs-mode: nil; js2-basic-offset: 4 -*- const Gio = imports.gi.Gio; const Clutter = imports.gi.Clutter; const GLib = imports.gi.GLib; const St = imports.gi.St; const Meta = imports.gi.Meta; const Shell = imports.gi.Shell; const Mainloop = imports.mainloop; const GObject = imports.gi.GObject; const Gettext = imports.gettext.domain('gnome-shell-extensions'); const _ = Gettext.gettext; const Main = imports.ui.main; const MessageTray = imports.ui.messageTray; const Tweener = imports.ui.tweener; const PanelMenu = imports.ui.panelMenu; const PopupMenu = imports.ui.popupMenu; const Slider = imports.ui.slider; const Conf = imports.misc.config; const ExtensionUtils = imports.misc.extensionUtils; const Me = ExtensionUtils.getCurrentExtension(); const Convenience = Me.imports.convenience; const CASCADE_WIDTH = 30; const CASCADE_HEIGHT = 30; const MIN_WINDOW_WIDTH = 500; const ARRANGEWINDOWS_SCHEMA = 'org.gnome.shell.extensions.arrangeWindows'; const ALL_MONITOR = 'all-monitors'; const COLUMN_NUMBER = 'column'; const HOTKEY_CASCADE = 'arrangewindow-cascade'; const HOTKEY_TILE = 'arrangewindow-tile'; const HOTKEY_SIDEBYSIDE = 'arrangewindow-sidebyside'; const HOTKEY_STACK = 'arrangewindow-stack'; const COLUMN = ['2', '3', '4', '5', '6', '7', '8']; let ArrangeMenu = GObject.registerClass( class ArrangeMenu extends PanelMenu.Button { _init() { super._init(0.0, _('Arrange Windows')); this._gsettings = Convenience.getSettings(ARRANGEWINDOWS_SCHEMA); this._allMonitor = this._gsettings.get_boolean(ALL_MONITOR); let icon = new St.Icon({ gicon: this._getCustIcon('arrange-windows-symbolic'), style_class: 'system-status-icon' }); this.add_actor(icon); this.menu.addAction(_("Cascade"), () => this.cascadeWindow(), this._getCustIcon('cascade-windows-symbolic')); this.menu.addAction(_("Tile"), () => this.tileWindow(), this._getCustIcon('tile-windows-symbolic')); this.menu.addAction(_("Side by side"), () => this.sideBySideWindow(), this._getCustIcon('sidebyside-windows-symbolic')); this.menu.addAction(_("Stack"), () => this.stackWindow(), this._getCustIcon('stack-windows-symbolic')); this.menu.addAction(_("Maximize"), () => this.maximizeWindow(Meta.MaximizeFlags.BOTH), this._getCustIcon('maximize-windows-symbolic')); this.menu.addAction(_("Maximize Vertical"), () => this.maximizeWindow(Meta.MaximizeFlags.VERTICAL), this._getCustIcon('maximize-vertical-windows-symbolic')); this.menu.addAction(_("Maximize Horizontal"), () => this.maximizeWindow(Meta.MaximizeFlags.HORIZONTAL), this._getCustIcon('maximize-horizontal-windows-symbolic')); this.menu.addAction(_("Restoring"), () => this.restoringWindow(), this._getCustIcon('restoring-window-symbolic')); this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); this._allMonitorItem = new PopupMenu.PopupSwitchMenuItem(_("All monitors"), this._allMonitor) this._allMonitorItem.connect('toggled', this._allMonitorToggle.bind(this)); this.menu.addMenuItem(this._allMonitorItem); this._column = new Column(); this.menu.addMenuItem(this._column.menu); this.show(); this.connect('destroy', this._onDestroy.bind(this)); } cascadeWindow() { let windows = this.getWindows(); if (windows.length == 0) return; let workArea = this.getWorkArea(windows[0]); let y = workArea.y + 5; let x = workArea.x + 10; let width = workArea.width * 0.7; let height = workArea.height * 0.7; for (let i = 0; i < windows.length; i++) { let win = windows[i].get_meta_window(); win.unmaximize(Meta.MaximizeFlags.BOTH); win.move_resize_frame(true, x, y, width, height); x = x + CASCADE_WIDTH; y = y + CASCADE_HEIGHT; } } sideBySideWindow() { let windows = this.getWindows(); if (windows.length == 0) return; let workArea = this.getWorkArea(windows[0]); let width = Math.round(workArea.width / windows.length) let y = workArea.y; let x = workArea.x; for (let i = 0; i < windows.length; i++) { let win = windows[i].get_meta_window(); win.unmaximize(Meta.MaximizeFlags.BOTH); win.move_resize_frame(false, x, y, width, workArea.height); x = x + width; } } stackWindow() { let windows = this.getWindows(); if (windows.length == 0) return; let workArea = this.getWorkArea(windows[0]); let height = Math.round(workArea.height / windows.length) let y = workArea.y; let x = workArea.x; for (let i = 0; i < windows.length; i++) { let win = windows[i].get_meta_window(); win.unmaximize(Meta.MaximizeFlags.BOTH); win.move_resize_frame(false, x, y, workArea.width, height); y += height; } } tileWindow() { /* Display all windows in a grid defined by the number of columns in * settings. * * In the last row, the rectangles may be wider so that the remaining * windows equally share the total width. * * Try to assign the windows to the closest rectangle in the grid so that * windows move by the smallest amount. This is important because they * may be pressing tile from a state that is already tiled so wouldn't expect * the windows to change order. * * A quick heuristic to approximate this is to calculate the closest grid position * for each window and then assign them to the closest available in order * of shortest first. */ let windows = this.getWindows(); if (windows.length == 0) return; let workArea = this.getWorkArea(windows[0]); // Get number of columns from settings let columnNumber = parseInt(COLUMN[this._gsettings.get_int(COLUMN_NUMBER)]); // Calculate number of rows based on number of windows and number of columns let rowNumber = Math.ceil(windows.length / columnNumber); // Create the grid let gridCells = []; for (let i = 0; i < windows.length; i ++) { let row = Math.floor(i / columnNumber); let col = i % columnNumber; let gridWidth = Math.floor(workArea.width / columnNumber); let gridHeight = Math.floor(workArea.height / rowNumber); let numLastRow = windows.length % columnNumber; let cell = {}; if (row + 1 === rowNumber && numLastRow !== 0) { // In the last row, recalculate width so that they fill the screen let gridWidthLastRow = Math.floor(workArea.width / numLastRow); cell.x = workArea.x + col * gridWidthLastRow; cell.w = gridWidthLastRow; } else { cell.x = workArea.x + col * gridWidth; cell.w = gridWidth; } cell.y = workArea.y + row * gridHeight; cell.h = gridHeight; cell.centerX = cell.x + cell.w / 2; cell.centerY = cell.y + cell.h / 2; gridCells.push(cell); } // Calculate distances[i][j] as the distance from windows[i] to // gridCells[j]. let distances = []; for (let windowI = 0; windowI < windows.length; windowI ++) { const win = windows[windowI]; const windowCenterX = win.x + win.width / 2; const windowCenterY = win.y + win.height / 2; distances[windowI] = []; for (let cellJ = 0; cellJ < gridCells.length; cellJ ++) { const cell = gridCells[cellJ]; const dist = Math.sqrt((windowCenterX - cell.centerX) ** 2 + (windowCenterY - cell.centerY) ** 2); distances[windowI][cellJ] = dist; } } // Move window into cell function moveWindow(wind, cell) { const win = wind.get_meta_window(); win.unmaximize(Meta.MaximizeFlags.BOTH); win.unminimize(); win.move_resize_frame(false, cell.x, cell.y, cell.w, cell.h); } // Now we can assign windows in order of closest const windowIsToMove = new Set(windows.keys()); const cellJsToFill = new Set(gridCells.keys()); // Move windows, closest to grid position first. for (let i = 0; i < windows.length; i ++) { if (windowIsToMove.size !== cellJsToFill.size) throw Error('Expected to assign one cell per window'); let minDist = Infinity; let minI, minJ; windowIsToMove.forEach(windowI => cellJsToFill.forEach(cellJ => { if (distances[windowI][cellJ] < minDist) { minDist = distances[windowI][cellJ]; minI = windowI; minJ = cellJ; } } ) ); moveWindow(windows[minI], gridCells[minJ]); windowIsToMove.delete(minI); cellJsToFill.delete(minJ); } } maximizeWindow(direction) { if (this._allMonitor == true) { this.maximizeWindowAllMonitor(direction); return; } let windows = this.getWindows(); for (let i = 0; i < windows.length; i++) { let actor = windows[i]; let win = actor.get_meta_window(); win.maximize(direction); } } maximizeWindowAllMonitor(direction) { let windows = this.getWindows(); if (windows.length == 0) return; let workArea = this.getWorkArea(windows[0]); for (let i = 0; i < windows.length; i++) { let win = windows[i].get_meta_window(); switch (direction) { case Meta.MaximizeFlags.BOTH: win.move_resize_frame(true, workArea.x, workArea.y, workArea.width, workArea.height); break; case Meta.MaximizeFlags.VERTICAL: win.move_resize_frame(true, win.get_frame_rect().x, workArea.y, win.get_frame_rect().width, workArea.height); break; case Meta.MaximizeFlags.HORIZONTAL: win.move_resize_frame(true, workArea.x, win.get_frame_rect().y, workArea.width, win.get_frame_rect().height); break; } } } restoringWindow() { let windows = this.getWindows(); for (let i = 0; i < windows.length; i++) { let actor = windows[i]; let win = actor.get_meta_window(); win.unmaximize(Meta.MaximizeFlags.BOTH); } } getWindows() { let currentWorkspace = global.workspace_manager.get_active_workspace(); let windows = global.get_window_actors().filter(actor => { if (actor.meta_window.get_window_type() == Meta.WindowType.NORMAL) return actor.meta_window.located_on_workspace(currentWorkspace); return false; }); if (!(this._allMonitor)) { windows = windows.filter(w => { return w.meta_window.get_monitor() == this.getFocusedMonitor(); }); } return windows; } getFocusedMonitor() { let focusWindow = global.display.get_focus_window(); if (focusWindow) { return focusWindow.get_monitor(); } else { return global.display.get_current_monitor(); } } getWorkArea(window) { if (this._allMonitor) return window.get_meta_window().get_work_area_all_monitors(); else return window.get_meta_window().get_work_area_current_monitor(); } _allMonitorToggle() { this._allMonitor = this._allMonitorItem.state; this._gsettings.set_boolean(ALL_MONITOR, this._allMonitorItem.state); } _getCustIcon(icon_name) { let gicon = Gio.icon_new_for_string( Me.dir.get_child('icons').get_path() + "/" + icon_name + ".svg" ); return gicon; } _onDestroy(){ } }); let Column = GObject.registerClass( class Column extends PanelMenu.SystemIndicator { _init() { super._init(); this._gsettings = Convenience.getSettings(ARRANGEWINDOWS_SCHEMA); this._item = new PopupMenu.PopupBaseMenuItem({ activate: false }); this.menu.addMenuItem(this._item); this._slider = new Slider.Slider(0); this._slider.connect('drag-end', this._sliderChanged.bind(this)); let number = this._gsettings.get_int(COLUMN_NUMBER); this._slider.value = number / 6; this._label = new St.Label({ text: 'Tile x' + COLUMN[number] }); this._item.add(this._label); this._item.add(this._slider); } _sliderChanged() { let number = Math.round(this._slider.value * 6); this._slider.value = number / 6; this._label.set_text('Tile x' + COLUMN[number]); this._gsettings.set_int(COLUMN_NUMBER, number); } }); function addKeybinding() { let modeType = Shell.ActionMode.NORMAL; Main.wm.addKeybinding(HOTKEY_CASCADE, arrange._gsettings, Meta.KeyBindingFlags.NONE, modeType, arrange.cascadeWindow.bind(arrange)); Main.wm.addKeybinding(HOTKEY_TILE, arrange._gsettings, Meta.KeyBindingFlags.NONE, modeType, arrange.tileWindow.bind(arrange)); Main.wm.addKeybinding(HOTKEY_SIDEBYSIDE, arrange._gsettings, Meta.KeyBindingFlags.NONE, modeType, arrange.sideBySideWindow.bind(arrange)); Main.wm.addKeybinding(HOTKEY_STACK, arrange._gsettings, Meta.KeyBindingFlags.NONE, modeType, arrange.stackWindow.bind(arrange)); } function removeKeybinding(){ Main.wm.removeKeybinding(HOTKEY_CASCADE); Main.wm.removeKeybinding(HOTKEY_TILE); Main.wm.removeKeybinding(HOTKEY_SIDEBYSIDE); Main.wm.removeKeybinding(HOTKEY_STACK); } let arrange; function init(metadata) { } function enable() { arrange = new ArrangeMenu; Main.panel.addToStatusArea('arrange-menu', arrange); addKeybinding(); } function disable() { removeKeybinding(); arrange.destroy(); }