mirror of
https://invent.kde.org/marcoa/shift-shell.git
synced 2026-06-11 00:47:22 +00:00
Make snap layouts optional on maximize hover
Move maximize-hover handling into the snap-assist effect so it can use the actual decoration button geometry. Render the scene below the picker, anchor layouts near the cursor, and preview the selected zone without a fullscreen modal surface. A normal maximize click keeps priority: hover activation requires a deliberate pause, dismissal starts a short cooldown, and clicks on the maximize button still toggle maximize while closing the suggestion.
This commit is contained in:
parent
ba1428851e
commit
2c0e329037
4 changed files with 517 additions and 139 deletions
|
|
@ -181,10 +181,9 @@ Decoration {
|
|||
}
|
||||
}
|
||||
|
||||
// Snap-assist hover trigger lives in the shift-tiling KWin script:
|
||||
// the decoration QML sandbox has no DBus / kglobalaccel access, so
|
||||
// the script polls the cursor over the active window's titlebar
|
||||
// and invokes the SHIFT Snap Assist shortcut after a short hover.
|
||||
// Snap-layout hover activation lives in the SHIFT Snap Assist effect.
|
||||
// The decoration only renders the maximize button; the effect decides
|
||||
// when maximize-hover is eligible and shows the popup.
|
||||
|
||||
Rectangle {
|
||||
visible: !isSpacer
|
||||
|
|
|
|||
|
|
@ -6,19 +6,252 @@
|
|||
// Presents a floating panel of layout preset buttons.
|
||||
// Activated by:
|
||||
// 1. Meta+Shift+S keyboard shortcut
|
||||
// 2. The SHIFT decoration invoking the kglobalaccel shortcut on maximize-hover
|
||||
// 2. Hovering the maximize button in convergence mode while dynamic tiling is off
|
||||
//
|
||||
// When a preset is clicked, the active window's frameGeometry is set to the
|
||||
// chosen zone (with outer gaps applied). If a second zone is non-empty a
|
||||
// "snap assist" strip shows recently-used windows as targets.
|
||||
// chosen zone (with outer gaps applied).
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import org.kde.kwin as KWinComponents
|
||||
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
|
||||
|
||||
KWinComponents.SceneEffect {
|
||||
id: effect
|
||||
|
||||
visible: false
|
||||
|
||||
readonly property bool snapLayoutsEligible: ShellSettings.Settings.convergenceModeEnabled
|
||||
&& !ShellSettings.Settings.gamingModeEnabled
|
||||
&& !ShellSettings.Settings.dynamicTilingEnabled
|
||||
readonly property int hoverBarHeight: 30
|
||||
readonly property int decorationButtonSize: 16
|
||||
readonly property int decorationButtonSpacing: 8
|
||||
readonly property int decorationButtonSideMargin: 12
|
||||
readonly property int maximizeButtonPadding: 4
|
||||
readonly property int hoverTimerInterval: 75
|
||||
readonly property int hoverDwellTicks: 14
|
||||
readonly property int hoverMoveTolerance: 5
|
||||
readonly property int hoverCooldownMs: 1200
|
||||
readonly property int panelDismissMargin: 24
|
||||
readonly property int panelScreenMargin: 8
|
||||
readonly property int panelCursorGap: 12
|
||||
readonly property int panelCursorRightBias: 34
|
||||
property var hoverWindowId: null
|
||||
property int hoverTicks: 0
|
||||
property string hoverWindowStateKey: ""
|
||||
property bool hoverSuppressedUntilLeave: false
|
||||
property point hoverAnchorPos: Qt.point(0, 0)
|
||||
property double hoverCooldownUntil: 0
|
||||
property var panelAnchorPos: Qt.point(0, 0)
|
||||
property string panelAnchorScreenName: ""
|
||||
property bool previewVisible: false
|
||||
property rect previewGeometry: Qt.rect(0, 0, 0, 0)
|
||||
property rect previewArea: Qt.rect(0, 0, 0, 0)
|
||||
property var previewZones: []
|
||||
property int previewActiveIndex: -1
|
||||
property string previewScreenName: ""
|
||||
|
||||
function resetHoverState() {
|
||||
hoverWindowId = null;
|
||||
hoverTicks = 0;
|
||||
hoverWindowStateKey = "";
|
||||
hoverSuppressedUntilLeave = false;
|
||||
hoverAnchorPos = Qt.point(0, 0);
|
||||
}
|
||||
|
||||
function resetHoverCandidate(win) {
|
||||
hoverWindowId = win ? win.internalId : null;
|
||||
hoverTicks = 1;
|
||||
hoverWindowStateKey = win ? windowStateKey(win) : "";
|
||||
hoverSuppressedUntilLeave = false;
|
||||
hoverAnchorPos = KWinComponents.Workspace.cursorPos;
|
||||
}
|
||||
|
||||
function setHoverCooldown() {
|
||||
hoverCooldownUntil = Date.now() + hoverCooldownMs;
|
||||
}
|
||||
|
||||
function hoverOnCooldown() {
|
||||
return Date.now() < hoverCooldownUntil;
|
||||
}
|
||||
|
||||
function hoverMovedTooFar() {
|
||||
const cursor = KWinComponents.Workspace.cursorPos;
|
||||
const dx = cursor.x - hoverAnchorPos.x;
|
||||
const dy = cursor.y - hoverAnchorPos.y;
|
||||
return dx * dx + dy * dy > hoverMoveTolerance * hoverMoveTolerance;
|
||||
}
|
||||
|
||||
function windowStateKey(win) {
|
||||
const geometry = win.frameGeometry;
|
||||
const maximized = win.maximized === undefined ? "" : win.maximized;
|
||||
return maximized + ":" + geometry.x + "," + geometry.y + "," + geometry.width + "x" + geometry.height;
|
||||
}
|
||||
|
||||
function hideSnapLayouts() {
|
||||
if (visible) {
|
||||
setHoverCooldown();
|
||||
}
|
||||
visible = false;
|
||||
resetHoverState();
|
||||
panelAnchorScreenName = "";
|
||||
hideSnapPreview();
|
||||
}
|
||||
|
||||
function showSnapLayouts() {
|
||||
const win = KWinComponents.Workspace.activeWindow;
|
||||
panelAnchorPos = KWinComponents.Workspace.cursorPos;
|
||||
panelAnchorScreenName = win && win.output ? win.output.name : "";
|
||||
visible = true;
|
||||
}
|
||||
|
||||
function toggleActiveWindowMaximized() {
|
||||
const win = KWinComponents.Workspace.activeWindow;
|
||||
if (!win || !win.normalWindow || win.fullScreen || !win.maximizable) {
|
||||
hideSnapLayouts();
|
||||
return;
|
||||
}
|
||||
|
||||
const maximize = !win.maximized;
|
||||
win.setMaximize(maximize, maximize);
|
||||
hideSnapLayouts();
|
||||
setHoverCooldown();
|
||||
}
|
||||
|
||||
function hideSnapPreview() {
|
||||
previewVisible = false;
|
||||
previewScreenName = "";
|
||||
previewZones = [];
|
||||
previewActiveIndex = -1;
|
||||
KWinComponents.Workspace.hideOutline();
|
||||
}
|
||||
|
||||
function showSnapPreview(preset, activeIndex) {
|
||||
const win = KWinComponents.Workspace.activeWindow;
|
||||
if (!win || !win.output) {
|
||||
hideSnapPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
const desktop = win.desktops.length > 0 ? win.desktops[0] : null;
|
||||
if (!desktop) {
|
||||
hideSnapPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
const area = KWinComponents.Workspace.clientArea(KWinComponents.Workspace.MaximizeArea, win.output, desktop);
|
||||
const zone = preset.zones[activeIndex];
|
||||
const gap = effect.outerGap;
|
||||
previewGeometry = Qt.rect(
|
||||
area.x + Math.round(zone.x * area.width) + gap,
|
||||
area.y + Math.round(zone.y * area.height) + gap,
|
||||
Math.round(zone.w * area.width) - 2 * gap,
|
||||
Math.round(zone.h * area.height) - 2 * gap
|
||||
);
|
||||
previewArea = area;
|
||||
previewZones = preset.zones;
|
||||
previewActiveIndex = activeIndex;
|
||||
previewScreenName = win.output.name;
|
||||
previewVisible = true;
|
||||
KWinComponents.Workspace.hideOutline();
|
||||
}
|
||||
|
||||
function cursorInActiveWindowMaximizeStrip() {
|
||||
const win = KWinComponents.Workspace.activeWindow;
|
||||
if (!win || !win.normalWindow || win.fullScreen || !win.maximizable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cursor = KWinComponents.Workspace.cursorPos;
|
||||
const button = maximizeButtonRect(win);
|
||||
if (button.width <= 0 || button.height <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return cursor.x >= button.x - effect.maximizeButtonPadding
|
||||
&& cursor.x <= button.x + button.width + effect.maximizeButtonPadding
|
||||
&& cursor.y >= button.y - effect.maximizeButtonPadding
|
||||
&& cursor.y <= button.y + button.height + effect.maximizeButtonPadding;
|
||||
}
|
||||
|
||||
function decorationButtonVisible(code) {
|
||||
return code === "M" || code === "N" || code === "I" || code === "A" || code === "X";
|
||||
}
|
||||
|
||||
function visibleDecorationButtons(sequence) {
|
||||
const buttons = [];
|
||||
for (let i = 0; i < sequence.length; i++) {
|
||||
const code = sequence[i];
|
||||
if (decorationButtonVisible(code)) {
|
||||
buttons.push(code);
|
||||
}
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
function maximizeButtonRect(win) {
|
||||
const fg = win.frameGeometry;
|
||||
const buttonY = fg.y + Math.round((effect.hoverBarHeight - effect.decorationButtonSize) / 2);
|
||||
const leftButtons = visibleDecorationButtons(ShellSettings.KWinSettings.titleButtonsOnLeft);
|
||||
const leftIndex = leftButtons.indexOf("A");
|
||||
if (leftIndex >= 0) {
|
||||
return Qt.rect(
|
||||
fg.x + effect.decorationButtonSideMargin + leftIndex * (effect.decorationButtonSize + effect.decorationButtonSpacing),
|
||||
buttonY,
|
||||
effect.decorationButtonSize,
|
||||
effect.decorationButtonSize
|
||||
);
|
||||
}
|
||||
|
||||
const rightButtons = visibleDecorationButtons(ShellSettings.KWinSettings.titleButtonsOnRight);
|
||||
const rightIndex = rightButtons.indexOf("A");
|
||||
if (rightIndex >= 0) {
|
||||
const rowWidth = rightButtons.length * effect.decorationButtonSize
|
||||
+ Math.max(0, rightButtons.length - 1) * effect.decorationButtonSpacing;
|
||||
return Qt.rect(
|
||||
fg.x + fg.width - effect.decorationButtonSideMargin - rowWidth
|
||||
+ rightIndex * (effect.decorationButtonSize + effect.decorationButtonSpacing),
|
||||
buttonY,
|
||||
effect.decorationButtonSize,
|
||||
effect.decorationButtonSize
|
||||
);
|
||||
}
|
||||
|
||||
return Qt.rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function cursorInPanel(screen) {
|
||||
if (!screen) {
|
||||
return false;
|
||||
}
|
||||
if (panelAnchorScreenName !== "" && panelAnchorScreenName !== screen.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cursor = KWinComponents.Workspace.cursorPos;
|
||||
const panel = panelRect(screen);
|
||||
return cursor.x >= panel.x - panelDismissMargin
|
||||
&& cursor.x <= panel.x + panel.width + panelDismissMargin
|
||||
&& cursor.y >= panel.y - panelDismissMargin
|
||||
&& cursor.y <= panel.y + panel.height + panelDismissMargin;
|
||||
}
|
||||
|
||||
function panelRect(screen) {
|
||||
const minX = screen.geometry.x + panelScreenMargin;
|
||||
const maxX = screen.geometry.x + screen.geometry.width - snapPanelWidth - panelScreenMargin;
|
||||
const minY = screen.geometry.y + panelScreenMargin;
|
||||
const maxY = screen.geometry.y + screen.geometry.height - snapPanelHeight - panelScreenMargin;
|
||||
const wantedX = panelAnchorPos.x - snapPanelWidth + panelCursorRightBias;
|
||||
const wantedY = panelAnchorPos.y + panelCursorGap;
|
||||
return Qt.rect(
|
||||
Math.max(minX, Math.min(maxX, wantedX)),
|
||||
Math.max(minY, Math.min(maxY, wantedY)),
|
||||
snapPanelWidth,
|
||||
snapPanelHeight
|
||||
);
|
||||
}
|
||||
|
||||
// ── Visibility ────────────────────────────────────────────────────────
|
||||
|
||||
// The effect starts invisible; toggle via shortcut.
|
||||
|
|
@ -28,14 +261,116 @@ KWinComponents.SceneEffect {
|
|||
name: "SHIFT Snap Assist"
|
||||
text: "SHIFT Snap Assist: Show snap layout picker"
|
||||
sequence: "Meta+Shift+S"
|
||||
onActivated: effect.visible = !effect.visible
|
||||
onActivated: {
|
||||
if (!effect.snapLayoutsEligible) {
|
||||
effect.hideSnapLayouts();
|
||||
return;
|
||||
}
|
||||
if (effect.visible) {
|
||||
effect.hideSnapLayouts();
|
||||
} else {
|
||||
effect.showSnapLayouts();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KWinComponents.ShortcutHandler {
|
||||
name: "SHIFT Snap Assist Escape"
|
||||
text: "SHIFT Snap Assist: Hide snap layout picker"
|
||||
sequence: "Esc"
|
||||
onActivated: effect.hideSnapLayouts()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: hoverTimer
|
||||
interval: effect.hoverTimerInterval
|
||||
repeat: true
|
||||
running: effect.snapLayoutsEligible
|
||||
|
||||
onTriggered: {
|
||||
if (effect.visible) {
|
||||
const screen = KWinComponents.Workspace.activeWindow ? KWinComponents.Workspace.activeWindow.output : null;
|
||||
if (!effect.cursorInActiveWindowMaximizeStrip() && !effect.cursorInPanel(screen)) {
|
||||
effect.hideSnapLayouts();
|
||||
} else {
|
||||
effect.resetHoverState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const win = KWinComponents.Workspace.activeWindow;
|
||||
if (!win || !win.normalWindow || win.fullScreen || !win.maximizable) {
|
||||
effect.resetHoverState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!effect.cursorInActiveWindowMaximizeStrip()) {
|
||||
effect.resetHoverState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (effect.hoverOnCooldown()) {
|
||||
effect.resetHoverState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (effect.hoverWindowId !== win.internalId) {
|
||||
effect.resetHoverCandidate(win);
|
||||
return;
|
||||
}
|
||||
|
||||
if (effect.hoverMovedTooFar()) {
|
||||
effect.resetHoverCandidate(win);
|
||||
return;
|
||||
}
|
||||
|
||||
const stateKey = effect.windowStateKey(win);
|
||||
if (stateKey !== effect.hoverWindowStateKey) {
|
||||
effect.hoverWindowStateKey = stateKey;
|
||||
effect.hoverTicks = 0;
|
||||
effect.hoverSuppressedUntilLeave = true;
|
||||
effect.setHoverCooldown();
|
||||
return;
|
||||
}
|
||||
|
||||
if (effect.hoverSuppressedUntilLeave) {
|
||||
return;
|
||||
}
|
||||
|
||||
effect.hoverTicks++;
|
||||
if (effect.hoverTicks >= effect.hoverDwellTicks) {
|
||||
effect.showSnapLayouts();
|
||||
effect.resetHoverState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-hide when a window starts being moved (decoration hover path)
|
||||
Connections {
|
||||
target: KWinComponents.Workspace
|
||||
function onActiveWindowChanged() {
|
||||
// Keep visible so decoration can re-trigger
|
||||
effect.resetHoverState();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: ShellSettings.Settings
|
||||
|
||||
function onConvergenceModeEnabledChanged() {
|
||||
if (!effect.snapLayoutsEligible) {
|
||||
effect.hideSnapLayouts();
|
||||
}
|
||||
}
|
||||
|
||||
function onGamingModeEnabledChanged() {
|
||||
if (!effect.snapLayoutsEligible) {
|
||||
effect.hideSnapLayouts();
|
||||
}
|
||||
}
|
||||
|
||||
function onDynamicTilingEnabledChanged() {
|
||||
if (!effect.snapLayoutsEligible) {
|
||||
effect.hideSnapLayouts();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +381,7 @@ KWinComponents.SceneEffect {
|
|||
// Each preset is an array of zone descriptors:
|
||||
// { x, y, w, h } in relative [0..1] coordinates (of work area).
|
||||
// The first zone is where the ACTIVE window will be placed.
|
||||
// Remaining zones are shown as snap-assist targets.
|
||||
// Remaining zones are currently visual-only.
|
||||
|
||||
readonly property var presets: [
|
||||
{
|
||||
|
|
@ -97,6 +432,16 @@ KWinComponents.SceneEffect {
|
|||
}
|
||||
]
|
||||
|
||||
readonly property int snapButtonWidth: 58
|
||||
readonly property int snapButtonHeight: 38
|
||||
readonly property int snapButtonSpacing: 8
|
||||
readonly property int snapPanelHorizontalPadding: 28
|
||||
readonly property int snapPanelVerticalPadding: 34
|
||||
readonly property int snapPanelWidth: presets.length * snapButtonWidth
|
||||
+ Math.max(0, presets.length - 1) * snapButtonSpacing
|
||||
+ snapPanelHorizontalPadding
|
||||
readonly property int snapPanelHeight: snapButtonHeight + snapPanelVerticalPadding + 23
|
||||
|
||||
// Apply a zone (in relative coords) to a window given a work area rect.
|
||||
function applyZone(win, zone, area) {
|
||||
const g = effect.outerGap;
|
||||
|
|
@ -113,18 +458,103 @@ KWinComponents.SceneEffect {
|
|||
delegate: Rectangle {
|
||||
id: screenDelegate
|
||||
|
||||
// Transparent background — clicks outside the panel pass through
|
||||
readonly property var targetScreen: KWinComponents.SceneView.screen
|
||||
readonly property rect popupRect: effect.panelRect(targetScreen)
|
||||
|
||||
color: "transparent"
|
||||
|
||||
// The panel sits at the top-right of the screen
|
||||
SnapPanel {
|
||||
anchors {
|
||||
top: parent.top
|
||||
topMargin: 48 // below typical titlebar height
|
||||
right: parent.right
|
||||
rightMargin: 16
|
||||
KWinComponents.DesktopBackground {
|
||||
anchors.fill: parent
|
||||
z: -100
|
||||
activity: KWinComponents.Workspace.currentActivity
|
||||
desktop: KWinComponents.Workspace.currentDesktop
|
||||
outputName: screenDelegate.targetScreen.name
|
||||
}
|
||||
|
||||
Instantiator {
|
||||
model: KWinComponents.WindowFilterModel {
|
||||
desktop: KWinComponents.Workspace.currentDesktop
|
||||
screenName: screenDelegate.targetScreen.name
|
||||
windowModel: stackModel
|
||||
minimizedWindows: false
|
||||
windowType: ~KWinComponents.WindowFilterModel.Desktop
|
||||
& ~KWinComponents.WindowFilterModel.Notification
|
||||
& ~KWinComponents.WindowFilterModel.CriticalNotification
|
||||
}
|
||||
|
||||
KWinComponents.WindowThumbnail {
|
||||
wId: model.window.internalId
|
||||
x: model.window.x - screenDelegate.targetScreen.geometry.x
|
||||
y: model.window.y - screenDelegate.targetScreen.geometry.y
|
||||
z: model.window.stackingOrder
|
||||
visible: !model.window.hidden
|
||||
}
|
||||
|
||||
onObjectAdded: (index, object) => {
|
||||
object.parent = screenDelegate
|
||||
}
|
||||
}
|
||||
|
||||
KWinComponents.WindowModel {
|
||||
id: stackModel
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: effect.previewVisible && effect.previewScreenName === screenDelegate.targetScreen.name ? effect.previewZones : []
|
||||
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool activeZone: index === effect.previewActiveIndex
|
||||
readonly property int previewGap: effect.outerGap
|
||||
|
||||
x: effect.previewArea.x + Math.round(modelData.x * effect.previewArea.width) + previewGap - screenDelegate.targetScreen.geometry.x
|
||||
y: effect.previewArea.y + Math.round(modelData.y * effect.previewArea.height) + previewGap - screenDelegate.targetScreen.geometry.y
|
||||
width: Math.max(1, Math.round(modelData.w * effect.previewArea.width) - 2 * previewGap)
|
||||
height: Math.max(1, Math.round(modelData.h * effect.previewArea.height) - 2 * previewGap)
|
||||
z: activeZone ? 90001 : 90000
|
||||
radius: 6
|
||||
color: activeZone ? Qt.rgba(0.38, 0.60, 0.98, 0.24)
|
||||
: Qt.rgba(0.88, 0.92, 1.0, 0.08)
|
||||
border.width: activeZone ? 2 : 1
|
||||
border.color: activeZone ? Qt.rgba(0.70, 0.82, 1.0, 0.78)
|
||||
: Qt.rgba(0.86, 0.90, 1.0, 0.26)
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onEscapePressed: effect.hideSnapLayouts()
|
||||
|
||||
Item {
|
||||
id: maximizeForwarder
|
||||
|
||||
readonly property var activeWindow: KWinComponents.Workspace.activeWindow
|
||||
readonly property rect buttonRect: activeWindow ? effect.maximizeButtonRect(activeWindow) : Qt.rect(0, 0, 0, 0)
|
||||
|
||||
visible: activeWindow
|
||||
&& activeWindow.output
|
||||
&& activeWindow.output.name === screenDelegate.targetScreen.name
|
||||
&& buttonRect.width > 0
|
||||
&& buttonRect.height > 0
|
||||
z: 100001
|
||||
x: buttonRect.x - effect.maximizeButtonPadding - screenDelegate.targetScreen.geometry.x
|
||||
y: buttonRect.y - effect.maximizeButtonPadding - screenDelegate.targetScreen.geometry.y
|
||||
width: buttonRect.width + 2 * effect.maximizeButtonPadding
|
||||
height: buttonRect.height + 2 * effect.maximizeButtonPadding
|
||||
|
||||
TapHandler {
|
||||
acceptedButtons: Qt.LeftButton
|
||||
onTapped: effect.toggleActiveWindowMaximized()
|
||||
}
|
||||
}
|
||||
|
||||
SnapPanel {
|
||||
id: snapPanel
|
||||
visible: effect.panelAnchorScreenName === "" || effect.panelAnchorScreenName === screenDelegate.targetScreen.name
|
||||
z: 100000
|
||||
x: screenDelegate.popupRect.x - screenDelegate.targetScreen.geometry.x
|
||||
y: screenDelegate.popupRect.y - screenDelegate.targetScreen.geometry.y
|
||||
|
||||
screen: KWinComponents.SceneView.screen
|
||||
}
|
||||
}
|
||||
|
|
@ -136,48 +566,47 @@ KWinComponents.SceneEffect {
|
|||
|
||||
required property var screen
|
||||
|
||||
width: presetsRow.implicitWidth + 32
|
||||
height: presetsRow.implicitHeight + 40
|
||||
width: effect.snapPanelWidth
|
||||
height: effect.snapPanelHeight
|
||||
|
||||
color: Qt.rgba(0.10, 0.12, 0.18, 0.92)
|
||||
radius: 12
|
||||
color: Qt.rgba(0.08, 0.10, 0.15, 0.82)
|
||||
radius: 8
|
||||
border.width: 1
|
||||
border.color: Qt.rgba(1, 1, 1, 0.10)
|
||||
border.color: Qt.rgba(1, 1, 1, 0.16)
|
||||
|
||||
// Drop shadow via a blurred copy underneath
|
||||
Rectangle {
|
||||
anchors { fill: parent; margins: -6 }
|
||||
color: Qt.rgba(0, 0, 0, 0.35)
|
||||
radius: parent.radius + 6
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: 3
|
||||
color: Qt.rgba(0, 0, 0, 0.22)
|
||||
radius: parent.radius
|
||||
z: -1
|
||||
layer.enabled: true
|
||||
layer.effect: Item {} // placeholder — real blur requires Qt.labs.platform
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors { fill: parent; margins: 16 }
|
||||
spacing: 12
|
||||
anchors { fill: parent; margins: 14 }
|
||||
spacing: 10
|
||||
|
||||
Text {
|
||||
text: "Snap layouts"
|
||||
color: "#b0b8d4"
|
||||
font.pixelSize: 11
|
||||
font.letterSpacing: 0.8
|
||||
font.pixelSize: 10
|
||||
font.capitalization: Font.AllUppercase
|
||||
}
|
||||
|
||||
Row {
|
||||
id: presetsRow
|
||||
spacing: 10
|
||||
spacing: effect.snapButtonSpacing
|
||||
|
||||
Repeater {
|
||||
model: effect.presets
|
||||
|
||||
PresetButton {
|
||||
delegate: PresetButton {
|
||||
required property var modelData
|
||||
|
||||
preset: modelData
|
||||
screen: panel.screen
|
||||
onClicked: {
|
||||
effect.visible = false;
|
||||
effect.hideSnapLayouts();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -194,61 +623,73 @@ KWinComponents.SceneEffect {
|
|||
required property var screen
|
||||
signal clicked
|
||||
|
||||
width: 64
|
||||
height: 44
|
||||
width: effect.snapButtonWidth
|
||||
height: effect.snapButtonHeight
|
||||
readonly property int previewMargin: 6
|
||||
|
||||
color: hovered ? Qt.rgba(1, 1, 1, 0.12) : Qt.rgba(1, 1, 1, 0.06)
|
||||
color: hovered ? Qt.rgba(1, 1, 1, 0.18) : Qt.rgba(1, 1, 1, 0.08)
|
||||
radius: 6
|
||||
border.width: 1
|
||||
border.color: hovered ? Qt.rgba(1, 1, 1, 0.30) : Qt.rgba(1, 1, 1, 0.12)
|
||||
border.color: hovered ? Qt.rgba(0.66, 0.78, 1.0, 0.62) : Qt.rgba(1, 1, 1, 0.14)
|
||||
|
||||
property bool hovered: false
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 80 } }
|
||||
|
||||
// Mini zone diagram
|
||||
Repeater {
|
||||
model: btn.preset.zones
|
||||
Rectangle {
|
||||
id: previewFrame
|
||||
anchors.fill: parent
|
||||
anchors.margins: btn.previewMargin
|
||||
color: Qt.rgba(0.06, 0.08, 0.12, 0.72)
|
||||
radius: 4
|
||||
border.width: 1
|
||||
border.color: Qt.rgba(1, 1, 1, 0.08)
|
||||
clip: true
|
||||
|
||||
Rectangle {
|
||||
x: Math.round(modelData.x * (btn.width - 2)) + 1
|
||||
y: Math.round(modelData.y * (btn.height - 2)) + 1
|
||||
width: Math.round(modelData.w * (btn.width - 2)) - 1
|
||||
height: Math.round(modelData.h * (btn.height - 2)) - 1
|
||||
color: index === 0 ? Qt.rgba(0.44, 0.62, 1.0, 0.85)
|
||||
: Qt.rgba(0.44, 0.62, 1.0, 0.35)
|
||||
border.width: 0
|
||||
radius: 2
|
||||
Repeater {
|
||||
model: btn.preset.zones
|
||||
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
x: Math.round(modelData.x * previewFrame.width)
|
||||
y: Math.round(modelData.y * previewFrame.height)
|
||||
width: Math.max(4, Math.round(modelData.w * previewFrame.width) - 1)
|
||||
height: Math.max(4, Math.round(modelData.h * previewFrame.height) - 1)
|
||||
color: zoneHover.hovered ? Qt.rgba(0.78, 0.86, 1.0, 0.96)
|
||||
: (index === 0 ? Qt.rgba(0.46, 0.64, 0.96, 0.96)
|
||||
: Qt.rgba(0.58, 0.68, 0.86, 0.48))
|
||||
border.width: 0
|
||||
radius: 2
|
||||
|
||||
HoverHandler {
|
||||
id: zoneHover
|
||||
onHoveredChanged: {
|
||||
btn.hovered = hovered;
|
||||
if (hovered) {
|
||||
effect.showSnapPreview(btn.preset, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: btn.applyZone(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: {
|
||||
btn.hovered = hovered;
|
||||
if (hovered) {
|
||||
// Show outline on screen for the first zone
|
||||
const win = KWinComponents.Workspace.activeWindow;
|
||||
if (!win || !win.output) return;
|
||||
const desktop = win.desktops.length > 0 ? win.desktops[0] : null;
|
||||
if (!desktop) return;
|
||||
const area = KWinComponents.Workspace.clientArea(
|
||||
KWinComponents.Workspace.MaximizeArea, win.output, desktop);
|
||||
const zone = btn.preset.zones[0];
|
||||
const g = effect.outerGap;
|
||||
KWinComponents.Workspace.showOutline(Qt.rect(
|
||||
area.x + Math.round(zone.x * area.width) + g,
|
||||
area.y + Math.round(zone.y * area.height) + g,
|
||||
Math.round(zone.w * area.width) - 2 * g,
|
||||
Math.round(zone.h * area.height) - 2 * g
|
||||
));
|
||||
} else {
|
||||
KWinComponents.Workspace.hideOutline();
|
||||
if (!hovered) {
|
||||
btn.hovered = false;
|
||||
effect.hideSnapPreview();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: {
|
||||
function applyZone(zone) {
|
||||
const win = KWinComponents.Workspace.activeWindow;
|
||||
if (!win || !win.output) {
|
||||
btn.clicked();
|
||||
|
|
@ -261,10 +702,9 @@ KWinComponents.SceneEffect {
|
|||
}
|
||||
const area = KWinComponents.Workspace.clientArea(
|
||||
KWinComponents.Workspace.MaximizeArea, win.output, desktop);
|
||||
KWinComponents.Workspace.hideOutline();
|
||||
effect.applyZone(win, btn.preset.zones[0], area);
|
||||
effect.hideSnapPreview();
|
||||
effect.applyZone(win, zone, area);
|
||||
btn.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
}
|
||||
],
|
||||
"Category": "Appearance",
|
||||
"Description": "Windows 11-style snap layout picker for SHIFT convergence mode. Activated by hovering the maximize button or pressing Meta+Shift+S.",
|
||||
"Description": "Windows 11-style snap layout picker for SHIFT convergence mode with native window placement. Activated by hovering the maximize button or pressing Meta+Shift+S when dynamic tiling is off.",
|
||||
"EnabledByDefault": false,
|
||||
"Id": "shift-snap-assist",
|
||||
"License": "EUPL-1.2",
|
||||
|
|
|
|||
|
|
@ -595,67 +595,6 @@ Item {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Snap-assist hover trigger ─────────────────────────────────────────
|
||||
//
|
||||
// The decoration QML sandbox has no DBus / kglobalaccel access, so we
|
||||
// detect the maximize-button hover here in the script. We poll the
|
||||
// cursor every 150 ms; when it stays in the top-right ~50×barHeight
|
||||
// strip of the active window for 500 ms (without dragging), we invoke
|
||||
// the SHIFT Snap Assist effect via kglobalaccel.
|
||||
//
|
||||
// Constants must match the decoration:
|
||||
// barHeight 30, btnSize 16, btnSpacing 8, btnSideMargin 12.
|
||||
// Right-cluster width ≈ 12 (margin) + 3·(16+8) = ~84 px. We use 90 px
|
||||
// to be forgiving.
|
||||
|
||||
readonly property int hoverBarHeight: 30
|
||||
readonly property int hoverButtonStrip: 90
|
||||
property var hoverWindowId: null
|
||||
property int hoverTicks: 0
|
||||
|
||||
Timer {
|
||||
id: snapHoverTimer
|
||||
interval: 150
|
||||
repeat: true
|
||||
running: root.tilingEnabled && root.isConvergence()
|
||||
|
||||
onTriggered: {
|
||||
// Don't fire while dragging or while no window is focused.
|
||||
if (root.draggingWindow) { root.hoverTicks = 0; root.hoverWindowId = null; return; }
|
||||
const win = KWinComponents.Workspace.activeWindow;
|
||||
if (!win || !win.normalWindow || win.fullScreen) {
|
||||
root.hoverTicks = 0; root.hoverWindowId = null; return;
|
||||
}
|
||||
const cursor = KWinComponents.Workspace.cursorPos;
|
||||
const fg = win.frameGeometry;
|
||||
// Right-side titlebar strip in absolute coords.
|
||||
const stripX = fg.x + fg.width - root.hoverButtonStrip;
|
||||
const stripY = fg.y;
|
||||
if (cursor.x >= stripX && cursor.x <= fg.x + fg.width &&
|
||||
cursor.y >= stripY && cursor.y <= stripY + root.hoverBarHeight) {
|
||||
if (root.hoverWindowId === win.internalId) {
|
||||
root.hoverTicks++;
|
||||
// 500 ms ≈ 4 ticks at 150 ms (3 + 1 to be safe).
|
||||
if (root.hoverTicks === 4) {
|
||||
callDBus(
|
||||
"org.kde.kglobalaccel",
|
||||
"/component/kwin",
|
||||
"org.kde.kglobalaccel.Component",
|
||||
"invokeShortcut",
|
||||
"SHIFT Snap Assist"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
root.hoverWindowId = win.internalId;
|
||||
root.hoverTicks = 1;
|
||||
}
|
||||
} else {
|
||||
root.hoverTicks = 0;
|
||||
root.hoverWindowId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Right-click menu ──────────────────────────────────────────────────
|
||||
|
||||
// Note: registerUserActionsMenu is a global function in KWin JS scripts.
|
||||
|
|
|
|||
Loading…
Reference in a new issue