diff --git a/tiling-assistant@leleat-on-github/media/tile-base-symbolic.svg b/tiling-assistant@leleat-on-github/media/tile-base-symbolic.svg new file mode 100644 index 00000000..126bc597 --- /dev/null +++ b/tiling-assistant@leleat-on-github/media/tile-base-symbolic.svg @@ -0,0 +1,16 @@ + + + + diff --git a/tiling-assistant@leleat-on-github/media/tile-bottom-symbolic.svg b/tiling-assistant@leleat-on-github/media/tile-bottom-symbolic.svg new file mode 100644 index 00000000..67c176a7 --- /dev/null +++ b/tiling-assistant@leleat-on-github/media/tile-bottom-symbolic.svg @@ -0,0 +1,22 @@ + + + + diff --git a/tiling-assistant@leleat-on-github/media/tile-horizontal-symbolic.svg b/tiling-assistant@leleat-on-github/media/tile-horizontal-symbolic.svg new file mode 100644 index 00000000..a5bead91 --- /dev/null +++ b/tiling-assistant@leleat-on-github/media/tile-horizontal-symbolic.svg @@ -0,0 +1,19 @@ + + + + diff --git a/tiling-assistant@leleat-on-github/media/tile-left-symbolic.svg b/tiling-assistant@leleat-on-github/media/tile-left-symbolic.svg new file mode 100644 index 00000000..b05a1efa --- /dev/null +++ b/tiling-assistant@leleat-on-github/media/tile-left-symbolic.svg @@ -0,0 +1,22 @@ + + + + diff --git a/tiling-assistant@leleat-on-github/media/tile-maximize-symbolic.svg b/tiling-assistant@leleat-on-github/media/tile-maximize-symbolic.svg new file mode 100644 index 00000000..0fdeecbf --- /dev/null +++ b/tiling-assistant@leleat-on-github/media/tile-maximize-symbolic.svg @@ -0,0 +1,19 @@ + + + + diff --git a/tiling-assistant@leleat-on-github/media/tile-q1-symbolic.svg b/tiling-assistant@leleat-on-github/media/tile-q1-symbolic.svg new file mode 100644 index 00000000..62bb30f8 --- /dev/null +++ b/tiling-assistant@leleat-on-github/media/tile-q1-symbolic.svg @@ -0,0 +1,28 @@ + + + + diff --git a/tiling-assistant@leleat-on-github/media/tile-q2-symbolic.svg b/tiling-assistant@leleat-on-github/media/tile-q2-symbolic.svg new file mode 100644 index 00000000..9e951c38 --- /dev/null +++ b/tiling-assistant@leleat-on-github/media/tile-q2-symbolic.svg @@ -0,0 +1,28 @@ + + + + diff --git a/tiling-assistant@leleat-on-github/media/tile-q3-symbolic.svg b/tiling-assistant@leleat-on-github/media/tile-q3-symbolic.svg new file mode 100644 index 00000000..492a17a3 --- /dev/null +++ b/tiling-assistant@leleat-on-github/media/tile-q3-symbolic.svg @@ -0,0 +1,28 @@ + + + + diff --git a/tiling-assistant@leleat-on-github/media/tile-q4-symbolic.svg b/tiling-assistant@leleat-on-github/media/tile-q4-symbolic.svg new file mode 100644 index 00000000..c38b541b --- /dev/null +++ b/tiling-assistant@leleat-on-github/media/tile-q4-symbolic.svg @@ -0,0 +1,28 @@ + + + + diff --git a/tiling-assistant@leleat-on-github/media/tile-quarter-symbolic.svg b/tiling-assistant@leleat-on-github/media/tile-quarter-symbolic.svg new file mode 100644 index 00000000..c2e1e192 --- /dev/null +++ b/tiling-assistant@leleat-on-github/media/tile-quarter-symbolic.svg @@ -0,0 +1,25 @@ + + + + diff --git a/tiling-assistant@leleat-on-github/media/tile-right-symbolic.svg b/tiling-assistant@leleat-on-github/media/tile-right-symbolic.svg new file mode 100644 index 00000000..a333b058 --- /dev/null +++ b/tiling-assistant@leleat-on-github/media/tile-right-symbolic.svg @@ -0,0 +1,22 @@ + + + + diff --git a/tiling-assistant@leleat-on-github/media/tile-top-symbolic.svg b/tiling-assistant@leleat-on-github/media/tile-top-symbolic.svg new file mode 100644 index 00000000..f6346e72 --- /dev/null +++ b/tiling-assistant@leleat-on-github/media/tile-top-symbolic.svg @@ -0,0 +1,22 @@ + + + + diff --git a/tiling-assistant@leleat-on-github/media/tile-vertical-symbolic.svg b/tiling-assistant@leleat-on-github/media/tile-vertical-symbolic.svg new file mode 100644 index 00000000..3ee05ea9 --- /dev/null +++ b/tiling-assistant@leleat-on-github/media/tile-vertical-symbolic.svg @@ -0,0 +1,19 @@ + + + + diff --git a/tiling-assistant@leleat-on-github/src/extension/layoutPicker.js b/tiling-assistant@leleat-on-github/src/extension/layoutPicker.js new file mode 100644 index 00000000..60bf85ea --- /dev/null +++ b/tiling-assistant@leleat-on-github/src/extension/layoutPicker.js @@ -0,0 +1,382 @@ +import { Clutter, GObject, St, Gio } from '../dependencies/gi.js'; +import { Main, Extension } from '../dependencies/shell.js'; + +const LayoutPickerVisibility = { + HIDDEN: 0, + PEAK: 1, + SHOWN: 2 +}; + +function iconPath(name) { + const path = Extension.lookupByURL(import.meta.url) + .dir + .get_child(`media/${name}-symbolic.svg`) + .get_path(); + + return path; +} + +export const LayoutPickerTileType = { + NONE: 0, + LEFT: 1, + RIGHT: 2, + TOP: 3, + BOTTOM: 4, + Q1: 5, + Q2: 6, + Q3: 7, + Q4: 8, + MAXIMIZE: 9 +}; + +// ommit '-symbolic.svg' as it is managed by iconPath function already +const ICONS = { + vertical: { + none: 'tile-vertical', + + states: { + [LayoutPickerTileType.TOP]: 'tile-top', + [LayoutPickerTileType.BOTTOM]: 'tile-bottom' + } + }, + + horizontal: { + none: 'tile-horizontal', + + states: { + [LayoutPickerTileType.LEFT]: 'tile-left', + [LayoutPickerTileType.RIGHT]: 'tile-right' + } + }, + + quarter: { + none: 'tile-quarter', + + states: { + [LayoutPickerTileType.Q1]: 'tile-q1', + [LayoutPickerTileType.Q2]: 'tile-q2', + [LayoutPickerTileType.Q3]: 'tile-q3', + [LayoutPickerTileType.Q4]: 'tile-q4' + } + }, + + maximize: { + none: 'tile-base', + + states: { + [LayoutPickerTileType.MAXIMIZE]: 'tile-maximize' + } + } +}; + +export const LayoutPicker = GObject.registerClass( +class LayoutPicker extends St.Bin { + _init() { + super._init({ + style_class: 'tiling-menu-container' + }); + + this._container = new St.BoxLayout({ + x_expand: true, + orientation: Clutter.Orientation.HORIZONTAL, + style_class: 'popup-menu-content' + }); + + this._visibility = LayoutPickerVisibility.HIDDEN; + this._dragging = false; + + this.set_child(this._container); + + this._addChrome(); + + this._icons = {}; + + for (const [group, config] of Object.entries(ICONS)) { + // start with icons that appear to be not hovered + const icon = this._createIcon(config.none); + + config.icon = icon; + this._icons[group] = icon; + + this._container.add_child(icon); + } + + this._tileType = LayoutPickerTileType.NONE; + + // e.g ubuntu dock is enabled and or disabled + global.display.connectObject('workareas-changed', () => { + this._updateAllocation(global.display.get_current_monitor()); + }, this); + + Main.layoutManager.connectObject('monitors-changed', () => { + this._updateAllocation(global.display.get_current_monitor()); + }, this); + + // just in case extension is enabled and disable + this._updateAllocation(global.display.get_current_monitor()); + + this.connectObject('notify::translation-y', () => { + this.set_clip( + 0, + this.height - this.translation_y, + this.width, + this.height + ); + }, this); + } + + get tileType() { + return this._tileType; + } + + get picking() { + return this._tileType !== LayoutPickerTileType.NONE; + } + + _setVisibility(visibility) { + if (this._visibility === visibility) + return; + + this._visibility = visibility; + + this.opacity = 255; + this.reactive = true; + + + let positions = [ + 0, + this._container.get_theme_node().get_padding(St.Side.Bottom) + + this.get_theme_node().get_padding(St.Side.BOTTOM), + this.height + ]; + + this.remove_all_transitions(); + + this.ease({ + translation_y: positions[visibility], + duration: 250, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this.opacity = this._visibility !== LayoutPickerVisibility.HIDDEN ? 255 : 0; + this.reactive = this._visibility !== LayoutPickerVisibility.HIDDEN; + + // once picker is fully shown onMoving might not be able to update tile type + // for situations like no cursor update after picker is fully shown. + // this could causes incorrect tile type shown. + if (this._visibility === LayoutPickerVisibility.SHOWN) { + let [curX, curY] = global.get_pointer(); + this._updateLayoutPickerTileType(curX, curY); + } + } + + }); + } + + onMoving(curX, curY) { + if (this._dragging === false) + return; + + this._updateAllocation(global.display.get_current_monitor()); + + let [w, h] = this.get_size(); + let [mx, my_] = this.get_transformed_position(); + + const themeNode = this.get_theme_node(); + let paddingLeft = themeNode.get_padding(St.Side.LEFT); + let paddingRight = themeNode.get_padding(St.Side.RIGHT); + let paddingBottom = this.get_theme_node().get_padding(St.Side.BOTTOM); + + const monitorIndex = global.display.get_current_monitor(); + const monitorArea = Main.layoutManager.monitors[monitorIndex]; + const activeWs = global.workspace_manager.get_active_workspace(); + const workArea = activeWs.get_work_area_for_monitor(monitorIndex); + + // using monitorArea.y instead of workArea.y as upper bound to compensate with chromes such us the top bar height + // placing cursor above workArea.y causes visibility glitch. workArea.y and monitorArea.y will be same for other monitor anyways. + if ( + curY >= monitorArea.y && + curY <= workArea.y + h - paddingBottom && + curX >= mx + paddingLeft && + curX <= mx + w - paddingRight + ) + this._setVisibility(LayoutPickerVisibility.SHOWN); + else + this._setVisibility(LayoutPickerVisibility.PEAK); + + this._updateLayoutPickerTileType(curX, curY); + } + + onMoveStarted() { + this._dragging = true; + this._setVisibility(LayoutPickerVisibility.PEAK); + } + + onMoveFinished() { + this._dragging = false; + this._setVisibility(LayoutPickerVisibility.HIDDEN); + } + + _setLayoutPickerIcon(tileType) { + this._clearIcons(); + + for (const config of Object.values(ICONS)) { + const iconName = config.states[tileType]; + + if (!iconName) + continue; + + this._updateIcon(config.icon, iconName); + break; + } + } + + _createIcon(name) { + let fallback_gicon = Gio.FileIcon.new( + Gio.File.new_for_path(iconPath(name)) + ); + + let gicon = new Gio.ThemedIcon({ + name: `${name}-symbolic` + }); + + return new St.Icon({ + gicon, + fallback_gicon + }); + } + + _updateIcon(icon, name) { + let fallback_gicon = Gio.FileIcon.new( + Gio.File.new_for_path(iconPath(name)) + ); + + let gicon = new Gio.ThemedIcon({ + name: `${name}-symbolic` + }); + icon.set({ + gicon, + fallback_gicon + }); + } + + _clearIcons() { + for (const config of Object.values(ICONS)) + this._updateIcon(config.icon, config.none); + } + + _updateLayoutPickerTileType(curX, curY) { + let rect = icon => { + let [x, y] = icon.get_transformed_position(); + let [w, h] = icon.get_size(); + return { x, y, w, h }; + }; + + let contains = ({ x, y, w, h }) => ( + curX >= x && + curX <= x + w && + curY >= y && + curY <= y + h + ); + + const horizontal = rect(this._icons.horizontal); + + if (contains(horizontal)) { + const leftPortion = curX < horizontal.x + horizontal.w / 2; + + this._tileType = leftPortion + ? LayoutPickerTileType.LEFT + : LayoutPickerTileType.RIGHT; + + this._setLayoutPickerIcon(this._tileType); + return; + } + + const vertical = rect(this._icons.vertical); + + if (contains(vertical)) { + const topPortion = curY <= horizontal.y + horizontal.h / 2; + + this._tileType = topPortion + ? LayoutPickerTileType.TOP + : LayoutPickerTileType.BOTTOM; + + this._setLayoutPickerIcon(this._tileType); + return; + } + + const quarter = rect(this._icons.quarter); + + if (contains(quarter)) { + const leftPortion = curX < quarter.x + quarter.w / 2; + const topPortion = curY <= quarter.y + quarter.h / 2; + + if (topPortion) + this._tileType = leftPortion + ? LayoutPickerTileType.Q2 + : LayoutPickerTileType.Q1; + else + this._tileType = leftPortion + ? LayoutPickerTileType.Q3 + : LayoutPickerTileType.Q4; + + this._setLayoutPickerIcon(this._tileType); + return; + } + + this._tileType = contains(rect(this._icons.maximize)) + ? LayoutPickerTileType.MAXIMIZE + : LayoutPickerTileType.NONE; + + this._setLayoutPickerIcon(this._tileType); + } + + _updateAllocation(monitorIndex) { + const activeWs = global.workspace_manager.get_active_workspace(); + const workArea = activeWs.get_work_area_for_monitor(monitorIndex); + + if (workArea === null) + return; + + const [, natWidth] = this.get_preferred_width(-1); + const [, natHeight] = this.get_preferred_height(-1); + + const targetX = Math.round(workArea.x + (workArea.width - natWidth) / 2); + const targetY = Math.round(workArea.y - natHeight); + + if (targetX === this.x && targetY === this.y) + return; + + this.set_position(targetX, targetY); + + this.remove_all_transitions(); + + this._visibility = LayoutPickerVisibility.HIDDEN; + this.translation_y = 0; + this.opacity = 0; + this.reactive = false; + } + + _addChrome() { + Main.layoutManager.addChrome(this, { + affectsStruts: false, + trackFullscreen: false + }); + } + + _untrackChrome() { + Main.layoutManager.untrackChrome(this); + } + + destroy() { + this._untrackChrome(); + + global.display.disconnectObject(this); + Main.layoutManager.disconnectObject(this); + + this._container?.destroy(); + this._container = null; + + super.destroy(); + } +}); + diff --git a/tiling-assistant@leleat-on-github/src/extension/moveHandler.js b/tiling-assistant@leleat-on-github/src/extension/moveHandler.js index 53034a4b..64a171b4 100644 --- a/tiling-assistant@leleat-on-github/src/extension/moveHandler.js +++ b/tiling-assistant@leleat-on-github/src/extension/moveHandler.js @@ -5,6 +5,7 @@ import { WINDOW_ANIMATION_TIME } from '../dependencies/unexported/windowManager. import { MoveModes, Orientation, Settings } from '../common.js'; import { Rect, Util } from './utility.js'; import { TilingWindowManager as Twm } from './tilingWindowManager.js'; +import { LayoutPicker, LayoutPickerTileType } from './layoutPicker.js'; /** * This class gets to handle the move events (grab & monitor change) of windows. @@ -93,6 +94,8 @@ export default class TilingMoveHandler { this ); handleWindowActionKeyConflict(); + + this._layoutPicker = new LayoutPicker(); } destroy() { @@ -104,6 +107,8 @@ export default class TilingMoveHandler { this._tilePreview.destroy(); + this._layoutPicker.destroy(); + if (this._latestMonitorLockTimerId) { GLib.Source.remove(this._latestMonitorLockTimerId); this._latestMonitorLockTimerId = null; @@ -142,6 +147,8 @@ export default class TilingMoveHandler { _onMoveStarted(window, grabOp) { if (window.is_skip_taskbar()) return; + + this._layoutPicker.onMoveStarted(); // Also work with a window, which was maximized by GNOME natively // because it may have been tiled with this extension before being @@ -153,6 +160,8 @@ export default class TilingMoveHandler { // Try to restore the window size if (window.tiledRect || this._wasMaximizedOnStart) { + this._layoutPicker.onMoveFinished(); + let counter = 0; this._restoreSizeTimerId && GLib.Source.remove(this._restoreSizeTimerId); this._restoreSizeTimerId = GLib.timeout_add(GLib.PRIORITY_HIGH_IDLE, 10, () => { @@ -237,6 +246,8 @@ export default class TilingMoveHandler { } _onMoveFinished(window) { + this._layoutPicker.onMoveFinished(); + try { window.assertExistence(); @@ -304,6 +315,8 @@ export default class TilingMoveHandler { const [x, y] = this.getDragCoords(); const currPointerPos = { x, y }; + this._layoutPicker.onMoving(x, y); + if (lowPerfMode) { if (!this._isGrabOp) { this._movingTimerId = null; @@ -496,23 +509,39 @@ export default class TilingMoveHandler { const wRect = window.get_frame_rect(); const workArea = new Rect(window.get_work_area_for_monitor(this._monitorNr)); + const layoutPickerTileType = this._layoutPicker.tileType; + const vDetectionSize = Settings.getInt('vertical-preview-area'); - const pointerAtTopEdge = this._lastPointerPos.y <= workArea.y + vDetectionSize; - const pointerAtBottomEdge = this._lastPointerPos.y >= workArea.y2 - vDetectionSize; + let pointerAtTopEdge = this._lastPointerPos.y <= workArea.y + vDetectionSize || + layoutPickerTileType === LayoutPickerTileType.TOP || + layoutPickerTileType === LayoutPickerTileType.MAXIMIZE; + let pointerAtBottomEdge = this._lastPointerPos.y >= workArea.y2 - vDetectionSize || + layoutPickerTileType === LayoutPickerTileType.BOTTOM; const hDetectionSize = Settings.getInt('horizontal-preview-area'); - const pointerAtLeftEdge = this._lastPointerPos.x <= workArea.x + hDetectionSize; - const pointerAtRightEdge = this._lastPointerPos.x >= workArea.x2 - hDetectionSize; + let pointerAtLeftEdge = this._lastPointerPos.x <= workArea.x + hDetectionSize || + layoutPickerTileType === LayoutPickerTileType.LEFT; + let pointerAtRightEdge = this._lastPointerPos.x >= workArea.x2 - hDetectionSize || + layoutPickerTileType === LayoutPickerTileType.RIGHT; // Also use window's pos for top and bottom area detection for quarters // because global.get_pointer's y isn't accurate (no idea why...) when // grabbing the titlebar & slowly going from the left/right sides to // the top/bottom corners. const titleBarGrabbed = this._lastPointerPos.y - wRect.y < 50; - const windowAtTopEdge = titleBarGrabbed && wRect.y === workArea.y; - const windowAtBottomEdge = wRect.y >= workArea.y2 - 75; - const tileTopLeftQuarter = pointerAtLeftEdge && (pointerAtTopEdge || windowAtTopEdge); - const tileTopRightQuarter = pointerAtRightEdge && (pointerAtTopEdge || windowAtTopEdge); - const tileBottomLeftQuarter = pointerAtLeftEdge && (pointerAtBottomEdge || windowAtBottomEdge); - const tileBottomRightQuarter = pointerAtRightEdge && (pointerAtBottomEdge || windowAtBottomEdge); + const windowAtTopEdge = titleBarGrabbed && wRect.y === workArea.y && !this._layoutPicker.picking; + const windowAtBottomEdge = wRect.y >= workArea.y2 - 75 && !this._layoutPicker.picking; + const tileTopLeftQuarter = pointerAtLeftEdge && (pointerAtTopEdge || windowAtTopEdge) || + layoutPickerTileType === LayoutPickerTileType.Q2; + const tileTopRightQuarter = pointerAtRightEdge && (pointerAtTopEdge || windowAtTopEdge) || + layoutPickerTileType === LayoutPickerTileType.Q1; + const tileBottomLeftQuarter = pointerAtLeftEdge && (pointerAtBottomEdge || windowAtBottomEdge)|| + layoutPickerTileType === LayoutPickerTileType.Q3; + const tileBottomRightQuarter = pointerAtRightEdge && (pointerAtBottomEdge || windowAtBottomEdge) || + layoutPickerTileType === LayoutPickerTileType.Q4; + + // we cannot just for example do this: + // const tileTopLeftQuarter = pointerAtLeftEdge && (pointerAtTopEdge || windowAtTopEdge) || layoutPickerTileType == LayoutPickerTileType.Q2; + // this can be buggy when both are true like triggering top right preview even when in LayoutPickerTileType.RIGHT + // so reassigning the value is a must if (tileTopLeftQuarter) { this._tileRect = Twm.getTileFor('tile-topleft-quarter', workArea, this._monitorNr); @@ -533,10 +562,10 @@ export default class TilingMoveHandler { const shouldMaximize = isLandscape && !Settings.getBoolean('enable-hold-maximize-inverse-landscape') || !isLandscape && !Settings.getBoolean('enable-hold-maximize-inverse-portrait'); - const tileRect = shouldMaximize + const tileRect = shouldMaximize && !this._layoutPicker.picking || layoutPickerTileType === LayoutPickerTileType.MAXIMIZE ? workArea : Twm.getTileFor('tile-top-half', workArea, this._monitorNr); - const holdTileRect = shouldMaximize + const holdTileRect = shouldMaximize && !this._layoutPicker.picking || layoutPickerTileType === LayoutPickerTileType.TOP ? Twm.getTileFor('tile-top-half', workArea, this._monitorNr) : workArea; // Dont open preview / start new timer if preview was already one for the top diff --git a/tiling-assistant@leleat-on-github/stylesheet.css b/tiling-assistant@leleat-on-github/stylesheet.css index 27878117..eb7f28b0 100644 --- a/tiling-assistant@leleat-on-github/stylesheet.css +++ b/tiling-assistant@leleat-on-github/stylesheet.css @@ -6,3 +6,17 @@ .layout-shortcut .boxed-list { box-shadow: none; } + +.tiling-menu-container { + padding: 20px; +} + +.tiling-menu-container > StBoxLayout { + spacing: 15px; + padding: 15px; + border-radius: 20px; +} + +.tiling-menu-container > StBoxLayout > StIcon { + icon-size: 64px; +}