1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 13:32:50 -05:00

hyprland: add keybinds cheatsheet

This commit is contained in:
bbedward
2025-10-15 11:18:02 -04:00
parent e47e7667c6
commit c4e88e5c05
6 changed files with 638 additions and 1 deletions

View File

@@ -473,12 +473,27 @@ Item {
}
}
LazyLoader {
id: hyprKeybindsModalLoader
active: false
HyprKeybindsModal {
id: hyprKeybindsModal
Component.onCompleted: {
PopoutService.hyprKeybindsModal = hyprKeybindsModal
}
}
}
DMSShellIPC {
powerMenuModalLoader: powerMenuModalLoader
processListModalLoader: processListModalLoader
controlCenterLoader: controlCenterLoader
dankDashPopoutLoader: dankDashPopoutLoader
notepadSlideoutVariants: notepadSlideoutVariants
hyprKeybindsModalLoader: hyprKeybindsModalLoader
}
Variants {

View File

@@ -13,6 +13,7 @@ Item {
required property var controlCenterLoader
required property var dankDashPopoutLoader
required property var notepadSlideoutVariants
required property var hyprKeybindsModalLoader
IpcHandler {
function open() {
@@ -306,4 +307,47 @@ Item {
target: "mpris"
}
IpcHandler {
function openBinds(): string {
if (!CompositorService.isHyprland) {
return "HYPR_NOT_AVAILABLE"
}
root.hyprKeybindsModalLoader.active = true
if (root.hyprKeybindsModalLoader.item) {
root.hyprKeybindsModalLoader.item.open()
return "HYPR_KEYBINDS_OPEN_SUCCESS"
}
return "HYPR_KEYBINDS_OPEN_FAILED"
}
function closeBinds(): string {
if (!CompositorService.isHyprland) {
return "HYPR_NOT_AVAILABLE"
}
if (root.hyprKeybindsModalLoader.item) {
root.hyprKeybindsModalLoader.item.close()
return "HYPR_KEYBINDS_CLOSE_SUCCESS"
}
return "HYPR_KEYBINDS_CLOSE_FAILED"
}
function toggleBinds(): string {
if (!CompositorService.isHyprland) {
return "HYPR_NOT_AVAILABLE"
}
root.hyprKeybindsModalLoader.active = true
if (root.hyprKeybindsModalLoader.item) {
if (root.hyprKeybindsModalLoader.item.shouldBeVisible) {
root.hyprKeybindsModalLoader.item.close()
} else {
root.hyprKeybindsModalLoader.item.open()
}
return "HYPR_KEYBINDS_TOGGLE_SUCCESS"
}
return "HYPR_KEYBINDS_TOGGLE_FAILED"
}
target: "hypr"
}
}

View File

@@ -0,0 +1,266 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Common
import qs.Modals.Common
import qs.Services
import qs.Widgets
DankModal {
id: root
width: 1400
height: 900
onBackgroundClicked: close()
function categorizeKeybinds() {
const categories = {
"Workspace": [],
"Window": [],
"Monitor": [],
"Execute": [],
"System": [],
"Other": []
}
function addKeybind(keybind) {
const dispatcher = keybind.dispatcher || ""
if (dispatcher.includes("workspace")) {
categories["Workspace"].push(keybind)
} else if (dispatcher.includes("monitor")) {
categories["Monitor"].push(keybind)
} else if (dispatcher.includes("window") || dispatcher.includes("focus") || dispatcher.includes("move") || dispatcher.includes("swap") || dispatcher.includes("resize") || dispatcher === "killactive" || dispatcher === "fullscreen" || dispatcher === "togglefloating") {
categories["Window"].push(keybind)
} else if (dispatcher === "exec") {
categories["Execute"].push(keybind)
} else if (dispatcher === "exit" || dispatcher.includes("dpms")) {
categories["System"].push(keybind)
} else {
categories["Other"].push(keybind)
}
}
const allKeybinds = HyprKeybindsService.keybinds.keybinds || []
for (let i = 0; i < allKeybinds.length; i++) {
addKeybind(allKeybinds[i])
}
const children = HyprKeybindsService.keybinds.children || []
for (let i = 0; i < children.length; i++) {
const child = children[i]
const childKeybinds = child.keybinds || []
for (let j = 0; j < childKeybinds.length; j++) {
addKeybind(childKeybinds[j])
}
}
categories["Workspace"].sort((a, b) => {
const dispA = a.dispatcher || ""
const dispB = b.dispatcher || ""
return dispA.localeCompare(dispB)
})
categories["Window"].sort((a, b) => {
const dispA = a.dispatcher || ""
const dispB = b.dispatcher || ""
return dispA.localeCompare(dispB)
})
categories["Monitor"].sort((a, b) => {
const dispA = a.dispatcher || ""
const dispB = b.dispatcher || ""
return dispA.localeCompare(dispB)
})
categories["Execute"].sort((a, b) => {
const modsA = a.mods || []
const keyA = a.key || ""
const bindA = [...modsA, keyA].join("+")
const modsB = b.mods || []
const keyB = b.key || ""
const bindB = [...modsB, keyB].join("+")
return bindA.localeCompare(bindB)
})
return categories
}
content: Component {
Item {
anchors.fill: parent
DankFlickable {
id: mainFlickable
anchors.fill: parent
anchors.margins: Theme.spacingL
contentWidth: rowLayout.implicitWidth
contentHeight: rowLayout.implicitHeight
clip: true
Row {
id: rowLayout
spacing: Theme.spacingM
property var categories: root.categorizeKeybinds()
property real columnWidth: (mainFlickable.width - spacing * 2) / 3
Column {
width: rowLayout.columnWidth
spacing: Theme.spacingXS
StyledText {
text: "Window / Monitor"
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Bold
color: Theme.primary
}
Rectangle {
width: parent.width
height: 1
color: Theme.primary
opacity: 0.3
}
Item { width: 1; height: Theme.spacingXS }
Column {
width: parent.width
spacing: Theme.spacingXS
Repeater {
model: [...(rowLayout.categories["Window"] || []), ...(rowLayout.categories["Monitor"] || [])]
Row {
width: parent.width
spacing: Theme.spacingS
StyledRect {
width: Math.min(140, parent.width * 0.42)
height: 22
radius: 4
opacity: 0.3
StyledText {
anchors.centerIn: parent
anchors.margins: 2
width: parent.width - 4
text: {
const mods = modelData.mods || []
const key = modelData.key || ""
const parts = [...mods, key]
return parts.join("+")
}
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
isMonospace: true
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
}
}
StyledText {
width: parent.width - 150
text: {
const comment = modelData.comment || ""
if (comment) return comment
const dispatcher = modelData.dispatcher || ""
const params = modelData.params || ""
return params ? `${dispatcher} ${params}` : dispatcher
}
font.pixelSize: Theme.fontSizeSmall
opacity: 0.9
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
Repeater {
model: ["Workspace", "Execute"]
Column {
width: rowLayout.columnWidth
spacing: Theme.spacingXS
StyledText {
text: modelData
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Bold
color: Theme.primary
}
Rectangle {
width: parent.width
height: 1
color: Theme.primary
opacity: 0.3
}
Item { width: 1; height: Theme.spacingXS }
Column {
width: parent.width
spacing: Theme.spacingXS
Repeater {
model: rowLayout.categories[modelData] || []
Row {
width: parent.width
spacing: Theme.spacingS
StyledRect {
width: Math.min(140, parent.width * 0.42)
height: 22
radius: 4
opacity: 0.3
StyledText {
anchors.centerIn: parent
anchors.margins: 2
width: parent.width - 4
text: {
const mods = modelData.mods || []
const key = modelData.key || ""
const parts = [...mods, key]
return parts.join("+")
}
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
isMonospace: true
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
}
}
StyledText {
width: parent.width - 150
text: {
const comment = modelData.comment || ""
if (comment) return comment
const dispatcher = modelData.dispatcher || ""
const params = modelData.params || ""
return params ? `${dispatcher} ${params}` : dispatcher
}
font.pixelSize: Theme.fontSizeSmall
opacity: 0.9
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,39 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import QtCore
import Quickshell
import Quickshell.Io
import qs.Common
Singleton {
id: root
readonly property string shellDir: Paths.strip(Qt.resolvedUrl(".").toString()).replace("/Services/", "")
property string scriptPath: `${shellDir}/scripts/hyprland_keybinds.py`
readonly property string _configUrl: StandardPaths.writableLocation(StandardPaths.ConfigLocation)
readonly property string _configDir: Paths.strip(_configUrl)
property string hyprConfigPath: `${_configDir}/hypr`
property var keybinds: ({"children": [], "keybinds": []})
Process {
id: getKeybinds
running: true
command: [root.scriptPath, "--path", root.hyprConfigPath]
stdout: SplitParser {
onRead: data => {
try {
root.keybinds = JSON.parse(data)
} catch (e) {
console.error("[HyprKeybindsService] Error parsing keybinds:", e)
}
}
}
}
function reload() {
getKeybinds.running = true
}
}

View File

@@ -511,6 +511,26 @@ File browser controls for selecting wallpapers and profile images.
- `profile` - Opens profile image file browser in Pictures directory
- Both browsers support common image formats (jpg, jpeg, png, bmp, gif, webp)
### Target: `hypr`
Hyprland keybinds cheatsheet modal control (Hyprland only).
**Functions:**
- `openBinds` - Show Hyprland keybinds cheatsheet modal
- Returns: Success/failure message
- Note: Returns "HYPR_NOT_AVAILABLE" if not running Hyprland
- `closeBinds` - Hide Hyprland keybinds cheatsheet modal
- Returns: Success/failure message
- Note: Returns "HYPR_NOT_AVAILABLE" if not running Hyprland
- `toggleBinds` - Toggle Hyprland keybinds cheatsheet modal visibility
- Returns: Success/failure message
- Note: Returns "HYPR_NOT_AVAILABLE" if not running Hyprland
**Description:**
Displays an auto-categorized cheatsheet of all Hyprland keybinds parsed from `~/.config/hypr`. Keybinds are organized into three columns:
- **Window / Monitor** - Window and monitor management keybinds (sorted by dispatcher)
- **Workspace** - Workspace switching and management (sorted by dispatcher)
- **Execute** - Application launchers and commands (sorted by keybind)
### Modal Examples
```bash
# Open application launcher
@@ -546,14 +566,19 @@ dms ipc call dash open weather
# Open file browsers
dms ipc call file browse wallpaper
dms ipc call file browse profile
# Show Hyprland keybinds cheatsheet (Hyprland only)
dms ipc call hypr toggleBinds
dms ipc call hypr openBinds
```
## Common Usage Patterns
### Keybinding Integration
These IPC commands are designed to be used with window manager keybindings. Example niri configuration:
These IPC commands are designed to be used with window manager keybindings.
**Example niri configuration:**
```kdl
binds {
Mod+Space { spawn "qs" "-c" "dms" "ipc" "call" "spotlight" "toggle"; }
@@ -565,6 +590,17 @@ binds {
}
```
**Example Hyprland configuration:**
```conf
bind = SUPER, Space, exec, qs -c dms ipc call spotlight toggle
bind = SUPER, V, exec, qs -c dms ipc call clipboard toggle
bind = SUPER, P, exec, qs -c dms ipc call notepad toggle
bind = SUPER, X, exec, qs -c dms ipc call powermenu toggle
bind = SUPER, slash, exec, qs -c dms ipc call hypr toggleBinds
bind = , XF86AudioRaiseVolume, exec, qs -c dms ipc call audio increment 3
bind = , XF86MonBrightnessUp, exec, qs -c dms ipc call brightness increment 5 ""
```
### Scripting and Automation
IPC commands can be used in scripts for automation:

237
scripts/hyprland_keybinds.py Executable file
View File

@@ -0,0 +1,237 @@
#!/usr/bin/env python3
# Based on end-4 dots-hyprland get_keybinds.py
# https://github.com/end-4/dots-hyprland/blob/main/.config/quickshell/ii/scripts/hyprland/get_keybinds.py
import argparse
import re
import os
import glob
from os.path import expandvars as os_expandvars
from typing import Dict, List
TITLE_REGEX = "#+!"
HIDE_COMMENT = "[hidden]"
MOD_SEPARATORS = ['+', ' ']
COMMENT_BIND_PATTERN = "#/#"
parser = argparse.ArgumentParser(description='Hyprland keybind reader')
parser.add_argument('--path', type=str, default="$HOME/.config/hypr", help='path to hyprland config directory')
args = parser.parse_args()
content_lines = []
reading_line = 0
Variables: Dict[str, str] = {}
class KeyBinding(dict):
def __init__(self, mods, key, dispatcher, params, comment) -> None:
self["mods"] = mods
self["key"] = key
self["dispatcher"] = dispatcher
self["params"] = params
self["comment"] = comment
class Section(dict):
def __init__(self, children, keybinds, name) -> None:
self["children"] = children
self["keybinds"] = keybinds
self["name"] = name
def read_content(directory: str) -> str:
expanded_dir = os.path.expanduser(os.path.expandvars(directory))
if not os.path.isdir(expanded_dir):
return "error"
conf_files = glob.glob(os.path.join(expanded_dir, "*.conf"))
if not conf_files:
return "error"
combined_content = []
for conf_file in sorted(conf_files):
if os.access(conf_file, os.R_OK):
with open(conf_file, "r") as file:
combined_content.append(file.read())
return "\n".join(combined_content) if combined_content else "error"
def autogenerate_comment(dispatcher: str, params: str = "") -> str:
match dispatcher:
case "resizewindow":
return "Resize window"
case "movewindow":
if(params == ""):
return "Move window"
else:
return "Window: move in {} direction".format({
"l": "left",
"r": "right",
"u": "up",
"d": "down",
}.get(params, "null"))
case "pin":
return "Window: pin (show on all workspaces)"
case "splitratio":
return "Window split ratio {}".format(params)
case "togglefloating":
return "Float/unfloat window"
case "resizeactive":
return "Resize window by {}".format(params)
case "killactive":
return "Close window"
case "fullscreen":
return "Toggle {}".format(
{
"0": "fullscreen",
"1": "maximization",
"2": "fullscreen on Hyprland's side",
}.get(params, "null")
)
case "fakefullscreen":
return "Toggle fake fullscreen"
case "workspace":
if params == "+1":
return "Workspace: focus right"
elif params == "-1":
return "Workspace: focus left"
return "Focus workspace {}".format(params)
case "movefocus":
return "Window: move focus {}".format(
{
"l": "left",
"r": "right",
"u": "up",
"d": "down",
}.get(params, "null")
)
case "swapwindow":
return "Window: swap in {} direction".format(
{
"l": "left",
"r": "right",
"u": "up",
"d": "down",
}.get(params, "null")
)
case "movetoworkspace":
if params == "+1":
return "Window: move to right workspace (non-silent)"
elif params == "-1":
return "Window: move to left workspace (non-silent)"
return "Window: move to workspace {} (non-silent)".format(params)
case "movetoworkspacesilent":
if params == "+1":
return "Window: move to right workspace"
elif params == "-1":
return "Window: move to right workspace"
return "Window: move to workspace {}".format(params)
case "togglespecialworkspace":
return "Workspace: toggle special"
case "exec":
return "Execute: {}".format(params)
case _:
return ""
def get_keybind_at_line(line_number, line_start = 0):
global content_lines
line = content_lines[line_number]
_, keys = line.split("=", 1)
keys, *comment = keys.split("#", 1)
mods, key, dispatcher, *params = list(map(str.strip, keys.split(",", 4)))
params = "".join(map(str.strip, params))
# Remove empty spaces
comment = list(map(str.strip, comment))
# Add comment if it exists, else generate it
if comment:
comment = comment[0]
if comment.startswith("[hidden]"):
return None
else:
comment = autogenerate_comment(dispatcher, params)
if mods:
modstring = mods + MOD_SEPARATORS[0] # Add separator at end to ensure last mod is read
mods = []
p = 0
for index, char in enumerate(modstring):
if(char in MOD_SEPARATORS):
if(index - p > 1):
mods.append(modstring[p:index])
p = index+1
else:
mods = []
return KeyBinding(mods, key, dispatcher, params, comment)
def get_binds_recursive(current_content, scope):
global content_lines
global reading_line
# print("get_binds_recursive({0}, {1}) [@L{2}]".format(current_content, scope, reading_line + 1))
while reading_line < len(content_lines): # TODO: Adjust condition
line = content_lines[reading_line]
heading_search_result = re.search(TITLE_REGEX, line)
# print("Read line {0}: {1}\tisHeading: {2}".format(reading_line + 1, content_lines[reading_line], "[{0}, {1}, {2}]".format(heading_search_result.start(), heading_search_result.start() == 0, ((heading_search_result != None) and (heading_search_result.start() == 0))) if heading_search_result != None else "No"))
if ((heading_search_result != None) and (heading_search_result.start() == 0)): # Found title
# Determine scope
heading_scope = line.find('!')
# Lower? Return
if(heading_scope <= scope):
reading_line -= 1
return current_content
section_name = line[(heading_scope+1):].strip()
# print("[[ Found h{0} at line {1} ]] {2}".format(heading_scope, reading_line+1, content_lines[reading_line]))
reading_line += 1
current_content["children"].append(get_binds_recursive(Section([], [], section_name), heading_scope))
elif line.startswith(COMMENT_BIND_PATTERN):
keybind = get_keybind_at_line(reading_line, line_start=len(COMMENT_BIND_PATTERN))
if(keybind != None):
current_content["keybinds"].append(keybind)
elif line == "" or not line.lstrip().startswith("bind"): # Comment, ignore
pass
else: # Normal keybind
keybind = get_keybind_at_line(reading_line)
if(keybind != None):
current_content["keybinds"].append(keybind)
reading_line += 1
return current_content;
def parse_keys(path: str) -> Dict[str, List[KeyBinding]]:
global content_lines
content_lines = read_content(path).splitlines()
if content_lines[0] == "error":
return "error"
return get_binds_recursive(Section([], [], ""), 0)
if __name__ == "__main__":
import json
ParsedKeys = parse_keys(args.path)
print(json.dumps(ParsedKeys))