diff --git a/.config/quickshell/modules/cheatsheet/Cheatsheet.qml b/.config/quickshell/modules/cheatsheet/Cheatsheet.qml new file mode 100644 index 00000000..03e91f1e --- /dev/null +++ b/.config/quickshell/modules/cheatsheet/Cheatsheet.qml @@ -0,0 +1,232 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { // Scope + id: root + + Variants { // Window repeater + id: cheatsheetVariants + model: Quickshell.screens + + PanelWindow { // Window + id: cheatsheetRoot + visible: false + focusable: true + + property var modelData + + screen: modelData + exclusiveZone: 0 + implicitWidth: cheatsheetBackground.width + Appearance.sizes.elevationMargin * 2 + implicitHeight: cheatsheetBackground.height + Appearance.sizes.elevationMargin * 2 + WlrLayershell.namespace: "quickshell:cheatsheet" + // Hyprland 0.49: Focus is always exclusive and setting this breaks mouse focus grab + // WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + color: "transparent" + + mask: Region { + item: cheatsheetBackground + } + + HyprlandFocusGrab { // Click outside to close + id: grab + windows: [ cheatsheetRoot ] + active: false + onCleared: () => { + if (!active) cheatsheetRoot.visible = false + } + } + + Connections { + target: cheatsheetRoot + function onVisibleChanged() { + delayedGrabTimer.start() + } + } + + Timer { + id: delayedGrabTimer + interval: ConfigOptions.hacks.arbitraryRaceConditionDelay + repeat: false + onTriggered: { + grab.active = cheatsheetRoot.visible + } + } + + // Background + Rectangle { + id: cheatsheetBackground + anchors.centerIn: parent + color: Appearance.colors.colLayer0 + radius: Appearance.rounding.windowRounding + property real padding: 30 + implicitWidth: cheatsheetColumnLayout.implicitWidth + padding * 2 + implicitHeight: cheatsheetColumnLayout.implicitHeight + padding * 2 + + Keys.onPressed: (event) => { // Esc to close + if (event.key === Qt.Key_Escape) { + cheatsheetRoot.visible = false + } + } + + Button { // Close button + id: closeButton + focus: cheatsheetRoot.visible + implicitWidth: 40 + implicitHeight: 40 + anchors { + top: parent.top + right: parent.right + topMargin: 20 + rightMargin: 20 + } + + PointingHandInteraction {} + onClicked: { + cheatsheetRoot.visible = false + } + + background: Item {} + contentItem: Rectangle { + anchors.fill: parent + radius: Appearance.rounding.full + color: closeButton.pressed ? Appearance.colors.colLayer0Active : + closeButton.hovered ? Appearance.colors.colLayer0Hover : + Appearance.transparentize(Appearance.colors.colLayer0, 1) + + Behavior on color { + ColorAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + MaterialSymbol { + anchors.centerIn: parent + font.pixelSize: Appearance.font.pixelSize.title + text: "close" + } + } + } + + ColumnLayout { // Real content + id: cheatsheetColumnLayout + anchors.centerIn: parent + spacing: 20 + + StyledText { + id: cheatsheetTitle + Layout.alignment: Qt.AlignHCenter + font.family: Appearance.font.family.title + font.pixelSize: Appearance.font.pixelSize.title + text: qsTr("Cheat sheet") + } + CheatsheetKeybinds {} + } + } + + // Shadow + DropShadow { + anchors.fill: cheatsheetBackground + horizontalOffset: 0 + verticalOffset: 2 + radius: Appearance.sizes.elevationMargin + samples: Appearance.sizes.elevationMargin * 2 + 1 // Ideally should be 2 * radius + 1, see qt docs + color: Appearance.colors.colShadow + source: cheatsheetBackground + } + + } + + } + + IpcHandler { + target: "cheatsheet" + + function toggle(): void { + for (let i = 0; i < cheatsheetVariants.instances.length; i++) { + let panelWindow = cheatsheetVariants.instances[i]; + if (panelWindow.modelData.name == Hyprland.focusedMonitor.name) { + panelWindow.visible = !panelWindow.visible; + if(panelWindow.visible) Notifications.timeoutAll(); + } + } + } + + function close(): void { + for (let i = 0; i < cheatsheetVariants.instances.length; i++) { + let panelWindow = cheatsheetVariants.instances[i]; + if (panelWindow.modelData.name == Hyprland.focusedMonitor.name) { + panelWindow.visible = false; + } + } + } + + function open(): void { + for (let i = 0; i < cheatsheetVariants.instances.length; i++) { + let panelWindow = cheatsheetVariants.instances[i]; + if (panelWindow.modelData.name == Hyprland.focusedMonitor.name) { + panelWindow.visible = true; + if(panelWindow.visible) Notifications.timeoutAll(); + } + } + } + } + + GlobalShortcut { + name: "cheatsheetToggle" + description: "Toggles cheatsheet on press" + + onPressed: { + for (let i = 0; i < cheatsheetVariants.instances.length; i++) { + let panelWindow = cheatsheetVariants.instances[i]; + if (panelWindow.modelData.name == Hyprland.focusedMonitor.name) { + panelWindow.visible = !panelWindow.visible; + if(panelWindow.visible) Notifications.timeoutAll(); + } + } + } + } + + GlobalShortcut { + name: "cheatsheetOpen" + description: "Opens cheatsheet on press" + + onPressed: { + for (let i = 0; i < cheatsheetVariants.instances.length; i++) { + let panelWindow = cheatsheetVariants.instances[i]; + if (panelWindow.modelData.name == Hyprland.focusedMonitor.name) { + panelWindow.visible = true; + if(panelWindow.visible) Notifications.timeoutAll(); + } + } + } + } + + GlobalShortcut { + name: "cheatsheetClose" + description: "Closes cheatsheet on press" + + onPressed: { + for (let i = 0; i < cheatsheetVariants.instances.length; i++) { + let panelWindow = cheatsheetVariants.instances[i]; + if (panelWindow.modelData.name == Hyprland.focusedMonitor.name) { + panelWindow.visible = false; + } + } + } + } + +} diff --git a/.config/quickshell/modules/cheatsheet/CheatsheetKeybinds.qml b/.config/quickshell/modules/cheatsheet/CheatsheetKeybinds.qml new file mode 100644 index 00000000..6e41e04e --- /dev/null +++ b/.config/quickshell/modules/cheatsheet/CheatsheetKeybinds.qml @@ -0,0 +1,147 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/modules/common/functions/file_utils.js" as FileUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import Quickshell.Hyprland + +Item { + id: root + readonly property var keybinds: HyprlandKeybinds.keybinds + property real spacing: 20 + property real titleSpacing: 7 + implicitWidth: rowLayout.implicitWidth + implicitHeight: rowLayout.implicitHeight + + property var keyBlacklist: ["Super_L"] + property var keySubstitutions: ({ + "Super": "󰖳", + "mouse_up": "Scroll ↓", // ikr, weird + "mouse_down": "Scroll ↑", // trust me bro + "mouse:272": "LMB", + "mouse:273": "RMB", + "mouse:275": "MouseBack", + "Slash": "/", + "Hash": "#" + }) + + RowLayout { // Keybind columns + id: rowLayout + spacing: root.spacing + Repeater { + model: keybinds.children + + delegate: ColumnLayout { // Keybind sections + spacing: root.spacing + required property var modelData + Layout.alignment: Qt.AlignTop + Repeater { + model: modelData.children + + delegate: Item { // Section with real keybinds + required property var modelData + implicitWidth: sectionColumnLayout.implicitWidth + implicitHeight: sectionColumnLayout.implicitHeight + ColumnLayout { + id: sectionColumnLayout + anchors.centerIn: parent + spacing: root.titleSpacing + StyledText { + id: sectionTitle + font.family: Appearance.font.family.title + font.pixelSize: Appearance.font.pixelSize.huge + color: Appearance.colors.colOnLayer0 + text: modelData.name + } + + GridLayout { + id: keybindGrid + columns: 2 + Repeater { + model: { + var result = []; + for (var i = 0; i < modelData.keybinds.length; i++) { + const keybind = modelData.keybinds[i]; + result.push({ + "type": "keys", + "mods": keybind.mods, + "key": keybind.key, + }); + result.push({ + "type": "comment", + "comment": keybind.comment, + }); + } + return result; + } + delegate: Item { + required property var modelData + implicitWidth: keybindLoader.implicitWidth + implicitHeight: keybindLoader.implicitHeight + + Loader { + id: keybindLoader + sourceComponent: (modelData.type === "keys") ? keysComponent : commentComponent + } + + Component { + id: keysComponent + RowLayout { + spacing: 4 + Repeater { + model: modelData.mods + delegate: KeyboardKey { + required property var modelData + key: keySubstitutions[modelData] || modelData + } + } + StyledText { + id: keybindPlus + visible: !keyBlacklist.includes(modelData.key) && modelData.mods.length > 0 + Layout.alignment: Qt.AlignVCenter + text: "+" + } + KeyboardKey { + id: keybindKey + visible: !keyBlacklist.includes(modelData.key) + key: keySubstitutions[modelData.key] || modelData.key + color: Appearance.colors.colOnLayer0 + } + } + } + + Component { + id: commentComponent + Item { + id: commentItem + implicitWidth: commentText.implicitWidth + 5 * 2 + implicitHeight: commentText.implicitHeight + + StyledText { + id: commentText + anchors.centerIn: parent + font.pixelSize: Appearance.font.pixelSize.smaller + text: modelData.comment + } + } + } + } + + } + } + } + } + + } + } + + } + } + +} \ No newline at end of file diff --git a/.config/quickshell/modules/common/Appearance.qml b/.config/quickshell/modules/common/Appearance.qml index 498b08cf..b7d4ec60 100644 --- a/.config/quickshell/modules/common/Appearance.qml +++ b/.config/quickshell/modules/common/Appearance.qml @@ -108,8 +108,8 @@ Singleton { property color colSubtext: m3colors.m3outline property color colLayer0: m3colors.m3background property color colOnLayer0: m3colors.m3onBackground - property color colLayer0Hover: mix(colLayer0, colOnLayer0, 0.85) - property color colLayer0Active: m3colors.m3surfaceContainerHigh + property color colLayer0Hover: mix(colLayer0, colOnLayer0, 0.9) + property color colLayer0Active: mix(colLayer0, colOnLayer0, 0.8) property color colLayer1: m3colors.m3surfaceContainerLow; property color colOnLayer1: m3colors.m3onSurfaceVariant; property color colOnLayer1Inactive: mix(colOnLayer1, colLayer1, 0.45); @@ -155,7 +155,7 @@ Singleton { font: QtObject { property QtObject family: QtObject { property string main: "Rubik" - property string title: "Rubik" + property string title: "Gabarito" property string iconMaterial: "Material Symbols Rounded" property string iconNerd: "SpaceMono NF" property string monospace: "JetBrains Mono NF" diff --git a/.config/quickshell/modules/common/widgets/KeyboardKey.qml b/.config/quickshell/modules/common/widgets/KeyboardKey.qml new file mode 100644 index 00000000..18058bd6 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/KeyboardKey.qml @@ -0,0 +1,42 @@ +import "root:/modules/common" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io + +Rectangle { + id: root + property string key + + property real horizontalPadding: 7 + property real verticalPadding: 2 + property real borderWidth: 1 + property real bottomBorderWidth: 3 + property color borderColor: Appearance.colors.colOnLayer0 + property real borderRadius: 5 + property color keyColor: Appearance.m3colors.m3surfaceContainerLow + implicitWidth: keyFace.implicitWidth + borderWidth * 2 + implicitHeight: keyFace.implicitHeight + borderWidth * 2 + bottomBorderWidth + radius: borderRadius + color: borderColor + + Rectangle { + id: keyFace + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: borderWidth + implicitWidth: keyText.implicitWidth + horizontalPadding * 2 + implicitHeight: keyText.implicitHeight + verticalPadding * 2 + color: keyColor + radius: borderRadius - borderWidth + + StyledText { + id: keyText + anchors.centerIn: parent + font.family: Appearance.font.family.monospace + font.pixelSize: Appearance.font.pixelSize.smaller + text: key + } + } +} diff --git a/.config/quickshell/scripts/hyprland/get_keybinds.py b/.config/quickshell/scripts/hyprland/get_keybinds.py new file mode 100755 index 00000000..559ba8a4 --- /dev/null +++ b/.config/quickshell/scripts/hyprland/get_keybinds.py @@ -0,0 +1,222 @@ +#!/usr/bin/env -S\_/bin/sh\_-c\_"source\_\$(eval\_echo\_\$ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate&&exec\_python\_-E\_"\$0"\_"\$@"" +import argparse +import re +import os +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/hyprland.conf", help='path to keybind file (sourcing isn\'t supported)') +args = parser.parse_args() +content_lines = [] +reading_line = 0 + +# Little Parser made for hyprland keybindings conf file +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(path: str) -> str: + if (not os.access(os.path.expanduser(os.path.expandvars(path)), os.R_OK)): + return ("error") + with open(os.path.expanduser(os.path.expandvars(path)), "r") as file: + return file.read() + + +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)) diff --git a/.config/quickshell/services/HyprlandKeybinds.qml b/.config/quickshell/services/HyprlandKeybinds.qml new file mode 100644 index 00000000..54299ede --- /dev/null +++ b/.config/quickshell/services/HyprlandKeybinds.qml @@ -0,0 +1,44 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common" +import "root:/modules/common/functions/file_utils.js" as FileUtils +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Singleton { + id: root + property var keybinds: [] + + Connections { + target: Hyprland + + function onRawEvent(event) { + console.log("[CheatsheetKeybinds] Event:", event.name) + if (event.name == "configreloaded") { + getKeybinds.running = true + } + } + } + + Process { + id: getKeybinds + running: true + command: [FileUtils.trimFileProtocol(`${XdgDirectories.config}/quickshell/scripts/hyprland/get_keybinds.py`), + "--path", FileUtils.trimFileProtocol(`${XdgDirectories.config}/hypr/hyprland/keybinds.conf`),] + + stdout: SplitParser { + onRead: data => { + try { + root.keybinds = JSON.parse(data) + } catch (e) { + console.error("[CheatsheetKeybinds] Error parsing keybinds:", e) + } + } + } + } +} + diff --git a/.config/quickshell/shell.qml b/.config/quickshell/shell.qml index f6b60478..fae85be7 100644 --- a/.config/quickshell/shell.qml +++ b/.config/quickshell/shell.qml @@ -1,6 +1,7 @@ //@ pragma UseQApplication import "./modules/bar/" +import "./modules/cheatsheet/" import "./modules/notificationPopup/" import "./modules/onScreenDisplay/" import "./modules/overview/" @@ -23,6 +24,7 @@ ShellRoot { } Bar {} + Cheatsheet {} NotificationPopup {} OnScreenDisplayBrightness {} OnScreenDisplayVolume {}