Files
DankMaterialShell/quickshell/Widgets/DContextMenu.qml
T
Marlon Kranzandpurian23 0444835016 fix(frame): make connected mode honor the settings it overrides (#3665)
* fix(frame): make connected mode honor the settings it overrides

Several frame-mode behaviors contradicted what the settings UI shows:

- Frame -> Blur gated blur for every shell surface in connected mode,
  including the Settings window, polkit, context menus, OSDs and toasts.
  WindowBlur now defaults to Background blur; only surfaces painted
  with the frame color (connected popouts, slideouts, Control Center)
  follow Frame -> Blur, and frame-owned surfaces are blurred by the
  frame as before.
- The notepad slideout painted its own fill at the notepad
  transparency. On screens where the frame owns connected chrome it now
  claims a frame surface (new connected_arc slot 5), so the frame paints
  it flush with the cutout, with fillets, and follows the slide.
- Notification popups painted a card surface on top of the frame body,
  stacking fills. That layer is transparent when the frame paints it.
- Connected mode stripped shadow, border and corner styling from every
  bar, including bars on displays without a frame, and hid the rows.
  Styling is now stripped and restored per bar based on whether it
  covers a frame display, re-checked on screen, frame display and
  display-name-mode changes.
- Auto popup gaps and Gap size were hidden in separate mode although
  they still apply there; they now hide only in connected mode.
- The launcher style picker was hidden in connected mode while Island
  still overrode the connected launcher. Connected mode now always uses
  the connected launcher; the picker shows Full, disabled, with a note
  linking to the Bar layout picker.
- The frame shadow ignored Shadows -> Apply to -> Bars.

* fix(frame): address review feedback on connected mode

- Skip bar style reconciliation while the screen list is empty, so a
  hotplug doesn't restore and re-strip every bar.
- Replace the 16 copies of the connector blur anchor and cutout in
  FrameWindow with a ConnectorBlurRegion inline component.
- Let connected mode use the island launcher again now that the island
  can sit in any bar. The picker offers Full and Island there and hides
  Spotlight, which opens the full launcher in that mode anyway; the note
  and the SettingsControlledBy bar target are dropped.

* frame: address review findings on connected mode

---------

Co-authored-by: purian23 <[email protected]>
2026-10-10 00:04:17 -04:00

419 lines
17 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Common
import qs.Services
import qs.DCommon.Widgets
import qs.Widgets
Item {
id: root
visible: false
width: 0
height: 0
property var menuItems: []
property Component customContent: null
property real customContentWidth: 0
property string layerNamespace: "dms:context-menu"
property real menuMargin: Theme.spacingS
property real minMenuWidth: Theme.fieldDefaultWidth
property bool keyboardNavigable: false
property var transientSurfaceTracker: null
property var targetScreen: null
property real anchorX: 0
property real anchorY: 0
property bool openState: false
property bool renderActive: false
property int selectedMenuIndex: -1
property bool keyboardNavigation: false
readonly property alias contextWindow: menuWindow
readonly property bool blurActive: renderActive && openState && BlurService.enabled
readonly property real maxMenuWidth: Math.max(0, (targetScreen?.width ?? Theme.launcherWidthMicro) - menuMargin * 2)
readonly property real maxMenuHeight: Math.max(0, (targetScreen?.height ?? Theme.launcherHeightDefault) - menuMargin * 2)
readonly property string longestMenuText: {
let longest = "";
for (const menuItem of menuItems) {
const text = menuItem.text || "";
if (text.length > longest.length)
longest = text;
}
return longest;
}
readonly property real naturalMenuWidth: customContent ? Math.max(minMenuWidth, customContentWidth) : Math.max(minMenuWidth, menuTextMetrics.width + Theme.iconSize + Theme.spacingS * 5)
readonly property real effectiveMenuWidth: Math.max(0, Math.min(maxMenuWidth, naturalMenuWidth))
readonly property real naturalMenuHeight: (customContent ? customContentLoader.implicitHeight : menuItemsHeight()) + Theme.spacingS * 2
readonly property real effectiveMenuHeight: Math.min(maxMenuHeight, naturalMenuHeight)
readonly property bool menuScrolls: naturalMenuHeight > effectiveMenuHeight + 0.5
readonly property int visibleItemCount: menuItems.filter(menuItem => menuItem.type === "item").length
signal backdropRightClicked(real x, real y)
onRenderActiveChanged: transientSurfaceTracker?.setActive(root, renderActive, contextWindow)
Component.onDestruction: transientSurfaceTracker?.unregister(root)
Connections {
target: root.transientSurfaceTracker
ignoreUnknownSignals: true
function onCloseRequested() {
root.hide();
}
}
StyledTextMetrics {
id: menuTextMetrics
text: root.longestMenuText
font.pixelSize: Theme.fontSizeSmall
}
function menuItemsHeight() {
let h = 0;
for (const menuItem of menuItems)
h += menuItem.type === "separator" ? Theme.spacingXS + Theme.dividerWidth : Theme.menuItemHeight;
if (menuItems.length > 1)
h += (menuItems.length - 1) * Theme.groupedListGap;
return h;
}
function open(screen, x, y, fromKeyboard) {
targetScreen = screen;
anchorX = x;
anchorY = y;
selectedMenuIndex = fromKeyboard ? 0 : -1;
keyboardNavigation = !!fromKeyboard;
renderActive = true;
openState = true;
Qt.callLater(() => {
menuFlickable.contentY = 0;
if (keyboardNavigable)
keyboardHandler.forceActiveFocus();
ensureSelectedVisible();
});
}
function openFromBar(anchor) {
if (!anchor?.screen)
return;
targetScreen = anchor.screen;
let x = anchor.x - effectiveMenuWidth / 2;
let y = anchor.edge === "bottom" ? anchor.y - effectiveMenuHeight : anchor.y;
if (anchor.isVertical) {
x = anchor.edge === "left" ? anchor.x : anchor.x - effectiveMenuWidth;
y = anchor.y - effectiveMenuHeight / 2;
}
open(anchor.screen, x, y, false);
}
function hide() {
if (!renderActive)
return;
openState = false;
}
function activate(menuItem) {
if (menuItem?.enabled === false)
return;
if (typeof menuItem?.action === "function")
menuItem.action();
hide();
}
function selectNext() {
if (visibleItemCount === 0)
return;
keyboardNavigation = true;
for (let step = 0; step < visibleItemCount; step++) {
selectedMenuIndex = (selectedMenuIndex + 1) % visibleItemCount;
if (menuItems[selectedDelegateIndex()]?.enabled !== false)
break;
}
ensureSelectedVisible();
}
function selectPrevious() {
if (visibleItemCount === 0)
return;
keyboardNavigation = true;
for (let step = 0; step < visibleItemCount; step++) {
selectedMenuIndex = (selectedMenuIndex - 1 + visibleItemCount) % visibleItemCount;
if (menuItems[selectedDelegateIndex()]?.enabled !== false)
break;
}
ensureSelectedVisible();
}
function selectedDelegateIndex() {
let itemIndex = 0;
for (let i = 0; i < menuItems.length; i++) {
if (menuItems[i].type !== "item")
continue;
if (itemIndex === selectedMenuIndex)
return i;
itemIndex++;
}
return -1;
}
function ensureSelectedVisible() {
Qt.callLater(() => {
const delegate = menuRepeater.itemAt(selectedDelegateIndex());
if (!delegate)
return;
const top = delegate.y;
const bottom = top + delegate.height;
const viewTop = menuFlickable.contentY;
const viewBottom = viewTop + menuFlickable.height;
if (top < viewTop) {
menuFlickable.contentY = Math.max(0, top);
return;
}
if (bottom > viewBottom)
menuFlickable.contentY = Math.min(Math.max(0, menuFlickable.contentHeight - menuFlickable.height), bottom - menuFlickable.height);
});
}
function activateSelected() {
const index = selectedDelegateIndex();
if (index >= 0)
activate(menuItems[index]);
}
PanelWindow {
id: menuWindow
screen: root.targetScreen
visible: root.renderActive
color: "transparent"
WlrLayershell.namespace: root.layerNamespace
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: {
if (!root.keyboardNavigable || !root.renderActive)
return WlrKeyboardFocus.None;
if (PopoutManager.screenshotActive || CompositorService.useHyprlandFocusGrab)
return WlrKeyboardFocus.None;
return WlrKeyboardFocus.Exclusive;
}
anchors {
top: true
left: true
right: true
bottom: true
}
WindowBlur {
targetWindow: menuWindow
surfaceColor: menuContainer.color
blurX: root.blurActive ? menuContainer.x : 0
blurY: root.blurActive ? menuContainer.y : 0
blurWidth: root.blurActive ? menuContainer.width : 0
blurHeight: root.blurActive ? menuContainer.height : 0
blurRadius: Theme.windowRadius
}
MouseArea {
anchors.fill: parent
z: -1
enabled: root.renderActive
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => {
if (mouse.button === Qt.RightButton) {
root.backdropRightClicked(mouse.x, mouse.y);
return;
}
root.hide();
}
}
Item {
id: keyboardHandler
anchors.fill: parent
focus: root.keyboardNavigable && root.openState
Keys.onPressed: event => {
switch (event.key) {
case Qt.Key_Down:
root.selectNext();
event.accepted = true;
return;
case Qt.Key_Up:
root.selectPrevious();
event.accepted = true;
return;
case Qt.Key_Return:
case Qt.Key_Enter:
root.activateSelected();
event.accepted = true;
return;
case Qt.Key_Escape:
case Qt.Key_Left:
root.hide();
event.accepted = true;
return;
}
}
Rectangle {
id: menuContainer
x: Math.max(root.menuMargin, Math.min(menuWindow.width - width - root.menuMargin, root.anchorX))
y: Math.max(root.menuMargin, Math.min(menuWindow.height - height - root.menuMargin, root.anchorY))
width: root.effectiveMenuWidth
height: root.effectiveMenuHeight
color: Theme.readableSurface
radius: Theme.windowRadius
border.color: BlurService.borderColor
border.width: BlurService.borderWidth
opacity: root.openState ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: SettingsData.reduceMotion ? 0 : Theme.shortDuration
easing.type: Theme.emphasizedEasing
onRunningChanged: {
if (!running && !root.openState)
root.renderActive = false;
}
}
}
ElevationShadow {
anchors.fill: parent
z: -1
level: Theme.elevationLevel2
targetRadius: parent.radius
targetColor: "transparent"
shadowEnabled: Theme.elevationEnabled
}
DFlickable {
id: menuFlickable
anchors.fill: parent
anchors.margins: Theme.spacingS
clip: true
contentWidth: width
contentHeight: menuColumn.implicitHeight
boundsBehavior: Flickable.StopAtBounds
interactive: root.menuScrolls
Column {
id: menuColumn
width: menuFlickable.width
spacing: Theme.groupedListGap
Loader {
id: customContentLoader
width: menuColumn.width
active: root.customContent !== null
visible: active
sourceComponent: root.customContent
}
Repeater {
id: menuRepeater
model: root.customContent ? [] : root.menuItems
Item {
id: menuItemDelegate
required property var modelData
required property int index
readonly property bool isSeparator: modelData.type === "separator"
readonly property int itemIndex: root.menuItems.slice(0, index).filter(menuItem => menuItem.type === "item").length
width: menuColumn.width
height: isSeparator ? Theme.spacingXS + Theme.dividerWidth : Theme.menuItemHeight
Rectangle {
visible: menuItemDelegate.isSeparator
width: parent.width - Theme.spacingS * 2
height: Theme.outlineWidth
anchors.centerIn: parent
color: Theme.outlineHeavy
}
Rectangle {
id: menuRow
readonly property bool selected: root.keyboardNavigation && root.selectedMenuIndex === menuItemDelegate.itemIndex
readonly property bool destructive: menuItemDelegate.modelData?.isDestructive ?? false
readonly property bool itemEnabled: menuItemDelegate.modelData?.enabled !== false
readonly property color contentColor: {
if (!itemEnabled)
return Theme.onSurface_38;
if (destructive)
return Theme.error;
return selected ? Theme.onSelectedContainer : Theme.onSurface;
}
visible: !menuItemDelegate.isSeparator
anchors.fill: parent
radius: Theme.cornerRadiusS
color: {
if (!itemEnabled)
return "transparent";
if (destructive)
return selected ? Theme.errorSelected : itemMouseArea.containsMouse ? Theme.errorHover : "transparent";
return selected ? Theme.selectedContainer : itemMouseArea.pressed ? Theme.withAlpha(Theme.onSurface, Theme.stateLayerPressed) : itemMouseArea.containsMouse ? Theme.withAlpha(Theme.onSurface, Theme.stateLayerHover) : "transparent";
}
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DIcon {
name: menuItemDelegate.modelData?.icon ?? ""
size: Theme.iconSizeMedium
color: menuRow.contentColor
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: menuItemDelegate.modelData.text || ""
font.pixelSize: Theme.fontSizeSmall
color: menuRow.contentColor
font.weight: Theme.fontWeight
anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight
width: parent.width - Theme.iconSizeMedium - Theme.spacingS
}
}
DRipple {
id: menuItemRipple
rippleColor: menuRow.contentColor
cornerRadius: Theme.cornerRadiusM
}
MouseArea {
id: itemMouseArea
anchors.fill: parent
enabled: menuRow.itemEnabled
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: {
root.keyboardNavigation = false;
root.selectedMenuIndex = menuItemDelegate.itemIndex;
}
onPressed: mouse => menuItemRipple.trigger(mouse.x, mouse.y)
onClicked: root.activate(menuItemDelegate.modelData)
}
}
}
}
}
}
}
}
}
}