diff --git a/apply-theme.sh b/apply-theme.sh index 0dd5333..78af6ae 100755 --- a/apply-theme.sh +++ b/apply-theme.sh @@ -42,6 +42,11 @@ USER_FILES=( "desktopenvs/hyprland/nwg-panel/menu-start.css|$HOME/.config/nwg-panel/menu-start.css" "desktopenvs/hyprlua/astal-menu/style/_colors.css|$HOME/.config/astal-menu/style/_colors.css" "desktopenvs/hyprlua/orbit-menu/style/_colors.css|$HOME/.config/orbit-menu/style/_colors.css" + "desktopenvs/hyprlua/horizon-dock/style/_colors.css|$HOME/.config/horizon-dock/style/_colors.css" + "desktopenvs/hyprdrive/orbit-menu/style/_colors.css|$HOME/.config/orbit-menu/style/_colors.css" + "desktopenvs/hyprdrive/horizon-dock/style/_colors.css|$HOME/.config/horizon-dock/style/_colors.css" + "desktopenvs/hyprdrive/astro-menu/style/_colors.css|$HOME/.config/astro-menu/style/_colors.css" + "desktopenvs/hyprdrive/station-bar/style/_colors.css|$HOME/.config/station-bar/style/_colors.css" "desktopenvs/hyprland/vicinae/cyberqueer.toml|$HOME/.config/vicinae/cyberqueer.toml" "desktopenvs/hyprland/scripts/onscreenkb.sh|$HOME/.config/scripts/onscreenkb.sh" "desktopenvs/hyprland/spicetify/Themes/cli-cyberqueer/color.ini|$HOME/.config/spicetify/Themes/cli-cyberqueer/color.ini" diff --git a/desktopenvs/hyprdrive/CRT/Cyberqueer.crt.json b/desktopenvs/hyprdrive/CRT/Cyberqueer.crt.json new file mode 100644 index 0000000..ebc0715 --- /dev/null +++ b/desktopenvs/hyprdrive/CRT/Cyberqueer.crt.json @@ -0,0 +1,27 @@ +{ + "backgroundColor": "#1a1a1a", + "fontColor": "#d6abab", + "flickering": 0.2, + "horizontalSync": 0.3762, + "staticNoise": 0.0955, + "chromaColor": 1, + "saturationColor": 0.4983, + "screenCurvature": 0.1716, + "glowingLine": 0.1476, + "burnIn": 0.0754, + "bloom": 0.5017, + "rasterization": 1, + "jitter": 0.2368, + "rbgShift": 0.0224, + "brightness": 0.5014, + "contrast": 0.85, + "ambientLight": 0.0835, + "windowOpacity": 0.2561, + "fontName": "TERMINUS_SCALED", + "fontWidth": 1, + "margin": 0.1, + "blinkingCursor": true, + "frameMargin": 0, + "name": "Cyberqeer", + "version": 2 +} \ No newline at end of file diff --git a/desktopenvs/hyprdrive/Vencord/settings/quickCss.css b/desktopenvs/hyprdrive/Vencord/settings/quickCss.css new file mode 100644 index 0000000..e69de29 diff --git a/desktopenvs/hyprdrive/Vencord/settings/settings.json b/desktopenvs/hyprdrive/Vencord/settings/settings.json new file mode 100644 index 0000000..9e19175 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/settings/settings.json @@ -0,0 +1,632 @@ +{ + "autoUpdate": true, + "autoUpdateNotification": true, + "useQuickCss": true, + "themeLinks": [], + "eagerPatches": false, + "enabledThemes": [ + "cyberqueer.theme.css" + ], + "enableReactDevtools": true, + "frameless": false, + "transparent": false, + "winCtrlQ": false, + "disableMinSize": false, + "winNativeTitleBar": false, + "plugins": { + "ChatInputButtonAPI": { + "enabled": true + }, + "CommandsAPI": { + "enabled": true + }, + "DynamicImageModalAPI": { + "enabled": false + }, + "MemberListDecoratorsAPI": { + "enabled": true + }, + "MessageAccessoriesAPI": { + "enabled": true + }, + "MessageDecorationsAPI": { + "enabled": true + }, + "MessageEventsAPI": { + "enabled": false + }, + "MessagePopoverAPI": { + "enabled": true + }, + "MessageUpdaterAPI": { + "enabled": true + }, + "ServerListAPI": { + "enabled": true + }, + "UserSettingsAPI": { + "enabled": true + }, + "AccountPanelServerProfile": { + "enabled": true + }, + "AlwaysAnimate": { + "enabled": true + }, + "AlwaysExpandRoles": { + "enabled": true + }, + "AlwaysTrust": { + "enabled": true, + "domain": true, + "file": true + }, + "AnonymiseFileNames": { + "enabled": true + }, + "AppleMusicRichPresence": { + "enabled": false + }, + "BANger": { + "enabled": false + }, + "BetterFolders": { + "enabled": false, + "sidebar": true, + "showFolderIcon": 1, + "keepIcons": false, + "closeAllHomeButton": false, + "closeAllFolders": false, + "forceOpen": false, + "sidebarAnim": true, + "closeOthers": false + }, + "BetterGifAltText": { + "enabled": true + }, + "BetterGifPicker": { + "enabled": true + }, + "BetterNotesBox": { + "enabled": true, + "hide": false + }, + "BetterRoleContext": { + "enabled": true + }, + "BetterRoleDot": { + "enabled": true, + "bothStyles": false, + "copyRoleColorInProfilePopout": false + }, + "BetterSessions": { + "enabled": true, + "backgroundCheck": false + }, + "BetterSettings": { + "enabled": true, + "disableFade": true, + "eagerLoad": true, + "organizeMenu": true + }, + "BetterUploadButton": { + "enabled": true + }, + "BiggerStreamPreview": { + "enabled": true + }, + "BlurNSFW": { + "enabled": false + }, + "CallTimer": { + "enabled": true + }, + "ClearURLs": { + "enabled": false + }, + "ClientTheme": { + "enabled": false + }, + "ColorSighted": { + "enabled": false + }, + "ConsoleJanitor": { + "enabled": false + }, + "ConsoleShortcuts": { + "enabled": false + }, + "CopyEmojiMarkdown": { + "enabled": false + }, + "CopyFileContents": { + "enabled": false + }, + "CopyUserURLs": { + "enabled": true + }, + "CrashHandler": { + "enabled": true + }, + "CtrlEnterSend": { + "enabled": false + }, + "CustomRPC": { + "enabled": true, + "type": 0 + }, + "CustomIdle": { + "enabled": false + }, + "Dearrow": { + "enabled": false + }, + "Decor": { + "enabled": false + }, + "DisableCallIdle": { + "enabled": false + }, + "DontRoundMyTimestamps": { + "enabled": false + }, + "EmoteCloner": { + "enabled": false + }, + "Experiments": { + "enabled": false + }, + "F8Break": { + "enabled": false + }, + "FakeNitro": { + "enabled": false + }, + "FakeProfileThemes": { + "enabled": false + }, + "FavoriteEmojiFirst": { + "enabled": false + }, + "FavoriteGifSearch": { + "enabled": false + }, + "FixCodeblockGap": { + "enabled": true + }, + "FixImagesQuality": { + "enabled": true + }, + "FixSpotifyEmbeds": { + "enabled": true + }, + "FixYoutubeEmbeds": { + "enabled": true + }, + "ForceOwnerCrown": { + "enabled": true + }, + "FriendInvites": { + "enabled": false + }, + "FriendsSince": { + "enabled": true + }, + "FullSearchContext": { + "enabled": false + }, + "FullUserInChatbox": { + "enabled": false + }, + "GameActivityToggle": { + "enabled": false + }, + "GifPaste": { + "enabled": false + }, + "GreetStickerPicker": { + "enabled": false + }, + "HideMedia": { + "enabled": false + }, + "iLoveSpam": { + "enabled": false + }, + "IgnoreActivities": { + "enabled": false + }, + "ImageLink": { + "enabled": false + }, + "ImageZoom": { + "enabled": false + }, + "ImplicitRelationships": { + "enabled": true, + "sortByAffinity": true + }, + "InvisibleChat": { + "enabled": true + }, + "IrcColors": { + "enabled": false, + "memberListColors": true, + "lightness": 70, + "applyColorOnlyInDms": false, + "applyColorOnlyToUsersWithoutColor": false + }, + "KeepCurrentChannel": { + "enabled": false + }, + "LastFMRichPresence": { + "enabled": false + }, + "LoadingQuotes": { + "enabled": true, + "replaceEvents": true, + "enableDiscordPresetQuotes": false, + "additionalQuotes": "", + "additionalQuotesDelimiter": "|", + "enablePluginPresetQuotes": true + }, + "MemberCount": { + "enabled": false + }, + "MentionAvatars": { + "enabled": false + }, + "MessageClickActions": { + "enabled": false + }, + "MessageLatency": { + "enabled": false + }, + "MessageLinkEmbeds": { + "enabled": false + }, + "MessageLogger": { + "enabled": false + }, + "MessageTags": { + "enabled": false + }, + "MoreCommands": { + "enabled": false + }, + "MoreKaomoji": { + "enabled": false + }, + "Moyai": { + "enabled": false + }, + "MutualGroupDMs": { + "enabled": false + }, + "NewGuildSettings": { + "enabled": false + }, + "NoBlockedMessages": { + "enabled": false + }, + "NoDevtoolsWarning": { + "enabled": false + }, + "NoF1": { + "enabled": false + }, + "NoMaskedUrlPaste": { + "enabled": false + }, + "NoMosaic": { + "enabled": false + }, + "NoOnboardingDelay": { + "enabled": false + }, + "NoPendingCount": { + "enabled": false + }, + "NoProfileThemes": { + "enabled": false + }, + "NoRPC": { + "enabled": false + }, + "NoReplyMention": { + "enabled": false + }, + "NoServerEmojis": { + "enabled": true + }, + "NoSystemBadge": { + "enabled": false + }, + "NoTypingAnimation": { + "enabled": false + }, + "NoUnblockToJump": { + "enabled": false + }, + "NormalizeMessageLinks": { + "enabled": false + }, + "NotificationVolume": { + "enabled": false + }, + "NSFWGateBypass": { + "enabled": false + }, + "OnePingPerDM": { + "enabled": false + }, + "oneko": { + "enabled": false + }, + "OpenInApp": { + "enabled": false + }, + "OverrideForumDefaults": { + "enabled": false + }, + "PartyMode": { + "enabled": false + }, + "PauseInvitesForever": { + "enabled": false + }, + "PermissionFreeWill": { + "enabled": false + }, + "PermissionsViewer": { + "enabled": false + }, + "petpet": { + "enabled": false + }, + "PictureInPicture": { + "enabled": false + }, + "PinDMs": { + "enabled": false + }, + "PlainFolderIcon": { + "enabled": true + }, + "PlatformIndicators": { + "enabled": true, + "colorMobileIndicator": true, + "list": true, + "badges": true, + "messages": true + }, + "PreviewMessage": { + "enabled": false + }, + "QuickMention": { + "enabled": true + }, + "QuickReply": { + "enabled": true + }, + "ReactErrorDecoder": { + "enabled": false + }, + "ReadAllNotificationsButton": { + "enabled": true + }, + "RelationshipNotifier": { + "enabled": true, + "offlineRemovals": true, + "groups": true, + "servers": true, + "friends": true, + "friendRequestCancels": true + }, + "ReplaceGoogleSearch": { + "enabled": false + }, + "ReplyTimestamp": { + "enabled": false + }, + "RevealAllSpoilers": { + "enabled": false + }, + "ReverseImageSearch": { + "enabled": true + }, + "ReviewDB": { + "enabled": true, + "notifyReviews": true + }, + "RoleColorEverywhere": { + "enabled": true, + "chatMentions": true, + "memberList": true, + "voiceUsers": true, + "reactorsList": true, + "pollResults": true, + "colorChatMessages": false + }, + "SecretRingToneEnabler": { + "enabled": false + }, + "Summaries": { + "enabled": false + }, + "SendTimestamps": { + "enabled": false + }, + "ServerInfo": { + "enabled": false + }, + "ServerListIndicators": { + "enabled": false + }, + "ShikiCodeblocks": { + "enabled": false + }, + "ShowAllMessageButtons": { + "enabled": false + }, + "ShowConnections": { + "enabled": false + }, + "ShowHiddenChannels": { + "enabled": false + }, + "ShowHiddenThings": { + "enabled": false + }, + "ShowMeYourName": { + "enabled": false + }, + "ShowTimeoutDuration": { + "enabled": false + }, + "SilentMessageToggle": { + "enabled": false + }, + "SilentTyping": { + "enabled": false + }, + "SortFriendRequests": { + "enabled": false + }, + "SpotifyControls": { + "enabled": true, + "hoverControls": false + }, + "SpotifyCrack": { + "enabled": false + }, + "SpotifyShareCommands": { + "enabled": false + }, + "StartupTimings": { + "enabled": false + }, + "StickerPaste": { + "enabled": false + }, + "StreamerModeOnStream": { + "enabled": false + }, + "SuperReactionTweaks": { + "enabled": false + }, + "TextReplace": { + "enabled": false + }, + "ThemeAttributes": { + "enabled": false + }, + "Translate": { + "enabled": false + }, + "TypingIndicator": { + "enabled": false + }, + "TypingTweaks": { + "enabled": true, + "alternativeFormatting": true + }, + "Unindent": { + "enabled": false + }, + "UnlockedAvatarZoom": { + "enabled": false + }, + "UnsuppressEmbeds": { + "enabled": false + }, + "UserMessagesPronouns": { + "enabled": true, + "showSelf": true, + "pronounsFormat": "LOWERCASE" + }, + "UserVoiceShow": { + "enabled": false + }, + "USRBG": { + "enabled": true, + "voiceBackground": true, + "nitroFirst": true + }, + "ValidReply": { + "enabled": true + }, + "ValidUser": { + "enabled": true + }, + "VoiceChatDoubleClick": { + "enabled": false + }, + "VcNarrator": { + "enabled": false + }, + "VencordToolbox": { + "enabled": true + }, + "ViewIcons": { + "enabled": false + }, + "ViewRaw": { + "enabled": false + }, + "VoiceDownload": { + "enabled": true + }, + "VoiceMessages": { + "enabled": true + }, + "VolumeBooster": { + "enabled": false + }, + "WhoReacted": { + "enabled": true + }, + "XSOverlay": { + "enabled": true, + "webSocketPort": 42070, + "botNotifications": false, + "pingColor": "#7289da", + "channelPingColor": "#8a2be2", + "serverNotifications": true, + "lengthBasedTimeout": true, + "opacity": 1, + "volume": 0.2, + "soundPath": "default", + "preferUDP": false + }, + "YoutubeAdblock": { + "enabled": true + }, + "BadgeAPI": { + "enabled": true + }, + "NoTrack": { + "enabled": true, + "disableAnalytics": true + }, + "Settings": { + "enabled": true, + "settingsLocation": "aboveNitro" + }, + "SupportHelper": { + "enabled": true + } + }, + "notifications": { + "timeout": 5000, + "position": "bottom-right", + "useNative": "not-focused", + "logLimit": 50 + }, + "cloud": { + "authenticated": false, + "url": "https://api.vencord.dev/", + "settingsSync": false, + "settingsSyncVersion": 1744550478260 + } +} \ No newline at end of file diff --git a/desktopenvs/hyprdrive/Vencord/themes/cyberqueer.theme.css b/desktopenvs/hyprdrive/Vencord/themes/cyberqueer.theme.css new file mode 100644 index 0000000..ac9caf0 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/cyberqueer.theme.css @@ -0,0 +1,89 @@ +/** + * @name system24 + * @description A tui-style discord theme. + * @author refact0r + * @version 1.0.0 + * @invite nz87hXyvcy + * @website https://github.com/refact0r/system24 + * @source https://github.com/refact0r/system24/blob/master/system24.theme.css + * @authorId 508863359777505290 + * @authorLink https://www.refact0r.dev +*/ + +@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */ +/* @import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */ +*{ + font-family: 'Agave Nerd Font Mono'; +} + +/* customize things here */ +:root { + font: 'Agave Nerd Font'; /* UI font name. it must be installed on your system. */ + letter-spacing: 0; /* decreases letter spacing for better readability. */ + font-weight: 400; /* UI font weight. */ + --label-font-weight: 400; /* font weight for panel labels. */ + --corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */ + --pad: 20px; /* padding between panels. */ + --txt-pad: 5px; /* padding inside panels to prevent labels from clipping */ + --panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */ + + /* background colors */ + --bg-0: #1a1a1a; /* main background color. */ + --bg-1: #1a1a1a; /* background color for secondary elements like code blocks, embeds, etc. */ + --bg-2: #1a1a1a; /* color of neutral buttons. */ + --bg-3: #641a1a; /* color of neutral buttons when hovered. */ + + /* state modifiers */ + --hover: #5018dd;/* color of hovered elements. */ + --active: #E40046;/* color of elements when clicked. */ + --selected: #5018dd; /* color of selected elements. */ + + /* text colors */ + --txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */ + --txt-link: var(--cyan); /* color of links. */ + --txt-0: #d6abab; /* color of bright/white text. */ + --txt-1: #d6abab;/* main text color. */ + --txt-2: #5018dd;/* color of secondary text like channel list. */ + --txt-3: #d6abab;/* color of muted text. */ + + /* accent colors */ + --acc-1: #E40046; /* main accent color. */ + --acc-0: #5018dd; /* color of accent buttons when hovered. */ + --acc-2: #5018dd; /* color of accent buttons when clicked. */ + + /* borders */ + --border-width: 19px; /* panel border thickness. */ + --border-color: #5018dd; /* panel border color. */ + --border-hover-color: #E40046; /* panel border color when hovered. */ + --border-transition: 0.2s ease; /* panel border transition. */ + + /* status dot colors */ + --online-dot: var(--green); /* color of online dot. */ + --dnd-dot: var(--pink); /* color of do not disturb dot. */ + --idle-dot: var(--yellow); /* color of idle dot. */ + --streaming-dot: var(--purple); /* color of streaming dot. */ + + /* mention/ping and message colors */ + --mention-txt: #5018dd; /* color of mention text. */ + --mention-bg: #E40046; /* background highlight of mention text. */ + --mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */ + --mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */ + --reply-overlay: var(--active); /* overlay color of message you are replying to. */ + --reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */ + + /* color shades */ + --pink: oklch(73% 0.12 0); + --pink-1: oklch(63% 0.12 0); + --pink-2: oklch(53% 0.12 0); + --purple: oklch(73% 0.12 300); + --purple-1: oklch(63% 0.12 300); + --purple-2: oklch(53% 0.12 300); + --cyan: oklch(73% 0.12 200); + --yellow: oklch(78% 0.12 80); + --green: hsl(141, 76%, 48%); + --green-1: hsl(141, 73%, 42%); + --green-2: hsl(141, 73%, 36%); +} +/* .container_c48ade{ + background-color: transparent; +} */ diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/.prettierrc b/desktopenvs/hyprdrive/Vencord/themes/system24/.prettierrc new file mode 100644 index 0000000..7bfe977 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/.prettierrc @@ -0,0 +1,6 @@ +{ + "tabWidth": 4, + "singleQuote": true, + "useTabs": true, + "printWidth": 200 +} diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/LICENSE b/desktopenvs/hyprdrive/Vencord/themes/system24/LICENSE new file mode 100644 index 0000000..db1d2a4 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 refact0r + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/README.md b/desktopenvs/hyprdrive/Vencord/themes/system24/README.md new file mode 100644 index 0000000..5e7c114 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/README.md @@ -0,0 +1,72 @@ +> [!WARNING] +> recently discord has rolled out a new UI refresh update, which breaks the theme. for now, development is paused. if and when i find the motivation, the theme will be rewritten for the new UI. until then, please check out my other theme, [midnight](https://github.com/refact0r/midnight-discord). + +![preivew](/assets/preview.png) + +# system24 + +a tui-style discord theme, inspired by [spicetify text theme](https://github.com/spicetify/spicetify-themes/tree/master/text). + +![screenshot](/assets/screenshot3.png) + +## discord server + +need help? want to get notified about updates? have feedback? join + +## install + +### vencord/betterdiscord (or client with theme folder) + +1. go the [default theme file](https://github.com/refact0r/system24/blob/main/theme/system24.theme.css) or choose a flavor from the options below. +2. click the download button in the top right. +3. move the downloaded file into your theme folder. (there should be a button to open the theme folder in theme settings) +4. you may need to enable the theme in your theme settings by clicking a toggle switch. +5. (optional) customize the theme by editing the variables in `system24.theme.css`. + +### install through link + +1. add `https://refact0r.github.io/system24/theme/system24.theme.css` to your theme import links. + +## flavors + +customized variants of the theme. + +### spotify-text + +matches the spotify/spicetify ["text" theme](https://github.com/spicetify/spicetify-themes/tree/master/text). thinner borders and different font weights from the default system24. + +[theme file](https://github.com/refact0r/system24/blob/main/theme/flavors/spotify-text.theme.css) + +![image](https://github.com/user-attachments/assets/f459f9fc-7b6c-4e9d-a8c9-4adefb0be417) + +### catppuccin mocha + +based on the mocha palette of [catppuccin](https://catppuccin.com/palette). + +[theme file](https://github.com/refact0r/system24/blob/main/theme/flavors/catppuccin-mocha.theme.css) + +![image](https://github.com/user-attachments/assets/994d35f6-90d8-4f99-bfce-c3df13f415d4) + +### gruvbox material + +based on the vim theme [gruvbox material](https://github.com/sainnhe/gruvbox-material). + +[theme file](https://github.com/refact0r/system24/blob/main/theme/flavors/gruvbox-material.theme.css) + +![image](/assets/gruv-material.png) + +### monochrome + +A monochrome version of the default theme with mostly neutral colors. + +[theme file](https://github.com/refact0r/system24/blob/main/theme/flavors/monochrome.theme.css) + +![image](/assets/monochrome.png) + +### rose pine + +based on the main palette of [rose pine](https://rosepinetheme.com/palette/). + +[theme file](https://github.com/refact0r/system24/blob/main/theme/flavors/rosepine.theme.css) + +![image](/assets/rosepine.png) diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/assets/gruv-material.png b/desktopenvs/hyprdrive/Vencord/themes/system24/assets/gruv-material.png new file mode 100644 index 0000000..844f9f9 Binary files /dev/null and b/desktopenvs/hyprdrive/Vencord/themes/system24/assets/gruv-material.png differ diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/assets/monochrome.png b/desktopenvs/hyprdrive/Vencord/themes/system24/assets/monochrome.png new file mode 100644 index 0000000..50a5ff7 Binary files /dev/null and b/desktopenvs/hyprdrive/Vencord/themes/system24/assets/monochrome.png differ diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/assets/preview.png b/desktopenvs/hyprdrive/Vencord/themes/system24/assets/preview.png new file mode 100644 index 0000000..cbb4e51 Binary files /dev/null and b/desktopenvs/hyprdrive/Vencord/themes/system24/assets/preview.png differ diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/assets/rosepine.png b/desktopenvs/hyprdrive/Vencord/themes/system24/assets/rosepine.png new file mode 100644 index 0000000..37147fd Binary files /dev/null and b/desktopenvs/hyprdrive/Vencord/themes/system24/assets/rosepine.png differ diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/assets/screenshot.png b/desktopenvs/hyprdrive/Vencord/themes/system24/assets/screenshot.png new file mode 100644 index 0000000..d67022f Binary files /dev/null and b/desktopenvs/hyprdrive/Vencord/themes/system24/assets/screenshot.png differ diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/assets/screenshot2.png b/desktopenvs/hyprdrive/Vencord/themes/system24/assets/screenshot2.png new file mode 100644 index 0000000..e187fc1 Binary files /dev/null and b/desktopenvs/hyprdrive/Vencord/themes/system24/assets/screenshot2.png differ diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/assets/screenshot3.png b/desktopenvs/hyprdrive/Vencord/themes/system24/assets/screenshot3.png new file mode 100644 index 0000000..4d050f3 Binary files /dev/null and b/desktopenvs/hyprdrive/Vencord/themes/system24/assets/screenshot3.png differ diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/benchmark/benchmark.js b/desktopenvs/hyprdrive/Vencord/themes/system24/benchmark/benchmark.js new file mode 100644 index 0000000..19a9275 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/benchmark/benchmark.js @@ -0,0 +1,56 @@ +function benchmarkSelectors(cssString) { + function extractSelectors(cssString) { + cssString = cssString.replace(/\/\*[\s\S]*?\*\//g, '') // remove comments + + // remove nested brackets + let result = '' + let depth = 0 + for (let char of cssString) { + if (char === '{') { + depth++ + } else if (char === '}') { + depth-- + } else if (depth === 0) { + result += char + } + } + cssString = result + + let selectors = cssString + .split(/,(?![^(]*\))|[\n\r]+/) // split by commas or newline + .map((s) => s.trim().replace(/::(?:before|after)/, '')) // trim pseudo-elements + .filter(Boolean) // remove empty strings + + return selectors + } + + const benchmarkSelector = (selector) => { + const start = performance.now() + let matches = 0 + for (let i = 0; i < 1000; i++) { + matches = document.querySelectorAll(selector).length + } + return [(performance.now() - start) / 1000, matches] + } + + return extractSelectors(cssString) + .map((selector) => { + try { + const [time, matches] = benchmarkSelector(selector) + return { selector, time, matches } + } catch (error) { + console.error( + `Error benchmarking "${selector}": ${error.message}` + ) + return null + } + }) + .filter(Boolean) + .sort((a, b) => b.time - a.time) + .map( + ({ selector, time, matches }) => + `"${selector}",${time.toFixed(6)},${matches}` + ) + .join('\n') + .replace(/^/, 'Selector,Time (ms),Matches\n') +} diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/benchmark/benchmarkresults.csv b/desktopenvs/hyprdrive/Vencord/themes/system24/benchmark/benchmarkresults.csv new file mode 100644 index 0000000..ac67040 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/benchmark/benchmarkresults.csv @@ -0,0 +1,195 @@ +Selector,Time (ms),Matches +"[class^='chatContent_']:has([class^='messagesWrapper_']:hover)",0.256000,0 +"[class^='chatContent_']:has([class^='typing_']) > [class^='messagesWrapper_']",0.252800,0 +"[class^=chat_] > [class*=container_]",0.191900,0 +"[class*=embedFull_]",0.191100,1 +"[class*=lookFilled_][class*=colorPrimary_]",0.187400,0 +"#app-mount [class*=lookLink_][class*=colorPrimary_]",0.186500,0 +"#app-mount [class*=controlIcon_]",0.180000,0 +"[class^=container_] > [class^=wrapper_]",0.134800,1 +"[class^=container_] > [class^=wrapper_]",0.133600,1 +"[class^=container_] > [class^=wrapper_]",0.132400,1 +"[class^=message_][class*=replying_]",0.132300,0 +"[class^=embedVideoActions_] > [class^=centerContent_] > [class^=wrapper_]",0.131900,1 +"[class^=panels_] > [class^=wrapper_] > [class^=container]",0.131600,0 +"[class^=sidebar_] > [class^=container_]",0.131200,1 +"[class^=container_] > [class^=wrapper_]",0.131200,1 +"[class^='form_'] > [class^='wrapper_']",0.131000,0 +"[class^=chat_] > [class^=content_] > [class^=container_]",0.130700,1 +"[class^=message_][class*=mentioned_]",0.130200,0 +"[class^=container_] > [class^=wrapper_]",0.130000,1 +"[class^=panels_] > [class^=container_]",0.129600,1 +"#app-mount [class^=sidebar_] > [class^=container_]",0.128500,1 +"[class^='sidebar_'] > [class^='container_']",0.128500,1 +"[class^=member_]",0.126500,28 +"[class^=panels_]",0.126500,1 +"[class^='membersWrap_']",0.126400,1 +"[class^=navButton_][class*=navButtonActive_]",0.126000,0 +"[class^='sidebar_'] > [class^='container_']",0.125800,1 +"[class^='dot_'][class*='maskSecondary_']",0.125800,0 +"[class^='membersWrap_']",0.125600,1 +"[class^=panels_]",0.125600,1 +"[class^=sidebar_]",0.125500,1 +"[class^=centerContent_] > [class^=wrapper_] > [class^=anchor_]",0.125300,1 +"[class^=privateChannels_] > [class^=scroller_]",0.125200,0 +"[class^=childWrapper_]",0.125200,9 +"#app-mount [class^=partyStatus_] [class^=anchor_]",0.125200,0 +"[class^=iconBadge_]:not([class*=isCurrentUserConnected_])",0.125200,1 +"[class^='toolbar_'] > [class^='search_']",0.125100,1 +"[class^=container_] > [class^=base_]",0.125100,1 +"[class^='panels_']",0.125000,1 +"[class^='status_'][style='background-color: rgb(242, 63, 67);']",0.124900,0 +"[class^=chatContent_] > [class^=messagesWrapper_]",0.124800,1 +"[class^='form_'] [class^='channelTextArea_']",0.124700,1 +"#app-mount [class^=sidebar_]",0.124700,1 +"[class^=members_]",0.124500,1 +"[class*=bannerVisible_] [class^=headerContent_]",0.124500,0 +"[class^=form_] [class^=channelTextArea_]",0.124500,1 +"[class^='typing_']",0.124500,0 +"[class^='upperBadge_']",0.124500,1 +"[class^=tabBody_]",0.124400,0 +"[class^=resizeHandle_]",0.124200,0 +"[class^='userPanelInner_']",0.124200,0 +"[class^=attachedBars_]",0.124100,0 +"[class^=container_] > [class^=base_]",0.124100,1 +"[class^=gifFavoriteButton_]",0.124000,0 +"[class^=membersWrap_]",0.124000,1 +"[class^=panels_] > [class^=panel_]",0.124000,0 +"[class^='searchResultsWrap_']",0.124000,0 +"[class^=profilePanel_] > [class^=userPanelOuter_]",0.123900,0 +"[class^=chat_]",0.123900,1 +"[class^=sidebar_]",0.123900,1 +"[class^='form_'] [class^='channelTextArea_']",0.123900,1 +"[class^=panels_] > [class^=panel_]",0.123800,0 +"[class^='membersWrap_']",0.123800,1 +"[class^='nowPlayingColumn_']",0.123800,0 +"[class^=replyBar_]",0.123700,0 +"[class^=iconWrapper_]",0.123700,7 +"[class^='status_'][style='background-color: rgb(35, 165, 90);']",0.123700,0 +"[class^=searchResultsWrap_]",0.123700,0 +"[class^='unreadPill_'][class*='endCap_']",0.123600,1 +"[class^=overlayBottom_] > [class^=text-]",0.123600,0 +"[class^=privateChannels_]",0.123600,0 +"[class^=categoryText_]",0.123500,0 +"[class^=profilePanel_]",0.123500,0 +"[class^='searchResultsWrap_']",0.123400,0 +"[class^='membersWrap_']",0.123400,1 +"[class^='peopleListItem_']",0.123400,0 +"[class^='status_'][style='background-color: rgb(240, 178, 50);']",0.123300,0 +"[class^=tabBody_]",0.123200,0 +"[class^=privateChannels_]",0.123200,0 +"[class^='status_'][style='background-color: rgb(128, 132, 142);']",0.123200,0 +"[class^='chatContent_'] > [class^='messagesWrapper_']",0.123100,1 +"[class^='profilePanel_']",0.123100,0 +"[class^=videoControls_]",0.123000,0 +"[class^=sidebar_]",0.123000,1 +"[class^=searchAnswer_]",0.122800,0 +"[class^='profilePanel_']",0.122800,0 +"[class^='peopleColumn_']",0.122800,0 +"[class^='nowPlayingColumn_']",0.122800,0 +"[class^='sectionDivider_']",0.122800,1 +"[class^=notAppAsidePanel_] > [class^=layerContainer_]",0.122700,2 +"[class^='privateChannels_']",0.122700,0 +"[class^=privateChannels_]",0.122500,0 +"[class^=container_] > [class^=base_]",0.122500,1 +"[class^='lowerBadge_']",0.122400,0 +"[class^=searchResultsWrap_]",0.122400,0 +"[class^=threadSuggestionBar_]",0.122200,0 +"[class^='chatContent_'] > [class^='messagesWrapper_']",0.122200,1 +"[class^=searchResultsWrap_]",0.122200,0 +"[class^=peopleColumn_]",0.122200,0 +"[class^='nowPlayingColumn_']",0.122100,0 +"[class^=searchFilter_]",0.122000,0 +"[class^=privateChannels_]",0.122000,0 +"[class^=nowPlayingColumn_]",0.122000,0 +"[class^='folderIconWrapper_'][style='background-color: rgba(88, 101, 242, 0.4);']",0.121900,6 +"[class^='container_'] > [class^='base_']",0.121900,1 +"[class^='panels_']",0.121800,1 +"[class^=friendRequestsButton_] [class^=numberBadge_]",0.121600,0 +"[class^=peopleColumn_]",0.121400,0 +"#app-mount [class^=partyStatus_]",0.121300,0 +"[class^='form_'] [class^='channelTextArea_']",0.121100,1 +"[class^='privateChannels_']",0.121100,0 +"[class^='peopleColumn_']",0.120900,0 +"#app-mount [class^='divider_']",0.120100,3 +".wrapper_c51b4e:has(rect[fill='#593695'])",0.109200,0 +".wrapper_c51b4e:has(rect[fill='#80848e'])",0.107600,0 +".wrapper_c51b4e:has(rect[fill='#23a55a'])",0.096000,18 +".wrapper_c51b4e:has(rect[fill='#f0b232'])",0.095800,4 +".wrapper_c51b4e:has(rect[fill='#f23f43'])",0.089600,8 +".wrapper_c51b4e:has(rect)",0.087900,30 +"[style='opacity: 1; background-color: rgb(35, 165, 90);']",0.074800,0 +"svg[style='color: rgb(88, 101, 242);']",0.062200,8 +"#app-mount button path[fill='var(--white-500)']",0.061600,0 +"[role='button'] path[fill='var(--white-500)']",0.060400,0 +"[class^='mask_'] > svg",0.060400,2 +"[class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask",0.058600,48 +"[class^='bannerSVGWrapper_'] > mask > circle",0.056000,1 +"svg[class^=slider_] > rect[fill='white']",0.055400,0 +"[class^='mask_'] > rect",0.055400,30 +":root",0.055200,1 +"[class^='mask_'] > foreignObject",0.055100,32 +"[class^='svg_'] > foreignObject",0.054900,70 +"[class^='mask_'] > circle",0.054300,1 +":root",0.053800,1 +":root",0.052300,1 +"[class^=jumpToPresentBar_] > button",0.051700,0 +"aside[class^='profilePanel_']",0.051000,0 +".custom-profile-theme [class*=color_][class*=text_]",0.049900,0 +".horizontal_bba380 > .flex_bba380",0.044800,0 +".container_fb4810",0.044300,0 +".container_a4d4d9",0.043900,1 +".clipContainer_b11c5e",0.043900,0 +".content_a7d72e",0.043600,1 +".container_fc4f04",0.043600,1 +".scroller_e2e187",0.043400,1 +"html.theme-light",0.043400,0 +".platform-osx [class^=container_] > [class^=wrapper_]",0.043300,0 +".container_fc4f04",0.043300,1 +".container_fc4f04",0.043300,1 +".container_adcaac",0.043200,0 +".attachButton_f298d4",0.043200,1 +".wrapper_fea3ef",0.043200,1 +".container_d1c246",0.043200,0 +".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.043100,0 +".textArea_d0696b",0.043000,2 +".chat_a7d72e",0.043000,1 +".wordmarkWindows_a934d8 > svg",0.043000,1 +".vc-spotify-time-right",0.042900,1 +".theme-dark",0.042900,3 +".container_c75f85",0.042800,0 +".form_a7d72e",0.042800,1 +".container_a6d69a",0.042800,0 +".vc-spotify-time-left",0.042800,1 +".form_a7d72e",0.042700,1 +".container_b2ca13",0.042700,1 +".container_a6d69a",0.042700,0 +".container_c2739c",0.042700,0 +".tabBody_c2739c",0.042700,0 +".vc-spotify-time-right",0.042700,1 +".container_e85446 > [class^='svg_'] > foreignObject > [class^='status_']",0.042600,0 +".container_d1c246",0.042600,0 +".panels_a4d4d9",0.042600,1 +".container_adcaac",0.042600,0 +".container_c2739c",0.042500,0 +".scroller_fb4810",0.042500,0 +".form_a7d72e",0.042400,1 +".newMessagesBar_cf58b5",0.042400,1 +".animatedContainer_fd6364",0.042400,1 +".vc-spotify-time-left",0.042400,1 +".scroller_fb4810",0.042300,0 +".wordmarkWindows_a934d8",0.042300,1 +".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.042300,0 +".grabber_c7a159",0.042200,1 +".container_a6d69a",0.042100,0 +".scroller_fb4810",0.042100,0 +".scroller_fb4810",0.042100,0 +".container_fc4f04",0.041600,1 +"*",0.036500,6407 +"#vc-spotify-progress-bar [class^='bar_']",0.000300,1 +"#app-mount #vc-spotify-player",0.000200,1 +"#vc-spotify-player",0.000200,1 +"#vc-spotify-player",0.000200,1 +"#vc-spotify-progress-bar",0.000200,1 +"::-webkit-scrollbar-thumb",0.000200,0 +"#vc-spotify-player",0.000100,1 \ No newline at end of file diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/benchmark/benchmarkresults2.csv b/desktopenvs/hyprdrive/Vencord/themes/system24/benchmark/benchmarkresults2.csv new file mode 100644 index 0000000..b0b68ef --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/benchmark/benchmarkresults2.csv @@ -0,0 +1,232 @@ +Selector,Time (ms),Matches,Total +[class*=buttonColor_],0.4697,0,38.4736 +[class*=embedFull_],0.446,2, +[class^='chatContent_']:has([class^='typing_']:hover),0.4342,0, +#app-mount [class*=lookLink_][class*=colorPrimary_],0.4331,0, +[class*=lookFilled_][class*=colorPrimary_],0.4315,0, +[class^='chatContent_']:has([class^='messagesWrapper_']:hover),0.4189,0, +[class^='chatContent_']:has([class^='typing_']) > [class^='messagesWrapper_'] > [class^='jumpToPresentBar_'],0.4146,0, +#app-mount [class*=controlIcon_],0.3911,0, +[class^=searchFilter_],0.3541,0, +[class^='newBadge_'],0.3385,0, +[class^=friendRequestsButton_] [class^=numberBadge_],0.3314,0, +[class^=chat_] > [class*=container_],0.32,0, +[class^=videoControls_],0.3099,0, +[class*=backgroundAccent_],0.3097,0, +[class^=searchAnswer_],0.3096,0, +#app-mount [class^=partyStatus_],0.3063,0, +[class^='newChannel_'],0.2995,0, +[class^=iconBadge_]:not([class*=isCurrentUserConnected_]),0.2892,0, +#app-mount [class^=partyStatus_] [class^=anchor_],0.2834,0, +[class^=childWrapper_],0.2817,15, +[class^=overlayBottom_] > [class^=text-],0.2786,0, +[class^=gifFavoriteButton_],0.2739,0, +[class^='form_'] [class^='channelTextArea_'],0.2703,1, +[class^='panels_'],0.2612,1, +[class^=panels_],0.2599,1, +[class^=sidebar_],0.2585,1, +[class^=button_][class*=bannerColor_],0.2576,0, +[class^='content_']>[class^='outer_'],0.2546,0, +[class^=channelTextArea] > [class^=scrollableContainer],0.2527,1, +[class^='content_']>[class^='outer_'],0.2511,0, +[class^=sidebar_] > [class^=container_],0.2439,1, +[class^='channelBottomBarArea_'],0.2423,1, +[class^=panels_] > [class^=wrapper_] > [class^=container],0.2386,0, +[class^='form_'] > [class^='wrapper_'],0.2365,0, +[class^=navButton_][class*=navButtonActive_],0.2356,0, +[class^=sidebar_],0.2315,1, +[class^=membersWrap_],0.2308,1, +[class^=embedVideoActions_] > [class^=centerContent_] > [class^=wrapper_],0.2273,0, +[class^=categoryText_],0.2255,0, +[class^='form_'] > [class^='wrapper_'],0.2233,0, +[class^=sidebar_],0.2232,1, +[class^=members_],0.2213,1, +"[class^='folderIconWrapper_'][style='background-color: rgba(88, 101, 242, 0.4);']",0.2209,0, +[class^=peopleColumn_],0.2206,0, +[class^=nowPlayingColumn_],0.2206,0, +[class^=message_][class*=replying_],0.2181,0, +[class^=message_][class*=mentioned_],0.2175,5, +[class^=bottomControls_],0.2173,0, +[class*=bannerVisible_] [class^=headerContent_],0.2168,0, +[class^=container_] > [class^=base_],0.2148,1, +[class^=tabBody_],0.2147,0, +[class^=privateChannels_],0.2141,0, +[class^='sidebar_'] > [class^='container_'],0.2129,1, +[class^='dot_'][class*='maskSecondary_'],0.2125,0, +"[class^='status_'][style='background-color: rgb(35, 165, 90);']",0.2124,0, +[class^=privateChannels_],0.2119,0, +[class^='sidebar_'] > [class^='container_'],0.2112,1, +[class^='content_']>[class^='outer_'],0.2105,0, +[class^=channelBottomBarArea_],0.2103,1, +[class^='form_'] [class^='channelTextArea_'],0.2101,1, +[class^=privateChannels_],0.21,0, +[class^='channelAppLauncher_'],0.2099,1, +[class^=gradientBottom_],0.2099,0, +[class^=centerContent_] > [class^=wrapper_] > [class^=anchor_],0.2099,0, +[class^='form_'] > [class^='typing_'],0.2095,0, +[class^='content_']>[class^='outer_']>[class^='inner_'],0.2094,0, +#app-mount [class^=sidebar_] > [class^=container_],0.2093,1, +[class^='nowPlayingColumn_'],0.209,0, +"[class^='status_'][style='background-color: rgb(128, 132, 142);']",0.2087,0, +[class^=peopleColumn_],0.2082,0, +[class^='nowPlayingColumn_'],0.2079,0, +[class^=session_],0.2078,0, +[class^='container_'] > [class^='base_'],0.2076,1, +[class^=searchResultsWrap_],0.2076,0, +[class^=panels_] > [class^=panel_],0.2076,0, +[class^='membersWrap_'],0.2076,1, +[class^=searchResultsWrap_],0.2073,0, +[class^=guildSeparator_],0.2072,2, +[class^=tableCell_],0.2072,0, +[class^=chat_] > [class^=content_] > [class^=container_],0.2071,1, +[class^='membersWrap_'],0.207,1, +[class^='sectionDivider_'],0.2069,0, +[class^='privateChannels_'],0.2067,0, +[class^='peopleColumn_'],0.2065,0, +[class^=attachedBars_],0.2061,0, +[class^=searchResultsWrap_],0.206,0, +[class^='callContainer_'],0.2055,0, +[class^='peopleColumn_'],0.2051,0, +[class^='notAppAsidePanel_'] > [class^='layerContainer_'],0.2049,2, +[class^='unreadPill_'][class*='endCap_'],0.2048,0, +[class^='panels_'],0.2047,1, +#app-mount [class^=sidebar_],0.2045,1, +[class^=chat_],0.2044,1, +[class^=panels_],0.2044,1, +[class^=panels_] > [class^=container_],0.2042,1, +[class^='chatContent_'] > [class^='messagesWrapper_'],0.2042,1, +[class^='nowPlayingColumn_'],0.2041,0, +[class^='membersWrap_'],0.2041,1, +[class^=container_] > [class^=base_],0.2039,1, +[class^='toolbar_'] > [class^='search_'],0.2034,1, +"[class^='status_'][style='background-color: rgb(240, 178, 50);']",0.2034,0, +[class^=tabBody_],0.2031,0, +[class^='lowerBadge_'],0.2031,0, +[class^=privateChannels_] > [class^=scroller_],0.2029,0, +[class^='searchResultsWrap_'],0.2028,0, +[class^=customizationSection_],0.2028,0, +[class^=chatContent_] > [class^=messagesWrapper_],0.2027,1, +[class^='upperBadge_'],0.2027,0, +"[class^='status_'][style='background-color: rgb(242, 63, 67);']",0.2025,0, +[class^=threadSuggestionBar_],0.2021,0, +[class^=panels_] > [class^=panel_],0.202,0, +[class^='searchResultsWrap_'],0.202,0, +[class^=columnHeading_],0.2016,0, +#app-mount [class^='divider_'],0.2016,2, +[class^=callContainer_],0.2014,0, +[class^='chatContent_'] > [class^='messagesWrapper_'],0.2011,1, +[class^='channelBottomBarArea_']:hover,0.201,0, +[class^=gradientTop_],0.2009,0, +[class^=iconWrapper_],0.2008,6, +[class^='guildSeparator_'],0.1998,2, +[class^='content_']>[class^='outer_'],0.1998,0, +[class^='privateChannels_'],0.1997,0, +[class^=container_] > [class^=base_],0.1996,1, +[class^=member_],0.1985,25, +[class^='content_']>[class^='outer_'],0.1984,0, +[class^='sectionDivider_'],0.198,0, +[class^=topControls_],0.198,0, +[class^='membersWrap_'],0.1974,1, +[class^=privateChannels_],0.1971,0, +[class^=content_] > [class^=resizeHandle_],0.1969,0, +[class^=replyBar_],0.196,0, +[class^='form_'] [class^='channelTextArea_'],0.1958,1, +[class^='divider_']:not(.isUnread_c2654d),0.194,2, +.wrapper_c51b4e:has(rect[fill='#f0b232']),0.1871,8, +[class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask,0.1624,39, +:root,0.1432,1, +.wrapper_c51b4e:has(rect[fill='#80848e']),0.1408,1, +.wrapper_c51b4e:has(rect[fill='#593695']),0.1371,0, +.wrapper_c51b4e:has(rect[fill='#f23f43']),0.1349,5, +.wrapper_c51b4e:has(rect),0.1342,26, +.wrapper_c51b4e:has(rect[fill='#23a55a']),0.1332,12, +[class^='svg_'] > foreignObject,0.123,99, +[role='button'] path[fill='var(--white-500)'],0.1159,0, +"svg[style='color: rgb(88, 101, 242);']",0.1122,13, +[class^=jumpToPresentBar_] > button,0.1117,2, +"[style='opacity: 1; background-color: rgb(35, 165, 90);']",0.1095,0, +:root,0.1094,1, +[class^='mask_'] > rect,0.1049,26, +[class^='mask_'] > foreignObject,0.1041,26, +[class^='mask_'] > svg,0.1035,1, +#app-mount button path[fill='var(--white-500)'],0.1031,0, +[class^='bannerSVGWrapper_'] > mask > circle,0.102,0, +svg[class^=slider_] > rect[fill='white'],0.1016,0, +:root,0.0946,1, +[class^='mask_'] > circle,0.0937,0, +[class^=callContainer_] [class^=topControls_] section,0.0882,0, +.container_a6d69a,0.0797,0, +.guilds_a4d4d9,0.0771,1, +.container_adcaac,0.0769,0, +.vc-spotify-time-left,0.0766,0, +.vc-spotify-time-right,0.0746,0, +.guilds_a4d4d9,0.0733,1, +.form_a7d72e,0.0731,1, +.container_fc4f04,0.0723,1, +.container_c2739c,0.072,0, +.form_a7d72e,0.0719,1, +*,0.0714,6196, +.timestamp_f9f2ca.latin24CompactTimeStamp_f9f2ca,0.0713,13, +.container_d1c246,0.0713,0, +.vc-ranb-button,0.0709,0, +.container_b2ca13,0.0707,1, +.wrapper_fea3ef,0.0706,1, +.statusBubbleOuter_d0a252,0.0706,0, +.container_fc4f04,0.0704,1, +.scroller_bf550a,0.0703,0, +.guilds_a4d4d9,0.0703,1, +.attachButton_f298d4,0.0702,1, +.container_a6d69a,0.07,0, +.clipContainer_b11c5e,0.0699,0, +.chat_a7d72e,0.0699,1, +.form_a7d72e,0.0698,1, +.panels_a4d4d9,0.0698,1, +".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.0698,0, +.textArea_bdf0de,0.0697,1, +.content_a7d72e,0.0695,1, +.container_adcaac,0.0695,0, +.animatedContainer_fd6364,0.0693,1, +.guilds_a4d4d9,0.0692,1, +.container_fb4810,0.0692,0, +.separator_a0,0.0692,0, +".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.0692,0, +.scroller_fb4810,0.0691,0, +.wordmarkWindows_a934d8 > svg,0.0691,1, +.wordmarkWindows_a934d8,0.0688,1, +.container_fc4f04,0.0688,1, +.scroller_fb4810,0.0688,0, +.guilds_a4d4d9,0.0687,1, +.container_e85446 > [class^='svg_'] > foreignObject > [class^='status_'],0.0686,0, +.newMessagesBar_cf58b5,0.0685,0, +.container_a4d4d9,0.0684,1, +.scroller_e2e187,0.0684,1, +.platform-osx .guilds_a4d4d9,0.0684,0, +.scroller_fb4810,0.0683,0, +.container_c75f85,0.0683,0, +.container_c2739c,0.0681,0, +html.theme-light,0.0681,0, +.container_a6d69a,0.0681,0, +.grabber_c7a159,0.068,0, +.scroller_fb4810,0.0679,0, +.theme-dark,0.0678,1, +.statusBubbleOuter_d0a252,0.0678,0, +.tabBody_c2739c,0.0677,0, +.container_d1c246,0.0676,0, +.horizontal_dc333f > .flex_dc333f,0.0673,0, +.separator_a0,0.0669,0, +.vc-spotify-time-left,0.0666,0, +.container_fc4f04,0.0662,1, +.vc-spotify-time-right,0.0661,0, +.wrapper_fea3ef.hidden_fea3ef,0.0661,0, +#vc-spotify-player,0.0004,0, +#vc-spotify-progress-bar [class^='bar_'],0.0004,0, +#vc-spotify-player,0.0004,0, +.overlay_f94206:after,0.0004,0, +#app-mount #vc-spotify-player,0.0003,0, +#vc-spotify-player,0.0003,0, +#vc-spotify-progress-bar,0.0003,0, +::placeholder,0.0003,0, +::selection,0.0003,0, +::-webkit-scrollbar-thumb,0.0003,0, +.overlay_f18fcb:after,0.0003,0, +#vc-spotify-player,0.0002,0, diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/benchmark/benchmarkresults3.csv b/desktopenvs/hyprdrive/Vencord/themes/system24/benchmark/benchmarkresults3.csv new file mode 100644 index 0000000..6136e4b --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/benchmark/benchmarkresults3.csv @@ -0,0 +1,231 @@ +Selector,Time (ms),Matches,Total +[class^='chatContent_']:has([class^='messagesWrapper_']:hover),0.4053,0,29.2904 +[class^='chatContent_']:has([class^='typing_']:hover),0.3815,0, +[class^=chat_] > [class*=container_],0.3098,0, +[class^=chat_] > [class^=content_] > [class^=container_],0.238,0, +[class*=backgroundAccent_],0.2297,0, +[class^='form_'] [class^='channelTextArea_'],0.2236,1, +[class^='membersWrap_'],0.2207,0, +[class^=panels_],0.2156,1, +[class^=centerContent_] > [class^=wrapper_] > [class^=anchor_],0.2081,0, +[class^=searchResultsWrap_],0.1998,0, +[class^='content_']>[class^='outer_'],0.1997,0, +[class^='form_'] > [class^='wrapper_'],0.1982,0, +[class^=videoControls_],0.1967,0, +[class^=sidebar_] > [class^=container_],0.1947,1, +[class^=privateChannels_],0.1911,0, +[class^='content_'] > [class^='outer_'],0.19,0, +"[class^='status_'][style='background-color: rgb(240, 178, 50);']",0.19,0, +[class^='form_'] > [class^='wrapper_'],0.1888,0, +[class^=embedVideoActions_] > [class^=centerContent_] > [class^=wrapper_],0.1887,0, +[class^=iconBadge_]:not([class*=isCurrentUserConnected_]),0.1881,1, +[class^='sidebar_'] > [class^='container_'],0.1842,1, +[class^='peopleColumn_'],0.1839,0, +[class^='lowerBadge_'],0.1839,0, +[class^='gradientBottom_'],0.1836,0, +[class^=overlayBottom_] > [class^=text-],0.1834,0, +"[class^='status_'][style='background-color: rgb(35, 165, 90);']",0.1834,0, +[class^='chatContent_']:has([class^='typing_']) > [class^='messagesWrapper_'] > [class^='jumpToPresentBar_'],0.1834,0, +"[class^='status_'][style='background-color: rgb(128, 132, 142);']",0.1834,0, +"[class^='status_'][style='background-color: rgb(242, 63, 67);']",0.1832,0, +[class^='form_'] > [class^='typing_'],0.183,1, +[class^=panels_],0.1826,1, +[class^='searchResultsWrap_'],0.1826,0, +[class^='callContainer_'],0.182,0, +[class^='upperBadge_'],0.1817,0, +[class^=childWrapper_],0.1817,15, +[class^=members_],0.1815,0, +[class^=button_][class*=bannerColor_],0.181,0, +[class^='gradientTop_'],0.181,0, +[class^='sidebar_'] > [class^='container_'],0.1808,1, +[class^=searchFilter_],0.1807,0, +[class^=privateChannels_],0.1804,0, +[class^='panels_'],0.1794,1, +[class^=member_],0.1791,0, +[class^='membersWrap_'],0.1791,0, +[class^=message_][class*=replying_],0.179,0, +[class^='unreadPill_'][class*='endCap_'],0.1788,0, +[class^='privateChannels_'],0.1787,0, +[class^='bottomControls_'],0.1787,0, +[class^=message_][class*=mentioned_],0.1784,5, +[class^='content_'] > [class^='outer_'],0.1784,0, +#app-mount [class^=partyStatus_],0.1784,0, +[class^=searchAnswer_],0.1781,0, +[class^=panels_] > [class^=wrapper_] > [class^=container],0.178,0, +[class^='peopleColumn_'],0.1779,0, +[class^='form_'] [class^='channelTextArea_'],0.1779,1, +#app-mount [class^=partyStatus_] [class^=anchor_],0.1776,0, +[class^='panels_'],0.1766,1, +[class^='form_'] [class^='channelTextArea_'],0.1766,1, +[class^=replyBar_],0.1765,0, +[class^='privateChannels_'],0.1765,0, +[class^=channelBottomBarArea_],0.1764,1, +[class^='divider_']:not(.isUnread_c2654d),0.1763,2, +[class^=navButton_][class*=navButtonActive_],0.1762,0, +[class^=container_] > [class^=base_],0.1756,1, +[class^='nowPlayingColumn_'],0.1755,0, +[class^='membersWrap_'],0.1755,0, +[class^=sidebar_],0.1753,1, +[class^='newChannel_'],0.1752,0, +[class^=nowPlayingColumn_],0.1752,0, +[class^=guildSeparator_],0.1751,2, +[class^='topControls_'],0.175,0, +[class^='container_'] > [class^='base_'],0.1748,1, +[class^=panels_] > [class^=container_],0.1742,1, +[class^='notAppAsidePanel_'] > [class^='layerContainer_'],0.1742,2, +[class^=columnHeading_],0.174,0, +[class^='channelBottomBarArea_']:hover,0.1739,0, +[class^=attachedBars_],0.1738,0, +[class^='membersWrap_'],0.1738,0, +[class^=searchResultsWrap_],0.1737,0, +[class^=gifFavoriteButton_],0.1736,0, +[class^=membersWrap_],0.1736,0, +[class^=customizationSection_],0.1732,0, +#app-mount [class^=sidebar_] > [class^=container_],0.1727,1, +[class^='newBadge_'],0.1726,0, +[class^='channelBottomBarArea_'],0.1723,1, +[class^=sidebar_],0.1722,1, +[class^=privateChannels_],0.1722,0, +[class^=sidebar_],0.1721,1, +[class^='toolbar_'] > [class^='search_'],0.172,1, +[class^=privateChannels_],0.1713,0, +[class^=friendRequestsButton_] [class^=numberBadge_],0.1713,0, +[class*=bannerVisible_] [class^=headerContent_],0.1712,0, +[class^=iconWrapper_],0.1712,7, +[class^=chatContent_] > [class^=messagesWrapper_],0.1712,1, +[class^='content_']>[class^='outer_']>[class^='inner_'],0.171,0, +[class^='nowPlayingColumn_'],0.1709,0, +[class^=peopleColumn_],0.1709,0, +[class^='content_']>[class^='outer_'],0.1707,0, +[class^=searchResultsWrap_],0.1707,0, +[class^='chatContent_'] > [class^='messagesWrapper_'],0.1707,1, +[class^=tableCell_],0.1707,0, +[class^=tabBody_],0.1706,0, +[class^=peopleColumn_],0.1705,0, +[class^=tabBody_],0.1703,0, +[class^='dot_'][class*='maskSecondary_'],0.1702,0, +[class^='sectionDivider_'],0.1701,1, +[class^='searchResultsWrap_'],0.1701,0, +[class^=privateChannels_] > [class^=scroller_],0.17,0, +[class^=container_] > [class^=base_],0.17,1, +[class^='content_'] > [class^='outer_'],0.1699,0, +[class^=chat_],0.1696,1, +[class^='channelAppLauncher_'],0.1696,1, +[class^='nowPlayingColumn_'],0.1696,0, +#app-mount [class^=sidebar_],0.1689,1, +[class^='guildSeparator_'],0.1688,2, +[class^=panels_] > [class^=panel_],0.1686,0, +[class^=threadSuggestionBar_],0.1685,0, +[class^='chatContent_'] > [class^='messagesWrapper_'],0.1684,1, +[class^=session_],0.1684,0, +[class^=categoryText_],0.1683,0, +[class^=channelTextArea] > [class^=scrollableContainer],0.1679,1, +[class^=panels_] > [class^=panel_],0.1677,0, +"[class^='folderIconWrapper_'][style='background-color: rgba(88, 101, 242, 0.4);']",0.1674,0, +[class^='sectionDivider_'],0.1671,1, +[class^='callContainer_'],0.1669,0, +[class^=content_] > [class^=resizeHandle_],0.1663,0, +[class^=container_] > [class^=base_],0.1661,1, +#app-mount [class^='divider_'],0.1639,2, +[class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask,0.123,45, +*,0.1194,5612, +[class^='mask_'] > foreignObject,0.1165,1, +[class^='bannerSVGWrapper_'] > mask > circle,0.1122,0, +[class^='mask_'] > rect,0.1074,1, +[class^='svg_'] > foreignObject,0.1069,100, +[class^='mask_'] > svg,0.1043,0, +#app-mount .controlIcon_ef18ee,0.1034,0, +#app-mount .lookLink_dd4f85.colorPrimary_dd4f85,0.1023,0, +[class^='mask_'] > circle,0.099,0, +"[style='opacity: 1; background-color: rgb(35, 165, 90);']",0.0979,0, +"svg[style='color: rgb(88, 101, 242);']",0.0884,13, +#app-mount button path[fill='var(--white-500)'],0.087,0, +[role='button'] path[fill='var(--white-500)'],0.083,0, +:root,0.0804,1, +:root,0.0792,1, +[class^='callContainer_'] [class^='topControls_'] section,0.0784,0, +svg[class^=slider_] > rect[fill='white'],0.0784,0, +:root,0.0767,1, +[class^=jumpToPresentBar_] > button,0.0735,0, +.wrapper_c51b4e:has(rect[fill='#80848e']),0.0695,1, +.textArea_bdf0de,0.0636,1, +.attachButton_f298d4,0.0619,1, +.wrapper_c51b4e:has(rect[fill='#593695']),0.0614,0, +.wrapper_c51b4e:has(rect[fill='#f0b232']),0.0609,0, +.guilds_a4d4d9,0.0607,1, +.wrapper_c51b4e:has(rect),0.0607,1, +.wrapper_c51b4e:has(rect[fill='#f23f43']),0.0606,0, +.theme-dark,0.0602,1, +.wrapper_c51b4e:has(rect[fill='#23a55a']),0.0597,0, +.lookFilled_dd4f85.colorPrimary_dd4f85,0.0584,0, +.container_fc4f04,0.0581,1, +.guilds_a4d4d9,0.058,1, +.timestamp_f9f2ca.latin24CompactTimeStamp_f9f2ca,0.0577,32, +.container_c2739c,0.0572,0, +.container_b2ca13,0.0571,1, +".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.0568,0, +.embedFull_b0068a,0.0567,1, +.separator_a0,0.0564,0, +.scroller_fb4810,0.0562,0, +.scroller_fb4810,0.0562,0, +.statusBubbleOuter_d0a252,0.056,0, +.statusBubbleOuter_d0a252,0.0559,0, +.container_a4d4d9,0.0558,1, +.container_a6d69a,0.0557,0, +.container_fc4f04,0.0557,1, +.container_a6d69a,0.0557,0, +.form_a7d72e,0.0556,1, +.wrapper_fea3ef.hidden_fea3ef,0.0556,0, +.guilds_a4d4d9,0.0555,1, +.vc-ranb-button,0.0555,0, +.horizontal_dc333f > .flex_dc333f,0.0555,0, +.wordmarkWindows_a934d8 > svg,0.0554,1, +".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.0554,0, +.vc-spotify-time-right,0.0553,0, +.newMessagesBar_cf58b5,0.0553,0, +.chat_a7d72e,0.0552,1, +.panels_a4d4d9,0.0552,1, +.container_fb4810,0.0552,0, +.vc-spotify-time-right,0.0552,0, +.content_a7d72e,0.0551,1, +.container_d1c246,0.0551,0, +.form_a7d72e,0.0551,1, +.wordmarkWindows_a934d8,0.0551,1, +.vc-spotify-time-left,0.0551,0, +.scroller_bf550a,0.055,0, +.clipContainer_b11c5e,0.0549,0, +.vc-spotify-time-left,0.0549,0, +.wrapper_fea3ef,0.0549,1, +.grabber_c7a159,0.0549,0, +.platform-osx .guilds_a4d4d9,0.0548,0, +.container_fc4f04,0.0548,1, +.guilds_a4d4d9,0.0547,1, +.container_a6d69a,0.0547,0, +.scroller_e2e187,0.0546,1, +.container_c75f85,0.0546,0, +.container_c2739c,0.0546,0, +.animatedContainer_fd6364,0.0544,0, +.container_e85446 > [class^='svg_'] > foreignObject > [class^='status_'],0.0544,0, +.container_fc4f04,0.0544,1, +.container_d1c246,0.0544,0, +.separator_a0,0.0544,0, +.container_adcaac,0.0542,0, +.guilds_a4d4d9,0.0542,1, +html.theme-light,0.054,0, +.form_a7d72e,0.0539,1, +.tabBody_c2739c,0.0539,0, +.scroller_fb4810,0.0539,0, +.container_adcaac,0.0538,0, +.scroller_fb4810,0.0533,0, +::selection,0.0028,0, +#vc-spotify-player,0.0003,0, +#vc-spotify-player,0.0003,0, +#vc-spotify-player,0.0003,0, +#vc-spotify-progress-bar,0.0003,0, +#vc-spotify-progress-bar [class^='bar_'],0.0003,0, +.overlay_f94206:after,0.0003,0, +#app-mount #vc-spotify-player,0.0002,0, +#vc-spotify-player,0.0002,0, +.overlay_f18fcb:after,0.0002,0, +::-webkit-scrollbar-thumb,0.0001,0, +::placeholder,0.0001,0, diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/package-lock.json b/desktopenvs/hyprdrive/Vencord/themes/system24/package-lock.json new file mode 100644 index 0000000..6d05cad --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/package-lock.json @@ -0,0 +1,215 @@ +{ + "name": "systemtheme", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "systemtheme", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "chokidar": "^3.6.0", + "dotenv": "^16.4.5" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + } + } +} diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/package.json b/desktopenvs/hyprdrive/Vencord/themes/system24/package.json new file mode 100644 index 0000000..16add93 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/package.json @@ -0,0 +1,14 @@ +{ + "name": "systemtheme", + "version": "1.0.0", + "scripts": { + "dev": "node scripts/dev.js", + "build": "node scripts/build.js" + }, + "author": "refact0r", + "license": "MIT", + "devDependencies": { + "chokidar": "^3.6.0", + "dotenv": "^16.4.5" + } +} diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/scripts/dev.js b/desktopenvs/hyprdrive/Vencord/themes/system24/scripts/dev.js new file mode 100644 index 0000000..920f361 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/scripts/dev.js @@ -0,0 +1,46 @@ +const fs = require('fs'); +const path = require('path'); +const chokidar = require('chokidar'); + +require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); + +const srcDir = path.join(__dirname, '..', 'src'); +const baseFile = path.join(__dirname, '..', 'system24.theme.css'); +const outputFile = process.env.DEV_OUTPUT_PATH; +const pathToIgnore = 'https://refact0r.github.io/system24/'; + +if (!outputFile) { + console.error('DEV_OUTPUT_PATH is not set in .env file'); + process.exit(1); +} + +async function replaceImports(content) { + const importRegex = /@import url\('([^']+)'\);/g; + let match; + while ((match = importRegex.exec(content)) !== null) { + const importUrl = match[1]; + const filePath = importUrl.replace(pathToIgnore, ''); + const localFilePath = path.join(__dirname, '..', filePath); + if (fs.existsSync(localFilePath)) { + const importedContent = fs.readFileSync(localFilePath, 'utf8'); + content = content.replace(match[0], importedContent); + } else { + console.error(`File not found: ${localFilePath}`); + } + } + return content; +} + +async function combineCSS() { + let combinedCSS = fs.readFileSync(baseFile, 'utf8'); + combinedCSS = await replaceImports(combinedCSS); + fs.writeFileSync(outputFile, combinedCSS); + console.log('Updated development CSS file.'); +} + +combineCSS(); + +chokidar.watch(srcDir).on('change', (event, path) => { + console.log('Changes detected. Rebuilding...'); + combineCSS(); +}); diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/src/main.1.css b/desktopenvs/hyprdrive/Vencord/themes/system24/src/main.1.css new file mode 100644 index 0000000..1d4d952 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/src/main.1.css @@ -0,0 +1,1126 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap'); +@import url('https://fonts.cdnfonts.com/css/asciid'); + +:root { + --font: 'DM Mono'; + letter-spacing: -0.05ch; + font-weight: 300; + --label-font-weight: 500; + + --corner-text: 'system24'; + + --pad: 16px; + --txt-pad: 10px; + + --panel-roundness: 0px; + + --bg-0: oklch(19% 0 0); + --bg-1: oklch(23% 0 0); + --bg-2: oklch(27% 0 0); + --bg-3: oklch(31% 0 0); + + --hover: oklch(54% 0 0 / 0.1); + --active: oklch(54% 0 0 / 0.2); + --selected: var(--active); + + --txt-dark: var(--bg-0); + --txt-link: var(--cyan); + --txt-0: oklch(90% 0 0); + --txt-1: oklch(80% 0 0); + --txt-2: oklch(60% 0 0); + --txt-3: oklch(40% 0 0); + + --acc-0: var(--purple); + --acc-1: var(--purple-1); + --acc-2: var(--purple-2); + + --border-width: 2px; + --border-color: var(--bg-3); + --border-hover-color: var(--acc-0); + --border-transition: 0.2s ease; + + --online-dot: var(--green); + --dnd-dot: var(--pink); + --idle-dot: var(--yellow); + --streaming-dot: var(--purple); + + --mention-txt: var(--acc-0); + --mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); + --mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); + --mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); + --reply-overlay: var(--active); + --reply-hover-overlay: var(--hover); + + --pink: oklch(73% 0.12 0); + --pink-1: oklch(63% 0.12 0); + --pink-2: oklch(53% 0.12 0); + --purple: oklch(73% 0.12 300); + --purple-1: oklch(63% 0.12 300); + --purple-2: oklch(53% 0.12 300); + --cyan: oklch(73% 0.12 200); + --yellow: oklch(78% 0.12 80); + --green: oklch(73% 0.12 160); + --green-1: oklch(63% 0.12 160); + --green-2: oklch(53% 0.12 160); +} + +:root { + --font-primary: var(--font), 'gg mono', monospace; + --font-display: var(--font), 'gg mono', monospace; + --font-code: var(--font), 'gg mono', monospace; +} + +/* colors */ +/* remove bg */ +.sidebar_c48ade /* channel list */, +.sidebar_c48ade>[class^='container_'] /* channel list inner */, +.privateChannels__35e86 /* dms list */, +.privateChannels__35e86>.scroller__99e7c /* dms list inner */, +.stackedBars__74017, .replyBar__841c8, .threadSuggestionBar__841c8 /* reply, thread, atttachment bars above chat bar */, +.panels_c48ade /* user panel */, +#app-mount #vc-spotify-player, +.panels_c48ade>.panel__5dec7 /* streaming panel */, +.panels_c48ade>.container__37e49 /* user panel inner */, +.container_c8ffbb /* member list */, +.members_c8ffbb /* member list inner */, +.member__5d473 /* member containers */, +.searchResultsWrap_a9e706 /* search results */, +.container_e131a9 /* vc panel */, +.scrollableContainer__74017, +.channelAppLauncher_e6e74f button /* app button */, +.footer__214dc /* discover button */, +.searchHeader_f3b986 /* search */ { + background: none; +} +/* fix bgs */ +.content_f75fb0>.outer_c0bea0 /* profile panel */ { + --background-secondary-alt: var(--bg-0) !important; +} +.childWrapper__6e9f8 /* no color servers */ { + background-color: var(--bg-1); +} +.embedFull__623de /* embed default color */ { + border-color: var(--bg-2); +} +/* fix text */ +.newChannel__599fa, +[class^='newBadge_'] { + color: var(--bg-0); +} +/* fix white-500 */ +.lookFilled__201d5.colorPrimary__201d5 /* neutral buttons (like in account settings) */, +#app-mount .lookLink__201d5.colorPrimary__201d5 /* link buttons (like in account settings) */, +.friendRequestsButton__523aa>.numberBadge__2b1f5 /* inbox friend requests number */, +.searchFilter_bd816, .searchAnswer_bd8186, +.partyStatus__4d3fa .anchor_edefb8 /* spotify listen along link */, +.partyStatus__4d3fa /* spotify listen along text */, +.iconBadge__650eb:not(.isCurrentUserConnected__650eb) /* vc icon */, +.videoControls_cf09d8 .controlIcon_cf09d8 /* video controls */, +.durationTimeWrapper_cf09d8 /* video duration */, +#app-mount .controlIcon_f1ceac /* stop streaming icon in preview */, +.navButtonActive__08434 /* gif picker header selected */, +.bannerColor_fb7f94 /* user profile buttons */, +.backgroundAccent__4cd16 /* server insights warning */ { + color: var(--txt-1); +} +.headerContent_f37cb1 /* server name (when banner) */, +.jumpToPresentBar__0f481>button, +.gifFavoriteButton__43deb, +.iconWrapper__6eb54 /* video play button */, +.overlayBottom__2f4f7>.text-md\/normal_cf4812 /* vc names */, +.categoryText_d02962 /* gif category labels */ { + color: var(--txt-0); +} +.wrapper__926d7 svg /* youtube embed play button */ { + color: var(--txt-0) !important; +} +[role='button'] path[fill='var(--white-500)'], +#app-mount button path[fill='var(--white-500)'] { + fill: var(--txt-0); +} + +/* accents */ +[style='color: rgb(88, 101, 242);'] /* folder icons */ { + color: var(--acc-0) !important; +} +.folderIconWrapper__48112[style='background-color: rgba(88, 101, 242, 0.4);'] { + background: var(--bg-1) !important; +} +.checked__87bf1 /* settings checkbuttons */ { + background-color: var(--green) !important; +} +.slider__87bf1>rect[fill='white'] /* settings checkbuttons slider */ { + fill: var(--txt-dark); +} +.replying__5126c::before /* reply message left bar */ { + background: var(--txt-1); +} +.mentioned__5126c::before /* mention message left bar */ { + background: var(--acc-0); +} + +/* add spacing */ +.base_c48ade /* outside edges */ { + margin: var(--pad) var(--pad) var(--pad) 0; +} +.guilds_c48ade /* outside edges */ { + margin: var(--pad) 0 var(--pad) var(--pad); +} +.container_c48ade /* server list and everything else */, +.sidebar_c48ade /* channel list and user panel */, +.content_f75fb0 /* between chat and member list */, +.container__133bf /* friend page */, +[class^=tabBody_] /* friends page inner */, +.container__01ae2 /* forum post header and chat */, +[class^=chat_] /* header bar and chat */, +.container__9293f /* new thread header and chat */, +[class^=container_]>[class^=base_] /* banner and main stuff */ { + gap: var(--pad); +} +.sidebar_c48ade /* channel list and chat */ { + margin-right: var(--pad); +} +[class^=content_]>[class^=resizeHandle_] /* pretend to be a margin */ { + width: var(--pad); +} +.timestamp_c19a55.latin24CompactTimeStamp_c19a55 { + width: 3.3rem !important; +} + +/* fix inbox overflow and hiding window controls */ +[class^='notAppAsidePanel_'] > [class^='layerContainer_'] { + overflow: hidden; +} + +/* platform spacing */ +.platform-osx .guilds_c48ade { + margin-top: calc(32px + var(--pad)); +} + +/* borders */ +.panels_c48ade /* user panel */, +.sidebar_c48ade>[class^=container_] /* channel list */, +.messagesWrapper__36d07 /* chat */, +.membersWrap_c8ffbb /* member list */, +.guilds_c48ade /* server list */, +.privateChannels__35e86 /* dm list */, +.container__9293f /* header bar */, +.peopleColumn__133bf /* friends page list */, +.nowPlayingColumn__133bf /* friends page active now */, +.itemCard__7e549 /* friends active now cards */, +.channelBottomBarArea_f75fb0 /* chat bar */, +.container_f369db /* forum */, +.content_f75fb0>.outer_c0bea0 /* dm profile panel */, +.searchResultsWrap_a9e706 /* search results */, +.scroller_fb64c9 /* new thread */, +[class^=chat_]>[class*=container_] /* header bar */, +[class^='form_']>[class^='wrapper_'] /* no access chat bar */, +.chatHeaderBar_dc83f5 /* add to channel list popup */, +.contentWrapper__08434 /* gifs/emojis/stickers */, +.container_a592e1 /* discover */, +.channelTextArea_fb64c9 /* new thread chatbox */ { + border-radius: var(--panel-roundness); + border: var(--border-width) solid var(--border-color); + transition: border-color var(--border-transition); + &:hover { + border-color: var(--border-hover-color); + &::after { + color: var(--border-hover-color); + } + } +} +/* adjust border thickness */ +#vc-spotify-player, +.panels_c48ade .container_e131a9 /* vc panel */, +.panels_c48ade>.panel__5dec7 /* streaming panel */ { + border-width: var(--border-width); +} +/* remove borders */ +[class^='dot_'][class*='maskSecondary_'] { + border: none; +} + +/* fix border hover effect */ +.chatContent_f75fb0 { + &:has(.typing_b88801:hover), + &:has(> .messagesWrapper__36d07:hover) { + .typing_b88801 { + border-color: var(--border-hover-color); + } + > .messagesWrapper__36d07 { + border-color: var(--border-hover-color); + } + } +} + +/* fix double left border at friends' activity now lane */ +.scroller__7d20c { + border-left: 0px solid var(--background-modifier-accent); +} + +/*"ReadAllNotifications" button*/ +.vc-ranb-button { + font-size: 12px; +} + +/* move chat bar out */ +:root { + --custom-channel-textarea-text-area-height: 56px; +} +.form_f75fb0 { + padding: 0; + width: 100%; + display: flex; + flex-direction: column; + margin-top: 0; +} +.form_f75fb0::after, +.form_f75fb0::before { + display: none !important; +} +.channelTextArea_f75fb0, +.channelTextArea_fb64c9 { + margin: 0; + border-radius: var(--panel-roundness); + box-sizing: border-box; +} +.channelBottomBarArea_f75fb0, +.submitContainer_fb64c9 { + margin-top: var(--pad); +} +[class^='form_'] > [class^='typing_'] { + background: var(--bg-0); + order: -1; + top: -24px; + left: 0; + width: 100%; + box-sizing: border-box; + position: absolute; + border: var(--border-width) solid var(--border-color); + border-top: none; + transition: border-color var(--border-transition); +} +.chatContent_f75fb0:has([class^='typing_']) > .messagesWrapper__36d07 > [class^='jumpToPresentBar_'] { + margin-bottom: 22px; +} +.clipContainer__841c8 { + margin-top: 0; +} +.textArea__74017 { + display: flex; + flex-direction: column; + justify-content: center; +} +.attachButton__0923f { + height: var(--custom-channel-textarea-text-area-height); +} +/* make header bar height match chat bar */ +.container__9293f, +.container__37e49 { + height: var(--custom-channel-textarea-text-area-height); + box-sizing: border-box; +} + +/* fix chat bar when no chat bar */ +[class^='form_'] > [class^='wrapper_'] { + margin: 0; + padding: 0; + box-sizing: border-box; + height: calc(var(--custom-channel-textarea-text-area-height) + 2 * var(--border-width)); + border: var(--border-width) solid var(--border-color); + margin-top: var(--pad); +} + +/* fix app button */ +.channelAppLauncher_e6e74f { + margin: 0; + + > [class^='buttonContainer_'] { + height: 100%; + border-left: var(--border-width) solid var(--border-color); + transition: border-color var(--border-transition); + box-sizing: border-box; + } +} +.channelBottomBarArea_f75fb0:hover { + > .buttonContainer_e6e74f { + border-color: var(--border-hover-color); + } + > .channelTextArea_f75fb0, + > .channelTextArea_fb64c9 { + &::after { + color: var(--border-hover-color); + } + } +} + +/* add text labels */ +.sidebar_c48ade > [class^='container_']::after, +.container__9293f::after, +.messagesWrapper__36d07::after, +.panels_c48ade::after, +.channelTextArea_f75fb0::after, +.channelTextArea_fb64c9::after, +.guilds_c48ade::after, +.membersWrap_c8ffbb::after, +.privateChannels__35e86::after, +.container_f369db::after, +.content_f75fb0 > .outer_c0bea0::after, +.searchResultsWrap_a9e706::after, +.scroller_fb64c9::after, +.peopleColumn__133bf::after, +.nowPlayingColumn__133bf::after, +.container_a592e1::after { + display: block; + color: var(--txt-3); + font-weight: var(--label-font-weight); + position: absolute; + top: -10px; + left: 8px; + background-color: var(--bg-0); + padding: 0 4px; + z-index: 100; + transition: color var(--border-transition); +} +.sidebar_c48ade > [class^='container_']::after { + content: 'server'; +} +.container__9293f::after { + content: 'channel'; + top: -11px; +} +.messagesWrapper__36d07::after, +.scroller_fb64c9::after { + content: 'messages'; +} +.panels_c48ade::after { + content: 'user'; +} +.channelTextArea_f75fb0, +.channelTextArea_fb64c9 { + &::after { + content: 'input'; + } +} +.guilds_c48ade::after { + content: 'nav'; +} +.membersWrap_c8ffbb::after { + content: 'members'; +} +.privateChannels__35e86::after { + content: 'dms'; +} +.container_f369db::after { + content: 'forum'; +} +.content_f75fb0 > .outer_c0bea0::after { + content: 'profile'; +} +.searchResultsWrap_a9e706::after { + content: 'search'; +} +.peopleColumn__133bf::after { + content: 'friends'; +} +.nowPlayingColumn__133bf::after { + content: 'activity'; +} + +/* Fix oversized statuses on friends page */ +.peopleColumn__133bf { + width: 0; +} + +/* fix hidden text labels */ +#app-mount .sidebar_c48ade, +.chat_f75fb0 /* header bar */, +.wrapper_ef3116 /* server list */, +.privateChannels__35e86 /* dms */, +.container__133bf /* friends page header */, +.searchResultsWrap_a9e706 /* search results */, +.peopleColumn__133bf /* friends page */ , +.tabBody__133bf /* friends page outer */, +.container__01ae2 /* thread outer */, +[class^=container_]>[class^=base_] { + overflow: visible; +} +.scroller_fb64c9 /* new thread messages box */ { + overflow: visible !important; + margin-top: var(--pad); +} +/* fix text label position */ +.panels_c48ade, +.nowPlayingColumn__133bf { + position: relative; +} +/* fix overflow visible */ +.privateChannels__35e86 /* dms */, +[class^=tabBody_] /* friends page */ { + min-height: 0; +} +[class^='container_'] > [class^='base_'] { + min-width: 0; +} +/* add padding to make label not look weird */ +.scroller__36d07 /* main chat */, +.newMessagesBar__0f481 { + top: var(--txt-pad); +} +.animatedContainer_f37cb1 /* server banner */ { + top: var(--txt-pad); + left: var(--txt-pad); + width: calc(100% - 2 * var(--txt-pad)); +} +#vc-spotify-player, +.container_e131a9 /* vc panel */ { + padding: var(--txt-pad) var(--txt-pad) calc(var(--txt-pad) / 2) var(--txt-pad); +} +.horizontal__7c0ba>.flex__7c0ba /* fix voice connected cut off */ { + margin-left: 4px; +} +.guilds_c48ade, +.membersWrap_c8ffbb { + padding-top: var(--txt-pad); +} +.membersWrap_c8ffbb { + height: 100%; + box-sizing: border-box; +} +/* add padding to user profile panel */ +.content_f75fb0 > .outer_c0bea0 { + padding: var(--txt-pad); + box-sizing: border-box; + background-clip: content-box; +} +.content_f75fb0 .inner_c0bea0 /* fix profile effects */ { + position: relative; +} + +/* vencord spotify progress bar */ +#vc-spotify-player { + --vc-spotify-green: var(--green); +} +#vc-spotify-progress-bar { + margin-bottom: 0.6em; +} +#vc-spotify-progress-bar [class^='bar_'] { + height: 20px; + background: var(--bg-1); +} +.vc-spotify-time-left, +.vc-spotify-time-right { + z-index: 1; + top: 4px; + mix-blend-mode: difference; + font-size: 14px; +} +.vc-spotify-time-left { + left: 6px; +} +.vc-spotify-time-right { + right: 6px; +} +.grabber_a562c8 { + visibility: hidden; +} + +/* wordmark */ +.wordmarkWindows__421ed > svg { + display: none; +} +.wordmarkWindows__421ed::after { + content: var(--corner-text); + display: block; + position: relative; + color: var(--txt-2); + margin-left: 6px; + width: 100px; + height: 12px; + font-family: var(--font-primary); + font-size: 16px; + font-weight: 500; + white-space: no-wrap; +} + +/* adjust divider/border thickness */ +[class^='sectionDivider_'] /* channel list dividers */, +.separator_b3f026 /* settings divider */, +[class^=guildSeparator_] /* server list divider */ { + border-bottom: var(--border-width) solid var(--border-color); + height: 0; +} +#app-mount [class^='divider_'] /* chat dividers */ { + border-width: var(--border-width); +} +.container_eff079::after /* channel list dividers */, +[class^=customizationSection_], +[class^=session_], +[class^=columnHeading_], +[class^=tableCell_] { + border-width: var(--border-width); + border-color: var(--border-color); +} +[class^='divider_']:not(.isUnread__908e2), +[class^='sectionDivider_'], +#vc-spotify-player, +.separator_b3f026, +[class^='guildSeparator_'] { + border-color: var(--border-color); +} +[class^='unreadPill_'][class*='endCap_'] { + top: calc(-6px - 0.5 * var(--border-width)); +} + +/* toolbar order */ +[class^='toolbar_'] > [class^='search_'] { + order: -1; +} + +h3[data-text-variant='heading-xxl/extrabold'] { + font-family: 'asciid'; + font-size: 50px; + font-weight: 400; +} + +.theme-dark, +html.theme-light { + --action-sheet-gradient-bg: var(--primary-630); + --activity-card-background: var(--bg-0); + --activity-card-icon-overlay: hsl(223 calc(1 * 6.7%) 20.6% / 0.85); + --alert-bg: var(--primary-600); + --android-navigation-bar-background: var(--primary-830); + --android-navigation-scrim-background: hsl(240 calc(1 * 4%) 4.9% / 0.5); + --android-ripple: hsl(0 calc(1 * 0%) 100% / 0.07); + --autocomplete-bg: var(--bg-1); + --background-accent: var(--bg-2); + --background-floating: var(--primary-800); + --background-mentioned: var(--mention-overlay); + --background-mentioned-hover: var(--mention-hover-overlay); + --background-message-automod: hsl(359 calc(1 * 87.3%) 59.8% / 0.05); + --background-message-automod-hover: hsl(359 calc(1 * 87.3%) 59.8% / 0.1); + --background-message-highlight: var(--reply-overlay); + --background-message-highlight-hover: var(--reply-hover-overlay); + --brand-experiment-10a: var(--background-message-highlight-hover); + --background-message-hover: hsl(0 calc(1 * 0%) 0.8% / 0.06); + --background-mobile-primary: var(--primary-600); + --background-mobile-secondary: var(--primary-630); + --background-modifier-accent: var(--bg-3); + --background-modifier-accent-2: hsl(228 calc(1 * 6%) 32.5% / 0.48); + --background-modifier-active: var(--active); + --background-modifier-hover: var(--hover); + --background-modifier-selected: var(--selected); + --background-nested-floating: var(--primary-630); + --background-primary: var(--bg-0); + --background-secondary: var(--bg-1); + --background-secondary-alt: var(--bg-1); + --background-tertiary: var(--bg-0); + --bg-backdrop: hsl(0 calc(1 * 0%) 0% / 0.7); + --bg-backdrop-no-opacity: var(--black-500); + --bg-base-primary: var(--bg-0); + --bg-base-secondary: var(--bg-1); + --bg-base-tertiary: var(--primary-660); + --bg-brand: var(--brand-500); + --bg-mod-faint: var(--bg-0); + --bg-mod-strong: var(--bg-2); + --bg-mod-subtle: hsl(228 calc(1 * 6%) 32.5% / 0.48); + --bg-surface-overlay: var(--bg-1); + --bg-surface-overlay-tmp: var(--primary-800); + --bg-surface-raised: var(--bg-0); + --black: var(--black-500); + --blur-fallback: hsl(225 calc(1 * 6.3%) 12.5% / 0.96); + --blur-fallback-pressed: hsl(225 calc(1 * 7.1%) 11% / 0.96); + --border-faint: hsl(0 calc(1 * 0%) 100% / 0.03); + --border-strong: hsl(0 calc(1 * 0%) 100% / 0.16); + --border-subtle: hsl(0 calc(1 * 0%) 100% / 0.08); + --bug-reporter-modal-submitting-background: hsl(220 calc(1 * 8.1%) 7.3% / 0.6); + --button-creator-revenue-background: var(--teal-430); + --button-danger-background: var(--red-430); + --button-danger-background-active: var(--red-530); + --button-danger-background-disabled: var(--red-430); + --button-danger-background-hover: var(--red-500); + --button-outline-brand-background: hsl(0 calc(1 * 0%) 100% / 0); + --button-outline-brand-background-active: var(--brand-560); + --button-outline-brand-background-hover: var(--brand-500); + --button-outline-brand-border: var(--brand-500); + --button-outline-brand-border-active: var(--brand-560); + --button-outline-brand-border-hover: var(--brand-500); + --button-outline-brand-text: var(--txt-1); + --button-outline-brand-text-active: var(--txt-1); + --button-outline-brand-text-hover: var(--txt-1); + --button-outline-danger-background: hsl(0 calc(1 * 0%) 100% / 0); + --button-outline-danger-background-active: var(--red-460); + --button-outline-danger-background-hover: var(--red-430); + --button-outline-danger-border: var(--red-400); + --button-outline-danger-border-active: var(--red-430); + --button-outline-danger-border-hover: var(--red-430); + --button-outline-danger-text: var(--txt-1); + --button-outline-danger-text-active: var(--txt-1); + --button-outline-danger-text-hover: var(--txt-1); + --button-outline-positive-background: hsl(0 calc(1 * 0%) 100% / 0); + --button-outline-positive-background-active: var(--green-530); + --button-outline-positive-background-hover: var(--green-430); + --button-outline-positive-border: var(--green-360); + --button-outline-positive-border-active: var(--green-530); + --button-outline-positive-border-hover: var(--green-430); + --button-outline-positive-text: var(--txt-1); + --button-outline-positive-text-active: var(--txt-1); + --button-outline-positive-text-hover: var(--txt-1); + --button-outline-primary-background: hsl(0 calc(1 * 0%) 100% / 0); + --button-outline-primary-background-active: var(--primary-430); + --button-outline-primary-background-hover: var(--primary-500); + --button-outline-primary-border: var(--primary-500); + --button-outline-primary-border-active: var(--primary-430); + --button-outline-primary-border-hover: var(--primary-500); + --button-outline-primary-text: var(--txt-1); + --button-outline-primary-text-active: var(--txt-1); + --button-outline-primary-text-hover: var(--txt-1); + --button-positive-background: var(--green-430); + --button-positive-background-active: var(--green-530); + --button-positive-background-disabled: var(--green-430); + --button-positive-background-hover: var(--green-500); + --button-secondary-background: var(--primary-500); + --button-secondary-background-active: var(--txt-3); + --button-secondary-background-disabled: var(--bg-1); + --button-secondary-background-hover: var(--bg-3); + --card-gradient-bg: hsl(0 calc(1 * 0%) 0% / 0.4); + --card-gradient-pressed-bg: hsl(0 calc(1 * 0%) 0% / 0.5); + --card-primary-bg: var(--primary-600); + --card-primary-pressed-bg: var(--primary-645); + --card-secondary-bg: var(--primary-630); + --card-secondary-pressed-bg: var(--primary-645); + --channel-icon: var(--txt-2); + --channel-text-area-placeholder: var(--txt-3); + --channels-default: var(--txt-2); + --channeltextarea-background: var(--bg-0); + --chat-background: var(--primary-600); + --chat-banner-bg: var(--primary-630); + --chat-border: var(--primary-700); + --chat-input-container-background: var(--primary-600); + --chat-swipe-to-reply-background: var(--primary-660); + --chat-swipe-to-reply-gradient-background: hsl(0 calc(1 * 0%) 0% / 0.1); + --coachmark-bg: var(--primary-800); + --content-inventory-media-seekbar-container: hsl(231 calc(1 * 6.7%) 79.4% / 0.24); + --content-inventory-overlay-text-primary: hsl(0 calc(1 * 0%) 100% / 0.85); + --content-inventory-overlay-text-secondary: hsl(0 calc(1 * 0%) 100% / 0.7); + --content-inventory-overlay-ui-mod: hsl(0 calc(1 * 0%) 100% / 0.7); + --content-inventory-overlay-ui-mod-bg: hsl(0 calc(1 * 0%) 0% / 0.1); + --context-menu-backdrop-background: hsl(0 calc(1 * 0%) 0% / 0.7); + --control-brand-foreground: var(--brand-360); + --control-brand-foreground-new: var(--acc-0); + --creator-revenue-icon-gradient-end: var(--teal-430); + --creator-revenue-icon-gradient-start: var(--teal-360); + --creator-revenue-info-box-background: hsl(190 calc(1 * 63%) 33.9% / 0.1); + --creator-revenue-info-box-border: var(--teal-400); + --creator-revenue-locked-channel-icon: var(--teal-345); + --creator-revenue-progress-bar: var(--teal-400); + --deprecated-card-bg: hsl(225 calc(1 * 6.3%) 12.5% / 0.6); + --deprecated-card-editable-bg: hsl(225 calc(1 * 6.3%) 12.5% / 0.3); + --deprecated-quickswitcher-input-background: var(--primary-400); + --deprecated-quickswitcher-input-placeholder: hsl(0 calc(1 * 0%) 100% / 0.3); + --deprecated-store-bg: var(--primary-600); + --deprecated-text-input-bg: var(--primary-700); + --deprecated-text-input-border: hsl(0 calc(1 * 0%) 0% / 0.3); + --deprecated-text-input-border-disabled: var(--primary-700); + --deprecated-text-input-border-hover: var(--primary-900); + --deprecated-text-input-prefix: var(--primary-200); + --display-banner-overflow-background: hsl(225 calc(1 * 6.3%) 12.5% / 0.5); + --divider-strong: hsl(0 calc(1 * 0%) 100% / 0.16); + --divider-subtle: hsl(0 calc(1 * 0%) 100% / 0.08); + --embed-background: var(--primary-630); + --embed-background-alternate: var(--primary-600); + --embed-title: var(--primary-130); + --expression-picker-bg: var(--primary-630); + --focus-primary: var(--blue-345); + --forum-post-extra-media-count-container-background: hsl(228 calc(1 * 6.7%) 14.7% / 0.8); + --forum-post-tag-background: hsl(228 calc(1 * 6.7%) 14.7% / 0.9); + --guild-icon-inactive-bg: var(--primary-630); + --guild-icon-inactive-nested-bg: var(--primary-600); + --guild-notifications-bottom-sheet-pill-background: var(--primary-700); + --halo-positive: hsl(145 calc(1 * 65%) 39.2% / 0.24); + --header-muted: var(--primary-360); + --header-primary: var(--txt-1); + --header-secondary: var(--txt-2); + --home-background: none; + --home-card-resting-border: hsl(0 calc(1 * 0%) 0% / 0); + --icon-muted: var(--primary-400); + --icon-primary: var(--primary-130); + --icon-secondary: var(--primary-330); + --icon-transparent: hsl(0 calc(1 * 0%) 0% / 0); + --info-box-background: hsl(200 calc(1 * 100%) 49.4% / 0.1); + --info-danger-background: hsl(359 calc(1 * 87.3%) 59.8% / 0.1); + --info-danger-foreground: var(--red-400); + --info-danger-text: var(--white-500); + --info-help-background: hsl(200 calc(1 * 100%) 49.4% / 0.1); + --info-help-foreground: var(--blue-345); + --info-help-text: var(--white-500); + --info-positive-background: hsl(145 calc(1 * 65%) 39.2% / 0.1); + --info-positive-foreground: var(--green-360); + --info-positive-text: var(--white-500); + --info-warning-background: hsl(40 calc(1 * 86.4%) 56.9% / 0.1); + --info-warning-foreground: var(--yellow-300); + --info-warning-text: var(--white-500); + --input-background: var(--bg-1); + --input-focused-border: hsl(0 calc(1 * 0%) 100% / 0.16); + --input-placeholder-text: var(--input-placeholder-text-dark); + --interactive-active: var(--txt-1); + --interactive-hover: var(--txt-1); + --interactive-muted: var(--txt-3); + --interactive-normal: var(--txt-2); + --legacy-android-blur-overlay-default: hsl(228 calc(1 * 6.7%) 14.7% / 0.5); + --legacy-android-blur-overlay-ultra-thin: hsl(0 calc(1 * 0%) 0% / 0.025); + --legacy-blur-fallback-default: hsl(228 calc(1 * 6.7%) 14.7% / 0.975); + --legacy-blur-fallback-ultra-thin: hsl(0 calc(1 * 0%) 0% / 0.95); + --live-stage-tile-border: hsl(228 calc(1 * 6%) 32.5% / 0.6); + --logo-primary: var(--white-500); + --mention-background: var(--mention-bg); + --mention-foreground: var(--mention-txt); + --modal-background: var(--bg-1); + --modal-footer-background: var(--bg-0); + --navigator-header-tint: var(--white-500); + --panel-bg: var(--primary-600); + --polls-normal-fill-hover: var(--primary-600); + --polls-normal-image-background: var(--primary-660); + --polls-victor-fill: hsl(145 calc(1 * 65%) 39.2% / 0.2); + --polls-voted-fill: hsl(235 calc(1 * 85.6%) 64.7% / 0.2); + --profile-gradient-card-background: hsl(0 calc(1 * 0%) 0% / 0.7); + --profile-gradient-message-input-border: hsl(228 calc(1 * 6%) 32.5% / 0.48); + --profile-gradient-note-background: hsl(0 calc(1 * 0%) 0% / 0.3); + --profile-gradient-overlay: hsl(0 calc(1 * 0%) 0% / 0.6); + --profile-gradient-overlay-synced-with-user-theme: hsl(0 calc(1 * 0%) 0% / 0.8); + --profile-gradient-profile-body-background-hover: hsl(0 calc(1 * 0%) 100% / 0.16); + --profile-gradient-role-pill-background: hsl(228 calc(1 * 6.7%) 14.7% / 0.5); + --profile-gradient-role-pill-border: hsl(0 calc(1 * 0%) 100% / 0.2); + --profile-gradient-section-box: hsl(0 calc(1 * 0%) 0% / 0.45); + --redesign-activity-card-background: var(--primary-560); + --redesign-activity-card-background-pressed: var(--primary-630); + --redesign-activity-card-badge-icon: var(--primary-360); + --redesign-activity-card-border: hsl(0 calc(1 * 0%) 100% / 0.02); + --redesign-activity-card-overflow-background: var(--primary-630); + --redesign-button-active-background: var(--green-430); + --redesign-button-active-pressed-background: var(--green-460); + --redesign-button-active-text: var(--white-500); + --redesign-button-danger-background: var(--red-430); + --redesign-button-danger-pressed-background: var(--red-460); + --redesign-button-danger-text: var(--white-500); + --redesign-button-destructive-background: var(--red-430); + --redesign-button-destructive-pressed-background: var(--red-460); + --redesign-button-destructive-text: var(--white-500); + --redesign-button-overlay-alpha-background: hsl(0 calc(1 * 0%) 0% / 0.54); + --redesign-button-overlay-alpha-pressed-background: hsl(0 calc(1 * 0%) 0% / 0.64); + --redesign-button-overlay-alpha-text: var(--white-500); + --redesign-button-overlay-background: var(--white-500); + --redesign-button-overlay-pressed-background: var(--primary-230); + --redesign-button-overlay-text: var(--primary-860); + --redesign-button-positive-background: var(--green-430); + --redesign-button-positive-pressed-background: var(--green-460); + --redesign-button-positive-text: var(--white-500); + --redesign-button-primary-alt-background: hsl(235 calc(1 * 85.6%) 64.7% / 0); + --redesign-button-primary-alt-border: var(--brand-360); + --redesign-button-primary-alt-on-blurple-background: hsl(235 calc(1 * 66.7%) 58.8% / 0); + --redesign-button-primary-alt-on-blurple-border: var(--white-500); + --redesign-button-primary-alt-on-blurple-pressed-background: var(--brand-530); + --redesign-button-primary-alt-on-blurple-pressed-border: var(--brand-360); + --redesign-button-primary-alt-on-blurple-text: var(--white-500); + --redesign-button-primary-alt-pressed-background: hsl(235 calc(1 * 47%) 25.9% / 0.16); + --redesign-button-primary-alt-pressed-border: hsl(235 calc(1 * 86.1%) 71.8% / 0.5); + --redesign-button-primary-alt-pressed-text: var(--brand-360); + --redesign-button-primary-alt-text: var(--brand-360); + --redesign-button-primary-background: var(--brand-500); + --redesign-button-primary-on-blurple-pressed-text: var(--brand-530); + --redesign-button-primary-overlay-background: var(--white-500); + --redesign-button-primary-overlay-pressed-background: var(--primary-230); + --redesign-button-primary-overlay-text: var(--primary-860); + --redesign-button-primary-pressed-background: var(--brand-560); + --redesign-button-primary-text: var(--white-500); + --redesign-button-secondary-background: var(--primary-460); + --redesign-button-secondary-border: hsl(0 calc(1 * 0%) 100% / 0.08); + --redesign-button-secondary-overlay-background: hsl(0 calc(1 * 0%) 0% / 0.54); + --redesign-button-secondary-overlay-pressed-background: hsl(0 calc(1 * 0%) 0% / 0.64); + --redesign-button-secondary-overlay-text: var(--white-500); + --redesign-button-secondary-pressed-background: var(--primary-500); + --redesign-button-secondary-pressed-border: hsl(0 calc(1 * 0%) 0% / 0); + --redesign-button-secondary-text: var(--primary-230); + --redesign-button-selected-background: hsl(235 calc(1 * 85.6%) 64.7% / 0.24); + --redesign-button-selected-pressed-background: hsl(235 calc(1 * 85.6%) 64.7% / 0.28); + --redesign-button-selected-text: var(--brand-500); + --redesign-button-tertiary-background: var(--primary-660); + --redesign-button-tertiary-pressed-background: var(--primary-560); + --redesign-button-tertiary-pressed-text: var(--primary-330); + --redesign-button-tertiary-text: var(--primary-230); + --redesign-channel-category-name-text: var(--primary-330); + --redesign-channel-message-preview-text: var(--primary-360); + --redesign-channel-name-muted-text: var(--primary-360); + --redesign-channel-name-text: var(--primary-130); + --redesign-chat-input-background: var(--primary-700); + --redesign-image-button-pressed-background: hsl(0 calc(1 * 0%) 0% / 0.08); + --redesign-input-control-active-bg: var(--primary-645); + --redesign-input-control-selected: var(--brand-500); + --redesign-only-background-active: var(--primary-530); + --redesign-only-background-default: var(--primary-600); + --redesign-only-background-overlay: var(--primary-645); + --redesign-only-background-raised: var(--primary-630); + --redesign-only-background-sunken: var(--primary-660); + --scrollbar-auto-scrollbar-color-thumb: var(--primary-730); + --scrollbar-auto-scrollbar-color-track: var(--primary-630); + --scrollbar-auto-thumb: var(--bg-1); + --scrollbar-auto-track: transparent; + --scrollbar-thin-thumb: var(--bg-1); + --scrollbar-thin-track: transparent; + --spoiler-hidden-background: var(--primary-700); + --spoiler-revealed-background: var(--primary-660); + --stage-card-pill-bg: var(--primary-630); + --status-danger: var(--pink); + --status-danger-background: var(--red-430); + --status-danger-text: var(--white-500); + --status-dnd: var(--red-400); + --status-idle: var(--yellow-300); + --status-offline: var(--primary-360); + --status-online: var(--green-360); + --status-positive: var(--green-360); + --status-positive-background: var(--green-430); + --status-positive-text: var(--white-500); + --status-speaking: var(--green-360); + --status-warning: var(--yellow-300); + --status-warning-background: var(--yellow-300); + --status-warning-text: var(--black-500); + --text-brand: var(--brand-360); + --text-danger: var(--red-345); + --text-link: var(--txt-link); + --text-link-low-saturation: var(--blue-330); + --text-low-contrast: var(--primary-360); + --text-message-preview-low-sat: var(--primary-360); + --text-muted: var(--txt-3); + --text-muted-on-default: var(--primary-330); + --text-normal: var(--txt-1); + --text-positive: var(--green); + --text-primary: var(--primary-230); + --text-secondary: var(--primary-330); + --text-warning: var(--yellow-300); + --textbox-markdown-syntax: var(--primary-360); + --theme-locked-blur-fallback: hsl(225 calc(1 * 6.3%) 12.5% / 0.96); + --thread-channel-spine: var(--primary-500); + --toast-bg: var(--primary-800); + --typing-indicator-bg: var(--primary-630); + --user-profile-header-overflow-background: hsl(225 calc(1 * 6.3%) 12.5% / 0.5); + --voice-video-video-tile-background: hsl(233 calc(1 * 9 / 0.3%) 19% / 0.4); + --voice-video-video-tile-blur-fallback: hsl(225 calc(1 * 6.3%) 12.5% / 0.48); + --white: var(--bg-0); + --you-bar-bg: var(--primary-800); + + --elevation-low: none; + --shadow-ledge: none; + + --brand-500: var(--acc-0); /* unread bar, accent buttons, spotify */ + --brand-530: var(--acc-0); /* hover highlight in context menu */ + --brand-560: var(--acc-1); /* accent button hover */ + --brand-600: var(--acc-2); /* accent button active */ + + --primary-500: var(--bg-2); /* neutral button background */ + --primary-800: var(--bg-0); /* call background */ + + --white-500: var(--txt-dark); /* THE BANE OF MY EXISTENCE */ + + --red-400: var(--pink); /* red outline buttons */ + --red-430: var(--pink); /* red buttons, outline button hover */ + --red-460: var(--pink-1); /* outline button active */ + --red-500: var(--pink-1); /* button hover */ + --red-530: var(--pink-2); /* button active */ + + --green-360: var(--green); /* vc icon on server */ + --green-430: var(--green); /* green buttons */ + --green-500: var(--green-1); + --green-530: var(--green-2); + + --twitch: var(--acc-1); +} + +/* begin 909 commits */ +::placeholder { + color: var(--txt-3) !important; +} +::selection { + color: var(--bg-0); + background: var(--txt-1); +} +.callContainer_cb9592 { + overflow: visible; + background: none !important; + overflow-x: hidden; + border-radius: var(--panel-roundness); + border: var(--border-width) solid var(--border-color); + transition: border-color var(--border-transition); + &:hover { + border-color: var(--border-hover-color); + &::after { + color: var(--border-hover-color); + } + } +} +.callContainer_cb9592 [class^='topControls_'] section { + border: none; + &::after { + content: ''; + } +} +.callContainer_cb9592::after { + content: 'call'; + display: block; + color: var(--txt-3); + font-weight: var(--label-font-weight); + position: absolute; + top: -8px; + left: 8px; + background-color: var(--bg-0); + padding: 0 4px; + z-index: 100; + transition: color var(--border-transition); +} + +.wrapper_ef3116.hidden_ef3116 { + display: none; +} + +[class^='gradientTop_'], +[class^='gradientBottom_'] { + display: none !important; +} +[class^='topControls_'], +[class^='bottomControls_'] { + transform: none !important; +} +/* end 909 commits */ + +/* begin Androser commits */ +.chatHeaderBar_dc83f5 { + padding: var(--txt-pad); + margin-bottom: var(--pad); + > :first-child { + color: var(--txt-2) !important; + font-weight: var(--label-font-weight); + overflow: hidden; + text-overflow: ellipsis; + text-wrap: nowrap; + } +} + +/* fix theming */ +[class^='circleIconButton_'] /* footer buttons */ { + background-color: var(--bg-1); + &[class*='selected_'] { + color: var(--white); + } +} +[class^='tooltip_'] { + background-color: var(--bg-0); + color: var(--txt-1); + > [class^='tooltipPointer_'] { + border-top-color: var(--bg-0); + } +} +[class*='tooltipGrey_'] { + background-color: var(--bg-1); + > [class^='tooltipPointer_'] { + border-top-color: var(--bg-1); + } +} +.drawerSizingWrapper__08434 /* gifs/emojis/stickers */ { + .contentWrapper__08434, + [class*='header_'], + [class*='wrapper_'], + .inspector_aeaaeb, + .container_d02962, + .stickerCategory__3ad28, + .categoryItemDefaultCategory_b9ee0c, + #gif-picker-tab-panel { + background-color: var(--bg-0); + box-shadow: unset; + } +} +.activeButtonChild_aa63ab /* send message button */ { + color: var(--interactive-normal); + transition: none; +} +.button_aa63ab { + &:hover, + &:focus { + .activeButtonChild_aa63ab { + color: var(--interactive-hover) !important; + } + } +} +.separator_aa63ab /* send message border */ { + background-color: var(--border-color); + width: var(--border-width); + height: 100%; +} + +/* rounding fixes */ +.members_c8ffbb /* member list */, +.messagesWrapper__36d07>[class^=scroller_] /* chat */, +.nowPlayingColumn__133bf>[class^='container_'] /* friends active now page outer */, +.nowPlayingColumn__133bf [class^='scroller'] /* friends active now page inner */, +.callContainer_cb9592 /* vc */, +.scroller__629e4 /* channel list */ { + border-radius: var(--panel-roundness) !important; +} +.privateChannels__35e86>[class^='scroller_'] /* dms */ { + border-radius: 0 0 var(--panel-roundness) var(--panel-roundness) !important; +} +.footer__214dc /* discover button */ { + margin-top: unset; + > .gradient__214dc { + background: var(--bg-overlay-app-frame, var(--background-tertiary)); + } +} +.container_a592e1 /* discover */ { + position: relative; + padding: 8px; + .backdrop__8a7fc, + .scroller__23746 { + border-radius: var(--panel-roundness); + } + /* add labels */ + &:has(.outerContainer_e1147e)::after { + content: 'apps'; + } + &:has(.container_d08938)::after { + content: 'servers'; + } + &:has(.container__955a3)::after { + content: 'quests'; + } +} +.overlay__8a7fc::after /* remove discover header separator */ { + display: none; +} + +/* remove extra space */ +.nowPlayingColumn__133bf [class^='scroller'] { + margin-left: 0; +} + +/* add discover label */ +.sidebar_c48ade > .container__551b0 { + position: relative; + &::after { + content: 'discover'; + } +} + +/* fix new thread */ +.channelTextArea_fb64c9 { + width: unset; +} +.textAreaThreadCreation__74017 { + height: unset !important; +} +/* end Androser commits */ + +/* scroll optimizations */ +[class*='scroll'] { + will-change: scroll-position; +} +.burstGlow__23977 { + display: none; +} diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/src/main.css b/desktopenvs/hyprdrive/Vencord/themes/system24/src/main.css new file mode 100644 index 0000000..f13b144 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/src/main.css @@ -0,0 +1,1126 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap'); +@import url('https://fonts.cdnfonts.com/css/asciid'); + +:root { + letter-spacing: -0.05ch; + font-weight: 300; + --label-font-weight: 500; + + --corner-text: 'system24'; + + --pad: 20px; + --txt-pad: 10px; + + --panel-roundness: 0px; + + --bg-0: oklch(19% 0 0); + --bg-1: oklch(23% 0 0); + --bg-2: oklch(27% 0 0); + --bg-3: oklch(31% 0 0); + + --hover: oklch(54% 0 0 / 0.1); + --active: oklch(54% 0 0 / 0.2); + --selected: var(--active); + + --txt-dark: var(--bg-0); + --txt-link: var(--cyan); + --txt-0: oklch(90% 0 0); + --txt-1: oklch(80% 0 0); + --txt-2: oklch(60% 0 0); + --txt-3: oklch(40% 0 0); + + --acc-0: var(--purple); + --acc-1: var(--purple-1); + --acc-2: var(--purple-2); + + --border-width: 2px; + --border-color: var(--bg-3); + --border-hover-color: var(--acc-0); + --border-transition: 0.2s ease; + + --online-dot: var(--green); + --dnd-dot: var(--pink); + --idle-dot: var(--yellow); + --streaming-dot: var(--purple); + + --mention-txt: var(--acc-0); + --mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); + --mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); + --mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); + --reply-overlay: var(--active); + --reply-hover-overlay: var(--hover); + + --pink: oklch(73% 0.12 0); + --pink-1: oklch(63% 0.12 0); + --pink-2: oklch(53% 0.12 0); + --purple: oklch(73% 0.12 300); + --purple-1: oklch(63% 0.12 300); + --purple-2: oklch(53% 0.12 300); + --cyan: oklch(73% 0.12 200); + --yellow: oklch(78% 0.12 80); + --green: oklch(73% 0.12 160); + --green-1: oklch(63% 0.12 160); + --green-2: oklch(53% 0.12 160); +} + +:root { + --font-primary: var(--font), 'gg mono', monospace; + --font-display: var(--font), 'gg mono', monospace; + --font-code: var(--font), 'gg mono', monospace; +} + +/* colors */ +/* remove bg */ +.sidebar_c48ade /* channel list */, +.sidebar_c48ade>[class^='container_'] /* channel list inner */, +.privateChannels__35e86 /* dms list */, +.privateChannels__35e86>.scroller__99e7c /* dms list inner */, +.stackedBars__74017, .replyBar__841c8, .threadSuggestionBar__841c8 /* reply, thread, atttachment bars above chat bar */, +.panels_c48ade /* user panel */, +#app-mount #vc-spotify-player, +.panels_c48ade>.panel__5dec7 /* streaming panel */, +.panels_c48ade>.container__37e49 /* user panel inner */, +.container_c8ffbb /* member list */, +.members_c8ffbb /* member list inner */, +.member__5d473 /* member containers */, +.searchResultsWrap_a9e706 /* search results */, +.container_e131a9 /* vc panel */, +.scrollableContainer__74017, +.channelAppLauncher_e6e74f button /* app button */, +.footer__214dc /* discover button */, +.searchHeader_f3b986 /* search */, +#app-mount .content__37e49 /* user panel bottom */ { + background: none; +} +/* fix bgs */ +.content_f75fb0>.outer_c0bea0 /* profile panel */ { + --background-secondary-alt: var(--bg-0) !important; +} +.childWrapper__6e9f8 /* no color servers */ { + background-color: var(--bg-1); +} +.embedFull__623de /* embed default color */ { + border-color: var(--bg-2); +} +/* fix text */ +.newChannel__599fa, +[class^='newBadge_'] { + color: var(--bg-0); +} +/* fix white-500 */ +.lookFilled__201d5.colorPrimary__201d5 /* neutral buttons (like in account settings) */, +#app-mount .lookLink__201d5.colorPrimary__201d5 /* link buttons (like in account settings) */, +.friendRequestsButton__523aa>.numberBadge__2b1f5 /* inbox friend requests number */, +.searchFilter_bd816, .searchAnswer_bd8186, +.partyStatus__4d3fa .anchor_edefb8 /* spotify listen along link */, +.partyStatus__4d3fa /* spotify listen along text */, +.iconBadge__650eb:not(.isCurrentUserConnected__650eb) /* vc icon */, +.videoControls_cf09d8 .controlIcon_cf09d8 /* video controls */, +.durationTimeWrapper_cf09d8 /* video duration */, +#app-mount .controlIcon_f1ceac /* stop streaming icon in preview */, +.navButtonActive__08434 /* gif picker header selected */, +.bannerColor_fb7f94 /* user profile buttons */, +.backgroundAccent__4cd16 /* server insights warning */ { + color: var(--txt-1); +} +.headerContent_f37cb1 /* server name (when banner) */, +.jumpToPresentBar__0f481>button, +.gifFavoriteButton__43deb, +.iconWrapper__6eb54 /* video play button */, +.overlayBottom__2f4f7>.text-md\/normal_cf4812 /* vc names */, +.categoryText_d02962 /* gif category labels */ { + color: var(--txt-0); +} +.wrapper__926d7 svg /* youtube embed play button */ { + color: var(--txt-0) !important; +} +[role='button'] path[fill='var(--white-500)'], +#app-mount button path[fill='var(--white-500)'] { + fill: var(--txt-0); +} + +/* accents */ +[style='color: rgb(88, 101, 242);'] /* folder icons */ { + color: var(--acc-0) !important; +} +.folderIconWrapper__48112[style='background-color: rgba(88, 101, 242, 0.4);'] { + background: var(--bg-1) !important; +} +.checked__87bf1 /* settings checkbuttons */ { + background-color: var(--green) !important; +} +.slider__87bf1>rect[fill='white'] /* settings checkbuttons slider */ { + fill: var(--txt-dark); +} +.replying__5126c::before /* reply message left bar */ { + background: var(--txt-1); +} +.mentioned__5126c::before /* mention message left bar */ { + background: var(--acc-0); +} + +/* add spacing */ +.base_c48ade /* outside edges */ { + margin: var(--pad) var(--pad) var(--pad) 0; +} +.guilds_c48ade /* outside edges */ { + margin: var(--pad) 0 var(--pad) var(--pad); +} +.container_c48ade /* server list and everything else */, +.sidebar_c48ade /* channel list and user panel */, +.content_f75fb0 /* between chat and member list */, +.container__133bf /* friend page */, +[class^=tabBody_] /* friends page inner */, +.container__01ae2 /* forum post header and chat */, +[class^=chat_] /* header bar and chat */, +.container__9293f /* new thread header and chat */, +[class^=container_]>[class^=base_] /* banner and main stuff */ { + gap: var(--pad); +} +.sidebar_c48ade /* channel list and chat */ { + margin-right: var(--pad); +} +[class^=content_]>[class^=resizeHandle_] /* pretend to be a margin */ { + width: var(--pad); +} +.timestamp_c19a55.latin24CompactTimeStamp_c19a55 { + width: 3.3rem !important; +} + +/* fix inbox overflow and hiding window controls */ +[class^='notAppAsidePanel_'] > [class^='layerContainer_'] { + overflow: hidden; +} + +/* platform spacing */ +.platform-osx .guilds_c48ade { + margin-top: calc(32px + var(--pad)); +} + +/* borders */ +.panels_c48ade /* user panel */, +.sidebar_c48ade>[class^=container_] /* channel list */, +.messagesWrapper__36d07 /* chat */, +.membersWrap_c8ffbb /* member list */, +.guilds_c48ade /* server list */, +.privateChannels__35e86 /* dm list */, +.container__9293f /* header bar */, +.peopleColumn__133bf /* friends page list */, +.nowPlayingColumn__133bf /* friends page active now */, +.itemCard__7e549 /* friends active now cards */, +.channelBottomBarArea_f75fb0 /* chat bar */, +.container_f369db /* forum */, +.content_f75fb0>.outer_c0bea0 /* dm profile panel */, +.searchResultsWrap_a9e706 /* search results */, +.scroller_fb64c9 /* new thread */, +[class^=chat_]>[class*=container_] /* header bar */, +[class^='form_']>[class^='wrapper_'] /* no access chat bar */, +.chatHeaderBar_dc83f5 /* add to channel list popup */, +.contentWrapper__08434 /* gifs/emojis/stickers */, +.container_a592e1 /* discover */, +.channelTextArea_fb64c9 /* new thread chatbox */ { + border-radius: var(--panel-roundness); + border: var(--border-width) solid var(--border-color); + transition: border-color var(--border-transition); + &:hover { + border-color: var(--border-hover-color); + &::after { + color: var(--border-hover-color); + } + } +} +/* adjust border thickness */ +#vc-spotify-player, +.panels_c48ade .container_e131a9 /* vc panel */, +.panels_c48ade>.panel__5dec7 /* streaming panel */ { + border-width: var(--border-width); +} +/* remove borders */ +[class^='dot_'][class*='maskSecondary_'] { + border: none; +} + +/* fix border hover effect */ +.chatContent_f75fb0 { + &:has(.typing_b88801:hover), + &:has(> .messagesWrapper__36d07:hover) { + .typing_b88801 { + border-color: var(--border-hover-color); + } + > .messagesWrapper__36d07 { + border-color: var(--border-hover-color); + } + } +} + +/* fix double left border at friends' activity now lane */ +.scroller__7d20c { + border-left: 0px solid var(--background-modifier-accent); +} + +/*"ReadAllNotifications" button*/ +.vc-ranb-button { + font-size: 12px; +} + +/* move chat bar out */ +:root { + --custom-channel-textarea-text-area-height: 56px; +} +.form_f75fb0 { + padding: 0; + width: 100%; + display: flex; + flex-direction: column; + margin-top: 0; +} +.form_f75fb0::after, +.form_f75fb0::before { + display: none !important; +} +.channelTextArea_f75fb0, +.channelTextArea_fb64c9 { + margin: 0; + border-radius: var(--panel-roundness); + box-sizing: border-box; +} +.channelBottomBarArea_f75fb0, +.submitContainer_fb64c9 { + margin-top: var(--pad); +} +[class^='form_'] > [class^='typing_'] { + background: var(--bg-0); + order: -1; + top: -24px; + left: 0; + width: 100%; + box-sizing: border-box; + position: absolute; + border: var(--border-width) solid var(--border-color); + border-top: none; + transition: border-color var(--border-transition); +} +.chatContent_f75fb0:has([class^='typing_']) > .messagesWrapper__36d07 > [class^='jumpToPresentBar_'] { + margin-bottom: 22px; +} +.clipContainer__841c8 { + margin-top: 0; +} +.textArea__74017 { + display: flex; + flex-direction: column; + justify-content: center; +} +.attachButton__0923f { + height: var(--custom-channel-textarea-text-area-height); +} +/* make header bar height match chat bar */ +.container__9293f, +.container__37e49 { + height: var(--custom-channel-textarea-text-area-height); + box-sizing: border-box; +} + +/* fix chat bar when no chat bar */ +[class^='form_'] > [class^='wrapper_'] { + margin: 0; + padding: 0; + box-sizing: border-box; + height: calc(var(--custom-channel-textarea-text-area-height) + 2 * var(--border-width)); + border: var(--border-width) solid var(--border-color); + margin-top: var(--pad); +} + +/* fix app button */ +.channelAppLauncher_e6e74f { + margin: 0; + + > [class^='buttonContainer_'] { + height: 100%; + border-left: var(--border-width) solid var(--border-color); + transition: border-color var(--border-transition); + box-sizing: border-box; + } +} +.channelBottomBarArea_f75fb0:hover { + > .buttonContainer_e6e74f { + border-color: var(--border-hover-color); + } + > .channelTextArea_f75fb0, + > .channelTextArea_fb64c9 { + &::after { + color: var(--border-hover-color); + } + } +} + +/* add text labels */ +.sidebar_c48ade > [class^='container_']::after, +.container__9293f::after, +.messagesWrapper__36d07::after, +.panels_c48ade::after, +.channelTextArea_f75fb0::after, +.channelTextArea_fb64c9::after, +.guilds_c48ade::after, +.membersWrap_c8ffbb::after, +.privateChannels__35e86::after, +.container_f369db::after, +.content_f75fb0 > .outer_c0bea0::after, +.searchResultsWrap_a9e706::after, +.scroller_fb64c9::after, +.peopleColumn__133bf::after, +.nowPlayingColumn__133bf::after, +.container_a592e1::after { + display: block; + color: var(--txt-3); + font-weight: var(--label-font-weight); + position: absolute; + top: -10px; + left: 8px; + background-color: var(--bg-0); + padding: 0 4px; + z-index: 100; + transition: color var(--border-transition); +} +.sidebar_c48ade > [class^='container_']::after { + content: 'server'; +} +.container__9293f::after { + content: 'channel'; + top: 11px; +} +.messagesWrapper__36d07::after, +.scroller_fb64c9::after { + content: 'messages'; +} +.panels_c48ade::after { + content: 'user'; +} +.channelTextArea_f75fb0, +.channelTextArea_fb64c9 { + &::after { + content: 'input'; + } +} +.guilds_c48ade::after { + content: 'nav'; +} +.membersWrap_c8ffbb::after { + content: 'members'; +} +.privateChannels__35e86::after { + content: 'dms'; +} +.container_f369db::after { + content: 'forum'; +} +.content_f75fb0 > .outer_c0bea0::after { + content: 'profile'; +} +.searchResultsWrap_a9e706::after { + content: 'search'; +} +.peopleColumn__133bf::after { + content: 'friends'; +} +.nowPlayingColumn__133bf::after { + content: 'activity'; +} + +/* Fix oversized statuses on friends page */ +.peopleColumn__133bf { + width: 0; +} + +/* fix hidden text labels */ +#app-mount .sidebar_c48ade, +.chat_f75fb0 /* header bar */, +.wrapper_ef3116 /* server list */, +.privateChannels__35e86 /* dms */, +.container__133bf /* friends page header */, +.searchResultsWrap_a9e706 /* search results */, +.peopleColumn__133bf /* friends page */ , +.tabBody__133bf /* friends page outer */, +.container__01ae2 /* thread outer */, +[class^=container_]>[class^=base_] { + overflow: visible; +} +.scroller_fb64c9 /* new thread messages box */ { + overflow: visible !important; + margin-top: var(--pad); +} +/* fix text label position */ +.panels_c48ade, +.nowPlayingColumn__133bf { + position: relative; +} +/* fix overflow visible */ +.privateChannels__35e86 /* dms */, +[class^=tabBody_] /* friends page */ { + min-height: 0; +} +[class^='container_'] > [class^='base_'] { + min-width: 0; +} +/* add padding to make label not look weird */ +.scroller__36d07 /* main chat */, +.newMessagesBar__0f481 { + top: var(--txt-pad); +} +.animatedContainer_f37cb1 /* server banner */ { + top: var(--txt-pad); + left: var(--txt-pad); + width: calc(100% - 2 * var(--txt-pad)); +} +#vc-spotify-player, +.container_e131a9 /* vc panel */ { + padding: var(--txt-pad) var(--txt-pad) calc(var(--txt-pad) / 2) var(--txt-pad); +} +.horizontal__7c0ba>.flex__7c0ba /* fix voice connected cut off */ { + margin-left: 4px; +} +.guilds_c48ade, +.membersWrap_c8ffbb { + padding-top: var(--txt-pad); +} +.membersWrap_c8ffbb { + height: 100%; + box-sizing: border-box; +} +/* add padding to user profile panel */ +.content_f75fb0 > .outer_c0bea0 { + padding: var(--txt-pad); + box-sizing: border-box; + background-clip: content-box; +} +.content_f75fb0 .inner_c0bea0 /* fix profile effects */ { + position: relative; +} + +/* vencord spotify progress bar */ +#vc-spotify-player { + --vc-spotify-green: var(--green); +} +#vc-spotify-progress-bar { + margin-bottom: 0.6em; +} +#vc-spotify-progress-bar [class^='bar_'] { + height: 20px; + background: var(--bg-1); +} +.vc-spotify-time-left, +.vc-spotify-time-right { + z-index: 1; + top: 4px; + mix-blend-mode: difference; + font-size: 14px; +} +.vc-spotify-time-left { + left: 6px; +} +.vc-spotify-time-right { + right: 6px; +} +.grabber_a562c8 { + visibility: hidden; +} + +/* wordmark */ +.wordmarkWindows__421ed > svg { + display: none; +} +.wordmarkWindows__421ed::after { + content: var(--corner-text); + display: block; + position: relative; + color: var(--txt-2); + margin-left: 6px; + width: 100px; + height: 12px; + font-family: var(--font-primary); + font-size: 16px; + font-weight: 500; + white-space: no-wrap; +} + +/* adjust divider/border thickness */ +[class^='sectionDivider_'] /* channel list dividers */, +.separator_b3f026 /* settings divider */, +[class^=guildSeparator_] /* server list divider */ { + border-bottom: var(--border-width) solid var(--border-color); + height: 0; +} +#app-mount [class^='divider_'] /* chat dividers */ { + border-width: var(--border-width); +} +.container_eff079::after /* channel list dividers */, +[class^=customizationSection_], +[class^=session_], +[class^=columnHeading_], +[class^=tableCell_] { + border-width: var(--border-width); + border-color: var(--border-color); +} +[class^='divider_']:not(.isUnread__908e2), +[class^='sectionDivider_'], +#vc-spotify-player, +.separator_b3f026, +[class^='guildSeparator_'] { + border-color: var(--border-color); +} +[class^='unreadPill_'][class*='endCap_'] { + top: calc(-6px - 0.5 * var(--border-width)); +} + +/* toolbar order */ +[class^='toolbar_'] > [class^='search_'] { + order: -1; +} + +h3[data-text-variant='heading-xxl/extrabold'] { + font-family: 'asciid'; + font-size: 50px; + font-weight: 400; +} + +.theme-dark, +html.theme-light { + --action-sheet-gradient-bg: var(--primary-630); + --activity-card-background: var(--bg-0); + --activity-card-icon-overlay: hsl(223 calc(1 * 6.7%) 20.6% / 0.85); + --alert-bg: var(--primary-600); + --android-navigation-bar-background: var(--primary-830); + --android-navigation-scrim-background: hsl(240 calc(1 * 4%) 4.9% / 0.5); + --android-ripple: hsl(0 calc(1 * 0%) 100% / 0.07); + --autocomplete-bg: var(--bg-1); + --background-accent: var(--bg-2); + --background-floating: var(--primary-800); + --background-mentioned: var(--mention-overlay); + --background-mentioned-hover: var(--mention-hover-overlay); + --background-message-automod: hsl(359 calc(1 * 87.3%) 59.8% / 0.05); + --background-message-automod-hover: hsl(359 calc(1 * 87.3%) 59.8% / 0.1); + --background-message-highlight: var(--reply-overlay); + --background-message-highlight-hover: var(--reply-hover-overlay); + --brand-experiment-10a: var(--background-message-highlight-hover); + --background-message-hover: hsl(0 calc(1 * 0%) 0.8% / 0.06); + --background-mobile-primary: var(--primary-600); + --background-mobile-secondary: var(--primary-630); + --background-modifier-accent: var(--bg-3); + --background-modifier-accent-2: hsl(228 calc(1 * 6%) 32.5% / 0.48); + --background-modifier-active: var(--active); + --background-modifier-hover: var(--hover); + --background-modifier-selected: var(--selected); + --background-nested-floating: var(--primary-630); + --background-primary: var(--bg-0); + --background-secondary: var(--bg-1); + --background-secondary-alt: var(--bg-1); + --background-tertiary: var(--bg-0); + --bg-backdrop: hsl(0 calc(1 * 0%) 0% / 0.7); + --bg-backdrop-no-opacity: var(--black-500); + --bg-base-primary: var(--bg-0); + --bg-base-secondary: var(--bg-1); + --bg-base-tertiary: var(--primary-660); + --bg-brand: var(--brand-500); + --bg-mod-faint: var(--bg-0); + --bg-mod-strong: var(--bg-2); + --bg-mod-subtle: hsl(228 calc(1 * 6%) 32.5% / 0.48); + --bg-surface-overlay: var(--bg-1); + --bg-surface-overlay-tmp: var(--primary-800); + --bg-surface-raised: var(--bg-0); + --black: var(--black-500); + --blur-fallback: hsl(225 calc(1 * 6.3%) 12.5% / 0.96); + --blur-fallback-pressed: hsl(225 calc(1 * 7.1%) 11% / 0.96); + --border-faint: hsl(0 calc(1 * 0%) 100% / 0.03); + --border-strong: hsl(0 calc(1 * 0%) 100% / 0.16); + --border-subtle: hsl(0 calc(1 * 0%) 100% / 0.08); + --bug-reporter-modal-submitting-background: hsl(220 calc(1 * 8.1%) 7.3% / 0.6); + --button-creator-revenue-background: var(--teal-430); + --button-danger-background: var(--red-430); + --button-danger-background-active: var(--red-530); + --button-danger-background-disabled: var(--red-430); + --button-danger-background-hover: var(--red-500); + --button-outline-brand-background: hsl(0 calc(1 * 0%) 100% / 0); + --button-outline-brand-background-active: var(--brand-560); + --button-outline-brand-background-hover: var(--brand-500); + --button-outline-brand-border: var(--brand-500); + --button-outline-brand-border-active: var(--brand-560); + --button-outline-brand-border-hover: var(--brand-500); + --button-outline-brand-text: var(--txt-1); + --button-outline-brand-text-active: var(--txt-1); + --button-outline-brand-text-hover: var(--txt-1); + --button-outline-danger-background: hsl(0 calc(1 * 0%) 100% / 0); + --button-outline-danger-background-active: var(--red-460); + --button-outline-danger-background-hover: var(--red-430); + --button-outline-danger-border: var(--red-400); + --button-outline-danger-border-active: var(--red-430); + --button-outline-danger-border-hover: var(--red-430); + --button-outline-danger-text: var(--txt-1); + --button-outline-danger-text-active: var(--txt-1); + --button-outline-danger-text-hover: var(--txt-1); + --button-outline-positive-background: hsl(0 calc(1 * 0%) 100% / 0); + --button-outline-positive-background-active: var(--green-530); + --button-outline-positive-background-hover: var(--green-430); + --button-outline-positive-border: var(--green-360); + --button-outline-positive-border-active: var(--green-530); + --button-outline-positive-border-hover: var(--green-430); + --button-outline-positive-text: var(--txt-1); + --button-outline-positive-text-active: var(--txt-1); + --button-outline-positive-text-hover: var(--txt-1); + --button-outline-primary-background: hsl(0 calc(1 * 0%) 100% / 0); + --button-outline-primary-background-active: var(--primary-430); + --button-outline-primary-background-hover: var(--primary-500); + --button-outline-primary-border: var(--primary-500); + --button-outline-primary-border-active: var(--primary-430); + --button-outline-primary-border-hover: var(--primary-500); + --button-outline-primary-text: var(--txt-1); + --button-outline-primary-text-active: var(--txt-1); + --button-outline-primary-text-hover: var(--txt-1); + --button-positive-background: var(--green-430); + --button-positive-background-active: var(--green-530); + --button-positive-background-disabled: var(--green-430); + --button-positive-background-hover: var(--green-500); + --button-secondary-background: var(--primary-500); + --button-secondary-background-active: var(--txt-3); + --button-secondary-background-disabled: var(--bg-1); + --button-secondary-background-hover: var(--bg-3); + --card-gradient-bg: hsl(0 calc(1 * 0%) 0% / 0.4); + --card-gradient-pressed-bg: hsl(0 calc(1 * 0%) 0% / 0.5); + --card-primary-bg: var(--primary-600); + --card-primary-pressed-bg: var(--primary-645); + --card-secondary-bg: var(--primary-630); + --card-secondary-pressed-bg: var(--primary-645); + --channel-icon: var(--txt-2); + --channel-text-area-placeholder: var(--txt-3); + --channels-default: var(--txt-2); + --channeltextarea-background: var(--bg-0); + --chat-background: var(--primary-600); + --chat-banner-bg: var(--primary-630); + --chat-border: var(--primary-700); + --chat-input-container-background: var(--primary-600); + --chat-swipe-to-reply-background: var(--primary-660); + --chat-swipe-to-reply-gradient-background: hsl(0 calc(1 * 0%) 0% / 0.1); + --coachmark-bg: var(--primary-800); + --content-inventory-media-seekbar-container: hsl(231 calc(1 * 6.7%) 79.4% / 0.24); + --content-inventory-overlay-text-primary: hsl(0 calc(1 * 0%) 100% / 0.85); + --content-inventory-overlay-text-secondary: hsl(0 calc(1 * 0%) 100% / 0.7); + --content-inventory-overlay-ui-mod: hsl(0 calc(1 * 0%) 100% / 0.7); + --content-inventory-overlay-ui-mod-bg: hsl(0 calc(1 * 0%) 0% / 0.1); + --context-menu-backdrop-background: hsl(0 calc(1 * 0%) 0% / 0.7); + --control-brand-foreground: var(--brand-360); + --control-brand-foreground-new: var(--acc-0); + --creator-revenue-icon-gradient-end: var(--teal-430); + --creator-revenue-icon-gradient-start: var(--teal-360); + --creator-revenue-info-box-background: hsl(190 calc(1 * 63%) 33.9% / 0.1); + --creator-revenue-info-box-border: var(--teal-400); + --creator-revenue-locked-channel-icon: var(--teal-345); + --creator-revenue-progress-bar: var(--teal-400); + --deprecated-card-bg: hsl(225 calc(1 * 6.3%) 12.5% / 0.6); + --deprecated-card-editable-bg: hsl(225 calc(1 * 6.3%) 12.5% / 0.3); + --deprecated-quickswitcher-input-background: var(--primary-400); + --deprecated-quickswitcher-input-placeholder: hsl(0 calc(1 * 0%) 100% / 0.3); + --deprecated-store-bg: var(--primary-600); + --deprecated-text-input-bg: var(--primary-700); + --deprecated-text-input-border: hsl(0 calc(1 * 0%) 0% / 0.3); + --deprecated-text-input-border-disabled: var(--primary-700); + --deprecated-text-input-border-hover: var(--primary-900); + --deprecated-text-input-prefix: var(--primary-200); + --display-banner-overflow-background: hsl(225 calc(1 * 6.3%) 12.5% / 0.5); + --divider-strong: hsl(0 calc(1 * 0%) 100% / 0.16); + --divider-subtle: hsl(0 calc(1 * 0%) 100% / 0.08); + --embed-background: var(--primary-630); + --embed-background-alternate: var(--primary-600); + --embed-title: var(--primary-130); + --expression-picker-bg: var(--primary-630); + --focus-primary: var(--blue-345); + --forum-post-extra-media-count-container-background: hsl(228 calc(1 * 6.7%) 14.7% / 0.8); + --forum-post-tag-background: hsl(228 calc(1 * 6.7%) 14.7% / 0.9); + --guild-icon-inactive-bg: var(--primary-630); + --guild-icon-inactive-nested-bg: var(--primary-600); + --guild-notifications-bottom-sheet-pill-background: var(--primary-700); + --halo-positive: hsl(145 calc(1 * 65%) 39.2% / 0.24); + --header-muted: var(--primary-360); + --header-primary: var(--txt-1); + --header-secondary: var(--txt-2); + --home-background: none; + --home-card-resting-border: hsl(0 calc(1 * 0%) 0% / 0); + --icon-muted: var(--primary-400); + --icon-primary: var(--primary-130); + --icon-secondary: var(--primary-330); + --icon-transparent: hsl(0 calc(1 * 0%) 0% / 0); + --info-box-background: hsl(200 calc(1 * 100%) 49.4% / 0.1); + --info-danger-background: hsl(359 calc(1 * 87.3%) 59.8% / 0.1); + --info-danger-foreground: var(--red-400); + --info-danger-text: var(--white-500); + --info-help-background: hsl(200 calc(1 * 100%) 49.4% / 0.1); + --info-help-foreground: var(--blue-345); + --info-help-text: var(--white-500); + --info-positive-background: hsl(145 calc(1 * 65%) 39.2% / 0.1); + --info-positive-foreground: var(--green-360); + --info-positive-text: var(--white-500); + --info-warning-background: hsl(40 calc(1 * 86.4%) 56.9% / 0.1); + --info-warning-foreground: var(--yellow-300); + --info-warning-text: var(--white-500); + --input-background: var(--bg-1); + --input-focused-border: hsl(0 calc(1 * 0%) 100% / 0.16); + --input-placeholder-text: var(--input-placeholder-text-dark); + --interactive-active: var(--txt-1); + --interactive-hover: var(--txt-1); + --interactive-muted: var(--txt-3); + --interactive-normal: var(--txt-2); + --legacy-android-blur-overlay-default: hsl(228 calc(1 * 6.7%) 14.7% / 0.5); + --legacy-android-blur-overlay-ultra-thin: hsl(0 calc(1 * 0%) 0% / 0.025); + --legacy-blur-fallback-default: hsl(228 calc(1 * 6.7%) 14.7% / 0.975); + --legacy-blur-fallback-ultra-thin: hsl(0 calc(1 * 0%) 0% / 0.95); + --live-stage-tile-border: hsl(228 calc(1 * 6%) 32.5% / 0.6); + --logo-primary: var(--white-500); + --mention-background: var(--mention-bg); + --mention-foreground: var(--mention-txt); + --modal-background: var(--bg-1); + --modal-footer-background: var(--bg-0); + --navigator-header-tint: var(--white-500); + --panel-bg: var(--primary-600); + --polls-normal-fill-hover: var(--primary-600); + --polls-normal-image-background: var(--primary-660); + --polls-victor-fill: hsl(145 calc(1 * 65%) 39.2% / 0.2); + --polls-voted-fill: hsl(235 calc(1 * 85.6%) 64.7% / 0.2); + --profile-gradient-card-background: hsl(0 calc(1 * 0%) 0% / 0.7); + --profile-gradient-message-input-border: hsl(228 calc(1 * 6%) 32.5% / 0.48); + --profile-gradient-note-background: hsl(0 calc(1 * 0%) 0% / 0.3); + --profile-gradient-overlay: hsl(0 calc(1 * 0%) 0% / 0.6); + --profile-gradient-overlay-synced-with-user-theme: hsl(0 calc(1 * 0%) 0% / 0.8); + --profile-gradient-profile-body-background-hover: hsl(0 calc(1 * 0%) 100% / 0.16); + --profile-gradient-role-pill-background: hsl(228 calc(1 * 6.7%) 14.7% / 0.5); + --profile-gradient-role-pill-border: hsl(0 calc(1 * 0%) 100% / 0.2); + --profile-gradient-section-box: hsl(0 calc(1 * 0%) 0% / 0.45); + --redesign-activity-card-background: var(--primary-560); + --redesign-activity-card-background-pressed: var(--primary-630); + --redesign-activity-card-badge-icon: var(--primary-360); + --redesign-activity-card-border: hsl(0 calc(1 * 0%) 100% / 0.02); + --redesign-activity-card-overflow-background: var(--primary-630); + --redesign-button-active-background: var(--green-430); + --redesign-button-active-pressed-background: var(--green-460); + --redesign-button-active-text: var(--white-500); + --redesign-button-danger-background: var(--red-430); + --redesign-button-danger-pressed-background: var(--red-460); + --redesign-button-danger-text: var(--white-500); + --redesign-button-destructive-background: var(--red-430); + --redesign-button-destructive-pressed-background: var(--red-460); + --redesign-button-destructive-text: var(--white-500); + --redesign-button-overlay-alpha-background: hsl(0 calc(1 * 0%) 0% / 0.54); + --redesign-button-overlay-alpha-pressed-background: hsl(0 calc(1 * 0%) 0% / 0.64); + --redesign-button-overlay-alpha-text: var(--white-500); + --redesign-button-overlay-background: var(--white-500); + --redesign-button-overlay-pressed-background: var(--primary-230); + --redesign-button-overlay-text: var(--primary-860); + --redesign-button-positive-background: var(--green-430); + --redesign-button-positive-pressed-background: var(--green-460); + --redesign-button-positive-text: var(--white-500); + --redesign-button-primary-alt-background: hsl(235 calc(1 * 85.6%) 64.7% / 0); + --redesign-button-primary-alt-border: var(--brand-360); + --redesign-button-primary-alt-on-blurple-background: hsl(235 calc(1 * 66.7%) 58.8% / 0); + --redesign-button-primary-alt-on-blurple-border: var(--white-500); + --redesign-button-primary-alt-on-blurple-pressed-background: var(--brand-530); + --redesign-button-primary-alt-on-blurple-pressed-border: var(--brand-360); + --redesign-button-primary-alt-on-blurple-text: var(--white-500); + --redesign-button-primary-alt-pressed-background: hsl(235 calc(1 * 47%) 25.9% / 0.16); + --redesign-button-primary-alt-pressed-border: hsl(235 calc(1 * 86.1%) 71.8% / 0.5); + --redesign-button-primary-alt-pressed-text: var(--brand-360); + --redesign-button-primary-alt-text: var(--brand-360); + --redesign-button-primary-background: var(--brand-500); + --redesign-button-primary-on-blurple-pressed-text: var(--brand-530); + --redesign-button-primary-overlay-background: var(--white-500); + --redesign-button-primary-overlay-pressed-background: var(--primary-230); + --redesign-button-primary-overlay-text: var(--primary-860); + --redesign-button-primary-pressed-background: var(--brand-560); + --redesign-button-primary-text: var(--white-500); + --redesign-button-secondary-background: var(--primary-460); + --redesign-button-secondary-border: hsl(0 calc(1 * 0%) 100% / 0.08); + --redesign-button-secondary-overlay-background: hsl(0 calc(1 * 0%) 0% / 0.54); + --redesign-button-secondary-overlay-pressed-background: hsl(0 calc(1 * 0%) 0% / 0.64); + --redesign-button-secondary-overlay-text: var(--white-500); + --redesign-button-secondary-pressed-background: var(--primary-500); + --redesign-button-secondary-pressed-border: hsl(0 calc(1 * 0%) 0% / 0); + --redesign-button-secondary-text: var(--primary-230); + --redesign-button-selected-background: hsl(235 calc(1 * 85.6%) 64.7% / 0.24); + --redesign-button-selected-pressed-background: hsl(235 calc(1 * 85.6%) 64.7% / 0.28); + --redesign-button-selected-text: var(--brand-500); + --redesign-button-tertiary-background: var(--primary-660); + --redesign-button-tertiary-pressed-background: var(--primary-560); + --redesign-button-tertiary-pressed-text: var(--primary-330); + --redesign-button-tertiary-text: var(--primary-230); + --redesign-channel-category-name-text: var(--primary-330); + --redesign-channel-message-preview-text: var(--primary-360); + --redesign-channel-name-muted-text: var(--primary-360); + --redesign-channel-name-text: var(--primary-130); + --redesign-chat-input-background: var(--primary-700); + --redesign-image-button-pressed-background: hsl(0 calc(1 * 0%) 0% / 0.08); + --redesign-input-control-active-bg: var(--primary-645); + --redesign-input-control-selected: var(--brand-500); + --redesign-only-background-active: var(--primary-530); + --redesign-only-background-default: var(--primary-600); + --redesign-only-background-overlay: var(--primary-645); + --redesign-only-background-raised: var(--primary-630); + --redesign-only-background-sunken: var(--primary-660); + --scrollbar-auto-scrollbar-color-thumb: var(--primary-730); + --scrollbar-auto-scrollbar-color-track: var(--primary-630); + --scrollbar-auto-thumb: var(--bg-1); + --scrollbar-auto-track: transparent; + --scrollbar-thin-thumb: var(--bg-1); + --scrollbar-thin-track: transparent; + --spoiler-hidden-background: var(--primary-700); + --spoiler-revealed-background: var(--primary-660); + --stage-card-pill-bg: var(--primary-630); + --status-danger: var(--pink); + --status-danger-background: var(--red-430); + --status-danger-text: var(--white-500); + --status-dnd: var(--red-400); + --status-idle: var(--yellow-300); + --status-offline: var(--primary-360); + --status-online: var(--green-360); + --status-positive: var(--green-360); + --status-positive-background: var(--green-430); + --status-positive-text: var(--white-500); + --status-speaking: var(--green-360); + --status-warning: var(--yellow-300); + --status-warning-background: var(--yellow-300); + --status-warning-text: var(--black-500); + --text-brand: var(--brand-360); + --text-danger: var(--red-345); + --text-link: var(--txt-link); + --text-link-low-saturation: var(--blue-330); + --text-low-contrast: var(--primary-360); + --text-message-preview-low-sat: var(--primary-360); + --text-muted: var(--txt-3); + --text-muted-on-default: var(--primary-330); + --text-normal: var(--txt-1); + --text-positive: var(--green); + --text-primary: var(--primary-230); + --text-secondary: var(--primary-330); + --text-warning: var(--yellow-300); + --textbox-markdown-syntax: var(--primary-360); + --theme-locked-blur-fallback: hsl(225 calc(1 * 6.3%) 12.5% / 0.96); + --thread-channel-spine: var(--primary-500); + --toast-bg: var(--primary-800); + --typing-indicator-bg: var(--primary-630); + --user-profile-header-overflow-background: hsl(225 calc(1 * 6.3%) 12.5% / 0.5); + --voice-video-video-tile-background: hsl(233 calc(1 * 9 / 0.3%) 19% / 0.4); + --voice-video-video-tile-blur-fallback: hsl(225 calc(1 * 6.3%) 12.5% / 0.48); + --white: var(--bg-0); + --you-bar-bg: var(--primary-800); + + --elevation-low: none; + --shadow-ledge: none; + + --brand-500: var(--acc-0); /* unread bar, accent buttons, spotify */ + --brand-530: var(--acc-0); /* hover highlight in context menu */ + --brand-560: var(--acc-1); /* accent button hover */ + --brand-600: var(--acc-2); /* accent button active */ + + --primary-500: var(--bg-2); /* neutral button background */ + --primary-800: var(--bg-0); /* call background */ + + --white-500: var(--txt-dark); /* THE BANE OF MY EXISTENCE */ + + --red-400: var(--pink); /* red outline buttons */ + --red-430: var(--pink); /* red buttons, outline button hover */ + --red-460: var(--pink-1); /* outline button active */ + --red-500: var(--pink-1); /* button hover */ + --red-530: var(--pink-2); /* button active */ + + --green-360: var(--green); /* vc icon on server */ + --green-430: var(--green); /* green buttons */ + --green-500: var(--green-1); + --green-530: var(--green-2); + + --twitch: var(--acc-1); +} + +/* begin 909 commits */ +::placeholder { + color: var(--txt-3) !important; +} +::selection { + color: var(--bg-0); + background: var(--txt-1); +} +.callContainer_cb9592 { + overflow: visible; + background: none !important; + overflow-x: hidden; + border-radius: var(--panel-roundness); + border: var(--border-width) solid var(--border-color); + transition: border-color var(--border-transition); + &:hover { + border-color: var(--border-hover-color); + &::after { + color: var(--border-hover-color); + } + } +} +.callContainer_cb9592 [class^='topControls_'] section { + border: none; + &::after { + content: ''; + } +} +.callContainer_cb9592::after { + content: 'call'; + display: block; + color: var(--txt-3); + font-weight: var(--label-font-weight); + position: absolute; + top: -8px; + left: 8px; + background-color: var(--bg-0); + padding: 0 4px; + z-index: 100; + transition: color var(--border-transition); +} + +.wrapper_ef3116.hidden_ef3116 { + display: none; +} + +[class^='gradientTop_'], +[class^='gradientBottom_'] { + display: none !important; +} +[class^='topControls_'], +[class^='bottomControls_'] { + transform: none !important; +} +/* end 909 commits */ + +/* begin Androser commits */ +.chatHeaderBar_dc83f5 { + padding: var(--txt-pad); + margin-bottom: var(--pad); + > :first-child { + color: var(--txt-2) !important; + font-weight: var(--label-font-weight); + overflow: hidden; + text-overflow: ellipsis; + text-wrap: nowrap; + } +} + +/* fix theming */ +[class^='circleIconButton_'] /* footer buttons */ { + background-color: var(--bg-1); + &[class*='selected_'] { + color: var(--white); + } +} +[class^='tooltip_'] { + background-color: var(--bg-0); + color: var(--txt-1); + > [class^='tooltipPointer_'] { + border-top-color: var(--bg-0); + } +} +[class*='tooltipGrey_'] { + background-color: var(--bg-1); + > [class^='tooltipPointer_'] { + border-top-color: var(--bg-1); + } +} +.drawerSizingWrapper__08434 /* gifs/emojis/stickers */ { + .contentWrapper__08434, + [class*='header_'], + [class*='wrapper_'], + .inspector_aeaaeb, + .container_d02962, + .stickerCategory__3ad28, + .categoryItemDefaultCategory_b9ee0c, + #gif-picker-tab-panel { + background-color: var(--bg-0); + box-shadow: unset; + } +} +.activeButtonChild_aa63ab /* send message button */ { + color: var(--interactive-normal); + transition: none; +} +.button_aa63ab { + &:hover, + &:focus { + .activeButtonChild_aa63ab { + color: var(--interactive-hover) !important; + } + } +} +.separator_aa63ab /* send message border */ { + background-color: var(--border-color); + width: var(--border-width); + height: 100%; +} + +/* rounding fixes */ +.members_c8ffbb /* member list */, +.messagesWrapper__36d07>[class^=scroller_] /* chat */, +.nowPlayingColumn__133bf>[class^='container_'] /* friends active now page outer */, +.nowPlayingColumn__133bf [class^='scroller'] /* friends active now page inner */, +.callContainer_cb9592 /* vc */, +.scroller__629e4 /* channel list */ { + border-radius: var(--panel-roundness) !important; +} +.privateChannels__35e86>[class^='scroller_'] /* dms */ { + border-radius: 0 0 var(--panel-roundness) var(--panel-roundness) !important; +} +.footer__214dc /* discover button */ { + margin-top: unset; + > .gradient__214dc { + background: var(--bg-overlay-app-frame, var(--background-tertiary)); + } +} +.container_a592e1 /* discover */ { + position: relative; + padding: 8px; + .backdrop__8a7fc, + .scroller__23746 { + border-radius: var(--panel-roundness); + } + /* add labels */ + &:has(.outerContainer_e1147e)::after { + content: 'apps'; + } + &:has(.container_d08938)::after { + content: 'servers'; + } + &:has(.container__955a3)::after { + content: 'quests'; + } +} +.overlay__8a7fc::after /* remove discover header separator */ { + display: none; +} + +/* remove extra space */ +.nowPlayingColumn__133bf [class^='scroller'] { + margin-left: 0; +} + +/* add discover label */ +.sidebar_c48ade > .container__551b0 { + position: relative; + &::after { + content: 'discover'; + } +} + +/* fix new thread */ +.channelTextArea_fb64c9 { + width: unset; +} +.textAreaThreadCreation__74017 { + height: unset !important; +} +/* end Androser commits */ + +/* scroll optimizations */ +[class*='scroll'] { + will-change: scroll-position; +} +.burstGlow__23977 { + display: none; +} diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/src/unrounding.css b/desktopenvs/hyprdrive/Vencord/themes/system24/src/unrounding.css new file mode 100644 index 0000000..0a6ecb7 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/src/unrounding.css @@ -0,0 +1,67 @@ +/* unrounding */ +::-webkit-scrollbar-thumb, +* { + border-radius: 0 !important; +} +/* hide round masks */ +[class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask, +[class^='bannerSVGWrapper_'] > mask > circle { + display: none; +} +[class^='mask_'] > foreignObject, +[class^='svg_'] > foreignObject { + mask-image: none; +} + +/* fiil circle mask on profile avatar */ +.overlay__75742:after, /* profile popup */ +.overlay_c0bea0:after { + /* user settings */ + border-radius: 0; +} + +/* hide status indicators */ +[class^='mask_'] > rect, +[class^='mask_'] > circle, +[class^='mask_'] > svg { + display: none; +} + +/* re-add status indicators */ +.wrapper__44b0c:has(rect)::after { + content: ''; + display: block; + position: absolute; + height: 8px; + width: 8px; + bottom: -4px; + right: -4px; + border: 2px solid var(--bg-0); +} +.wrapper__44b0c:has(rect[fill='#23a55a'])::after { + background: var(--online-dot) !important; +} +.wrapper__44b0c:has(rect[fill='#f23f43'])::after { + background: var(--dnd-dot) !important; +} +.wrapper__44b0c:has(rect[fill='#f0b232'])::after { + background: var(--idle-dot) !important; +} +.wrapper__44b0c:has(rect[fill='#80848e'])::after { + background: var(--txt-3) !important; +} +.wrapper__44b0c:has(rect[fill='#593695'])::after { + background: var(--streaming-dot); +} + +/* fix server list badges */ +[class^='lowerBadge_'] { + border: 2px solid var(--bg-0); + bottom: -4px; + right: -4px; +} +[class^='upperBadge_'] { + border: 2px solid var(--bg-0); + top: -4px; + right: -4px; +} diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/catppuccin-macchiato.theme.css b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/catppuccin-macchiato.theme.css new file mode 100644 index 0000000..1cc534f --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/catppuccin-macchiato.theme.css @@ -0,0 +1,87 @@ +/** + * @name system24 (catppuccin macchiato lavender) + * @description A tui-style discord theme. Based on the catppuccin maccchiato color palette. + * @author refact0r, bean + * @version 1.0.0 + * @invite nz87hXyvcy + * @website https://github.com/refact0r/system24 + * @source https://github.com/refact0r/system24/blob/master/flavors/catppuccin-mocha.theme.css + * @authorId 508863359777505290 + * @authorLink https://www.refact0r.dev +*/ + +/* import theme modules */ +@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */ +@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */ + +/* customize things here */ +:root { + --font: 'DM Mono'; /* UI font name. it must be installed on your system. */ + letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */ + font-weight: 300; /* UI font weight. */ + --label-font-weight: 500; /* font weight for panel labels. */ + --corner-text: 'macchiato24'; /* custom text to display in the corner. only works on windows. */ + --pad: 16px; /* padding between panels. */ + --txt-pad: 10px; /* padding inside panels to prevent labels from clipping */ + --panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */ + + /* background colors */ + --bg-0: #24273a; /* main background color. */ + --bg-1: #1e2030; /* background color for secondary elements like code blocks, embeds, etc. */ + --bg-2: #363a4f; /* color of neutral buttons. */ + --bg-3: #494d64; /* color of neutral buttons when hovered. */ + + /* state modifiers */ + --hover: color-mix(in oklch, var(--txt-3), transparent 80%); /* color of hovered elements. */ + --active: color-mix(in oklch, var(--txt-3), transparent 60%); /* color of elements when clicked. */ + --selected: var(--active); /* color of selected elements. */ + + /* text colors */ + --txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */ + --txt-link: var(--cyan); /* color of links. */ + --txt-0: #eaeefa; /* color of bright/white text. */ + --txt-1: #cad3f5; /* main text color. */ + --txt-2: #939ab7; /* color of secondary text like channel list. */ + --txt-3: #5b6078; /* color of muted text. */ + + /* accent colors */ + --acc-0: var(--lavender); /* main accent color. */ + --acc-1: var(--lavender-1); /* color of accent buttons when hovered. */ + --acc-2: var(--lavender-2); /* color of accent buttons when clicked. */ + + /* borders */ + --border-width: 2px; /* panel border thickness. */ + --border-color: var(--bg-2); /* panel border color. */ + --border-hover-color: var(--acc-0); /* panel border color when hovered. */ + --border-transition: 0.2s ease; /* panel border transition. */ + + /* status dot colors */ + --online-dot: var(--cyan); /* color of online dot. */ + --dnd-dot: var(--pink); /* color of do not disturb dot. */ + --idle-dot: var(--yellow); /* color of idle dot. */ + --streaming-dot: var(--purple); /* color of streaming dot. */ + + /* mention/ping and message colors */ + --mention-txt: var(--acc-0); /* color of mention text. */ + --mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */ + --mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */ + --mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */ + --reply-overlay: var(--active); /* overlay color of message you are replying to. */ + --reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */ + + /* color shades */ + --pink: oklch(73.7% 0.125 11.19); + --pink-1: oklch(63.7% 0.125 11.19); + --pink-2: oklch(53.7% 0.125 11.19); + --purple: oklch(77.15% 0.126 303.9); + --purple-1: oklch(67.15% 0.126 303.9); + --purple-2: oklch(57.15% 0.126 303.9); + --cyan: oklch(78.51% 0.085 228.38); + --yellow: oklch(87.9% 0.074 84.75); + --green: oklch(83.5% 0.108 138.15); + --green-1: oklch(73.5% 0.108 138.15); + --green-2: oklch(63.5% 0.108 138.15); + --lavender: oklch(81.66% 0.091 277.31); + --lavender-1: oklch(71.66% 0.091 277.31); + --lavender-2: oklch(61.66% 0.091 277.31); +} diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/catppuccin-mocha.theme.css b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/catppuccin-mocha.theme.css new file mode 100644 index 0000000..5320d89 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/catppuccin-mocha.theme.css @@ -0,0 +1,84 @@ +/** + * @name system24 (catppuccin mocha) + * @description A tui-style discord theme. Based on the catppuccin mocha color palette. + * @author refact0r + * @version 1.0.0 + * @invite nz87hXyvcy + * @website https://github.com/refact0r/system24 + * @source https://github.com/refact0r/system24/blob/master/flavors/catppuccin-mocha.theme.css + * @authorId 508863359777505290 + * @authorLink https://www.refact0r.dev +*/ + +/* import theme modules */ +@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */ +@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */ + +/* customize things here */ +:root { + --font: 'DM Mono'; /* UI font name. it must be installed on your system. */ + letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */ + font-weight: 300; /* UI font weight. */ + --label-font-weight: 500; /* font weight for panel labels. */ + --corner-text: 'catppuccin24'; /* custom text to display in the corner. only works on windows. */ + --pad: 16px; /* padding between panels. */ + --txt-pad: 10px; /* padding inside panels to prevent labels from clipping */ + --panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */ + + /* background colors */ + --bg-0: #1e1e2e; /* main background color. */ + --bg-1: #181825; /* background color for secondary elements like code blocks, embeds, etc. */ + --bg-2: #313244; /* color of neutral buttons. */ + --bg-3: #45475a; /* color of neutral buttons when hovered. */ + + /* state modifiers */ + --hover: color-mix(in oklch, var(--txt-3), transparent 80%); /* color of hovered elements. */ + --active: color-mix(in oklch, var(--txt-3), transparent 60%); /* color of elements when clicked. */ + --selected: var(--active); /* color of selected elements. */ + + /* text colors */ + --txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */ + --txt-link: var(--cyan); /* color of links. */ + --txt-0: #eaeefa; /* color of bright/white text. */ + --txt-1: #cdd6f4; /* main text color. */ + --txt-2: #9399b2; /* color of secondary text like channel list. */ + --txt-3: #585b70; /* color of muted text. */ + + /* accent colors */ + --acc-0: var(--purple); /* main accent color. */ + --acc-1: var(--purple-1); /* color of accent buttons when hovered. */ + --acc-2: var(--purple-2); /* color of accent buttons when clicked. */ + + /* borders */ + --border-width: 2px; /* panel border thickness. */ + --border-color: var(--bg-2); /* panel border color. */ + --border-hover-color: var(--acc-0); /* panel border color when hovered. */ + --border-transition: 0.2s ease; /* panel border transition. */ + + /* status dot colors */ + --online-dot: var(--green); /* color of online dot. */ + --dnd-dot: var(--pink); /* color of do not disturb dot. */ + --idle-dot: var(--yellow); /* color of idle dot. */ + --streaming-dot: var(--purple); /* color of streaming dot. */ + + /* mention/ping and message colors */ + --mention-txt: var(--acc-0); /* color of mention text. */ + --mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */ + --mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */ + --mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */ + --reply-overlay: var(--active); /* overlay color of message you are replying to. */ + --reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */ + + /* color shades */ + --pink: #f38ba8; + --pink-1: #d16c89; + --pink-2: #af4e6c; + --purple: #cba6f7; + --purple-1: #ab87d6; + --purple-2: #8d69b5; + --cyan: #74c7ec; + --yellow: #f9e2af; + --green: #a6e3a1; + --green-1: #87c282; + --green-2: #68a364; +} diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/cyberqueer.theme.css b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/cyberqueer.theme.css new file mode 100644 index 0000000..9d579ba --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/cyberqueer.theme.css @@ -0,0 +1,84 @@ +/** + * @name system24 + * @description A tui-style discord theme. + * @author refact0r + * @version 1.0.0 + * @invite nz87hXyvcy + * @website https://github.com/refact0r/system24 + * @source https://github.com/refact0r/system24/blob/master/system24.theme.css + * @authorId 508863359777505290 + * @authorLink https://www.refact0r.dev +*/ + +/* import theme modules */ +@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */ +@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */ + +/* customize things here */ +:root { + --font: 'Agave Nerd Font'; /* UI font name. it must be installed on your system. */ + letter-spacing: 0; /* decreases letter spacing for better readability. */ + font-weight: 400; /* UI font weight. */ + --label-font-weight: 400; /* font weight for panel labels. */ + --corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */ + --pad: 18px; /* padding between panels. */ + --txt-pad: 10px; /* padding inside panels to prevent labels from clipping */ + --panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */ + + /* background colors */ + --bg-0: #1a1a1a; /* main background color. */ + --bg-1: #1a1a1a; /* background color for secondary elements like code blocks, embeds, etc. */ + --bg-2: #1a1a1a; /* color of neutral buttons. */ + --bg-3: #641a1a; /* color of neutral buttons when hovered. */ + + /* state modifiers */ + --hover: #641a1a; /* color of hovered elements. */ + --active: #5018dd; /* color of elements when clicked. */ + --selected: #E40046; /* color of selected elements. */ + + /* text colors */ + --txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */ + --txt-link: var(--cyan); /* color of links. */ + --txt-0: #d6abab; /* color of bright/white text. */ + --txt-1: #d6abab;/* main text color. */ + --txt-2: #5018dd;/* color of secondary text like channel list. */ + --txt-3: #d6abab;/* color of muted text. */ + + /* accent colors */ + --acc-1: #E40046; /* main accent color. */ + --acc-0: #5018dd; /* color of accent buttons when hovered. */ + --acc-2: #5018dd; /* color of accent buttons when clicked. */ + + /* borders */ + --border-width: 1px; /* panel border thickness. */ + --border-color: #5018dd; /* panel border color. */ + --border-hover-color: #E40046; /* panel border color when hovered. */ + --border-transition: 0.2s ease; /* panel border transition. */ + + /* status dot colors */ + --online-dot: var(--green); /* color of online dot. */ + --dnd-dot: var(--pink); /* color of do not disturb dot. */ + --idle-dot: var(--yellow); /* color of idle dot. */ + --streaming-dot: var(--purple); /* color of streaming dot. */ + + /* mention/ping and message colors */ + --mention-txt: #f50505; /* color of mention text. */ + --mention-bg: #5018dd; /* background highlight of mention text. */ + --mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */ + --mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */ + --reply-overlay: var(--active); /* overlay color of message you are replying to. */ + --reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */ + + /* color shades */ + --pink: oklch(73% 0.12 0); + --pink-1: oklch(63% 0.12 0); + --pink-2: oklch(53% 0.12 0); + --purple: oklch(73% 0.12 300); + --purple-1: oklch(63% 0.12 300); + --purple-2: oklch(53% 0.12 300); + --cyan: oklch(73% 0.12 200); + --yellow: oklch(78% 0.12 80); + --green: hsl(141, 76%, 48%); + --green-1: hsl(141, 73%, 42%); + --green-2: hsl(141, 73%, 36%); +} diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/gruvbox-material.theme.css b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/gruvbox-material.theme.css new file mode 100755 index 0000000..671fe3e --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/gruvbox-material.theme.css @@ -0,0 +1,78 @@ +/** + * @name system24 (gruvbox material) + * @description A tui-style discord theme. Based on the gruvbox material color palette. + * @author refact0r, Sophed + * @version 1.0.0 + * @invite nz87hXyvcy + * @website https://github.com/refact0r/system24 + * @authorId 508863359777505290 + * @authorLink https://www.refact0r.dev +*/ + +/* import theme modules */ +@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */ +@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */ + +/* customize things here */ +:root { + --font: 'DM Mono'; /* UI font name. it must be installed on your system. */ + letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */ + font-weight: 400; /* UI font weight. */ + --label-font-weight: 500; /* font weight for panel labels. */ + --corner-text: 'gruvbox material'; /* custom text to display in the corner. only works on windows. */ + --pad: 16px; /* padding between panels. */ + --txt-pad: 10px; /* padding inside panels to prevent labels from clipping */ + --panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */ + + /* background colors */ + --bg-0: #282828; /* main background color. */ + --bg-1: #1d2021; /* background color for secondary elements like code blocks, embeds, etc. */ + --bg-2: #3c3836; /* color of neutral buttons. */ + --bg-3: #504945; /* color of neutral buttons when hovered. */ + + /* state modifiers */ + --hover: color-mix(in oklch, var(--txt-3), transparent 80%); /* color of hovered elements. */ + --active: color-mix(in oklch, var(--txt-3), transparent 60%); /* color of elements when clicked. */ + --selected: var(--active); /* color of selected elements. */ + + /* text colors */ + --txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */ + --txt-link: var(--aqua); /* color of links. */ + --txt-0: #d4be98; /* color of bright/white text. */ + --txt-1: #ddc7a1; /* main text color. */ + --txt-2: #d4be98; /* color of secondary text like channel list. */ + --txt-3: #928374; /* color of muted text. */ + + /* accent colors */ + --acc-0: var(--aqua); /* main accent color. */ + --acc-1: var(--green); /* color of accent buttons when hovered. */ + --acc-2: var(--blue); /* color of accent buttons when clicked. */ + + /* borders */ + --border-width: 2px; /* panel border thickness. */ + --border-color: var(--bg-2); /* panel border color. */ + --border-hover-color: var(--acc-0); /* panel border color when hovered. */ + --border-transition: 0.2s ease; /* panel border transition. */ + + /* status dot colors */ + --online-dot: var(--green); /* color of online dot. */ + --dnd-dot: var(--red); /* color of do not disturb dot. */ + --idle-dot: var(--yellow); /* color of idle dot. */ + --streaming-dot: var(--purple); /* color of streaming dot. */ + + /* mention/ping and message colors */ + --mention-txt: var(--acc-0); /* color of mention text. */ + --mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */ + --mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */ + --mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */ + --reply-overlay: var(--active); /* overlay color of message you are replying to. */ + --reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */ + + /* color shades */ + --red: #ea6962; + --purple: #d3869b; + --yellow: #f9e2af; + --aqua: #89b482; + --blue: #7daea3; + --green: #a9b665; +} diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/light.theme.css b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/light.theme.css new file mode 100644 index 0000000..096aa70 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/light.theme.css @@ -0,0 +1,88 @@ +/** + * @name system24 (light) + * @description A tui-style discord theme. light mode. + * @author refact0r + * @version 1.0.0 + * @invite nz87hXyvcy + * @website https://github.com/refact0r/system24 + * @source https://github.com/refact0r/system24/blob/master/flavors/light.theme.css + * @authorId 508863359777505290 + * @authorLink https://www.refact0r.dev +*/ + +/* import theme modules */ +@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */ +@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */ + +/* customize things here */ +:root { + --font: 'DM Mono'; /* UI font name. it must be installed on your system. */ + letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */ + font-weight: 300; /* UI font weight. */ + --label-font-weight: 500; /* font weight for panel labels. */ + --corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */ + --pad: 16px; /* padding between panels. */ + --txt-pad: 10px; /* padding inside panels to prevent labels from clipping */ + --panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */ + + /* background colors */ + --bg-0: oklch(97.6% 0 0); /* main background color. */ + --bg-1: oklch(92.6% 0 0); /* background color for secondary elements like code blocks, embeds, etc. */ + --bg-2: oklch(87.6% 0 0); /* color of neutral buttons. */ + --bg-3: oklch(82.6% 0 0); /* color of neutral buttons when hovered. */ + + /* state modifiers */ + --hover: oklch(40% 0 0 / 0.1); /* color of hovered elements. */ + --active: oklch(40% 0 0 / 0.2); /* color of elements when clicked. */ + --selected: var(--active); /* color of selected elements. */ + + /* text colors */ + --txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */ + --txt-link: var(--cyan); /* color of links. */ + --txt-0: oklch(100% 0 0); /* color of bright/white text. */ + --txt-1: oklch(30% 0 0); /* main text color. */ + --txt-2: oklch(50% 0 0); /* color of secondary text like channel list. */ + --txt-3: oklch(70% 0 0); /* color of muted text. */ + + /* accent colors */ + --acc-0: var(--purple); /* main accent color. */ + --acc-1: var(--purple-1); /* color of accent buttons when hovered. */ + --acc-2: var(--purple-2); /* color of accent buttons when clicked. */ + + /* borders */ + --border-width: 2px; /* panel border thickness. */ + --border-color: var(--bg-2); /* panel border color. */ + --border-hover-color: var(--acc-0); /* panel border color when hovered. */ + --border-transition: 0.2s ease; /* panel border transition. */ + + /* status dot colors */ + --online-dot: var(--green); /* color of online dot. */ + --dnd-dot: var(--pink); /* color of do not disturb dot. */ + --idle-dot: var(--yellow); /* color of idle dot. */ + --streaming-dot: var(--purple); /* color of streaming dot. */ + + /* mention/ping and message colors */ + --mention-txt: var(--acc-0); /* color of mention text. */ + --mention-bg: color-mix(in oklch, var(--acc-0), transparent 85%); /* background highlight of mention text. */ + --mention-overlay: color-mix(in oklch, var(--acc-0), transparent 85%); /* overlay color of messages that mention you. */ + --mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you when hovered. */ + --reply-overlay: var(--active); /* overlay color of message you are replying to. */ + --reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */ + + /* color shades */ + --pink: oklch(57% 0.13 0); + --pink-1: oklch(50% 0.13 0); + --pink-2: oklch(43% 0.13 0); + --purple: oklch(57% 0.13 300); + --purple-1: oklch(50% 0.13 300); + --purple-2: oklch(43% 0.13 300); + --cyan: oklch(57% 0.13 200); + --yellow: oklch(71% 0.13 80); + --green: oklch(57% 0.13 160); + --green-1: oklch(50% 0.13 160); + --green-2: oklch(43% 0.13 160); +} + +[class^=jumpToPresentBar_] > button { + color: var(--txt-1); +} diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/monochrome.theme.css b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/monochrome.theme.css new file mode 100644 index 0000000..3c312b5 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/monochrome.theme.css @@ -0,0 +1,86 @@ +/** + * @name system24 (monochrome) + * @description A tui-style discord theme. Has less colors and more neutral greys and whites. + * @author refact0r, DeadGrip + * @version 1.0.0 + * @invite nz87hXyvcy + * @website https://github.com/refact0r/system24 + * @authorId 508863359777505290 + * @authorLink https://refact0r.dev/ +*/ + +/* import theme modules */ +@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */ +@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */ + +/* customize things here */ +:root { + --font: 'DM Mono'; /* UI font name. it must be installed on your system. */ + letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */ + font-weight: 300; /* UI font weight. */ + --label-font-weight: 500; /* font weight for panel labels. */ + --corner-text: 'monochrome'; /* custom text to display in the corner. only works on windows. */ + --pad: 16px; /* padding between panels. */ + --txt-pad: 10px; /* padding inside panels to prevent labels from clipping */ + --panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */ + + /* background colors */ + --bg-0: oklch(19% 0 0); /* main background color. */ + --bg-1: oklch(23% 0 0); /* background color for secondary elements like code blocks, embeds, etc. */ + --bg-2: oklch(27% 0 0); /* color of neutral buttons. */ + --bg-3: oklch(31% 0 0); /* color of neutral buttons when hovered. */ + + /* state modifiers */ + --hover: oklch(54% 0 0 / 0.1); /* color of hovered elements. */ + --active: oklch(54% 0 0 / 0.2); /* color of elements when clicked. */ + --selected: var(--active); /* color of selected elements. */ + + /* text colors */ + --txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */ + --txt-link: var(--shade-0); /* color of links. */ + --txt-0: oklch(90% 0 0); /* color of bright/white text. */ + --txt-1: oklch(80% 0 0); /* main text color. */ + --txt-2: oklch(60% 0 0); /* color of secondary text like channel list. */ + --txt-3: oklch(40% 0 0); /* color of muted text. */ + + /* accent colors */ + --acc-0: var(--shade-0); /* main accent color. */ + --acc-1: var(--shade-1); /* color of accent buttons when hovered. */ + --acc-2: var(--shade-2); /* color of accent buttons when clicked. */ + + /* borders */ + --border-width: 2px; /* panel border thickness. */ + --border-color: var(--bg-3); /* panel border color. */ + --border-hover-color: var(--acc-1); /* panel border color when hovered. */ + --border-transition: 0.2s ease; /* panel border transition. */ + + /* status dot colors */ + --online-dot: var(--shade-0); /* color of online dot. */ + --dnd-dot: oklch(80% 0.08 0); /* color of do not disturb dot. */ + --idle-dot: oklch(80% 0.08 100); /* color of idle dot. */ + --streaming-dot: oklch(80% 0.08 300); /* color of streaming dot. */ + + /* mention/ping and message colors */ + --mention-txt: var(--acc-0); /* color of mention text. */ + --mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */ + --mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */ + --mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */ + --reply-overlay: var(--active); /* overlay color of message you are replying to. */ + --reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */ + + /* color shades */ + --pink: oklch(90% 0 0); + --pink-1: oklch(70% 0 0); + --pink-2: oklch(50% 0 0); + --purple: oklch(90% 0 0); + --purple-1: oklch(70% 0 0); + --purple-2: oklch(50% 0 0); + --cyan: oklch(90% 0 0); + --yellow: oklch(90% 0 0); + --green: oklch(90% 0 0); + --green-1: oklch(70% 0 0); + --green-2: oklch(50% 0 0); + --shade-0: oklch(90% 0 0); + --shade-1: oklch(70% 0 0); + --shade-2: oklch(50% 0 0); +} diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/rosepine.theme.css b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/rosepine.theme.css new file mode 100644 index 0000000..5d30105 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/rosepine.theme.css @@ -0,0 +1,87 @@ +/** + * @name system24 + * @description A tui-style discord theme. + * @author refact0r + * @version 1.0.0 + * @invite nz87hXyvcy + * @website https://github.com/refact0r/system24 + * @source https://github.com/refact0r/system24/blob/master/system24.theme.css + * @authorId 508863359777505290 + * @authorLink https://www.refact0r.dev +*/ + +/* import theme modules */ +@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */ +@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */ + +/* customize things here */ +:root { + --font: 'DM Mono'; /* UI font name. it must be installed on your system. */ + letter-spacing: 0; /* decreases letter spacing for better readability. */ + font-weight: 400; /* UI font weight. */ + --label-font-weight: 400; /* font weight for panel labels. */ + --corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */ + --pad: 16px; /* padding between panels. */ + --txt-pad: 10px; /* padding inside panels to prevent labels from clipping */ + --panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */ + + /* background colors */ + --bg-0: #191724; /* main background color. */ + --bg-1: #1f1d2e; /* background color for secondary elements like code blocks, embeds, etc. */ + --bg-2: #26233a; /* color of neutral buttons. */ + --bg-3: #403d52; /* color of neutral buttons when hovered. */ + + /* state modifiers */ + --hover: color-mix(in srgb, var(--bg-3), transparent 60%); /* color of hovered elements. */ + --active: color-mix(in srgb, var(--bg-3), transparent 80%); /* color of elements when clicked. */ + --selected: var(--active); /* color of selected elements. */ + + /* text colors */ + --txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */ + --txt-link: var(--rose); /* color of links. */ + --txt-0: #ffffff; /* color of bright/white text. */ + --txt-1: #e0def4; /* main text color. */ + --txt-2: #908caa; /* color of secondary text like channel list. */ + --txt-3: #6e6a86; /* color of muted text. */ + + /* accent colors */ + --acc-0: var(--rose); /* main accent color. */ + --acc-1: var(--rose-1); /* color of accent buttons when hovered. */ + --acc-2: var(--rose-2); /* color of accent buttons when clicked. */ + + /* borders */ + --border-width: 2px; /* panel border thickness. */ + --border-color: var(--bg-2); /* panel border color. */ + --border-hover-color: var(--acc-0); /* panel border color when hovered. */ + --border-transition: 0.2s ease; /* panel border transition. */ + + /* status dot colors */ + --online-dot: var(--green); /* color of online dot. */ + --dnd-dot: var(--pink); /* color of do not disturb dot. */ + --idle-dot: var(--yellow); /* color of idle dot. */ + --streaming-dot: var(--purple); /* color of streaming dot. */ + + /* mention/ping and message colors */ + --mention-txt: var(--acc-0); /* color of mention text. */ + --mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */ + --mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */ + --mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */ + --reply-overlay: var(--active); /* overlay color of message you are replying to. */ + --reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */ + + /* color shades */ + --pink: oklch(69.77% 0.1565 4.22); + --pink-1: oklch(59.77% 0.1565 4.22); + --pink-2: oklch(49.77% 0.1565 4.22); + --purple: oklch(77.6% 0.0945 304.99); + --purple-1: oklch(67.6% 0.0945 304.99); + --purple-2: oklch(57.6% 0.0945 304.99); + --cyan: oklch(52.77% 0.0793 227.72); + --yellow: oklch(84.29% 0.11 74.6); + --green: oklch(82.19% 0.0543 209.56); + --green-1: oklch(72.19% 0.0543 209.56); + --green-2: oklch(62.19% 0.0543 209.56); + --rose: oklch(83.63% 0.0544 21.14); + --rose-1: oklch(73.63% 0.0544 21.14); + --rose-2: oklch(63.63% 0.0544 21.14); +} diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/spotify-text.theme.css b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/spotify-text.theme.css new file mode 100644 index 0000000..0d2067c --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/spotify-text.theme.css @@ -0,0 +1,84 @@ +/** + * @name system24 + * @description A tui-style discord theme. + * @author refact0r + * @version 1.0.0 + * @invite nz87hXyvcy + * @website https://github.com/refact0r/system24 + * @source https://github.com/refact0r/system24/blob/master/system24.theme.css + * @authorId 508863359777505290 + * @authorLink https://www.refact0r.dev +*/ + +/* import theme modules */ +@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */ +@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */ + +/* customize things here */ +:root { + --font: 'DM Mono'; /* UI font name. it must be installed on your system. */ + letter-spacing: 0; /* decreases letter spacing for better readability. */ + font-weight: 400; /* UI font weight. */ + --label-font-weight: 400; /* font weight for panel labels. */ + --corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */ + --pad: 18px; /* padding between panels. */ + --txt-pad: 10px; /* padding inside panels to prevent labels from clipping */ + --panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */ + + /* background colors */ + --bg-0: hsl(0, 0%, 7%); /* main background color. */ + --bg-1: hsl(0, 0%, 10%); /* background color for secondary elements like code blocks, embeds, etc. */ + --bg-2: hsl(0, 0%, 13%); /* color of neutral buttons. */ + --bg-3: hsl(0, 0%, 16%); /* color of neutral buttons when hovered. */ + + /* state modifiers */ + --hover: hsla(0, 0%, 40%, 0.1); /* color of hovered elements. */ + --active: hsla(0, 0%, 40%, 0.2); /* color of elements when clicked. */ + --selected: var(--active); /* color of selected elements. */ + + /* text colors */ + --txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */ + --txt-link: var(--cyan); /* color of links. */ + --txt-0: hsl(0, 0%, 100%); /* color of bright/white text. */ + --txt-1: hsl(0, 0%, 70%); /* main text color. */ + --txt-2: hsl(0, 0%, 50%); /* color of secondary text like channel list. */ + --txt-3: hsl(0, 0%, 30%); /* color of muted text. */ + + /* accent colors */ + --acc-0: var(--green); /* main accent color. */ + --acc-1: var(--green-1); /* color of accent buttons when hovered. */ + --acc-2: var(--green-2); /* color of accent buttons when clicked. */ + + /* borders */ + --border-width: 1px; /* panel border thickness. */ + --border-color: #535353; /* panel border color. */ + --border-hover-color: #1ed760; /* panel border color when hovered. */ + --border-transition: 0.2s ease; /* panel border transition. */ + + /* status dot colors */ + --online-dot: var(--green); /* color of online dot. */ + --dnd-dot: var(--pink); /* color of do not disturb dot. */ + --idle-dot: var(--yellow); /* color of idle dot. */ + --streaming-dot: var(--purple); /* color of streaming dot. */ + + /* mention/ping and message colors */ + --mention-txt: var(--acc-0); /* color of mention text. */ + --mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */ + --mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */ + --mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */ + --reply-overlay: var(--active); /* overlay color of message you are replying to. */ + --reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */ + + /* color shades */ + --pink: oklch(73% 0.12 0); + --pink-1: oklch(63% 0.12 0); + --pink-2: oklch(53% 0.12 0); + --purple: oklch(73% 0.12 300); + --purple-1: oklch(63% 0.12 300); + --purple-2: oklch(53% 0.12 300); + --cyan: oklch(73% 0.12 200); + --yellow: oklch(78% 0.12 80); + --green: hsl(141, 76%, 48%); + --green-1: hsl(141, 73%, 42%); + --green-2: hsl(141, 73%, 36%); +} diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/templates/pywal16.theme.css b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/templates/pywal16.theme.css new file mode 100644 index 0000000..59a167d --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/templates/pywal16.theme.css @@ -0,0 +1,84 @@ +/** + * @name system24 + * @description A tui-style discord theme. + * @author refact0r, Liyua + * @version 1.0.0 + * @invite nz87hXyvcy + * @website https://github.com/refact0r/system24 + * @source https://github.com/refact0r/system24/blob/master/system24.theme.css + * @authorId 508863359777505290 + * @authorLink https://www.refact0r.dev +*/ + +/* import theme modules */ +@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */ +@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */ + +/* customize things here */ +:root {{ + --font: 'DM Mono'; /* UI font name. it must be installed on your system. */ + letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */ + font-weight: 300; /* UI font weight. */ + --label-font-weight: 500; /* font weight for panel labels. */ + --corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */ + --pad: 16px; /* padding between panels. */ + --txt-pad: 10px; /* padding inside panels to prevent labels from clipping */ + --panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */ + + /* background colors */ + --bg-0: oklch(from {background} l c h); /* main background color. */ + --bg-1: oklch(from {background} calc(l + 0.04) c h); /* background color for secondary elements like code blocks, embeds, etc. */ + --bg-2: oklch(from {background} calc(l + 0.08) c h); /* color of neutral buttons. */ + --bg-3: oklch(from {background} calc(l + 0.12) c h); /* color of neutral buttons when hovered. */ + + /* state modifiers */ + --hover: oklch(54% 0 0 / 0.1); /* color of hovered elements. */ + --active: oklch(54% 0 0 / 0.2); /* color of elements when clicked. */ + --selected: var(--active); /* color of selected elements. */ + + /* text colors */ + --txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */ + --txt-link: var(--cyan); /* color of links. */ + --txt-0: oklch(from {foreground} 100% c h); /* color of bright/white text. */ + --txt-1: oklch(from {foreground} 80% c h); /* main text color. */ + --txt-2: oklch(from {foreground} 60% c h); /* color of secondary text like channel list. */ + --txt-3: oklch(from {foreground} 40% c h); /* color of muted text. */ + + /* accent colors */ + --acc-0: var(--purple); /* main accent color. */ + --acc-1: var(--purple-1); /* color of accent buttons when hovered. */ + --acc-2: var(--purple-2); /* color of accent buttons when clicked. */ + + /* borders */ + --border-width: 2px; /* panel border thickness. */ + --border-color: var(--bg-3); /* panel border color. */ + --border-hover-color: var(--acc-0); /* panel border color when hovered. */ + --border-transition: 0.2s ease; /* panel border transition. */ + + /* status dot colors */ + --online-dot: var(--green); /* color of online dot. */ + --dnd-dot: var(--pink); /* color of do not disturb dot. */ + --idle-dot: var(--yellow); /* color of idle dot. */ + --streaming-dot: var(--purple); /* color of streaming dot. */ + + /* mention/ping and message colors */ + --mention-txt: var(--acc-0); /* color of mention text. */ + --mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */ + --mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */ + --mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */ + --reply-overlay: var(--active); /* overlay color of message you are replying to. */ + --reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */ + + /* color shades */ + --pink: oklch(from {color0} l c h); + --pink-1: oklch(from var(--pink) calc(l - 0.1) c h); + --pink-2: oklch(from var(--pink) calc(l - 0.2) c h); + --purple: oklch(from {color1} l c h); + --purple-1: oklch(from var(--purple) calc(l - 0.1) c h); + --purple-2: oklch(from var(--purple) calc(l - 0.2) c h); + --cyan: oklch(from {color2} l c h); + --yellow: oklch(from {color3} l c h); + --green: oklch(from {color4} l c h); + --green-1: oklch(from var(--green) calc(l - 0.1) c h); + --green-2: oklch(from var(--green) calc(l - 0.2) c h); +}} \ No newline at end of file diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/vencord.theme.css b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/vencord.theme.css new file mode 100644 index 0000000..51f4e38 --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/flavors/vencord.theme.css @@ -0,0 +1,83 @@ +/** + * @name system24 (vencord) + * @description A tui-style discord theme. Based on Vencord's color palette. (https://vencord.dev/) + * @author refact0r, makimwah + * @version 1.0.0 + * @invite nz87hXyvcy + * @website https://github.com/refact0r/system24 + * @source https://github.com/refact0r/system24/blob/master/system24.theme.css + * @authorId 508863359777505290 + * @authorLink https://www.refact0r.dev +*/ + +/* import theme modules */ +@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */ +@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */ + +/* customize things here */ +:root { + --font: 'DM Mono'; /* UI font name. it must be installed on your system. */ + letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */ + font-weight: 300; /* UI font weight. */ + --label-font-weight: 500; /* font weight for panel labels. */ + --corner-text: 'vencord24'; /* custom text to display in the corner. only works on windows. */ + --pad: 16px; /* padding between panels. */ + --txt-pad: 10px; /* padding inside panels to prevent labels from clipping */ + --panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */ + + /* background colors */ + --bg-0: oklch(28% 0.002 48); /* main background color. */ + --bg-1: oklch(25% 0.001 48); /* background color for secondary elements like code blocks, embeds, etc. */ + --bg-2: oklch(34% 0.004 48); /* color of neutral buttons. */ + --bg-3: oklch(38% 0.006 48); /* color of neutral buttons when hovered. */ + + /* state modifiers */ + --hover: oklch(54% 0.006 48 / 0.1); /* color of hovered elements. */ + --active: oklch(54% 0.006 48 / 0.2); /* color of elements when clicked. */ + --selected: var(--active); /* color of selected elements. */ + + /* text colors */ + --txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */ + --txt-link: var(--cyan); /* color of links. */ + --txt-0: oklch(90% 0 0); /* color of bright/white text. */ + --txt-1: #ddc7a0; /* main text color. */ + --txt-2: #918274; /* color of secondary text like channel list. */ + --txt-3: #918274; /* color of muted text. */ + + /* accent colors */ + --acc-0: var(--purple); /* main accent color. */ + --acc-1: var(--purple-1); /* color of accent buttons when hovered. */ + --acc-2: var(--purple-1); /* color of accent buttons when clicked. */ + + /* borders */ + --border-width: 2px; /* panel border thickness. */ + --border-color: var(--bg-2); /* panel border color. */ + --border-hover-color: var(--acc-0); /* panel border color when hovered. */ + --border-transition: 0.2s ease; /* panel border transition. */ + + /* status dot colors */ + --online-dot: var(--green); /* color of online dot. */ + --dnd-dot: var(--pink); /* color of do not disturb dot. */ + --idle-dot: var(--yellow); /* color of idle dot. */ + --streaming-dot: var(--purple); /* color of streaming dot. */ + + /* mention/ping and message colors */ + --mention-txt: var(--acc-0); /* color of mention text. */ + --mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */ + --mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */ + --mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */ + --reply-overlay: var(--active); /* overlay color of message you are replying to. */ + --reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */ + + /* color shades */ + --pink: #ea6962; + --pink-1: oklch(63% 0.12 0); + --pink-2: oklch(53% 0.12 0); + --purple: #d3869b; + --purple-1: #d27390; + --cyan: #7caea3; + --yellow: #d8a656; + --green: #a8b665; + --green-1: oklch(63% 0.12 160); + --green-2: oklch(53% 0.12 160); +} \ No newline at end of file diff --git a/desktopenvs/hyprdrive/Vencord/themes/system24/theme/system24.theme.css b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/system24.theme.css new file mode 100644 index 0000000..71372ee --- /dev/null +++ b/desktopenvs/hyprdrive/Vencord/themes/system24/theme/system24.theme.css @@ -0,0 +1,84 @@ +/** + * @name system24 + * @description A tui-style discord theme. + * @author refact0r + * @version 1.0.0 + * @invite nz87hXyvcy + * @website https://github.com/refact0r/system24 + * @source https://github.com/refact0r/system24/blob/master/system24.theme.css + * @authorId 508863359777505290 + * @authorLink https://www.refact0r.dev +*/ + +/* import theme modules */ +@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */ +@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */ + +/* customize things here */ +:root { + --font: 'DM Mono'; /* UI font name. it must be installed on your system. */ + letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */ + font-weight: 300; /* UI font weight. */ + --label-font-weight: 500; /* font weight for panel labels. */ + --corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */ + --pad: 16px; /* padding between panels. */ + --txt-pad: 10px; /* padding inside panels to prevent labels from clipping */ + --panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */ + + /* background colors */ + --bg-0: oklch(19% 0 0); /* main background color. */ + --bg-1: oklch(23% 0 0); /* background color for secondary elements like code blocks, embeds, etc. */ + --bg-2: oklch(27% 0 0); /* color of neutral buttons. */ + --bg-3: oklch(31% 0 0); /* color of neutral buttons when hovered. */ + + /* state modifiers */ + --hover: oklch(54% 0 0 / 0.1); /* color of hovered elements. */ + --active: oklch(54% 0 0 / 0.2); /* color of elements when clicked. */ + --selected: var(--active); /* color of selected elements. */ + + /* text colors */ + --txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */ + --txt-link: var(--cyan); /* color of links. */ + --txt-0: oklch(90% 0 0); /* color of bright/white text. */ + --txt-1: oklch(80% 0 0); /* main text color. */ + --txt-2: oklch(60% 0 0); /* color of secondary text like channel list. */ + --txt-3: oklch(40% 0 0); /* color of muted text. */ + + /* accent colors */ + --acc-0: var(--purple); /* main accent color. */ + --acc-1: var(--purple-1); /* color of accent buttons when hovered. */ + --acc-2: var(--purple-2); /* color of accent buttons when clicked. */ + + /* borders */ + --border-width: 2px; /* panel border thickness. */ + --border-color: var(--bg-3); /* panel border color. */ + --border-hover-color: var(--acc-0); /* panel border color when hovered. */ + --border-transition: 0.2s ease; /* panel border transition. */ + + /* status dot colors */ + --online-dot: var(--green); /* color of online dot. */ + --dnd-dot: var(--pink); /* color of do not disturb dot. */ + --idle-dot: var(--yellow); /* color of idle dot. */ + --streaming-dot: var(--purple); /* color of streaming dot. */ + + /* mention/ping and message colors */ + --mention-txt: var(--acc-0); /* color of mention text. */ + --mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */ + --mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */ + --mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */ + --reply-overlay: var(--active); /* overlay color of message you are replying to. */ + --reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */ + + /* color shades */ + --pink: oklch(73% 0.12 0); + --pink-1: oklch(63% 0.12 0); + --pink-2: oklch(53% 0.12 0); + --purple: oklch(73% 0.12 300); + --purple-1: oklch(63% 0.12 300); + --purple-2: oklch(53% 0.12 300); + --cyan: oklch(73% 0.12 200); + --yellow: oklch(78% 0.12 80); + --green: oklch(73% 0.12 160); + --green-1: oklch(63% 0.12 160); + --green-2: oklch(53% 0.12 160); +} \ No newline at end of file diff --git a/desktopenvs/hyprdrive/alacritty/alacritty b/desktopenvs/hyprdrive/alacritty/alacritty new file mode 120000 index 0000000..624c74c --- /dev/null +++ b/desktopenvs/hyprdrive/alacritty/alacritty @@ -0,0 +1 @@ +/home/themiro/Dotfiles/desktopenvs/hyprland/alacritty \ No newline at end of file diff --git a/desktopenvs/hyprdrive/alacritty/alacritty.toml b/desktopenvs/hyprdrive/alacritty/alacritty.toml new file mode 100644 index 0000000..606a177 --- /dev/null +++ b/desktopenvs/hyprdrive/alacritty/alacritty.toml @@ -0,0 +1,10 @@ +[font] +normal = {family = "Agave NerdFont", style = "Regular"} + +[terminal] +[terminal.shell] +program = "/usr/bin/zsh" +args = ["-l"] + +[window] +opacity = 0.3 diff --git a/desktopenvs/hyprdrive/astro-menu/.gitignore b/desktopenvs/hyprdrive/astro-menu/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/desktopenvs/hyprdrive/astro-menu/README.md b/desktopenvs/hyprdrive/astro-menu/README.md new file mode 100644 index 0000000..37a2e6e --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/README.md @@ -0,0 +1,60 @@ +# astro-menu + +A touch-friendly GTK4 popup control centre for the hyprlua desktop, replacing +`nwg-dock` and `nwg-drawer`. Triggered from the EWW top bar (or `Super+D`). + +Layout: a **2×2 quad grid** of feature modules over a full-width **application +drawer**. Any quad can expand to overlay the other three; the drawer can expand to +the bottom of the screen. A single margined root box letterboxes the menu identically +in every state. + +## Stack + +- **Frontend:** Python + PyGObject + **GTK4**, as a `wlr-layer-shell` surface + (`gtk4-layer-shell`). Chosen over Lua because lgi/Astal-Lua only support GTK3. +- **Location map:** a static OSM image stitched from tiles (`backend/staticmap.py`, + Pillow). libshumate does not paint tiles in this environment (the official + `shumate-demo` shows the same blank map), though tile downloads work — hence the + static fallback. +- **Services:** Astal GObject libraries via introspection — `AstalNetwork`, + `AstalBluetooth`, `AstalApps` — plus our own IP-geolocation singleton. +- **Backends:** Python/Bash scripts in `backend/` (JSON on stdout), run async via + `Gio.Subprocess` so nothing blocks the UI. +- **Theme:** `style/_colors.css` (`@define-color`, generated from + `~/Dotfiles/colors.conf` by `apply-theme.sh`) + `style/style.css`. + +## Running + +`main.py` is single-instance. The autostart launches a hidden resident daemon; +verbs are forwarded over D-Bus: + + scripts/astro-menu-start.sh # resident daemon (hidden); sets LD_PRELOAD + scripts/menu-toggle.sh # --toggle + scripts/menu-toggle.sh appdrawer # open with the app drawer expanded + +`astro-menu-start.sh` must `LD_PRELOAD` libgtk4-layer-shell (it loads after +libwayland under PyGObject otherwise). + +## Adding a module + +1. Create `modules/.py` exposing a top-level `SPEC = ModuleSpec(...)` whose + `build(ctx)` returns a `ModuleInstance(compact=…, expanded=…, …)`. + - `compact` shows in the 2×2 cell; a distinct `expanded` widget enables the + expand button (they must be separate instances — GTK widgets have one parent). + - Declare per-feature toggles via `features=[Feature("id", "Label", default)]`; + read them with `ctx.feature("id")`. A disabled quad never calls `build()`. + - Shared state (network, bluetooth, location) is on `ctx.services`. +2. Append its `SPEC` to `ALL_SPECS` in `registry.py`. Nothing else changes. + +## Files + + main.py app + single-instance IPC window.py layer-shell + letterbox + registry.py module list (extension seam) settings.py JSON toggles/order + module_base.py ModuleSpec / ModuleInstance / ModuleContext + appservices.py shared Astal services + location + ui/ quadcard, quadgrid, appdrawer + modules/ location, weather, bluetooth, network + services/ location (geolocation singleton) + lib/ ansi (SGR→TextTag), proc (async subprocess) + backend/ geolocate.py, weather.sh, network.sh + style/ _colors.css, style.css diff --git a/desktopenvs/hyprdrive/astro-menu/appservices.py b/desktopenvs/hyprdrive/astro-menu/appservices.py new file mode 100644 index 0000000..a9797bf --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/appservices.py @@ -0,0 +1,35 @@ +"""Lazily-built shared services handed to every module via ModuleContext. + +Astal's GObject service libraries (AstalNetwork, AstalBluetooth) are consumed here +through GObject-introspection; modules connect to their `notify::` signals instead +of polling. The location service is our own singleton. +""" + +from __future__ import annotations + +import gi + +gi.require_version("AstalNetwork", "0.1") +gi.require_version("AstalBluetooth", "0.1") +from gi.repository import AstalBluetooth, AstalNetwork # noqa: E402 + +from services.location import get_location_service + + +class Services: + def __init__(self) -> None: + self._network = None + self._bluetooth = None + self.location = get_location_service() + + @property + def network(self): + if self._network is None: + self._network = AstalNetwork.Network.get_default() + return self._network + + @property + def bluetooth(self): + if self._bluetooth is None: + self._bluetooth = AstalBluetooth.Bluetooth.get_default() + return self._bluetooth diff --git a/desktopenvs/hyprdrive/astro-menu/backend/geolocate.py b/desktopenvs/hyprdrive/astro-menu/backend/geolocate.py new file mode 100755 index 0000000..fbc5987 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/backend/geolocate.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""IP geolocation, cached with a TTL. + +Discovers the public IP by tracerouting to 1.1.1.1 and taking the first +globally-routable hop (the ISP egress nearest the user), then resolves that IP to a +location through a public geolocation API. Falls back to locating this host's own +public IP when traceroute is unavailable or yields no public hop. + +Prints JSON: {lat, lon, city, country, ip, source}. Diagnostics go to stderr, +non-zero exit on total failure. Used by services/location.py (and runnable +standalone for testing). +""" + +from __future__ import annotations + +import ipaddress +import json +import os +import re +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astro-menu" / "location.json" +TTL = 1800 # seconds + +TARGET = "1.1.1.1" +_IPV4 = re.compile(r"\b(\d{1,3}(?:\.\d{1,3}){3})\b") + +# Geolocation providers. Each builds a URL for a given IP; an empty IP asks the +# provider to resolve the caller's own public address (the self-IP fallback). +PROVIDERS = [ + ("ip-api", + lambda ip: f"http://ip-api.com/json/{ip}?fields=lat,lon,city,country,query", + lambda d: {"lat": d["lat"], "lon": d["lon"], "city": d.get("city"), + "country": d.get("country"), "ip": d.get("query")}), + ("ipapi.co", + lambda ip: f"https://ipapi.co/{ip}/json/" if ip else "https://ipapi.co/json/", + lambda d: {"lat": d["latitude"], "lon": d["longitude"], "city": d.get("city"), + "country": d.get("country_name"), "ip": d.get("ip")}), + ("ipinfo", + lambda ip: f"https://ipinfo.io/{ip}/json" if ip else "https://ipinfo.io/json", + lambda d: {"lat": float(d["loc"].split(",")[0]), "lon": float(d["loc"].split(",")[1]), + "city": d.get("city"), "country": d.get("country"), "ip": d.get("ip")}), +] + + +def _cached() -> dict | None: + try: + blob = json.loads(CACHE.read_text()) + if time.time() - blob.get("_ts", 0) < TTL: + return blob["data"] + except (FileNotFoundError, json.JSONDecodeError, KeyError): + pass + return None + + +def _store(data: dict) -> None: + CACHE.parent.mkdir(parents=True, exist_ok=True) + CACHE.write_text(json.dumps({"_ts": time.time(), "data": data})) + + +def _public_hop_ip() -> str | None: + """First globally-routable hop on the path to 1.1.1.1 — i.e. the ISP egress + closest to the user. The private/CGNAT hops before it and the anycast target + itself (Cloudflare, useless for locating the user) are skipped.""" + try: + proc = subprocess.run( + ["traceroute", "-n", "-q", "1", "-w", "2", "-m", "12", TARGET], + capture_output=True, text=True, timeout=40, + ) + except (FileNotFoundError, subprocess.SubprocessError) as exc: + print(f"traceroute: {exc}", file=sys.stderr) + return None + for line in proc.stdout.splitlines(): + if line.lower().startswith("traceroute to"): + continue # header line names the target; not a hop + for ip in _IPV4.findall(line): + if ip == TARGET: + continue + try: + if ipaddress.ip_address(ip).is_global: + return ip + except ValueError: + continue + return None + + +def _fetch(url: str) -> dict: + req = urllib.request.Request(url, headers={"User-Agent": "astro-menu/1.0"}) + with urllib.request.urlopen(req, timeout=8) as resp: + return json.loads(resp.read().decode()) + + +def main() -> int: + if "--no-cache" not in sys.argv: + hit = _cached() + if hit: + print(json.dumps(hit)) + return 0 + + hop = _public_hop_ip() + # Try the traced public hop first, then fall back to our own public IP. + candidates = ([hop] if hop else []) + [""] + for ip in candidates: + for name, url_of, parse in PROVIDERS: + try: + data = parse(_fetch(url_of(ip))) + if data.get("lat") is None or data.get("lon") is None: + raise ValueError("no coordinates") + data["source"] = f"{name} via {ip}" if ip else name + _store(data) + print(json.dumps(data)) + return 0 + except Exception as exc: # noqa: BLE001 — try the next provider/candidate + print(f"{name}({ip or 'self'}): {exc}", file=sys.stderr) + print("all geolocation attempts failed", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktopenvs/hyprdrive/astro-menu/backend/network.sh b/desktopenvs/hyprdrive/astro-menu/backend/network.sh new file mode 100755 index 0000000..6d70fc3 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/backend/network.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Networking backend for the astro-menu Network quad. +# Each subcommand prints JSON (or plain text for pubip) to stdout. Wi-Fi listing and +# state come from AstalNetwork in the UI; this covers the rest (ip/routes/ports, +# public IP, wifi connect via nmcli, and the manual/DHCP switch). +set -euo pipefail + +active_conn() { + # First active connection on a real device (wifi/ethernet). + nmcli -t -g NAME,TYPE,DEVICE connection show --active 2>/dev/null \ + | awk -F: '$2 ~ /wireless|ethernet/ {print $1; exit}' +} + +cmd="${1:-}"; shift || true +case "$cmd" in + ip) + ip -j addr show 2>/dev/null || echo '[]' + ;; + routes) + ip -j route show 2>/dev/null || echo '[]' + ;; + ports) + # Listening TCP/UDP sockets -> JSON array. + ss -tulnH 2>/dev/null | awk ' + BEGIN { print "["; sep="" } + { printf "%s{\"proto\":\"%s\",\"local\":\"%s\"}", sep, $1, $5; sep="," } + END { print "]" }' + ;; + pubip) + curl -sf --max-time 8 https://ifconfig.co 2>/dev/null \ + || curl -sf --max-time 8 https://api.ipify.org 2>/dev/null \ + || echo "unavailable" + ;; + wifi-connect) + ssid="${1:-}"; pass="${2:-}" + if [[ -n "$pass" ]]; then + nmcli dev wifi connect "$ssid" password "$pass" + else + nmcli dev wifi connect "$ssid" + fi + ;; + wifi-disconnect) + con="$(active_conn)" + [[ -n "$con" ]] && nmcli connection down "$con" + ;; + set-manual) + con="${4:-$(active_conn)}"; addr="${1:-}"; gw="${2:-}"; dns="${3:-}" + [[ -z "$con" ]] && { echo "no active connection" >&2; exit 1; } + nmcli connection modify "$con" ipv4.method manual \ + ipv4.addresses "$addr" ipv4.gateway "$gw" ipv4.dns "$dns" + nmcli connection up "$con" + ;; + set-dhcp) + con="${1:-$(active_conn)}" + [[ -z "$con" ]] && { echo "no active connection" >&2; exit 1; } + nmcli connection modify "$con" ipv4.method auto + nmcli connection up "$con" + ;; + active-conn) + active_conn + ;; + *) + echo "usage: network.sh {ip|routes|ports|pubip|wifi-connect|wifi-disconnect|set-manual|set-dhcp|active-conn}" >&2 + exit 2 + ;; +esac diff --git a/desktopenvs/hyprdrive/astro-menu/backend/nm.py b/desktopenvs/hyprdrive/astro-menu/backend/nm.py new file mode 100755 index 0000000..337ec13 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/backend/nm.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""NetworkManager backend for the Network quad's expanded settings. + +Every subcommand prints JSON to stdout ({"ok": bool, ...} for mutations) and +diagnostics to stderr. Reads use `nmcli -g` (raw, unescaped single fields) and +`ip -j`; writes go through `nmcli connection modify/up`, so persistence and polkit +auth are handled by NetworkManager. Kept as one script so the UI has a single, +stable contract (see modules/network.py). + + adapters list manageable devices + their connection + ipconfig ipv4/ipv6 method/addresses/gateway/dns + set-ip ipv4|ipv6 [addr_cidr] [gateway] [dns_space_sep] + routes kernel routing table (read-only) + vlans configured VLAN connections + vlan-add [name] create + bring up a VLAN + vlan-del delete a VLAN connection + vlan-up activate a VLAN connection + dns effective DNS servers per device + set-dns ipv4|ipv6 +""" + +from __future__ import annotations + +import json +import subprocess +import sys + + +def _run(argv: list[str], timeout: int = 20) -> tuple[bool, str, str]: + try: + p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout) + except (FileNotFoundError, subprocess.SubprocessError) as exc: + return False, "", str(exc) + return p.returncode == 0, p.stdout, p.stderr + + +def _get(field: str, con: str) -> str: + ok, out, _ = _run(["nmcli", "-g", field, "connection", "show", con]) + return out.strip() if ok else "" + + +def _list(field: str, con: str) -> list[str]: + raw = _get(field, con) + return [v.strip() for v in raw.replace(",", " ").split() if v.strip()] + + +def _emit(obj) -> int: + print(json.dumps(obj)) + return 0 + + +def _ok(ok: bool, err: str = "") -> int: + print(json.dumps({"ok": bool(ok), "error": err.strip()})) + return 0 if ok else 1 + + +# -- reads ----------------------------------------------------------------- +def adapters() -> int: + ok, out, _ = _run(["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"]) + rows = [] + if ok: + for line in out.splitlines(): + # DEVICE may contain escaped colons (bt MACs); split from the right on + # the three trailing fields we know are colon-free-ish. + parts = line.split(":") + if len(parts) < 4: + continue + connection = parts[-1] + state = parts[-2] + typ = parts[-3] + device = ":".join(parts[:-3]).replace("\\:", ":") + if typ not in ("ethernet", "wifi"): + continue + rows.append({"device": device, "type": typ, "state": state, + "connection": connection.replace("\\:", ":")}) + return _emit(rows) + + +def ipconfig(con: str) -> int: + def family(fam: str) -> dict: + return { + "method": _get(f"{fam}.method", con) or "auto", + "addresses": _list(f"{fam}.addresses", con), + "gateway": _get(f"{fam}.gateway", con), + "dns": _list(f"{fam}.dns", con), + "ignore_auto_dns": _get(f"{fam}.ignore-auto-dns", con) == "yes", + } + return _emit({"connection": con, "ipv4": family("ipv4"), "ipv6": family("ipv6")}) + + +def routes() -> int: + ok, out, err = _run(["ip", "-j", "route", "show"]) + if not ok: + return _emit([]) + try: + return _emit(json.loads(out)) + except json.JSONDecodeError: + return _emit([]) + + +def vlans() -> int: + ok, out, _ = _run(["nmcli", "-t", "-f", "NAME,TYPE,DEVICE,ACTIVE", "connection", "show"]) + rows = [] + if ok: + for line in out.splitlines(): + parts = line.rsplit(":", 3) + if len(parts) < 4 or parts[1] != "vlan": + continue + name, _typ, device, active = parts + rows.append({"name": name, "device": device, "active": active == "yes"}) + return _emit(rows) + + +def dns() -> int: + """Effective DNS servers, per device, from `nmcli device show`.""" + ok, out, _ = _run(["nmcli", "-t", "-f", + "GENERAL.DEVICE,IP4.DNS,IP6.DNS", "device", "show"]) + devices: dict[str, dict] = {} + cur = None + if ok: + for line in out.splitlines(): + if ":" not in line: + continue + key, _, val = line.partition(":") + val = val.strip() + if key == "GENERAL.DEVICE": + cur = val + devices.setdefault(cur, {"device": cur, "servers": []}) + elif cur and key.startswith(("IP4.DNS", "IP6.DNS")) and val: + devices[cur]["servers"].append(val) + return _emit([d for d in devices.values() if d["servers"]]) + + +# -- writes ---------------------------------------------------------------- +def set_ip(con: str, fam: str, method: str, addr: str = "", + gateway: str = "", dns: str = "") -> int: + if fam not in ("ipv4", "ipv6"): + return _ok(False, "family must be ipv4 or ipv6") + args = ["nmcli", "connection", "modify", con, f"{fam}.method", method] + if method == "manual": + args += [f"{fam}.addresses", addr or ""] + args += [f"{fam}.gateway", gateway or ""] + if dns: + args += [f"{fam}.dns", dns] + else: # auto: clear any manual leftovers so DHCP is clean + args += [f"{fam}.addresses", "", f"{fam}.gateway", ""] + ok, _, err = _run(args) + if ok: + ok, _, err = _run(["nmcli", "connection", "up", con]) + return _ok(ok, err) + + +def set_dns(con: str, fam: str, servers: str, ignore_auto: str) -> int: + if fam not in ("ipv4", "ipv6"): + return _ok(False, "family must be ipv4 or ipv6") + ok, _, err = _run(["nmcli", "connection", "modify", con, + f"{fam}.dns", servers, + f"{fam}.ignore-auto-dns", "yes" if ignore_auto == "yes" else "no"]) + if ok: + ok, _, err = _run(["nmcli", "connection", "up", con]) + return _ok(ok, err) + + +def vlan_add(parent: str, vid: str, name: str = "") -> int: + name = name or f"vlan{vid}" + ok, _, err = _run(["nmcli", "connection", "add", "type", "vlan", + "con-name", name, "dev", parent, "id", str(vid)]) + if ok: + ok, _, err = _run(["nmcli", "connection", "up", name]) + return _ok(ok, err) + + +def vlan_del(name: str) -> int: + ok, _, err = _run(["nmcli", "connection", "delete", name]) + return _ok(ok, err) + + +def vlan_up(name: str) -> int: + ok, _, err = _run(["nmcli", "connection", "up", name]) + return _ok(ok, err) + + +def main(argv: list[str]) -> int: + if not argv: + print("no subcommand", file=sys.stderr) + return 2 + cmd, args = argv[0], argv[1:] + table = { + "adapters": lambda: adapters(), + "ipconfig": lambda: ipconfig(args[0]), + "set-ip": lambda: set_ip(*args), + "routes": lambda: routes(), + "vlans": lambda: vlans(), + "vlan-add": lambda: vlan_add(*args), + "vlan-del": lambda: vlan_del(args[0]), + "vlan-up": lambda: vlan_up(args[0]), + "dns": lambda: dns(), + "set-dns": lambda: set_dns(*args), + } + fn = table.get(cmd) + if fn is None: + print(f"unknown subcommand: {cmd}", file=sys.stderr) + return 2 + try: + return fn() + except TypeError as exc: + print(f"bad arguments for {cmd}: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/desktopenvs/hyprdrive/astro-menu/backend/staticmap.py b/desktopenvs/hyprdrive/astro-menu/backend/staticmap.py new file mode 100755 index 0000000..fdcc993 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/backend/staticmap.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Render a static slippy-map image centred on a coordinate, with a marker. + +Used by the Location quad because libshumate does not render tiles in this +environment (the official shumate-demo shows the same blank map — a library/GTK +render bug), while plain tile downloads work fine. Stitches raster tiles into one +PNG with Pillow and caches them. The quad re-renders this at a new centre to pan. + + staticmap.py \ + [style] [marker_lat] [marker_lon] + + style: dark (default, CartoDB dark_matter) | satellite (Esri) | standard (OSM) + marker_*: where to draw the location pin; defaults to the centre. When the centre + is panned away, the pin clamps to the nearest edge so it stays visible. +""" + +from __future__ import annotations + +import io +import math +import os +import sys +import urllib.request +from pathlib import Path + +from PIL import Image, ImageDraw + +TILE = 256 +CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astro-menu" / "tiles" +UA = "astro-menu/1.0 (personal dotfiles)" +BG = (26, 26, 26) +ACCENT = (228, 0, 70) + +STYLES = { + "standard": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "dark": "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", + "satellite": "https://server.arcgisonline.com/ArcGIS/rest/services/" + "World_Imagery/MapServer/tile/{z}/{y}/{x}", +} + + +def _center_px(lat: float, lon: float, zoom: int) -> tuple[float, float]: + n = 2 ** zoom + lat_r = math.radians(lat) + x = (lon + 180.0) / 360.0 * n + y = (1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n + return x * TILE, y * TILE + + +def _tile(z: int, x: int, y: int, style: str) -> Image.Image | None: + CACHE.mkdir(parents=True, exist_ok=True) + fp = CACHE / f"{style}_{z}_{x}_{y}.png" + if fp.exists(): + try: + return Image.open(fp).convert("RGB") + except Exception: + pass + url = STYLES[style].format(z=z, x=x, y=y) + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + data = urllib.request.urlopen(req, timeout=8).read() + except Exception as exc: + print(f"tile {style} {z}/{x}/{y}: {exc}", file=sys.stderr) + return None + img = Image.open(io.BytesIO(data)).convert("RGB") + img.save(fp) # normalise to PNG in the cache (satellite is served as JPEG) + return img + + +def render(lat: float, lon: float, zoom: int, w: int, h: int, out: str, + style: str = "dark", marker: tuple[float, float] | None = None) -> None: + if style not in STYLES: + style = "dark" + n = 2 ** zoom + cx, cy = _center_px(lat, lon, zoom) + left, top = cx - w / 2, cy - h / 2 + img = Image.new("RGB", (w, h), BG) + x0, x1 = int(left // TILE), int((left + w) // TILE) + y0, y1 = int(top // TILE), int((top + h) // TILE) + for tx in range(x0, x1 + 1): + for ty in range(y0, y1 + 1): + if ty < 0 or ty >= n: + continue + tile = _tile(zoom, tx % n, ty, style) + if tile is None: + continue + img.paste(tile, (int(tx * TILE - left), int(ty * TILE - top))) + + # marker at the real location (defaults to centre); clamp to the edge so it stays + # on-screen when the view is panned away from it. + mlat, mlon = marker if marker else (lat, lon) + mwx, mwy = _center_px(mlat, mlon, zoom) + r = 8 + mx = max(r, min(w - r, mwx - left)) + my = max(r, min(h - r, mwy - top)) + d = ImageDraw.Draw(img) + d.ellipse([mx - r, my - r, mx + r, my + r], fill=ACCENT, outline=(255, 255, 255), width=2) + + Path(out).parent.mkdir(parents=True, exist_ok=True) + img.save(out) + + +if __name__ == "__main__": + a = sys.argv + style = a[7] if len(a) > 7 else "dark" + marker = (float(a[8]), float(a[9])) if len(a) > 9 else None + render(float(a[1]), float(a[2]), int(a[3]), int(a[4]), int(a[5]), a[6], style, marker) + print(a[6]) diff --git a/desktopenvs/hyprdrive/astro-menu/backend/weather.sh b/desktopenvs/hyprdrive/astro-menu/backend/weather.sh new file mode 100755 index 0000000..9426c89 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/backend/weather.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Weather backend: wttr.in with its native ANSI art (curl UA gets terminal colours). +# Args: $1 = location (may be empty -> wttr.in geolocates the caller), $2 = query opts. +# Prints the wttr.in body (ANSI or plain, per opts) to stdout. +set -uo pipefail + +loc="${1:-}" +# Note: ${2-0}, not ${2:-0} — the expanded view passes an *empty* opts to request +# wttr.in's default 3-day forecast, and :- would wrongly rewrite that empty string +# to "0" (current conditions only). Default to "0" only when no opts arg is given. +opts="${2-0}" + +# URL-encode spaces in a city name. +loc="${loc// /+}" + +url="https://wttr.in/${loc}?${opts}" +# -A curl makes wttr.in return terminal (ANSI) output regardless of the real UA. +curl -sf -A curl --max-time 10 "$url" diff --git a/desktopenvs/hyprdrive/astro-menu/config.py b/desktopenvs/hyprdrive/astro-menu/config.py new file mode 100644 index 0000000..96f59b3 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/config.py @@ -0,0 +1,31 @@ +"""Tiny user-editable config file: ~/.local/state/astro-menu/config.json. + +Same pattern as orbit-menu/config.py and horizon-dock/config.py. Deliberately +separate from settings.py's Settings class: that one persists quad enable/ +feature toggles and favorites (module-scoped, read via ctx.feature()), while +this is a single whole-window flag read once at startup. +""" + +from __future__ import annotations + +import json + +from paths import CONFIG_FILE, ensure_dirs + +_DEFAULTS = {"hologram": True} + + +def _load() -> dict: + try: + data = json.loads(CONFIG_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + data = {} + merged = {**_DEFAULTS, **data} + if not CONFIG_FILE.exists(): + ensure_dirs() + CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n") + return merged + + +def hologram_enabled() -> bool: + return bool(_load().get("hologram", True)) diff --git a/desktopenvs/hyprdrive/astro-menu/lib/ansi.py b/desktopenvs/hyprdrive/astro-menu/lib/ansi.py new file mode 100644 index 0000000..febb91f --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/lib/ansi.py @@ -0,0 +1,138 @@ +"""Render ANSI/SGR-coloured terminal text (e.g. wttr.in) into a Gtk.TextView. + +wttr.in returns real terminal escape sequences when curled. We parse the SGR +subset it uses (basic 8/16 colours, xterm-256, truecolor, bold, reset) and emit +Gtk.TextTags so the CLI art keeps its colours and block-glyph alignment. This is +deliberately reusable by any future "show me some CLI art" widget. +""" + +from __future__ import annotations + +import re + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +_SGR = re.compile(r"\x1b\[([0-9;]*)m") + +# xterm 16-colour base palette (approx, tuned to the CyberQueer dark background). +_BASE16 = [ + (0x00, 0x00, 0x00), (0xCC, 0x24, 0x24), (0x33, 0xCC, 0x33), (0xCC, 0xCC, 0x33), + (0x33, 0x66, 0xCC), (0xCC, 0x33, 0xCC), (0x33, 0xCC, 0xCC), (0xD6, 0xAB, 0xAB), + (0x66, 0x66, 0x66), (0xF5, 0x05, 0x05), (0x55, 0xFF, 0x55), (0xFF, 0xFF, 0x55), + (0x55, 0x88, 0xFF), (0xE4, 0x00, 0x46), (0x55, 0xFF, 0xFF), (0xFF, 0xFF, 0xFF), +] + + +def _xterm256(n: int) -> tuple[int, int, int]: + if n < 16: + return _BASE16[n] + if n < 232: # 6x6x6 colour cube + n -= 16 + r, g, b = n // 36, (n // 6) % 6, n % 6 + conv = lambda c: 55 + c * 40 if c else 0 + return conv(r), conv(g), conv(b) + v = 8 + (n - 232) * 10 # grayscale ramp + return v, v, v + + +def _rgba(rgb: tuple[int, int, int]) -> Gdk.RGBA: + c = Gdk.RGBA() + c.red, c.green, c.blue, c.alpha = rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1.0 + return c + + +class AnsiRenderer: + """Owns a TextView and repaints it from ANSI text. Tags are cached by state.""" + + def __init__(self) -> None: + self.view = Gtk.TextView( + editable=False, cursor_visible=False, monospace=True, + wrap_mode=Gtk.WrapMode.NONE, + ) + self.view.add_css_class("ansi-view") + self.buffer = self.view.get_buffer() + self._tag_cache: dict[tuple, Gtk.TextTag] = {} + + def _tag(self, fg, bg, bold) -> Gtk.TextTag | None: + if fg is None and bg is None and not bold: + return None + key = (fg, bg, bold) + tag = self._tag_cache.get(key) + if tag is None: + tag = self.buffer.create_tag() + if fg is not None: + tag.set_property("foreground-rgba", _rgba(fg)) + if bg is not None: + tag.set_property("background-rgba", _rgba(bg)) + if bold: + tag.set_property("weight", 700) + self._tag_cache[key] = tag + return tag + + def set_text(self, text: str) -> None: + self.buffer.set_text("", 0) + fg = bg = None + bold = False + pos = 0 + for m in _SGR.finditer(text): + chunk = text[pos:m.start()] + if chunk: + self._insert(chunk, fg, bg, bold) + fg, bg, bold = self._apply(m.group(1), fg, bg, bold) + pos = m.end() + tail = text[pos:] + if tail: + self._insert(tail, fg, bg, bold) + + def _insert(self, chunk, fg, bg, bold) -> None: + end = self.buffer.get_end_iter() + tag = self._tag(fg, bg, bold) + if tag is None: + self.buffer.insert(end, chunk) + else: + self.buffer.insert_with_tags(end, chunk, tag) + + @staticmethod + def _apply(params: str, fg, bg, bold): + codes = [int(x) if x else 0 for x in params.split(";")] if params else [0] + i = 0 + while i < len(codes): + c = codes[i] + if c == 0: + fg = bg = None + bold = False + elif c == 1: + bold = True + elif c == 22: + bold = False + elif c == 39: + fg = None + elif c == 49: + bg = None + elif 30 <= c <= 37: + fg = _BASE16[c - 30] + elif 90 <= c <= 97: + fg = _BASE16[c - 90 + 8] + elif 40 <= c <= 47: + bg = _BASE16[c - 40] + elif 100 <= c <= 107: + bg = _BASE16[c - 100 + 8] + elif c in (38, 48): + target = "fg" if c == 38 else "bg" + if i + 1 < len(codes) and codes[i + 1] == 5: + val = _xterm256(codes[i + 2]) if i + 2 < len(codes) else None + i += 2 + elif i + 1 < len(codes) and codes[i + 1] == 2: + val = tuple(codes[i + 2:i + 5]) if i + 4 < len(codes) else None + i += 4 + else: + val = None + if target == "fg": + fg = val + else: + bg = val + i += 1 + return fg, bg, bold diff --git a/desktopenvs/hyprdrive/astro-menu/lib/border.py b/desktopenvs/hyprdrive/astro-menu/lib/border.py new file mode 100644 index 0000000..e653e5f --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/lib/border.py @@ -0,0 +1,86 @@ +"""Draw a rounded border around any widget with a Cairo DrawingArea overlay. + +This GTK build's renderer does not paint CSS border/background nodes on plain +container widgets (Box/Frame) — only on buttons/entries — but it renders Cairo +draw funcs and textures fine (the map image proves it). So module 'borders' are +drawn explicitly here instead of via CSS. +""" + +from __future__ import annotations + +import math + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +# CyberQueer accent/violet (matches @accent/@violet / COLOR_HIGHLIGHT/COLOR_DARK +# in colors.conf). +ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) +VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) +# Module fills are translucent violet rather than solid gray/black — lets the +# hologram overlay's scanlines/noise (drawn above, on window.py's overlay) and +# the desktop behind the panel both read through, so the whole thing looks +# like projected light/glass instead of an opaque card. +FILL_COLOR = VIOLET +FILL_ALPHA = 0.34 + + +def _rounded_rect(cr, x, y, w, h, r) -> None: + r = min(r, w / 2, h / 2) + cr.new_sub_path() + cr.arc(x + w - r, y + r, r, -math.pi / 2, 0) + cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2) + cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi) + cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2) + cr.close_path() + + +# Soft outer glow around the border ring: Cairo has no native blur, so this is +# the usual poor-man's bloom — the same crisp stroke redrawn a few times at +# increasing width and decreasing alpha, underneath the final crisp line. +_GLOW_LAYERS = [(10, 0.05), (6, 0.09), (3, 0.15)] + + +def _make_draw(border: int, radius: int, color, fill_bg: bool, glow: bool): + def draw(_area, cr, w, h, *_a) -> None: + inset = border / 2 + r, g, b = color + if glow: + for extra_w, alpha in _GLOW_LAYERS: + _rounded_rect(cr, inset, inset, w - border, h - border, radius) + cr.set_source_rgba(r, g, b, alpha) + cr.set_line_width(border + extra_w) + cr.stroke() + _rounded_rect(cr, inset, inset, w - border, h - border, radius) + if fill_bg: + cr.set_source_rgba(*FILL_COLOR, FILL_ALPHA) + cr.fill_preserve() + cr.set_source_rgb(*color) + cr.set_line_width(border) + cr.stroke() + return draw + + +def bordered(child: Gtk.Widget, border: int = 3, radius: int = 16, + color=ACCENT, fill_bg: bool = False, glow: bool = True) -> Gtk.Overlay: + """Wrap child in an overlay whose background is a drawn rounded border — + with a soft holographic glow around it by default, matching orbit-menu/ + horizon-dock's aesthetic. + + The DrawingArea is the overlay's main child (drawn first, behind); the content + is an overlay child on top and drives the size. A small margin keeps the content + clear of the drawn border ring. + """ + overlay = Gtk.Overlay() + area = Gtk.DrawingArea() + area.set_draw_func(_make_draw(border, radius, color, fill_bg, glow)) + overlay.set_child(area) # background: the border ring + child.set_margin_start(border + 4) + child.set_margin_end(border + 4) + child.set_margin_top(border + 4) + child.set_margin_bottom(border + 4) + overlay.add_overlay(child) # content on top + overlay.set_measure_overlay(child, True) # size the overlay to the content + return overlay diff --git a/desktopenvs/hyprdrive/astro-menu/lib/hologram.py b/desktopenvs/hyprdrive/astro-menu/lib/hologram.py new file mode 100644 index 0000000..73304f5 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/lib/hologram.py @@ -0,0 +1,124 @@ +"""Holographic scanline/sweep/noise overlay — the same visual treatment as +orbit-menu's hologram effect, adapted for astro-menu's rectangular panel +instead of orbit-menu's circular canvas: no radial vignette mask here, since +the panel's own Cairo-drawn module borders (lib/border.py) already bound it — +covering the full rectangle reads as one continuous "HUD screen" rather than +tinting past its own edges. + +Owns its own animation clock (advanced by window.py's tick callback via +.tick(dt)) and a persistent particle list for the noise specs, exactly like +orbit-menu's _update_hologram_particles: each spec keeps its position/color +for a real randomized lifetime (fade in, hold, fade out) instead of every +spec teleporting to a new position every frame. +""" + +from __future__ import annotations + +import math +import random + +import cairo +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +# Same CyberQueer violet/magenta/red combo as orbit-menu's hologram overlay. +_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) +_MAGENTA = (0.92, 0.0, 0.65) +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) + + +class HologramOverlay: + SCANLINE_GAP = 4.0 + SCANLINE_ALPHA = 0.16 # fixed grid — kept clearly visible, not just a faint texture + SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass + SWEEP_HALF_HEIGHT = 90.0 + NOISE_COUNT = 95 # specs alive at once (each with its own lifetime) + NOISE_COLORS = [_MAGENTA, _ACCENT] + NOISE_LIFETIME = (0.5, 1.4) + NOISE_FADE_IN = 0.2 + NOISE_FADE_OUT = 0.35 + NOISE_ALPHA_RANGE = (0.10, 0.34) + + def __init__(self, enabled: bool = True) -> None: + self.enabled = enabled + self._sat_time = 0.0 + self._particles: list[dict] = [] + + self.widget = Gtk.DrawingArea() + self.widget.set_can_target(False) # never steals clicks from content underneath + self.widget.add_css_class("astro-hologram") + self.widget.set_hexpand(True) + self.widget.set_vexpand(True) + self.widget.set_halign(Gtk.Align.FILL) + self.widget.set_valign(Gtk.Align.FILL) + self.widget.set_draw_func(self._draw_frame) + + def tick(self, dt: float) -> None: + if not self.enabled: + return + self._sat_time += dt + self.widget.queue_draw() + + # -- drawing -------------------------------------------------------------- + def _draw_frame(self, _area, cr, width: float, height: float) -> None: + if not self.enabled or width <= 0 or height <= 0: + return + r, g, b = _VIOLET + + cr.save() + cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA) + cr.set_line_width(1.0) + y = 0.0 + while y < height: + cr.move_to(0, y) + cr.line_to(width, y) + y += self.SCANLINE_GAP + cr.stroke() + cr.restore() + + phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD + sweep_y = phase * height + hh = self.SWEEP_HALF_HEIGHT + grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh) + grad.add_color_stop_rgba(0.0, r, g, b, 0.0) + grad.add_color_stop_rgba(0.5, r, g, b, 0.07) + grad.add_color_stop_rgba(1.0, r, g, b, 0.0) + cr.set_source(grad) + cr.rectangle(0, sweep_y - hh, width, hh * 2) + cr.fill() + + flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0) + cr.set_source_rgba(r, g, b, max(0.0, flicker)) + cr.paint() + + self._draw_noise(cr, width, height) + + def _draw_noise(self, cr, width: float, height: float) -> None: + now = self._sat_time + self._particles = [p for p in self._particles if now - p["birth"] < p["life"]] + while len(self._particles) < self.NOISE_COUNT: + self._particles.append({ + "x": random.uniform(0, width), + "y": random.uniform(0, height), + "w": random.uniform(1.0, 2.6), + "h": random.uniform(1.0, 2.0), + "color": random.choice(self.NOISE_COLORS), + "peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE), + "birth": now, + "life": random.uniform(*self.NOISE_LIFETIME), + }) + + for p in self._particles: + t = (now - p["birth"]) / p["life"] + if t < self.NOISE_FADE_IN: + envelope = t / self.NOISE_FADE_IN + elif t > 1.0 - self.NOISE_FADE_OUT: + envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT) + else: + envelope = 1.0 + r, g, b = p["color"] + cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope) + cr.rectangle(p["x"], p["y"], p["w"], p["h"]) + cr.fill() diff --git a/desktopenvs/hyprdrive/astro-menu/lib/proc.py b/desktopenvs/hyprdrive/astro-menu/lib/proc.py new file mode 100644 index 0000000..34a8924 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/lib/proc.py @@ -0,0 +1,100 @@ +"""Subprocess helpers built on Gio so nothing blocks the GTK main loop. + +The whole backend contract (see backend/*.sh, backend/geolocate.py) is: a command +prints JSON (or plain text) to stdout, diagnostics to stderr, non-zero exit on +failure. These helpers run such commands asynchronously and hand the parsed result +back on the main thread. +""" + +from __future__ import annotations + +import json +import shlex +from typing import Callable, Sequence + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib # noqa: E402 + + +def _as_argv(cmd: Sequence[str] | str) -> list[str]: + return shlex.split(cmd) if isinstance(cmd, str) else list(cmd) + + +def run_text(cmd: Sequence[str] | str, cb: Callable[[bool, str, str], None]) -> None: + """Run cmd, call cb(ok, stdout, stderr) on the main thread when done.""" + try: + proc = Gio.Subprocess.new( + _as_argv(cmd), + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE, + ) + except GLib.Error as err: + GLib.idle_add(cb, False, "", str(err)) + return + + def _done(p: Gio.Subprocess, res: Gio.AsyncResult) -> None: + try: + _, out, errout = p.communicate_utf8_finish(res) + except GLib.Error as err: + cb(False, "", str(err)) + return + cb(p.get_successful(), out or "", errout or "") + + proc.communicate_utf8_async(None, None, _done) + + +def run_json(cmd: Sequence[str] | str, cb: Callable[[bool, object], None]) -> None: + """Run cmd expecting JSON on stdout; call cb(ok, data).""" + + def _text(ok: bool, out: str, err: str) -> None: + if not ok or not out.strip(): + cb(False, err.strip() or "no output") + return + try: + cb(True, json.loads(out)) + except json.JSONDecodeError as exc: + cb(False, f"bad json: {exc}") + + run_text(cmd, _text) + + +class Poller: + """Repeatedly run a command (or callable) on an interval, main-thread callback. + + Used for lightweight state that has no change signal (open ports, public IP, + weather refresh). Modules that back onto an Astal GObject service should prefer + connecting to that service's `notify::` signals instead of polling. + """ + + def __init__(self, interval_s: float, cmd: Sequence[str] | str, cb, json_mode: bool = False): + self.interval_s = interval_s + self.cmd = cmd + self.cb = cb + self.json_mode = json_mode + self._source_id: int | None = None + self._stopped = False + + def start(self) -> "Poller": + self._stopped = False + self._tick() + self._source_id = GLib.timeout_add_seconds(int(self.interval_s), self._tick) + return self + + def _tick(self) -> bool: + if self._stopped: + return GLib.SOURCE_REMOVE + if self.json_mode: + run_json(self.cmd, lambda ok, data: None if self._stopped else self.cb(ok, data)) + else: + run_text(self.cmd, lambda ok, out, err: None if self._stopped else self.cb(ok, out, err)) + return GLib.SOURCE_CONTINUE + + def refresh_now(self) -> None: + self._tick() + + def stop(self) -> None: + self._stopped = True + if self._source_id is not None: + GLib.source_remove(self._source_id) + self._source_id = None diff --git a/desktopenvs/hyprdrive/astro-menu/main.py b/desktopenvs/hyprdrive/astro-menu/main.py new file mode 100755 index 0000000..5674087 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/main.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""astro-menu — a touch-friendly GTK4 popup control centre (Location, Weather, +Bluetooth, Network) plus an application drawer, replacing nwg-dock/nwg-drawer. + +Single-instance: the first launch builds the (hidden) window and holds. Later +invocations forward their arguments to it over D-Bus, so `main.py --toggle` +toggles the running instance without spawning a new process. + + main.py run the resident instance (stays hidden until toggled) + main.py --toggle toggle visibility + main.py --show show | --hide hide | --appdrawer show with drawer open +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# astro-menu-start.sh LD_PRELOADs libgtk4-layer-shell so it loads before +# libwayland-client (a load-ordering requirement of the layer-shell library). That +# only matters at *this* process's exec: the library is already resident now, so the +# variable is never read again. Drop it here so the GUI apps we launch via +# AstalApps.launch() (and the backend subprocesses) don't inherit it — Firefox, for +# one, aborts at startup with libgtk4-layer-shell preloaded. +os.environ.pop("LD_PRELOAD", None) + +# Make sibling modules importable no matter the CWD. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import gi # noqa: E402 + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib, Gtk # noqa: E402 + +import theme # noqa: E402 +from appservices import Services # noqa: E402 +from paths import APP_ID, ensure_dirs # noqa: E402 +from settings import Settings # noqa: E402 +from window import MenuWindow # noqa: E402 + + +class AstalMenuApp(Gtk.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID, + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + self.window: MenuWindow | None = None + + def do_startup(self) -> None: + Gtk.Application.do_startup(self) + ensure_dirs() + theme.load_css() + settings = Settings() + services = Services() + self.window = MenuWindow(self, settings, services) + self._register_actions() + self.hold() # stay alive with no visible window + + def _register_actions(self) -> None: + # Mirrors the --show/--hide/--toggle/--appdrawer verbs from do_command_line, but + # reachable via org.gtk.Actions.Activate. menu-toggle.sh calls these directly with + # `gdbus call` for already-running instances, which skips spawning a whole second + # python3 + GTK process (~0.5s) just to forward one verb over D-Bus. + def add(name: str, callback, has_side: bool) -> None: + action = Gio.SimpleAction.new(name, GLib.VariantType.new("s") if has_side else None) + action.connect("activate", callback) + self.add_action(action) + + def side_of(param: GLib.Variant | None) -> str: + return param.get_string() if param is not None else "" + + def on_show(_action, param) -> None: + assert self.window is not None + if side_of(param): + self.window.set_side(side_of(param)) + self.window.show_menu() + + def on_hide(_action, _param) -> None: + assert self.window is not None + self.window.hide_menu() + + def on_toggle(_action, param) -> None: + assert self.window is not None + if side_of(param): + self.window.set_side(side_of(param)) + self.window.toggle() + + def on_appdrawer(_action, param) -> None: + assert self.window is not None + if side_of(param): + self.window.set_side(side_of(param)) + self.window.show_menu(focus_appdrawer=True) + + add("show", on_show, has_side=True) + add("hide", on_hide, has_side=False) + add("toggle", on_toggle, has_side=True) + add("appdrawer", on_appdrawer, has_side=True) + + def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int: + args = list(cmdline.get_arguments()[1:]) + # Optional "--side top|bottom|left|right": the menu slides in from and pins to + # that monitor edge. Applied before the verb so the surface maps animated. + side = None + if "--side" in args: + i = args.index("--side") + if i + 1 < len(args): + side = args[i + 1] + del args[i:i + 2] + # No args = start (or keep) the resident instance hidden. Only explicit + # verbs change visibility, so the autostart launch never pops the menu. + action = args[0] if args else "--daemon" + if self.window is None: + return 0 + if side: + self.window.set_side(side) + if action == "--show": + self.window.show_menu() + elif action == "--hide": + self.window.hide_menu() + elif action == "--appdrawer": + self.window.show_menu(focus_appdrawer=True) + elif action == "--toggle": + self.window.toggle() + # --daemon and anything else: no-op (stay as-is) + return 0 + + def do_activate(self) -> None: + # Resident instance: nothing to do on plain activate. + pass + + +def main() -> int: + GLib.set_prgname("astro-menu") + return AstalMenuApp().run(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktopenvs/hyprdrive/astro-menu/module_base.py b/desktopenvs/hyprdrive/astro-menu/module_base.py new file mode 100644 index 0000000..e50c1b8 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/module_base.py @@ -0,0 +1,73 @@ +"""The contract every quad module implements. + +Adding a module = drop a file in modules/ that defines a top-level `SPEC` +(a ModuleSpec), then append its import to registry.py. Nothing else needs to +change: enable/disable, feature toggles, the card chrome, and the expand/collapse +plumbing are all provided generically. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, Optional + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + + +@dataclass +class Feature: + """A per-quad on/off switch surfaced in the card's settings popover.""" + id: str + label: str + default: bool = True + + +@dataclass +class ModuleInstance: + """What a module's build() returns.""" + compact: Gtk.Widget # shown in the 2x2 cell + expanded: Optional[Gtk.Widget] = None # shown when the quad is expanded + scroll_expanded: bool = True # wrap the expanded view in a scroller + on_show: Optional[Callable[[], None]] = None + on_hide: Optional[Callable[[], None]] = None + destroy: Optional[Callable[[], None]] = None + + +@dataclass +class ModuleSpec: + id: str + title: str + icon: str # nerd-font glyph + build: Callable[["ModuleContext"], ModuleInstance] + default_enabled: bool = True + features: list[Feature] = field(default_factory=list) + + +class ModuleContext: + """Handed to a module's build(). Scopes settings to the module and exposes the + expand/collapse requests so a module can drive the layout without knowing it.""" + + def __init__(self, spec: ModuleSpec, settings, services, + request_expand: Callable[[str], None], + request_collapse: Callable[[], None]): + self.spec = spec + self.settings = settings + self.services = services + self._request_expand = request_expand + self._request_collapse = request_collapse + + def expand(self) -> None: + self._request_expand(self.spec.id) + + def collapse(self) -> None: + self._request_collapse() + + # feature toggles, scoped to this module + def feature(self, feature_id: str, default: bool = True) -> bool: + return self.settings.feature(self.spec.id, feature_id, default) + + def on_settings_changed(self, cb: Callable[[], None]) -> None: + self.settings.subscribe(cb) diff --git a/desktopenvs/hyprdrive/astro-menu/modules/bluetooth.py b/desktopenvs/hyprdrive/astro-menu/modules/bluetooth.py new file mode 100644 index 0000000..dfb79b5 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/modules/bluetooth.py @@ -0,0 +1,292 @@ +"""Bluetooth quad: powered by AstalBluetooth (bluez wrapper). + +Discovery, connect, disconnect and a local connection history (bluez keeps none, so +we record successful connects in ~/.cache/astro-menu/bt-history.json). 'discovery' +and 'history' are per-quad feature toggles. +""" + +from __future__ import annotations + +import json +import time + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import GLib, Gtk # noqa: E402 + +from lib.proc import run_text +from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec +from paths import CACHE_DIR + +_HISTORY = CACHE_DIR / "bt-history.json" + + +def _load_history() -> list[dict]: + try: + return json.loads(_HISTORY.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return [] + + +def _record_history(address: str, name: str) -> None: + hist = [h for h in _load_history() if h.get("address") != address] + hist.insert(0, {"address": address, "name": name, "ts": int(time.time())}) + CACHE_DIR.mkdir(parents=True, exist_ok=True) + _HISTORY.write_text(json.dumps(hist[:50], indent=2)) + + +class _BluetoothView(Gtk.Box): + def __init__(self, ctx: ModuleContext, full: bool): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.add_css_class("bt-view") + self.ctx = ctx + self.full = full + self.bt = ctx.services.bluetooth + self._recorded: set[str] = set() + self._hooked: set[str] = set() # devices whose state signals we've wired + self._pending: set[str] = set() # addresses with an in-flight connect + self._failed: set[str] = set() # addresses whose last connect failed + + self.append(self._build_header()) + + self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + self._list.add_css_class("bt-list") + if full: + scroller = Gtk.ScrolledWindow(vexpand=True, + hscrollbar_policy=Gtk.PolicyType.NEVER) + scroller.set_child(self._list) + self.append(scroller) + else: + self.append(self._list) + + if self.bt is not None: + self.bt.connect("notify::devices", lambda *_: self._refresh()) + self.bt.connect("notify::is-powered", lambda *_: self._refresh()) + self._refresh() + + def _adapter(self): + return self.bt.get_adapter() if self.bt else None + + def _build_header(self) -> Gtk.Widget: + header = Gtk.CenterBox() + header.add_css_class("bt-header") + left = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + left.append(Gtk.Label(label="Power")) + self._power = Gtk.Switch(active=bool(self.bt and self.bt.get_is_powered()), + valign=Gtk.Align.CENTER) + self._power.connect("state-set", self._on_power) + left.append(self._power) + header.set_start_widget(left) + + if self.full and self.ctx.feature("discovery", True): + self._scan = Gtk.ToggleButton(label=" Scan") + self._scan.add_css_class("quad-action") + self._scan.connect("toggled", self._on_scan) + header.set_end_widget(self._scan) + return header + + def _on_power(self, _sw, value: bool) -> bool: + ad = self._adapter() + if value: + # The adapter is frequently rfkill soft-blocked at boot; while blocked, + # AstalBluetooth.set_powered(True) is a silent no-op. Unblock first, then + # power on (bluez usually auto-powers on unblock, so set_powered is a + # belt-and-suspenders follow-up). + run_text(["rfkill", "unblock", "bluetooth"], + lambda *_a: ad.set_powered(True) if ad else None) + elif ad: + ad.set_powered(False) + return False + + def _on_scan(self, btn: Gtk.ToggleButton) -> None: + ad = self._adapter() + if not ad: + return + if btn.get_active(): + ad.start_discovery() + else: + ad.stop_discovery() + + # -- device list ------------------------------------------------------- + def _refresh(self) -> None: + child = self._list.get_first_child() + while child: + self._list.remove(child) + child = self._list.get_first_child() + if not self.bt: + self._list.append(Gtk.Label(label="No Bluetooth adapter")) + return + + devices = list(self.bt.get_devices()) + devices.sort(key=lambda d: (not d.get_connected(), not d.get_paired(), + (d.get_name() or d.get_address() or "").lower())) + if not self.full: + devices = [d for d in devices if d.get_connected() or d.get_paired()][:4] + for dev in devices: + self._hook_device(dev) + self._list.append(self._device_row(dev)) + + if self.full and self.ctx.feature("history", True): + self._list.append(self._history_section()) + + def _hook_device(self, dev) -> None: + # React to a device's own state changes (adapter-level notify::devices doesn't + # fire for per-device connect/disconnect). Hook once each. + key = dev.get_address() or "" + if key in self._hooked: + return + self._hooked.add(key) + for sig in ("notify::connected", "notify::connecting", "notify::paired"): + dev.connect(sig, lambda *_a, d=dev: self._on_device_state(d)) + + def _on_device_state(self, dev) -> None: + addr = dev.get_address() or "" + if dev.get_connected(): + self._pending.discard(addr) + self._failed.discard(addr) + if self.ctx.feature("history", True): + self._maybe_record(dev, force=True) + elif addr in self._pending and not dev.get_connecting(): + # bluez stopped trying without establishing a link → the connect failed + self._pending.discard(addr) + self._failed.add(addr) + self._refresh() + + def _device_row(self, dev) -> Gtk.Widget: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + row.add_css_class("bt-row") + addr = dev.get_address() or "" + icon = Gtk.Image.new_from_icon_name((dev.get_icon() or "bluetooth") + "-symbolic") + row.append(icon) + name = dev.get_name() or addr or "Unknown" + + connected = dev.get_connected() + connecting = dev.get_connecting() or addr in self._pending + failed = addr in self._failed + + lbl = Gtk.Label(label=name, xalign=0.0, hexpand=True) + row.append(lbl) + + status = ("connected" if connected else "connecting…" if connecting else + "failed" if failed else "paired" if dev.get_paired() else "") + if status: + tag = Gtk.Label(label=status) + tag.add_css_class("bt-status") + if failed: + tag.add_css_class("bt-failed") + row.append(tag) + + actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + if connecting: + spinner = Gtk.Spinner(valign=Gtk.Align.CENTER) + spinner.start() + actions.append(spinner) + actions.append(self._act("Cancel", lambda: self._cancel(dev))) + else: + if dev.get_paired(): + actions.append(self._act("Forget", lambda: self._forget(dev))) + if connected: + actions.append(self._act("Disconnect", + lambda: dev.disconnect_device(None, self._noop))) + else: + actions.append(self._act("Retry" if failed else "Connect", + lambda: self._start_connect(dev))) + row.append(actions) + + if connected and self.ctx.feature("history", True): + self._maybe_record(dev) + return row + + @staticmethod + def _act(label: str, cb) -> Gtk.Button: + b = Gtk.Button(label=label, valign=Gtk.Align.CENTER) + b.add_css_class("quad-action") + b.connect("clicked", lambda *_a: cb()) + return b + + # -- connect / cancel / forget ---------------------------------------- + def _start_connect(self, dev) -> None: + addr = dev.get_address() or "" + self._failed.discard(addr) + self._pending.add(addr) + + def done(d, res): + try: + d.connect_device_finish(res) + except Exception: + a = d.get_address() or "" + if not d.get_connected(): + self._pending.discard(a) + self._failed.add(a) + self._refresh() + dev.connect_device(None, done) + # Backstop: some failures never resolve the async call, so time out. + GLib.timeout_add_seconds(25, lambda: self._connect_timeout(addr)) + self._refresh() + + def _connect_timeout(self, addr: str) -> bool: + if addr in self._pending: + self._pending.discard(addr) + self._failed.add(addr) + self._refresh() + return GLib.SOURCE_REMOVE + + def _cancel(self, dev) -> None: + addr = dev.get_address() or "" + self._pending.discard(addr) + dev.disconnect_device(None, self._noop) # abort the in-flight attempt + self._refresh() + + def _forget(self, dev) -> None: + ad = self._adapter() + addr = dev.get_address() or "" + self._pending.discard(addr) + self._failed.discard(addr) + self._hooked.discard(addr) + if ad: + ad.remove_device(dev) # fires notify::devices → refresh + self._refresh() + + def _maybe_record(self, dev, force: bool = False) -> None: + addr = dev.get_address() or "" + if not addr or (addr in self._recorded and not force): + return + self._recorded.add(addr) + _record_history(addr, dev.get_name() or addr) + + @staticmethod + def _noop(obj, res) -> None: + try: + obj.disconnect_device_finish(res) + except Exception: + pass + + def _history_section(self) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + box.add_css_class("bt-history") + box.append(Gtk.Separator()) + box.append(Gtk.Label(label="History", xalign=0.0)) + for h in _load_history()[:8]: + when = time.strftime("%d.%m %H:%M", time.localtime(h.get("ts", 0))) + box.append(Gtk.Label(label=f"{h.get('name', '?')} · {when}", xalign=0.0)) + return box + + +def build(ctx: ModuleContext) -> ModuleInstance: + # Feature toggles (discovery/history) rebuild the whole card via QuadCard, so + # both views are recreated with the current features — no partial refresh here. + compact = _BluetoothView(ctx, full=False) + expanded = _BluetoothView(ctx, full=True) + return ModuleInstance(compact=compact, expanded=expanded) + + +SPEC = ModuleSpec( + id="bluetooth", + title="Bluetooth", + icon="", # nf-fa-bluetooth + build=build, + default_enabled=True, + features=[Feature("discovery", "Device discovery", True), + Feature("history", "Connection history", True)], +) diff --git a/desktopenvs/hyprdrive/astro-menu/modules/location.py b/desktopenvs/hyprdrive/astro-menu/modules/location.py new file mode 100644 index 0000000..c0387bc --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/modules/location.py @@ -0,0 +1,182 @@ +"""Location quad: a slippy-map image centred on the device's IP-geolocated position, +with a marker. Drag to pan, scroll to zoom, double-click to recentre. + +The position comes from the shared LocationService (backend/geolocate.py), which +traceroutes to 1.1.1.1, takes the first public hop (the ISP egress) and resolves it +through a public geolocation API. The "Locate via IP" feature toggle gates that +lookup entirely; when off, this quad shows a placeholder instead. "Satellite view" +switches the tiles between the dark night map and Esri satellite imagery. + +We render a static map (backend/staticmap.py) rather than an interactive libshumate +map: Shumate does not paint tiles in this environment (the official shumate-demo +shows the same blank map), while tile downloads themselves work fine — so panning is +done by re-rendering at a new centre. The Weather quad consumes the same service. +""" + +from __future__ import annotations + +import math +import sys + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, GLib, Gtk # noqa: E402 + +from lib.proc import run_text +from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec +from paths import BACKEND_DIR, CACHE_DIR + +_SCRIPT = str(BACKEND_DIR / "staticmap.py") +_TILE = 256 + + +def _center_px(lat: float, lon: float, zoom: int) -> tuple[float, float]: + n = 2 ** zoom + y = (1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n + x = (lon + 180.0) / 360.0 * n + return x * _TILE, y * _TILE + + +def _px_latlon(px: float, py: float, zoom: int) -> tuple[float, float]: + n = 2 ** zoom + lon = px / (_TILE * n) * 360.0 - 180.0 + lat = math.degrees(math.atan(math.sinh(math.pi * (1.0 - 2.0 * py / (_TILE * n))))) + return lat, lon + + +class _MapView(Gtk.Box): + def __init__(self, ctx: ModuleContext, zoom: int, size: tuple[int, int], + tag: str, show_info: bool): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("map-view") + self.ctx = ctx + self.zoom = zoom + self.size = size + self._out = str(CACHE_DIR / f"map_{tag}.png") + self._loc: tuple[float, float] | None = None # geolocated position (marker) + self._view: tuple[float, float] | None = None # current view centre + self._drag_from: tuple[float, float] | None = None + self._render_pending = False + + self.picture = Gtk.Picture(content_fit=Gtk.ContentFit.COVER, vexpand=True) + self.picture.add_css_class("map-picture") + self.append(self.picture) + + self._info = None + if show_info: + self._info = Gtk.Label(label="Locating…", xalign=0.0) + self._info.add_css_class("map-info") + self.append(self._info) + + self._wire_gestures() + ctx.services.location.subscribe(self._on_location) + + def _style(self) -> str: + return "satellite" if self.ctx.feature("satellite", False) else "dark" + + # -- data / render ----------------------------------------------------- + def _on_location(self, data: dict) -> None: + self._loc = (data["lat"], data["lon"]) + self._view = (data["lat"], data["lon"]) # recentre on a fresh fix + self._render() + if self._info is not None: + city = data.get("city") or "Unknown" + country = data.get("country") or "" + self._info.set_text(f"{city}, {country} · {data['lat']:.3f}, {data['lon']:.3f}" + f" · drag to pan · scroll to zoom · double-click to recentre") + + def _render(self) -> None: + if self._view is None: + return + vlat, vlon = self._view + mlat, mlon = self._loc or self._view + w, h = self.size + run_text([sys.executable, _SCRIPT, str(vlat), str(vlon), str(self.zoom), + str(w), str(h), self._out, self._style(), str(mlat), str(mlon)], + self._on_rendered) + + def _schedule_render(self) -> None: + # coalesce the flood of drag/scroll updates into ~11 renders/sec + if self._render_pending: + return + self._render_pending = True + + def go() -> bool: + self._render_pending = False + self._render() + return GLib.SOURCE_REMOVE + GLib.timeout_add(90, go) + + def _on_rendered(self, ok: bool, out: str, err: str) -> None: + if not ok: + return + # load a fresh texture; set_filename would ignore an unchanged path on re-pan + try: + self.picture.set_paintable(Gdk.Texture.new_from_filename(self._out)) + except GLib.Error: + self.picture.set_filename(self._out) + + # -- gestures: drag to pan, scroll to zoom, double-click to recentre ---- + def _wire_gestures(self) -> None: + drag = Gtk.GestureDrag() + drag.connect("drag-begin", lambda *_a: setattr(self, "_drag_from", self._view)) + drag.connect("drag-update", self._on_drag) + self.picture.add_controller(drag) + + scroll = Gtk.EventControllerScroll( + flags=Gtk.EventControllerScrollFlags.VERTICAL) + scroll.connect("scroll", self._on_scroll) + self.picture.add_controller(scroll) + + click = Gtk.GestureClick() + click.connect("pressed", self._on_click) + self.picture.add_controller(click) + + def _on_drag(self, _gesture, ox: float, oy: float) -> None: + if self._drag_from is None: + return + cx, cy = _center_px(self._drag_from[0], self._drag_from[1], self.zoom) + # drag right → the view centre moves left (map content follows the cursor) + self._view = _px_latlon(cx - ox, cy - oy, self.zoom) + self._schedule_render() + + def _on_scroll(self, _controller, _dx: float, dy: float) -> bool: + self.zoom = max(3, min(18, self.zoom + (1 if dy < 0 else -1))) + self._schedule_render() + return True + + def _on_click(self, _gesture, n_press: int, _x: float, _y: float) -> None: + if n_press >= 2 and self._loc is not None: + self._view = self._loc + self._schedule_render() + + +def _placeholder() -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, + valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER) + box.add_css_class("map-view") + box.append(Gtk.Label(label="IP location is off")) + return box + + +def build(ctx: ModuleContext) -> ModuleInstance: + # "Locate via IP" gates geolocation entirely: when off we neither traceroute nor + # hit any geolocation API, and just show a placeholder. Toggling it rebuilds the + # card (via QuadCard), so flipping it back on re-triggers the lookup. + if not ctx.feature("ip_locate", True): + return ModuleInstance(compact=_placeholder(), expanded=None) + ctx.services.location.get() # kick off geolocation (traceroute → API) if not started + compact = _MapView(ctx, zoom=12, size=(620, 150), tag="compact", show_info=False) + expanded = _MapView(ctx, zoom=13, size=(1100, 620), tag="expanded", show_info=True) + return ModuleInstance(compact=compact, expanded=expanded, scroll_expanded=False) + + +SPEC = ModuleSpec( + id="location", + title="Location", + icon="", # nf-fa-map_marker + build=build, + features=[Feature("ip_locate", "Locate via IP", True), + Feature("satellite", "Satellite view", False)], +) diff --git a/desktopenvs/hyprdrive/astro-menu/modules/network.py b/desktopenvs/hyprdrive/astro-menu/modules/network.py new file mode 100644 index 0000000..16b631c --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/modules/network.py @@ -0,0 +1,529 @@ +"""Network quad. + +Compact: Wi-Fi enable + primary connection/IP (AstalNetwork + backend/network.sh). +Expanded: a small NetworkManager control panel over backend/nm.py, as four tabs — + Adapters : each device foldout with editable IPv4/IPv6 (DHCP toggle, address, + subnet-mask <-> CIDR, gateway); applied via `nmcli connection modify/up` + Routes : the kernel routing table (read-only; editing kernel routes needs root) + VLAN : list / create / activate / delete VLAN connections + DNS : effective servers per device + a per-connection override table +Every tab is an independently toggle-able feature. +""" + +from __future__ import annotations + +import ipaddress +import sys + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from lib.proc import run_json +from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec +from paths import BACKEND_DIR + +_NET = str(BACKEND_DIR / "network.sh") +_NM = str(BACKEND_DIR / "nm.py") + + +def _nm(args: list[str], cb) -> None: + """Run a backend/nm.py subcommand expecting JSON on stdout.""" + run_json([sys.executable, _NM, *args], cb) + + +def _primary_ip(data) -> str: + if not isinstance(data, list): + return "—" + for iface in data: + if iface.get("ifname") == "lo": + continue + for a in iface.get("addr_info", []): + if a.get("family") == "inet": + return f"{a['local']}/{a.get('prefixlen', '')} ({iface.get('ifname')})" + return "—" + + +# -- subnet mask <-> CIDR prefix ------------------------------------------ +def _mask_to_prefix(mask: str) -> int | None: + try: + return ipaddress.IPv4Network(f"0.0.0.0/{mask.strip()}").prefixlen + except (ipaddress.NetmaskValueError, ValueError): + return None + + +def _prefix_to_mask(prefix: int) -> str: + return str(ipaddress.IPv4Network(f"0.0.0.0/{prefix}").netmask) + + +class _Compact(Gtk.Box): + def __init__(self, ctx: ModuleContext): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.add_css_class("net-view") + self.ctx = ctx + self.net = ctx.services.network + + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self._primary = Gtk.Label(label="…", xalign=0.0, hexpand=True) + row.append(Gtk.Label(label="")) # nf wifi + row.append(self._primary) + wifi = self.net.get_wifi() if self.net else None + self._wifi_switch = Gtk.Switch(active=bool(wifi and wifi.get_enabled()), + valign=Gtk.Align.CENTER) + self._wifi_switch.set_tooltip_text("Wi-Fi") + self._wifi_switch.connect("state-set", self._on_wifi_toggle) + row.append(self._wifi_switch) + self.append(row) + + self._ip = Gtk.Label(label="", xalign=0.0) + self._ip.add_css_class("net-ip") + self.append(self._ip) + + if self.net: + self.net.connect("notify::primary", lambda *_: self.refresh()) + if wifi: + wifi.connect("notify::ssid", lambda *_: self.refresh()) + self.refresh() + + def _on_wifi_toggle(self, _sw, value: bool) -> bool: + wifi = self.net.get_wifi() if self.net else None + if wifi: + wifi.set_enabled(value) + return False + + def refresh(self) -> None: + wifi = self.net.get_wifi() if self.net else None + if wifi and wifi.get_active_access_point(): + self._primary.set_text(f"{wifi.get_ssid() or '?'} · {wifi.get_strength()}%") + elif self.net and self.net.get_wired() and self.net.get_wired().get_internet(): + self._primary.set_text("Wired") + else: + self._primary.set_text("Disconnected") + run_json([_NET, "ip"], lambda ok, d: self._ip.set_text(_primary_ip(d) if ok else "—")) + + +# -- shared little widgets ------------------------------------------------- +def _clear(box: Gtk.Box) -> None: + child = box.get_first_child() + while child: + box.remove(child) + child = box.get_first_child() + + +def _field(placeholder: str, text: str = "") -> Gtk.Entry: + e = Gtk.Entry(placeholder_text=placeholder, text=text, hexpand=True) + e.add_css_class("net-entry") + return e + + +def _labeled(label: str, widget: Gtk.Widget) -> Gtk.Widget: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + lbl = Gtk.Label(label=label, xalign=0.0) + lbl.set_size_request(90, -1) + row.append(lbl) + row.append(widget) + return row + + +def _pill(label: str, cb) -> Gtk.Button: + b = Gtk.Button(label=label) + b.add_css_class("quad-action") + b.connect("clicked", lambda *_: cb()) + return b + + +# -- IPv4 / IPv6 editor for one connection -------------------------------- +class _IPSection(Gtk.Box): + """One address family's editor. IPv4 exposes a linked subnet-mask/CIDR pair; + IPv6 exposes a prefix-length field. Fields are only sensitive in Manual mode.""" + + def __init__(self, con: str, family: str, fam_data: dict, on_result): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self.con = con + self.family = family + self._on_result = on_result + self.add_css_class("net-view") + + title = "IPv4" if family == "ipv4" else "IPv6" + head = Gtk.CenterBox() + head.set_start_widget(Gtk.Label(label=title, xalign=0.0)) + auto = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + auto.append(Gtk.Label(label="Automatic")) + self._dhcp = Gtk.Switch(active=(fam_data.get("method", "auto") != "manual"), + valign=Gtk.Align.CENTER) + self._dhcp.connect("state-set", self._on_mode) + auto.append(self._dhcp) + head.set_end_widget(auto) + self.append(head) + + addr, prefix = self._split(fam_data.get("addresses", [])) + self._addr = _field("Address", addr) + self._gw = _field("Gateway", fam_data.get("gateway", "")) + + self._fields = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self._fields.append(_labeled("Address", self._addr)) + if family == "ipv4": + self._guard = False + self._mask = _field("255.255.255.0", _prefix_to_mask(prefix) if prefix else "") + self._cidr = _field("/24", f"/{prefix}" if prefix else "") + self._cidr.set_size_request(70, -1) + self._mask.connect("changed", self._sync_from_mask) + self._cidr.connect("changed", self._sync_from_cidr) + mask_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + mask_row.append(self._mask) + mask_row.append(self._cidr) + self._fields.append(_labeled("Netmask", mask_row)) + else: + self._prefix = _field("64", str(prefix) if prefix else "") + self._fields.append(_labeled("Prefix", self._prefix)) + self._fields.append(_labeled("Gateway", self._gw)) + apply = Gtk.Button(label=f"Apply {title}") + apply.add_css_class("enable-btn") + apply.connect("clicked", lambda *_: self._apply()) + self._fields.append(apply) + self.append(self._fields) + + self._set_manual_enabled(not self._dhcp.get_active()) + + def _set_manual_enabled(self, on: bool) -> None: + # The reset GTK theme doesn't dim insensitive widgets, so fade the manual + # fields explicitly when DHCP/Automatic is active. + self._fields.set_sensitive(on) + self._fields.set_opacity(1.0 if on else 0.45) + + @staticmethod + def _split(addresses: list[str]) -> tuple[str, int | None]: + if not addresses: + return "", None + addr, _, prefix = addresses[0].partition("/") + try: + return addr, int(prefix) + except ValueError: + return addr, None + + def _on_mode(self, _sw, auto: bool) -> bool: + self._set_manual_enabled(not auto) + return False + + # keep the dotted mask and /CIDR fields in lock-step (IPv4 only) + def _sync_from_mask(self, _e) -> None: + if self._guard: + return + p = _mask_to_prefix(self._mask.get_text()) + if p is not None: + self._guard = True + self._cidr.set_text(f"/{p}") + self._guard = False + + def _sync_from_cidr(self, _e) -> None: + if self._guard: + return + raw = self._cidr.get_text().lstrip("/").strip() + if raw.isdigit() and 0 <= int(raw) <= 32: + self._guard = True + self._mask.set_text(_prefix_to_mask(int(raw))) + self._guard = False + + def _prefixlen(self) -> str: + if self.family == "ipv4": + raw = self._cidr.get_text().lstrip("/").strip() + if raw.isdigit(): + return raw + p = _mask_to_prefix(self._mask.get_text()) + return str(p) if p is not None else "" + return self._prefix.get_text().strip() + + def _apply(self) -> None: + if self._dhcp.get_active(): + args = ["set-ip", self.con, self.family, "auto"] + else: + addr, plen = self._addr.get_text().strip(), self._prefixlen() + cidr = f"{addr}/{plen}" if addr and plen else addr + args = ["set-ip", self.con, self.family, "manual", + cidr, self._gw.get_text().strip(), ""] + _nm(args, lambda ok, d: self._on_result( + ok and isinstance(d, dict) and d.get("ok"), + (d.get("error") if isinstance(d, dict) else "") or "")) + + +class _AdaptersPage(Gtk.Box): + def __init__(self): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self._status = Gtk.Label(xalign=0.0) + self._status.add_css_class("net-ip") + self._status.set_visible(False) + self.append(self._status) + self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) + scroller.set_child(self._list) + self.append(scroller) + self._reload() + + def _reload(self) -> None: + _nm(["adapters"], self._populate) + + def _populate(self, ok: bool, adapters) -> None: + _clear(self._list) + if not ok or not isinstance(adapters, list) or not adapters: + self._list.append(Gtk.Label(label="No manageable adapters", xalign=0.0)) + return + for a in adapters: + self._list.append(self._adapter_row(a)) + + def _adapter_row(self, a: dict) -> Gtk.Widget: + icon = "" if a.get("type") == "wifi" else "" # nf wifi / ethernet + exp = Gtk.Expander( + label=f"{icon} {a['device']} · {a.get('connection') or '—'} [{a.get('state')}]") + exp.add_css_class("net-adapter") + body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + body.set_margin_top(8) + con = a.get("connection") + if not con: + body.append(Gtk.Label(label="No connection profile", xalign=0.0)) + else: + loading = Gtk.Label(label="Loading…", xalign=0.0) + body.append(loading) + _nm(["ipconfig", con], lambda ok, d, b=body, l=loading, c=con: + self._fill(ok, d, b, l, c)) + exp.set_child(body) + return exp + + def _fill(self, ok, data, body, loading, con) -> None: + body.remove(loading) + if not ok or not isinstance(data, dict): + body.append(Gtk.Label(label="Could not read config", xalign=0.0)) + return + result = Gtk.Label(xalign=0.0) + result.set_visible(False) + + def on_result(good: bool, err: str) -> None: + result.set_text("Applied" if good else f"Failed: {err or 'error'}") + result.set_visible(True) + + body.append(_IPSection(con, "ipv4", data.get("ipv4", {}), on_result)) + body.append(Gtk.Separator()) + body.append(_IPSection(con, "ipv6", data.get("ipv6", {}), on_result)) + body.append(result) + + +class _RoutesPage(Gtk.Box): + def __init__(self): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=4) + header = Gtk.CenterBox() + header.set_start_widget(Gtk.Label(label="Kernel routing table", xalign=0.0)) + header.set_end_widget(_pill(" Refresh", self._reload)) + self.append(header) + self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) + scroller.set_child(self._list) + self.append(scroller) + self._reload() + + def _reload(self) -> None: + _nm(["routes"], self._populate) + + def _populate(self, ok: bool, routes) -> None: + _clear(self._list) + if not ok or not isinstance(routes, list) or not routes: + self._list.append(Gtk.Label(label="No routes", xalign=0.0)) + return + for r in routes: + self._list.append(Gtk.Label(label=self._fmt(r), xalign=0.0, + selectable=True, wrap=True)) + + @staticmethod + def _fmt(r: dict) -> str: + dst = r.get("dst", "?") + via = f" via {r['gateway']}" if r.get("gateway") else "" + dev = f" dev {r['dev']}" if r.get("dev") else "" + metric = f" metric {r['metric']}" if r.get("metric") is not None else "" + proto = f" ({r['proto']})" if r.get("proto") else "" + return f"{dst}{via}{dev}{metric}{proto}" + + +class _VlanPage(Gtk.Box): + def __init__(self): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self._adapters: list[dict] = [] + + add = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self._parent = Gtk.DropDown.new_from_strings(["…"]) + self._vid = _field("VLAN ID") + self._vid.set_size_request(90, -1) + add.append(self._parent) + add.append(self._vid) + add.append(_pill("Add", self._add)) + self.append(_labeled("New VLAN", add)) + + self._status = Gtk.Label(xalign=0.0) + self._status.add_css_class("net-ip") + self._status.set_visible(False) + self.append(self._status) + + self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) + scroller.set_child(self._list) + self.append(scroller) + + _nm(["adapters"], self._set_adapters) + self._reload() + + def _set_adapters(self, ok, adapters) -> None: + self._adapters = adapters if ok and isinstance(adapters, list) else [] + names = [a["device"] for a in self._adapters] or ["(no devices)"] + self._parent.set_model(Gtk.StringList.new(names)) + + def _reload(self) -> None: + _nm(["vlans"], self._populate) + + def _populate(self, ok, vlans) -> None: + _clear(self._list) + if not ok or not isinstance(vlans, list) or not vlans: + self._list.append(Gtk.Label(label="No VLANs configured", xalign=0.0)) + return + for v in vlans: + self._list.append(self._vlan_row(v)) + + def _vlan_row(self, v: dict) -> Gtk.Widget: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.add_css_class("net-row") + state = "active" if v.get("active") else "inactive" + row.append(Gtk.Label(label=f"{v['name']} · {v.get('device') or '—'} [{state}]", + xalign=0.0, hexpand=True)) + if not v.get("active"): + row.append(_pill("Up", lambda n=v["name"]: self._act("vlan-up", n))) + row.append(_pill("Delete", lambda n=v["name"]: self._act("vlan-del", n))) + return row + + def _add(self) -> None: + idx = self._parent.get_selected() + vid = self._vid.get_text().strip() + if idx < 0 or idx >= len(self._adapters) or not vid.isdigit(): + self._flash("Pick a parent device and numeric VLAN ID") + return + parent = self._adapters[idx]["device"] + _nm(["vlan-add", parent, vid], self._after_write) + + def _act(self, cmd: str, name: str) -> None: + _nm([cmd, name], self._after_write) + + def _after_write(self, ok, d) -> None: + good = ok and isinstance(d, dict) and d.get("ok") + self._flash("Done" if good else + f"Failed: {(d.get('error') if isinstance(d, dict) else '') or 'error'}") + self._reload() + + def _flash(self, msg: str) -> None: + self._status.set_text(msg) + self._status.set_visible(True) + + +class _DnsPage(Gtk.Box): + def __init__(self): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self._adapters: list[dict] = [] + + self.append(Gtk.Label(label="Effective servers", xalign=0.0)) + self._effective = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + self.append(self._effective) + + self.append(Gtk.Separator()) + self.append(Gtk.Label(label="Override", xalign=0.0)) + self._conn = Gtk.DropDown.new_from_strings(["…"]) + self.append(_labeled("Connection", self._conn)) + self._servers = _field("1.1.1.1 8.8.8.8") + self.append(_labeled("Servers", self._servers)) + ignore = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + ignore.append(Gtk.Label(label="Ignore automatic", hexpand=True, xalign=0.0)) + self._ignore = Gtk.Switch(active=True, valign=Gtk.Align.CENTER) + ignore.append(self._ignore) + self.append(ignore) + self.append(_pill("Apply DNS", self._apply)) + self._status = Gtk.Label(xalign=0.0) + self._status.add_css_class("net-ip") + self._status.set_visible(False) + self.append(self._status) + + _nm(["adapters"], self._set_adapters) + _nm(["dns"], self._show_effective) + + def _set_adapters(self, ok, adapters) -> None: + self._adapters = [a for a in (adapters or []) if a.get("connection")] \ + if ok and isinstance(adapters, list) else [] + names = [f"{a['connection']} ({a['device']})" for a in self._adapters] or ["(none)"] + self._conn.set_model(Gtk.StringList.new(names)) + + def _show_effective(self, ok, data) -> None: + _clear(self._effective) + if not ok or not isinstance(data, list) or not data: + self._effective.append(Gtk.Label(label="—", xalign=0.0)) + return + for d in data: + self._effective.append(Gtk.Label( + label=f"{d['device']}: {', '.join(d.get('servers', []))}", + xalign=0.0, selectable=True)) + + def _apply(self) -> None: + idx = self._conn.get_selected() + if idx < 0 or idx >= len(self._adapters): + self._flash("Pick a connection") + return + con = self._adapters[idx]["connection"] + servers = self._servers.get_text().strip() + ign = "yes" if self._ignore.get_active() else "no" + _nm(["set-dns", con, "ipv4", servers, ign], self._after) + + def _after(self, ok, d) -> None: + good = ok and isinstance(d, dict) and d.get("ok") + self._flash("Applied" if good else + f"Failed: {(d.get('error') if isinstance(d, dict) else '') or 'error'}") + _nm(["dns"], self._show_effective) + + def _flash(self, msg: str) -> None: + self._status.set_text(msg) + self._status.set_visible(True) + + +class _Expanded(Gtk.Box): + def __init__(self, ctx: ModuleContext): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.add_css_class("net-view") + self.ctx = ctx + + self.stack = Gtk.Stack(vexpand=True) + switcher = Gtk.StackSwitcher(stack=self.stack) + switcher.add_css_class("net-switcher") + self.append(switcher) + self.append(self.stack) + + f = ctx.feature + if f("adapters", True): + self.stack.add_titled(_AdaptersPage(), "adapters", "Adapters") + if f("routes", True): + self.stack.add_titled(_RoutesPage(), "routes", "Routes") + if f("vlan", True): + self.stack.add_titled(_VlanPage(), "vlan", "VLAN") + if f("dns", True): + self.stack.add_titled(_DnsPage(), "dns", "DNS") + + +def build(ctx: ModuleContext) -> ModuleInstance: + # Feature toggles rebuild the whole card via QuadCard, so _Expanded is recreated + # with the enabled tabs — no in-place page rebuild needed here. + compact = _Compact(ctx) + expanded = _Expanded(ctx) + return ModuleInstance(compact=compact, expanded=expanded) + + +SPEC = ModuleSpec( + id="network", + title="Network", + icon="", # nf-md-lan + build=build, + default_enabled=True, + features=[Feature("adapters", "Adapters", True), + Feature("routes", "Routes", True), + Feature("vlan", "VLAN", True), + Feature("dns", "DNS", True)], +) diff --git a/desktopenvs/hyprdrive/astro-menu/modules/weather.py b/desktopenvs/hyprdrive/astro-menu/modules/weather.py new file mode 100644 index 0000000..2ebe8bd --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/modules/weather.py @@ -0,0 +1,72 @@ +"""Weather quad: wttr.in rendered with its original ANSI/CLI art (via AnsiRenderer). + +Reuses the shared LocationService for the city; if none is known yet, wttr.in +geolocates the caller's IP itself, so the widget still works standalone. The +'ascii_art' feature toggle swaps the art for a compact one-line text summary. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from lib.ansi import AnsiRenderer +from lib.proc import run_text +from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec +from paths import BACKEND_DIR + +_SCRIPT = str(BACKEND_DIR / "weather.sh") + + +class _WeatherView(Gtk.Box): + def __init__(self, ctx: ModuleContext, opts: str): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("weather-view") + self.ctx = ctx + self.opts = opts + self._loc = "" + + self.renderer = AnsiRenderer() + self.append(self.renderer.view) + self._status = Gtk.Label(label="Loading weather…") + self._status.add_css_class("weather-status") + self.append(self._status) + + ctx.services.location.subscribe(self._on_location) + self.refresh() + + def _on_location(self, data: dict) -> None: + self._loc = data.get("city") or "" + self.refresh() + + def refresh(self) -> None: + art = self.ctx.feature("ascii_art", True) + opts = self.opts if art else "format=%l:+%c+%t,+%w" + run_text([_SCRIPT, self._loc, opts], self._on_result) + + def _on_result(self, ok: bool, out: str, err: str) -> None: + if ok and out.strip() and " ModuleInstance: + # The ascii_art toggle rebuilds the whole card via QuadCard, so both views are + # recreated and re-fetch with the current format — no partial refresh here. + compact = _WeatherView(ctx, opts="0") # current conditions only + expanded = _WeatherView(ctx, opts="") # full 3-day forecast + return ModuleInstance(compact=compact, expanded=expanded) + + +SPEC = ModuleSpec( + id="weather", + title="Weather", + icon="", # nf-weather + build=build, + features=[Feature("ascii_art", "CLI art", True)], +) diff --git a/desktopenvs/hyprdrive/astro-menu/paths.py b/desktopenvs/hyprdrive/astro-menu/paths.py new file mode 100644 index 0000000..fdce17d --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/paths.py @@ -0,0 +1,28 @@ +"""Shared filesystem locations. Works whether run from the repo or ~/.config.""" + +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +BACKEND_DIR = BASE_DIR / "backend" +STYLE_DIR = BASE_DIR / "style" + +CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "astro-menu" +CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astro-menu" +# User settings live under XDG_STATE_HOME, NOT CONFIG_DIR: the config-updater does +# `rm -rf ~/.config/astro-menu` on every deploy, which would wipe pinned favourites +# and quad toggles. The state dir is never touched by config sync. +STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "astro-menu" + +SETTINGS_FILE = STATE_DIR / "settings.json" +CONFIG_FILE = STATE_DIR / "config.json" + +APP_ID = "eu.abdelbaki.astromenu" + + +def ensure_dirs() -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + CACHE_DIR.mkdir(parents=True, exist_ok=True) + STATE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/desktopenvs/hyprdrive/astro-menu/registry.py b/desktopenvs/hyprdrive/astro-menu/registry.py new file mode 100644 index 0000000..77e5a4e --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/registry.py @@ -0,0 +1,29 @@ +"""The module registry — the single place that lists available quads in order. + +To add a new module: create modules/.py exposing a top-level `SPEC` +(ModuleSpec), then import it and append its SPEC here. The first four *enabled* +specs fill the 2x2 grid; extra specs are kept for future paginated layouts. +""" + +from __future__ import annotations + +from modules import bluetooth, location, network, weather +from module_base import ModuleSpec + +ALL_SPECS: list[ModuleSpec] = [ + location.SPEC, + weather.SPEC, + bluetooth.SPEC, + network.SPEC, +] + + +def ordered_specs(settings) -> list[ModuleSpec]: + """Return specs in the user's saved order, unknown/new ones appended.""" + order = settings.order() + if not order: + return list(ALL_SPECS) + by_id = {s.id: s for s in ALL_SPECS} + result = [by_id[i] for i in order if i in by_id] + result += [s for s in ALL_SPECS if s.id not in order] + return result diff --git a/desktopenvs/hyprdrive/astro-menu/services/location.py b/desktopenvs/hyprdrive/astro-menu/services/location.py new file mode 100644 index 0000000..88735df --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/services/location.py @@ -0,0 +1,55 @@ +"""Shared geolocation singleton. + +Runs backend/geolocate.py (IP geolocation, cached) exactly once and notifies +subscribers. Both the Location map and the Weather widget subscribe, so there is a +single source of truth for "where am I" and only one network lookup. +""" + +from __future__ import annotations + +import sys +from typing import Callable, Optional + +from lib.proc import run_json +from paths import BACKEND_DIR + + +class LocationService: + def __init__(self) -> None: + self.data: Optional[dict] = None + self._subs: list[Callable[[dict], None]] = [] + self._inflight = False + + def subscribe(self, cb: Callable[[dict], None]) -> None: + self._subs.append(cb) + if self.data is not None: + cb(self.data) + + def get(self) -> Optional[dict]: + if self.data is None and not self._inflight: + self.refresh() + return self.data + + def refresh(self) -> None: + if self._inflight: + return + self._inflight = True + argv = [sys.executable, str(BACKEND_DIR / "geolocate.py")] + run_json(argv, self._on_result) + + def _on_result(self, ok: bool, data) -> None: + self._inflight = False + if ok and isinstance(data, dict) and "lat" in data: + self.data = data + for cb in list(self._subs): + cb(data) + + +_instance: LocationService | None = None + + +def get_location_service() -> LocationService: + global _instance + if _instance is None: + _instance = LocationService() + return _instance diff --git a/desktopenvs/hyprdrive/astro-menu/settings.py b/desktopenvs/hyprdrive/astro-menu/settings.py new file mode 100644 index 0000000..ff564c8 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/settings.py @@ -0,0 +1,83 @@ +"""Persisted user settings: which quads are enabled, per-module feature toggles, +and quad ordering. Single JSON file, read at startup, written on every change. + +Modules never import this directly for their own feature flags; they receive a +scoped view via ctx.feature(...) so the persistence format stays centralised. +""" + +from __future__ import annotations + +import json +from typing import Callable + +from paths import CONFIG_DIR, SETTINGS_FILE, ensure_dirs + + +class Settings: + def __init__(self) -> None: + self._data: dict = {"quads": {}, "features": {}, "order": None, "favorites": []} + self._listeners: list[Callable[[], None]] = [] + self.load() + + # -- persistence ------------------------------------------------------- + def load(self) -> None: + path = SETTINGS_FILE + if not path.exists(): + # one-time migration from the old CONFIG_DIR location (pre state-dir) + legacy = CONFIG_DIR / "settings.json" + if legacy.exists(): + path = legacy + try: + self._data.update(json.loads(path.read_text())) + except (FileNotFoundError, json.JSONDecodeError): + pass + + def save(self) -> None: + ensure_dirs() + SETTINGS_FILE.write_text(json.dumps(self._data, indent=2)) + for cb in list(self._listeners): + cb() + + def subscribe(self, cb: Callable[[], None]) -> None: + self._listeners.append(cb) + + # -- quad enable/disable ---------------------------------------------- + def quad_enabled(self, module_id: str, default: bool = True) -> bool: + return bool(self._data["quads"].get(module_id, default)) + + def set_quad_enabled(self, module_id: str, value: bool) -> None: + self._data["quads"][module_id] = bool(value) + self.save() + + # -- per-module feature toggles --------------------------------------- + def feature(self, module_id: str, feature_id: str, default: bool = True) -> bool: + return bool(self._data["features"].get(module_id, {}).get(feature_id, default)) + + def set_feature(self, module_id: str, feature_id: str, value: bool) -> None: + self._data["features"].setdefault(module_id, {})[feature_id] = bool(value) + self.save() + + # -- favorites --------------------------------------------------------- + def favorites(self) -> list[str]: + return list(self._data.get("favorites", [])) + + def is_favorite(self, entry: str) -> bool: + return entry in self._data.get("favorites", []) + + def toggle_favorite(self, entry: str) -> None: + if not entry: + return + favs = self._data.setdefault("favorites", []) + if entry in favs: + favs.remove(entry) + else: + favs.append(entry) + self.save() + + # -- ordering ---------------------------------------------------------- + def order(self) -> list[str] | None: + return self._data.get("order") + + def set_order(self, order: list[str]) -> None: + self._data["order"] = order + self.save() diff --git a/desktopenvs/hyprdrive/astro-menu/style/_colors.css b/desktopenvs/hyprdrive/astro-menu/style/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/style/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/desktopenvs/hyprdrive/astro-menu/style/style.css b/desktopenvs/hyprdrive/astro-menu/style/style.css new file mode 100644 index 0000000..b25703e --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/style/style.css @@ -0,0 +1,332 @@ +/* astro-menu — "hologram info display" theme. Colours come from _colors.css + * (@text/@bg/@accent/@violet/@danger). Mirrors the existing bar idiom: Agave + * Nerd Font Mono, 3px borders, ~25px pill radii. Touch-friendly hit targets + * throughout. Module card borders get a Cairo-drawn glow (lib/border.py); + * this file adds matching CSS box-shadow glow + smooth transitions on every + * interactive element, and a scanline/sweep/noise hologram overlay (see + * .astro-hologram, painted by window.py) so the whole panel reads as one + * continuous piece with orbit-menu and horizon-dock. */ + +* { + font-family: "Agave Nerd Font Mono", monospace; + font-size: 13pt; +} + +/* ---- window / letterbox --------------------------------------------- */ +/* The CyberQueer GTK theme paints `* { background-color: #1a1a1a }` on EVERY node, + * which fills the gaps between modules with a solid slab. Each module carries its + * own drawn (Cairo) card background via bordered(fill_bg=True), so blank the + * structural container nodes here to let the desktop show between the floating + * modules. `drawingarea` is the bordered() ring canvas — transparent so only its + * Cairo-drawn rounded fill shows, not a full-bleed square. */ +window, +window.background, +.menu-window, +#menu-window, +.panel, +#panel-root, +overlay, +revealer, +grid, +scrolledwindow, +viewport, +drawingarea { + background: transparent; + background-color: transparent; +} + +#backdrop { + background: alpha(black, 0.25); +} + +/* Letterbox margins are set in code (window.py) as a fraction of the monitor so + * the inset scales per display; this box just carries the panel background. */ + +/* ---- quad grid + cards ---------------------------------------------- */ +.quad-grid.dimmed { + opacity: 0.15; +} + +/* bouncy pop when a quad expands (added in ui/quadgrid.py on expand) */ +@keyframes quad-pop { + 0% { transform: scale(0.82); opacity: 0.4; } + 55% { transform: scale(1.05); opacity: 1; } + 78% { transform: scale(0.985); } + 100% { transform: scale(1.0); } +} +.quad-pop { animation: quad-pop 300ms ease-out; } + +/* bouncy pop-expand for the app drawer (added in ui/appdrawer.py on expand) */ +@keyframes drawer-pop { + 0% { transform: scale(0.90); opacity: 0.45; } + 60% { transform: scale(1.025); opacity: 1; } + 82% { transform: scale(0.995); } + 100% { transform: scale(1.0); } +} +.drawer-pop { animation: drawer-pop 280ms ease-out; } + +.quad-card { min-height: 100px; } +.section-title { color: @text; font-weight: bold; opacity: 0.85; } + +/* The single module border + background is drawn with Cairo by bordered(fill_bg=True) + * (see lib/border.py); the CSS border/background here would render a second, + * concentric ring now that the app stylesheet sits above the theme, so only keep + * the inner content padding. */ +.quad-card, +.quad-expanded, +.appdrawer, +.taskbar, +.favorites { + padding: 8px 12px; +} + +.quad-header, +.appdrawer-header, +.expanded-header { + margin-bottom: 8px; +} + +.quad-title { color: @text; font-weight: bold; } +.quad-icon { color: @accent; font-size: 15pt; } + +/* minimalist system-stats line (top of the panel) */ +.statsbar { padding: 19px 8px; } +.statsbar .stat-icon { color: @accent; font-size: 11pt; } +.statsbar .stat-value { color: @text; font-size: 10pt; } + +.quad-body { color: @text; } + +/* pill action buttons */ +.quad-action, +.enable-btn { + color: @text; + background: alpha(@violet, 0.4); + border: 3px solid @violet; + border-radius: 25px; + padding: 1px 14px; /* tight vertically so pills don't waste vertical space */ + min-height: 22px; + min-width: 22px; + transition: border-color 180ms ease, color 180ms ease, box-shadow 220ms ease, background 180ms ease; + box-shadow: 0 0 0 0 alpha(@accent, 0); +} +.quad-action:hover, +.enable-btn:hover { + border-color: @accent; + color: @accent; + box-shadow: 0 0 12px 1px alpha(@accent, 0.55); +} +.quad-action:active, +button:checked.quad-action { background: @accent; color: @bg; border-color: @accent; } + +/* Columns count stepper value (between the − / + pills) */ +.stepper-value { + min-width: 34px; + font-weight: bold; + color: @accent; + font-feature-settings: "tnum"; +} + +/* A MenuButton (the settings cog) wraps an inner `button` node that would render a + * second ring inside the outer .quad-action pill — flatten it so only one border + * shows. */ +.quad-action > button { + border: none; + background: transparent; + padding: 0; + min-width: 0; + min-height: 0; +} + +.quad-disabled { color: @text; opacity: 0.7; } +.enable-btn { border-color: @accent; } + +/* settings popover */ +.quad-settings { background: alpha(@violet, 0.4); border: 3px solid @violet; border-radius: 16px; padding: 10px; } +.switch-row { min-height: 36px; } + +.expanded-header .quad-title { font-size: 14pt; } + +/* ---- appdrawer ------------------------------------------------------ */ +.appdrawer-search { + border: 3px solid @violet; + border-radius: 25px; + padding: 8px 14px; + margin-bottom: 8px; + color: @text; + background: alpha(@violet, 0.4); + transition: border-color 180ms ease, box-shadow 220ms ease; + box-shadow: 0 0 0 0 alpha(@accent, 0); +} +.appdrawer-search:focus-within { + border-color: @accent; + box-shadow: 0 0 14px 1px alpha(@accent, 0.45); +} + +.app-tile { + background: transparent; + border: 2px solid transparent; + border-radius: 16px; + padding: 10px 6px; + min-width: 92px; + transition: border-color 180ms ease, background 180ms ease, box-shadow 220ms ease; + box-shadow: 0 0 0 0 alpha(@accent, 0); +} +.app-tile:hover { + border-color: @accent; + background: alpha(@violet, 0.18); + box-shadow: 0 0 14px 1px alpha(@accent, 0.4); +} +.app-tile label { color: @text; font-size: 10pt; } +.fav-star { color: @accent; font-size: 13pt; margin: 2px 4px; } + +/* ---- favourites row (top of drawer) --------------------------------- */ +.favorites-row { padding: 4px 0; } +.fav-tile { + background: alpha(@violet, 0.4); + border: 2px solid @violet; + border-radius: 16px; + padding: 6px 12px; + min-height: 40px; + transition: border-color 180ms ease, box-shadow 220ms ease; + box-shadow: 0 0 0 0 alpha(@violet, 0); +} +.fav-tile:hover { + border-color: @accent; + box-shadow: 0 0 12px 1px alpha(@accent, 0.5); +} +.fav-tile label { color: @text; font-size: 11pt; } + +/* ---- taskbar (open windows) ----------------------------------------- */ +.taskbar-row { padding: 4px 0; } +.task-tile { + background: transparent; + border: 2px solid transparent; + border-radius: 14px; + padding: 6px 10px; + min-height: 44px; + min-width: 44px; + transition: border-color 180ms ease, background 180ms ease, box-shadow 220ms ease; + box-shadow: 0 0 0 0 alpha(@accent, 0); +} +.task-tile:hover { + border-color: @accent; + background: alpha(@violet, 0.18); + box-shadow: 0 0 14px 1px alpha(@accent, 0.4); +} +.task-badge { + color: @bg; background: @accent; + border-radius: 10px; padding: 0 6px; font-size: 9pt; +} +.task-popover { background: alpha(@violet, 0.4); border: 3px solid @violet; border-radius: 14px; padding: 6px; } +.task-window { color: @text; background: transparent; border-radius: 10px; padding: 6px 12px; } +.task-window:hover { color: @accent; } + +/* pop-open panel: layout controls + per-window rows */ +.task-panel { padding: 4px 2px; } +.layout-page { padding: 6px 4px 2px 4px; } +.task-row { padding: 2px 4px; border-radius: 10px; min-height: 30px; } +.task-row:hover { background: alpha(@violet, 0.12); } +.task-name { background: transparent; border: none; padding: 2px 6px; min-height: 26px; } +.task-name:hover { color: @accent; } +.task-name label { color: @text; } + +/* ---- location map --------------------------------------------------- */ +.map-view { border-radius: 14px; } +.map-info { color: @text; padding: 6px 2px 0 2px; font-size: 11pt; } +.map-marker { color: @accent; } + +/* ---- weather -------------------------------------------------------- */ +.ansi-view, +.ansi-view text { + background: alpha(@violet, 0.4); + color: @text; + font-family: "Agave Nerd Font Mono", monospace; + font-size: 11pt; + padding: 4px; +} +.weather-status { color: @text; opacity: 0.7; } + +/* ---- bluetooth / network shared rows -------------------------------- */ +.bt-row, +.net-row { + padding: 4px 6px; + border-radius: 12px; + min-height: 30px; + transition: background 180ms ease; +} +.bt-row:hover, +.net-row:hover { background: alpha(@violet, 0.15); } +.bt-status { color: @accent; font-size: 10pt; } +.bt-status.bt-failed { color: @danger; } +.bt-history label, +.net-ip { color: @text; opacity: 0.85; font-size: 11pt; } + +.net-switcher { margin-bottom: 8px; } +.net-adapter { padding: 4px 0; } +.net-adapter > box { padding-left: 6px; } +.net-entry { + border: 2px solid @violet; + border-radius: 12px; + padding: 6px 10px; + color: @text; + background: alpha(@violet, 0.4); + transition: border-color 180ms ease, box-shadow 220ms ease; + box-shadow: 0 0 0 0 alpha(@accent, 0); +} +.net-entry:focus-within { + border-color: @accent; + box-shadow: 0 0 12px 1px alpha(@accent, 0.45); +} +.pubip { color: @accent; font-size: 15pt; } + +/* switches: fixed pill with a bordered round knob (the reset theme leaves them + * shapeless otherwise) */ +switch { + min-width: 48px; + min-height: 26px; + border: 2px solid @violet; + border-radius: 15px; + background: alpha(@violet, 0.4); + padding: 0; + transition: background 180ms ease, border-color 180ms ease, box-shadow 220ms ease; +} +switch:checked { + background: @accent; + border-color: @accent; + box-shadow: 0 0 10px 1px alpha(@accent, 0.5); +} +switch > slider { + min-width: 18px; + min-height: 18px; + margin: 2px; + border-radius: 50%; + border: 1px solid @violet; + background: @text; +} +switch:checked > slider { border-color: @accent; } +scrollbar slider { background: @violet; border-radius: 8px; min-width: 6px; } +scrollbar slider:hover { background: @accent; } + +/* module frames — Gtk.Frame paints borders reliably where boxes do not */ +.module-frame { border: 3px solid @accent; border-radius: 16px; background: alpha(@violet, 0.4); } +.module-frame > box { padding: 10px 12px; } + +/* floating panel + close button */ +.panel { padding: 2px; } +.close-btn { + color: @text; background: alpha(@violet, 0.4); + border: none; border-radius: 20px; + min-width: 34px; min-height: 34px; + margin: 16px 20px; /* keep it clear of the module's drawn border */ + transition: background 180ms ease, color 180ms ease, box-shadow 220ms ease; +} +.close-btn:hover { + background: @accent; + color: @bg; + box-shadow: 0 0 14px 2px alpha(@accent, 0.55); +} + +/* ---- holographic overlay (see window.py's Gtk.Overlay + _draw_hologram) --- */ +.astro-hologram { + background: transparent; +} diff --git a/desktopenvs/hyprdrive/astro-menu/theme.py b/desktopenvs/hyprdrive/astro-menu/theme.py new file mode 100644 index 0000000..6b732a5 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/theme.py @@ -0,0 +1,35 @@ +"""Load the two stylesheets as ordered CSS providers. + +_colors.css (generated from ~/Dotfiles/colors.conf by apply-theme.sh) defines the +five CyberQueer @define-color names; style.css consumes them. They are loaded as +two separate providers rather than via @import, because GTK4 resolves @import +paths unreliably. + +Priority is USER+1, not APPLICATION: the CyberQueer GTK theme is installed as +~/.config/gtk-4.0/gtk.css (a symlink), which GTK4 loads at PRIORITY_USER (800) — +above APPLICATION (600). Its aggressive `* { background-color: #1a1a1a }` would +otherwise beat our rules (e.g. the transparent structural containers that let the +modules float), so we must sit just above the user-level theme. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +from paths import STYLE_DIR + + +def load_css() -> None: + display = Gdk.Display.get_default() + for name in ("_colors.css", "style.css"): + path = STYLE_DIR / name + if not path.exists(): + continue + provider = Gtk.CssProvider() + provider.load_from_path(str(path)) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1 + ) diff --git a/desktopenvs/hyprdrive/astro-menu/ui/appdrawer.py b/desktopenvs/hyprdrive/astro-menu/ui/appdrawer.py new file mode 100644 index 0000000..b23b0fb --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/ui/appdrawer.py @@ -0,0 +1,175 @@ +"""Section 5: the full-width application drawer that replaces nwg-drawer. + +Top to bottom: a favourites row (full-width module), then search, then a FlowBox of +all apps. Collapsed it is a bottom strip; expanded it fills down to the bottom. +Right-click / long-press an app tile to pin or unpin it from favourites. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("AstalApps", "0.1") +from gi.repository import AstalApps, GLib, Gtk # noqa: E402 + +from ui.favorites import Favorites, add_pin_gestures + +_STRIP_HEIGHT = 150 # collapsed grid height (logical px) + + +class AppDrawer(Gtk.Box): + def __init__(self, settings, on_launch, on_toggle_expand): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.add_css_class("appdrawer") + self.settings = settings + self._on_launch = on_launch + self._on_toggle_expand = on_toggle_expand + self._apps = AstalApps.Apps() + self._expanded = False + + self.favorites = Favorites(settings, on_launch) + self.append(self.favorites) + self.append(Gtk.Separator()) + + self.append(self._build_header()) + + self._search = Gtk.SearchEntry(placeholder_text="Search applications") + self._search.add_css_class("appdrawer-search") + self._search.connect("search-changed", self._on_search) + self.append(self._search) + + self._flow = Gtk.FlowBox( + selection_mode=Gtk.SelectionMode.NONE, homogeneous=True, + min_children_per_line=4, max_children_per_line=12, + row_spacing=8, column_spacing=8, + valign=Gtk.Align.START) # keep rows their natural height (no stretch) + self._flow.add_css_class("appdrawer-flow") + self._flow.connect("child-activated", self._on_child_activated) + + self._scroll = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True) + self._scroll.set_propagate_natural_height(False) + self._scroll.set_child(self._flow) + self.append(self._scroll) + + self._apply_mode() + self._populate(self._sorted_all()) + # re-render tiles when favourites change so the pinned star stays in sync + settings.subscribe(self._on_settings_changed) + + def _on_settings_changed(self) -> None: + # Deferred so we never rebuild the FlowBox from inside a tile's own gesture. + GLib.idle_add(lambda: (self._on_search(self._search), False)[1]) + + def _build_header(self) -> Gtk.Widget: + header = Gtk.CenterBox() + header.add_css_class("appdrawer-header") + title = Gtk.Label(label="Applications", xalign=0.0) + title.add_css_class("section-title") + header.set_start_widget(title) + self._expand_btn = Gtk.Button(label="") # nf-fa-expand (uniform with quad expand) + self._expand_btn.add_css_class("quad-action") + self._expand_btn.set_tooltip_text("Expand") + self._expand_btn.connect("clicked", lambda *_: self._toggle_expand()) + header.set_end_widget(self._expand_btn) + return header + + # -- expansion --------------------------------------------------------- + def _apply_mode(self) -> None: + if self._expanded: + self.set_vexpand(True) + self._scroll.set_min_content_height(_STRIP_HEIGHT) + self._scroll.set_max_content_height(100000) + self._expand_btn.set_label("") # nf-fa-compress + else: + self.set_vexpand(False) + self._scroll.set_min_content_height(_STRIP_HEIGHT) + self._scroll.set_max_content_height(_STRIP_HEIGHT) + self._expand_btn.set_label("") # nf-fa-expand + + def _pop(self) -> None: + # bouncy scale pop on the app grid when the drawer expands; cleared after the + # animation so a re-layout (tiles loading) can't restart it into a loop. + self._scroll.add_css_class("drawer-pop") + GLib.timeout_add(320, self._clear_pop) + + def _clear_pop(self) -> bool: + self._scroll.remove_css_class("drawer-pop") + return GLib.SOURCE_REMOVE + + def _toggle_expand(self) -> None: + self._expanded = not self._expanded + self._apply_mode() + if self._expanded: + self._pop() + self._on_toggle_expand(self._expanded) + + def set_expanded(self, value: bool) -> None: + if value != self._expanded: + self._toggle_expand() + + # -- data -------------------------------------------------------------- + def _sorted_all(self) -> list: + apps = list(self._apps.get_list()) + apps.sort(key=lambda a: (-a.get_frequency(), a.get_name().lower())) + return apps + + def _on_search(self, entry: Gtk.SearchEntry) -> None: + text = entry.get_text().strip() + results = self._apps.fuzzy_query(text) if text else self._sorted_all() + self._populate(results) + + def _populate(self, apps: list) -> None: + child = self._flow.get_first_child() + while child: + self._flow.remove(child) + child = self._flow.get_first_child() + for app in apps: + self._flow.append(self._app_button(app)) + + def _app_button(self, app) -> Gtk.Widget: + btn = Gtk.Button(valign=Gtk.Align.START) + btn.add_css_class("app-tile") + btn.app = app + overlay = Gtk.Overlay() + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + icon = Gtk.Image.new_from_icon_name(app.get_icon_name() or "application-x-executable") + icon.set_pixel_size(48) + label = Gtk.Label(label=app.get_name(), ellipsize=3, max_width_chars=12, + justify=Gtk.Justification.CENTER) + content.append(icon) + content.append(label) + overlay.set_child(content) + # a star marks pinned apps (visible only when favourited) + star = Gtk.Label(label="★") + star.add_css_class("fav-star") + star.set_halign(Gtk.Align.END) + star.set_valign(Gtk.Align.START) + star.set_visible(self.settings.is_favorite(app.get_entry())) + overlay.add_overlay(star) + btn.set_child(overlay) + btn.connect("clicked", lambda *_: self._launch(app)) + add_pin_gestures(btn, lambda: self._toggle_fav(app)) + return btn + + def _toggle_fav(self, app) -> None: + self.settings.toggle_favorite(app.get_entry()) + + def _on_child_activated(self, _flow, child) -> None: + btn = child.get_child() + if btn and getattr(btn, "app", None): + self._launch(btn.app) + + def _launch(self, app) -> None: + try: + app.launch() + except Exception: + pass + self._on_launch() + + def on_show(self) -> None: + self._search.set_text("") + self._populate(self._sorted_all()) + self.favorites.refresh() + self._search.grab_focus() diff --git a/desktopenvs/hyprdrive/astro-menu/ui/favorites.py b/desktopenvs/hyprdrive/astro-menu/ui/favorites.py new file mode 100644 index 0000000..d303adc --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/ui/favorites.py @@ -0,0 +1,106 @@ +"""Full-width favourites row at the top of the app drawer. + +Shows pinned apps (settings["favorites"], a list of .desktop entry ids). If nothing +is pinned yet it falls back to the most-frequently-launched apps. Pin/unpin from the +drawer grid via right-click / long-press. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("AstalApps", "0.1") +from gi.repository import AstalApps, Gtk # noqa: E402 + + +def add_pin_gestures(widget: Gtk.Widget, on_toggle) -> None: + """Wire right-click and long-press on `widget` to pin/unpin. + + Uses the CAPTURE phase and claims the sequence so the gesture fires reliably on a + Gtk.Button (whose own primary-click gesture would otherwise swallow it) and does + not also trigger the button's launch action.""" + def fire(gesture, *_a) -> None: + on_toggle() + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + + rclick = Gtk.GestureClick(button=3) + rclick.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + rclick.connect("pressed", fire) + widget.add_controller(rclick) + + longpress = Gtk.GestureLongPress() + longpress.set_touch_only(False) + longpress.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + longpress.connect("pressed", fire) + widget.add_controller(longpress) + + +class Favorites(Gtk.Box): + def __init__(self, settings, on_launch): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("favorites") + self.settings = settings + self._on_launch = on_launch + self._apps = AstalApps.Apps() + + title = Gtk.Label(label="Favorites", xalign=0.0) + title.add_css_class("section-title") + self.append(title) + + self._row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self._row.add_css_class("favorites-row") + scroller = Gtk.ScrolledWindow( + vscrollbar_policy=Gtk.PolicyType.NEVER, + hscrollbar_policy=Gtk.PolicyType.AUTOMATIC) + scroller.set_child(self._row) + self.append(scroller) + + settings.subscribe(self.refresh) + self.refresh() + + def _by_entry(self) -> dict: + return {a.get_entry(): a for a in self._apps.get_list() if a.get_entry()} + + def _resolve(self) -> list: + by_entry = self._by_entry() + entries = self.settings.favorites() + if entries: + return [by_entry[e] for e in entries if e in by_entry] + # fallback: most-used apps + apps = sorted(self._apps.get_list(), key=lambda a: -a.get_frequency()) + return [a for a in apps if a.get_frequency() > 0][:8] + + def refresh(self) -> None: + child = self._row.get_first_child() + while child: + self._row.remove(child) + child = self._row.get_first_child() + apps = self._resolve() + if not apps: + self._row.append(Gtk.Label(label="Right-click an app below to pin it", + xalign=0.0)) + return + for app in apps: + self._row.append(self._tile(app)) + + def _tile(self, app) -> Gtk.Widget: + btn = Gtk.Button() + btn.add_css_class("fav-tile") + content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + icon = Gtk.Image.new_from_icon_name(app.get_icon_name() or "application-x-executable") + icon.set_pixel_size(28) + content.append(icon) + content.append(Gtk.Label(label=app.get_name(), ellipsize=3, max_width_chars=14)) + btn.set_child(content) + btn.connect("clicked", lambda *_a: self._launch(app)) + # right-click / long-press unpins + add_pin_gestures(btn, lambda: self.settings.toggle_favorite(app.get_entry())) + return btn + + def _launch(self, app) -> None: + try: + app.launch() + except Exception: + pass + self._on_launch() diff --git a/desktopenvs/hyprdrive/astro-menu/ui/quadcard.py b/desktopenvs/hyprdrive/astro-menu/ui/quadcard.py new file mode 100644 index 0000000..93b1eb8 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/ui/quadcard.py @@ -0,0 +1,158 @@ +"""Generic chrome around any module: a header (icon, title, expand, settings) and +a body that is either the module's compact widget or an 'enable me' placeholder. + +A disabled quad never calls the module's build(), so a disabled Bluetooth/Network +quad spawns no backend work at all. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from module_base import ModuleContext, ModuleInstance, ModuleSpec + + +class QuadCard(Gtk.Box): + def __init__(self, spec: ModuleSpec, settings, services, + request_expand, request_collapse): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("quad-card") + self.spec = spec + self.settings = settings + self.services = services + self.ctx = ModuleContext(spec, settings, services, request_expand, request_collapse) + self.instance: ModuleInstance | None = None + + self._header = self._build_header() + self.append(self._header) + + self._body_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._body_holder.add_css_class("quad-body") + self._body_holder.set_vexpand(True) + self.append(self._body_holder) + + self._features: dict[str, bool] = {} + self._rebuild_body() + + # -- header ------------------------------------------------------------ + def _build_header(self) -> Gtk.Widget: + header = Gtk.CenterBox() + header.add_css_class("quad-header") + + title = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + icon = Gtk.Label(label=self.spec.icon) + icon.add_css_class("quad-icon") + name = Gtk.Label(label=self.spec.title, xalign=0.0) + name.add_css_class("quad-title") + title.append(icon) + title.append(name) + header.set_start_widget(title) + + actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + self._expand_btn = Gtk.Button(label="") # nf-fa-expand + self._expand_btn.add_css_class("quad-action") + self._expand_btn.set_tooltip_text("Expand") + self._expand_btn.connect("clicked", lambda *_: self.ctx.expand()) + actions.append(self._build_settings_button()) + actions.append(self._expand_btn) + header.set_end_widget(actions) + return header + + def _build_settings_button(self) -> Gtk.Widget: + btn = Gtk.MenuButton(label="") # nf-fa-cog + btn.add_css_class("quad-action") + btn.set_tooltip_text("Settings") + pop = Gtk.Popover() + pop.add_css_class("quad-settings") + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + + box.append(self._switch_row("Enabled", self.settings.quad_enabled( + self.spec.id, self.spec.default_enabled), + lambda v: self.settings.set_quad_enabled(self.spec.id, v))) + + for feat in self.spec.features: + box.append(Gtk.Separator()) + box.append(self._switch_row( + feat.label, self.settings.feature(self.spec.id, feat.id, feat.default), + lambda v, fid=feat.id: self.settings.set_feature(self.spec.id, fid, v))) + + pop.set_child(box) + btn.set_popover(pop) + return btn + + @staticmethod + def _switch_row(label: str, value: bool, on_change) -> Gtk.Widget: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + row.add_css_class("switch-row") + lbl = Gtk.Label(label=label, xalign=0.0, hexpand=True) + sw = Gtk.Switch(active=value, valign=Gtk.Align.CENTER) + sw.connect("state-set", lambda _sw, v: (on_change(v), False)[1]) + row.append(lbl) + row.append(sw) + return row + + # -- body -------------------------------------------------------------- + def _clear_body(self) -> None: + if self.instance and self.instance.destroy: + self.instance.destroy() + self.instance = None + child = self._body_holder.get_first_child() + while child: + self._body_holder.remove(child) + child = self._body_holder.get_first_child() + + def _rebuild_body(self) -> None: + self._clear_body() + self._features = self._feature_snapshot() + enabled = self.settings.quad_enabled(self.spec.id, self.spec.default_enabled) + if enabled: + inst = self.spec.build(self.ctx) + self.instance = inst + self._body_holder.append(inst.compact) + self._expand_btn.set_sensitive(inst.expanded is not None) + else: + self._expand_btn.set_sensitive(False) + self._body_holder.append(self._disabled_placeholder()) + + def _feature_snapshot(self) -> dict[str, bool]: + return {f.id: self.settings.feature(self.spec.id, f.id, f.default) + for f in self.spec.features} + + def _disabled_placeholder(self) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10, + valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER) + box.add_css_class("quad-disabled") + box.append(Gtk.Label(label=f"{self.spec.title} is off")) + btn = Gtk.Button(label="Enable") + btn.add_css_class("enable-btn") + btn.connect("clicked", + lambda *_: self.settings.set_quad_enabled(self.spec.id, True)) + box.append(btn) + return box + + # -- lifecycle --------------------------------------------------------- + def on_settings_changed(self) -> None: + """Called by the grid when settings.json changes; rebuild if this quad's + enablement flipped or any of its feature toggles changed, so every switch in + the settings popover takes effect live (the popover is only reachable while + the grid is collapsed, so no expanded view is ever reparented mid-rebuild).""" + enabled = self.settings.quad_enabled(self.spec.id, self.spec.default_enabled) + has_module = self.instance is not None + features_changed = enabled and self._feature_snapshot() != self._features + if enabled != has_module or features_changed: + self._rebuild_body() + + @property + def expanded_widget(self) -> Gtk.Widget | None: + return self.instance.expanded if self.instance else None + + def on_show(self) -> None: + if self.instance and self.instance.on_show: + self.instance.on_show() + + def on_hide(self) -> None: + if self.instance and self.instance.on_hide: + self.instance.on_hide() diff --git a/desktopenvs/hyprdrive/astro-menu/ui/quadgrid.py b/desktopenvs/hyprdrive/astro-menu/ui/quadgrid.py new file mode 100644 index 0000000..90b707b --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/ui/quadgrid.py @@ -0,0 +1,191 @@ +"""The 2x2 quad region and its expand-over-the-others behaviour. + +A Gtk.Overlay stacks two things in the same space: + * base : a 2x2 Gtk.Grid of QuadCards + * overlay: a Revealer that, when a quad expands, fills the whole region (the full + content width, covering all four cells) with that module's expanded + view wrapped in a small header carrying a collapse button. + +Because the overlay fills the region exactly, the expanded quad is as wide as the +appdrawer below it, and the outer letterbox margins (applied further up the tree) +are untouched — so letterboxing stays identical in every state. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import GLib, Gtk # noqa: E402 + +from lib.border import bordered +from module_base import ModuleSpec +from ui.quadcard import QuadCard + + +class QuadGrid(Gtk.Overlay): + def __init__(self, specs: list[ModuleSpec], settings, services): + super().__init__() + self.add_css_class("quad-region") + self.set_vexpand(True) + self.settings = settings + self._expanded_id: str | None = None + + self.grid = Gtk.Grid(column_homogeneous=True, row_homogeneous=True, + column_spacing=10, row_spacing=10) + self.grid.add_css_class("quad-grid") + self.set_child(self.grid) + + self.cards: dict[str, QuadCard] = {} + for index, spec in enumerate(specs[:4]): + card = QuadCard(spec, settings, services, + self.request_expand, self.request_collapse) + self.cards[spec.id] = card + self.grid.attach(bordered(card, radius=16, fill_bg=True), + index % 2, index // 2, 1, 1) + + # overlay used for the expanded quad + self._expand_reveal = Gtk.Revealer( + transition_type=Gtk.RevealerTransitionType.CROSSFADE, + transition_duration=180, reveal_child=False) + self._expand_reveal.set_halign(Gtk.Align.FILL) + self._expand_reveal.set_valign(Gtk.Align.FILL) + self._expand_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._expand_holder.add_css_class("quad-expanded") + self._expand_card = bordered(self._expand_holder, radius=16, fill_bg=True) + self._expand_reveal.set_child(self._expand_card) + self.add_overlay(self._expand_reveal) + # The overlay's holder is opaque; keep the whole overlay hidden unless a quad + # is actually expanded, otherwise it paints over the 2x2 grid. + self._expand_reveal.set_visible(False) + + # A second overlay: an external "takeover" widget (the taskbar's workspace/ + # window panel) that, when shown, covers the whole quad region. + self._takeover_reveal = Gtk.Revealer( + transition_type=Gtk.RevealerTransitionType.CROSSFADE, + transition_duration=180, reveal_child=False) + self._takeover_reveal.set_halign(Gtk.Align.FILL) + self._takeover_reveal.set_valign(Gtk.Align.FILL) + self._takeover_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._takeover_holder.add_css_class("quad-expanded") + self._takeover_card = bordered(self._takeover_holder, radius=16, fill_bg=True) + self._takeover_reveal.set_child(self._takeover_card) + self.add_overlay(self._takeover_reveal) + self._takeover_reveal.set_visible(False) + + settings.subscribe(self._on_settings_changed) + + # -- external takeover (taskbar panel) --------------------------------- + def set_takeover_widget(self, widget: Gtk.Widget, on_back) -> None: + """Mount an external widget (once) that will cover the quad region when shown. + A Back row is prepended so the covered quads can be restored.""" + header = Gtk.CenterBox() + header.add_css_class("expanded-header") + back = Gtk.Button(label="") # nf-fa-compress (unfullscreen) + back.add_css_class("quad-action") + back.set_tooltip_text("Collapse") + back.connect("clicked", lambda *_: on_back()) + header.set_start_widget(back) + self._takeover_holder.append(header) + widget.set_vexpand(True) + self._takeover_holder.append(widget) + + def show_takeover(self) -> None: + self._takeover_reveal.set_visible(True) + self._takeover_card.add_css_class("quad-pop") # same bouncy scale as a quad + GLib.timeout_add(340, lambda: (self._takeover_card.remove_css_class("quad-pop"), + GLib.SOURCE_REMOVE)[1]) + self._takeover_reveal.set_reveal_child(True) + self.grid.add_css_class("dimmed") + + def hide_takeover(self) -> None: + self._takeover_reveal.set_reveal_child(False) + self._takeover_reveal.set_visible(False) + self.grid.remove_css_class("dimmed") + + # -- expansion --------------------------------------------------------- + def request_expand(self, module_id: str) -> None: + card = self.cards.get(module_id) + if not card or card.instance is None or card.instance.expanded is None: + return + # A module opts into expansion by supplying a distinct expanded widget + # (a separate instance), so no reparenting of the compact cell is needed. + content = card.instance.expanded + # reparent content into the expanded holder + self._clear_expand_holder() + self._expand_reveal.set_visible(True) + self._expand_holder.append(self._expanded_header(card.spec.title)) + if card.instance.scroll_expanded: + wrap = Gtk.ScrolledWindow(vexpand=True, + hscrollbar_policy=Gtk.PolicyType.NEVER) + wrap.set_child(content) + self._expand_holder.append(wrap) + else: + content.set_vexpand(True) + self._expand_holder.append(content) + self._expanded_id = module_id + self._expand_reveal.set_reveal_child(True) + # bouncy scale pop (CSS @keyframes). Keep the class only for the animation's + # duration, then drop it: while it's applied, any re-layout of the card (module + # content streaming in, the grid updating underneath) restarts the transform + # animation, which made the pop loop forever. + self._expand_card.add_css_class("quad-pop") + GLib.timeout_add(340, self._clear_pop) + self.grid.add_css_class("dimmed") + card.on_show() + + def _clear_pop(self) -> bool: + self._expand_card.remove_css_class("quad-pop") + return GLib.SOURCE_REMOVE + + def request_collapse(self) -> None: + self._expand_reveal.set_reveal_child(False) + self._expand_reveal.set_visible(False) + self._expand_card.remove_css_class("quad-pop") # reset so the pop replays + self._expanded_id = None + self.grid.remove_css_class("dimmed") + # Drop the reference to the reparented widget so the card can reuse it. + self._clear_expand_holder() + + def _clear_expand_holder(self) -> None: + child = self._expand_holder.get_first_child() + while child: + # detach any ScrolledWindow's child so it survives for the card + if isinstance(child, Gtk.ScrolledWindow): + inner = child.get_child() + if inner: + child.set_child(None) + self._expand_holder.remove(child) + child = self._expand_holder.get_first_child() + + def _expanded_header(self, title: str) -> Gtk.Widget: + header = Gtk.CenterBox() + header.add_css_class("expanded-header") + back = Gtk.Button(label="") # nf-fa-compress (unfullscreen) + back.add_css_class("quad-action") + back.set_tooltip_text("Collapse") + back.connect("clicked", lambda *_: self.request_collapse()) + header.set_start_widget(back) + lbl = Gtk.Label(label=title) + lbl.add_css_class("quad-title") + header.set_center_widget(lbl) + return header + + @property + def is_expanded(self) -> bool: + return self._expanded_id is not None + + # -- lifecycle --------------------------------------------------------- + def _on_settings_changed(self) -> None: + for card in self.cards.values(): + card.on_settings_changed() + + def on_show(self) -> None: + for card in self.cards.values(): + card.on_show() + + def on_hide(self) -> None: + if self.is_expanded: + self.request_collapse() + for card in self.cards.values(): + card.on_hide() diff --git a/desktopenvs/hyprdrive/astro-menu/ui/statsbar.py b/desktopenvs/hyprdrive/astro-menu/ui/statsbar.py new file mode 100644 index 0000000..b9f6e15 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/ui/statsbar.py @@ -0,0 +1,133 @@ +"""A minimalist system-stats line for the top of the panel. + +CPU / RAM / GPU / disk utilisation plus network up/down rate, refreshed on a timer. +Everything is read straight from /proc and /sys on the main thread — these are local +file reads that take microseconds, so no subprocess or worker thread is needed. +""" + +from __future__ import annotations + +import glob +import os +import time + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import GLib, Gtk # noqa: E402 + +_GPU_FILES = glob.glob("/sys/class/drm/card*/device/gpu_busy_percent") +_NET_SKIP = ("lo", "docker", "veth", "br-", "virbr", "tun", "tap") + + +def _human(rate: float) -> str: + for unit in ("B", "K", "M", "G"): + if rate < 1024 or unit == "G": + return f"{rate:4.1f}{unit}" + rate /= 1024 + return f"{rate:4.1f}G" + + +class Statsbar(Gtk.Box): + def __init__(self): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=18, + halign=Gtk.Align.CENTER) + self.add_css_class("statsbar") + + # short text labels rather than nerd glyphs (some don't exist in the font and + # render as tofu); keeps the line legible on any theme. + self._cpu = self._cell("CPU") + self._ram = self._cell("MEM") + self._gpu = self._cell("GPU") + self._disk = self._cell("DISK") + self._down = self._cell("↓") + self._up = self._cell("↑") + + self._prev_cpu: tuple[int, int] | None = None + self._prev_net: tuple[int, int] | None = None + self._prev_t: float | None = None + + self._refresh() + self._source = GLib.timeout_add_seconds(2, self._tick) + + def _cell(self, icon: str) -> Gtk.Label: + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + ic = Gtk.Label(label=icon) + ic.add_css_class("stat-icon") + val = Gtk.Label(label="…") + val.add_css_class("stat-value") + box.append(ic) + box.append(val) + self.append(box) + return val + + def _tick(self) -> bool: + self._refresh() + return GLib.SOURCE_CONTINUE + + # -- readers ----------------------------------------------------------- + @staticmethod + def _cpu_pct(prev) -> tuple[float, tuple[int, int]]: + parts = [int(x) for x in open("/proc/stat").readline().split()[1:]] + idle = parts[3] + parts[4] # idle + iowait + total = sum(parts) + if prev is None: + return 0.0, (total, idle) + dt, di = total - prev[0], idle - prev[1] + pct = (1 - di / dt) * 100 if dt > 0 else 0.0 + return max(0.0, min(100.0, pct)), (total, idle) + + @staticmethod + def _ram_pct() -> float: + mem = {} + for line in open("/proc/meminfo"): + k, _, v = line.partition(":") + mem[k] = int(v.split()[0]) + if len(mem) > 4 and "MemAvailable" in mem: + break + total, avail = mem.get("MemTotal", 1), mem.get("MemAvailable", 0) + return (1 - avail / total) * 100 + + @staticmethod + def _gpu_pct() -> float: + best = 0 + for f in _GPU_FILES: + try: + best = max(best, int(open(f).read().strip())) + except (OSError, ValueError): + pass + return float(best) + + @staticmethod + def _disk_pct() -> float: + s = os.statvfs("/") + return (1 - s.f_bfree / s.f_blocks) * 100 if s.f_blocks else 0.0 + + @staticmethod + def _net_bytes() -> tuple[int, int]: + rx = tx = 0 + with open("/proc/net/dev") as fh: + for line in fh.readlines()[2:]: + name, _, rest = line.partition(":") + name = name.strip() + if name.startswith(_NET_SKIP): + continue + cols = rest.split() + rx += int(cols[0]) + tx += int(cols[8]) + return rx, tx + + def _refresh(self) -> None: + cpu, self._prev_cpu = self._cpu_pct(self._prev_cpu) + self._cpu.set_text(f"{cpu:2.0f}%") + self._ram.set_text(f"{self._ram_pct():2.0f}%") + self._gpu.set_text(f"{self._gpu_pct():2.0f}%") + self._disk.set_text(f"{self._disk_pct():2.0f}%") + + now = time.monotonic() + rx, tx = self._net_bytes() + if self._prev_net is not None and self._prev_t is not None and now > self._prev_t: + dt = now - self._prev_t + self._down.set_text(_human((rx - self._prev_net[0]) / dt) + "/s") + self._up.set_text(_human((tx - self._prev_net[1]) / dt) + "/s") + self._prev_net, self._prev_t = (rx, tx), now diff --git a/desktopenvs/hyprdrive/astro-menu/ui/taskbar.py b/desktopenvs/hyprdrive/astro-menu/ui/taskbar.py new file mode 100644 index 0000000..f6abbaa --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/ui/taskbar.py @@ -0,0 +1,477 @@ +"""Full-width taskbar strip: jump to any open window, plus an expand-over panel. + +Compact: app-grouped icons; click focuses the window (single) or opens a pop-out of +instances (grouped). The ⤢ toggle collapses this compact strip and hands its panel +(exposed as `panel_widget`) to the menu window, which mounts it over the quad region +so it *covers* the 2x2 quads rather than pushing anything down. The panel holds: + * workspace/layout controls — pick the current workspace's layout (scrolling / + dwindle / master / monocle, enumerated from ~/.cache/astro-menu/layouts.json, + written by hypr/layouts) and, for directional layouts, its direction. Applied + live via `hyprctl eval 'layouts.set(ws, name, dir)'`. + * a per-window row list: [icon + title → focus/jump] [⇤ pull to this workspace]. +""" + +from __future__ import annotations + +import json + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("AstalApps", "0.1") +from gi.repository import AstalApps, Gtk # noqa: E402 + +from lib.proc import run_json, run_text +from paths import CACHE_DIR + +_LAYOUTS_MANIFEST = CACHE_DIR / "layouts.json" +_COLUMNS_STATE = CACHE_DIR / "columns-state.json" +_LAYOUTS_STATE = CACHE_DIR / "layouts-state.json" + + +def _eval(lua: str) -> None: + run_text(["hyprctl", "eval", lua], lambda *_a: None) + + +def _dispatch(lua: str) -> None: + # In hyprlua, `hyprctl dispatch` evaluates its argument as Lua (the hl.dsp.* API). + run_text(["hyprctl", "dispatch", lua], lambda *_a: None) + + +class Taskbar(Gtk.Box): + def __init__(self, on_activate, on_toggle_panel=None): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self.add_css_class("taskbar") + self._on_activate = on_activate + self._on_toggle_panel = on_toggle_panel # window mounts the panel over the quads + self._apps = AstalApps.Apps() + self._wm_index = self._build_wm_index() + self._layouts = self._load_layouts() + self._active_ws = None + self._clients: list = [] + self._dir_dds = {} + self._fit_sws = {} + + # The workspace/window panel. It is NOT appended here: the menu window mounts + # `panel_widget` into the quad region so that expanding COLLAPSES this strip's + # body (below) and the panel COVERS the 2x2 quads instead of pushing anything. + self._panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self._panel.add_css_class("task-panel") + self._panel_scroll = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True) + self._panel_scroll.set_child(self._panel) + + header = Gtk.CenterBox() + title = Gtk.Label(label="Open windows", xalign=0.0) + title.add_css_class("section-title") + header.set_start_widget(title) + self._expand_btn = Gtk.ToggleButton(label="") # nf-fa-expand + self._expand_btn.add_css_class("quad-action") + self._expand_btn.set_tooltip_text("Workspace & window controls") + self._expand_btn.connect("toggled", lambda b: self._toggle_panel(b.get_active())) + header.set_end_widget(self._expand_btn) + self.append(header) + + # compact icon strip (hidden while the panel is expanded) + self._row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self._row.add_css_class("taskbar-row") + self._strip = Gtk.ScrolledWindow( + vscrollbar_policy=Gtk.PolicyType.NEVER, + hscrollbar_policy=Gtk.PolicyType.AUTOMATIC) + self._strip.set_child(self._row) + self.append(self._strip) + + # -- expand-over-the-quads panel -------------------------------------- + @property + def panel_widget(self) -> Gtk.Widget: + """The workspace/window panel body; the window mounts this over the quads.""" + return self._panel_scroll + + def _toggle_panel(self, active: bool) -> None: + # Collapse this strip's compact body; the window reveals/hides the panel that + # it has mounted over the quad region (via on_toggle_panel). + self._strip.set_visible(not active) + if self._on_toggle_panel: + self._on_toggle_panel(active) + if active: + self.refresh() + + def collapse_panel(self) -> None: + """Return to the compact strip (called on menu hide / from the panel's Back).""" + if self._expand_btn.get_active(): + self._expand_btn.set_active(False) # fires toggled -> _toggle_panel(False) + else: + self._strip.set_visible(True) + + # -- setup helpers ----------------------------------------------------- + def _build_wm_index(self) -> dict: + idx = {} + for app in self._apps.get_list(): + for key in (app.get_wm_class(), app.get_executable(), app.get_name()): + if key: + idx.setdefault(key.lower(), app) + return idx + + def _icon_for(self, cls: str) -> str: + app = self._wm_index.get((cls or "").lower()) + if app and app.get_icon_name(): + return app.get_icon_name() + return (cls or "application-x-executable").lower() + + @staticmethod + def _load_layouts() -> list: + try: + return json.loads(_LAYOUTS_MANIFEST.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + # fallback if hypr/layouts hasn't written the manifest yet + return [{"name": "scrolling", "label": "Scrolling", "directional": True, + "dirs": ["down", "up", "right", "left"], "default_dir": "down", + "fit_method": True}, + {"name": "columns", "label": "Columns", "directional": True, + "dirs": ["right", "down"], "dir_labels": ["Left / Right", "Up / Down"], + "default_dir": "right", "fit_method": True}, + {"name": "dwindle", "label": "Dwindle", "directional": False, "dirs": []}, + {"name": "master", "label": "Master", "directional": False, "dirs": []}, + {"name": "monocle", "label": "Monocle", "directional": False, "dirs": []}] + + # -- populate ---------------------------------------------------------- + def refresh(self) -> None: + run_json(["hyprctl", "clients", "-j"], self._on_clients) + run_json(["hyprctl", "activeworkspace", "-j"], self._on_ws) + + def _on_ws(self, ok: bool, data) -> None: + if ok and isinstance(data, dict): + self._active_ws = data.get("id") + self._rebuild_panel() + + def _on_clients(self, ok: bool, data) -> None: + self._clients = data if ok and isinstance(data, list) else [] + self._rebuild_strip() + self._rebuild_panel() + + # -- compact strip ----------------------------------------------------- + def _rebuild_strip(self) -> None: + self._clear(self._row) + groups: dict[str, list] = {} + for w in self._clients: + if not w.get("mapped", True) or not w.get("class"): + continue + groups.setdefault(w["class"], []).append(w) + if not groups: + self._row.append(Gtk.Label(label="No open windows")) + return + for cls, wins in sorted(groups.items()): + self._row.append(self._group_button(cls, wins)) + + def _group_button(self, cls: str, wins: list) -> Gtk.Widget: + icon = Gtk.Image.new_from_icon_name(self._icon_for(cls)) + icon.set_pixel_size(32) + if len(wins) == 1: + btn = Gtk.Button() + btn.add_css_class("task-tile") + btn.set_child(icon) + btn.set_tooltip_text(wins[0].get("title") or cls) + btn.connect("clicked", lambda *_a, w=wins[0]: self._focus(w)) + return btn + btn = Gtk.MenuButton() + btn.add_css_class("task-tile") + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + box.append(icon) + badge = Gtk.Label(label=str(len(wins))) + badge.add_css_class("task-badge") + box.append(badge) + btn.set_child(box) + btn.set_tooltip_text(f"{cls} ({len(wins)})") + pop = Gtk.Popover() + pop.add_css_class("task-popover") + plist = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + for w in wins: + item = Gtk.Button(label=w.get("title") or cls) + item.add_css_class("task-window") + item.connect("clicked", lambda *_a, ww=w: (pop.popdown(), self._focus(ww))) + plist.append(item) + pop.set_child(plist) + btn.set_popover(pop) + return btn + + # -- pop-open panel: layout controls + window list --------------------- + def _rebuild_panel(self) -> None: + self._clear(self._panel) + + # tabbed layout selector for the active workspace: a tab per layout, and a + # per-layout options page underneath that switches with the tab. Selecting a + # tab applies that layout to the currently focused workspace. + ws = self._active_ws + hdr = Gtk.Label(label=f"Workspace {ws if ws is not None else '?'} layout", xalign=0.0) + hdr.add_css_class("section-title") + self._panel.append(hdr) + + self._dir_dds = {} # layout name -> (Gtk.DropDown, [dir values]) + self._fit_sws = {} # layout name -> Gtk.Switch + self._layout_stack = Gtk.Stack() + for ly in self._layouts: + self._layout_stack.add_titled(self._layout_page(ly), ly["name"], ly["label"]) + switcher = Gtk.StackSwitcher(stack=self._layout_stack) + switcher.add_css_class("net-switcher") # reuse the tab pill styling + self._panel.append(switcher) + self._panel.append(self._layout_stack) + self._layout_stack.connect("notify::visible-child-name", self._on_tab_switch) + self._sync_layout_controls() + + self._panel.append(Gtk.Separator()) + + # per-window rows: [focus/jump] [pull here] + wins = [w for w in self._clients if w.get("mapped", True) and w.get("class")] + wins.sort(key=lambda w: (w.get("workspace", {}).get("id", 0), + (w.get("title") or w.get("class") or "").lower())) + if not wins: + self._panel.append(Gtk.Label(label="No open windows", xalign=0.0)) + return + listing = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) + scroller.set_child(listing) + for w in wins: + listing.append(self._window_row(w)) + self._panel.append(scroller) + + def _window_row(self, w) -> Gtk.Widget: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.add_css_class("task-row") + icon = Gtk.Image.new_from_icon_name(self._icon_for(w.get("class", ""))) + icon.set_pixel_size(22) + wsid = w.get("workspace", {}).get("id") + title = (w.get("title") or w.get("class") or "?") + name = Gtk.Button(hexpand=True) + name.add_css_class("task-name") + lbl = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + lbl.append(icon) + wl = Gtk.Label(label=f"{title}", xalign=0.0, hexpand=True, ellipsize=3, max_width_chars=32) + lbl.append(wl) + lbl.append(Gtk.Label(label=f"ws {wsid}", xalign=1.0)) + name.set_child(lbl) + name.set_tooltip_text("Jump to window") + name.connect("clicked", lambda *_a: self._focus(w)) + row.append(name) + + pull = Gtk.Button(label="⇤ here") + pull.add_css_class("quad-action") + pull.set_tooltip_text("Pull this window to the current workspace") + pull.connect("clicked", lambda *_a: self._pull(w)) + row.append(pull) + return row + + # -- per-layout options page ------------------------------------------- + def _layout_page(self, ly: dict) -> Gtk.Widget: + page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + page.add_css_class("layout-page") + if ly.get("dirs"): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.append(Gtk.Label(label="direction", xalign=0.0, hexpand=True)) + # dir_labels (optional) are friendly labels shown in place of the raw + # scrolling:direction values (e.g. "Left / Right" for "right"). + labels = ly.get("dir_labels") or ly["dirs"] + dd = Gtk.DropDown.new_from_strings(labels) + dd.connect("notify::selected", self._on_opt_change) + row.append(dd) + page.append(row) + self._dir_dds[ly["name"]] = (dd, list(ly["dirs"])) + if ly.get("fit_method"): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.append(Gtk.Label(label="center-focused", xalign=0.0, hexpand=True)) + sw = Gtk.Switch(valign=Gtk.Align.CENTER) + if ly["name"] == "columns": + sw.set_tooltip_text("Keep the focused window centred in its column as " + "it scrolls, instead of the minimal-movement default") + else: + sw.set_tooltip_text("Keep the focused column centred so the prev/next " + "columns stay on-screen and tappable") + sw.connect("state-set", lambda s, state, name=ly["name"]: self._on_fit_toggle(s, state, name)) + row.append(sw) + page.append(row) + self._fit_sws[ly["name"]] = sw + if ly.get("stepper"): # Columns: (-)[N](+) + page.append(self._cols_stepper()) + if not ly.get("dirs") and not ly.get("fit_method") and not ly.get("stepper"): + page.append(Gtk.Label(label="No adjustable options", xalign=0.0, + css_classes=["net-ip"])) + return page + + # -- columns count stepper -------------------------------------------- + def _cols_stepper(self) -> Gtk.Widget: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.append(Gtk.Label(label="Columns", xalign=0.0, hexpand=True)) + minus = Gtk.Button(label="−") + minus.add_css_class("quad-action") + self._cols_lbl = Gtk.Label(label=str(self._read_cols())) + self._cols_lbl.add_css_class("stepper-value") + plus = Gtk.Button(label="+") + plus.add_css_class("quad-action") + minus.connect("clicked", lambda *_a: self._step_cols(-1)) + plus.connect("clicked", lambda *_a: self._step_cols(1)) + row.append(minus) + row.append(self._cols_lbl) + row.append(plus) + return row + + def _read_cols(self) -> int: + try: + data = json.loads(_COLUMNS_STATE.read_text()) + return int(data.get(str(self._active_ws), {}).get("cols", 2)) + except (FileNotFoundError, json.JSONDecodeError, ValueError, TypeError): + return 2 + + def _step_cols(self, delta: int) -> None: + try: + cur = int(self._cols_lbl.get_label()) + except ValueError: + cur = self._read_cols() + new = max(1, cur + delta) + self._cols_lbl.set_label(str(new)) + _eval(f'hl.dispatch(hl.dsp.layout("cols {"+1" if delta > 0 else "-1"}"))') + + def _read_center(self) -> bool: + # columns.lua's own "center-focused" switch — global, published in the same + # cache file as the columns stepper's count (see columns.lua's publish()). + try: + data = json.loads(_COLUMNS_STATE.read_text()) + return bool(data.get("_center", False)) + except (FileNotFoundError, json.JSONDecodeError, ValueError, TypeError): + return False + + # -- sync + handlers --------------------------------------------------- + def _sync_layout_controls(self) -> None: + # Reflect the current per-ws layout / direction / focus_fit in the tabs+options. + # + # This fires 3 concurrent hyprctl subprocess calls with no guaranteed resolution + # order, and refresh() can trigger _rebuild_panel() (hence this) twice in a row + # (once from the clients query, once from the activeworkspace one) — each + # rebuilding a fresh _layout_stack/_dir_dds/_fit_sws. Two failure modes existed: + # 1. The 3 callbacks used to each toggle the shared `self._syncing` flag + # independently (True at start, False at end). Whichever resolved *first* + # dropped the guard while the others — in particular the tab-selection one, + # which programmatically flips the visible stack child — were still in + # flight, letting `_on_tab_switch`/`_on_opt_change` misfire as if the user + # had clicked and re-apply stale data via `layouts.set(...)`. + # 2. A second `_sync_layout_controls()` call (from the redundant rebuild) could + # land while the first's callbacks were still pending, so a stale callback + # from sync #1 could decrement sync #2's pending-count or apply sync #1's + # (possibly wrong-workspace) data into sync #2's freshly built widgets. + # A generation token invalidates any sync superseded by a newer one outright — + # if the user switches workspaces (or a redundant rebuild fires) mid-flight, only + # the latest call's callbacks are allowed to touch anything, and self._syncing + # only lifts once *that* generation's 3 calls have all resolved. + self._sync_generation = getattr(self, "_sync_generation", 0) + 1 + gen = self._sync_generation + ws = self._active_ws + self._syncing = True + pending = [3] + + def _done() -> None: + if gen != self._sync_generation: + return # superseded — a newer sync owns self._syncing now + pending[0] -= 1 + if pending[0] <= 0: + self._syncing = False + + def _tab_cb(ok, data) -> None: + if gen == self._sync_generation: + self._apply_tab_sel(ws, ok, data) + _done() + + def _dir_cb(ok, data) -> None: + if gen == self._sync_generation: + self._apply_dir_sel(ok, data) + _done() + + def _fit_cb(ok, data) -> None: + if gen == self._sync_generation: + self._apply_fit_sel(ok, data) + _done() + + run_json(["hyprctl", "getoption", "general:layout", "-j"], _tab_cb) + run_json(["hyprctl", "getoption", "scrolling:direction", "-j"], _dir_cb) + run_json(["hyprctl", "getoption", "scrolling:focus_fit_method", "-j"], _fit_cb) + # columns' own center-focused state lives in columns-state.json, not a hyprctl + # option — read it synchronously rather than round-tripping a subprocess. + columns_sw = self._fit_sws.get("columns") + if columns_sw is not None: + columns_sw.set_active(self._read_center()) + + def _apply_tab_sel(self, ws, ok, data) -> None: + cur = data.get("str") if ok and isinstance(data, dict) else None + # prefer the per-workspace layout recorded by hypr/layouts (layouts.set) + try: + state = json.loads(_LAYOUTS_STATE.read_text()) + cur = state.get(str(ws), cur) + except (FileNotFoundError, json.JSONDecodeError): + pass + if cur and any(ly["name"] == cur for ly in self._layouts): + self._layout_stack.set_visible_child_name(cur) + + def _apply_dir_sel(self, ok, data) -> None: + # scrolling:direction is global; reflect it in every directional layout's + # dropdown whose value set contains it. + cur = data.get("str") if ok and isinstance(data, dict) else None + for dd, values in self._dir_dds.values(): + if cur in values: + dd.set_selected(values.index(cur)) + + def _apply_fit_sel(self, ok, data) -> None: + # scrolling:focus_fit_method only describes the scrolling layout's own switch; + # columns' switch is synced separately from columns-state.json (see + # _sync_layout_controls) since it isn't backed by a hyprctl option at all. + val = data.get("int") if ok and isinstance(data, dict) else 0 + sw = self._fit_sws.get("scrolling") + if sw is not None: + sw.set_active(val == 1) + + def _cur_dir(self) -> str: + entry = self._dir_dds.get(self._layout_stack.get_visible_child_name()) + if not entry: + return "" + dd, values = entry + i = dd.get_selected() + return values[i] if 0 <= i < len(values) else "" + + def _on_tab_switch(self, *_a) -> None: + if getattr(self, "_syncing", False) or self._active_ws is None: + return + name = self._layout_stack.get_visible_child_name() + if name: + _eval(f'layouts.set("{self._active_ws}", "{name}", "{self._cur_dir()}")') + + def _on_opt_change(self, *_a) -> None: + if getattr(self, "_syncing", False) or self._active_ws is None: + return + name = self._layout_stack.get_visible_child_name() + if name: + _eval(f'layouts.set("{self._active_ws}", "{name}", "{self._cur_dir()}")') + + def _on_fit_toggle(self, _sw, state, name) -> bool: + if not getattr(self, "_syncing", False): + if name == "columns": + _eval(f'hl.dispatch(hl.dsp.layout("center {"on" if state else "off"}"))') + else: + _eval(f"layouts.set_fit({1 if state else 0})") + return False + + # -- window actions ---------------------------------------------------- + def _focus(self, w) -> None: + addr = w.get("address") + if addr: + _dispatch(f'hl.dsp.focus({{ window = "address:{addr}" }})') + self._on_activate() + + def _pull(self, w) -> None: + addr = w.get("address") + if addr and self._active_ws is not None: + _dispatch(f'hl.dsp.window.move({{ window = "address:{addr}", ' + f'workspace = "{self._active_ws}" }})') + run_json(["hyprctl", "clients", "-j"], self._on_clients) # reflect the move + + @staticmethod + def _clear(box: Gtk.Box) -> None: + child = box.get_first_child() + while child: + box.remove(child) + child = box.get_first_child() diff --git a/desktopenvs/hyprdrive/astro-menu/window.py b/desktopenvs/hyprdrive/astro-menu/window.py new file mode 100644 index 0000000..c55c104 --- /dev/null +++ b/desktopenvs/hyprdrive/astro-menu/window.py @@ -0,0 +1,234 @@ +"""The popup: a content-sized floating layer-shell panel anchored top-centre. + +Earlier this was a full-monitor overlay with a dim backdrop, but that blocks the +whole screen — the invisible full-screen surface intercepts every click. Instead the +window now sizes to its own content and only occupies that area, leaving the rest of +the screen usable. It is dismissed with the launcher toggle, Esc, or the ✕ button +(there is no click-outside-to-close, since that would require a blocking full-screen +surface). + + Gtk.Window (layer TOP, anchored TOP → horizontally centred, height = content) + └ Gtk.Overlay + main : #panel-root (Taskbar / QuadGrid / AppDrawer, each drawn-bordered) + over : ✕ close button (top-right) + +Expanding the app drawer additionally anchors the BOTTOM edge so the panel stretches +down and the drawer fills to the bottom; collapsing removes that anchor. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +gi.require_version("Gtk4LayerShell", "1.0") +from gi.repository import Gdk, GLib, Gtk # noqa: E402 +from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 + +import config +from appservices import Services +from lib.border import bordered +from lib.hologram import HologramOverlay +from registry import ordered_specs +from ui.appdrawer import AppDrawer +from ui.quadgrid import QuadGrid +from ui.statsbar import Statsbar +from ui.taskbar import Taskbar + +PANEL_WIDTH_FRACTION = 0.5 # of the monitor width... +MAX_PANEL_WIDTH = 1100 # ...clamped to this +EDGE_MARGIN = 28 # gap from the anchored edge +BOTTOM_MARGIN = 34 # gap from the far edge when the drawer is expanded +SIDES = ("top", "bottom", "left", "right") + + +class MenuWindow(Gtk.ApplicationWindow): + def __init__(self, app, settings, services: Services): + super().__init__(application=app) + self.set_name("menu-window") + self.add_css_class("menu-window") + self.settings = settings + self.services = services + self._drawer_expanded = False + self._side = "top" + + self._init_layer_shell() + + self.root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.root.set_name("panel-root") + self.root.add_css_class("panel") + + # minimalist system-stats line (full-width, very top) + self.statsbar = Statsbar() + self.statsbar_reveal = Gtk.Revealer( + transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN, + transition_duration=160, reveal_child=True) + self.statsbar_reveal.set_child(bordered(self.statsbar, border=2, radius=12, fill_bg=True)) + self.root.append(self.statsbar_reveal) + + # taskbar (full-width, top) + self.taskbar = Taskbar(on_activate=self.hide_menu, + on_toggle_panel=self._on_taskbar_panel) + self.taskbar_reveal = Gtk.Revealer( + transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN, + transition_duration=160, reveal_child=True) + self.taskbar_reveal.set_child(bordered(self.taskbar, fill_bg=True)) + self.root.append(self.taskbar_reveal) + + # quads + specs = list(ordered_specs(settings)) + self.grid = QuadGrid(specs, settings, services) + # The taskbar's workspace/window panel is mounted over the quad region so that + # expanding it collapses the taskbar strip and covers the 2x2 quads. + self.grid.set_takeover_widget(self.taskbar.panel_widget, + on_back=self.taskbar.collapse_panel) + self.quad_reveal = Gtk.Revealer( + transition_type=Gtk.RevealerTransitionType.SLIDE_UP, + transition_duration=200, reveal_child=True) + self.quad_reveal.set_child(self.grid) + self.root.append(self.quad_reveal) + + # appdrawer + self.appdrawer = AppDrawer(settings, on_launch=self.hide_menu, + on_toggle_expand=self._on_appdrawer_expand) + self.appdrawer_wrap = bordered(self.appdrawer, fill_bg=True) + self.appdrawer_wrap.set_valign(Gtk.Align.FILL) + self.root.append(self.appdrawer_wrap) + + overlay = Gtk.Overlay() + overlay.set_child(self.root) + + self._hologram = HologramOverlay(enabled=config.hologram_enabled()) + overlay.add_overlay(self._hologram.widget) + + close = Gtk.Button(label="✕") + close.add_css_class("close-btn") + close.set_halign(Gtk.Align.END) + close.set_valign(Gtk.Align.START) + close.connect("clicked", lambda *_: self.hide_menu()) + overlay.add_overlay(close) + self.set_child(overlay) + + key = Gtk.EventControllerKey() + key.connect("key-pressed", self._on_key) + self.add_controller(key) + + self._last_tick: float | None = None + self._tick_id: int | None = None + + self.connect("map", lambda *_: self._apply_size()) + self.set_visible(False) + + # -- layer shell / size ----------------------------------------------- + def _init_layer_shell(self) -> None: + LayerShell.init_for_window(self) + LayerShell.set_layer(self, LayerShell.Layer.TOP) + LayerShell.set_namespace(self, "astro-menu") + LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.ON_DEMAND) + self._apply_anchor() + + def set_side(self, side: str) -> None: + """Anchor the panel to the given monitor edge ('top'|'bottom'|'left'|'right'). + The compositor's layer 'slide' animation then slides it in from that edge, and + the panel sits flush against it. Set before showing so the map animates.""" + if side in SIDES: + self._side = side + self._apply_anchor() + + def _apply_anchor(self) -> None: + """Pin the panel to its side edge; while the drawer is expanded, also pin the + perpendicular edges so it fills the screen height.""" + E = LayerShell.Edge + for edge in (E.TOP, E.BOTTOM, E.LEFT, E.RIGHT): + LayerShell.set_anchor(self, edge, False) + LayerShell.set_margin(self, edge, 0) + primary = {"top": E.TOP, "bottom": E.BOTTOM, "left": E.LEFT, "right": E.RIGHT}[self._side] + LayerShell.set_anchor(self, primary, True) + LayerShell.set_margin(self, primary, EDGE_MARGIN) + if self._drawer_expanded: + # fill vertically so the expanded drawer reaches top and bottom + for edge in (E.TOP, E.BOTTOM): + LayerShell.set_anchor(self, edge, True) + if edge != primary: + LayerShell.set_margin(self, edge, BOTTOM_MARGIN) + + def _monitor_width(self) -> int: + display = Gdk.Display.get_default() + surface = self.get_surface() + mon = display.get_monitor_at_surface(surface) if surface is not None else None + if mon is None: + monitors = display.get_monitors() + mon = monitors.get_item(0) if monitors.get_n_items() else None + return mon.get_geometry().width if mon is not None else 1920 + + def _apply_size(self) -> None: + width = min(int(self._monitor_width() * PANEL_WIDTH_FRACTION), MAX_PANEL_WIDTH) + self.root.set_size_request(width, -1) + + # -- taskbar panel expansion ------------------------------------------ + def _on_taskbar_panel(self, expanded: bool) -> None: + # Cover the quads with the taskbar's workspace/window panel. Fully collapse the + # taskbar strip (header included) so the expanded panel reads as ONE module + # rather than a thin strip on top plus a detached card. Collapse any quad first. + if expanded and self.grid.is_expanded: + self.grid.request_collapse() + self.taskbar_reveal.set_reveal_child(not expanded) + self.taskbar_reveal.set_visible(not expanded) + self.grid.show_takeover() if expanded else self.grid.hide_takeover() + + # -- appdrawer expansion ---------------------------------------------- + def _on_appdrawer_expand(self, expanded: bool) -> None: + self._drawer_expanded = expanded + self._apply_anchor() + # Hide (not just un-reveal) the stats/taskbar/quads so they reserve zero space + # and the drawer fills the whole panel. + for rev in (self.statsbar_reveal, self.quad_reveal, self.taskbar_reveal): + rev.set_reveal_child(not expanded) + rev.set_visible(not expanded) + self.appdrawer_wrap.set_vexpand(expanded) + + # -- visibility -------------------------------------------------------- + def show_menu(self, focus_appdrawer: bool = False) -> None: + self.appdrawer.set_expanded(False) + self.taskbar.refresh() + self.grid.on_show() + self.appdrawer.on_show() + self.set_visible(True) + self.present() + GLib.timeout_add(30, lambda: (self._apply_size(), False)[1]) + if focus_appdrawer: + self.appdrawer.set_expanded(True) + if self._hologram.enabled and self._tick_id is None: + self._tick_id = self.add_tick_callback(self._on_tick) + + def hide_menu(self) -> None: + self.grid.on_hide() + self.taskbar.collapse_panel() + self.grid.hide_takeover() + self.appdrawer.set_expanded(False) + self.set_visible(False) + if self._tick_id is not None: + self.remove_tick_callback(self._tick_id) + self._tick_id = None + self._last_tick = None + + def toggle(self, focus_appdrawer: bool = False) -> None: + if self.get_visible(): + self.hide_menu() + else: + self.show_menu(focus_appdrawer) + + # -- hologram animation loop -------------------------------------------- + def _on_tick(self, _widget, frame_clock) -> bool: + now = frame_clock.get_frame_time() / 1_000_000 + dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick) + self._last_tick = now + self._hologram.tick(dt) + return True # keep ticking every frame while the menu is visible + + def _on_key(self, _c, keyval, _kc, _state) -> bool: + if keyval == Gdk.KEY_Escape: + self.hide_menu() + return True + return False diff --git a/desktopenvs/hyprdrive/btop/btop.conf b/desktopenvs/hyprdrive/btop/btop.conf new file mode 100644 index 0000000..56c049c --- /dev/null +++ b/desktopenvs/hyprdrive/btop/btop.conf @@ -0,0 +1,272 @@ +#? Config file for btop v.1.4.6 + +#* Name of a btop++/bpytop/bashtop formatted ".theme" file, "Default" and "TTY" for builtin themes. +#* Themes should be placed in "../share/btop/themes" relative to binary or "$HOME/.config/btop/themes" +color_theme = "/usr/share/btop/themes/cyberqueer.theme" + +#* If the theme set background should be shown, set to False if you want terminal background transparency. +theme_background = true + +#* Sets if 24-bit truecolor should be used, will convert 24-bit colors to 256 color (6x6x6 color cube) if false. +truecolor = true + +#* Set to true to force tty mode regardless if a real tty has been detected or not. +#* Will force 16-color mode and TTY theme, set all graph symbols to "tty" and swap out other non tty friendly symbols. +force_tty = false + +#* Define presets for the layout of the boxes. Preset 0 is always all boxes shown with default settings. Max 9 presets. +#* Format: "box_name:P:G,box_name:P:G" P=(0 or 1) for alternate positions, G=graph symbol to use for box. +#* Use whitespace " " as separator between different presets. +#* Example: "cpu:0:default,mem:0:tty,proc:1:default cpu:0:braille,proc:0:tty" +presets = "cpu:1:default,proc:0:default cpu:0:default,mem:0:default,net:0:default cpu:0:block,net:0:tty" + +#* Set to True to enable "h,j,k,l,g,G" keys for directional control in lists. +#* Conflicting keys for h:"help" and k:"kill" is accessible while holding shift. +vim_keys = false + +#* Rounded corners on boxes, is ignored if TTY mode is ON. +rounded_corners = true + +#* Use terminal synchronized output sequences to reduce flickering on supported terminals. +terminal_sync = true + +#* Default symbols to use for graph creation, "braille", "block" or "tty". +#* "braille" offers the highest resolution but might not be included in all fonts. +#* "block" has half the resolution of braille but uses more common characters. +#* "tty" uses only 3 different symbols but will work with most fonts and should work in a real TTY. +#* Note that "tty" only has half the horizontal resolution of the other two, so will show a shorter historical view. +graph_symbol = "braille" + +# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty". +graph_symbol_cpu = "default" + +# Graph symbol to use for graphs in gpu box, "default", "braille", "block" or "tty". +graph_symbol_gpu = "default" + +# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty". +graph_symbol_mem = "default" + +# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty". +graph_symbol_net = "default" + +# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty". +graph_symbol_proc = "default" + +#* Manually set which boxes to show. Available values are "cpu mem net proc" and "gpu0" through "gpu5", separate values with whitespace. +shown_boxes = "cpu mem net proc" + +#* Update time in milliseconds, recommended 2000 ms or above for better sample times for graphs. +update_ms = 100 + +#* Processes sorting, "pid" "program" "arguments" "threads" "user" "memory" "cpu lazy" "cpu direct", +#* "cpu lazy" sorts top process over time (easier to follow), "cpu direct" updates top process directly. +proc_sorting = "cpu lazy" + +#* Reverse sorting order, True or False. +proc_reversed = false + +#* Show processes as a tree. +proc_tree = false + +#* Use the cpu graph colors in the process list. +proc_colors = true + +#* Use a darkening gradient in the process list. +proc_gradient = true + +#* If process cpu usage should be of the core it's running on or usage of the total available cpu power. +proc_per_core = false + +#* Show process memory as bytes instead of percent. +proc_mem_bytes = true + +#* Show cpu graph for each process. +proc_cpu_graphs = true + +#* Use /proc/[pid]/smaps for memory information in the process info box (very slow but more accurate) +proc_info_smaps = false + +#* Show proc box on left side of screen instead of right. +proc_left = false + +#* (Linux) Filter processes tied to the Linux kernel(similar behavior to htop). +proc_filter_kernel = false + +#* In tree-view, always accumulate child process resources in the parent process. +proc_aggregate = false + +#* Should cpu and memory usage display be preserved for dead processes when paused. +keep_dead_proc_usage = false + +#* Sets the CPU stat shown in upper half of the CPU graph, "total" is always available. +#* Select from a list of detected attributes from the options menu. +cpu_graph_upper = "Auto" + +#* Sets the CPU stat shown in lower half of the CPU graph, "total" is always available. +#* Select from a list of detected attributes from the options menu. +cpu_graph_lower = "Auto" + +#* If gpu info should be shown in the cpu box. Available values = "Auto", "On" and "Off". +show_gpu_info = "Auto" + +#* Toggles if the lower CPU graph should be inverted. +cpu_invert_lower = true + +#* Set to True to completely disable the lower CPU graph. +cpu_single_graph = false + +#* Show cpu box at bottom of screen instead of top. +cpu_bottom = false + +#* Shows the system uptime in the CPU box. +show_uptime = true + +#* Shows the CPU package current power consumption in watts. Requires running `make setcap` or `make setuid` or running with sudo. +show_cpu_watts = true + +#* Show cpu temperature. +check_temp = true + +#* Which sensor to use for cpu temperature, use options menu to select from list of available sensors. +cpu_sensor = "Auto" + +#* Show temperatures for cpu cores also if check_temp is True and sensors has been found. +show_coretemp = true + +#* Set a custom mapping between core and coretemp, can be needed on certain cpus to get correct temperature for correct core. +#* Use lm-sensors or similar to see which cores are reporting temperatures on your machine. +#* Format "x:y" x=core with wrong temp, y=core with correct temp, use space as separator between multiple entries. +#* Example: "4:0 5:1 6:3" +cpu_core_map = "" + +#* Which temperature scale to use, available values: "celsius", "fahrenheit", "kelvin" and "rankine". +temp_scale = "celsius" + +#* Use base 10 for bits/bytes sizes, KB = 1000 instead of KiB = 1024. +base_10_sizes = false + +#* Show CPU frequency. +show_cpu_freq = true + +#* How to calculate CPU frequency, available values: "first", "range", "lowest", "highest" and "average". +freq_mode = "first" + +#* Draw a clock at top of screen, formatting according to strftime, empty string to disable. +#* Special formatting: /host = hostname | /user = username | /uptime = system uptime +clock_format = "%X" + +#* Update main ui in background when menus are showing, set this to false if the menus is flickering too much for comfort. +background_update = true + +#* Custom cpu model name, empty string to disable. +custom_cpu_name = "" + +#* Optional filter for shown disks, should be full path of a mountpoint, separate multiple values with whitespace " ". +#* Only disks matching the filter will be shown. Prepend exclude= to only show disks not matching the filter. Examples: disk_filter="/boot /home/user", disks_filter="exclude=/boot /home/user" +disks_filter = "" + +#* Show graphs instead of meters for memory values. +mem_graphs = true + +#* Show mem box below net box instead of above. +mem_below_net = false + +#* Count ZFS ARC in cached and available memory. +zfs_arc_cached = true + +#* If swap memory should be shown in memory box. +show_swap = true + +#* Show swap as a disk, ignores show_swap value above, inserts itself after first disk. +swap_disk = true + +#* If mem box should be split to also show disks info. +show_disks = true + +#* Filter out non physical disks. Set this to False to include network disks, RAM disks and similar. +only_physical = true + +#* Read disks list from /etc/fstab. This also disables only_physical. +use_fstab = true + +#* Setting this to True will hide all datasets, and only show ZFS pools. (IO stats will be calculated per-pool) +zfs_hide_datasets = false + +#* Set to true to show available disk space for privileged users. +disk_free_priv = false + +#* Toggles if io activity % (disk busy time) should be shown in regular disk usage view. +show_io_stat = true + +#* Toggles io mode for disks, showing big graphs for disk read/write speeds. +io_mode = false + +#* Set to True to show combined read/write io graphs in io mode. +io_graph_combined = false + +#* Set the top speed for the io graphs in MiB/s (100 by default), use format "mountpoint:speed" separate disks with whitespace " ". +#* Example: "/mnt/media:100 /:20 /boot:1". +io_graph_speeds = "" + +#* Set fixed values for network graphs in Mebibits. Is only used if net_auto is also set to False. +net_download = 100 + +net_upload = 100 + +#* Use network graphs auto rescaling mode, ignores any values set above and rescales down to 10 Kibibytes at the lowest. +net_auto = true + +#* Sync the auto scaling for download and upload to whichever currently has the highest scale. +net_sync = true + +#* Starts with the Network Interface specified here. +net_iface = "" + +#* "True" shows bitrates in base 10 (Kbps, Mbps). "False" shows bitrates in binary sizes (Kibps, Mibps, etc.). "Auto" uses base_10_sizes. +base_10_bitrate = "Auto" + +#* Show battery stats in top right if battery is present. +show_battery = true + +#* Which battery to use if multiple are present. "Auto" for auto detection. +selected_battery = "Auto" + +#* Show power stats of battery next to charge indicator. +show_battery_watts = true + +#* Set loglevel for "~/.local/state/btop.log" levels are: "ERROR" "WARNING" "INFO" "DEBUG". +#* The level set includes all lower levels, i.e. "DEBUG" will show all logging info. +log_level = "WARNING" + +#* Automatically save current settings to config file on exit. +save_config_on_exit = true + +#* Measure PCIe throughput on NVIDIA cards, may impact performance on certain cards. +nvml_measure_pcie_speeds = true + +#* Measure PCIe throughput on AMD cards, may impact performance on certain cards. +rsmi_measure_pcie_speeds = true + +#* Horizontally mirror the GPU graph. +gpu_mirror_graph = true + +#* Set which GPU vendors to show. Available values are "nvidia amd intel" +shown_gpus = "nvidia amd intel" + +#* Custom gpu0 model name, empty string to disable. +custom_gpu_name0 = "" + +#* Custom gpu1 model name, empty string to disable. +custom_gpu_name1 = "" + +#* Custom gpu2 model name, empty string to disable. +custom_gpu_name2 = "" + +#* Custom gpu3 model name, empty string to disable. +custom_gpu_name3 = "" + +#* Custom gpu4 model name, empty string to disable. +custom_gpu_name4 = "" + +#* Custom gpu5 model name, empty string to disable. +custom_gpu_name5 = "" diff --git a/desktopenvs/hyprdrive/btop/themes/cyberqueer.theme b/desktopenvs/hyprdrive/btop/themes/cyberqueer.theme new file mode 100644 index 0000000..92a4b8b --- /dev/null +++ b/desktopenvs/hyprdrive/btop/themes/cyberqueer.theme @@ -0,0 +1,92 @@ +#Bashtop monokai theme +#by aristocratos + +# Colors should be in 6 or 2 character hexadecimal or single spaced rgb decimal: "#RRGGBB", "#BW" or "0-255 0-255 0-255" +# example for white: "#FFFFFF", "#ff" or "255 255 255". + +# All graphs and meters can be gradients +# For single color graphs leave "mid" and "end" variable empty. +# Use "start" and "end" variables for two color gradient +# Use "start", "mid" and "end" for three color gradient + +# Main background, empty for terminal default, need to be empty if you want transparent background +theme[main_bg]="#1a1a1a" + +# Main text color +theme[main_fg]="#E40046" + +# Title color for boxes +theme[title]="#d6abab" + +# Highlight color for keyboard shortcuts +theme[hi_fg]="#f50505" + +# Background color of selected item in processes box +theme[selected_bg]="#7a1137" + +# Foreground color of selected item in processes box +theme[selected_fg]="#F8F8F2" + +# Color of inactive/disabled text +theme[inactive_fg]="#5018dd" + +# Color of text appearing on top of graphs, i.e uptime and current network graph scaling +theme[graph_text]="#797667" + +# Misc colors for processes box including mini cpu graphs, details memory graph and details status text +theme[proc_misc]="#A6E22E" + +# Cpu box outline color +theme[cpu_box]="#5018dd" + +# Memory/disks box outline color +theme[mem_box]="#5018dd" + +# Net up/down box outline color +theme[net_box]="#5018dd" + +# Processes box outline color +theme[proc_box]="#5018dd" + +# Box divider line and small boxes line color +theme[div_line]="#5018dd" + +# Temperature graph colors +theme[temp_start]="#5018dd" +theme[temp_mid]="#E40046" +theme[temp_end]="#f50505" + +# CPU graph colors +theme[cpu_start]="#5018dd" +theme[cpu_mid]="#E40046" +theme[cpu_end]="#f50505" + +# Mem/Disk free meter +theme[free_start]="#5018dd" +theme[free_mid]="#E40046" +theme[free_end]="#f50505" + +# Mem/Disk cached meter +theme[cached_start]="#5018dd" +theme[cached_mid]="#E40046" +theme[cached_end]="#f50505" + +# Mem/Disk available meter +theme[available_start]="#5018dd" +theme[available_mid]="#E40046" +theme[available_end]="#f50505" + +# Mem/Disk used meter +theme[used_start]="#5018dd" +theme[used_mid]="#E40046" +theme[used_end]="#f50505" + +# Download graph colors +theme[download_start]="#5018dd" +theme[download_mid]="#E40046" +theme[download_end]="#f50505" + +# Upload graph colors +theme[upload_start]="#5018dd" +theme[upload_mid]="#E40046" +theme[upload_end]="#f50505" diff --git a/desktopenvs/hyprdrive/config-updater/update-configs.sh b/desktopenvs/hyprdrive/config-updater/update-configs.sh new file mode 100755 index 0000000..b2b27fd --- /dev/null +++ b/desktopenvs/hyprdrive/config-updater/update-configs.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# update-configs — sync dotfiles configs into ~/.config +# +# Config: ~/.config/config-updater/updater.conf +# Manifest: ~/.config/config-updater/manifest +# +# Syntax in updater.conf: +# config [except ...] +# copy SOURCE_BASE/ to ~/.config/, optionally skipping subdirs +# flat +# copy contents of SOURCE_BASE/ directly into ~/.config/ +# ignore +# present in source but intentionally not managed here + +set -euo pipefail + +CONF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/config-updater" +CONF_FILE="${CONF_DIR}/updater.conf" +MANIFEST="${CONF_DIR}/manifest" +TARGET="${XDG_CONFIG_HOME:-$HOME/.config}" + +RED='\033[0;31m'; YEL='\033[1;33m'; GRN='\033[0;32m'; DIM='\033[2m'; RST='\033[0m' +err() { printf "${RED}✖ %s${RST}\n" "$*" >&2; } +warn() { printf "${YEL}⚠ %s${RST}\n" "$*"; } +ok() { printf "${GRN}✔ %s${RST}\n" "$*"; } +note() { printf "${DIM} %s${RST}\n" "$*"; } +die() { err "$*"; exit 1; } + +[[ -f "$CONF_FILE" ]] || die "Config not found: $CONF_FILE" + +# ── parse updater.conf ──────────────────────────────────────────────────────── +SOURCE_BASE="" +declare -A ENTRY_TYPE # name → config | flat | ignore +declare -A ENTRY_EXCL # name → space-separated list of excluded subdirs + +while IFS= read -r line; do + line="${line%%#*}" + line="${line#"${line%%[! ]*}"}" + line="${line%"${line##*[! ]}"}" + [[ -z "$line" ]] && continue + + if [[ "$line" =~ ^SOURCE_BASE[[:space:]]*=[[:space:]]*(.+)$ ]]; then + src="${BASH_REMATCH[1]%"${BASH_REMATCH[1]##*[! ]}"}" + SOURCE_BASE="${src/#\~/$HOME}" + continue + fi + + read -r type name rest <<< "$line" + [[ -z "${name:-}" ]] && continue + + case "$type" in + config|flat|ignore) + ENTRY_TYPE["$name"]="$type" + # Parse optional "except sub1 sub2 ..." + if [[ "${rest:-}" =~ ^except[[:space:]]+(.+)$ ]]; then + ENTRY_EXCL["$name"]="${BASH_REMATCH[1]}" + else + ENTRY_EXCL["$name"]="" + fi + ;; + *) warn "Unknown type '$type' for '$name' — skipping" ;; + esac +done < "$CONF_FILE" + +[[ -n "$SOURCE_BASE" ]] || die "SOURCE_BASE not defined in $CONF_FILE" +[[ -d "$SOURCE_BASE" ]] || die "SOURCE_BASE not found: $SOURCE_BASE" + +# ── load previous manifest ──────────────────────────────────────────────────── +declare -A PREV_MANIFEST +if [[ -f "$MANIFEST" ]]; then + while IFS=: read -r mtype mname; do + [[ -n "${mtype:-}" && -n "${mname:-}" ]] && PREV_MANIFEST["$mname"]="$mtype" + done < "$MANIFEST" +fi + +warned=0 + +# ── warn: manifest entries no longer in updater.conf ───────────────────────── +for mname in "${!PREV_MANIFEST[@]}"; do + [[ -n "${ENTRY_TYPE[$mname]:-}" ]] && continue + warn "'$mname' was previously managed but is no longer in updater.conf" + note "Remove ~/.config/$mname manually if it is no longer needed" + warned=1 +done + +# ── warn: source items not covered by updater.conf ─────────────────────────── +while IFS= read -r -d '' item; do + name="$(basename "$item")" + [[ "$name" == .* ]] && continue + [[ -n "${ENTRY_TYPE[$name]:-}" ]] && continue + warn "Untracked source item: '$name' — add to updater.conf (config / flat / ignore)" + warned=1 +done < <(find "$SOURCE_BASE" -maxdepth 1 -mindepth 1 -print0 | sort -z) + +(( warned )) && printf '\n' + +# ── apply ───────────────────────────────────────────────────────────────────── +errors=0 +new_manifest=() + +for name in "${!ENTRY_TYPE[@]}"; do + type="${ENTRY_TYPE[$name]}" + [[ "$type" == ignore ]] && continue + + src="${SOURCE_BASE}/${name}" + if [[ ! -e "$src" ]]; then + err "Source missing: $src" + (( errors++ )) || true + continue + fi + + excl="${ENTRY_EXCL[$name]:-}" + + case "$type" in + config) + if [[ -z "$excl" ]]; then + rm -rf "${TARGET:?}/${name}" + cp -r "$src" "$TARGET/$name" + ok "config $name" + else + # Copy all top-level items of $src except excluded subdirs + mkdir -p "${TARGET}/${name}" + item_errors=0 + while IFS= read -r -d '' item; do + item_name="$(basename "$item")" + skip_item=false + for e in $excl; do + [[ "$item_name" == "$e" ]] && skip_item=true && break + done + [[ "$skip_item" == true ]] && continue + rm -rf "${TARGET:?}/${name}/${item_name}" + cp -r "$item" "${TARGET}/${name}/" || (( item_errors++ )) || true + done < <(find "$src" -maxdepth 1 -mindepth 1 -print0 | sort -z) + if (( item_errors > 0 )); then + err "config $name (${item_errors} error(s))" + (( errors++ )) || true + else + ok "config $name (excl: $excl)" + fi + fi + ;; + flat) + [[ -d "$src" ]] || { + err "flat entry '$name' must be a directory: $src" + (( errors++ )) || true + continue + } + cp -r "${src}/." "$TARGET/" + ok "flat $name → (contents into ~/.config/)" + ;; + esac + + new_manifest+=("${type}:${name}") +done + +# ── write manifest ──────────────────────────────────────────────────────────── +printf '%s\n' "${new_manifest[@]}" | sort > "$MANIFEST" + +printf '\n' +if (( errors > 0 )); then + err "$errors error(s) — manifest may be incomplete" + exit 1 +else + ok "Done — manifest updated at $MANIFEST" +fi diff --git a/desktopenvs/hyprdrive/config-updater/updater.conf b/desktopenvs/hyprdrive/config-updater/updater.conf new file mode 100644 index 0000000..abf7a1d --- /dev/null +++ b/desktopenvs/hyprdrive/config-updater/updater.conf @@ -0,0 +1,38 @@ +# Config updater — defines which dotfiles configs are deployed to ~/.config +# +# SOURCE_BASE base directory containing all configs in the dotfiles +# +# config copy SOURCE_BASE/ to ~/.config/ +# flat copy contents of SOURCE_BASE/ directly into ~/.config/ +# (for installation-specific files that live at the ~/.config root) +# ignore present in source but intentionally not managed here + +SOURCE_BASE = ~/Dotfiles/desktopenvs/hyprdrive + +# ── deployed as ~/.config/ ───────────────────────────────────────────── +config alacritty +config astro-menu +config btop +config dunst +config gtk-3.0 +config hypr except usr +config horizon-dock +config kitty +config mimeapps.list +config orbit-menu +config scripts +config station-bar +config vicinae +config xfce4 + +# ── intentionally not managed here ─────────────────────────────────────────── +ignore config-updater # the updater itself +ignore CRT # referenced from dotfiles path directly in binds.conf +ignore greetd-tuigreet # deployed to /etc/greetd/ at install time +ignore spicetify # managed separately (spicetify handles its own config) +ignore Vencord # managed separately +ignore wallpaper # deployed to ~/Pictures/ at install time, not ~/.config +# hypr/usr/ contains device-specific lua files (monitors, binds, input, etc.). +# They are excluded from automated syncs to preserve per-device customisations. +# Deploy manually on a new device: +# cp -r ~/Dotfiles/desktopenvs/hyprdrive/hypr/usr/ ~/.config/hypr/ diff --git a/desktopenvs/hyprdrive/dunst/dunst b/desktopenvs/hyprdrive/dunst/dunst new file mode 120000 index 0000000..5b21566 --- /dev/null +++ b/desktopenvs/hyprdrive/dunst/dunst @@ -0,0 +1 @@ +/home/themiro/Dotfiles/desktopenvs/hyprland/dunst/ \ No newline at end of file diff --git a/desktopenvs/hyprdrive/dunst/dunstrc b/desktopenvs/hyprdrive/dunst/dunstrc new file mode 100644 index 0000000..6e6d9ea --- /dev/null +++ b/desktopenvs/hyprdrive/dunst/dunstrc @@ -0,0 +1,492 @@ +# See dunst(5) for all configuration options + +[global] + ### Display ### + + # Which monitor should the notifications be displayed on. + monitor = 0 + + # Display notification on focused monitor. Possible modes are: + # mouse: follow mouse pointer + # keyboard: follow window with keyboard focus + # none: don't follow anything + # + # "keyboard" needs a window manager that exports the + # _NET_ACTIVE_WINDOW property. + # This should be the case for almost all modern window managers. + # + # If this option is set to mouse or keyboard, the monitor option + # will be ignored. + follow = none + + ### Geometry ### + + # The width of the window, excluding the frame. + # dynamic width from 0 to 300 + # width = (0, 300) + # constant width of 300 + width = 300 + + # The height of a single notification, excluding the frame. + # dynamic height from 0 to 300 + height = (0, 300) + # constant height of 300 + # height = 300 + + # Position the notification in the top right corner + origin = top-center + + # Offset from the origin + offset = (10, 50) + + # Scale factor. It is auto-detected if value is 0. + scale = 0 + + # Maximum number of notification (0 means no limit) + notification_limit = 20 + + ### Progress bar ### + + # Turn on the progress bar. It appears when a progress hint is passed with + # for example dunstify -h int:value:12 + progress_bar = true + + # Set the progress bar height. This includes the frame, so make sure + # it's at least twice as big as the frame width. + progress_bar_height = 10 + + # Set the frame width of the progress bar + progress_bar_frame_width = 1 + + # Set the minimum width for the progress bar + progress_bar_min_width = 150 + + # Set the maximum width for the progress bar + progress_bar_max_width = 300 + + # Corner radius for the progress bar. 0 disables rounded corners. + progress_bar_corner_radius = 0 + + # Define which corners to round when drawing the progress bar. If progress_bar_corner_radius + # is set to 0 this option will be ignored. + progress_bar_corners = all + + # Corner radius for the image. + icon_corner_radius = 23 + + # Define which corners to round when drawing the icon image. If icon_corner_radius + # is set to 0 this option will be ignored. + icon_corners = all + + # Show how many messages are currently hidden (because of + # notification_limit). + indicate_hidden = yes + + # The transparency of the window. Range: [0; 100]. + # This option will only work if a compositing window manager is + # present (e.g. xcompmgr, compiz, etc.). (X11 only) + transparency = 0 + + # Draw a line of "separator_height" pixel height between two + # notifications. + # Set to 0 to disable. + # If gap_size is greater than 0, this setting will be ignored. + separator_height = 2 + + # Padding between text and separator. + padding = 4 + + # Horizontal padding. + horizontal_padding = 8 + + # Padding between text and icon. + text_icon_padding = 0 + + # Defines width in pixels of frame around the notification window. + # Set to 0 to disable. + frame_width = 3 + + # Defines color of the frame around the notification window. + frame_color = "#5018DD" + + # Size of gap to display between notifications - requires a compositor. + # If value is greater than 0, separator_height will be ignored and a border + # of size frame_width will be drawn around each notification instead. + # Click events on gaps do not currently propagate to applications below. + gap_size = 5 + + # Define a color for the separator. + # possible values are: + # * auto: dunst tries to find a color fitting to the background; + # * foreground: use the same color as the foreground; + # * frame: use the same color as the frame; + # * anything else will be interpreted as a X color. + separator_color = frame + + # Sort type. + # possible values are: + # * id: sort by id + # * urgency_ascending: sort by urgency (low then normal then critical) + # * urgency_descending: sort by urgency (critical then normal then low) + # * update: sort by update (most recent always at the top) + sort = yes + + # Don't remove messages, if the user is idle (no mouse or keyboard input) + # for longer than idle_threshold seconds. + # Set to 0 to disable. + # A client can set the 'transient' hint to bypass this. See the rules + # section for how to disable this if necessary + # idle_threshold = 120 + + ### Text ### + + font = Agave Nerd Font 12 + + # The spacing between lines. If the height is smaller than the + # font height, it will get raised to the font height. + line_height = 0 + + # Possible values are: + # full: Allow a small subset of html markup in notifications: + # bold + # italic + # strikethrough + # underline + # + # For a complete reference see + # . + # + # strip: This setting is provided for compatibility with some broken + # clients that send markup even though it's not enabled on the + # server. Dunst will try to strip the markup but the parsing is + # simplistic so using this option outside of matching rules for + # specific applications *IS GREATLY DISCOURAGED*. + # + # no: Disable markup parsing, incoming notifications will be treated as + # plain text. Dunst will not advertise that it has the body-markup + # capability if this is set as a global setting. + # + # It's important to note that markup inside the format option will be parsed + # regardless of what this is set to. + markup = full + + # The format of the message. Possible variables are: + # %a appname + # %s summary + # %b body + # %i iconname (including its path) + # %I iconname (without its path) + # %p progress value if set ([ 0%] to [100%]) or nothing + # %n progress value if set without any extra characters + # %% Literal % + # Markup is allowed + format = "%s\n%b" + + # Alignment of message text. + # Possible values are "left", "center" and "right". + alignment = center + + # Vertical alignment of message text and icon. + # Possible values are "top", "center" and "bottom". + vertical_alignment = center + + # Show age of message if message is older than show_age_threshold + # seconds. + # Set to -1 to disable. + show_age_threshold = 60 + + # Specify where to make an ellipsis in long lines. + # Possible values are "start", "middle" and "end". + ellipsize = middle + + # Ignore newlines '\n' in notifications. + ignore_newline = no + + # Stack together notifications with the same content + stack_duplicates = true + + # Hide the count of stacked notifications with the same content + hide_duplicate_count = false + + # Display indicators for URLs (U) and actions (A). + show_indicators = yes + + ### Icons ### + + # Recursive icon lookup. You can set a single theme, instead of having to + # define all lookup paths. + enable_recursive_icon_lookup = true + + # Set icon theme (only used for recursive icon lookup) + icon_theme = "Papirus-Dark, Adwaita" + # You can also set multiple icon themes, with the leftmost one being used first. + # icon_theme = "Adwaita, breeze" + + # Align icons left/right/top/off + icon_position = top + + # Scale small icons up to this size, set to 0 to disable. Helpful + # for e.g. small files or high-dpi screens. In case of conflict, + # max_icon_size takes precedence over this. + min_icon_size = 32 + + # Scale larger icons down to this size, set to 0 to disable + max_icon_size = 128 + + # Paths to default icons (only necessary when not using recursive icon lookup) + icon_path = /usr/share/icons/gnome/16x16/status/:/usr/share/icons/gnome/16x16/devices/ + + ### History ### + + # Should a notification popped up from history be sticky or timeout + # as if it would normally do. + sticky_history = yes + + # Maximum amount of notifications kept in history + history_length = 20 + + ### Misc/Advanced ### + + # dmenu path. + dmenu = /usr/bin/dmenu -p dunst: + + # Browser for opening urls in context menu. + browser = /usr/bin/xdg-open + + # Always run rule-defined scripts, even if the notification is suppressed + always_run_script = true + + # Define the title of the windows spawned by dunst (X11 only) + title = Dunst + + # Define the class of the windows spawned by dunst (X11 only) + class = Dunst + + # Define the corner radius of the notification window + # in pixel size. If the radius is 0, you have no rounded + # corners. + # The radius will be automatically lowered if it exceeds half of the + # notification height to avoid clipping text and/or icons. + corner_radius = 20 + + # Define which corners to round when drawing the window. If the corner radius + # is set to 0 this option will be ignored. + # + # Comma-separated list of the corners. The accepted corner values are bottom-right, + # bottom-left, top-right, top-left, top, bottom, left, right or all. + corners = all + + # Ignore the dbus closeNotification message. + # Useful to enforce the timeout set by dunst configuration. Without this + # parameter, an application may close the notification sent before the + # user defined timeout. + ignore_dbusclose = false + + ### Wayland ### + # These settings are Wayland-specific. They have no effect when using X11 + + # Uncomment this if you want to let notifications appear under fullscreen + # applications (default: overlay) + # layer = top + + # Set this to true to use X11 output on Wayland. + force_xwayland = false + + ### Legacy + + # Use the Xinerama extension instead of RandR for multi-monitor support. + # This setting is provided for compatibility with older nVidia drivers that + # do not support RandR and using it on systems that support RandR is highly + # discouraged. + # + # By enabling this setting dunst will not be able to detect when a monitor + # is connected or disconnected which might break follow mode if the screen + # layout changes. + force_xinerama = false + + ### mouse + + # Defines list of actions for each mouse event + # Possible values are: + # * none: Don't do anything. + # * do_action: Invoke the action determined by the action_name rule. If there is no + # such action, open the context menu. + # * open_url: If the notification has exactly one url, open it. If there are multiple + # ones, open the context menu. + # * close_current: Close current notification. + # * close_all: Close all notifications. + # * context: Open context menu for the notification. + # * context_all: Open context menu for all notifications. + # These values can be strung together for each mouse event, and + # will be executed in sequence. + mouse_left_click = close_current + mouse_middle_click = do_action, close_current + mouse_right_click = close_all + +# Experimental features that may or may not work correctly. Do not expect them +# to have a consistent behaviour across releases. +[experimental] + # Calculate the dpi to use on a per-monitor basis. + # If this setting is enabled the Xft.dpi value will be ignored and instead + # dunst will attempt to calculate an appropriate dpi value for each monitor + # using the resolution and physical size. This might be useful in setups + # where there are multiple screens with very different dpi values. + per_monitor_dpi = false + + +[urgency_low] + # IMPORTANT: colors have to be defined in quotation marks. + # Otherwise the "#" and following would be interpreted as a comment. + background = "#1a1a1a" + foreground = "#5018dd" + frame_color = "#5018dd" + timeout = 10 + # Icon for notifications with low urgency + default_icon = dialog-information + +[urgency_normal] + background = "#1a1a1a" + foreground = "#E40046" + frame_color = "#5018dd" + timeout = 10 + override_pause_level = 30 + # Icon for notifications with normal urgency + default_icon = dialog-information + +[urgency_critical] + background = "#E40046" + foreground = "#5018dd" + frame_color = "#5018dd" + timeout = 0 + override_pause_level = 60 + # Icon for notifications with critical urgency + default_icon = dialog-warning + +# Every section that isn't one of the above is interpreted as a rules to +# override settings for certain messages. +# +# Messages can be matched by +# appname (discouraged, see desktop_entry) +# body +# category +# desktop_entry +# icon +# match_transient +# msg_urgency +# stack_tag +# summary +# +# and you can override the +# background +# foreground +# format +# frame_color +# fullscreen +# new_icon +# set_stack_tag +# set_transient +# set_category +# timeout +# urgency +# icon_position +# skip_display +# history_ignore +# action_name +# word_wrap +# ellipsize +# alignment +# hide_text +# override_pause_level +# +# Shell-like globbing will get expanded. +# +# Instead of the appname filter, it's recommended to use the desktop_entry filter. +# GLib based applications export their desktop-entry name. In comparison to the appname, +# the desktop-entry won't get localized. +# +# You can also allow a notification to appear even when paused. Notification will appear whenever notification's override_pause_level >= dunst's paused level. +# This can be used to set partial pause modes, where more urgent notifications get through, but less urgent stay paused. To do that, you can override the following in the rules: +# override_pause_level = X + +# SCRIPTING +# You can specify a script that gets run when the rule matches by +# setting the "script" option. +# The script will be called as follows: +# script appname summary body icon urgency +# where urgency can be "LOW", "NORMAL" or "CRITICAL". +# +# NOTE: It might be helpful to run dunst -print in a terminal in order +# to find fitting options for rules. + +# Disable the transient hint so that idle_threshold cannot be bypassed from the +# client +#[transient_disable] +# match_transient = yes +# set_transient = no +# +# Make the handling of transient notifications more strict by making them not +# be placed in history. +#[transient_history_ignore] +# match_transient = yes +# history_ignore = yes + +# fullscreen values +# show: show the notifications, regardless if there is a fullscreen window opened +# delay: displays the new notification, if there is no fullscreen window active +# If the notification is already drawn, it won't get undrawn. +# pushback: same as delay, but when switching into fullscreen, the notification will get +# withdrawn from screen again and will get delayed like a new notification +#[fullscreen_delay_everything] +# fullscreen = delay +#[fullscreen_show_critical] +# msg_urgency = critical +# fullscreen = show + +#[espeak] +# summary = "*" +# script = dunst_espeak.sh + +#[script-test] +# summary = "*script*" +# script = dunst_test.sh + +#[ignore] +# # This notification will not be displayed +# summary = "foobar" +# skip_display = true + +#[history-ignore] +# # This notification will not be saved in history +# summary = "foobar" +# history_ignore = yes + +#[skip-display] +# # This notification will not be displayed, but will be included in the history +# summary = "foobar" +# skip_display = yes + +#[signed_on] +# appname = Pidgin +# summary = "*signed on*" +# urgency = low +# +#[signed_off] +# appname = Pidgin +# summary = *signed off* +# urgency = low +# +#[says] +# appname = Pidgin +# summary = *says* +# urgency = critical +# +#[twitter] +# appname = Pidgin +# summary = *twitter.com* +# urgency = normal +# +#[stack-volumes] +# appname = "some_volume_notifiers" +# set_stack_tag = "volume" +# +# vim: ft=cfg diff --git a/desktopenvs/hyprdrive/greetd-tuigreet/config.toml b/desktopenvs/hyprdrive/greetd-tuigreet/config.toml new file mode 100644 index 0000000..df24f86 --- /dev/null +++ b/desktopenvs/hyprdrive/greetd-tuigreet/config.toml @@ -0,0 +1,16 @@ +[terminal] +# The VT to run the greeter on. Can be "next", "current" or a number +# designating the VT. +vt = 1 + +# The default session, also known as the greeter. +[default_session] + +# `agreety` is the bundled agetty/login-lookalike. You can replace `/bin/sh` +# with whatever you want started, such as `sway`. +command = "tuigreet --cmd Hyprland" + +# The user to run the command as. The privileges this user must have depends +# on the greeter. A graphical greeter may for example require the user to be +# in the `video` group. +user = "greeter" diff --git a/desktopenvs/hyprdrive/gtk-3.0/bookmarks b/desktopenvs/hyprdrive/gtk-3.0/bookmarks new file mode 100644 index 0000000..63e3635 --- /dev/null +++ b/desktopenvs/hyprdrive/gtk-3.0/bookmarks @@ -0,0 +1 @@ +file://$HOME/Nextcloud diff --git a/desktopenvs/hyprdrive/gtk-3.0/settings.ini b/desktopenvs/hyprdrive/gtk-3.0/settings.ini new file mode 100644 index 0000000..01fa035 --- /dev/null +++ b/desktopenvs/hyprdrive/gtk-3.0/settings.ini @@ -0,0 +1,17 @@ +[Settings] +gtk-theme-name=cyberqueer +gtk-icon-theme-name=Papirus-Dark +gtk-font-name=Adwaita Sans 11 +gtk-cursor-theme-name=Nordzy-cursors-lefthand +gtk-cursor-theme-size=50 +gtk-toolbar-style=GTK_TOOLBAR_ICONS +gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR +gtk-button-images=0 +gtk-menu-images=0 +gtk-enable-event-sounds=1 +gtk-enable-input-feedback-sounds=0 +gtk-xft-antialias=1 +gtk-xft-hinting=1 +gtk-xft-hintstyle=hintslight +gtk-xft-rgba=rgb +gtk-application-prefer-dark-theme=1 diff --git a/desktopenvs/hyprdrive/horizon-dock/.gitignore b/desktopenvs/hyprdrive/horizon-dock/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/desktopenvs/hyprdrive/horizon-dock/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/desktopenvs/hyprdrive/horizon-dock/apps.py b/desktopenvs/hyprdrive/horizon-dock/apps.py new file mode 100644 index 0000000..3476802 --- /dev/null +++ b/desktopenvs/hyprdrive/horizon-dock/apps.py @@ -0,0 +1,77 @@ +"""All-apps data source (AstalApps, same library astal-menu's appdrawer uses) plus +favorite-app persistence — a plain JSON list of desktop-entry IDs under +~/.local/state/horizon-dock/favorites.json (see paths.py for why STATE_DIR, not +CONFIG_DIR).""" + +from __future__ import annotations + +import json + +import gi + +gi.require_version("AstalApps", "0.1") +from gi.repository import AstalApps # noqa: E402 + +from paths import FAVORITES_FILE, ensure_dirs + + +class AppSource: + def __init__(self) -> None: + self._apps = AstalApps.Apps() + self._favorites: list[str] = self._load_favorites() + self._wm_index: dict[str, str] = {} + + # -- listing ------------------------------------------------------------- + def all_apps(self) -> list: + """Every installed app, alphabetical by name.""" + apps = list(self._apps.get_list()) + apps.sort(key=lambda a: (a.get_name() or "").lower()) + return apps + + def icon_for_window(self, window: dict) -> str: + """Map an open window's wm class to its installed app's icon — same + wm-class/executable/name index astal-menu/ui/taskbar.py builds for its + own taskbar, rebuilt lazily here on first use.""" + if not self._wm_index: + for app in self._apps.get_list(): + for key in (app.get_wm_class(), app.get_executable(), app.get_name()): + if key: + self._wm_index.setdefault(key.lower(), app.get_icon_name()) + cls = (window.get("class") or "").lower() + return self._wm_index.get(cls) or cls or "application-x-executable" + + def favorite_apps(self) -> list: + """Favorited apps, in the order they were pinned. Entries whose app has + since been uninstalled are silently dropped (not un-favorited — the + desktop file may just be temporarily missing, e.g. mid-update).""" + by_entry = {a.get_entry(): a for a in self._apps.get_list()} + return [by_entry[e] for e in self._favorites if e in by_entry] + + @staticmethod + def launch(app) -> None: + app.launch() + + # -- favorites persistence ------------------------------------------------ + def is_favorite(self, app) -> bool: + return app.get_entry() in self._favorites + + def toggle_favorite(self, app) -> None: + entry = app.get_entry() + if not entry: + return + if entry in self._favorites: + self._favorites.remove(entry) + else: + self._favorites.append(entry) + self._save_favorites() + + def _load_favorites(self) -> list[str]: + try: + data = json.loads(FAVORITES_FILE.read_text()) + return [e for e in data if isinstance(e, str)] + except (FileNotFoundError, json.JSONDecodeError): + return [] + + def _save_favorites(self) -> None: + ensure_dirs() + FAVORITES_FILE.write_text(json.dumps(self._favorites, indent=2) + "\n") diff --git a/desktopenvs/hyprdrive/horizon-dock/config.py b/desktopenvs/hyprdrive/horizon-dock/config.py new file mode 100644 index 0000000..9f43f0e --- /dev/null +++ b/desktopenvs/hyprdrive/horizon-dock/config.py @@ -0,0 +1,37 @@ +"""Tiny user-editable config file: ~/.local/state/horizon-dock/config.json. + +Read once at startup (main.py); flags are passed into HorizonDock's constructor +rather than polled, so a change takes effect on the next horizon-dock-start.sh +restart, not live. Same pattern as orbit-menu/config.py. +""" + +from __future__ import annotations + +import json + +from paths import CONFIG_FILE, ensure_dirs + +# "tray": still undecided whether the full StatusNotifierItem satellite earns its +# keep vs a simpler dock — default on, but easy to switch off without touching code. +# "hologram": same toggle orbit-menu/astro-menu expose for their own overlay. +_DEFAULTS = {"tray": True, "hologram": True} + + +def _load() -> dict: + try: + data = json.loads(CONFIG_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + data = {} + merged = {**_DEFAULTS, **data} + if not CONFIG_FILE.exists(): + ensure_dirs() + CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n") + return merged + + +def tray_enabled() -> bool: + return bool(_load().get("tray", True)) + + +def hologram_enabled() -> bool: + return bool(_load().get("hologram", True)) diff --git a/desktopenvs/hyprdrive/horizon-dock/dock.py b/desktopenvs/hyprdrive/horizon-dock/dock.py new file mode 100644 index 0000000..88db4c3 --- /dev/null +++ b/desktopenvs/hyprdrive/horizon-dock/dock.py @@ -0,0 +1,380 @@ +"""horizon-dock — a hover-scrollable orbital dock, matching orbit-menu's visual +language (same CyberQueer glow/hover "planet" node styling). + +Anchored full-width to the bottom of the screen. Toggled show/hide (not an +always-resident hover-reveal dock); every time it's shown it fades+slides up +into place from below the screen edge, and hiding reverses that — driven by the +same per-frame tick-callback+easing pattern orbit-menu uses for its own +animations. Three rows of circular "planet" icon buttons — Open Windows, +Favorites, All Apps — each laid out evenly-spaced along a shared giant-circle +arc (the "horizon" curve: a shallow dip toward the screen edges, radius derived +from the monitor width so the sag reads consistently at any resolution). + +Hovering a row's Y-band selects it as the active scroll target — mouse-wheel +scrolling only ever moves the currently-hovered row (Gtk.EventControllerScroll), +independent of the other two. The tray satellite sits at the fixed right edge of +the Favorites row: its position is never touched by that row's scroll +repositioning, and it's added to the canvas last so it paints on top — meaning +overflowing favorites scrolling under it are simply covered, reading as "going +behind" the satellite. +""" + +from __future__ import annotations + +import math +from typing import Callable, Optional + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +gi.require_version("Gsk", "4.0") +gi.require_version("Graphene", "1.0") +gi.require_version("Gtk4LayerShell", "1.0") +from gi.repository import Gdk, Graphene, Gsk, Gtk # noqa: E402 +from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 + +import apps as apps_source +import windows as windows_source +from lib.hologram import HologramOverlay +from tray import TrayHost + +# CyberQueer accent/violet — hardcoded here as orbit-menu's own orbit_menu.py +# does for its Cairo drawing, since Cairo paints directly and doesn't see GTK +# CSS @define-color names. Kept in sync by eye with style/_colors.css. +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) +_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) + + +class HorizonDock(Gtk.Window): + DOCK_HEIGHT = 214 + PLANET_SIZE = 52 + ITEM_SPACING = 66.0 # center-to-center distance between planets in a row + ARC_SAG = 34.0 # how far a row dips toward the screen edges vs. its center + + ROW_ORDER = ["windows", "favorites", "apps"] # back -> front, top -> bottom + ROW_Y = {"windows": 46.0, "favorites": 112.0, "apps": 176.0} + ROW_BAND_TOP = {"windows": 0.0, "favorites": 79.0, "apps": 144.0} + ROW_TITLE = {"windows": "Open Windows", "favorites": "Favorites", "apps": "All Apps"} + + TRAY_SIZE = 40 + TRAY_MARGIN = 30.0 + SCROLL_SENSITIVITY = 0.9 + + REVEAL_DURATION = 0.28 + + def __init__(self, on_close: Optional[Callable[[], None]] = None, tray_enabled: bool = True, + hologram_enabled: bool = True): + super().__init__() + self.add_css_class("horizon-dock-window") + self._on_close = on_close + self._tray_enabled = tray_enabled + + self._apps = apps_source.AppSource() + self._tray = TrayHost() if tray_enabled else None + + self._width = self._monitor_width() + self._scroll_offset = {row: 0.0 for row in self.ROW_ORDER} + self._items: dict[str, list] = {row: [] for row in self.ROW_ORDER} # source objects + self._widgets: dict[str, list[Gtk.Widget]] = {row: [] for row in self.ROW_ORDER} + self._hovered_row: Optional[str] = None + + self._reveal_progress = 0.0 + self._reveal_target = 0.0 + self._sat_time = 0.0 + self._last_tick: Optional[float] = None + self._tick_id: Optional[int] = None + + self._init_layer_shell() + + self._bg = Gtk.DrawingArea() + self._bg.set_size_request(int(self._width), self.DOCK_HEIGHT) + self._bg.set_draw_func(self._draw_background) + self._bg.add_css_class("horizon-canvas") + + self._content = Gtk.Fixed() + self._content.set_size_request(int(self._width), self.DOCK_HEIGHT) + self._content.put(self._bg, 0, 0) + + self._viewport = Gtk.Fixed() + self._viewport.set_size_request(int(self._width), self.DOCK_HEIGHT) + self._viewport.put(self._content, 0, 0) + + self._hologram = HologramOverlay(enabled=hologram_enabled) + overlay = Gtk.Overlay() + overlay.set_child(self._viewport) + overlay.add_overlay(self._hologram.widget) + self.set_child(overlay) + + motion = Gtk.EventControllerMotion() + motion.connect("motion", self._on_motion) + motion.connect("leave", self._on_motion_leave) + self.add_controller(motion) + + scroll = Gtk.EventControllerScroll() + scroll.set_flags(Gtk.EventControllerScrollFlags.VERTICAL) + scroll.connect("scroll", self._on_scroll) + self.add_controller(scroll) + + self._tray_satellite: Optional[Gtk.Widget] = None + self._tray_popover: Optional[Gtk.Popover] = None + + self.set_visible(False) + self._rebuild_all() + + # -- layer shell ------------------------------------------------------ + def _init_layer_shell(self) -> None: + LayerShell.init_for_window(self) + LayerShell.set_layer(self, LayerShell.Layer.TOP) + LayerShell.set_namespace(self, "horizon-dock") + LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.NONE) + LayerShell.set_exclusive_zone(self, 0) + for edge in (LayerShell.Edge.LEFT, LayerShell.Edge.RIGHT, LayerShell.Edge.BOTTOM): + LayerShell.set_anchor(self, edge, True) + LayerShell.set_margin(self, edge, 0) + + def _monitor_width(self) -> int: + display = Gdk.Display.get_default() + monitors = display.get_monitors() if display is not None else None + mon = monitors.get_item(0) if monitors is not None and monitors.get_n_items() else None + return mon.get_geometry().width if mon is not None else 1920 + + # -- layout math -------------------------------------------------------- + def _arc_radius(self) -> float: + half_w = self._width / 2 + sag = self.ARC_SAG + return (sag * sag + half_w * half_w) / (2 * sag) + + def _row_y_at(self, row: str, x: float) -> float: + row_y = self.ROW_Y[row] + dx = x - self._width / 2 + r = self._arc_radius() + dip = r - math.sqrt(max(r * r - dx * dx, 0.0)) + return row_y + dip + + def _item_position(self, row: str, index: int) -> tuple[float, float]: + slot = index - self._scroll_offset[row] + x = self._width / 2 + slot * self.ITEM_SPACING + return x, self._row_y_at(row, x) + + def _clamp_scroll(self, row: str) -> None: + count = len(self._items[row]) + max_offset = max(0.0, count - 1) + self._scroll_offset[row] = min(max(self._scroll_offset[row], 0.0), max_offset) + + # -- planet buttons ------------------------------------------------------- + def _make_planet(self, icon_name: str, tooltip: str, size: int = PLANET_SIZE) -> Gtk.Button: + btn = Gtk.Button() + btn.set_has_frame(False) + btn.add_css_class("horizon-planet") + btn.set_size_request(size, size) + btn.set_tooltip_text(tooltip) + image = Gtk.Image.new_from_icon_name(icon_name or "application-x-executable") + image.set_pixel_size(int(size * 0.55)) + btn.set_child(image) + return btn + + def _rebuild_row(self, row: str) -> None: + for w in self._widgets[row]: + if w.get_parent() is not None: + self._content.remove(w) + self._widgets[row] = [] + + if row == "windows": + self._items[row] = windows_source.open_windows() + elif row == "favorites": + self._items[row] = self._apps.favorite_apps() + else: + self._items[row] = self._apps.all_apps() + + self._clamp_scroll(row) + + for i, item in enumerate(self._items[row]): + btn = self._build_item_button(row, item) + x, y = self._item_position(row, i) + self._content.put(btn, x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2) + self._widgets[row].append(btn) + + if row == "favorites": + self._place_tray_satellite() + + self._bg.queue_draw() + + def _build_item_button(self, row: str, item) -> Gtk.Button: + if row == "windows": + icon = self._apps.icon_for_window(item) + title = item.get("title") or item.get("class") or "?" + btn = self._make_planet(icon, title) + addr = item.get("address") + btn.connect("clicked", lambda *_a, a=addr: windows_source.focus_window(a)) + else: + btn = self._make_planet(item.get_icon_name(), item.get_name() or "") + btn.connect("clicked", lambda *_a, a=item: self._apps.launch(a)) + if row == "apps": + right_click = Gtk.GestureClick(button=3) + right_click.connect("pressed", lambda *_a, a=item: self._toggle_favorite(a)) + btn.add_controller(right_click) + return btn + + def _toggle_favorite(self, item) -> None: + self._apps.toggle_favorite(item) + self._rebuild_row("favorites") + + def _reflow_row(self, row: str) -> None: + for i, w in enumerate(self._widgets[row]): + x, y = self._item_position(row, i) + self._content.move(w, x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2) + + def _rebuild_all(self) -> None: + for row in self.ROW_ORDER: + self._rebuild_row(row) + + # -- tray satellite ------------------------------------------------------- + def _place_tray_satellite(self) -> None: + if not self._tray_enabled: + return + if self._tray_satellite is not None and self._tray_satellite.get_parent() is not None: + self._content.remove(self._tray_satellite) + + x = self._width - self.TRAY_MARGIN + y = self.ROW_Y["favorites"] + btn = Gtk.MenuButton() + btn.add_css_class("horizon-planet") + btn.add_css_class("horizon-tray-satellite") + btn.set_size_request(self.TRAY_SIZE, self.TRAY_SIZE) + btn.set_tooltip_text("Tray") + icon = Gtk.Image.new_from_icon_name("view-list-symbolic") + icon.set_pixel_size(int(self.TRAY_SIZE * 0.5)) + btn.set_child(icon) + + popover = Gtk.Popover() + popover.add_css_class("horizon-tray-popover") + popover.set_child(self._build_tray_box()) + btn.set_popover(popover) + self._tray_popover = popover + + self._content.put(btn, x - self.TRAY_SIZE / 2, y - self.TRAY_SIZE / 2) + self._tray_satellite = btn + + def _build_tray_box(self) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + box.add_css_class("horizon-tray-box") + if self._tray is None: + box.append(Gtk.Label(label="Tray disabled")) + return box + self._tray.build_into(box) + return box + + # -- hover / scroll routing ------------------------------------------------- + def _row_at_y(self, y: float) -> Optional[str]: + for row in reversed(self.ROW_ORDER): + if y >= self.ROW_BAND_TOP[row]: + return row + return None + + def _on_motion(self, _ctrl, _x: float, y: float) -> None: + row = self._row_at_y(y) + if row != self._hovered_row: + self._hovered_row = row + self._bg.queue_draw() + + def _on_motion_leave(self, _ctrl) -> None: + if self._hovered_row is not None: + self._hovered_row = None + self._bg.queue_draw() + + def _on_scroll(self, _ctrl, _dx: float, dy: float) -> bool: + row = self._hovered_row + if row is None: + return False + self._scroll_offset[row] += dy * self.SCROLL_SENSITIVITY + self._clamp_scroll(row) + self._reflow_row(row) + return True + + # -- background: the horizon arcs + hover band -------------------------- + def _draw_background(self, _area, cr, width: float, height: float) -> None: + for row in self.ROW_ORDER: + self._draw_row_arc(cr, row, width) + if self._hovered_row is not None: + self._draw_hover_band(cr, self._hovered_row, width) + + def _draw_row_arc(self, cr, row: str, width: float) -> None: + cr.save() + cr.set_source_rgba(*_VIOLET, 0.16) + cr.set_line_width(1.2) + cr.move_to(0, self._row_y_at(row, 0)) + steps = 48 + for s in range(1, steps + 1): + x = width * s / steps + cr.line_to(x, self._row_y_at(row, x)) + cr.stroke() + cr.restore() + + def _draw_hover_band(self, cr, row: str, width: float) -> None: + top = self.ROW_BAND_TOP[row] + bottom_candidates = [self.ROW_BAND_TOP[r] for r in self.ROW_ORDER if self.ROW_BAND_TOP[r] > top] + bottom = min(bottom_candidates) if bottom_candidates else self.DOCK_HEIGHT + cr.save() + cr.set_source_rgba(*_ACCENT, 0.05) + cr.rectangle(0, top, width, bottom - top) + cr.fill() + cr.restore() + + # -- reveal animation ----------------------------------------------------- + def _reveal_transform(self, progress: float) -> Gsk.Transform: + offset = (1.0 - progress) * self.DOCK_HEIGHT + t = Gsk.Transform.new() + t = t.translate(Graphene.Point().init(0, offset)) + return t + + def _on_tick(self, _widget, frame_clock) -> bool: + now = frame_clock.get_frame_time() / 1_000_000 + dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick) + self._last_tick = now + self._sat_time += dt + self._hologram.tick(dt) + + ease = 1 - math.exp(-dt * (1.0 / self.REVEAL_DURATION) * 3.2) + self._reveal_progress += (self._reveal_target - self._reveal_progress) * ease + if abs(self._reveal_target - self._reveal_progress) < 0.002: + self._reveal_progress = self._reveal_target + + self._viewport.set_child_transform(self._content, self._reveal_transform(self._reveal_progress)) + self._content.set_opacity(max(0.0, min(1.0, self._reveal_progress))) + + if self._reveal_progress == self._reveal_target and self._reveal_target == 0.0: + self.set_visible(False) + self._tick_id = None # GTK drops the callback itself on a False return + if self._on_close: + self._on_close() + return False + return True + + def _ensure_tick(self) -> None: + if self._tick_id is None: + self._tick_id = self.add_tick_callback(self._on_tick) + + def _stop_tick(self) -> None: + if self._tick_id is not None: + self.remove_tick_callback(self._tick_id) + self._tick_id = None + + # -- external control ---------------------------------------------------- + def show_dock(self) -> None: + self._width = self._monitor_width() + self._rebuild_all() + self._reveal_target = 1.0 + self.set_visible(True) + self.present() + self._ensure_tick() + + def hide_dock(self) -> None: + self._reveal_target = 0.0 + self._ensure_tick() + + def toggle(self) -> None: + if self.get_visible() and self._reveal_target == 1.0: + self.hide_dock() + else: + self.show_dock() diff --git a/desktopenvs/hyprdrive/horizon-dock/lib/hologram.py b/desktopenvs/hyprdrive/horizon-dock/lib/hologram.py new file mode 100644 index 0000000..8c03728 --- /dev/null +++ b/desktopenvs/hyprdrive/horizon-dock/lib/hologram.py @@ -0,0 +1,123 @@ +"""Holographic scanline/sweep/noise overlay — same treatment and tuning as +astro-menu's lib/hologram.py (itself matching orbit-menu's), reused here so +horizon-dock reads as one more orbit of the same Cosmonaut Shell suite. No +radial vignette mask: the dock's own module borders/canvas already bound it, +so covering the full rectangle reads as one continuous "HUD screen". + +Owns its own animation clock (advanced by dock.py's tick callback via +.tick(dt)) and a persistent particle list for the noise specs, exactly like +astro-menu/orbit-menu's hologram: each spec keeps its position/color for a +real randomized lifetime (fade in, hold, fade out) instead of every spec +teleporting to a new position every frame. +""" + +from __future__ import annotations + +import math +import random + +import cairo +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +# Same CyberQueer violet/magenta/red combo as orbit-menu's/astro-menu's hologram. +_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) +_MAGENTA = (0.92, 0.0, 0.65) +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) + + +class HologramOverlay: + SCANLINE_GAP = 4.0 + SCANLINE_ALPHA = 0.16 # fixed grid — kept clearly visible, not just a faint texture + SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass + SWEEP_HALF_HEIGHT = 90.0 + NOISE_COUNT = 95 # specs alive at once (each with its own lifetime) + NOISE_COLORS = [_MAGENTA, _ACCENT] + NOISE_LIFETIME = (0.5, 1.4) + NOISE_FADE_IN = 0.2 + NOISE_FADE_OUT = 0.35 + NOISE_ALPHA_RANGE = (0.10, 0.34) + + def __init__(self, enabled: bool = True) -> None: + self.enabled = enabled + self._sat_time = 0.0 + self._particles: list[dict] = [] + + self.widget = Gtk.DrawingArea() + self.widget.set_can_target(False) # never steals clicks from content underneath + self.widget.add_css_class("horizon-hologram") + self.widget.set_hexpand(True) + self.widget.set_vexpand(True) + self.widget.set_halign(Gtk.Align.FILL) + self.widget.set_valign(Gtk.Align.FILL) + self.widget.set_draw_func(self._draw_frame) + + def tick(self, dt: float) -> None: + if not self.enabled: + return + self._sat_time += dt + self.widget.queue_draw() + + # -- drawing -------------------------------------------------------------- + def _draw_frame(self, _area, cr, width: float, height: float) -> None: + if not self.enabled or width <= 0 or height <= 0: + return + r, g, b = _VIOLET + + cr.save() + cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA) + cr.set_line_width(1.0) + y = 0.0 + while y < height: + cr.move_to(0, y) + cr.line_to(width, y) + y += self.SCANLINE_GAP + cr.stroke() + cr.restore() + + phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD + sweep_y = phase * height + hh = self.SWEEP_HALF_HEIGHT + grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh) + grad.add_color_stop_rgba(0.0, r, g, b, 0.0) + grad.add_color_stop_rgba(0.5, r, g, b, 0.07) + grad.add_color_stop_rgba(1.0, r, g, b, 0.0) + cr.set_source(grad) + cr.rectangle(0, sweep_y - hh, width, hh * 2) + cr.fill() + + flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0) + cr.set_source_rgba(r, g, b, max(0.0, flicker)) + cr.paint() + + self._draw_noise(cr, width, height) + + def _draw_noise(self, cr, width: float, height: float) -> None: + now = self._sat_time + self._particles = [p for p in self._particles if now - p["birth"] < p["life"]] + while len(self._particles) < self.NOISE_COUNT: + self._particles.append({ + "x": random.uniform(0, width), + "y": random.uniform(0, height), + "w": random.uniform(1.0, 2.6), + "h": random.uniform(1.0, 2.0), + "color": random.choice(self.NOISE_COLORS), + "peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE), + "birth": now, + "life": random.uniform(*self.NOISE_LIFETIME), + }) + + for p in self._particles: + t = (now - p["birth"]) / p["life"] + if t < self.NOISE_FADE_IN: + envelope = t / self.NOISE_FADE_IN + elif t > 1.0 - self.NOISE_FADE_OUT: + envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT) + else: + envelope = 1.0 + r, g, b = p["color"] + cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope) + cr.rectangle(p["x"], p["y"], p["w"], p["h"]) + cr.fill() diff --git a/desktopenvs/hyprdrive/horizon-dock/main.py b/desktopenvs/hyprdrive/horizon-dock/main.py new file mode 100644 index 0000000..0a1e3a7 --- /dev/null +++ b/desktopenvs/hyprdrive/horizon-dock/main.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""horizon-dock — a hover-scrollable orbital dock for hyprdrive, matching +orbit-menu's visual language. + +Single-instance, same pattern as astal-menu/orbit-menu's main.py: the first +launch builds the (hidden) window and holds; later invocations forward their +verb over D-Bus via scripts/horizon-dock.sh instead of spawning a second +python3+GTK4 process. + + main.py run the resident instance (stays hidden until toggled) + main.py --show slide/fade in + main.py --hide slide/fade out + main.py --toggle whichever of the above applies +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# horizon-dock-start.sh LD_PRELOADs libgtk4-layer-shell (load-ordering requirement +# ahead of libwayland-client). Drop it once resident so it isn't inherited by +# anything this process launches (see astal-menu/orbit-menu's main.py for the same +# rationale — some GTK4 apps abort at startup with the layer-shell lib preloaded). +os.environ.pop("LD_PRELOAD", None) + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import gi # noqa: E402 + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib, Gtk # noqa: E402 + +import config # noqa: E402 +import theme # noqa: E402 +from dock import HorizonDock # noqa: E402 +from paths import APP_ID # noqa: E402 + + +class HorizonDockApp(Gtk.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID, + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + self.window: HorizonDock | None = None + + def do_startup(self) -> None: + Gtk.Application.do_startup(self) + theme.load_css() + self.window = HorizonDock(tray_enabled=config.tray_enabled(), + hologram_enabled=config.hologram_enabled()) + self._register_actions() + self.hold() # stay alive with no visible window + + def _register_actions(self) -> None: + def add(name: str, callback) -> None: + action = Gio.SimpleAction.new(name, None) + action.connect("activate", callback) + self.add_action(action) + + def guarded(fn): + def wrapper(*_a) -> None: + assert self.window is not None + fn(self.window) + return wrapper + + add("show", guarded(lambda w: w.show_dock())) + add("hide", guarded(lambda w: w.hide_dock())) + add("toggle", guarded(lambda w: w.toggle())) + + def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int: + args = list(cmdline.get_arguments()[1:]) + verb = args[0] if args else "--daemon" + if self.window is None: + return 0 + if verb == "--show": + self.window.show_dock() + elif verb == "--hide": + self.window.hide_dock() + elif verb == "--toggle": + self.window.toggle() + # --daemon and anything else: no-op (stay resident, hidden) + return 0 + + def do_activate(self) -> None: + pass # resident instance: nothing to do on plain activate + + +def main() -> int: + GLib.set_prgname("horizon-dock") + return HorizonDockApp().run(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktopenvs/hyprdrive/horizon-dock/paths.py b/desktopenvs/hyprdrive/horizon-dock/paths.py new file mode 100644 index 0000000..405f142 --- /dev/null +++ b/desktopenvs/hyprdrive/horizon-dock/paths.py @@ -0,0 +1,23 @@ +"""Shared filesystem locations. Works whether run from the repo or ~/.config.""" + +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +STYLE_DIR = BASE_DIR / "style" + +# User settings/favorites live under XDG_STATE_HOME, NOT ~/.config — config-updater +# does `rm -rf ~/.config/horizon-dock` on every dotfiles sync (see orbit-menu/ +# paths.py and astal-menu/paths.py for the same rationale), which would wipe a +# hand-edited config or pinned favorites on the spot. +STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "horizon-dock" +CONFIG_FILE = STATE_DIR / "config.json" +FAVORITES_FILE = STATE_DIR / "favorites.json" + +APP_ID = "eu.abdelbaki.horizondock" + + +def ensure_dirs() -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/desktopenvs/hyprdrive/horizon-dock/style/_colors.css b/desktopenvs/hyprdrive/horizon-dock/style/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/desktopenvs/hyprdrive/horizon-dock/style/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/desktopenvs/hyprdrive/horizon-dock/style/style.css b/desktopenvs/hyprdrive/horizon-dock/style/style.css new file mode 100644 index 0000000..f1dd10a --- /dev/null +++ b/desktopenvs/hyprdrive/horizon-dock/style/style.css @@ -0,0 +1,80 @@ +/* horizon-dock — CyberQueer orbital dock. Colors come from _colors.css + * (@text/@bg/@accent/@violet/@danger), loaded first by theme.py. Every icon is + * a "planet" node styled identically to orbit-menu's .orbit-node/.orbit-center + * (same glow/hover treatment, same radial-gradient formula) so the two + * components read as one continuous aesthetic. + */ + +window.horizon-dock-window { + background: transparent; +} + +/* The cyberqueer theme's `* { background-color: #1a1a1a }` paints DrawingArea + * nodes specifically (see orbit-menu/style/style.css's .orbit-hologram fix for + * the same discovery) — the background arc canvas is a DrawingArea sitting + * behind everything, so without this it paints an opaque rectangle that blots + * out the transparent window, which is what reads as "the system GTK theme". */ +.horizon-canvas, +.horizon-hologram { + background: transparent; + background-color: transparent; + background-image: none; +} + +/* -- planet nodes: identical language to orbit-menu's .orbit-node ------------ */ + +.horizon-planet { + border-radius: 9999px; + border: 1.5px solid alpha(@violet, 0.5); + color: @text; + background-image: radial-gradient(circle at 35% 30%, alpha(@violet, 0.18), alpha(@violet, 0.32) 80%); + transition: background 200ms ease, border-color 200ms ease, + box-shadow 220ms cubic-bezier(0.34, 1.56, 0.64, 1); + box-shadow: 0 0 0 0 alpha(@violet, 0); +} + +.horizon-planet * { + background: transparent; + background-color: transparent; + background-image: none; +} + +.horizon-planet:hover { + background-image: radial-gradient(circle at 35% 30%, alpha(@violet, 0.55), alpha(@violet, 0.32) 80%); + border-color: @violet; + box-shadow: 0 0 16px 1px alpha(@violet, 0.6); +} + +.horizon-planet:active { + box-shadow: 0 0 22px 3px alpha(@violet, 0.85); +} + +/* the tray satellite reads as "special" — accent-tinted instead of violet, and + * an OPAQUE background (not the translucent radial-gradient the other planets + * use) so overflowing favorites scrolling underneath it visibly disappear + * behind it rather than showing through. Opaque, but still violet-tinted + * rather than flat gray (a literal near-black-violet, not @bg/@violet, since + * this one spot genuinely needs full opacity for the occlusion to work). */ +.horizon-tray-satellite { + border-color: alpha(@accent, 0.55); + background-image: none; + background-color: #140b28; +} +.horizon-tray-satellite:hover { + border-color: @accent; + box-shadow: 0 0 16px 1px alpha(@accent, 0.6); +} + +.horizon-tray-popover { + background-color: #140b28; +} + +.horizon-tray-box { + padding: 8px; +} + +.horizon-tray-item { + border-radius: 9999px; + min-width: 28px; + min-height: 28px; +} diff --git a/desktopenvs/hyprdrive/horizon-dock/theme.py b/desktopenvs/hyprdrive/horizon-dock/theme.py new file mode 100644 index 0000000..433c8c1 --- /dev/null +++ b/desktopenvs/hyprdrive/horizon-dock/theme.py @@ -0,0 +1,30 @@ +"""Load the two stylesheets as ordered CSS providers — same scheme as orbit-menu's +and astal-menu's theme.py. _colors.css (generated from ~/Dotfiles/colors.conf by +apply-theme.sh) defines the CyberQueer @define-color names; style.css consumes them. + +Priority is USER+1 for the same reason as the other two apps: the CyberQueer GTK +theme at ~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above APPLICATION +(600), and would beat our transparent structural containers otherwise. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +from paths import STYLE_DIR + + +def load_css() -> None: + display = Gdk.Display.get_default() + for name in ("_colors.css", "style.css"): + path = STYLE_DIR / name + if not path.exists(): + continue + provider = Gtk.CssProvider() + provider.load_from_path(str(path)) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1 + ) diff --git a/desktopenvs/hyprdrive/horizon-dock/tray.py b/desktopenvs/hyprdrive/horizon-dock/tray.py new file mode 100644 index 0000000..354e7d3 --- /dev/null +++ b/desktopenvs/hyprdrive/horizon-dock/tray.py @@ -0,0 +1,140 @@ +"""Minimal StatusNotifierItem (SNI) HOST — connects to whichever +org.kde.StatusNotifierWatcher is already running (the existing eww bars' own +systray widget registers one) and renders the currently registered tray items +as clickable planet buttons. The SNI spec allows multiple hosts, so registering +alongside eww's own host is expected and fine, not a conflict. + +Rebuilt fresh each time the tray popover box is built (dock.py calls +build_into() on every dock reveal, since favorites — and therefore the tray +satellite — rebuild then) rather than live-subscribed to Registered/ +Unregistered signals — simple and correct for a popover that's only open +briefly; if no watcher is running at all, build_into() shows a small +placeholder instead of raising. +""" + +from __future__ import annotations + +from typing import Optional + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib, Gtk # noqa: E402 + +_WATCHER_BUS = "org.kde.StatusNotifierWatcher" +_WATCHER_PATH = "/StatusNotifierWatcher" +_WATCHER_IFACE = "org.kde.StatusNotifierWatcher" +_ITEM_IFACE = "org.kde.StatusNotifierItem" +_HOST_NAME = "org.kde.StatusNotifierHost-horizon-dock" + + +class TrayHost: + def __init__(self) -> None: + self._watcher: Optional[Gio.DBusProxy] = None + self._registered_host = False + self._connect() + + def _connect(self) -> None: + try: + proxy = Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, + _WATCHER_BUS, _WATCHER_PATH, _WATCHER_IFACE, None, + ) + # A proxy can be constructed even for a name with no owner yet — only + # a real name-owner query tells us whether a watcher is actually up. + self._watcher = proxy if proxy.get_name_owner() is not None else None + except GLib.Error: + self._watcher = None + + def _ensure_host_registered(self) -> None: + if self._registered_host or self._watcher is None: + return + try: + self._watcher.call_sync( + "RegisterStatusNotifierHost", GLib.Variant("(s)", (_HOST_NAME,)), + Gio.DBusCallFlags.NONE, 500, None, + ) + except GLib.Error: + pass # some watchers don't require/support this explicitly — fine either way + self._registered_host = True + + def _registered_items(self) -> list[str]: + if self._watcher is None: + return [] + value = self._watcher.get_cached_property("RegisteredStatusNotifierItems") + if value is None: + return [] + return list(value.unpack()) + + @staticmethod + def _parse_item(entry: str) -> tuple[str, str]: + """Registration strings are either just a bus name (path defaults to + /StatusNotifierItem) or "busname/ObjectPath" — implementations disagree, + so accept both.""" + if "/" in entry: + bus, _, path = entry.partition("/") + return bus, "/" + path + return entry, "/StatusNotifierItem" + + # -- public: build the popover contents ----------------------------------- + def build_into(self, box: Gtk.Box) -> None: + """Populate `box` with one planet button per currently registered tray + item. Safe to call repeatedly (dock.py does, on every dock reveal).""" + if self._watcher is None: + box.append(Gtk.Label(label="No tray watcher running")) + return + self._ensure_host_registered() + + entries = self._registered_items() + if not entries: + box.append(Gtk.Label(label="(empty)")) + return + + for entry in entries: + bus, path = self._parse_item(entry) + btn = self._build_item_button(bus, path) + if btn is not None: + box.append(btn) + + def _build_item_button(self, bus: str, path: str) -> Optional[Gtk.Button]: + try: + proxy = Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, + bus, path, _ITEM_IFACE, None, + ) + except GLib.Error: + return None + + icon_name = self._prop_str(proxy, "IconName") or "application-x-executable" + title = self._prop_str(proxy, "Title") or self._prop_str(proxy, "IconName") or bus + + btn = Gtk.Button() + btn.set_has_frame(False) + btn.add_css_class("horizon-planet") + btn.add_css_class("horizon-tray-item") + btn.set_tooltip_text(title) + image = Gtk.Image.new_from_icon_name(icon_name) + image.set_pixel_size(20) + btn.set_child(image) + btn.connect("clicked", lambda *_a, p=proxy: self._activate(p)) + + right_click = Gtk.GestureClick(button=3) + right_click.connect("pressed", lambda *_a, p=proxy: self._context_menu(p)) + btn.add_controller(right_click) + return btn + + @staticmethod + def _prop_str(proxy: Gio.DBusProxy, name: str) -> str: + value = proxy.get_cached_property(name) + return value.unpack() if value is not None else "" + + @staticmethod + def _activate(proxy: Gio.DBusProxy) -> None: + proxy.call("Activate", GLib.Variant("(ii)", (0, 0)), + Gio.DBusCallFlags.NONE, -1, None, None, None) + + @staticmethod + def _context_menu(proxy: Gio.DBusProxy) -> None: + proxy.call("ContextMenu", GLib.Variant("(ii)", (0, 0)), + Gio.DBusCallFlags.NONE, -1, None, None, None) diff --git a/desktopenvs/hyprdrive/horizon-dock/windows.py b/desktopenvs/hyprdrive/horizon-dock/windows.py new file mode 100644 index 0000000..9c0662c --- /dev/null +++ b/desktopenvs/hyprdrive/horizon-dock/windows.py @@ -0,0 +1,27 @@ +"""Open-window data source — synchronous `hyprctl clients -j`, same pattern as +orbit-menu/menu_tree.py's _hyprctl_json (a local unix-socket round trip, cheap +enough to call straight from the UI thread; refreshed each time the dock is +revealed, not polled continuously).""" + +from __future__ import annotations + +import json +import subprocess + + +def open_windows() -> list[dict]: + try: + out = subprocess.run(["hyprctl", "clients", "-j"], capture_output=True, + text=True, timeout=1.5, check=True).stdout + clients = json.loads(out) + except (subprocess.SubprocessError, json.JSONDecodeError, OSError): + return [] + wins = [w for w in clients if w.get("mapped", True) and w.get("class")] + wins.sort(key=lambda w: (w.get("workspace", {}).get("id", 0), + (w.get("title") or w.get("class") or "").lower())) + return wins + + +def focus_window(address: str) -> None: + subprocess.Popen(["hyprctl", "dispatch", f'hl.dsp.focus({{ window = "address:{address}" }})'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) diff --git a/desktopenvs/hyprdrive/hypr/application-style.conf b/desktopenvs/hyprdrive/hypr/application-style.conf new file mode 100644 index 0000000..1a2b8d6 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/application-style.conf @@ -0,0 +1,9 @@ +# ============================================================================= +# application-style.conf — Placeholder for per-application Hyprland styling +# +# This file is intentionally empty. It is reserved for future use as a place +# to override decoration, opacity, or blur settings on a per-application basis +# (e.g., forcing a specific app to use a different rounding or border colour). +# +# Any rules added here will be sourced by hyprland.lua or the main conf chain. +# ============================================================================= diff --git a/desktopenvs/hyprdrive/hypr/deprecated/hyprland.conf.old b/desktopenvs/hyprdrive/hypr/deprecated/hyprland.conf.old new file mode 100644 index 0000000..2a9f2e1 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/deprecated/hyprland.conf.old @@ -0,0 +1,384 @@ + +source = ~/.config/input.conf +source = ~/.config/monitors.conf + + +# https://wiki.hyprland.org/Configuring/Variables/#gestures +gestures { + workspace_swipe = true +} + +# Example per-device config +# See https://wiki.hyprland.org/Configuring/Keywords/#per-device-input-configs for more +device { + name = epic-mouse-v1 + sensitivity = -0.5 +} + + + +#autogenerated = 0 # remove this line to remove the warning + +# This is an example Hyprland config file. +# Refer to the wiki for more information. +# https://wiki.hyprland.org/Configuring/Configuring-Hyprland/ + +# Please note not all available settings / options are set here. +# For a full list, see the wiki + +# You can split this configuration into multiple files +# Create your files separately and then link them to this file like this: +# source = ~/.config/hypr/myColors.conf + + +################ +### MONITORS ### +################ + +# See https://wiki.hyprland.org/Configuring/Monitors/ +monitor=,preferred,auto,auto + +################ +### XWayland ### +################ + +# change monitor to high resolution, the last argument is the scale factor +#monitor = , highres, auto, 2 + +# unscale XWayland +xwayland { + force_zero_scaling = true +} + +# toolkit-specific scale +env = GDK_SCALE,2 +env = XCURSOR_SIZE,32 + +################### +### MY PROGRAMS ### +################### + +# See https://wiki.hyprland.org/Configuring/Keywords/ + +# Set programs that you use +$terminal = kitty +$fileManager = kitty -e yazi +$editor = kitty micro +$menu = wofi --show=drun + + +################# +### AUTOSTART ### +################# + +# Autostart necessary processes (like notifications daemons, status bars, etc.) +# Or execute your favorite apps at launch like this: + +exec-once=bash ~/.config/hypr/ewwstart.sh + +#exec-once = waybar +exec-once = dunst +# exec-once = nm-applet & +exec-once = swww-daemon & swww img /ast/bg.jpg & sleep 1s & swww restore + + +############################# +### ENVIRONMENT VARIABLES ### +############################# + +# See https://wiki.hyprland.org/Configuring/Environment-variables/ + +env = XCURSOR_SIZE,40 +env = HYPRCURSOR_SIZE,40 + + +##################### +### LOOK AND FEEL ### +##################### + +# Refer to https://wiki.hyprland.org/Configuring/Variables/ + +# https://wiki.hyprland.org/Configuring/Variables/#general +general { + gaps_in = 5 + gaps_out = 20 + + border_size = 4 + + # https://wiki.hyprland.org/Configuring/Variables/#variable-types for info about colors + col.active_border = rgb(E40046) rgb(fc0588) 40deg + col.inactive_border = rgb(5018dd) + + # Set to true enable resizing windows by clicking and dragging on borders and gaps + resize_on_border = false + + # Please see https://wiki.hyprland.org/Configuring/Tearing/ before you turn this on + allow_tearing = false + + layout = dwindle +} + +# https://wiki.hyprland.org/Configuring/Variables/#decoration +decoration { + rounding = 20 + + # Change transparency of focused and unfocused windows + active_opacity = 1 + inactive_opacity = 0.8 + #drop_shadow = true + #shadow_range = 4 + #shadow_render_power = 3 + #col.shadow = rgba(1a1a1aee) + + # https://wiki.hyprland.org/Configuring/Variables/#blur + blur { + enabled = true + size = 3 + passes = 3 + + vibrancy = 0.1696 + } +} + +# https://wiki.hyprland.org/Configuring/Variables/#animations +animations { + enabled = true + + # Default animations, see https://wiki.hyprland.org/Configuring/Animations/ for more + + bezier = myBezier, 0.05, 0.9, 0.1, 1.05 + + animation = windows, 1, 7, myBezier + animation = windowsOut, 1, 7, default, popin 80% + animation = border, 1, 10, default + animation = borderangle, 1, 8, default + animation = fade, 1, 7, default + #animation = workspaces, 1, 6, default + #animation = workspaces,1,5,default,slidevert + animation = specialWorkspace, 1, 10, default, slidevert +} + +# See https://wiki.hyprland.org/Configuring/Dwindle-Layout/ for more +dwindle { + pseudotile = true # Master switch for pseudotiling. Enabling is bound to mainMod + P in the keybinds section below + preserve_split = true # You probably want this +} + +# See https://wiki.hyprland.org/Configuring/Master-Layout/ for more +master { + new_status = master +} + +# https://wiki.hyprland.org/Configuring/Variables/#misc +misc { + force_default_wallpaper = 0 # Set to 0 or 1 to disable the anime mascot wallpapers + disable_hyprland_logo = true # If true disables the random hyprland logo / anime girl background. :( +} + + + +#################### +### KEYBINDINGSS ### +#################### + +# See https://wiki.hyprland.org/Configuring/Keywords/ +$mainMod = SUPER # Sets "Windows" key as main modifier + +# Example binds, see https://wiki.hyprland.org/Configuring/Binds/ for more +bind = $mainMod, T, exec, $terminal +bind = $mainMod, Z, exec, $terminal btop +bind = $mainMod, K, killactive, +bind = $mainMod, M, exec, $editor +bind = $mainMod, F, exec, $fileManager +bind = $mainMod, V, togglefloating, +bind = $mainMod, H, movecurrentworkspacetomonitor +bind = $mainMod, D, pseudo, # dwindle +bind = $mainMod, J, togglesplit, # dwindle +bind = $mainMod, L, exec, hyprctl dispatch exit 1 +#bind = $mainMod, A, exec, killall waybar ; waybar +bind = $mainMod, A, exec, eww reload +bind = $mainMod, N, exec, nextcloud +bind = $mainMod, I, exec, kitty nmtui +bind = $mainMod, S, exec, kitty pulsemixer + + +# Move focus with mainMod + arrow keys +bind = $mainMod, left, movefocus, l +bind = $mainMod, right, movefocus, r +bind = $mainMod, up, movefocus, u +bind = $mainMod, down, movefocus, d + +# Switch workspaces with mainMod + [0-9] +bind = $mainMod, 1, workspace, 1 +bind = $mainMod, 2, workspace, 2 +bind = $mainMod, 3, workspace, 3 +bind = $mainMod, 4, workspace, 4 +bind = $mainMod, 5, workspace, 5 +bind = $mainMod, 6, workspace, 6 +bind = $mainMod, 7, workspace, 7 +bind = $mainMod, 8, workspace, 8 +bind = $mainMod, 9, workspace, 9 +bind = $mainMod, 0, workspace, 10 + +# Move active window to a workspace with mainMod + SHIFT + [0-9] +bind = $mainMod SHIFT, 1, movetoworkspace, 1 +bind = $mainMod SHIFT, 2, movetoworkspace, 2 +bind = $mainMod SHIFT, 3, movetoworkspace, 3 +bind = $mainMod SHIFT, 4, movetoworkspace, 4 +bind = $mainMod SHIFT, 5, movetoworkspace, 5 +bind = $mainMod SHIFT, 6, movetoworkspace, 6 +bind = $mainMod SHIFT, 7, movetoworkspace, 7 +bind = $mainMod SHIFT, 8, movetoworkspace, 8 +bind = $mainMod SHIFT, 9, movetoworkspace, 9 +bind = $mainMod SHIFT, 0, movetoworkspace, 10 + +# Example special workspace (scratchpad) +bind = $mainMod, SPACE, togglespecialworkspace, magic +bind = $mainMod SHIFT, SPACE, movetoworkspace, special:magic + +bind = $mainMod CTRL, up, togglespecialworkspace, magic +bind = $mainMod CTRL SHIFT, up, movetoworkspace, special:magic + + +# Scroll through existing workspaces with mainMod + scroll +bind = $mainMod, mouse_down, workspace, e+1 +bind = $mainMod, mouse_up, workspace, e-1 + +binde = $mainMod, XF86AudioLowerVolume, workspace, e-1 +binde = $mainMod, XF86AudioRaiseVolume, workspace, e+1 + +bind = $mainMod SHIFT, XF86AudioLowerVolume, movetoworkspace, e-1 +bind = $mainMod SHIFT, XF86AudioRaiseVolume, movetoworkspace, e+1 + +bind = $mainMod CTRL, left, workspace, e-1 +bind = $mainMod CTRL, right, workspace, e+1 + +bind = $mainMod CTRL SHIFT, left, movetoworkspace, e-1 +bind = $mainMod CTRL SHIFT, right, movetoworkspace, e+1 + +# Move/resize windows with mainMod + LMB/RMB and dragging +bindm = $mainMod, mouse:272, movewindow +bindm = $mainMod, mouse:273, resizewindow +bindm = $mainMod SHIFT, mouse:272, resizewindow + +# Move/rezie windows with Keyboard + +#rofi keybind +bind = $mainMod, R, exec, wofi --show drun +bind = CTRL SHIFT, R, exec, wofi --show drun + +#window location edit mode source: https://www.reddit.com/r/hyprland/comments/14jehzj/comment/jpl5c8f/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button + +#bind = $mainMod, A, submap, fastedit +#submap = fastedit +bind = $mainMod ALT, right, resizeactive, 10 0 +bind = $mainMod ALT, left, resizeactive, -10 0 +bind = $mainMod ALT, up, resizeactive, 0 -10 +bind = $mainMod ALT, down, resizeactive, 0 10 + +bind = $mainMod SHIFT, left, movewindow, l +bind = $mainMod SHIFT, right, movewindow, r +bind = $mainMod SHIFT, up, movewindow, u +bind = $mainMod SHIFT, down, movewindow, d +#bind = , escape, submap, reset +#submap = reset + +bind = $mainMod SHIFT, K , exec, hyprctl kill + + + +#flameshot +#monitor=eDP-1, preferred, 0x0, 1 +#monitor=DP-1, preferred, 1920x0, 1 +# +#windowrule=fakefullscreen, flameshot +#windowrule=float,flameshot +#windowrule=monitor 0,flameshot +#windowrule=move 0 0,flameshot +#windowrule=workspace 0,flameshot +#windowrule=stayfocused,flameshot + +#screenshot capture +bind = , Print, exec, grim -g "$(slurp -d)" - | wl-copy +bind = $mainMod, P, exec, grim -g "$(slurp -d)" - | wl-copy + +#audio controls +binde =, XF86AudioRaiseVolume, exec, wpctl set-volume -l 1.4 @DEFAULT_AUDIO_SINK@ 5%+ + +binde =, XF86AudioLowerVolume, exec, wpctl set-volume -l 1.4 @DEFAULT_AUDIO_SINK@ 5%- + +bind =, XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle + +bindle=, XF86MonBrightnessUp, exec, bri --up +bindle=, XF86MonBrightnessDown, exec, bri --down + +bindl=, XF86AudioPlay, exec, playerctl play-pause -p spotify, vlc + +#exec-once = flameshot +#bind = $mainMod, Print, exec, flameshot gui +#bind = $mainMod, Print, exec, XDG_CURRENT_DESKTOP=hyprland flameshot gui -r | wl-copy + +############################## +### WINDOWS AND WORKSPACES ### +############################## + +# See https://wiki.hyprland.org/Configuring/Window-Rules/ for more +# See https://wiki.hyprland.org/Configuring/Workspace-Rules/ for workspace rules + +# Example windowrule v1 +windowrule = float, ^(rofi)$ + +# Example windowrule v2 +# windowrulev2 = float,class:^(kitty)$,title:^(kitty)$ + +windowrulev2 = suppressevent maximize, class:.* # You'll probably like this. + +#windowrulev2 = noborder, class:wofi +windowrulev2 = float, class:wofi +windowrulev2 = pin, class:wofi +windowrulev2 = center, class:wofi +windowrulev2 = stayfocused, class:wofi +windowrulev2 = nomaxsize, class:wofi +windowrulev2 = minsize 5 5, class:wofi +windowrulev2 = noshadow, class:wofi + +windowrulev2 = opacity 1 0.8, class:kitty +windowrulev2 = opacity 0.8 0.3, initialTitle:Spotify +windowrulev2 = float, initialTitle:Spotify + + +#sefix +windowrulev2 = float, class:Spaceengineers +windowrulev2 = stayfocused, class:Spaceengineers + +#rwfix +windowrulev2 = tile, class:RimWorldLinux + +windowrulev2 = float, class:Drill.+ +#windowrulev2 = fakefullscreen, title:Drill.+ + +#windowrule = fakefullscreen, class:steam_app_1217060 +#windowrule = fakefullscreen, title:Gunfire Reborn + +windowrule = float, class:steam_app_1217060 +windowrule = float, title:Gunfire Reborn + +#windowrulev2 = fullscreen, class:steam_app_1217060 +#windowrulev2 = fullscreen, title:Gunfire Reborn + +windowrulev2 = float, title:pico.+ + +windowrulev2 = minsize 60 70, title:.+btop.+ + +windowrulev2 = stayfocused, class:.+dmenu.+ + +windowrulev2 = move 70% 10%, class:com.nextcloud.desktopclient.nextcloud +windowrulev2 = float, class:com.nextcloud.desktopclient.nextcloud +windowrulev2 = pin, class:com.nextcloud.desktopclient.nextcloud +windowrulev2 = size 28% 50%, class:com.nextcloud.desktopclient.nextcloud + +#exec-once = nextcloud +exec-once=bash swww img ~/Pictures/background.jpg && swww restore +#exec-once=bash dunst +#exec-once=bash nm-applet --indicator +#exec-once=exec, [workspace 2 silent; float; move 0 0] kitty diff --git a/desktopenvs/hyprdrive/hypr/deprecated/hyprlock.conf.old b/desktopenvs/hyprdrive/hypr/deprecated/hyprlock.conf.old new file mode 100644 index 0000000..eedf683 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/deprecated/hyprlock.conf.old @@ -0,0 +1,142 @@ +# /* ---- 💫 https://github.com/JaKooLit 💫 ---- */ # +# Hyprlock +# Original config submitted by https://github.com/SherLock707 + +# Sourcing colors generated by wallust +source = $HOME/.config/hypr/wallust/wallust-hyprland.conf +$Scripts = $HOME/.config/hypr/scripts + +general { + grace = 2 + hide_cursor = true + ignore_empty_input = true + +} + +auth { + pam:module = sudo +} + +background { + monitor = + #path = screenshot # screenshot of your desktop + path = ~/Pictures/background.jpg # NOTE only png supported for now + #color = $color7 + + # all these options are taken from hyprland, see https://wiki.hyprland.org/Configuring/Variables/#blur for explanations + blur_size = 5 + blur_passes = 1 # 0 disables blurring + noise = 0.0117 + contrast = 1.3000 # Vibrant!!! + brightness = 0.8000 + vibrancy = 0.2100 + vibrancy_darkness = 0.0 +} + +input-field { + monitor = + size = 250, 50 + outline_thickness = 8 + dots_size = 0.33 # Scale of input-field height, 0.2 - 0.8 + dots_spacing = 0.15 # Scale of dots' absolute size, 0.0 - 1.0 + dots_center = true + #fade_on_empty = true + placeholder_text = Password... # Text rendered in the input box when it's empty. + hide_input = false + + position = 0, 200 + halign = center + valign = bottom +} + +# Date +label { + monitor = + text = cmd[update:18000000] echo " "$(date +'%A, %-d %B %Y')" " + color = $color12 + font_size = 34 + font_family = Agave NerdFont 10 + + position = 0, -150 + halign = center + valign = top +} + +# Week +label { + monitor = + text = cmd[update:18000000] echo " "$(date +'Week %U')" " + color = $color5 + font_size = 24 + font_family = Agave NerdFont 10 + position = 0, -250 + halign = center + valign = top +} + +# Time +label { + monitor = + #text = cmd[update:1000] echo " $(date +"%I:%M:%S %p") " # AM/PM + text = cmd[update:1000] echo " $(date +"%H:%M:%S") " # 24H + color = $color15 + font_size = 94 + font_family = Agave NerdFont 10 + + position = 0, 0 + halign = center + valign = center +} + +# User +label { + monitor = + text =  $USER + color = $color12 + font_size = 18 + font_family = Agave NerdFont 10 + + position = 0, 100 + halign = center + valign = bottom +} + +# uptime +label { + monitor = + text = cmd[update:60000] echo " "$(uptime -p || $Scripts/UptimeNixOS.sh)" " + color = $color12 + font_size = 24 + font_family = Agave NerdFont 10 + + position = 0, 0 + halign = right + valign = bottom +} + +# weather edit specific location. Note, this cause a 2-4 seconds delay in locking +label { + monitor = + text = cmd[update:10000] sh ~/Dotfiles/desktopenvs/hyprland/eww/scripts/batteryperc + color = $color12 + font_size = 24 + font_family = Agave NerdFont 10 + position = 50, 0 + halign = left + valign = bottom +} + +# Put a picture of choice here. Default is the current wallpaper +image { + monitor = + path = $HOME/.config/hypr/wallpaper_effects/.wallpaper_current + size = 230 + rounding = -1 + border_size = 2 + border_color = $color11 + rotate = 0 + reload_time = -1 + position = 0, 300 + halign = center + valign = bottom +} diff --git a/desktopenvs/hyprdrive/hypr/hypridle.conf b/desktopenvs/hyprdrive/hypr/hypridle.conf new file mode 100644 index 0000000..27b7f92 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/hypridle.conf @@ -0,0 +1,56 @@ +# ============================================================================= +# hypridle.conf — Idle management daemon configuration +# +# hypridle watches for user inactivity and triggers actions (lock, suspend) +# after configurable timeouts. It integrates with the presence-detection daemon +# (presence-detect.sh) which resets the idle timer every 20s (backed off to 120s +# under heavy CPU/RAM load) while the webcam detects motion, so these timeouts +# only fire when the user has truly stepped away from the machine. +# +# Reference: https://wiki.hypr.land/Hypr-Ecosystem/hypridle/ +# ============================================================================= + +general { + # Command to run when the lock screen should be shown. + # `pidof hyprlock || hyprlock` first checks if hyprlock is already running + # (pidof returns 0 if found) and only launches a new instance if it is not. + # This prevents stacking multiple lock screen instances on repeated trigger. + lock_cmd = pidof hyprlock || hyprlock + + # Lock the session immediately when the system is about to suspend. + # loginctl lock-session sends the Lock D-Bus signal to the session manager, + # which hyprlock listens to, ensuring the screen is locked before the + # display blanks during suspend. + before_sleep_cmd = loginctl lock-session + + # fprintd restart ensures fingerprint sensor is ready after resume + # Turn the display back on after the system wakes from suspend. + # `hyprctl dispatch dpms on` sends DPMS power-on to all connected monitors. + after_sleep_cmd = hyprctl dispatch dpms on + + # ignore_dbus_inhibit = false means hypridle honours D-Bus idle-inhibit locks. + # This allows systemd-inhibit (used by the presence-detect daemon and the + # caffeine script) to pause the idle timer while they are active. + ignore_dbus_inhibit = false # respect systemd-inhibit locks (presence-detect, caffeine) +} + +# Presence detection resets the idle timer every 20s (up to 120s under load) +# while motion is detected, so these timeouts only run when you've actually +# stepped away. + +# First idle listener: lock the screen after 30s of inactivity. +listener { + timeout = 30 # 30s — lock screen + # loginctl lock-session triggers the session lock signal; hyprlock picks it up. + on-timeout = loginctl lock-session +} + +# Second idle listener: sleep after 1 minute of inactivity. suspend-then-hibernate +# suspends to RAM immediately (fast resume); HibernateDelaySec — set system-wide by +# hypr/systemd-sleep.conf, deployed to /etc/systemd/sleep.conf.d/ by sysupdate.sh — +# then hibernates (writes RAM to disk, fully powers it down) 79 minutes later, so +# hibernate lands ~80 minutes after the machine first went idle (1min + 79min). +listener { + timeout = 60 # 1 min — sleep + on-timeout = systemctl suspend-then-hibernate +} diff --git a/desktopenvs/hyprdrive/hypr/hyprland.lua b/desktopenvs/hyprdrive/hypr/hyprland.lua new file mode 100644 index 0000000..b585f1d --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/hyprland.lua @@ -0,0 +1,227 @@ +-- ============================================================================= +-- hyprland.lua — Root Hyprland Lua configuration entry point +-- +-- This is the top-level config file loaded by Hyprland when the Lua backend +-- is active. It is intentionally kept thin: device-specific settings (monitors, +-- keybinds, environment variables, input, window rules, autostart programs) all +-- live in the usr/ subdirectory so they can be deployed per-machine without +-- touching this shared file. +-- +-- Reference: https://wiki.hypr.land/Configuring/Start/ +-- Device-specific files live in ~/.config/hypr/usr/ (deployed from hypr/usr/). +-- ============================================================================= + +-- Pull in per-device configuration modules. +-- monitors is loaded from the Lua search path root (monitors.lua, managed by +-- hyprmoncfg); the remaining modules live in usr/ and are per-machine overrides. +-- They are loaded in this specific order so that later modules can safely +-- depend on earlier ones (e.g., binds may reference programs launched by +-- autostart; input-device-exceptions builds on the global input baseline). +require("usr.monitors") -- monitor layout, resolution, scale (managed by hyprmoncfg) +require("usr.envvars") -- environment variables injected into every child process +require("usr.input") -- keyboard layout, mouse sensitivity, touchpad behaviour +require("usr.binds") -- all keybindings and mouse button bindings +require("usr.windowrules") -- per-app float/pin/opacity/tag/size rules +require("usr.autostart") -- programs launched once when the compositor starts + +-------------------- +---- MY PROGRAMS --- +-------------------- + +-- Convenience variables naming the preferred application for each role. +-- These are referenced by keybind definitions in usr/binds.lua so that +-- changing the preferred terminal, editor, etc. requires only a single edit here. +local terminal = "kitty" -- GPU-accelerated terminal emulator +local fileManager = "thunar" -- GTK file manager (Xfce/GNOME ecosystem) +local editor = "kitty nvim" -- Neovim inside kitty; avoids a separate GUI wrapper +local menu = "vicinae toggle" -- vicinae is the custom app-launcher; toggle shows/hides it + +--------------------- +---- LOOK & FEEL ---- +--------------------- + +-- hl.config() is the Lua API equivalent of Hyprland's declarative keyword blocks. +-- All visual and layout settings for the compositor are collected here so the full +-- theming picture is visible in one place rather than spread across multiple files. +hl.config({ + general = { + -- Inner gap between tiled windows (px). + -- Kept small (3 px) so screen real-estate is not wasted on multi-monitor setups. + gaps_in = 3, + -- Outer gap between the outermost windows and the monitor edge (px). + -- Slightly larger than gaps_in (6 px) to give the desktop a "framed" look, + -- ensuring windows never touch the absolute screen edge. + gaps_out = 6, + -- Border width around every window (px). 4 px is thick enough to show the + -- animated gradient colour clearly at 1.5x HiDPI scaling. + border_size = 4, + col = { + -- Active window border: an animated gradient cycling through red hues at 35 degrees. + -- Five colour stops alternating between #E40046 and #f50505 create a slow pulsing + -- effect. The angle = 35 rotates the gradient continuously (see borderangle animation). + active_border = { colors = { "rgb(E40046)", "rgb(f50505)", "rgb(E40046)", "rgb(f50505)", "rgb(E40046)" }, angle = 35 }, + -- Inactive border: deep electric blue (#5018dd). Blue recedes visually relative to + -- the hot red active border, making focus immediately obvious without being distracting. + inactive_border = "rgb(5018dd)", + }, + -- Disable resize-by-grabbing the window border. This prevents accidental resizes + -- when the cursor clips the edge during fast workspace switching or window dragging. + resize_on_border = false, + -- Tearing introduces screen-tear artefacts in exchange for lower latency. + -- Disabled here for a clean image; enable per-window via windowrules for games. + allow_tearing = false, + -- The default tiling algorithm is chosen at the end of this file by the layout + -- registry (require("layouts.init") → layouts.set_default), not hard-coded here. + }, + group = { + -- Window groups stack multiple windows into one tiled slot with a tab bar. + -- These colours keep the group tabs visually consistent with the main border theme. + col = { + -- Active tab in a window group: accent red to match the active window border. + border_active = "rgb(E40046)", + -- Inactive tabs fade to dark blue to de-emphasise them. + border_inactive = "rgb(5018dd)", + -- "Locked" groups cannot be accidentally moved between workspaces. + -- A slightly different red (#f50505) distinguishes them from ordinary active groups. + border_locked_active = "rgb(f50505)", + border_locked_inactive = "rgb(5018dd)", + }, + groupbar = { + -- Agave NerdFont provides both monospace text and NerdFont icon glyphs, + -- letting the bar display icons without requiring a separate symbol font. + font_family = "Agave NerdFont", + font_size = 20, -- px; large enough to be readable at 1.5x HiDPI scale + height = 25, -- px; matches indicator_height for a full-height flat pill + -- round_only_edges = false: every tab gets the same border-radius, not just + -- the first and last, giving a uniform pill row rather than a single rounded strip. + round_only_edges = false, + -- indicator_height = height (25 px): the selected-tab indicator fills the entire + -- bar height instead of being a thin underline, making the active tab unmissable. + indicator_height = 25, + -- stacked = false: tabs appear side-by-side horizontally (breadcrumb style), + -- not vertically stacked. Horizontal layout conserves vertical screen space. + stacked = false, + -- Tab label text colour: accent red, consistent with the active border colour. + text_color = "rgb(E40046)", + -- priority = 3 ensures the groupbar is rendered above regular window decorations + -- (shadows, rounded corners) so it is never visually clipped. + priority = 3, + -- Rounded corners on the groupbar container (13 px radius). The value is chosen + -- to complement the 20 px window rounding without looking disproportionate. + rounding = 13, + col = { + -- Active tab fill: solid accent red, clearly selected. + active = "rgb(E40046)", + -- Inactive tab fill: blue background recedes visually. + inactive = "rgb(5018dd)", + -- Locked-group tab colours mirror the border locked colours for consistency. + locked_active = "rgb(E40046)", + locked_inactive = "rgb(5018dd)", + }, + }, + }, + decoration = { + -- Window corner radius (px). 20 px gives a modern "card" look that matches + -- the GTK cyberqueer theme's rounded widgets. + rounding = 20, + -- Focused window: fully opaque (1.0) so content is crisp and readable. + active_opacity = 1, + -- Unfocused windows: 80% opacity hints at the depth/focus hierarchy and lets + -- the wallpaper or background windows bleed through subtly without obscuring content. + inactive_opacity = 0.8, + blur = { + -- Gaussian blur applied behind transparent/translucent surfaces (terminals, + -- unfocused windows). This creates the "frosted glass" depth effect. + enabled = true, + size = 3, -- blur kernel radius in pixels; small = tight, localised blur halo + passes = 3, -- number of successive blur passes; more = smoother but higher GPU cost + -- vibrancy boosts colour saturation of the blurred background so the wallpaper + -- colours stay vivid even when blurred under a translucent surface. + vibrancy = 0.1696, + }, + }, + animations = { + -- Master switch for all compositor animations. + -- Individual animation types are configured below in the animations section. + enabled = true, + }, + -- Per-layout settings (dwindle/master/scrolling/monocle) live in hypr/layouts/*.lua + -- and are applied by the layout registry, so they are not repeated here. + misc = { + -- force_default_wallpaper = 0 disables Hyprland's built-in anime wallpaper so + -- hyprpaper (or any other wallpaper daemon) has exclusive control of the background. + force_default_wallpaper = 0, + -- Remove the Hyprland logo / splash animation that plays on startup. + -- The custom wallpaper is shown instead from the first frame. + disable_hyprland_logo = true, + }, +}) + +----------------- +---- ANIMATIONS -- +----------------- + +-- Define a custom cubic Bezier easing curve named "myBezier". +-- Control points P1 = {0.05, 0.9} and P2 = {0.1, 1.05} produce a slight overshoot +-- (spring-like bounce) at the end of the motion — windows "pop" snappily into their +-- final position rather than gliding to a dead stop. This gives the DE an energetic feel. +hl.curve("myBezier", { type = "bezier", points = { {0.05, 0.9}, {0.1, 1.05} } }) + +-- Window open and resize animation uses the custom spring curve. +-- speed = 7 (Hyprland internal units; higher = faster) keeps the open feeling snappy. +hl.animation({ leaf = "windows", enabled = true, speed = 7, bezier = "myBezier" }) + +-- Window close animation: "popin 80%" shrinks the window to 80% of its original size +-- while it fades out, giving a quick snappy dismiss effect rather than a slow full collapse. +hl.animation({ leaf = "windowsOut", enabled = true, speed = 7, bezier = "default", style = "popin 80%" }) + +-- Border colour crossfade when focus changes (active red <-> inactive blue). +-- speed = 10 makes this almost instantaneous so focus changes feel immediate. +hl.animation({ leaf = "border", enabled = true, speed = 10, bezier = "default" }) + +-- Animated gradient angle for the active border (the rotating multi-stop red gradient). +-- speed = 8 produces a slow, ambient-pulse sweep rather than a distracting fast spin. +hl.animation({ leaf = "borderangle", enabled = true, speed = 8, bezier = "default" }) + +-- Opacity crossfade when a window gains or loses focus (1.0 <-> 0.8 transition). +hl.animation({ leaf = "fade", enabled = true, speed = 7, bezier = "default" }) + +-- Special (scratchpad) workspace entrance/exit animation. +-- "slidevert" makes it drop in from the top / slide back up, distinguishing it clearly +-- from regular workspace transitions which use horizontal slides by default. +hl.animation({ leaf = "specialWorkspace", enabled = true, speed = 10, bezier = "default", style = "slidevert" }) + +-- Layer-shell surfaces (the astal-menu control centre, notifications, the bar) slide +-- in from their anchored edge with the bouncy myBezier curve, so opening the menu +-- reads as a springy slide-down rather than a plain fade. (A per-namespace layerrule +-- was tried but this hyprlua build doesn't honour its animation timing; the global +-- `layers` leaf does.) +hl.animation({ leaf = "layers", enabled = true, speed = 5, bezier = "myBezier", style = "slide" }) + +-------------- +---- DEVICE --- +-------------- + +-- Per-device input override for a specific mouse identified by its kernel event name. +-- Only fields that differ from the global input settings need to be listed here. +-- sensitivity = -0.5 (valid range -1 to 1) reduces pointer speed for this mouse model +-- without touching the global sensitivity, allowing different mice to have different feels. +hl.device({ + name = "epic-mouse-v1", + sensitivity = -0.5, +}) + +--------------- +---- LAYOUTS --- +--------------- + +-- Load the per-file layout registry (hypr/layouts/*.lua) and select the session +-- default. Switching a workspace's layout at runtime is done from the astal-menu +-- popup via `hyprctl eval 'layouts.set(ws, name, dir)'`. +require("layouts.init") +layouts.set_default("columns") +-- special:magic defaults to a downward scrolling layout instead of the session +-- default; layouts.restore() below still wins if the user has since picked +-- something else for it via the astal-menu. +layouts.set("special:magic", "scrolling", "down") +layouts.restore() -- re-apply per-workspace layout choices so a reload doesn't drop them diff --git a/desktopenvs/hyprdrive/hypr/hyprlock-backgrounds.conf b/desktopenvs/hyprdrive/hypr/hyprlock-backgrounds.conf new file mode 100644 index 0000000..32e3eca --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/hyprlock-backgrounds.conf @@ -0,0 +1,12 @@ +# default — overwritten by ~/.config/scripts/wallpaper-picker +background { + monitor = + path = ~/Pictures/background.jpg + blur_size = 6 + blur_passes = 2 + noise = 0.015 + contrast = 1.25 + brightness = 0.85 + vibrancy = 0.2 + vibrancy_darkness = 0.1 +} diff --git a/desktopenvs/hyprdrive/hypr/hyprlock.conf b/desktopenvs/hyprdrive/hypr/hyprlock.conf new file mode 100644 index 0000000..b42d5f4 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/hyprlock.conf @@ -0,0 +1,141 @@ +# Hyprlock Themed Config 💫 Matching EWW + + +general { + # grace: seconds after lock is triggered during which the lock can be dismissed + # without a password (e.g. accidental idle). Set to 0 to require password immediately. + grace = 2 + + hide_cursor = true + + # Do not attempt authentication if the input field is empty; prevents accidental + # PAM lockouts from empty-string submissions when brushing the keyboard. + ignore_empty_input = true +} + +auth { + # PAM service name to use for authentication (/etc/pam.d/hyprlock). + # The hyprlock PAM stack is where howdy (biometric) and FIDO2 are wired in. + pam:module = hyprlock + + # Enable fingerprint unlock via fprintd. Works alongside the password field; + # whichever succeeds first (finger or typed password) dismisses the lock. + fingerprint:enabled = true +} + +# per-monitor backgrounds written by ~/.config/scripts/wallpaper-picker +source = ~/.config/hypr/hyprlock-backgrounds.conf + +input-field { + # Empty monitor = field appears on all monitors. Name a specific output (e.g. DP-1) + # to restrict the field to one screen. + monitor = + + # Width × height in pixels of the password box. + size = 300, 60 + + # Pixel thickness of the coloured outline drawn around the input box. + outline_thickness = 4 + + # Dot diameter as a fraction of the input-field height (0.0–1.0). + # 0.5 = dots are half the field height, giving a balanced look. + dots_size = 0.5 + + # Gap between dots as a fraction of dot size. 0.2 adds a small breathing room. + dots_spacing = 0.2 + + dots_center = true + hide_input = false + + # Pango markup is supported; renders italic placeholder text. + placeholder_text = Password... + + # Position is (x-offset, y-offset) from the anchor set by halign/valign. + # 20% pushes the field 20% of screen height above the bottom edge. + position = 0, 20% + halign = center + valign = bottom + + # Accent border & color matching EWW + # Dark near-black fill so text/dots are readable against any wallpaper. + inner_color = rgb(1a1a1a) + font_color = rgb(b0b4bc) + font_family = Agave Nerd Font Mono +} + +# DATE +label { + # cmd[update:N] re-runs the command every N milliseconds and replaces the label text. + # 18000000 ms = 5 hours — the date only changes once a day, so this is generous. + text = cmd[update:18000000] sh ~/Dotfiles/desktopenvs/hyprland/scripts/date.sh + color = rgb(5018dd) + font_size = 34 + font_family = Agave Nerd Font Mono + # position = (x-offset, y-offset) from the halign/valign anchor. + # 0, 40% = horizontally centred, 40% of screen height above the screen centre. + position = 0, 40% + halign = center + valign = center +} + +# TIME +label { + # update:1000 = refresh every second so the clock ticks in real time. + text = cmd[update:1000] sh ~/Dotfiles/desktopenvs/hyprland/scripts/time.sh + color = rgb(E40046) + # Large font makes the time the dominant visual element on the lock screen. + font_size = 95 + font_family = Agave Nerd Font Mono + # 25% above centre keeps the time above the input field while staying mid-screen. + position = 0, 25% + halign = center + valign = center +} + +# USER +label { + text =  $USER + color = rgb(5018dd) + font_size = 18 + font_family = Agave Nerd Font Mono + position = 0, 10% + halign = center + valign = bottom +} + +# UPTIME +label { + text = cmd[update:60000] sh ~/Dotfiles/desktopenvs/hyprland/scripts/uptime.sh + font_size = 24 + color = rgb(5018dd) + font_family = Agave Nerd Font Mono + position = 0, 0 + halign = right + valign = bottom +} + +# BATTERY +label { + text = cmd[update:10000] sh ~/Dotfiles/desktopenvs/hyprland/scripts/batteryperc + color = rgb(5018dd) + font_size = 24 + font_family = Agave Nerd Font Mono + position = 50, 0 + halign = left + valign = bottom +} + +# PROFILE PICTURE OR LOGO +image { + path = ~/Pictures/profile.jpg + size = 230 + rounding = 110 + border_size = 4 + border_color = rgb(5018dd) + rotate = 0 + position = 0, 40% + halign = center + valign = bottom +} + + diff --git a/desktopenvs/hyprdrive/hypr/hyprpaper.conf b/desktopenvs/hyprdrive/hypr/hyprpaper.conf new file mode 100644 index 0000000..74fbbc1 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/hyprpaper.conf @@ -0,0 +1,11 @@ +splash = false + +# fallback if no per-monitor entries exist (first boot) +wallpaper { + monitor = + path = ~/Pictures/background.jpg + fit_mode = cover +} + +# per-monitor state, written by ~/.config/scripts/wallpaper-picker +source = ~/.config/hypr/usr/wallpaper.conf diff --git a/desktopenvs/hyprdrive/hypr/hyprtoolkit.conf b/desktopenvs/hyprdrive/hypr/hyprtoolkit.conf new file mode 100644 index 0000000..ae65999 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/hyprtoolkit.conf @@ -0,0 +1,15 @@ +background = rgb(1A1A1A) +base = rgb(5018DD) +font_family = Agave Nerd Font Mono +font_family_monospace = Agave Nerd Font Mono +text = rgb(D6ABAB) +alternate_base = rgb(E40046) +bright_text = rgb(F50505) +accent = rgb(E40046) +accent_secondary = rgb(5018DD) +icon_theme = Papirus +h1_size = 15 +h1_size = 20 +h1_size = 30 +font_size = 10 +small_font_size = 5 diff --git a/desktopenvs/hyprdrive/hypr/layouts/columns.lua b/desktopenvs/hyprdrive/hypr/layouts/columns.lua new file mode 100644 index 0000000..649ec77 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/layouts/columns.lua @@ -0,0 +1,494 @@ +-- Columns: a custom (Lua) tiling layout — a row of vertical columns. Think of it as +-- one long horizontal strip of columns that spans every monitor: focus/move flow off +-- the edge of one monitor onto the next, so it behaves like a huge row of scrolling +-- workspaces rather than a set of isolated boxes. +-- +-- Each column is an INDEPENDENTLY-SCROLLING vertical strip (the whole point): when a +-- column holds more windows than fit, it pans to keep the focused one in view while the +-- other columns stay put — like the wheels of a combination lock. Sizing is flexible, +-- nothing is a fixed fraction: +-- * Each COLUMN has a width weight; the viewport width is split between columns by +-- those weights. Resizing left/right grows/shrinks the focused column. +-- * Each WINDOW has a height weight. The base height of a weight-1 window is +-- viewport/`rows` (rows = windows-per-view). A column that is *underfull* stretches +-- its windows to fill (a lone window fills the column; two equal windows share it +-- 50/50) — so you can stack a file-explorer over a terminal in column 1 while +-- Firefox owns column 2. A column that is *overfull* keeps natural heights and +-- SCROLLS. Resizing up/down retunes the focused window's height weight. +-- Weights and per-column pan persist across relayouts. +-- +-- This is a real custom layout (hl.layout.register), not the built-in scrolling engine — +-- that one has a single shared tape; this pans each column apart. +-- +-- Controls (routed here from binds.lua via `hl.dsp.layout("")` → layout_msg): +-- focus l/r/u/d — l/r step between columns (off the edge → next/prev monitor), +-- u/d step within the focused column (which pans to it). Swap vertical. +-- move l/r/u/d — l/r move between columns (off the edge → next/prev monitor). +-- u/d PARK/UNPARK within the column: moving toward a single neighbour +-- parks the two side by side in one slot (persistent; both keep the full +-- scroll-axis length, split across the column — a vertical divider when +-- horizontal — and the column closes up so there's no gap). Moving a +-- parked window again pops it out and past its partner. +-- resize l/r/u/d — l/r change the focused column's width, u/d the focused window's +-- height (weights, so neighbours give/take space). Axes swap vertical. +-- count h/j/k/l — horizontal ws: h/l = −/+ columns, k/j = −/+ windows-per-view. +-- vertical ws: k/j = −/+ columns, h/l = −/+ per-view. +-- cols +/-/N — direct column-count set (the menu's stepper). +-- orient h/v — orientation (set from the menu dropdown). +-- center on/off/toggle — "center-focused" switch (menu): when a column is overfull +-- and scrolls, keep the focused window centred in the column +-- instead of the minimal-movement default (whichever edge of the +-- focused window would otherwise go off-screen just barely comes +-- back into view). Global (mirrors the scrolling layout's +-- "Centered follow" toggle), persisted across reloads, OFF by +-- default. Only ever repositions the column holding the focused +-- window — every other column's pan is untouched, since there is +-- only one focused window at a time. +-- +-- State is per workspace; a window's column assignment, order, both weight tables and +-- each column's pan offset persist across relayouts. + +local state = {} -- [wsid] = { ncols, rows, orient, assign={addr=col}, seq={addr=n}, + -- nextseq, colw={col=weight}, winh={addr=weight}, pan={col=px} } + +-- Resize weights: base 1.0, clamped to a sane band so nothing collapses or hogs. +local RSTEP, RMIN, RMAX = 0.15, 0.25, 6.0 +local function clampw(v) return math.max(RMIN, math.min(RMAX, v)) end + +-- Publish per-workspace column count so the astro-menu can show a live stepper. +local STATE_FILE = (os.getenv("HOME") or "") .. "/.cache/astro-menu/columns-state.json" + +-- "center-focused" switch — global (like the scrolling layout's own fit-method +-- toggle), so a single state, not per-workspace. Seeded once at module load from +-- the last-published state, since a reload wipes this module's Lua state (same +-- reason ncols/rows/orient are seeded — see seed() below). +local center_focus = false +do + local f = io.open(STATE_FILE, "r") + if f then + local data = f:read("*a") or ""; f:close() + if data:match('"_center"%s*:%s*true') then center_focus = true end + end +end + +local function publish() + local parts = { string.format('"_center":%s', tostring(center_focus)) } + for wsid, s in pairs(state) do + parts[#parts + 1] = string.format('"%s":{"cols":%d,"rows":%d,"orient":"%s"}', + tostring(wsid), s.ncols, s.rows, s.orient) + end + pcall(function() + local f = io.open(STATE_FILE, "w") + if f then f:write("{" .. table.concat(parts, ",") .. "}\n"); f:close() end + end) +end + +-- Seed ncols/rows/orient for a workspace from the last published state — a reload wipes +-- this module's Lua state, so without this the user's column count and orientation reset +-- to defaults every reload. (Per-window assignment/weights are not restored; they reflow.) +local function seed(wsid) + local out = {} + local f = io.open(STATE_FILE, "r") + if not f then return out end + local data = f:read("*a") or ""; f:close() + local blob = data:match('"' .. tostring(wsid) .. '"%s*:%s*(%b{})') + if not blob then return out end + out.ncols = tonumber(blob:match('"cols"%s*:%s*(%d+)')) + out.rows = tonumber(blob:match('"rows"%s*:%s*(%d+)')) + out.orient = blob:match('"orient"%s*:%s*"(%a+)"') + return out +end + +-- First-ever layout of a workspace (no seeded/published state) picks its starting +-- column count from the monitor's usable width, ~COL_TARGET_WIDTH px per column, so a +-- wide monitor doesn't start out with one cramped default column and a narrow one +-- doesn't start out overcrowded. 1080p (1920px) / 700 -> 2 columns. +local COL_TARGET_WIDTH = 700 + +local function ws_state(wsid, area) + local s = state[wsid] + if not s then + local sd = seed(wsid) + local orient = sd.orient or "h" + local ncols = sd.ncols + if not ncols then + local len = area and ((orient == "h") and area.w or area.h) + ncols = (len and len > 0) and math.max(1, math.floor(len / COL_TARGET_WIDTH)) or 2 + end + s = { ncols = ncols, rows = sd.rows or 2, orient = orient, + assign = {}, seq = {}, nextseq = 1, colw = {}, winh = {}, pan = {}, pair = {} } + state[wsid] = s + publish() + end + return s +end + +-- Reflow every window evenly across the current column count, by insertion order. +local function reflow(s, targets) + local addrs = {} + for _, tg in ipairs(targets) do + local ok, addr = pcall(function() return tg.window.address end) + if ok and addr then addrs[#addrs + 1] = addr end + end + table.sort(addrs, function(a, b) return (s.seq[a] or 0) < (s.seq[b] or 0) end) + for i, addr in ipairs(addrs) do s.assign[addr] = ((i - 1) % s.ncols) + 1 end + for k in pairs(s.pair) do s.pair[k] = nil end -- a redistribute breaks parked pairs +end + +local function win_ws_id(win) + local ok, ws = pcall(function() return win.workspace end) + if ok and ws ~= nil then + local ok2, id = pcall(function() return ws.id end) + if ok2 and id then return id end + local id2 = tostring(ws):match("%((%-?%d+)") + if id2 then return tonumber(id2) end + end + return 0 +end + +local function focused_addr() + local aw = hl.get_active_window and hl.get_active_window() + if not aw then return nil end + local ok, a = pcall(function() return aw.address end) + return ok and a or nil +end + +-- Build the per-column ordered address lists from the live targets, reconciling the +-- persistent assignment (drop closed windows; a single new window stacks into the +-- working column, a batch spreads across the emptiest columns — see below). +local function columnize(s, targets) + local present, tgt_of = {}, {} + for _, tg in ipairs(targets) do + local ok, addr = pcall(function() return tg.window.address end) + if ok and addr then present[addr] = true; tgt_of[addr] = tg end + end + for addr in pairs(s.assign) do + if not present[addr] then + local p = s.pair[addr] + if p then s.pair[p] = nil; s.pair[addr] = nil end -- release a parked partner + s.assign[addr] = nil; s.seq[addr] = nil; s.winh[addr] = nil + end + end + local foc = focused_addr() + local counts = {} + for c = 1, s.ncols do counts[c] = 0 end + for addr, c in pairs(s.assign) do + if present[addr] and c >= 1 and c <= s.ncols then counts[c] = counts[c] + 1 end + end + local fresh = {} + for addr in pairs(present) do if not s.assign[addr] then fresh[#fresh + 1] = addr end end + local function least_col() + local best, bestn = 1, math.huge + for c = 1, s.ncols do if counts[c] < bestn then best, bestn = c, counts[c] end end + return best + end + if #fresh == 1 and next(s.assign) ~= nil then + -- ONE new window while others already exist → it stacks into the column the user + -- is working in (the new window itself grabbed focus, so use the remembered + -- last-focused column). Opening a 2nd window thus shares that column instead of + -- spreading to an empty one — that's how you get two windows at one height. + local c = s.lastcol + if not (c and c >= 1 and c <= s.ncols) then + -- focus memory not ready (a just-opened window's focus can lag the relayout) + -- → join the most-recently-opened window's column instead. + local best_seq = -1 + for addr, col in pairs(s.assign) do + if present[addr] and (s.seq[addr] or 0) > best_seq then + best_seq = s.seq[addr]; c = col + end + end + if not (c and c >= 1 and c <= s.ncols) then c = least_col() end + end + -- ...but don't let one column run away: if it's already 2+ ahead of the emptiest, + -- send the newcomer to the emptiest instead. This both balances rapid opens and + -- keeps a reload (which re-adds every window one-by-one) from piling into column 1. + local lc = least_col() + if counts[c] - counts[lc] >= 2 then c = lc end + s.assign[fresh[1]] = c; counts[c] = counts[c] + 1 + s.seq[fresh[1]] = s.nextseq; s.nextseq = s.nextseq + 1 + else + -- first window, or several appearing at once (session start / restore) → spread + -- across the emptiest columns so a fresh session isn't one giant pile. + for _, addr in ipairs(fresh) do + local c = least_col() + s.assign[addr] = c; counts[c] = counts[c] + 1 + s.seq[addr] = s.nextseq; s.nextseq = s.nextseq + 1 + end + end + for addr, c in pairs(s.assign) do + if c > s.ncols then s.assign[addr] = s.ncols elseif c < 1 then s.assign[addr] = 1 end + end + -- remember the focused window's column so the *next* new window stacks there. + if foc and s.assign[foc] then s.lastcol = s.assign[foc] end + local cols = {} + for c = 1, s.ncols do cols[c] = {} end + for addr in pairs(present) do table.insert(cols[s.assign[addr]], addr) end + for c = 1, s.ncols do + table.sort(cols[c], function(a, b) return (s.seq[a] or 0) < (s.seq[b] or 0) end) + end + return cols, tgt_of, foc +end + +local function recalculate(ctx) + local t = ctx.targets + if type(t) ~= "table" or not t[1] then return end + local A = ctx.area + if type(A) ~= "table" then return end + local s = ws_state(win_ws_id(t[1].window), A) + local cols, tgt_of, foc = columnize(s, t) + local horizontal = (s.orient ~= "v") + local rows = math.max(1, s.rows) + + -- primary axis is split between columns; the secondary axis stacks each column's + -- windows and pans (scrolls) when they overflow. + local primLen = horizontal and A.w or A.h + local primOrg = horizontal and A.x or A.y + local secLen = horizontal and A.h or A.w + local secOrg = horizontal and A.y or A.x + + local csum = 0 + for c = 1, s.ncols do csum = csum + (s.colw[c] or 1) end + if csum <= 0 then csum = 1 end + + local cpos = primOrg + for c = 1, s.ncols do + local colLen = primLen * ((s.colw[c] or 1) / csum) + local list = cols[c] + local m = #list + if m > 0 then + -- group the column into rows: two mutually-paired, adjacent windows share one + -- row side by side (a "parked" pair); every other window is its own row. Pairing + -- removes a row, so the column closes up around a park — no gap. + local rowlist = {} + local i = 1 + while i <= m do + local a, b = list[i], list[i + 1] + if b and s.pair[a] == b and s.pair[b] == a then + rowlist[#rowlist + 1] = { a, b }; i = i + 2 + else + rowlist[#rowlist + 1] = { a }; i = i + 1 + end + end + + -- base height of a weight-1 row is viewport / windows-per-view; a parked row + -- takes the larger of its two weights so neither window is clipped. + local unit = secLen / rows + local function wof(addr) local w = s.winh[addr] or 1; return (w > 0) and w or 1 end + local rh, total = {}, 0 + for r, row in ipairs(rowlist) do + local w = (#row == 2) and math.max(wof(row[1]), wof(row[2])) or wof(row[1]) + rh[r] = w * unit; total = total + rh[r] + end + + -- row holding the focused window (drives the pan) + local frow + for r, row in ipairs(rowlist) do + for _, addr in ipairs(row) do if addr == foc then frow = r end end + end + + local pan + if total <= secLen then + -- underfull: stretch rows to fill so there's no dead space. + local scale = secLen / total + for r = 1, #rowlist do rh[r] = rh[r] * scale end + pan = 0 + else + -- overfull: keep natural heights and PAN so the focused row stays in view + -- (center_focus: centred in the viewport instead of minimal movement). + -- Only the column holding the focused window (frow ~= nil) is touched here; + -- every other column keeps whatever pan it already had. + pan = s.pan[c] or 0 + if frow then + local top = 0 + for r = 1, frow - 1 do top = top + rh[r] end + local bottom = top + rh[frow] + if center_focus then + pan = top + (rh[frow] - secLen) / 2 + else + if top - pan < 0 then pan = top end + if bottom - pan > secLen then pan = bottom - secLen end + end + end + -- clamp to the column's actual scroll range either way — this is also what + -- keeps "centred" from showing blank space past the first/last row. + pan = math.max(0, math.min(pan, total - secLen)) + end + s.pan[c] = pan + + -- place a box given position/length on the primary (column) axis and the + -- secondary (scroll/stacking) axis, mapped to x/y per orientation. + local function place_box(addr, ppos, plen, spos, slen) + local box = horizontal + and { x = ppos, y = spos, w = plen, h = slen } + or { x = spos, y = ppos, w = slen, h = plen } + local tg = tgt_of[addr] + if tg then pcall(function() tg:place(box) end) end + end + + local spos = secOrg - pan + for r, row in ipairs(rowlist) do + if #row == 1 then + place_box(row[1], cpos, colLen, spos, rh[r]) + else + -- parked pair: side by side, split across the column, both keeping the + -- full row length along the scroll axis (a vertical divider horizontally). + local halfp = colLen / 2 + place_box(row[1], cpos, halfp, spos, rh[r]) + place_box(row[2], cpos + halfp, halfp, spos, rh[r]) + end + spos = spos + rh[r] + end + end + cpos = cpos + colLen + end +end + +local function layout_msg(ctx, cmd) + local verb, arg = tostring(cmd or ""):match("^(%S+)%s*(%S*)$") + if not verb then return end + + -- "center-focused" switch: global, doesn't touch any window, so handled before the + -- empty-workspace bail-out below (toggling it from the menu shouldn't require a + -- window to be focused/present). + if verb == "center" then + if arg == "on" then center_focus = true + elseif arg == "off" then center_focus = false + else center_focus = not center_focus end + publish() + return + end + + local t = ctx.targets + if type(t) ~= "table" or not t[1] then return end + local s = ws_state(win_ws_id(t[1].window)) + local cols, _, foc = columnize(s, t) + local horizontal = (s.orient ~= "v") + + local fc = (foc and s.assign[foc]) or 1 + local fi = 1 + if foc then for i, a in ipairs(cols[fc] or {}) do if a == foc then fi = i end end end + + -- l/r (horizontal) or u/d (vertical) run perpendicular to the column axis → these + -- act "between columns"; the other pair acts within the focused column. + local function between(a) return horizontal and (a == "l" or a == "r") or (not horizontal and (a == "u" or a == "d")) end + local function decrease(a) return a == "l" or a == "u" end + local function unpair(addr) + local p = addr and s.pair[addr] + if p then s.pair[p] = nil; s.pair[addr] = nil end + end + + if verb == "focus" then + if between(arg) then + local nc = fc + (decrease(arg) and -1 or 1) + if nc < 1 or nc > s.ncols then + -- Off the edge: jump to the monitor spatially in that screen direction + -- (arg is already "l"/"r"/"u"/"d", i.e. the edge we just fell off of), + -- not Hyprland's own -1/+1 list-order cycling — see monitor-adjacent for + -- why that distinction matters once a monitor has a transform. + hl.exec_cmd("~/.config/scripts/monitor-adjacent focus " .. arg) + elseif cols[nc] and #cols[nc] > 0 then + local ni = math.min(fi, #cols[nc]) + hl.dispatch(hl.dsp.focus({ window = "address:" .. cols[nc][ni] })) + end + else + -- within-column step WRAPS at the ends (both keyboard focus u/d and the + -- mouse wheel, which forwards here too — see hypr-nav's scroll case). + local list = cols[fc] + if list and #list > 0 then + local n = #list + local step = decrease(arg) and -1 or 1 + local ni = ((fi - 1 + step) % n + n) % n + 1 + hl.dispatch(hl.dsp.focus({ window = "address:" .. list[ni] })) + end + end + elseif verb == "move" then + if not foc then return end + if between(arg) then + unpair(foc) -- leaving the column un-parks it + local nc = fc + (decrease(arg) and -1 or 1) + if nc < 1 or nc > s.ncols then + -- Off the edge: carry the window to the monitor spatially in that screen + -- direction — see the matching comment in the "focus" branch above. + hl.exec_cmd("~/.config/scripts/monitor-adjacent move " .. arg) + else + s.assign[foc] = nc + end + else + -- PARK / UNPARK within the column. Moving toward a single neighbour parks the + -- two side by side in one slot — a persistent state, and the column closes up so + -- there's no gap. Moving a parked window again pops it out and past its partner. + local list = cols[fc] + local step = decrease(arg) and -1 or 1 + local partner = s.pair[foc] + if partner then + -- parked → un-park and move the focused window past its partner. + s.pair[foc] = nil; s.pair[partner] = nil + local a, b = s.seq[foc] or 0, s.seq[partner] or 0 + if (step > 0 and a < b) or (step < 0 and a > b) then + s.seq[foc], s.seq[partner] = b, a -- put foc on the far side + end + elseif list then + local nb = list[fi + step] + if nb and not s.pair[nb] then + s.pair[foc] = nb; s.pair[nb] = foc -- park side by side + elseif nb then + -- neighbour is already a parked pair → hop the focused window over both. + local nb2 = s.pair[nb] + local n1, n2 = s.seq[nb] or 0, s.seq[nb2] or 0 + s.seq[foc] = (step > 0) and (math.max(n1, n2) + 0.5) or (math.min(n1, n2) - 0.5) + end + end + end + elseif verb == "resize" then + if between(arg) then + -- grow/shrink the focused column's width weight + s.colw[fc] = clampw((s.colw[fc] or 1) + (decrease(arg) and -RSTEP or RSTEP)) + elseif foc then + -- grow/shrink the focused window's height weight within its column + s.winh[foc] = clampw((s.winh[foc] or 1) + (decrease(arg) and -RSTEP or RSTEP)) + end + elseif verb == "count" then + -- horizontal ws: h/l change column count, k/j change windows-per-view. + -- vertical ws: k/j change column count, h/l change windows-per-view. + local more_cols = horizontal and "l" or "j" + local less_cols = horizontal and "h" or "k" + local more_rows = horizontal and "j" or "l" + local less_rows = horizontal and "k" or "h" + local changed_cols = false + if arg == more_cols then s.ncols = s.ncols + 1; changed_cols = true + elseif arg == less_cols then s.ncols = math.max(1, s.ncols - 1); changed_cols = true + elseif arg == more_rows then s.rows = s.rows + 1 + elseif arg == less_rows then s.rows = math.max(1, s.rows - 1) end + if changed_cols then reflow(s, t) end + publish() + elseif verb == "cols" then + -- direct column-count change (the menu's (-)[N](+) stepper). + if arg == "+" or arg == "+1" then s.ncols = s.ncols + 1 + elseif arg == "-" or arg == "-1" then s.ncols = math.max(1, s.ncols - 1) + else local n = tonumber(arg); if n then s.ncols = math.max(1, math.floor(n)) end end + reflow(s, t) + publish() + elseif verb == "orient" then + s.orient = (arg == "v") and "v" or "h" + publish() + end +end + +-- Register the layout (guard against the double-register that a live reload can cause). +pcall(function() + hl.layout.register("columns", { recalculate = recalculate, layout_msg = layout_msg }) +end) + +-- Spec consumed by the layout registry (init.lua) + the astro-menu dropdown. +return { + name = "columns", + label = "Columns", + backend = "lua:columns", -- custom Lua layout, addressed with the lua: prefix + lua = true, -- init.lua sends orientation via layout_msg, not scrolling:direction + directional = true, -- reuse the menu's direction dropdown for orientation + dirs = { "h", "v" }, + dir_labels = { "Horizontal", "Vertical" }, + default_dir = "h", + fit_method = true, -- menu shows the "center-focused" switch (routed to "center on/off") + stepper = true, -- menu shows a "Columns: (-)[N](+)" count stepper +} diff --git a/desktopenvs/hyprdrive/hypr/layouts/dwindle.lua b/desktopenvs/hyprdrive/hypr/layouts/dwindle.lua new file mode 100644 index 0000000..b27324f --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/layouts/dwindle.lua @@ -0,0 +1,10 @@ +-- Dwindle: binary-tree split (Fibonacci spiral), all windows tiled and visible. +return { + name = "dwindle", + label = "Dwindle", + backend = "dwindle", + directional = false, + config = { + dwindle = { preserve_split = true, special_scale_factor = 0.95 }, + }, +} diff --git a/desktopenvs/hyprdrive/hypr/layouts/init.lua b/desktopenvs/hyprdrive/hypr/layouts/init.lua new file mode 100644 index 0000000..2691dbf --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/layouts/init.lua @@ -0,0 +1,128 @@ +-- Layout registry for the hyprlua session. +-- +-- Adding a layout is a one-file drop-in: create `layouts/.lua` returning a +-- spec table (see scrolling.lua for the shape) and it is auto-discovered here — no +-- other wiring. Exposes the global `layouts` table used by hyprland.lua (to pick the +-- default) and by the astro-menu popup (over `hyprctl eval`) to switch a workspace's +-- layout live. A JSON manifest is written for the menu to enumerate the choices. + +local M = { registry = {}, order = {}, current = {} } -- current: ws(str) -> name + +local HOME = os.getenv("HOME") +local DIR = HOME .. "/.config/hypr/layouts" +local MANIFEST = HOME .. "/.cache/astro-menu/layouts.json" +local STATE = HOME .. "/.cache/astro-menu/layouts-state.json" + +local function scan() + local p = io.popen("ls '" .. DIR .. "' 2>/dev/null") + if not p then return {} end + local names = {} + for line in p:lines() do + local n = line:match("^(.+)%.lua$") + if n and n ~= "init" then names[#names + 1] = n end + end + p:close() + table.sort(names) + return names +end + +local function jstr(s) return '"' .. tostring(s):gsub('\\', '\\\\'):gsub('"', '\\"') .. '"' end + +-- Write layouts.json so the menu can list {name,label,directional,dirs,default_dir}. +function M.write_manifest() + local items = {} + for _, name in ipairs(M.order) do + local s = M.registry[name] + local dirs = {} + for _, d in ipairs(s.dirs or {}) do dirs[#dirs + 1] = jstr(d) end + local dlabels = {} + for _, d in ipairs(s.dir_labels or {}) do dlabels[#dlabels + 1] = jstr(d) end + items[#items + 1] = table.concat({ + "{", '"name":', jstr(s.name), + ',"label":', jstr(s.label or s.name), + ',"directional":', tostring(s.directional or false), + ',"dirs":[', table.concat(dirs, ","), "]", + ',"dir_labels":[', table.concat(dlabels, ","), "]", + ',"default_dir":', jstr(s.default_dir or ""), + ',"fit_method":', tostring(s.fit_method or false), + ',"stepper":', tostring(s.stepper or false), "}", + }) + end + os.execute("mkdir -p '" .. MANIFEST:match("(.+)/[^/]+$") .. "' 2>/dev/null") + local f = io.open(MANIFEST, "w") + if f then f:write("[" .. table.concat(items, ",") .. "]\n"); f:close() end +end + +function M.load() + for _, name in ipairs(scan()) do + local ok, spec = pcall(require, "layouts." .. name) + if ok and type(spec) == "table" and spec.name then + if not M.registry[spec.name] then M.order[#M.order + 1] = spec.name end + M.registry[spec.name] = spec + end + end + M.write_manifest() + return M +end + +-- Global default layout (called once from hyprland.lua). +function M.set_default(name) + local s = M.registry[name]; if not s then return end + hl.config({ general = { layout = s.backend } }) + if s.config then hl.config(s.config) end +end + +-- Switch one workspace's layout live (called by the menu via hyprctl eval). +-- `dir` is applied for directional layouts (scrolling.direction is a global option, +-- so this affects every scrolling workspace — hyprland has no per-ws direction). +local function write_state() + local parts = {} + for ws, name in pairs(M.current) do + parts[#parts + 1] = jstr(ws) .. ":" .. jstr(name) + end + local f = io.open(STATE, "w") + if f then f:write("{" .. table.concat(parts, ",") .. "}\n"); f:close() end +end + +function M.set(ws, name, dir) + local s = M.registry[name]; if not s then return end + ws = tostring(ws) + hl.workspace_rule({ workspace = ws, layout = s.backend }) + if s.lua then + -- custom Lua layout: orientation/options are per-layout state, set via a message + -- to the active workspace's layout (not the global scrolling:direction option). + if dir and dir ~= "" then hl.dispatch(hl.dsp.layout("orient " .. dir)) end + elseif s.directional and dir and dir ~= "" then + hl.config({ scrolling = { direction = dir } }) + end + M.current[ws] = name -- remember so the menu can show the real per-ws layout + write_state() +end + +-- Toggle the scrolling "centered follow" (focus_fit_method 0=fit / 1=centered). +-- column_width stays < 1.0 (set in scrolling.lua) so neighbours remain clickable. +function M.set_fit(method) + hl.config({ scrolling = { focus_fit_method = tonumber(method) or 0 } }) +end + +-- Re-apply the saved per-workspace layout choices. `hyprctl reload` resets the Lua VM +-- AND drops every runtime `workspace_rule`, so without this any workspace the user had +-- switched to Columns (or another non-default layout) silently reverts to the default +-- while the menu still shows the old choice. We replay what M.set recorded in +-- layouts-state.json. Called from hyprland.lua right after set_default(). +function M.restore() + local f = io.open(STATE, "r") + if not f then return end + local data = f:read("*a") or "" + f:close() + for ws, name in data:gmatch('"([^"]+)"%s*:%s*"([^"]+)"') do + local s = M.registry[name] + if s then + hl.workspace_rule({ workspace = tostring(ws), layout = s.backend }) + M.current[tostring(ws)] = name + end + end +end + +_G.layouts = M.load() +return M diff --git a/desktopenvs/hyprdrive/hypr/layouts/master.lua b/desktopenvs/hyprdrive/hypr/layouts/master.lua new file mode 100644 index 0000000..e907a32 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/layouts/master.lua @@ -0,0 +1,10 @@ +-- Master: one large master pane + a stack of the rest. +return { + name = "master", + label = "Master", + backend = "master", + directional = false, + config = { + master = { new_status = "master", special_scale_factor = 0.95 }, + }, +} diff --git a/desktopenvs/hyprdrive/hypr/layouts/monocle.lua b/desktopenvs/hyprdrive/hypr/layouts/monocle.lua new file mode 100644 index 0000000..058ae59 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/layouts/monocle.lua @@ -0,0 +1,8 @@ +-- Monocle: one full-size window at a time (fullscreen-like tiling). +return { + name = "monocle", + label = "Monocle", + backend = "monocle", + directional = false, + config = {}, +} diff --git a/desktopenvs/hyprdrive/hypr/layouts/scrolling.lua b/desktopenvs/hyprdrive/hypr/layouts/scrolling.lua new file mode 100644 index 0000000..a40cd2e --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/layouts/scrolling.lua @@ -0,0 +1,30 @@ +-- Scrolling: an infinite row/column of windows you scroll through. The session +-- default. Adjustable from the menu: +-- * direction (down by default) — see M.set() in init.lua. +-- * focus_fit_method 0/1 ("Centered follow") — see M.set_fit(); when centered the +-- focused column is centred and the previous/next columns peek in from the sides. +-- +-- column_width is the default window size along the scroll axis, as a fraction of the +-- monitor (0.76 = 76% of the monitor's width for a horizontal tape, or its height for a +-- vertical one — the layout applies it to whichever axis `direction` scrolls). Keeping it +-- < 1.0 leaves the prev/next windows peeking in on-screen and clickable; +-- fullscreen_on_one_column lets a lone window still fill the screen, so it costs nothing +-- when a window is alone. +return { + name = "scrolling", + label = "Scrolling", + backend = "scrolling", -- hyprland general.layout value + directional = true, + dirs = { "down", "up", "right", "left" }, + default_dir = "down", + fit_method = true, -- exposes the "Centered follow" toggle in the menu + config = { + scrolling = { + direction = "down", + column_width = 0.76, -- 76% of the monitor along the scroll axis + focus_fit_method = 1, + follow_focus = true, + fullscreen_on_one_column = true, + }, + }, +} diff --git a/desktopenvs/hyprdrive/hypr/usr/autostart.lua b/desktopenvs/hyprdrive/hypr/usr/autostart.lua new file mode 100644 index 0000000..2774919 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/usr/autostart.lua @@ -0,0 +1,31 @@ +-- https://wiki.hypr.land/Configuring/Basics/Autostart/ +hl.on("hyprland.start", function() + -- monitorhandler (was exec-once in monitors.conf) + hl.exec_cmd("~/Dotfiles/desktopenvs/hyprdrive/scripts/monitorhandler.sh") + + hl.exec_cmd("export QT_QPA_PLATFORMTHEME='' && vicinae server") + hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/bluetooth-applet.sh") + hl.exec_cmd("udiskie -t -m nested -n") + hl.exec_cmd("systemctl --user start hyprpolkitagent") + hl.exec_cmd("hyprsunset") + hl.exec_cmd("nm-applet") + hl.exec_cmd("dunst") + hl.exec_cmd("[workspace special:magic silent] kitty") + hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50") + hl.exec_cmd("hyprpaper") + hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/astro-menu-start.sh") + hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/orbit-menu-start.sh") + hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/horizon-dock-start.sh") + hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/station-bar-start.sh") + hl.exec_cmd("blueman-applet") + hl.exec_cmd("blueman-tray") + hl.exec_cmd("hypridle") + hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/presence-detect.sh") + hl.exec_cmd("chamel") + hl.exec_cmd("ydotoold") + hl.exec_cmd("[workspace special:magic silent] opendeck") + + -- Reload once everything has spawned so layer-shell clients (orbit-menu, + -- horizon-dock, station-bar) pick up the final monitor/config state. + hl.exec_cmd("sleep 1 && hyprctl reload") +end) diff --git a/desktopenvs/hyprdrive/hypr/usr/binds.lua b/desktopenvs/hyprdrive/hypr/usr/binds.lua new file mode 100644 index 0000000..d4b2d46 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/usr/binds.lua @@ -0,0 +1,333 @@ +-- https://wiki.hypr.land/Configuring/Basics/Binds/ + +local mainMod = "SUPER" + +local terminal = "kitty" +local fileManager = "thunar" +local editor = "kitty nvim" +local menu = "vicinae toggle" +local winswitch = "" -- TODO: define your window switcher command + +-- Screen-absolute focus/move wrapper. In the scrolling/columns layouts, Hyprland +-- resolves a direction perpendicular to the tape onto "scroll the tape", so the same +-- key means different things depending on scrolling:direction. hypr-nav keeps every +-- key literal to its on-screen direction (and is a pass-through for dwindle/master). +local nav = "~/.config/scripts/hypr-nav" + +-------------------- +---- LID SWITCH ---- +-------------------- + +hl.bind("switch:on:Lid Switch", hl.dsp.exec_cmd("hyprlock"), { locked = true }) +hl.bind("switch:off:Lid Switch", hl.dsp.exec_cmd("hyprctl dispatch dpms on"), { locked = true }) + +-------------------- +---- GESTURES ------ +-------------------- + +hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" }) +hl.gesture({ fingers = 3, direction = "up", action = function() hl.dispatch(hl.dsp.window.move({ workspace = "special:magic", silent = true })) end }) +hl.gesture({ fingers = 3, direction = "down", action = "special", workspace_name = "magic" }) +hl.gesture({ fingers = 3, direction = "pinchin", action = function() hl.dispatch(hl.dsp.exec_cmd(menu)) end }) +hl.gesture({ fingers = 3, direction = "pinchout", action = "float" }) +hl.gesture({ fingers = 4, direction = "pinchout", action = "close" }) +hl.gesture({ fingers = 4, direction = "pinchin", action = function() hl.dispatch(hl.dsp.exec_cmd(terminal)) end }) +hl.gesture({ fingers = 4, direction = "down", action = function() hl.dispatch(hl.dsp.window.move({ workspace = "special:magic" })) end }) +hl.gesture({ fingers = 4, direction = "up", action = function() hl.dispatch(hl.dsp.window.move({ workspace = "r+0" })) end }) +hl.gesture({ fingers = 4, direction = "left", action = function() hl.dispatch(hl.dsp.window.move({ workspace = "r+1" })) end }) +hl.gesture({ fingers = 4, direction = "right", action = function() hl.dispatch(hl.dsp.window.move({ workspace = "r-1" })) end }) + +-------------------- +---- APPS ---------- +-------------------- + +hl.bind(mainMod .. " + T", hl.dsp.exec_cmd(terminal)) +hl.bind(mainMod .. " + SHIFT + T", hl.dsp.exec_cmd("cool-retro-term -p ~/Dotfiles/desktopenvs/hyprdrive/CRT")) +hl.bind(mainMod .. " + M", hl.dsp.exec_cmd(editor)) +hl.bind(mainMod .. " + E", hl.dsp.exec_cmd(fileManager)) +hl.bind(mainMod .. " + ALT + E", hl.dsp.exec_cmd("pcmanfm-qt")) +hl.bind(mainMod .. " + N", hl.dsp.exec_cmd("nextcloud")) +-- Wi-Fi quick-pick (iwmenu --launcher walker) needs a dmenu-protocol launcher +-- backend hyprdrive doesn't install; pending a hyprdrive-native replacement, +-- Super+Shift+I opens the full GUI editor instead, and Super+I is free. +hl.bind(mainMod .. " + SHIFT + I", hl.dsp.exec_cmd("[tag +mixer] nm-connection-editor")) +hl.bind(mainMod .. " + R", hl.dsp.exec_cmd(menu)) +hl.bind(mainMod .. " + RETURN", hl.dsp.exec_cmd(menu)) +hl.bind(mainMod .. " + SHIFT + RETURN", hl.dsp.exec_cmd(winswitch)) +hl.bind("CTRL + SHIFT + R", hl.dsp.exec_cmd(menu)) + +-- File picker dialog -> clipboard (path / contents) +hl.bind(mainMod .. " + F", hl.dsp.exec_cmd("[tag +filepicker] ~/.config/scripts/filepicker-clipboard.sh path")) +hl.bind(mainMod .. " + SHIFT + F", hl.dsp.exec_cmd("[tag +filepicker] ~/.config/scripts/filepicker-clipboard.sh content")) +hl.bind(mainMod .. " + ALT + F", hl.dsp.exec_cmd("qalculate-gtk")) + +hl.bind(mainMod .. " + S", hl.dsp.exec_cmd("[tag +mixer] pavucontrol")) +hl.bind(mainMod .. " + U", hl.dsp.exec_cmd("[tag +centered-L] kitty btop")) +hl.bind(mainMod .. " + W", hl.dsp.exec_cmd("[tag +centered-L] kitty -e ~/.config/scripts/wallpaper-picker ~/Pictures")) +hl.bind(mainMod .. " + SHIFT + M", hl.dsp.exec_cmd("[tag +centered-L] kitty -e ~/.config/scripts/monitor-manager")) +hl.bind(mainMod .. " + CTRL + R", hl.dsp.exec_cmd("[tag +centered-L] kitty -e ~/.config/scripts/amssh")) +hl.bind(mainMod .. " + F1", hl.dsp.exec_cmd("[tag +centered] kitty ~/.config/scripts/helpmenu.sh")) +hl.bind(mainMod .. " + CTRL + T", hl.dsp.exec_cmd("[tag +centered-S] kitty bash ~/.config/scripts/timer-pick")) +hl.bind(mainMod .. " + SHIFT + F1", hl.dsp.exec_cmd("[tag +centered-L] kitty nvim ~/.config/hypr/usr/binds.lua")) + +hl.bind(mainMod .. " + CTRL + P", hl.dsp.exec_cmd("~/.config/scripts/screenrec.sh")) + +-------------------- +---- WINDOW MGMT --- +-------------------- + +hl.bind(mainMod .. " + Q", hl.dsp.window.close()) +hl.bind(mainMod .. " + V", hl.dsp.window.float({ action = "toggle" })) +hl.bind(mainMod .. " + C", hl.dsp.window.pseudo()) +hl.bind(mainMod .. " + SHIFT + V", hl.dsp.window.tag({ tag = "+centered" })) +hl.bind(mainMod .. " + SHIFT + Q", hl.dsp.exec_cmd("hyprctl kill")) + +hl.bind(mainMod .. " + CTRL + M", hl.dsp.exec_cmd("~/.config/scripts/toggle-layout.sh")) + +-- lock/exit — hyprshutdown gracefully closes every app (with a UI) before it exits +-- Hyprland and runs its --post-cmd, instead of yanking the session out from under them. +-- orbit-menu wraps hyprshutdown behind the radial menu: Shift+O opens straight into +-- the Power ring (sleep/reboot/poweroff/soft-reboot); Ctrl+O opens the full 5-category +-- menu (Power/Tools/Scripts/Management/Windows). Re-press to close. +hl.bind(mainMod .. " + O", hl.dsp.exec_cmd("hyprlock")) +hl.bind(mainMod .. " + SHIFT + O", hl.dsp.exec_cmd("~/.config/scripts/orbit-menu.sh power")) +hl.bind(mainMod .. " + CTRL + O", hl.dsp.exec_cmd("~/.config/scripts/orbit-menu.sh menu")) +hl.bind(mainMod .. " + CTRL + SHIFT + O",hl.dsp.exec_cmd('hyprshutdown -p "systemctl reboot"')) -- graceful reboot +-- Super+Alt+O (frequent-commands quick menu) and Super+Z/Ctrl+B (bar toggle/ +-- reload) dropped: their backing scripts (pwr-dmenu.sh, togglebar.sh) were +-- wofi/eww-specific and hyprdrive has neither. Free for future rebinding. +hl.bind(mainMod .. " + ALT + CTRL + SHIFT + END", hl.dsp.exit()) + +-------------------- +---- FOCUS --------- +-------------------- + +hl.bind(mainMod .. " + h", hl.dsp.exec_cmd(nav .. " focus l")) +hl.bind(mainMod .. " + l", hl.dsp.exec_cmd(nav .. " focus r")) +hl.bind(mainMod .. " + k", hl.dsp.exec_cmd(nav .. " focus u")) +hl.bind(mainMod .. " + j", hl.dsp.exec_cmd(nav .. " focus d")) +hl.bind(mainMod .. " + left", hl.dsp.exec_cmd(nav .. " focus l")) +hl.bind(mainMod .. " + right", hl.dsp.exec_cmd(nav .. " focus r")) +hl.bind(mainMod .. " + up", hl.dsp.exec_cmd(nav .. " focus u")) +hl.bind(mainMod .. " + down", hl.dsp.exec_cmd(nav .. " focus d")) + +hl.bind(mainMod .. " + TAB", hl.dsp.window.cycle_next()) +hl.bind(mainMod .. " + SHIFT + TAB", hl.dsp.exec_cmd(winswitch)) + +-------------------- +---- MOVE WINDOW --- +-------------------- + +hl.bind(mainMod .. " + SHIFT + left", hl.dsp.exec_cmd(nav .. " move l")) +hl.bind(mainMod .. " + SHIFT + right", hl.dsp.exec_cmd(nav .. " move r")) +hl.bind(mainMod .. " + SHIFT + up", hl.dsp.exec_cmd(nav .. " move u")) +hl.bind(mainMod .. " + SHIFT + down", hl.dsp.exec_cmd(nav .. " move d")) +hl.bind(mainMod .. " + SHIFT + h", hl.dsp.exec_cmd(nav .. " move l")) +hl.bind(mainMod .. " + SHIFT + l", hl.dsp.exec_cmd(nav .. " move r")) +hl.bind(mainMod .. " + SHIFT + k", hl.dsp.exec_cmd(nav .. " move u")) +hl.bind(mainMod .. " + SHIFT + j", hl.dsp.exec_cmd(nav .. " move d")) + +-- Columns layout: manage how many columns / windows-per-view (no-op in other layouts). +-- Horizontal ws: H/L = fewer/more columns, K/J = fewer/more windows-per-view. +-- Vertical ws: K/J = fewer/more columns, H/L = fewer/more windows-per-view. +hl.bind(mainMod .. " + CTRL + ALT + h", hl.dsp.exec_cmd(nav .. " count h")) +hl.bind(mainMod .. " + CTRL + ALT + l", hl.dsp.exec_cmd(nav .. " count l")) +hl.bind(mainMod .. " + CTRL + ALT + k", hl.dsp.exec_cmd(nav .. " count k")) +hl.bind(mainMod .. " + CTRL + ALT + j", hl.dsp.exec_cmd(nav .. " count j")) + +-- cross-monitor: focus / move the active window to the previous/next monitor. +-- Works in every layout (including scrolling, where the directional keys deliberately +-- do NOT jump monitors — see hypr-nav). +1/-1 cycle through the monitors. +hl.bind(mainMod .. " + comma", hl.dsp.focus({ monitor = "-1" })) +hl.bind(mainMod .. " + period", hl.dsp.focus({ monitor = "+1" })) +hl.bind(mainMod .. " + SHIFT + comma", hl.dsp.window.move({ monitor = "-1" })) +hl.bind(mainMod .. " + SHIFT + period", hl.dsp.window.move({ monitor = "+1" })) + +-- mouse drag / resize +hl.bind(mainMod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) +-- Super+rightclick resize. The custom Columns layout gives no mouse-resize hook, so ONLY +-- there the window is floated first (enable is a no-op if already floating) so it can be +-- mouse-dragged; re-tile afterwards with Super+V. In every other layout it's a plain +-- interactive resize, untouched. (Keyboard resize that stays tiled: Super+Alt + hjkl.) +local function active_ws_is_columns() + local ok, aw = pcall(hl.get_active_window) + if not ok or not aw then return false end + local wid + pcall(function() wid = aw.workspace.id end) + if not wid then return false end + local f = io.open((os.getenv("HOME") or "") .. "/.cache/astro-menu/layouts-state.json", "r") + if not f then return false end + local data = f:read("*a") or ""; f:close() + return data:match('"' .. tostring(wid) .. '"%s*:%s*"([^"]+)"') == "columns" +end +hl.bind(mainMod .. " + mouse:273", function() + if active_ws_is_columns() then hl.dispatch(hl.dsp.window.float({ action = "enable" })) end + hl.dispatch(hl.dsp.window.resize()) +end, { mouse = true }) +hl.bind(mainMod .. " + SHIFT + mouse:272", hl.dsp.window.resize(), { mouse = true }) + +-------------------- +---- RESIZE -------- +-------------------- + +-- Routed through hypr-nav: in the Columns layout ALT-resize retunes the focused +-- column's width / window's height (weights); everywhere else it's a plain screen- +-- relative resize, exactly as before. l/h narrower, r/l wider, k/up shorter, j/down taller. +hl.bind(mainMod .. " + ALT + right", hl.dsp.exec_cmd(nav .. " resize r"), { repeating = true }) +hl.bind(mainMod .. " + ALT + left", hl.dsp.exec_cmd(nav .. " resize l"), { repeating = true }) +hl.bind(mainMod .. " + ALT + up", hl.dsp.exec_cmd(nav .. " resize u"), { repeating = true }) +hl.bind(mainMod .. " + ALT + down", hl.dsp.exec_cmd(nav .. " resize d"), { repeating = true }) +hl.bind(mainMod .. " + ALT + l", hl.dsp.exec_cmd(nav .. " resize r"), { repeating = true }) +hl.bind(mainMod .. " + ALT + h", hl.dsp.exec_cmd(nav .. " resize l"), { repeating = true }) +hl.bind(mainMod .. " + ALT + k", hl.dsp.exec_cmd(nav .. " resize u"), { repeating = true }) +hl.bind(mainMod .. " + ALT + j", hl.dsp.exec_cmd(nav .. " resize d"), { repeating = true }) + +-------------------- +---- WORKSPACES ---- +-------------------- + +for i = 1, 9 do + hl.bind(mainMod .. " + " .. i, hl.dsp.focus({ workspace = i })) + hl.bind(mainMod .. " + SHIFT + " .. i, hl.dsp.window.move({ workspace = i })) +end +hl.bind(mainMod .. " + 0", hl.dsp.focus({ workspace = 10 })) +hl.bind(mainMod .. " + SHIFT + 0", hl.dsp.window.move({ workspace = 10 })) + +-- relative workspace nav — arrows +hl.bind(mainMod .. " + CTRL + right", hl.dsp.focus({ workspace = "r+1" })) +hl.bind(mainMod .. " + CTRL + left", hl.dsp.focus({ workspace = "r-1" })) +hl.bind(mainMod .. " + CTRL + SHIFT + right", hl.dsp.window.move({ workspace = "r+1" })) +hl.bind(mainMod .. " + CTRL + SHIFT + left", hl.dsp.window.move({ workspace = "r-1" })) + +-- relative workspace nav — vim +hl.bind(mainMod .. " + CTRL + l", hl.dsp.focus({ workspace = "r+1" })) +hl.bind(mainMod .. " + CTRL + h", hl.dsp.focus({ workspace = "r-1" })) +hl.bind(mainMod .. " + CTRL + SHIFT + l", hl.dsp.window.move({ workspace = "r+1" })) +hl.bind(mainMod .. " + CTRL + SHIFT + h", hl.dsp.window.move({ workspace = "r-1" })) + +-- Super + wheel: travel along the focused strip in the scrolling/columns layouts +-- (next/prev window in the tape or focused column); falls back to switching workspaces +-- in every other layout — see hypr-nav. +hl.bind(mainMod .. " + mouse_down", hl.dsp.exec_cmd(nav .. " scroll d")) +hl.bind(mainMod .. " + mouse_up", hl.dsp.exec_cmd(nav .. " scroll u")) + +-- volume keys as workspace nav (when mainMod held) +hl.bind(mainMod .. " + XF86AudioRaiseVolume", hl.dsp.focus({ workspace = "r+1" }), { repeating = true }) +hl.bind(mainMod .. " + XF86AudioLowerVolume", hl.dsp.focus({ workspace = "r-1" }), { repeating = true }) +hl.bind(mainMod .. " + SHIFT + XF86AudioRaiseVolume", hl.dsp.window.move({ workspace = "r+1" })) +hl.bind(mainMod .. " + SHIFT + XF86AudioLowerVolume", hl.dsp.window.move({ workspace = "r-1" })) + +-------------------- +---- SCRATCHPAD ---- +-------------------- + +hl.bind(mainMod .. " + SPACE", hl.dsp.workspace.toggle_special("magic")) +hl.bind(mainMod .. " + SHIFT + SPACE", hl.dsp.window.move({ workspace = "special:magic" })) +hl.bind(mainMod .. " + CTRL + up", hl.dsp.workspace.toggle_special("magic")) +hl.bind(mainMod .. " + CTRL + SHIFT + up", hl.dsp.window.move({ workspace = "special:magic" })) +hl.bind(mainMod .. " + CTRL + SHIFT + down", hl.dsp.window.move({ workspace = "empty" })) +hl.bind(mainMod .. " + CTRL + down", hl.dsp.window.move({ workspace = "special:magic", silent = true })) +hl.bind(mainMod .. " + CTRL + k", hl.dsp.workspace.toggle_special("magic")) +hl.bind(mainMod .. " + CTRL + j", hl.dsp.window.move({ workspace = "special:magic", silent = true })) +hl.bind(mainMod .. " + CTRL + SHIFT + j", hl.dsp.window.move({ workspace = "r+0" })) +hl.bind(mainMod .. " + CTRL + SHIFT + k", hl.dsp.window.move({ workspace = "special:magic" })) +hl.bind(mainMod .. " + XF86AudioMute", hl.dsp.workspace.toggle_special("magic"), { repeating = true }) +hl.bind(mainMod .. " + SHIFT + XF86AudioMute", hl.dsp.window.move({ workspace = "special:magic" }), { repeating = true }) + +-------------------- +---- GROUPS -------- +-------------------- + +hl.bind(mainMod .. " + ALT + C", hl.dsp.group.toggle()) -- TODO: verify hl.dsp.group API +hl.bind(mainMod .. " + CTRL + SHIFT + C", hl.dsp.group.lock_active("toggle")) +hl.bind(mainMod .. " + A", hl.dsp.exec_cmd("hyprctl dispatch changegroupactive f")) +hl.bind(mainMod .. " + Y", hl.dsp.exec_cmd("hyprctl dispatch changegroupactive b")) + +hl.bind(mainMod .. " + SHIFT + ALT + left", hl.dsp.group.move_window("l")) +hl.bind(mainMod .. " + SHIFT + ALT + right", hl.dsp.group.move_window("r")) +hl.bind(mainMod .. " + SHIFT + ALT + up", hl.dsp.group.move_window("u")) +hl.bind(mainMod .. " + SHIFT + ALT + down", hl.dsp.group.move_window("d")) +hl.bind(mainMod .. " + SHIFT + ALT + h", hl.dsp.group.move_window("l")) +hl.bind(mainMod .. " + SHIFT + ALT + l", hl.dsp.group.move_window("r")) +hl.bind(mainMod .. " + SHIFT + ALT + k", hl.dsp.group.move_window("u")) +hl.bind(mainMod .. " + SHIFT + ALT + j", hl.dsp.group.move_window("d")) + +-------------------- +---- ASTRO-MENU ---- +-------------------- + +-- astro-menu is astal-menu re-themed as a hologram info display — same +-- functionality (Location/Weather/Bluetooth/Network quads, taskbar, app +-- drawer), same binds it always had in hyprlua. +hl.bind(mainMod .. " + D", hl.dsp.exec_cmd("~/.config/scripts/astro-menu.sh toggle top"), { release = true }) +hl.bind(mainMod .. " + SHIFT + A", hl.dsp.exec_cmd("~/.config/scripts/astro-menu.sh appdrawer")) + +-------------------- +---- HORIZON-DOCK -- +-------------------- + +-- Super+D went to astro-menu (its "og" bind); horizon-dock gets its own key. +-- (H is fully claimed by vim-style hjkl focus/move/resize across every +-- modifier combo, so this uses D too, distinguished by Shift.) +hl.bind(mainMod .. " + SHIFT + D", hl.dsp.exec_cmd("~/.config/scripts/horizon-dock.sh toggle"), { release = true }) + +-------------------- +---- STATION-BAR --- +-------------------- + +-- station-bar autostarts (see hypr/usr/autostart.lua) and is visible by +-- default; this just lets you hide/show it (e.g. to reclaim the reserved +-- top-edge space for a fullscreen app that doesn't already do so itself). +hl.bind(mainMod .. " + B", hl.dsp.exec_cmd("~/.config/scripts/station-bar.sh toggle"), { release = true }) + +-------------------- +---- SCREENSHOT ---- +-------------------- + +hl.bind("Print", hl.dsp.exec_cmd("~/.config/scripts/screenshot.sh")) +hl.bind(mainMod .. " + P", hl.dsp.exec_cmd("~/.config/scripts/screenshot.sh")) +hl.bind(mainMod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker | wl-copy")) +hl.bind(mainMod .. " + CTRL + P", hl.dsp.exec_cmd("~/.config/scripts/screenrec.sh")) + +-------------------- +---- AUDIO --------- +-------------------- + +hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1.4 @DEFAULT_AUDIO_SINK@ 5%+"), { repeating = true }) +hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1.4 @DEFAULT_AUDIO_SINK@ 5%-"), { repeating = true }) +hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle")) +hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle")) +hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause -p spotify,vlc"), { locked = true }) + +-------------------- +---- BRIGHTNESS ---- +-------------------- + +hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("bri --up"), { locked = true, repeating = true }) +hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("bri --down"), { locked = true, repeating = true }) + +-------------------- +---- HYPRSUNSET ---- +-------------------- + +hl.bind(mainMod .. " + CTRL + W", hl.dsp.exec_cmd("hyprctl hyprsunset gamma +10")) +hl.bind(mainMod .. " + CTRL + S", hl.dsp.exec_cmd("hyprctl hyprsunset gamma -10")) +hl.bind(mainMod .. " + CTRL + A", hl.dsp.exec_cmd("hyprctl hyprsunset temperature +450")) +hl.bind(mainMod .. " + CTRL + Q", hl.dsp.exec_cmd("hyprctl hyprsunset temperature -450")) +hl.bind(mainMod .. " + CTRL + X", hl.dsp.exec_cmd("hyprctl hyprsunset identity")) + +-------------------- +---- MISC ---------- +-------------------- + +hl.bind(mainMod .. " + CTRL + I", hl.dsp.exec_cmd("chamel toggle")) +hl.bind(mainMod .. " + CTRL + U", hl.dsp.exec_cmd("chamel clear")) +hl.bind(mainMod .. " + CTRL + Z", hl.dsp.exec_cmd("chamel clear-and-deactivate")) +hl.bind(mainMod .. " + CTRL + C", hl.dsp.exec_cmd("dunstctl close-all")) +hl.bind(mainMod .. " + CTRL + G", hl.dsp.exec_cmd("~/.config/scripts/onscreenkb.sh")) +hl.bind(mainMod .. " + SHIFT + C", hl.dsp.exec_cmd("~/.config/scripts/caffeine.sh")) +hl.bind(mainMod .. " + SHIFT + B", hl.dsp.exec_cmd("[tag +centered-S] kitty bash ~/.config/scripts/enroll-biometrics.sh")) +hl.bind(mainMod .. " + SHIFT + X", hl.dsp.exec_cmd("~/.config/scripts/hyprland-toggle-touchpad.sh")) + +hl.bind(mainMod .. " + CTRL + E", hl.dsp.exec_cmd("~/.config/scripts/screenrotationwcw.sh")) +hl.bind(mainMod .. " + CTRL + D", hl.dsp.exec_cmd("~/.config/scripts/screenrotationacw.sh")) diff --git a/desktopenvs/hyprdrive/hypr/usr/envvars.lua b/desktopenvs/hyprdrive/hypr/usr/envvars.lua new file mode 100644 index 0000000..9ed1a4b --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/usr/envvars.lua @@ -0,0 +1,5 @@ +hl.env("GTK_THEME", "cyberqueer") +hl.env("GDK_SCALE", "2") +hl.env("XCURSOR_SIZE", "40") +hl.env("HYPRCURSOR_SIZE", "40") +hl.env("QT_QPA_PLATFORMTHEME", "gtk3") diff --git a/desktopenvs/hyprdrive/hypr/usr/input-device-exceptions.lua b/desktopenvs/hyprdrive/hypr/usr/input-device-exceptions.lua new file mode 100644 index 0000000..f3d9a94 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/usr/input-device-exceptions.lua @@ -0,0 +1,52 @@ +-- Device-specific input overrides — settings here take precedence over global input config. +hl.device({ + name = "zsa-technology-labs-moonlander-mark-i-keyboard", + kb_layout = "de", + kb_variant = "", + kb_model = "", + kb_options = "", + kb_rules = "", + repeat_rate = 15, + repeat_delay = 750, + sensitivity = 0, +}) +hl.device({ + name = "logitech-pro-2-mouse-1", + sensitivity = 0, + accel_profile = "", + left_handed = false, -- left-handed mode handled on the mouse's onboard memory + natural_scroll = false, + scroll_points = "", +}) +hl.device({ + name = "logitech-g903-ls-1", + sensitivity = 0, + accel_profile = "", + left_handed = false, + natural_scroll = false, + scroll_points = "", +}) +hl.device({ + name = "logitech-pro-2-mouse-2", + sensitivity = 0, + accel_profile = "", + left_handed = false, + natural_scroll = false, + scroll_points = "", +}) +hl.device({ + name = "logitech-pro-2-mouse-3", + sensitivity = 0, + accel_profile = "", + left_handed = false, + natural_scroll = false, + scroll_points = "", +}) +hl.device({ + name = "logitech-pro-2-mouse-4", + sensitivity = 0, + accel_profile = "", + left_handed = false, + natural_scroll = false, + scroll_points = "", +}) diff --git a/desktopenvs/hyprdrive/hypr/usr/input.lua b/desktopenvs/hyprdrive/hypr/usr/input.lua new file mode 100644 index 0000000..95928a5 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/usr/input.lua @@ -0,0 +1,18 @@ +-- https://wiki.hypr.land/Configuring/Basics/Variables/ +hl.config({ + input = { + kb_layout = "de", + kb_variant = "", + kb_model = "", + kb_options = "caps:swapescape", + kb_rules = "", + left_handed = true, + follow_mouse = 1, + sensitivity = 0, + touchpad = { + natural_scroll = false, + }, + }, +}) + +require("usr.input-device-exceptions") diff --git a/desktopenvs/hyprdrive/hypr/usr/mk-device-block.sh b/desktopenvs/hyprdrive/hypr/usr/mk-device-block.sh new file mode 100755 index 0000000..aeba862 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/usr/mk-device-block.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +devices=$(hyprctl devices -j) + +# Tag each device with its type so the right block can be emitted on selection. +entries=$(jq -r ' + (.keyboards[]?.name | "keyboard\t" + .), + (.mice[]?.name | "mouse\t" + .) +' <<<"$devices") + +if [[ -z "$entries" ]]; then + echo "No keyboard or mouse devices found" >&2 + exit 1 +fi + +if command -v fzf &>/dev/null; then + selected=$(printf '%s\n' "$entries" | fzf --prompt="Select device: " --height=~10 --with-nth=2.. --delimiter='\t') +else + echo "Select a device:" >&2 + IFS=$'\n' + select selected in $entries; do + [[ -n "$selected" ]] && break + done + unset IFS +fi + +[[ -z "$selected" ]] && exit 1 + +type=${selected%%$'\t'*} +name=${selected#*$'\t'} + +case "$type" in +keyboard) + cat < +set -euo pipefail + +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +[[ $# -eq 1 ]] || die "Usage: ${0##*/} " +target="$1" + +command -v hyprctl &>/dev/null || die "hyprctl not found" +command -v dialog &>/dev/null || die "dialog not found" +command -v jq &>/dev/null || die "jq not found" + +# ---------- device selection ---------- + +devices=$(hyprctl devices -j) +entries=$(jq -r ' + (.keyboards[]?.name | "keyboard\t" + .), + (.mice[]?.name | "mouse\t" + .) +' <<<"$devices") + +[[ -z "$entries" ]] && die "No keyboard or mouse devices found" + +if command -v fzf &>/dev/null; then + selected=$(printf '%s\n' "$entries" \ + | fzf --prompt="Select device: " --height=~10 --with-nth=2.. --delimiter=$'\t') +else + echo "Select a device:" >&2 + IFS=$'\n' + select selected in $entries; do [[ -n "$selected" ]] && break; done + unset IFS +fi + +[[ -z "$selected" ]] && exit 1 + +type=${selected%%$'\t'*} +name=${selected#*$'\t'} + +# ---------- parse existing field value from target file ---------- +# Returns the Lua literal for the field (e.g. `"de"`, `15`, `false`). + +get_field() { + local field="$1" default="$2" + [[ -f "$target" ]] || { printf '%s' "$default"; return; } + awk -v dev="$name" -v fld="$field" -v def="$default" ' + /^hl\.device\(\{/ { blk=""; in_blk=1; matched=0 } + in_blk { blk = blk $0 "\n" } + in_blk && /name = / { if ($0 ~ "\"" dev "\"") matched=1 } + in_blk && /^\}\)/ { + if (matched) { + n = split(blk, lines, "\n") + for (i = 1; i <= n; i++) { + line = lines[i] + sub(/^[[:space:]]+/, "", line) # strip indentation + if (line ~ "^" fld "[[:space:]]*=") { + sub(/^[^=]+=/, "", line) # drop "fieldname = " + sub(/^[[:space:]]*/, "", line) # strip leading space + sub(/[[:space:]]*,.*$/, "", line) # drop trailing comma and any inline comment + print line; exit + } + } + print def; exit + } + in_blk = 0 + } + ' "$target" +} + +# Strip surrounding Lua string quotes for display in form ("de" -> de). +unquote() { local v="$1"; v="${v#\"}"; v="${v%\"}"; printf '%s' "$v"; } + +# ---------- show form and collect values ---------- + +case "$type" in +keyboard) + v_layout=$(unquote "$(get_field kb_layout '"de"')") + v_variant=$(unquote "$(get_field kb_variant '""')") + v_model=$(unquote "$(get_field kb_model '""')") + v_options=$(unquote "$(get_field kb_options '""')") + v_rules=$(unquote "$(get_field kb_rules '""')") + v_rate=$(get_field repeat_rate 0) + v_delay=$(get_field repeat_delay 0) + v_sens=$(get_field sensitivity 0) + + # dialog writes its TUI to stdout (/dev/tty) and the result to stderr. + values=$(dialog \ + --title "Keyboard exception: $name" \ + --form "" 16 74 8 \ + "kb_layout" 1 1 "$v_layout" 1 17 42 0 \ + "kb_variant" 2 1 "$v_variant" 2 17 42 0 \ + "kb_model" 3 1 "$v_model" 3 17 42 0 \ + "kb_options" 4 1 "$v_options" 4 17 42 0 \ + "kb_rules" 5 1 "$v_rules" 5 17 42 0 \ + "repeat_rate" 6 1 "$v_rate" 6 17 10 0 \ + "repeat_delay" 7 1 "$v_delay" 7 17 10 0 \ + "sensitivity" 8 1 "$v_sens" 8 17 10 0 \ + 2>&1 >/dev/tty) || exit 0 + + mapfile -t vals <<< "$values" + new_block="hl.device({ + name = \"$name\", + kb_layout = \"${vals[0]:-}\", + kb_variant = \"${vals[1]:-}\", + kb_model = \"${vals[2]:-}\", + kb_options = \"${vals[3]:-}\", + kb_rules = \"${vals[4]:-}\", + repeat_rate = ${vals[5]:-0}, + repeat_delay = ${vals[6]:-0}, + sensitivity = ${vals[7]:-0}, +})" + ;; + +mouse) + v_sens=$(get_field sensitivity 0) + v_accel=$(unquote "$(get_field accel_profile '""')") + v_left=$(get_field left_handed false) + v_natural=$(get_field natural_scroll false) + v_scroll=$(unquote "$(get_field scroll_points '""')") + + values=$(dialog \ + --title "Mouse exception: $name" \ + --form "" 13 74 5 \ + "sensitivity" 1 1 "$v_sens" 1 20 20 0 \ + "accel_profile" 2 1 "$v_accel" 2 20 20 0 \ + "left_handed" 3 1 "$v_left" 3 20 10 0 \ + "natural_scroll" 4 1 "$v_natural" 4 20 10 0 \ + "scroll_points" 5 1 "$v_scroll" 5 20 20 0 \ + 2>&1 >/dev/tty) || exit 0 + + mapfile -t vals <<< "$values" + new_block="hl.device({ + name = \"$name\", + sensitivity = ${vals[0]:-0}, + accel_profile = \"${vals[1]:-}\", + left_handed = ${vals[2]:-false}, + natural_scroll = ${vals[3]:-false}, + scroll_points = \"${vals[4]:-}\", +})" + ;; +esac + +# ---------- replace or append in target file ---------- + +[[ -f "$target" ]] || touch "$target" + +# Remove the existing block for this device (no-op if not present). +tmp=$(mktemp) +awk -v dev="$name" ' + /^hl\.device\(\{/ { blk = $0 "\n"; in_blk=1; matched=0; next } + in_blk { + blk = blk $0 "\n" + if (/name = / && ($0 ~ "\"" dev "\"")) matched=1 + if (/^\}\)/) { + if (!matched) printf "%s", blk + in_blk=0; blk="" + } + next + } + { print } +' "$target" > "$tmp" +mv "$tmp" "$target" + +# Append the new block, separated by a blank line if the file is non-empty. +if [[ -s "$target" ]]; then + [[ "$(tail -c 1 "$target")" != $'\n' ]] && printf '\n' >> "$target" + printf '\n' >> "$target" +fi +printf '%s\n' "$new_block" >> "$target" + +printf 'Written to %s\n' "$target" diff --git a/desktopenvs/hyprdrive/hypr/usr/monitors.lua b/desktopenvs/hyprdrive/hypr/usr/monitors.lua new file mode 100644 index 0000000..2904e14 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/usr/monitors.lua @@ -0,0 +1,21 @@ +-- Default monitor configuration. +-- +-- This is a functional fallback only: a single catch-all rule that brings up +-- every connected output at its preferred mode, auto-positioned with an +-- automatic scale. It is enough to get a usable screen on any machine. +-- +-- Per-machine layout (resolution, position, scale, rotation, mirroring) is +-- managed by scripts/monitor-manager, which overwrites this file. Don't hand- +-- edit it for a specific setup -- run the manager instead. +hl.monitor({ + output = "", -- empty output = wildcard, matches every monitor + mode = "preferred", -- use each monitor's preferred resolution/refresh + position = "auto", -- let Hyprland place outputs side by side + scale = "auto", -- pick a sensible scale per monitor +}) + +hl.config({ + xwayland = { + force_zero_scaling = true, + }, +}) diff --git a/desktopenvs/hyprdrive/hypr/usr/wallpaper.conf b/desktopenvs/hyprdrive/hypr/usr/wallpaper.conf new file mode 100644 index 0000000..b852451 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/usr/wallpaper.conf @@ -0,0 +1,7 @@ +# template for ~/.config/wallpaper.conf — sourced by hypr/hyprpaper.conf, +# rewritten by ~/.config/scripts/wallpaper-picker +wallpaper { + monitor = + path = ~/Pictures/background.jpg + fit_mode = cover +} diff --git a/desktopenvs/hyprdrive/hypr/usr/windowrules.conf.old b/desktopenvs/hyprdrive/hypr/usr/windowrules.conf.old new file mode 100644 index 0000000..8d8d399 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/usr/windowrules.conf.old @@ -0,0 +1,109 @@ +layerrule = ignorezero, nwg-dock +workspace = w[tv1]s[false], gapsout:0, gapsin:0 +workspace = f[1]s[false], gapsout:0, gapsin:0 +windowrule = bordersize 2, floating:0, onworkspace:w[tv1]s[false] +windowrule = rounding 4, floating:0, onworkspace:w[tv1]s[false] +windowrule = bordersize 2, floating:0, onworkspace:f[1]s[false] +windowrule = rounding 4, floating:0, onworkspace:f[1]s[false] +# Example windowrule v1 +#windowrule = float, ^(rofi)$ +# Example windowrule v2 +# windowrulev2 = float,class:^(kitty)$,title:^(kitty)$ +windowrulev2 = suppressevent maximize, class:.* # You'll probably like this. +#windowrulev2 = noborder, class:wofi +windowrulev2 = float, class:wofi +windowrulev2 = pin, class:wofi +windowrulev2 = center, class:wofi +windowrulev2 = stayfocused, class:wofi +windowrulev2 = nomaxsize, class:wofi +windowrulev2 = minsize 5 5, class:wofi +windowrulev2 = noshadow, class:wofi +windowrulev2 = float, class:nm-connection-editor +windowrulev2 = pin, class:nm-connection-editor +windowrulev2 = center, class:nm-connection-editor +windowrulev2 = nomaxsize, class:nm-connection-editor +windowrulev2 = minsize 5 5, class:nm-connection-editor +windowrulev2 = noshadow, class:nm-connection-editor +windowrulev2 = opacity 1 0.8, class:kitty +windowrulev2 = opacity 0.8 0.3, initialTitle:Spotify +windowrulev2 = float, initialTitle:Spotify +windowrulev2 = noblur, initialTitle:Spotify +#windowrulev2 = float, class:Spaceengineers +#windowrulev2 = fullscreen, class:Spaceengineers +#windowrulev2 = stayfocused, class:Spaceengineers +#windowrule = float, class:steam_app_1133870 +windowrulev2 = tile, class:RimWorldLinux +windowrulev2 = float, class:Drill.+ +#windowrulev2 = fakefullscreen, title:Drill.+ +#windowrule = fakefullscreen, class:steam_app_1217060 +#windowrule = fakefullscreen, title:Gunfire Reborn +windowrule = float, class:steam_app_1217060 +windowrule = float, title:Gunfire Reborn +#windowrulev2 = fullscreen, class:steam_app_1217060 +#windowrulev2 = fullscreen, title:Gunfire Reborn +windowrulev2 = float, title:pico.+ +windowrulev2 = minsize 60 70, title:.+btop.+ +windowrulev2 = stayfocused, class:.+dmenu.+ +windowrulev2 = move 65% 10%, class:com.nextcloud.desktopclient.nextcloud +windowrulev2 = float, class:com.nextcloud.desktopclient.nextcloud +windowrulev2 = pin, class:com.nextcloud.desktopclient.nextcloud +windowrulev2 = size 30% 50%, class:com.nextcloud.desktopclient.nextcloud +windowrulev2 = move 65% 10%, tag:mixer +windowrulev2 = float, tag:mixer +windowrulev2 = pin, tag:mixer +windowrulev2 = size 30% 50%, tag:mixer + +windowrulev2 = move 65% 10%, class:blueman-manager +windowrulev2 = float, class:blueman-manager +windowrulev2 = pin, class:blueman-manager +windowrulev2 = size 30% 50%, class:blueman-manager + +windowrulev2 = opacity 1 1 override, class:firefox + +windowrulev2 = float, tag:centered +windowrulev2 = pin, tag:centered +windowrulev2 = center, tag:centered +windowrulev2 = stayfocused, tag:centered +windowrulev2 = size 40% 50%, tag:centered + +windowrulev2 = float, tag:centered-L +windowrulev2 = pin, tag:centered-L +windowrulev2 = center, tag:centered-L +windowrulev2 = stayfocused, tag:centered-L +windowrulev2 = size 70% 80%, tag:centered-L + +windowrulev2 = float, class:dev.benz.walker.+ +windowrulev2 = pin, class:dev.benz.walker.+ +windowrulev2 = center, class:dev.benz.walker.+ +windowrulev2 = stayfocused, class:dev.benz.walker.+ +windowrulev2 = minsize 180% 170%, class:dev.benz.walker.+ +windowrulev2 = noblur, class:dev.benz.walker.+ +windowrulev2 = noshadow, class:dev.benz.walker.+ +windowrulev2 = noborder, class:dev.benz.walker.+ +windowrulev2 = float, class:dev.benz.walker.+ + +windowrulev2 = float, class:dev.benz.walker +windowrulev2 = pin, class:dev.benz.walker +windowrulev2 = center, class:dev.benz.walker +windowrulev2 = stayfocused, class:dev.benz.walker +windowrulev2 = minsize 180% 170%, class:dev.benz.walker +windowrulev2 = noblur, class:dev.benz.walker +windowrulev2 = noshadow, class:dev.benz.walker +windowrulev2 = noborder, class:dev.benz.walker +windowrulev2 = float, class:dev.benz.walker + +windowrulev2 = float, class:ulauncher +windowrulev2 = pin, class:ulauncher +windowrulev2 = stayfocused, class:ulauncher +windowrulev2 = minsize 180% 170%, class:ulauncher +windowrulev2 = noblur, class:ulauncher +windowrulev2 = noshadow, class:ulauncher +windowrulev2 = noborder, class:ulauncher +windowrulev2 = float, class:ulauncher +windowrulev2 = move onscreen cursor -10% -10%, initialClass:ulauncher + +windowrulev2 = float, title:Quake 3: Arena +windowrulev2 = pin, title:Quake 3: Arena +windowrulev2 = center, title:Quake 3: Arena +windowrulev2 = stayfocused, title:Quake 3: Arena +windowrulev2 = minsize 80% 70%, title:Quake 3: Arena diff --git a/desktopenvs/hyprdrive/hypr/usr/windowrules.lua b/desktopenvs/hyprdrive/hypr/usr/windowrules.lua new file mode 100644 index 0000000..e0696d4 --- /dev/null +++ b/desktopenvs/hyprdrive/hypr/usr/windowrules.lua @@ -0,0 +1,119 @@ +-- https://wiki.hypr.land/Configuring/Basics/Window-Rules/ + +-- launcher — float centred at cursor +hl.window_rule({ name = "vicinae-launcher", match = { class = "vicinae" }, tag = "+launcher" }) + +hl.window_rule({ + name = "launcher", + match = { tag = "launcher" }, + float = true, + pin = true, + no_shadow = true, + no_blur = true, + border_size = 0, + move = "(cursor_x-(window_w/2)) (cursor_y-(window_h/2))", +}) + +hl.window_rule({ + name = "vicinae-server", + match = { class = "vicinae-server" }, + pin = true, + no_shadow = true, + no_blur = true, + border_size = 0, + move = "(cursor_x-(window_w/2)) (cursor_y-(window_h/2))", +}) + +-- firefox +hl.window_rule({ name = "firefox", match = { class = "firefox" }, opaque = true, render_unfocused = true }) + +-- vesktop (Discord) +hl.window_rule({ name = "vesktop", match = { class = "vesktop" }, render_unfocused = true, no_screen_share = true }) + +-- mixer tag — tag assignment +hl.window_rule({ name = "nextcloud-mixer", match = { class = "com.nextcloud.desktopclient.nextcloud" }, tag = "+mixer" }) +hl.window_rule({ name = "blueman-mixer", match = { class = "blueman-manager" }, tag = "+mixer" }) +hl.window_rule({ name = "nm-mixer", match = { class = "nm-connection-editor" }, tag = "+mixer" }) + +-- mixer tag — behaviour +hl.window_rule({ + name = "mixer-float", + match = { tag = "mixer" }, + float = true, + pin = true, + move = "(monitor_w*0.7) (monitor_h*0.05)", + size = "(monitor_w*0.2) (monitor_h*0.3)", +}) + +-- centred large +hl.window_rule({ + name = "large-centered-L", + match = { tag = "centered-L" }, + float = true, + move = "(monitor_w*0.1) (monitor_h*0.1)", + size = "(monitor_w*0.8) (monitor_h*0.8)", +}) + +-- centred medium +hl.window_rule({ + name = "large-centered", + match = { tag = "centered" }, + float = true, + move = "(monitor_w*0.2) (monitor_h*0.2)", + size = "(monitor_w*0.6) (monitor_h*0.6)", +}) + +-- centred small +hl.window_rule({ + name = "centered-small", + match = { tag = "centered-S" }, + float = true, + move = "(monitor_w*0.4) (monitor_h*0.4)", + size = "(monitor_w*0.2) (monitor_h*0.2)", +}) + +-- filepicker — float, fixed 760x460, centred on the cursor. +-- The size and move must be kept in sync by hand: Hyprland evaluates `move` +-- using the window's *natural* size and only then applies `size`, resizing +-- from the top-left. Using cursor_x-(window_w/2) would therefore centre the +-- natural (~1274x714) window and leave the shrunk one off-cursor, so the +-- offsets below are hard-coded to half the final size (380x230). zenity's +-- file chooser ignores --width/--height and has a ~738x363 GTK minimum, so +-- 760x460 is about as small as it goes while landing exactly on the pointer. +hl.window_rule({ + name = "filepicker", + match = { tag = "filepicker" }, + float = true, + size = "760 460", + move = "(cursor_x-380) (cursor_y-230)", +}) + +-- xdg portal dialogs — float, centred on the monitor (i.e. above the parent). +-- The GTK portal backend (xdg-desktop-portal-gtk) serves native file/app/print +-- choosers for portal-driven apps: Steam ("select library folder"), Chromium/ +-- Electron, Flatpak apps, etc. It runs in its *own* process, so the dialog opens +-- as a parentless top-level with no xdg-dialog/X11-dialog hint — Hyprland has no +-- signal that it's a dialog and would otherwise tile it (unlike in-process xdg +-- dialogs, which it auto-floats). Natural size is kept, so the centre offsets use +-- the real window_w/window_h (no `size` to skew them; see the filepicker note). +hl.window_rule({ + name = "xdg-portal-dialog", + match = { class = "xdg-desktop-portal-gtk" }, + float = true, + move = "(monitor_w/2 - window_w/2) (monitor_h/2 - window_h/2)", +}) + +-- spotify floating +hl.window_rule({ + name = "spotify-float", + match = { class = "spotify", float = true }, + no_blur = true, + opacity = "0.5 0.05", +}) + +-- smart gaps +hl.workspace_rule({ workspace = "w[tv1]s[false]", gaps_out = 0, gaps_in = 0 }) +hl.workspace_rule({ workspace = "f[1]s[false]", gaps_out = 0, gaps_in = 0 }) + +hl.window_rule({ name = "smart-gaps-tiling-tv1", match = { float = false, workspace = "w[tv1]s[false]" }, border_size = 1, rounding = 3 }) +hl.window_rule({ name = "smart-gaps-tiling-f1", match = { float = false, workspace = "f[1]s[false]" }, border_size = 0, rounding = 0 }) diff --git a/desktopenvs/hyprdrive/kitty/bash-remoteconf b/desktopenvs/hyprdrive/kitty/bash-remoteconf new file mode 100644 index 0000000..08d29a9 --- /dev/null +++ b/desktopenvs/hyprdrive/kitty/bash-remoteconf @@ -0,0 +1,63 @@ +# +# ~/.bashrc +# + +# If not running interactively, don't do anything +[[ $- != *i* ]] && return + +alias ls='ls --color=auto' +alias grep='grep --color=auto' +PS1='[\u@\h \W]\$ ' + +alias ll="ls -la" +alias l="ll" + +alias ..="cd .." + +alias m="micro" +alias sm="sudo micro" + +alias n="nano" +alias sn="sudo nano" + +alias gita="git add ." +alias gitc="git commit -m" +alias gitp="git push" + + +function gitf() { + + if [ -z $1 ]; then + echo "no commit message, doing nothing" + else + echo "directly commiting with message \"$1\"" + git add . + git commit -m $1 + git push + fi +} + +alias icat="kitten icat" +alias cls="clear" + +alias ssh="kitten ssh" +alias ssk="kitten ssh" + +alias t="wd" + +function y() { + local tmp="$(mktemp -t "yazi-cwd.XXXXXX")" cwd + yazi "$@" --cwd-file="$tmp" + if cwd="$(command cat -- "$tmp")" && [ -n "$cwd" ] && [ "$cwd" != "$PWD" ]; then + builtin cd -- "$cwd" + fi + rm -f -- "$tmp" +} + +export TERM=xterm-256color + +#eval "$(starship init bash)" + +#fastfetch --logo-color-1 red --logo-color-2 red --color red -l ~/Dotfiles/m-archy-SPC.txt + +export PS1="\u@\H>\t " diff --git a/desktopenvs/hyprdrive/kitty/current-theme.conf b/desktopenvs/hyprdrive/kitty/current-theme.conf new file mode 100644 index 0000000..a7b99ca --- /dev/null +++ b/desktopenvs/hyprdrive/kitty/current-theme.conf @@ -0,0 +1,106 @@ +# vim:ft=kitty +#: This is a template that can be used to create new kitty themes. +#: Theme files should start with a metadata block consisting of +#: lines beginning with ##. All metadata fields are optional. + +## name: The name of the theme (if not present, derived from filename) +## author: The name of the theme author +## license: The license information +## upstream: A URL pointing to the location of this file upstream for auto-updates +## blurb: A description of this theme. This must be the +## last item in the metadata and can continue over multiple lines. + +#: All the settings below are colors, which you can choose to modify, or use the +#: defaults. You can also add non-color based settings if needed but note that +#: these will not work with using kitty @ set-colors with this theme. For a +#: reference on what these settings do see https://sw.kovidgoyal.net/kitty/conf/ + +#: The basic colors + +foreground #d6abab +background #000000 +selection_foreground #E40046 +selection_background #5018dd + + +#: Cursor colors + +cursor #E40046 +cursor_text_color #5018dd + + +#: URL underline color when hovering with mouse + +url_color #f50505 + + +#: kitty window border colors and terminal bell colors + +# active_border_color #00ff00 +# inactive_border_color #cccccc +# bell_border_color #ff5a00 +# visual_bell_color none + + +#: OS Window titlebar colors + +# wayland_titlebar_color system +# macos_titlebar_color system + + +#: Tab bar colors + +active_tab_foreground #5018dd +active_tab_background #E40046 +inactive_tab_foreground #E40046 +inactive_tab_background #5018dd +# tab_bar_background none +# tab_bar_margin_color none + + +#: Colors for marks (marked text in the terminal) + +# mark1_foreground black +# mark1_background #98d3cb +# mark2_foreground black +# mark2_background #f2dcd3 +# mark3_foreground black +# mark3_background #f274bc + + +#: The basic 16 colors + +#: black +# color0 #000000 +# color8 #767676 + +#: red +# color1 #cc0403 +# color9 #f2201f + +#: green +# color2 #19cb00 +# color10 #23fd00 + +#: yellow +# color3 #cecb00 +# color11 #fffd00 + +#: blue +# color4 #0d73cc +# color12 #1a8fff + +#: magenta +# color5 #cb1ed1 +# color13 #fd28ff + +#: cyan +# color6 #0dcdcd +# color14 #14ffff + +#: white +# color7 #dddddd +# color15 #ffffff + + +#: You can set the remaining 240 colors as color16 to color255. diff --git a/desktopenvs/hyprdrive/kitty/kitty.conf b/desktopenvs/hyprdrive/kitty/kitty.conf new file mode 100644 index 0000000..2e3a30a --- /dev/null +++ b/desktopenvs/hyprdrive/kitty/kitty.conf @@ -0,0 +1,36 @@ +font_family Agave Nerd Font +font_size 13.0 +shell_integration no-cursor +cursor_shape beam +cursor_beam_thickness 2 +cursor_trail 3 +cursor_text_color #E40046 +cursor_trail_decay 0.1 0.7 +cursor #5018dd +cursor_trail_start_threshold 0 +cursor_blink_interval -1 +window_padding_width 4 +scrollback_lines 20000 +background_opacity 0.4 +background_blur 1 +editor nvim +confirm_os_window_close 0 + +map f1 new_tab_with_cwd + +map f2 previous_tab +map f3 next_tab +map ctrl+f2 new_tab +map ctrl+f3 close_tab + +tab_bar_style powerline +# active_tab_foreground #5018dd +# active_tab_background #E40046 +# inactive_tab_foreground #E40046 +# inactive_tab_background #5018dd + + +# BEGIN_KITTY_THEME +# Cyberqueer +include current-theme.conf +# END_KITTY_THEME diff --git a/desktopenvs/hyprdrive/kitty/kitty.conf.old b/desktopenvs/hyprdrive/kitty/kitty.conf.old new file mode 100644 index 0000000..550bc1b --- /dev/null +++ b/desktopenvs/hyprdrive/kitty/kitty.conf.old @@ -0,0 +1,25 @@ +font_family Agave Nerd Font +font_size 13.0 +shell_integration enabled +cursor_shape block +cursor_blink_interval 4 +window_padding_width 4 +scrollback_lines 20000 +background_opacity 0.4 +background_blur 1 +editor micro +confirm_os_window_close 0 + +map f1 new_tab_with_cwd + +map f2 previous_tab +map f3 next_tab +map ctrl+f2 new_tab +map ctrl+f3 close_tab + +tab_bar_style powerline + +active_tab_foreground #5018dd +active_tab_background #E40046 +inactive_tab_foreground #E40046 +inactive_tab_background #5018dd diff --git a/desktopenvs/hyprdrive/kitty/ssh.conf b/desktopenvs/hyprdrive/kitty/ssh.conf new file mode 100644 index 0000000..689163d --- /dev/null +++ b/desktopenvs/hyprdrive/kitty/ssh.conf @@ -0,0 +1,6 @@ +remote_dir .local/share/kitty-ssh-kitten +copy --dest .bashrc Dotfiles/desktopenvs/hyprland/kitty/bash-remoteconf +shell_integration inherited +remote_kitty if-needed +share_connections yes +askpass native diff --git a/desktopenvs/hyprdrive/kitty/themes/cyberqueer.conf b/desktopenvs/hyprdrive/kitty/themes/cyberqueer.conf new file mode 100644 index 0000000..7d18731 --- /dev/null +++ b/desktopenvs/hyprdrive/kitty/themes/cyberqueer.conf @@ -0,0 +1,106 @@ +# vim:ft=kitty +#: This is a template that can be used to create new kitty themes. +#: Theme files should start with a metadata block consisting of +#: lines beginning with ##. All metadata fields are optional. + +## name: The name of the theme (if not present, derived from filename) +## author: The name of the theme author +## license: The license information +## upstream: A URL pointing to the location of this file upstream for auto-updates +## blurb: A description of this theme. This must be the +## last item in the metadata and can continue over multiple lines. + +#: All the settings below are colors, which you can choose to modify, or use the +#: defaults. You can also add non-color based settings if needed but note that +#: these will not work with using kitty @ set-colors with this theme. For a +#: reference on what these settings do see https://sw.kovidgoyal.net/kitty/conf/ + +#: The basic colors + +foreground #d6abab +background #000000 +selection_foreground #E40046 +selection_background #5018dd + + +#: Cursor colors + +cursor #E40046 +cursor_text_color #5018dd + + +#: URL underline color when hovering with mouse + +url_color #f50505 + + +#: kitty window border colors and terminal bell colors + +# active_border_color #00ff00 +# inactive_border_color #cccccc +# bell_border_color #ff5a00 +# visual_bell_color none + + +#: OS Window titlebar colors + +# wayland_titlebar_color system +# macos_titlebar_color system + + +#: Tab bar colors + +active_tab_foreground #5018dd +active_tab_background #E40046 +inactive_tab_foreground #E40046 +inactive_tab_background #5018dd +# tab_bar_background none +# tab_bar_margin_color none + + +#: Colors for marks (marked text in the terminal) + +# mark1_foreground black +# mark1_background #98d3cb +# mark2_foreground black +# mark2_background #f2dcd3 +# mark3_foreground black +# mark3_background #f274bc + + +#: The basic 16 colors + +#: black +# color0 #000000 +# color8 #767676 + +#: red +# color1 #cc0403 +# color9 #f2201f + +#: green +# color2 #19cb00 +# color10 #23fd00 + +#: yellow +# color3 #cecb00 +# color11 #fffd00 + +#: blue +# color4 #0d73cc +# color12 #1a8fff + +#: magenta +# color5 #cb1ed1 +# color13 #fd28ff + +#: cyan +# color6 #0dcdcd +# color14 #14ffff + +#: white +# color7 #dddddd +# color15 #ffffff + + +#: You can set the remaining 240 colors as color16 to color255. diff --git a/desktopenvs/hyprdrive/mimeapps.list b/desktopenvs/hyprdrive/mimeapps.list new file mode 100644 index 0000000..a1ff681 --- /dev/null +++ b/desktopenvs/hyprdrive/mimeapps.list @@ -0,0 +1,47 @@ +[Default Applications] +text/plain=nvim.desktop +image/svg+xml=org.inkscape.Inkscape.desktop +image/jpeg=pinta.desktop +image/png=pinta.desktop +x-scheme-handler/discord=vesktop.desktop +x-scheme-handler/ror2mm=gale-handler.desktop +x-scheme-handler/gale=gale-handler.desktop +application/zip=org.gnome.FileRoller.desktop +terminal=kitty.desktop +TerminalEmulator=kitty.desktop +inode/directory=thunar.desktop +x-scheme-handler/http=firefox.desktop +x-scheme-handler/https=firefox.desktop +x-scheme-handler/chrome=firefox.desktop +text/html=firefox.desktop +application/x-extension-htm=firefox.desktop +application/x-extension-html=firefox.desktop +application/x-extension-shtml=firefox.desktop +application/xhtml+xml=firefox.desktop +application/x-extension-xhtml=firefox.desktop +application/x-extension-xht=firefox.desktop +text/x-python=nvim.desktop +x-scheme-handler/opendeck=opendeck-handler.desktop +x-scheme-handler/openaction=opendeck-handler.desktop +x-scheme-handler/streamdeck=opendeck-handler.desktop +x-scheme-handler/claude-cli=claude-code-url-handler.desktop + +[Added Associations] +application/zip=org.gnome.FileRoller.desktop;org.kde.ark.desktop;thunar.desktop; +application/x-compressed-tar=thunar.desktop; +image/jpeg=pinta.desktop; +text/x-emacs-lisp=vim.desktop; +x-scheme-handler/http=firefox.desktop; +x-scheme-handler/https=firefox.desktop; +x-scheme-handler/chrome=firefox.desktop; +text/html=firefox.desktop; +application/x-extension-htm=firefox.desktop; +application/x-extension-html=firefox.desktop; +application/x-extension-shtml=firefox.desktop; +application/xhtml+xml=firefox.desktop; +application/x-extension-xhtml=firefox.desktop; +application/x-extension-xht=firefox.desktop; +application/xml=nvim.desktop; +text/x-python=nvim.desktop; +video/mp4=firefox.desktop;vlc.desktop; +application/x-shellscript=nvim.desktop; diff --git a/desktopenvs/hyprdrive/orbit-menu/.gitignore b/desktopenvs/hyprdrive/orbit-menu/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/desktopenvs/hyprdrive/orbit-menu/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/desktopenvs/hyprdrive/orbit-menu/actions.py b/desktopenvs/hyprdrive/orbit-menu/actions.py new file mode 100644 index 0000000..e0838a1 --- /dev/null +++ b/desktopenvs/hyprdrive/orbit-menu/actions.py @@ -0,0 +1,63 @@ +"""Side-effecting helpers shared by menu_tree.py's leaf actions. + +Everything here fires-and-forgets via Gio.Subprocess (lib.proc.fire) so a click +never blocks the GTK main loop or the menu's close animation. + +Per-window ops use two different mechanisms on purpose: + * focus / bring-here go through `hyprctl dispatch "hl.dsp...."` — hyprlua's own + Lua-eval dispatch layer, the same one astal-menu/ui/taskbar.py already uses in + production for exactly this (see its _focus/_pull). + * close / force-kill go through plain native Hyprland dispatchers (`closewindow + address:...`) and a bare `kill -9 `, which work regardless of the Lua + layer and don't require guessing whether hl.dsp.window exposes a close-by- + address form. +""" + +from __future__ import annotations + +from lib.proc import fire + + +def launch(cmd: str) -> None: + """Run cmd via Hyprland's exec dispatcher — cmd may carry a `[tag ...]` / + `[workspace ...]` window-rule-on-launch prefix, same as binds.lua.""" + fire(["hyprctl", "dispatch", "exec", cmd]) + + +def hyprshutdown(post_cmd: str | None = None) -> None: + if post_cmd: + fire(["hyprshutdown", "-p", post_cmd]) + else: + fire(["hyprshutdown"]) + + +def _dispatch_lua(lua: str) -> None: + fire(["hyprctl", "dispatch", lua]) + + +def focus_window(address: str) -> None: + _dispatch_lua(f'hl.dsp.focus({{ window = "address:{address}" }})') + + +def bring_here(address: str, active_ws: int) -> None: + _dispatch_lua(f'hl.dsp.window.move({{ window = "address:{address}", ' + f'workspace = {active_ws} }})') + + +def send_to_workspace(address: str, workspace_id: int) -> None: + _dispatch_lua(f'hl.dsp.window.move({{ window = "address:{address}", ' + f'workspace = {workspace_id}, silent = true }})') + + +def close_window(address: str) -> None: + fire(["hyprctl", "dispatch", "closewindow", f"address:{address}"]) + + +def force_kill(pid: int) -> None: + fire(["kill", "-9", str(pid)]) + + +def report_pid(pid: int, title: str) -> None: + fire(["wl-copy", str(pid)]) + fire(["notify-send", "-a", "orbit-menu", f"PID {pid}", + f"{title} — copied to clipboard"]) diff --git a/desktopenvs/hyprdrive/orbit-menu/config.py b/desktopenvs/hyprdrive/orbit-menu/config.py new file mode 100644 index 0000000..ff66ace --- /dev/null +++ b/desktopenvs/hyprdrive/orbit-menu/config.py @@ -0,0 +1,34 @@ +"""Tiny user-editable config file: ~/.local/state/orbit-menu/config.json. + +Read once at startup (main.py); flags are passed into OrbitMenu's constructor +rather than polled, so a change takes effect on the next orbit-menu-start.sh +restart, not live. +""" + +from __future__ import annotations + +import json + +from paths import CONFIG_FILE, ensure_dirs + +_DEFAULTS = {"satellites": True, "hologram": True} + + +def _load() -> dict: + try: + data = json.loads(CONFIG_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + data = {} + merged = {**_DEFAULTS, **data} + if not CONFIG_FILE.exists(): + ensure_dirs() + CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n") + return merged + + +def satellites_enabled() -> bool: + return bool(_load().get("satellites", True)) + + +def hologram_enabled() -> bool: + return bool(_load().get("hologram", True)) diff --git a/desktopenvs/hyprdrive/orbit-menu/lib/proc.py b/desktopenvs/hyprdrive/orbit-menu/lib/proc.py new file mode 100644 index 0000000..8c5c197 --- /dev/null +++ b/desktopenvs/hyprdrive/orbit-menu/lib/proc.py @@ -0,0 +1,63 @@ +"""Subprocess helpers built on Gio so nothing blocks the GTK main loop. + +Trimmed copy of astal-menu/lib/proc.py's contract: a command prints JSON (or plain +text) to stdout, diagnostics to stderr, non-zero exit on failure. These helpers run +such commands asynchronously and hand the parsed result back on the main thread. +""" + +from __future__ import annotations + +import json +import shlex +from typing import Callable, Sequence + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib # noqa: E402 + + +def _as_argv(cmd: Sequence[str] | str) -> list[str]: + return shlex.split(cmd) if isinstance(cmd, str) else list(cmd) + + +def run_text(cmd: Sequence[str] | str, cb: Callable[[bool, str, str], None]) -> None: + """Run cmd, call cb(ok, stdout, stderr) on the main thread when done.""" + try: + proc = Gio.Subprocess.new( + _as_argv(cmd), + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE, + ) + except GLib.Error as err: + GLib.idle_add(cb, False, "", str(err)) + return + + def _done(p: Gio.Subprocess, res: Gio.AsyncResult) -> None: + try: + _, out, errout = p.communicate_utf8_finish(res) + except GLib.Error as err: + cb(False, "", str(err)) + return + cb(p.get_successful(), out or "", errout or "") + + proc.communicate_utf8_async(None, None, _done) + + +def run_json(cmd: Sequence[str] | str, cb: Callable[[bool, object], None]) -> None: + """Run cmd expecting JSON on stdout; call cb(ok, data).""" + + def _text(ok: bool, out: str, err: str) -> None: + if not ok or not out.strip(): + cb(False, err.strip() or "no output") + return + try: + cb(True, json.loads(out)) + except json.JSONDecodeError as exc: + cb(False, f"bad json: {exc}") + + run_text(cmd, _text) + + +def fire(cmd: Sequence[str] | str) -> None: + """Run cmd and discard the result — for launch/dispatch calls no one awaits.""" + run_text(cmd, lambda *_a: None) diff --git a/desktopenvs/hyprdrive/orbit-menu/main.py b/desktopenvs/hyprdrive/orbit-menu/main.py new file mode 100644 index 0000000..d12ce0d --- /dev/null +++ b/desktopenvs/hyprdrive/orbit-menu/main.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""orbit-menu — a radial power/utility menu for hyprdrive. + +Two entry points into the same tree: + Super+Shift+O -> --power opens straight into the Power ring (sleep / reboot / + power off / soft reboot, via hyprshutdown) + Super+Ctrl+O -> --menu opens the 5-category root (Power, Tools, Scripts, + Management, Windows) + +Single-instance, same pattern as astal-menu/main.py: the first launch builds the +(hidden) window and holds; later invocations forward their verb over D-Bus via +scripts/orbit-menu.sh instead of spawning a second python3+GTK4 process. + + main.py run the resident instance (stays hidden until toggled) + main.py --power show, rooted at Power + main.py --menu show, rooted at the full category menu + main.py --hide hide +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# orbit-menu-start.sh LD_PRELOADs libgtk4-layer-shell (load-ordering requirement +# ahead of libwayland-client). Drop it once resident so it isn't inherited by +# anything this process launches (see astal-menu/main.py for the same rationale — +# some GTK4 apps abort at startup with the layer-shell lib preloaded). +os.environ.pop("LD_PRELOAD", None) + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import gi # noqa: E402 + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib, Gtk # noqa: E402 + +import config # noqa: E402 +import menu_tree # noqa: E402 +import theme # noqa: E402 +from orbit_menu import OrbitMenu # noqa: E402 +from paths import APP_ID # noqa: E402 + + +class OrbitMenuApp(Gtk.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID, + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + self.window: OrbitMenu | None = None + self._current_mode: str | None = None # "power" | "menu" + + def do_startup(self) -> None: + Gtk.Application.do_startup(self) + theme.load_css() + self.window = OrbitMenu(menu_tree.full_menu_root(), on_close=self._on_closed, + satellites=config.satellites_enabled(), + hologram=config.hologram_enabled()) + self._current_mode = "menu" + self._register_actions() + self.hold() # stay alive with no visible window + + def _on_closed(self) -> None: + pass # nothing to do; the window just hides itself + + def _open(self, mode: str) -> None: + assert self.window is not None + if self.window.get_visible() and self._current_mode == mode: + self.window.set_visible(False) + return + if self._current_mode != mode: + root = menu_tree.power_only_root() if mode == "power" else menu_tree.full_menu_root() + self.window.set_root(root) + self._current_mode = mode + self.window.open_at_root() + + def _register_actions(self) -> None: + def add(name: str, callback) -> None: + action = Gio.SimpleAction.new(name, None) + action.connect("activate", callback) + self.add_action(action) + + def on_hide(*_a) -> None: + assert self.window is not None + self.window.set_visible(False) + + add("power", lambda *_a: self._open("power")) + add("menu", lambda *_a: self._open("menu")) + add("hide", on_hide) + + def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int: + args = list(cmdline.get_arguments()[1:]) + verb = args[0] if args else "--daemon" + if self.window is None: + return 0 + if verb == "--power": + self._open("power") + elif verb == "--menu": + self._open("menu") + elif verb == "--hide": + self.window.set_visible(False) + # --daemon and anything else: no-op (stay resident, hidden) + return 0 + + def do_activate(self) -> None: + pass # resident instance: nothing to do on plain activate + + +def main() -> int: + GLib.set_prgname("orbit-menu") + return OrbitMenuApp().run(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktopenvs/hyprdrive/orbit-menu/menu_tree.py b/desktopenvs/hyprdrive/orbit-menu/menu_tree.py new file mode 100644 index 0000000..8861691 --- /dev/null +++ b/desktopenvs/hyprdrive/orbit-menu/menu_tree.py @@ -0,0 +1,182 @@ +"""Builds the 5-category MenuItem tree: Power, Tools, Scripts, Management, Windows. + +Power/Tools/Management are static lists. Scripts and Windows are `dynamic=True` — +orbit_menu.py never caches a dynamic node's page, so both are rebuilt fresh (current +~/Documents/Scripts listing, current `hyprctl clients -j`) every time they're +entered; that dynamism cascades to their children too (see OrbitMenu._cache_key), +so a window's own action ring and its "send to workspace" submenu are always live. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import actions +from orbit_menu import MenuItem +from paths import SCRIPTS_DIR + + +def _hyprctl_json(*args: str): + try: + out = subprocess.run(["hyprctl", *args, "-j"], capture_output=True, + text=True, timeout=1.5, check=True).stdout + return json.loads(out) + except (subprocess.SubprocessError, json.JSONDecodeError, OSError): + return None + + +# -- Power ------------------------------------------------------------------ + +def power_root() -> MenuItem: + return MenuItem(label="Power", icon="⏻", children=[ + MenuItem(label="Sleep", icon="☾", danger=True, + action=lambda: actions.hyprshutdown("systemctl suspend")), + MenuItem(label="Reboot", icon="⟳", danger=True, + action=lambda: actions.hyprshutdown("systemctl reboot")), + MenuItem(label="Power Off", icon="⏻", danger=True, + action=lambda: actions.hyprshutdown("systemctl poweroff")), + MenuItem(label="Soft Reboot", icon="⟲", danger=True, + action=lambda: actions.hyprshutdown("systemctl soft-reboot")), + ]) + + +# -- Tools -------------------------------------------------------------------- +# The little utilities also reachable via their own keybinds (see binds.lua) — +# gathered here as one launcher so they don't all need to be memorised. + +def tools_root() -> MenuItem: + def kitty(tag: str, cmd: str) -> str: + return f"[tag {tag}] kitty {cmd}" + + return MenuItem(label="Tools", icon="⚙", children=[ + MenuItem(label="Timer", + action=lambda: actions.launch( + kitty("+centered-S", "bash ~/.config/scripts/timer-pick"))), + MenuItem(label="SSH Manager", + action=lambda: actions.launch( + kitty("+centered-L", "-e ~/.config/scripts/amssh"))), + MenuItem(label="System Monitor", + action=lambda: actions.launch(kitty("+centered-L", "btop"))), + MenuItem(label="Screen Recorder", + action=lambda: actions.launch("~/.config/scripts/screenrec.sh")), + MenuItem(label="Color Picker", + action=lambda: actions.launch("hyprpicker | wl-copy")), + MenuItem(label="Caffeine", + action=lambda: actions.launch("~/.config/scripts/caffeine.sh")), + MenuItem(label="On-Screen Kbd", + action=lambda: actions.launch("~/.config/scripts/onscreenkb.sh")), + MenuItem(label="Calculator", + action=lambda: actions.launch("qalculate-gtk")), + MenuItem(label="Keybind Help", + action=lambda: actions.launch( + kitty("+centered", "~/.config/scripts/helpmenu.sh"))), + ]) + + +# -- Scripts (dynamic) --------------------------------------------------------- + +def _script_children() -> list[MenuItem]: + if not SCRIPTS_DIR.is_dir(): + return [MenuItem(label=f"(create {SCRIPTS_DIR})")] + entries = sorted(p for p in SCRIPTS_DIR.iterdir() if p.is_file()) + if not entries: + return [MenuItem(label="(no scripts found)")] + + items = [] + for p in entries: + if os.access(p, os.X_OK): + cmd = f"[tag +centered-L] kitty {p}" + elif p.suffix == ".py": + cmd = f"[tag +centered-L] kitty python3 {p}" + elif p.suffix in (".sh", ".bash"): + cmd = f"[tag +centered-L] kitty bash {p}" + else: + continue + items.append(MenuItem(label=p.stem, action=lambda c=cmd: actions.launch(c))) + return items or [MenuItem(label="(no runnable scripts)")] + + +def scripts_root() -> MenuItem: + return MenuItem(label="Scripts", icon="❯", dynamic=True, children=_script_children) + + +# -- Management ----------------------------------------------------------------- + +def management_root() -> MenuItem: + return MenuItem(label="Management", icon="⛶", children=[ + MenuItem(label="Wallpaper Picker", + action=lambda: actions.launch( + "[tag +centered-L] kitty -e ~/.config/scripts/wallpaper-picker ~/Pictures")), + MenuItem(label="Monitor Manager", + action=lambda: actions.launch( + "[tag +centered-L] kitty -e ~/.config/scripts/monitor-manager")), + ]) + + +# -- Windows (dynamic) ----------------------------------------------------------- + +def _window_label(w: dict) -> str: + return (w.get("title") or w.get("class") or "?")[:28] + + +def _workspace_children(address: str) -> list[MenuItem]: + workspaces = _hyprctl_json("workspaces") or [] + if not workspaces: + return [MenuItem(label="(no workspaces)")] + items = [] + for ws in sorted(workspaces, key=lambda w: w.get("id", 0)): + wsid = ws.get("id") + name = ws.get("name") or str(wsid) + items.append(MenuItem(label=name, icon="⇒", + action=lambda w=wsid: actions.send_to_workspace(address, w))) + return items + + +def _window_actions(w: dict) -> list[MenuItem]: + address = w["address"] + pid = w.get("pid", -1) + active = _hyprctl_json("activeworkspace") or {} + active_ws = active.get("id", w.get("workspace", {}).get("id", 0)) + title = _window_label(w) + return [ + MenuItem(label="Go To Window", icon="➤", + action=lambda: actions.focus_window(address)), + MenuItem(label="Bring Here", icon="⇤", + action=lambda: actions.bring_here(address, active_ws)), + MenuItem(label="Close Window", icon="✕", + action=lambda: actions.close_window(address)), + MenuItem(label="Force Kill", icon="⊘", danger=True, + action=lambda: actions.force_kill(pid)), + MenuItem(label="Get PID", icon="#", + action=lambda: actions.report_pid(pid, title)), + MenuItem(label="Send To Workspace", icon="⇒", + dynamic=True, children=lambda: _workspace_children(address)), + ] + + +def _window_children() -> list[MenuItem]: + clients = _hyprctl_json("clients") or [] + wins = [w for w in clients if w.get("mapped", True) and w.get("class")] + if not wins: + return [MenuItem(label="(no open windows)")] + wins.sort(key=lambda w: (w.get("workspace", {}).get("id", 0), _window_label(w).lower())) + return [MenuItem(label=_window_label(w), dynamic=True, + children=lambda w=w: _window_actions(w)) for w in wins] + + +def windows_root() -> MenuItem: + return MenuItem(label="Windows", icon="▣", dynamic=True, children=_window_children) + + +# -- Roots ------------------------------------------------------------------ + +def full_menu_root() -> MenuItem: + return MenuItem(label="Menu", children=[ + power_root(), tools_root(), scripts_root(), management_root(), windows_root(), + ]) + + +def power_only_root() -> MenuItem: + return power_root() diff --git a/desktopenvs/hyprdrive/orbit-menu/orbit_menu.py b/desktopenvs/hyprdrive/orbit-menu/orbit_menu.py new file mode 100644 index 0000000..57719cd --- /dev/null +++ b/desktopenvs/hyprdrive/orbit-menu/orbit_menu.py @@ -0,0 +1,915 @@ +"""Orbit menu — the base element. + +A single "orbit" is one central node surrounded by a ring of child nodes, laid out +on a circle like moons around a planet. Clicking a child that has its own children +re-centers the view on that child and draws a fresh ring for *its* children — the +same base element recursively applied, so orbits nest arbitrarily deep. Clicking the +center (or Backspace) steps back out one level; Escape closes the menu. + +Each level is rendered once into its own Gtk.Fixed page and cached in a plain dict +keyed by the path of indices taken to reach it (e.g. (2, 0)). Navigating between +levels plays a sci-fi-orbital-map zoom (see _start_transition/_update_transition): +the clicked node's on-screen position becomes a focal point — the parent page +zooms *into* that fixed point (as if diving toward it, everything else rushing off +past the edges) while the child page slides in from that exact spot and grows from +a small dot up to the full view, becoming the new center, like a moon becoming the +planet you now orbit. Going back plays the same motion in reverse around the same +point (each depth's focal point is remembered in _stack_origins). Driven by the +same per-frame tick callback that animates the satellites, using GTK4's +Gsk.Transform (via Gtk.Fixed.set_child_transform) to scale each page around an +arbitrary pivot. A category with a dynamic child list (open windows, user scripts) +passes `dynamic=True` so its page is rebuilt — never cached — on every visit. + +Ported from the ~/code.dev/test-astralmenu prototype onto raw Gtk4LayerShell (no +Astal.Window dependency) to match astal-menu/window.py's layer-shell approach. +""" + +from __future__ import annotations + +import math +import random +from dataclasses import dataclass, field +from typing import Callable, Optional + +import cairo +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +gi.require_version("Gsk", "4.0") +gi.require_version("Graphene", "1.0") +gi.require_version("Gtk4LayerShell", "1.0") +from gi.repository import Gdk, Graphene, Gsk, Gtk # noqa: E402 +from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 + +# CyberQueer accent/violet, matching style/_colors.css — hardcoded here (as +# lib/border.py does for its own Cairo drawing) since Cairo paints directly and +# doesn't see GTK CSS @define-color names. _MAGENTA has no CSS counterpart — +# it's only used for the hologram noise specs (see _draw_hologram_noise). +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) +_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) +_MAGENTA = (0.92, 0.0, 0.65) + + +@dataclass +class _Satellite: + glyph: str + height: float # fixed radial distance from its node — never changes at runtime + wobble_amp: float # extra angle (radians) layered on top of the mouse-facing direction + wobble_speed: float + phase: float + color: tuple[float, float, float] + + +@dataclass +class _Assignment: + """One satellite bound to one node — decided once per page build (see + OrbitMenu._pick_assignments), not re-rolled every frame. A node may end up with + two assignments; since each _Satellite has its own fixed `height`, the two + already ride different-radius orbits, and _pick_assignments additionally + widens the gap between them (see effective_height) so they never crowd.""" + sat: _Satellite + node: tuple[float, float] + bracket: Optional[tuple[str, str]] = None # flanking glyphs, tangent to the orbit + line_style: str = "solid" # "solid" | "dashed" | "dotted" + axis: Optional[str] = None # None -> faces the cursor; "x"/"y" -> see below + effective_height: float = 0.0 # sat.height, widened if this node is doubled up + mouse_offset: float = 0.0 # per-assignment phase added to the axis angle + mult_x: float = 1.0 # independent per-assignment response strength + mult_y: float = 1.0 # to horizontal vs vertical mouse movement + + +@dataclass +class MenuItem: + label: str + children: list["MenuItem"] | Callable[[], list["MenuItem"]] = field(default_factory=list) + action: Optional[Callable[[], None]] = None + danger: bool = False # red/danger tint (power actions) + dynamic: bool = False # rebuild this node's page every visit, never cache + icon: str = "" # optional leading nerd-font glyph, shown above label + + def resolved_children(self) -> list["MenuItem"]: + return self.children() if callable(self.children) else self.children + + +class OrbitMenu(Gtk.Window): + """A layer-shell window that renders a MenuItem tree as nested, cached orbits.""" + + CENTER_SIZE = 108 + NODE_SIZE = 66 # prototype was 84; shrunk >=1/5 (66/84 ≈ 0.79) per node + RADIUS = 168 + CANVAS_SIZE = 480 + + # Cosmetic satellites: glyphs that each orbit ONE ring node at a fixed radial + # "height" from it (set once here, never changed at runtime) — only their angle + # around that node moves, easing to face the cursor plus a small idle wobble so + # they're alive even when the mouse is still. Which nodes get one, how many, and + # which glyph goes where is re-rolled once per page build (_pick_assignments) — + # varied each time you land on a category, stable while that page is showing. + _GLYPH_FONT = "Agave Nerd Font Mono" + _SATELLITES = [ + _Satellite(glyph="\uef5f", height=42, wobble_amp=0.30, wobble_speed=0.6, phase=0.0, color=_ACCENT), + _Satellite(glyph="\U000f0471", height=50, wobble_amp=0.22, wobble_speed=0.45, phase=1.4, color=_VIOLET), + _Satellite(glyph="\uf197", height=46, wobble_amp=0.35, wobble_speed=0.7, phase=2.6, color=_VIOLET), + _Satellite(glyph="\U000f1383", height=54, wobble_amp=0.25, wobble_speed=0.5, phase=4.0, color=_ACCENT), + ] + + # Flanking glyphs drawn tangent to the orbit around some (not all) satellites — + # e.g. "-< >-". Rotated to the local tangent direction in _draw_glyph. + _BRACKETS = [("-<", ">-"), ("-[", "]-"), ("-(", ")-"), ("-{", "}-"), ("-|", "|-")] + _BRACKET_CHANCE = 0.45 + + def __init__(self, root: MenuItem, on_close: Optional[Callable[[], None]] = None, + satellites: bool = True, hologram: bool = True): + super().__init__() + self.add_css_class("orbit-menu-window") + self._on_close = on_close + self._satellites_enabled = satellites + self._hologram_enabled = hologram + + self._root = root + self._stack_path: list[int] = [] # indices taken from root + self._stack_origins: list[tuple[float, float]] = [] # focal point per depth, for _go_back + self._pages: dict[tuple, Gtk.Fixed] = {} # cached pages, keyed by path + self._page_rings: dict[Gtk.Fixed, Gtk.DrawingArea] = {} + self._page_buttons: dict[Gtk.Fixed, list[tuple[Gtk.Widget, float, float]]] = {} + self._current_ring: Gtk.DrawingArea | None = None + self._current_page: Gtk.Fixed | None = None + self._transition: dict | None = None # active zoom/fade nav transition, if any + self._intro: dict | None = None # active opening "materialize" animation, if any + self._holo_particles: list[dict] = [] # persistent hologram noise specs, see _update_hologram_particles + + # satellite/mouse-parallax state (unused entirely if satellites=False) + cx = cy = self.CANVAS_SIZE / 2 + self._mouse_target = (cx, cy) + self._mouse_smooth = (cx, cy) + self._sat_time = 0.0 + self._last_tick: float | None = None + self._tick_id: int | None = None + + self._init_layer_shell() + + self._viewport = Gtk.Fixed() + self._viewport.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE) + + # Hologram overlay: a single DrawingArea painted on top of EVERYTHING + # (buttons included), covering the whole menu regardless of which page is + # showing. set_can_target(False) so it never steals clicks meant for the + # buttons underneath. + self._hologram_area = Gtk.DrawingArea() + self._hologram_area.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE) + self._hologram_area.set_can_target(False) + self._hologram_area.set_draw_func(self._draw_hologram_frame) + self._hologram_area.add_css_class("orbit-hologram") + + root_overlay = Gtk.Overlay() + root_overlay.add_css_class("orbit-root-overlay") + root_overlay.set_child(self._viewport) + root_overlay.add_overlay(self._hologram_area) + self.set_child(root_overlay) + + keys = Gtk.EventControllerKey() + keys.connect("key-pressed", self._on_key) + self.add_controller(keys) + + if self._satellites_enabled: + motion = Gtk.EventControllerMotion() + motion.connect("motion", self._on_motion) + motion.connect("leave", self._on_motion_leave) + self.add_controller(motion) + + self._show_path((), animate=False) + + # -- layer shell ---------------------------------------------------- + def _init_layer_shell(self) -> None: + LayerShell.init_for_window(self) + LayerShell.set_layer(self, LayerShell.Layer.OVERLAY) + LayerShell.set_namespace(self, "orbit-menu") + LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.EXCLUSIVE) + # No anchors set => gtk-layer-shell centers the surface on its output. + + # -- item lookup / path helpers -------------------------------------- + def _item_at(self, path: tuple) -> MenuItem: + item = self._root + for i in path: + item = item.resolved_children()[i] + return item + + def _cache_key(self, path: tuple) -> tuple | None: + """None => never cache (path passes through a dynamic node).""" + item = self._root + for i in path: + if item.dynamic: + return None + item = item.resolved_children()[i] + return None if item.dynamic else path + + # -- page construction ------------------------------------------------ + @classmethod + def _ring_position(cls, count: int, idx: int) -> tuple[float, float]: + """Where ring node `idx` (of `count`) sits — same formula every page + uses, so a click handler can compute a node's position without needing + the built page widget (used as the zoom's focal point, see _on_node_clicked).""" + cx = cy = cls.CANVAS_SIZE / 2 + angle = -math.pi / 2 + idx * (2 * math.pi / max(count, 1)) + return cx + cls.RADIUS * math.cos(angle), cy + cls.RADIUS * math.sin(angle) + + def _build_page(self, path: tuple) -> tuple[Gtk.Fixed, Gtk.DrawingArea]: + current = self._item_at(path) + fixed = Gtk.Fixed() + fixed.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE) + cx = cy = self.CANVAS_SIZE / 2 + + ring = Gtk.DrawingArea() + ring.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE) + children = current.resolved_children() + fixed.put(ring, 0, 0) + + # (widget, x, y) for every button on this page, center first — used only by + # the opening "materialize from noise" intro (see _start_intro), which needs + # each button's position to fade it in and spawn a noise burst there. + buttons: list[tuple[Gtk.Widget, float, float]] = [] + + center_btn = Gtk.Button() + center_btn.set_has_frame(False) + center_btn.add_css_class("orbit-center") + if len(path) == 0: + center_btn.add_css_class("orbit-root") + center_btn.set_child(self._node_label(current.icon, current.label)) + center_btn.set_size_request(self.CENTER_SIZE, self.CENTER_SIZE) + center_btn.connect("clicked", lambda *_a: self._go_back()) + fixed.put(center_btn, cx - self.CENTER_SIZE / 2, cy - self.CENTER_SIZE / 2) + buttons.append((center_btn, cx, cy)) + + count = len(children) + node_positions: list[tuple[float, float]] = [] + for i, item in enumerate(children): + nx, ny = self._ring_position(count, i) + node_positions.append((nx, ny)) + + node_btn = Gtk.Button() + node_btn.set_has_frame(False) + node_btn.add_css_class("orbit-node") + if item.danger: + node_btn.add_css_class("orbit-danger") + node_btn.set_child(self._node_label(item.icon, item.label)) + node_btn.set_size_request(self.NODE_SIZE, self.NODE_SIZE) + node_btn.set_tooltip_text(item.label) + node_btn.connect("clicked", lambda *_a, idx=i: self._on_node_clicked(idx)) + fixed.put(node_btn, nx - self.NODE_SIZE / 2, ny - self.NODE_SIZE / 2) + buttons.append((node_btn, nx, ny)) + + self._page_buttons[fixed] = buttons + + assignments = self._pick_assignments(node_positions, center=(cx, cy)) \ + if self._satellites_enabled else [] + ring.set_draw_func(self._make_page_draw(has_ring=bool(children), assignments=assignments)) + return fixed, ring + + _MAX_PER_NODE = 2 + _MIN_SPREAD_NODES = 3 # every satellite spawned is spread across >= this + # many distinct nodes, node count permitting + _DOUBLED_MIN_GAP = 22 # minimum radius gap between 2 satellites sharing a node + _CENTER_HEIGHT_BUMP = 30 # extra clearance so satellites orbit outside the + # (much bigger) center button, not underneath it + _LINE_STYLES = ["solid", "dashed", "dotted"] + _COUNT_BASE = 2 + _COUNT_RANDOM_MOD = 3 # random upper bound = (_COUNT_RANDOM_MOD % node-amount) + _COUNT_RANDOM_ADD + _COUNT_RANDOM_ADD = 2 + + def _pick_assignments(self, node_positions: list[tuple[float, float]], + center: Optional[tuple[float, float]] = None) -> list[_Assignment]: + """Randomize which nodes get a satellite, which glyph goes where, and which + (if any) get a tangential bracket — decided once per page build. Total count + is always _COUNT_BASE + random(1, (_COUNT_RANDOM_MOD % node-amount) + + _COUNT_RANDOM_ADD) — scales down on pages with few nodes — clamped to + however many actually fit given _MAX_PER_NODE; since there are only + len(_SATELLITES) distinct glyphs, counts above that cycle through them again + rather than repeating the same one back-to-back. Placement first seeds + _MIN_SPREAD_NODES distinct nodes one each, then drops the rest onto random + nodes with room — so on any page with enough nodes, they land on at least 3 + of them, never crammed onto 1-2. `center` (the center/back button's + position) is just one more candidate node — it can get satellites too, with + extra height so they clear it.""" + all_positions = list(node_positions) + if center is not None: + all_positions.append(center) + n_nodes = len(all_positions) + if n_nodes == 0: + return [] + + random_max = (self._COUNT_RANDOM_MOD % n_nodes) + self._COUNT_RANDOM_ADD + count = self._COUNT_BASE + random.randint(1, random_max) + count = min(count, n_nodes * self._MAX_PER_NODE) + sats: list[_Satellite] = [] + while len(sats) < count: + cycle = list(self._SATELLITES) + random.shuffle(cycle) + sats.extend(cycle) + sats = sats[:count] + random.shuffle(sats) + + node_order = list(range(n_nodes)) + random.shuffle(node_order) + seed_count = min(self._MIN_SPREAD_NODES, n_nodes) + + per_node = [0] * n_nodes + placements: list[tuple[_Satellite, int]] = [] + for idx in node_order[:seed_count]: + if not sats: + break + per_node[idx] += 1 + placements.append((sats.pop(), idx)) + for sat in sats: + candidates = [i for i, c in enumerate(per_node) if c < self._MAX_PER_NODE] + if not candidates: + break # every node already at cap (only possible when n_nodes is tiny) + idx = random.choice(candidates) + per_node[idx] += 1 + placements.append((sat, idx)) + + assignments = [ + _Assignment( + sat=sat, node=all_positions[idx], + effective_height=sat.height + (self._CENTER_HEIGHT_BUMP if all_positions[idx] == center else 0), + bracket=random.choice(self._BRACKETS) if random.random() < self._BRACKET_CHANCE else None, + line_style=random.choice(self._LINE_STYLES), + # Independent per-satellite response strength to horizontal vs. + # vertical mouse movement — drawn separately, so a given satellite + # often ends up noticeably more reactive in one direction than + # the other instead of moving toward the cursor symmetrically. + mult_x=random.uniform(0.5, 1.8), + mult_y=random.uniform(0.5, 1.8), + ) + for sat, idx in placements + ] + + # A node with 2 satellites: split their motion instead of both facing the + # cursor identically (one tracks the mouse's X only, the other its Y — see + # _draw_satellites), widen the gap between their orbit radii so the higher + # one reads as clearly further out, and give each its own random phase + # offset — so when a page has several doubled nodes, they drift out of + # sync with each other instead of all sweeping in lockstep. + by_node: dict[tuple[float, float], list[_Assignment]] = {} + for a in assignments: + by_node.setdefault(a.node, []).append(a) + for group in by_node.values(): + if len(group) == self._MAX_PER_NODE: + random.shuffle(group) + group[0].axis = "x" + group[1].axis = "y" + for g in group: + g.mouse_offset = random.uniform(0.0, 2 * math.pi) + lo, hi = sorted(group, key=lambda a: a.effective_height) + if hi.effective_height - lo.effective_height < self._DOUBLED_MIN_GAP: + hi.effective_height = lo.effective_height + self._DOUBLED_MIN_GAP + + return assignments + + @staticmethod + def _node_label(icon: str, label: str) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, + halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER) + if icon: + ic = Gtk.Label(label=icon) + ic.add_css_class("orbit-node-icon") + box.append(ic) + lb = Gtk.Label(label=label, wrap=True, justify=Gtk.Justification.CENTER, + max_width_chars=10, lines=2, ellipsize=3) + lb.add_css_class("orbit-node-text") + box.append(lb) + return box + + def _make_page_draw(self, has_ring: bool, assignments: list[_Assignment]): + def draw(_area, cr, width, height): + if has_ring: + cx, cy = width / 2, height / 2 + cr.set_source_rgba(1, 1, 1, 0.12) + cr.set_line_width(1.5) + cr.arc(cx, cy, self.RADIUS, 0, 2 * math.pi) + cr.stroke() + if self._satellites_enabled: + self._draw_satellites(cr, assignments) + return draw + + def _draw_satellites(self, cr, assignments: list[_Assignment]) -> None: + """Each assigned glyph orbits its node at a FIXED radial height (set once in + _SATELLITES, never changed here) — only the angle around the node moves: it + eases to face the cursor (see _on_tick's mouse smoothing) plus a small idle + wobble so it's alive even when the mouse sits still. Purely cosmetic, never + hit-testable. Orbit lines are drawn first, in their own pass, so no glyph's + glow ends up half-covered by a line belonging to a different satellite.""" + for a in assignments: + nx, ny = a.node + self._draw_orbit_line(cr, nx, ny, a.effective_height, a.sat.color, a.line_style) + + mx, my = self._mouse_smooth + for a in assignments: + nx, ny = a.node + if a.axis == "x": + # tracks only the cursor's X position — a full sweep left-to-right + # across the canvas takes it all the way around its node. Each + # doubled node's own random offset (see _pick_assignments) keeps + # multiple doubled nodes from all sweeping in lockstep. + face_angle = (mx / self.CANVAS_SIZE) * 2 * math.pi * a.mult_x + a.mouse_offset + elif a.axis == "y": + face_angle = (my / self.CANVAS_SIZE) * 2 * math.pi * a.mult_y + a.mouse_offset + else: + # mult_x/mult_y independently scale each axis before the direction + # is computed, so this satellite can be visibly more reactive to + # horizontal cursor movement than vertical, or vice versa. + dx, dy = (mx - nx) * a.mult_x, (my - ny) * a.mult_y + face_angle = math.atan2(dy, dx) if (dx or dy) else 0.0 + angle = face_angle + a.sat.wobble_amp * math.sin(self._sat_time * a.sat.wobble_speed + a.sat.phase) + px = nx + a.effective_height * math.cos(angle) + py = ny + a.effective_height * math.sin(angle) + self._draw_glyph(cr, px, py, a.sat.glyph, a.sat.color, orbit_angle=angle, bracket=a.bracket) + + def _draw_orbit_line(self, cr, nx: float, ny: float, radius: float, + color: tuple[float, float, float], style: str) -> None: + """A circle tracing the path a satellite's angle sweeps around its node — + solid/dashed/dotted per-assignment (see _pick_assignments), fixed for as + long as that page's assignments are, so it never flickers style. Kept + visibly faint against the dark theme, but not so faint it disappears.""" + r, g, b = color + cr.save() + if style == "dashed": + cr.set_dash([7.0, 5.0]) + elif style == "dotted": + cr.set_dash([2.0, 3.5]) + cr.set_source_rgba(r, g, b, 0.38) + cr.set_line_width(1.4) + cr.arc(nx, ny, radius, 0, 2 * math.pi) + cr.stroke() + cr.restore() + + def _draw_glyph(self, cr, x: float, y: float, glyph: str, color: tuple[float, float, float], + orbit_angle: float = 0.0, bracket: Optional[tuple[str, str]] = None) -> None: + r, g, b = color + + glow = cairo.RadialGradient(x, y, 0, x, y, 16) + glow.add_color_stop_rgba(0.0, r, g, b, 0.45) + glow.add_color_stop_rgba(1.0, r, g, b, 0.0) + cr.set_source(glow) + cr.arc(x, y, 16, 0, 2 * math.pi) + cr.fill() + + cr.select_font_face(self._GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) + cr.set_font_size(15) + ext = cr.text_extents(glyph) + cr.move_to(x - ext.width / 2 - ext.x_bearing, y - ext.height / 2 - ext.y_bearing) + cr.set_source_rgba(r, g, b, 0.95) + cr.show_text(glyph) + + if bracket is not None: + self._draw_bracket(cr, x, y, orbit_angle, bracket, color) + + def _draw_bracket(self, cr, x: float, y: float, orbit_angle: float, + bracket: tuple[str, str], color: tuple[float, float, float]) -> None: + """Flank the glyph at (x, y) with two small strings along the orbit's + TANGENT at this point (perpendicular to the radial orbit_angle), e.g. + "-< ->-" either side — like brackets riding along the direction of travel + rather than pointing at the node.""" + r, g, b = color + tangent = orbit_angle + math.pi / 2 + # keep the text roughly upright instead of upside-down on the far side + if math.cos(tangent) < 0: + tangent += math.pi + + cr.select_font_face(self._GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) + cr.set_font_size(11) + offset = 15.0 + for text, sign in ((bracket[0], -1), (bracket[1], 1)): + ext = cr.text_extents(text) + bx = x + sign * offset * math.cos(tangent) + by = y + sign * offset * math.sin(tangent) + cr.save() + cr.translate(bx, by) + cr.rotate(tangent) + cr.move_to(-ext.width / 2 - ext.x_bearing, -ext.height / 2 - ext.y_bearing) + cr.set_source_rgba(r, g, b, 0.7) + cr.show_text(text) + cr.restore() + + # -- holographic overlay -------------------------------------------------- + # Config-toggleable (~/.local/state/orbit-menu/config.json's "hologram" key, + # see config.py); painted once, on top of the whole menu, independent of + # which page is showing — see the Gtk.Overlay setup in __init__. + _HOLO_TINT = _VIOLET # scanlines/sweep/flicker: violet + _HOLO_SCANLINE_GAP = 4.0 + _HOLO_SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass + _HOLO_SWEEP_HALF_HEIGHT = 90.0 + _HOLO_NOISE_COUNT = 370 # specs alive at once (each with its own lifetime, not per-frame) + _HOLO_NOISE_COLORS = [_MAGENTA, _ACCENT] # magenta + red only + _HOLO_NOISE_LIFETIME = (0.6, 1.7) # seconds a given spec persists before being replaced + _HOLO_NOISE_FADE_IN = 0.2 # fraction of lifetime spent fading in + _HOLO_NOISE_FADE_OUT = 0.5 # fraction of lifetime spent fading out (at the end) + _HOLO_MASK_INNER = RADIUS + 8 # full-strength out to just past the ring nodes + # faded out completely by here — capped so it fully resolves to 0 before the + # canvas edge (CANVAS_SIZE/2), not just before the corners + _HOLO_MASK_OUTER = min(RADIUS + NODE_SIZE * 1.4, CANVAS_SIZE / 2 - 10) + + def _draw_hologram_frame(self, _area, cr, width: float, height: float) -> None: + if self._hologram_enabled: + self._draw_hologram(cr, width, height) + + def _draw_hologram(self, cr, width: float, height: float) -> None: + """Draws the whole effect into an offscreen group, then composites it back + through a radial-gradient alpha mask centered on the canvas — full strength + around the ring, soft-fading to nothing by _HOLO_MASK_OUTER, so it reads as + one big glow over the menu instead of tinting the whole square canvas.""" + cx, cy = width / 2, height / 2 + cr.push_group() + self._draw_hologram_content(cr, width, height) + pattern = cr.pop_group() + + mask = cairo.RadialGradient(cx, cy, self._HOLO_MASK_INNER, cx, cy, self._HOLO_MASK_OUTER) + mask.add_color_stop_rgba(0.0, 1, 1, 1, 1) + mask.add_color_stop_rgba(1.0, 1, 1, 1, 0) + cr.set_source(pattern) + cr.mask(mask) + + def _draw_hologram_content(self, cr, width: float, height: float) -> None: + r, g, b = self._HOLO_TINT + + # faint horizontal scanlines + cr.save() + cr.set_source_rgba(r, g, b, 0.05) + cr.set_line_width(1.0) + y = 0.0 + while y < height: + cr.move_to(0, y) + cr.line_to(width, y) + y += self._HOLO_SCANLINE_GAP + cr.stroke() + cr.restore() + + # a bright band sweeping top -> bottom on a loop, like a hologram scan pass + phase = (self._sat_time % self._HOLO_SWEEP_PERIOD) / self._HOLO_SWEEP_PERIOD + sweep_y = phase * height + hh = self._HOLO_SWEEP_HALF_HEIGHT + grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh) + grad.add_color_stop_rgba(0.0, r, g, b, 0.0) + grad.add_color_stop_rgba(0.5, r, g, b, 0.10) + grad.add_color_stop_rgba(1.0, r, g, b, 0.0) + cr.set_source(grad) + cr.rectangle(0, sweep_y - hh, width, hh * 2) + cr.fill() + + # subtle overall flicker so it doesn't look perfectly static + flicker = 0.018 + 0.012 * math.sin(self._sat_time * 11.0) + cr.set_source_rgba(r, g, b, max(0.0, flicker)) + cr.paint() + + self._draw_hologram_noise(cr, width, height) + + def _update_hologram_particles(self, width: float, height: float) -> None: + """Age out expired specs and top back up to _HOLO_NOISE_COUNT — each spec + keeps its position/color for its own randomized lifetime (_HOLO_NOISE_ + LIFETIME) instead of every spec being replaced every single frame.""" + now = self._sat_time + self._holo_particles = [p for p in self._holo_particles if now - p["birth"] < p["life"]] + while len(self._holo_particles) < self._HOLO_NOISE_COUNT: + self._holo_particles.append({ + "x": random.uniform(0, width), + "y": random.uniform(0, height), + "w": random.uniform(1.0, 2.6), + "h": random.uniform(1.0, 2.0), + "color": random.choice(self._HOLO_NOISE_COLORS), + "peak_alpha": random.uniform(0.08, 0.30), + "birth": now, + "life": random.uniform(*self._HOLO_NOISE_LIFETIME), + }) + + def _draw_hologram_noise(self, cr, width: float, height: float) -> None: + """Colored specs that persist for a real lifetime, fading in then out — + reads as signal static/interference rather than the smooth violet wash the + scanlines/sweep alone would give, without the single-frame teleport flicker + redrawing everything from scratch every tick would cause.""" + self._update_hologram_particles(width, height) + now = self._sat_time + for p in self._holo_particles: + t = (now - p["birth"]) / p["life"] # 0 (birth) .. 1 (death) + if t < self._HOLO_NOISE_FADE_IN: + envelope = t / self._HOLO_NOISE_FADE_IN + elif t > 1.0 - self._HOLO_NOISE_FADE_OUT: + envelope = max(0.0, (1.0 - t) / self._HOLO_NOISE_FADE_OUT) + else: + envelope = 1.0 + r, g, b = p["color"] + cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope) + cr.rectangle(p["x"], p["y"], p["w"], p["h"]) + cr.fill() + + # -- mouse parallax / animation loop ------------------------------------- + def _on_motion(self, _ctrl, x: float, y: float) -> None: + self._mouse_target = (x, y) + + def _on_motion_leave(self, _ctrl) -> None: + self._mouse_target = (self.CANVAS_SIZE / 2, self.CANVAS_SIZE / 2) + + def _on_tick(self, _widget, frame_clock) -> bool: + now = frame_clock.get_frame_time() / 1_000_000 # -> seconds + dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick) + self._last_tick = now + + self._sat_time += dt + + mx, my = self._mouse_target + sx, sy = self._mouse_smooth + ease = 1 - math.exp(-dt * 6.0) # frame-rate independent lerp toward the cursor + self._mouse_smooth = (sx + (mx - sx) * ease, sy + (my - sy) * ease) + + tr = self._transition + if tr is not None: + self._update_transition() + if tr.get("old_ring") is not None: + tr["old_ring"].queue_draw() + if tr.get("new_ring") is not None: + tr["new_ring"].queue_draw() + elif self._satellites_enabled and self._current_ring is not None: + self._current_ring.queue_draw() + + if self._intro is not None: + self._update_intro() + + if self._hologram_enabled: + self._hologram_area.queue_draw() + return True # keep ticking every frame while the window is mapped + + # -- zoom/fade navigation transition -------------------------------------- + # Sci-fi-orbital-map feel: the clicked node IS the focal point. Going in, the + # parent page zooms in on that point (as if diving toward it, everything else + # rushing off past the edges) while the child page slides in from that same + # screen position and grows from a small dot up to full size, becoming the new + # center — like a moon becoming the planet you now orbit. Going back reverses + # the exact same motion around the exact same point. + _ZOOM_DURATION = 0.34 # seconds — a bit more cinematic than a plain fade + _ZOOM_CHILD_START = 0.4 # child page starts at this scale, sitting at the focal point + _ZOOM_PARENT_END = 2.4 # parent page zooms into the focal point up to this scale + + def _focal_zoom_transform(self, fx: float, fy: float, scale: float) -> Gsk.Transform: + """Scale a page around an arbitrary point (fx, fy) instead of its own + center — used for the page that stays put but zooms into/out of the + focal point (translate(focal) . scale . translate(-focal)).""" + t = Gsk.Transform.new() + t = t.translate(Graphene.Point().init(fx, fy)) + t = t.scale(scale, scale) + t = t.translate(Graphene.Point().init(-fx, -fy)) + return t + + def _slide_zoom_transform(self, target_x: float, target_y: float, scale: float) -> Gsk.Transform: + """Map a page's own center to on-screen position (target_x, target_y) at + the given scale — used for the page that slides between the focal point + and the canvas center while growing/shrinking to become the full view.""" + c = self.CANVAS_SIZE / 2 + t = Gsk.Transform.new() + t = t.translate(Graphene.Point().init(target_x, target_y)) + t = t.scale(scale, scale) + t = t.translate(Graphene.Point().init(-c, -c)) + return t + + def _start_transition(self, new_page: Gtk.Fixed, direction: str, animate: bool, + focal: Optional[tuple[float, float]] = None) -> None: + old_page = self._current_page + if old_page is new_page: + return + if self._transition is not None: + self._finalize_transition() # snap any in-flight transition instantly first + + if new_page.get_parent() is None: + self._viewport.put(new_page, 0, 0) + + if not animate or old_page is None: + new_page.set_opacity(1.0) + self._viewport.set_child_transform(new_page, None) + if old_page is not None and old_page.get_parent() is not None: + self._viewport.set_child_transform(old_page, None) + self._viewport.remove(old_page) + self._transition = None + return + + c = self.CANVAS_SIZE / 2 + fx, fy = focal if focal is not None else (c, c) + new_page.set_opacity(0.0) + self._transition = { + "old": old_page, "new": new_page, + "old_ring": self._page_rings.get(old_page), + "new_ring": self._page_rings.get(new_page), + "start": None, # set on the first tick, in _sat_time units + "forward": direction == "in", + "focal": (fx, fy), + } + + def _update_transition(self) -> None: + tr = self._transition + if tr is None: + return + if tr["start"] is None: + tr["start"] = self._sat_time + progress = (self._sat_time - tr["start"]) / self._ZOOM_DURATION + progress = min(1.0, max(0.0, progress)) + eased = progress * progress * (3 - 2 * progress) # smoothstep + + c = self.CANVAS_SIZE / 2 + fx, fy = tr["focal"] + forward = tr["forward"] + + # The "slide" page is whichever one occupies the canvas-center full view at + # one end of the animation and the small focal-point dot at the other: the + # child when going in (0 -> 1 = focal -> center), the child when going back + # too but reversed (1 -> 0 = center -> focal) since it's the outgoing side. + if forward: + slide_page, zoom_page = tr["new"], tr["old"] + slide_t0, slide_t1 = (fx, fy), (c, c) + slide_s0, slide_s1 = self._ZOOM_CHILD_START, 1.0 + zoom_s0, zoom_s1 = 1.0, self._ZOOM_PARENT_END + else: + slide_page, zoom_page = tr["old"], tr["new"] + slide_t0, slide_t1 = (c, c), (fx, fy) + slide_s0, slide_s1 = 1.0, self._ZOOM_CHILD_START + zoom_s0, zoom_s1 = self._ZOOM_PARENT_END, 1.0 + + tx = slide_t0[0] + (slide_t1[0] - slide_t0[0]) * eased + ty = slide_t0[1] + (slide_t1[1] - slide_t0[1]) * eased + slide_scale = slide_s0 + (slide_s1 - slide_s0) * eased + self._viewport.set_child_transform(slide_page, self._slide_zoom_transform(tx, ty, slide_scale)) + + if zoom_page is not None: + zoom_scale = zoom_s0 + (zoom_s1 - zoom_s0) * eased + self._viewport.set_child_transform(zoom_page, self._focal_zoom_transform(fx, fy, zoom_scale)) + + tr["new"].set_opacity(eased) + if tr["old"] is not None: + tr["old"].set_opacity(1.0 - eased) + + if progress >= 1.0: + self._finalize_transition() + + def _finalize_transition(self) -> None: + """Snap the active transition to its end state — called both when it + completes naturally and to interrupt one cleanly if navigation happens + again before the previous transition finished.""" + tr = self._transition + if tr is None: + return + self._viewport.set_child_transform(tr["new"], None) + tr["new"].set_opacity(1.0) + if tr["old"] is not None: + self._viewport.set_child_transform(tr["old"], None) + if tr["old"].get_parent() is not None: + self._viewport.remove(tr["old"]) + self._transition = None + + # -- opening intro: nodes materialize from holo-noise ---------------------- + # Only plays when the menu transitions closed -> open (see open_at_root), never + # between nodes — that's the separate zoom transition above. Each button starts + # invisible and fades in on its own staggered delay (center first, then ring + # nodes in order around the circle); the instant a button starts fading in, a + # tight burst of extra hologram noise flashes at its position and burns off, + # like the node is condensing out of the static. + _INTRO_CENTER_DELAY = 0.0 + _INTRO_RING_START = 0.10 # ring nodes begin after the center starts + _INTRO_RING_STAGGER = 0.055 # gap between consecutive ring nodes' start + _INTRO_FADE_DURATION = 0.30 + _INTRO_BURST_COUNT = 10 + _INTRO_BURST_RADIUS = 26.0 + _INTRO_BURST_LIFETIME = (0.35, 0.7) + + def _start_intro(self, page: Gtk.Fixed) -> None: + buttons = self._page_buttons.get(page) + if not buttons: + self._intro = None + return + entries = [] + for i, (widget, x, y) in enumerate(buttons): + widget.set_opacity(0.0) + delay = self._INTRO_CENTER_DELAY if i == 0 else \ + self._INTRO_RING_START + (i - 1) * self._INTRO_RING_STAGGER + entries.append({"widget": widget, "x": x, "y": y, "delay": delay, "burst_spawned": False}) + self._intro = {"start": self._sat_time, "entries": entries} + + def _spawn_holo_burst(self, x: float, y: float) -> None: + colors = [self._HOLO_TINT, self._HOLO_TINT, _MAGENTA, _ACCENT] # mostly violet + for _ in range(self._INTRO_BURST_COUNT): + angle = random.uniform(0, 2 * math.pi) + radius = random.uniform(0, self._INTRO_BURST_RADIUS) + self._holo_particles.append({ + "x": x + radius * math.cos(angle), "y": y + radius * math.sin(angle), + "w": random.uniform(1.0, 2.8), "h": random.uniform(1.0, 2.2), + "color": random.choice(colors), + "peak_alpha": random.uniform(0.20, 0.45), # brighter than ambient noise + "birth": self._sat_time, + "life": random.uniform(*self._INTRO_BURST_LIFETIME), + }) + + def _update_intro(self) -> None: + intro = self._intro + if intro is None: + return + now = self._sat_time - intro["start"] + done = True + for e in intro["entries"]: + t = now - e["delay"] + if t < 0: + done = False + continue + if not e["burst_spawned"]: + if self._hologram_enabled: + self._spawn_holo_burst(e["x"], e["y"]) + e["burst_spawned"] = True + progress = min(1.0, t / self._INTRO_FADE_DURATION) + eased = progress * progress * (3 - 2 * progress) + e["widget"].set_opacity(eased) + if progress < 1.0: + done = False + if done: + self._intro = None + + # -- navigation --------------------------------------------------------- + def _show_path(self, path: tuple, direction: str = "in", animate: bool = True, + focal: Optional[tuple[float, float]] = None) -> None: + key = self._cache_key(path) + if key is not None and key in self._pages: + page = self._pages[key] + ring = self._page_rings[page] + else: + page, ring = self._build_page(path) + self._page_rings[page] = ring + if key is not None: + self._pages[key] = page + + self._start_transition(page, direction=direction, animate=animate, focal=focal) + self._stack_path = list(path) + self._current_ring = ring + self._current_page = page + + def _on_node_clicked(self, idx: int) -> None: + path = tuple(self._stack_path) + children = self._item_at(path).resolved_children() + item = children[idx] + if item.resolved_children(): + focal = self._ring_position(len(children), idx) + self._stack_origins.append(focal) + self._show_path(path + (idx,), direction="in", focal=focal) + else: + if item.action: + item.action() + self._close() + + def _go_back(self) -> None: + if self._stack_path: + focal = self._stack_origins.pop() if self._stack_origins else None + self._show_path(tuple(self._stack_path[:-1]), direction="out", focal=focal) + else: + self._close() + + def _close(self) -> None: + self.set_visible(False) + if self._tick_id is not None: + self.remove_tick_callback(self._tick_id) + self._tick_id = None + if self._on_close: + self._on_close() + + def _on_key(self, _ctrl, keyval, _keycode, _state) -> bool: + if keyval == Gdk.KEY_Escape: + self._close() + return True + if keyval == Gdk.KEY_BackSpace: + self._go_back() + return True + return False + + # -- external control ---------------------------------------------------- + def set_root(self, root: MenuItem) -> None: + """Swap the whole tree (e.g. power-only <-> full category menu). Any + cached pages belonged to the old tree's indices, so they're dropped.""" + self._transition = None + child = self._viewport.get_first_child() + while child is not None: + nxt = child.get_next_sibling() + self._viewport.set_child_transform(child, None) + self._viewport.remove(child) + child = nxt + self._pages.clear() + self._page_rings.clear() + self._page_buttons.clear() + self._current_ring = None + self._current_page = None + self._stack_origins.clear() + self._intro = None + self._root = root + + def open_at_root(self) -> None: + """Reset to the top of the tree, instantly (no zoom on open) — dynamic + pages along the way get rebuilt the next time they're actually visited, + not eagerly here. Plays the "materialize from noise" intro (see + _start_intro) every time, since this only runs on closed -> open, never + on in-menu navigation.""" + self._show_path((), animate=False) + self._start_intro(self._current_page) + self.set_visible(True) + self.present() + if self._tick_id is None: + self._tick_id = self.add_tick_callback(self._on_tick) diff --git a/desktopenvs/hyprdrive/orbit-menu/paths.py b/desktopenvs/hyprdrive/orbit-menu/paths.py new file mode 100644 index 0000000..e642425 --- /dev/null +++ b/desktopenvs/hyprdrive/orbit-menu/paths.py @@ -0,0 +1,23 @@ +"""Shared filesystem locations. Works whether run from the repo or ~/.config.""" + +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +STYLE_DIR = BASE_DIR / "style" + +SCRIPTS_DIR = Path.home() / "Documents" / "Scripts" + +# User settings live under XDG_STATE_HOME, NOT ~/.config — config-updater does +# `rm -rf ~/.config/orbit-menu` on every dotfiles sync (see astal-menu/paths.py +# for the same rationale), which would wipe a hand-edited config on the spot. +STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "orbit-menu" +CONFIG_FILE = STATE_DIR / "config.json" + +APP_ID = "eu.abdelbaki.orbitmenu" + + +def ensure_dirs() -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/desktopenvs/hyprdrive/orbit-menu/style/_colors.css b/desktopenvs/hyprdrive/orbit-menu/style/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/desktopenvs/hyprdrive/orbit-menu/style/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/desktopenvs/hyprdrive/orbit-menu/style/style.css b/desktopenvs/hyprdrive/orbit-menu/style/style.css new file mode 100644 index 0000000..7e702dd --- /dev/null +++ b/desktopenvs/hyprdrive/orbit-menu/style/style.css @@ -0,0 +1,112 @@ +/* orbit-menu — CyberQueer radial menu. + * Colors come from _colors.css (@text/@bg/@accent/@violet/@danger), loaded first + * by theme.py. Nested-level navigation is a manual zoom (see orbit_menu.py's + * _update_transition); this file only owns per-node hover/glow. + */ + +window.orbit-menu-window { + background: transparent; +} + +/* The cyberqueer theme's `* { background-color: #1a1a1a }` paints DrawingArea + * nodes too (unlike plain Box/Frame containers, which this GTK build's renderer + * leaves alone — see lib/border.py in astal-menu for the same observation). The + * hologram overlay sits on TOP of the entire menu via Gtk.Overlay, so if it kept + * that background it would hide everything underneath it — force both it and its + * Gtk.Overlay parent transparent. */ +.orbit-root-overlay, +.orbit-hologram { + background: transparent; + background-color: transparent; + background-image: none; +} + +.orbit-center, +.orbit-node { + border-radius: 9999px; + border: 1.5px solid alpha(@text, 0.15); + color: @text; + transition: background 200ms ease, border-color 200ms ease, + box-shadow 220ms cubic-bezier(0.34, 1.56, 0.64, 1); + box-shadow: 0 0 0 0 alpha(@accent, 0); +} + +/* The cyberqueer GTK theme (~/.config/gtk-4.0/gtk.css) paints every widget node + * with `* { background-color: #1a1a1a }`, including the label/box that carries + * each node's icon+text — leaving a dark rectangle behind the glyphs instead of + * showing the round gradient button underneath. Force those descendants clear. */ +.orbit-center *, +.orbit-node * { + background: transparent; + background-color: transparent; + background-image: none; +} + +.orbit-node-icon { + font-size: 15px; + color: @text; +} + +.orbit-node-text { + font-size: 10px; + font-weight: 600; +} + +/* -- center node ------------------------------------------------------- */ + +.orbit-center { + background-image: radial-gradient(circle at 35% 30%, alpha(@violet, 0.9), alpha(@violet, 0.32) 75%); + border-color: alpha(@accent, 0.45); + font-size: 13px; + font-weight: 700; +} +.orbit-center .orbit-node-text { + font-size: 12px; +} +.orbit-center:hover { + border-color: @accent; + box-shadow: 0 0 18px 2px alpha(@accent, 0.55); +} + +.orbit-root { + border-color: alpha(@text, 0.25); +} + +/* -- ring nodes ---------------------------------------------------------- */ + +.orbit-node { + background-image: radial-gradient(circle at 35% 30%, alpha(@violet, 0.18), alpha(@violet, 0.32) 80%); + border-color: alpha(@violet, 0.5); +} + +.orbit-node:hover { + background-image: radial-gradient(circle at 35% 30%, alpha(@violet, 0.55), alpha(@violet, 0.32) 80%); + border-color: @violet; + box-shadow: 0 0 16px 1px alpha(@violet, 0.6); +} + +.orbit-node:active { + box-shadow: 0 0 22px 3px alpha(@violet, 0.85); +} + +/* -- danger nodes (power actions, force kill) ---------------------------- */ + +.orbit-danger { + border-color: alpha(@danger, 0.55); + background-image: radial-gradient(circle at 35% 30%, alpha(@danger, 0.22), alpha(@violet, 0.32) 80%); +} + +.orbit-danger .orbit-node-icon, +.orbit-danger .orbit-node-text { + color: alpha(@text, 0.95); +} + +.orbit-danger:hover { + border-color: @danger; + background-image: radial-gradient(circle at 35% 30%, alpha(@danger, 0.5), alpha(@violet, 0.32) 80%); + box-shadow: 0 0 18px 2px alpha(@danger, 0.7); +} + +.orbit-danger:active { + box-shadow: 0 0 26px 4px alpha(@danger, 0.9); +} diff --git a/desktopenvs/hyprdrive/orbit-menu/theme.py b/desktopenvs/hyprdrive/orbit-menu/theme.py new file mode 100644 index 0000000..a36995b --- /dev/null +++ b/desktopenvs/hyprdrive/orbit-menu/theme.py @@ -0,0 +1,30 @@ +"""Load the two stylesheets as ordered CSS providers — same scheme as astal-menu's +theme.py. _colors.css (generated from ~/Dotfiles/colors.conf by apply-theme.sh) +defines the CyberQueer @define-color names; style.css consumes them. + +Priority is USER+1 for the same reason as astal-menu: the CyberQueer GTK theme at +~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above APPLICATION (600), and +would beat our transparent structural containers otherwise. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +from paths import STYLE_DIR + + +def load_css() -> None: + display = Gdk.Display.get_default() + for name in ("_colors.css", "style.css"): + path = STYLE_DIR / name + if not path.exists(): + continue + provider = Gtk.CssProvider() + provider.load_from_path(str(path)) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1 + ) diff --git a/desktopenvs/hyprdrive/scripts/_toggle_Template.tmplsh b/desktopenvs/hyprdrive/scripts/_toggle_Template.tmplsh new file mode 100755 index 0000000..bcdc56b --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/_toggle_Template.tmplsh @@ -0,0 +1,15 @@ +#!/bin/bash + +statecon=$( echo "example" | grep x) +#notify-send $statecon +if [ "$statecon" != '' ]; then + + echo "ex" + +else + + echo "nx" + +fi + + diff --git a/desktopenvs/hyprdrive/scripts/activewindow b/desktopenvs/hyprdrive/scripts/activewindow new file mode 100755 index 0000000..89a5954 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/activewindow @@ -0,0 +1,25 @@ +#!/bin/bash +# Max character count before the title is truncated with an ellipsis. +trunc=16 +# Query Hyprland over IPC for the active window and extract the title field. +# hyprctl outputs plain text; grep + awk picks the value after "title: ". +sample=$(hyprctl activewindow | grep title: | awk -F: '{print $2}') + +#echo ${sample} + +# ${#sample} is the string length. Truncate with head -c (byte cut) and append +# the UTF-8 ellipsis character "…" via sed's end-of-line anchor. +if [ ${#sample} -gt $trunc ]; then + echo $sample | head -c $trunc | sed 's/$/…/' +else + # If the title is non-empty but short enough, print as-is; otherwise "None" + # for Eww widgets that always need a non-empty string. + if [ ${#sample} -ne 0 ]; then + echo $sample + else + echo None + fi +fi + +#hyprctl activewindow | grep title: | awk -F: '{print $2}' | head -c $trunc | sed 's/$/.../' + diff --git a/desktopenvs/hyprdrive/scripts/amssh b/desktopenvs/hyprdrive/scripts/amssh new file mode 100755 index 0000000..0d65b93 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/amssh @@ -0,0 +1,659 @@ +#!/usr/bin/env bash +# amssh — encrypted SSH login manager +# Storage : ~/.amssh (AES-256-CBC · PBKDF2-SHA256 · 600 000 iterations) +# Modes : --tui (default, fzf TUI) | --drun (wofi launcher mode) +# Auth : master passphrase; FIDO2/PAM layer if pam_u2f + pamtester present + +set -euo pipefail + +# ── constants ──────────────────────────────────────────────────────────────── +STORE="${AMSSH_STORE:-$HOME/.amssh}" +CONF_DIR="${HOME}/.config/amssh" +AUTH_CONF="${CONF_DIR}/auth" +ITERS=600000 +TERM_CMD="${AMSSH_TERM:-kitty}" +MODE="tui" +VERSION="#amssh:v1" + +_TMP=$(mktemp -d /tmp/amssh.XXXXXX) +trap 'rm -rf "$_TMP"' EXIT + +# ── usage ──────────────────────────────────────────────────────────────────── +_usage() { + cat >&2 <<'EOF' +amssh — encrypted SSH login manager + +Usage: + amssh [--tui] Interactive fzf TUI (default) + amssh --drun Wofi launcher mode + +TUI keys: + Enter Connect to selected host + Ctrl-A Add new entry + Ctrl-E Edit selected entry + Ctrl-D Delete selected entry + Esc Quit + +Store: ~/.amssh (AES-256-CBC, PBKDF2-SHA256) +EOF +} + +# ── argument parsing ───────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --tui|-t) MODE="tui" ;; + --drun|-d) MODE="drun" ;; + --help|-h) _usage; exit 0 ;; + *) printf '[amssh] Unknown option: %s\n' "$1" >&2; _usage; exit 1 ;; + esac + shift +done + +# ── crypto ─────────────────────────────────────────────────────────────────── +_decrypt() { + AMSSH_PASS="$1" openssl enc -aes-256-cbc -pbkdf2 -iter "$ITERS" -d \ + -in "$STORE" -pass env:AMSSH_PASS 2>/dev/null +} + +_encrypt_stdin() { + AMSSH_PASS="$1" openssl enc -aes-256-cbc -pbkdf2 -iter "$ITERS" \ + -out "$STORE" -pass env:AMSSH_PASS +} + +# ── passphrase helpers ──────────────────────────────────────────────────────── +_ask_tty() { + local p + printf '%s' "${1:-Passphrase: }" >&2 + IFS= read -rs p &2 + printf '%s' "$p" +} + +_ask_pinentry() { + local cmd + cmd=$(command -v pinentry-qt 2>/dev/null \ + || command -v pinentry-gtk-2 2>/dev/null \ + || command -v pinentry 2>/dev/null) || return 1 + + local out + out=$(printf 'SETPROMPT amssh master passphrase\nSETDESC Enter master passphrase for the encrypted SSH store\nGETPIN\n' \ + | "$cmd" 2>/dev/null) || return 1 + + printf '%s\n' "$out" | grep -q '^ERR' && return 1 + printf '%s\n' "$out" | awk '/^D /{sub(/^D /,""); print; exit}' +} + +# ── FIDO2 / PAM detection ──────────────────────────────────────────────────── +PAM_SVC=amssh +PAM_SVC_FILE="/etc/pam.d/${PAM_SVC}" + +_find_pam_u2f() { + local f + for f in /lib/security/pam_u2f.so \ + /usr/lib/security/pam_u2f.so \ + /lib/x86_64-linux-gnu/security/pam_u2f.so \ + /usr/lib/x86_64-linux-gnu/security/pam_u2f.so \ + /usr/lib/aarch64-linux-gnu/security/pam_u2f.so; do + [[ -f "$f" ]] && printf '%s' "$f" && return 0 + done + return 1 +} + +_fido_keys_file() { + local f + for f in "$HOME/.config/Yubico/u2f_keys" "$HOME/.config/pam-u2f/keys"; do + [[ -f "$f" ]] && printf '%s' "$f" && return 0 + done + return 1 +} + +# Hardware + tool present — enough to offer FIDO setup in the dialog. +_fido_hardware_available() { + _find_pam_u2f >/dev/null && command -v pamtester &>/dev/null +} + +# Fully configured — all four prerequisites ready to authenticate. +_fido_pam_available() { + _fido_hardware_available \ + && _fido_keys_file >/dev/null \ + && [[ -f "$PAM_SVC_FILE" ]] +} + +# Creates /etc/pam.d/amssh — requires sudo once. +_ensure_pam_service() { + [[ -f "$PAM_SVC_FILE" ]] && return 0 + printf '[amssh] Creating PAM service %s (requires sudo)\n' "$PAM_SVC_FILE" >&2 + sudo tee "$PAM_SVC_FILE" >/dev/null <<'PAM' +#%PAM-1.0 +auth required pam_u2f.so cue +PAM +} + +# Registers a FIDO key into ~/.config/Yubico/u2f_keys if not already present. +_register_fido_key() { + local keys_file="$HOME/.config/Yubico/u2f_keys" + if [[ -f "$keys_file" ]]; then + printf '[amssh] Key file already exists at %s\n' "$keys_file" >&2 + printf '[amssh] To add another key run: pamu2fcfg -n >> %s\n' "$keys_file" >&2 + return 0 + fi + mkdir -p "$(dirname "$keys_file")" + printf '[amssh] Insert your FIDO key, then press Enter...\n' >&2 + read -r -s < /dev/tty + printf '[amssh] Touch your FIDO key when it blinks...\n' >&2 + if pamu2fcfg > "$keys_file" 2>/dev/tty; then + printf '[amssh] Key registered at %s\n' "$keys_file" >&2 + else + rm -f "$keys_file" + printf '[amssh] Registration failed — check key and retry\n' >&2 + return 1 + fi +} + +_pam_authenticate() { + # Both stdout and stderr must go to /dev/tty: pamtester prints its success + # message to stdout, which would contaminate pass=$(_get_passphrase) if left + # uncaptured. stderr carries the pam_u2f tap prompt. + pamtester "$PAM_SVC" "$USER" authenticate >/dev/tty 2>&1 +} + +# ── master auth (passphrase + optional FIDO2 PAM layer) ───────────────────── +_get_passphrase() { + # FIDO2/PAM second-factor: opt-in via AMSSH_PAM=1 + if [[ "${AMSSH_PAM:-}" == "1" ]] && _fido_pam_available; then + printf '[amssh] FIDO2/PAM authentication required\n' >&2 + _pam_authenticate || { printf '[amssh] PAM auth failed\n' >&2; exit 1; } + printf '[amssh] PAM OK\n' >&2 + fi + + local pass + if [[ "$MODE" == "drun" ]]; then + pass=$(_ask_pinentry) || pass=$(_ask_tty "amssh passphrase: ") + else + pass=$(_ask_tty "amssh passphrase: ") + fi + [[ -z "$pass" ]] && { printf '[amssh] No passphrase provided\n' >&2; exit 1; } + printf '%s' "$pass" +} + +# ── store helpers ───────────────────────────────────────────────────────────── +# Entry format: alias|user|host|port|identity|description +# (port defaults to 22; identity and description may be empty) + +_init_store() { + printf '%s\n' "$VERSION" | _encrypt_stdin "$1" +} + +_verify_pass() { + _decrypt "$1" 2>/dev/null | grep -q "^${VERSION}" || return 1 +} + +_load_entries() { + _decrypt "$1" | grep -v '^#' | grep -v '^[[:space:]]*$' || true +} + +_save_entries() { + # reads entry lines from stdin, prepends version header, encrypts + local pass="$1" + { printf '%s\n' "$VERSION"; cat; } | grep -v '^[[:space:]]*$' | _encrypt_stdin "$pass" +} + +# ── display formatting ──────────────────────────────────────────────────────── +# Entry format: alias|user|host|port|identity|description|password (7 fields) +# password is stored in plaintext inside the AES-256 encrypted store. + +_entry_to_display() { + awk -F'|' '{ + addr = $2 "@" $3 + if ($4 != "" && $4 != "22") addr = addr ":" $4 + line = $1 " \342\200\224 " addr + if ($7 != "") line = line " [*]" + if ($6 != "") line = line " \342\200\224 " $6 + print line + }' +} + +# ── SSH connector ───────────────────────────────────────────────────────────── +_connect() { + local entry="$1" + local alias user host port identity desc password + IFS='|' read -r alias user host port identity desc password <<< "$entry" + + local args=() + [[ -n "$port" && "$port" != "22" ]] && args+=(-p "$port") + [[ -n "$identity" ]] && args+=(-i "$identity") + args+=("${user}@${host}") + + printf '[amssh] Connecting: ssh %s\n' "${args[*]}" >&2 + + if [[ -n "$password" ]]; then + # Use SSH_ASKPASS_REQUIRE=force so SSH calls our script for the password + # instead of prompting interactively — works with OpenSSH 8.4+ + local askpass="$_TMP/askpass" + printf '#!/bin/sh\nprintf "%%s" "$AMSSH_CONN_PW"\n' > "$askpass" + chmod 700 "$askpass" + AMSSH_CONN_PW="$password" SSH_ASKPASS="$askpass" \ + SSH_ASKPASS_REQUIRE=force ssh "${args[@]}" + else + ssh "${args[@]}" + fi +} + +# ── prompt for entry (drun / zenity) ───────────────────────────────────────── +_prompt_entry_zenity() { + local result + result=$(zenity --forms \ + --title="amssh — Add Entry" \ + --text="SSH login details" \ + --add-entry="user@host[:port]" \ + --add-entry="Alias" \ + --add-password="SSH password (optional)" \ + --separator='|' 2>/dev/null) || return 1 + + local target alias password + IFS='|' read -r target alias password <<< "$result" + + local user hostport host port + user="${target%%@*}"; hostport="${target#*@}" + if [[ "$hostport" == *:* ]]; then + host="${hostport%%:*}"; port="${hostport##*:}" + else + host="$hostport"; port="22" + fi + + [[ -z "$alias" || -z "$user" || -z "$host" ]] && { + zenity --error --text="user@host and alias are required" 2>/dev/null || true + return 1 + } + printf '%s|%s|%s|%s||%s|%s' "$alias" "$user" "$host" "$port" "$alias" "$password" +} + +# ── TUI mode (fzf with execute+reload — stays inside fzf on add/delete) ────── +_tui_mode() { + local pass="$1" + local passfile="$_TMP/.pass" + local list_sh="$_TMP/list.sh" + local add_sh="$_TMP/add.sh" + local del_sh="$_TMP/del.sh" + local prev_sh="$_TMP/prev.sh" + + # Secure passphrase file — readable only by owner + printf '%s' "$pass" > "$passfile" + chmod 600 "$passfile" + + # Snapshot constants into local vars for script generation + local S="$STORE" I="$ITERS" V="$VERSION" + + # ── list.sh: decrypt + format for fzf display ──────────────────────────── + { printf '#!/usr/bin/env bash\n' + printf '_P=$(cat %q); _S=%q; _I=%q\n' "$passfile" "$S" "$I" + cat << 'LIST' +AMSSH_PASS="$_P" openssl enc -aes-256-cbc -pbkdf2 -iter "$_I" -d \ + -in "$_S" -pass env:AMSSH_PASS 2>/dev/null \ +| awk -F'|' '!/^#/ && NF>0 { + addr=$2"@"$3; if($4!=""&&$4!="22") addr=addr":"$4 + line=$1" \342\200\224 "addr + if($7!="") line=line" [*]" + if($6!="") line=line" \342\200\224 "$6 + print line +}' 2>/dev/null || true +LIST + } > "$list_sh"; chmod +x "$list_sh" + + # ── add.sh: full-screen TUI form (alternate screen, hidden password) ──────── + { printf '#!/usr/bin/env bash\n' + printf '_P=$(cat %q); _S=%q; _I=%q; _V=%q\n' "$passfile" "$S" "$I" "$V" + cat << 'ADD' +# ── terminal setup ──────────────────────────────────────────────────────────── +_stty=$(stty -g 2>/dev/null) +_cleanup() { stty "$_stty" 2>/dev/null; tput rmcup 2>/dev/null; tput cnorm 2>/dev/null; } +trap '_cleanup; exit 1' INT TERM +trap '_cleanup' EXIT + +tput smcup 2>/dev/null # switch to alternate screen +tput civis # hide cursor while drawing +tput clear + +cols=$(tput cols 2>/dev/null || printf '80') +rows=$(tput lines 2>/dev/null || printf '24') + +BW=66; BH=14 +BX=$(( (cols - BW) / 2 )); [[ $BX -lt 0 ]] && BX=0 +BY=$(( (rows - BH) / 2 )); [[ $BY -lt 0 ]] && BY=0 + +# colour / style shortcuts +_b=$(tput bold); _r=$(tput sgr0); _di=$(tput dim) +_HL=$'\033[38;2;228;0;70m' # Highlights #E40046 +_DK=$'\033[38;2;80;24;221m' # Dark #5018DD +_QW=$'\033[38;2;214;171;171m' # Quasi-White #D6ABAB +_RH=$'\033[38;2;245;5;5m' # Red-Hivis #F50505 + +# ── draw box ────────────────────────────────────────────────────────────────── +tput cup $BY $BX +printf "${_b}${_HL}╔$(printf '═%.0s' $(seq 1 $((BW-2))))╗${_r}" +for (( i=1; i/dev/null \ + | grep -v '^#' | grep -v '^[[:space:]]*$' || true) + +if printf '%s\n' "$_ex" | grep -q "^${_alias}|" 2>/dev/null; then + _err "alias \"${_alias}\" already exists" +fi + +# ── save ────────────────────────────────────────────────────────────────────── +{ printf '%s\n' "$_V" + printf '%s\n' "$_ex" + printf '%s|%s|%s|%s||%s|%s\n' "$_alias" "$_user" "$_host" "$_port" "$_alias" "$_pw" +} | grep -v '^[[:space:]]*$' \ + | AMSSH_PASS="$_P" openssl enc -aes-256-cbc -pbkdf2 -iter "$_I" \ + -out "$_S" -pass env:AMSSH_PASS + +tput cup $((BY+BH-2)) $((BX+3)) +printf "${_QW}${_b}✓ Added:${_r} %s → %s@%s:%s" "$_alias" "$_user" "$_host" "$_port" +sleep 0.8 +ADD + } > "$add_sh"; chmod +x "$add_sh" + + # ── del.sh: delete entry by alias ──────────────────────────────────────── + { printf '#!/usr/bin/env bash\n' + printf '_P=$(cat %q); _S=%q; _I=%q; _V=%q\n' "$passfile" "$S" "$I" "$V" + cat << 'DEL' +_alias="$1"; [[ -z "$_alias" ]] && exit 0 +_ex=$(AMSSH_PASS="$_P" openssl enc -aes-256-cbc -pbkdf2 -iter "$_I" -d \ + -in "$_S" -pass env:AMSSH_PASS 2>/dev/null \ + | grep -v '^#' | grep -v '^[[:space:]]*$' || true) +{ printf '%s\n' "$_V" + printf '%s\n' "$_ex" | grep -v "^${_alias}|" +} | grep -v '^[[:space:]]*$' \ + | AMSSH_PASS="$_P" openssl enc -aes-256-cbc -pbkdf2 -iter "$_I" \ + -out "$_S" -pass env:AMSSH_PASS +printf ' Deleted: %s\n' "$_alias" >&2 +DEL + } > "$del_sh"; chmod +x "$del_sh" + + # ── prev.sh: preview panel ──────────────────────────────────────────────── + { printf '#!/usr/bin/env bash\n' + printf '_P=$(cat %q); _S=%q; _I=%q\n' "$passfile" "$S" "$I" + cat << 'PREV' +_line="$1" +_a=$(printf '%s' "$_line" | awk -F' \342\200\224 ' '{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$1);print $1}') +[[ -z "$_a" ]] && exit 0 +AMSSH_PASS="$_P" openssl enc -aes-256-cbc -pbkdf2 -iter "$_I" -d \ + -in "$_S" -pass env:AMSSH_PASS 2>/dev/null \ +| awk -F'|' -v a="$_a" '$1==a { + addr=$3; if($4!=""&&$4!="22") addr=addr":"$4 + printf " Alias : %s\n User : %s\n Host : %s\n Port : %s\n Identity : %s\n Password : %s\n Note : %s\n", + $1,$2,addr,($4?$4:"22"),($5?$5:"(default)"),($7?"stored":"none"),($6?$6:"-") +}' 2>/dev/null || true +PREV + } > "$prev_sh"; chmod +x "$prev_sh" + + # ── fzf loop: stay open after SSH session ends ──────────────────────────── + while true; do + local sel + sel=$(bash "$list_sh" \ + | fzf \ + --prompt="SSH › " \ + --color='fg:#D6ABAB,hl:#E40046,fg+:#D6ABAB,bg+:#1A1A1A,hl+:#F50505,info:#5018DD,prompt:#E40046,pointer:#E40046,marker:#5018DD,spinner:#E40046,header:#D6ABAB' \ + --header=$' \e[1m\e[38;2;228;0;70mEnter\e[0m connect \e[1m\e[38;2;228;0;70ma\e[0m add \e[1m\e[38;2;228;0;70md\e[0m delete \e[1m\e[38;2;228;0;70mq\e[0m quit\n \e[2m\e[38;2;80;24;221mj/k\e[0m down/up \e[2m\e[38;2;80;24;221mg/G\e[0m top/bottom \e[2m\e[38;2;80;24;221m/\e[0m filter' \ + --bind="a:execute(bash '$add_sh')+reload(bash '$list_sh')" \ + --bind="d:execute(bash '$del_sh' {1})+reload(bash '$list_sh')" \ + --preview="bash '$prev_sh' {}" \ + --preview-window="right:50%:wrap" \ + --no-sort --ansi \ + --bind="j:down,k:up,g:first,G:last,/:toggle-search,q:abort" \ + 2>/dev/null || true) + + [[ -z "$sel" ]] && break + + local alias + alias=$(printf '%s' "$sel" \ + | awk -F' \342\200\224 ' '{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$1); print $1}') + [[ -z "$alias" ]] && break + + local entry + entry=$(AMSSH_PASS="$pass" openssl enc -aes-256-cbc -pbkdf2 -iter "$ITERS" -d \ + -in "$STORE" -pass env:AMSSH_PASS 2>/dev/null \ + | awk -F'|' -v a="$alias" '$1==a' || true) + [[ -z "$entry" ]] && { printf '[amssh] Entry not found: %s\n' "$alias" >&2; continue; } + + _connect "$entry" + # fzf reopens after SSH session ends + done +} + +# ── drun mode (wofi) ────────────────────────────────────────────────────────── +_drun_mode() { + local pass="$1" + + local raw_entries display_list + raw_entries=$(_load_entries "$pass") + + if [[ -z "$raw_entries" ]]; then + display_list="(no entries — add via amssh --tui)" + else + display_list=$(printf '%s\n' "$raw_entries" | _entry_to_display) + fi + + local selected + selected=$(printf '%s\n' "$display_list" \ + | wofi --show=dmenu --prompt="SSH: " --dmenu \ + 2>/dev/null) || exit 0 + + [[ -z "$selected" || "$selected" == "(no entries"* ]] && exit 0 + + local alias + alias=$(printf '%s' "$selected" | awk -F' — ' '{gsub(/^[[:space:]]+|[[:space:]]+$/,"",\$1); print $1}') + + local entry + entry=$(printf '%s\n' "$raw_entries" | awk -F'|' -v a="$alias" '$1==a') + [[ -z "$entry" ]] && exit 1 + + local e_alias e_user e_host e_port e_identity e_desc + IFS='|' read -r e_alias e_user e_host e_port e_identity e_desc <<< "$entry" + + local args=() + [[ -n "$e_port" && "$e_port" != "22" ]] && args+=(-p "$e_port") + [[ -n "$e_identity" ]] && args+=(-i "$e_identity") + args+=("${e_user}@${e_host}") + + # Launch SSH in a terminal window + "$TERM_CMD" -e ssh "${args[@]}" +} + +# ── FIDO2 info on startup (TUI only) ───────────────────────────────────────── +_print_fido_status() { + [[ "$MODE" != "tui" ]] && return + if _fido_pam_available; then + if [[ "${AMSSH_PAM:-}" == "1" ]]; then + printf '[amssh] FIDO2/PAM: active (second factor enabled)\n' >&2 + else + printf '[amssh] FIDO2/PAM: available — set AMSSH_PAM=1 to require as second factor\n' >&2 + fi + fi +} + +# ── auth config (persisted choice from first-launch dialog) ────────────────── +_load_auth_config() { + [[ ! -f "$AUTH_CONF" ]] && return + local method + method=$(cat "$AUTH_CONF") + if [[ "$method" == "fido" ]] && _fido_pam_available; then + export AMSSH_PAM=1 + fi +} + +_first_launch_dialog() { + mkdir -p "$CONF_DIR" + + # FIDO2 hardware unavailable — silently default to passphrase + if ! _fido_hardware_available; then + printf 'passphrase' > "$AUTH_CONF" + return + fi + + local choice + if [[ "$MODE" == "drun" ]]; then + choice=$(zenity --list \ + --radiolist \ + --title="amssh — Authentication Setup" \ + --text="Choose how to unlock your SSH store:" \ + --column="" --column="Method" --column="Description" \ + TRUE "passphrase" "Master passphrase (recommended)" \ + FALSE "fido" "FIDO2 hardware key (second factor)" \ + --height=210 --width=500 2>/dev/null) || choice="passphrase" + else + # whiptail writes selection to stderr; swap fds so $() captures it + choice=$(whiptail \ + --title "amssh — Authentication Setup" \ + --menu "Choose how to unlock your SSH store:" \ + 13 62 2 \ + "passphrase" "Master passphrase" \ + "fido" "FIDO2 hardware key (second factor)" \ + 3>&1 1>&2 2>&3) || choice="passphrase" + fi + + [[ -z "$choice" ]] && choice="passphrase" + printf '%s' "$choice" > "$AUTH_CONF" + + if [[ "$choice" == "fido" ]]; then + printf '\n[amssh] FIDO2 selected — setting up...\n' >&2 + _register_fido_key || { printf 'passphrase' > "$AUTH_CONF"; return; } + _ensure_pam_service || { printf 'passphrase' > "$AUTH_CONF"; return; } + printf '[amssh] FIDO2 ready — your key will be required on each unlock.\n' >&2 + fi +} + +# ── main ───────────────────────────────────────────────────────────────────── +main() { + _load_auth_config + + if [[ ! -f "$STORE" ]]; then + _first_launch_dialog + _load_auth_config # apply choice made in dialog + fi + + _print_fido_status + + local pass + pass=$(_get_passphrase) + + if [[ ! -f "$STORE" ]]; then + printf '[amssh] Creating new store at %s\n' "$STORE" >&2 + local confirm + if [[ "$MODE" == "drun" ]]; then + confirm=$(_ask_pinentry) || { printf '[amssh] Cancelled\n' >&2; exit 1; } + else + confirm=$(_ask_tty "Confirm passphrase: ") + fi + [[ "$pass" != "$confirm" ]] && { printf '[amssh] Passphrases do not match\n' >&2; exit 1; } + _init_store "$pass" + printf '[amssh] Store initialised\n' >&2 + [[ "$MODE" == "drun" ]] && exit 0 + else + _verify_pass "$pass" || { printf '[amssh] Wrong passphrase or corrupted store\n' >&2; exit 1; } + fi + + case "$MODE" in + tui) _tui_mode "$pass" ;; + drun) _drun_mode "$pass" ;; + esac +} + +main diff --git a/desktopenvs/hyprdrive/scripts/astro-menu-start.sh b/desktopenvs/hyprdrive/scripts/astro-menu-start.sh new file mode 100755 index 0000000..6876eae --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/astro-menu-start.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Resident launcher for the astro-menu GTK4 popup control centre. +# Same LD_PRELOAD requirement and rationale as orbit-menu-start.sh/horizon- +# dock-start.sh: gtk4-layer-shell must load before libwayland-client, which +# isn't guaranteed under PyGObject. + +APP="${HOME}/.config/astro-menu/main.py" +SO="$(ldconfig -p 2>/dev/null | awk '/libgtk4-layer-shell\.so/ {print $NF; exit}')" +if [[ -n "${SO:-}" ]]; then + export LD_PRELOAD="${SO}${LD_PRELOAD:+:${LD_PRELOAD}}" +fi + +exec python3 "$APP" "$@" diff --git a/desktopenvs/hyprdrive/scripts/astro-menu.sh b/desktopenvs/hyprdrive/scripts/astro-menu.sh new file mode 100755 index 0000000..f93b6e3 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/astro-menu.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Toggle the astro-menu popup (or open it to a section). Forwards a verb to the +# resident daemon over D-Bus; if the daemon isn't running yet, starts it first. +# Mirrors hyprlua's menu-toggle.sh (same app, renamed for hyprdrive). +# (No `set -e`: a non-zero `grep`/`busctl` in the wait loop is expected and must +# not abort the script before it forwards the verb.) +# +# astro-menu.sh -> --toggle (top, the default) +# astro-menu.sh appdrawer -> open with the app drawer expanded +# astro-menu.sh left -> toggle, sliding in from / pinned to the left +# astro-menu.sh toggle right -> same, explicit verb +# astro-menu.sh appdrawer bottom +# +# Verbs: (toggle) | show | hide | appdrawer. Sides: top | bottom | left | right. + +BUS="eu.abdelbaki.astromenu" +OBJ="/eu/abdelbaki/astromenu" +APP="${HOME}/.config/astro-menu/main.py" + +verb="${1:-}"; side="${2:-}" +case "$verb" in + top|bottom|left|right) side="$verb"; verb="" ;; +esac + +case "$verb" in + appdrawer) VERB="--appdrawer"; ACTION="appdrawer"; HAS_PARAM=1 ;; + show) VERB="--show"; ACTION="show"; HAS_PARAM=1 ;; + hide) VERB="--hide"; ACTION="hide"; HAS_PARAM=0 ;; + *) VERB="--toggle"; ACTION="toggle"; HAS_PARAM=1 ;; +esac + +case "$side" in + top|bottom|left|right) SIDE=(--side "$side") ;; + *) side=""; SIDE=() ;; +esac + +registered() { busctl --user list 2>/dev/null | grep -q "$BUS"; } + +if registered; then + if [[ $HAS_PARAM -eq 1 ]]; then + param="[<'${side}'>]" + else + param="[]" + fi + exec gdbus call --session --dest "$BUS" --object-path "$OBJ" \ + --method org.gtk.Actions.Activate "$ACTION" "$param" "{}" >/dev/null +fi + +"${HOME}/.config/scripts/astro-menu-start.sh" >/dev/null 2>&1 & +for _ in $(seq 1 25); do + if registered; then + exec "$0" "$@" + fi + sleep 0.2 +done + +exec python3 "$APP" "$VERB" "${SIDE[@]}" diff --git a/desktopenvs/hyprdrive/scripts/batteryperc b/desktopenvs/hyprdrive/scripts/batteryperc new file mode 100755 index 0000000..f98a027 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/batteryperc @@ -0,0 +1,48 @@ +#!/bin/bash + +# Query upower for the BAT1 battery; awk strips the "%" so we get a plain number. +perc=$(upower -i /org/freedesktop/UPower/devices/battery_BAT1 | awk '/percentage/ {gsub("%",""); print $2}') +# State can be "charging", "discharging", "fully-charged", etc. +state=$(upower -i /org/freedesktop/UPower/devices/battery_BAT1 | awk '/state/ {print $2}') + +# Check if values are not empty +if [ -z "$perc" ] || [ -z "$state" ]; then + echo "Battery info unavailable" + exit 1 +fi + +# Strip any fractional part for integer comparisons below (upower may return "42.00"). +num=${perc%%.*} # In case perc is float + +# Show a plug/charging icon regardless of level while the charger is connected. +if [ "$state" == "charging" ]; then + echo "󰂄 ${perc}%" +else + # Map the level to a Nerd Font battery icon — 10% steps, critical at ≤10%. + if [ "$num" -gt 95 ]; then + echo "󰁹 ${perc}%" + elif [ "$num" -gt 90 ]; then + echo "󰂂 ${perc}%" + elif [ "$num" -gt 80 ]; then + echo "󰂁 ${perc}%" + elif [ "$num" -gt 70 ]; then + echo "󰂀 ${perc}%" + elif [ "$num" -gt 60 ]; then + echo "󰁿 ${perc}%" + elif [ "$num" -gt 50 ]; then + echo "󰁾 ${perc}%" + elif [ "$num" -gt 40 ]; then + echo "󰁽 ${perc}%" + elif [ "$num" -gt 30 ]; then + echo "󰁼 ${perc}%" + elif [ "$num" -gt 20 ]; then + echo "󰁻 ${perc}%" + elif [ "$num" -gt 10 ]; then + echo "󰁺 ${perc}%" + else + # At ≤10% fire a critical urgency desktop notification via dunst/libnotify. + notify-send --urgency=critical -t 2000 "󱃍 low battery, please charge" + echo "󰂎 ${perc}%" + fi +fi + diff --git a/desktopenvs/hyprdrive/scripts/bluetooth-applet.sh b/desktopenvs/hyprdrive/scripts/bluetooth-applet.sh new file mode 100755 index 0000000..8654a9d --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/bluetooth-applet.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Launch blueman-applet through Hyprland's IPC dispatcher rather than directly. +# Using "dispatch exec" ensures the process is tracked as a Hyprland child and +# avoids inheriting the calling shell's environment inappropriately. +hyprctl dispatch exec blueman-applet diff --git a/desktopenvs/hyprdrive/scripts/caffeine-manual-status.sh b/desktopenvs/hyprdrive/scripts/caffeine-manual-status.sh new file mode 100755 index 0000000..f899b45 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/caffeine-manual-status.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Reports the MANUAL-INHIBIT input for the Eww caffeine widget: whether a manual +# caffeine toggle currently holds the shared idle lock. +# +# The lock is shared with presence-detect.sh, so "PID alive" alone can't tell the +# two sources apart. OWNED_FLAG is present only while the daemon owns the lock, so +# "PID alive AND OWNED_FLAG absent" uniquely means a manual caffeine session holds +# it. (For the PRESENCE input — camera sees you — see presence-status.sh.) +# +# Prints "true" for a manual caffeine session, else "false". +PID_FILE="/tmp/caffeine-inhibit.pid" +OWNED_FLAG="/tmp/presence-inhibit-owned" + +if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null && [[ ! -f "$OWNED_FLAG" ]]; then + echo "true" +else + echo "false" +fi diff --git a/desktopenvs/hyprdrive/scripts/caffeine-status.sh b/desktopenvs/hyprdrive/scripts/caffeine-status.sh new file mode 100755 index 0000000..fe78a60 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/caffeine-status.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Reports whether the caffeine idle inhibitor is currently active. +# Used by Eww widgets to poll state and update the caffeine icon/label. +PID_FILE="/tmp/caffeine-inhibit.pid" +# kill -0 does a liveness check without sending a real signal (stderr suppressed). +# Prints "true" if the PID file exists and its process is alive, else "false". +[[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null && echo "true" || echo "false" diff --git a/desktopenvs/hyprdrive/scripts/caffeine.sh b/desktopenvs/hyprdrive/scripts/caffeine.sh new file mode 100755 index 0000000..03db103 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/caffeine.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Toggle idle inhibit via systemd-inhibit (hypridle respects the logind idle hint). +# The PID file tracks the background sleep process used to hold the inhibitor lock. +# This lock is shared with presence-detect.sh, which also inhibits idle while +# motion is detected — OWNED_FLAG marks lock ownership between the two, so clear +# it on every manual toggle: this action is always a manual takeover of the lock. +PID_FILE="/tmp/caffeine-inhibit.pid" +OWNED_FLAG="/tmp/presence-inhibit-owned" + +# kill -0 sends no signal; it just checks whether the process is still alive. +# If the PID file exists and the process is running, caffeine is active → turn it off. +if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + # Killing the sleep process releases the systemd-inhibit lock automatically. + kill "$(cat "$PID_FILE")" + rm -f "$PID_FILE" "$OWNED_FLAG" + notify-send -t 2000 "Caffeine" "Idle inhibit OFF" +else + # --what=idle: block the logind idle-inhibit slot that hypridle watches. + # --mode=block: the inhibitor stays active for the lifetime of the command. + # "sleep infinity" is the sentinel process whose PID we save. + systemd-inhibit --what=idle \ + --who="caffeine" \ + --why="Caffeine mode active" \ + --mode=block \ + sleep infinity & + # $! is the PID of the most recently backgrounded process (sleep infinity above). + echo $! > "$PID_FILE" + rm -f "$OWNED_FLAG" + notify-send -t 2000 "Caffeine" "Idle inhibited" +fi diff --git a/desktopenvs/hyprdrive/scripts/calender-fix.sh b/desktopenvs/hyprdrive/scripts/calender-fix.sh new file mode 100755 index 0000000..0f116ee --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/calender-fix.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# %-d strips the leading zero so "06" becomes "6" — needed for bracket matching below. +today=$(date +%-d) +# %u is ISO weekday: 1=Monday … 7=Sunday. +weekdaynum=$(date +%u) +weekday="" +# Map ISO weekday number to the two-letter abbreviation that `cal` prints in its header. +if [[ $weekdaynum -eq 1 ]]; then + weekday="Mo" +elif [[ $weekdaynum -eq 2 ]]; then + weekday="Tu" +elif [[ $weekdaynum -eq 3 ]]; then + weekday="We" +elif [[ $weekdaynum -eq 4 ]]; then + weekday="Th" +elif [[ $weekdaynum -eq 5 ]]; then + weekday="Fr" +elif [[ $weekdaynum -eq 6 ]]; then + weekday="Sa" +elif [[ $weekdaynum -eq 7 ]]; then + weekday="Su" +fi + +echo ====================== +date '+%A, %d.%m.%Y' +echo ====================== +# -m: start week on Monday. `sed 's/^/ /'` indents every line for padding. +# The two substitutions wrap today's date number and the current weekday column +# in square brackets so Eww can highlight them via CSS class matching. +cal -m | sed -e 's/^/ /' | sed "s/ $today /[$today]/" | sed "s/ $weekday /[$weekday]/" + + diff --git a/desktopenvs/hyprdrive/scripts/date.sh b/desktopenvs/hyprdrive/scripts/date.sh new file mode 100755 index 0000000..0ac320c --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/date.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Output the current date wrapped in Pango bold markup for Eww label widgets. +# %-d strips the leading zero (e.g. "6" instead of "06"). +echo " "$(date +'%A, %-d %B %Y')" " diff --git a/desktopenvs/hyprdrive/scripts/deprecated/batteryperc.old b/desktopenvs/hyprdrive/scripts/deprecated/batteryperc.old new file mode 100644 index 0000000..d72cb8b --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/deprecated/batteryperc.old @@ -0,0 +1,37 @@ +#!/bin/bash + +perc=$(upower -i /org/freedesktop/UPower/devices/battery_BAT0 | grep percentage | awk -F: '{print $2}' | xargs) +state=$(upower -i /org/freedesktop/UPower/devices/battery_BAT0 | grep state | awk -F: '{print $2}' | xargs) + +num=$(echo ${perc::-1}) + +if [ $state == "charging" ]; then + echo "󰂄 ${perc}" +else + #echo "󰁹 ${perc}" + + if [ $num -gt 95 ]; then + echo "󰁹 ${perc}" + elif [ $num -gt 90 ]; then + echo "󰂂 ${perc}" + elif [ $num -gt 80 ]; then + echo "󰂁 ${perc}" + elif [ $num -gt 70 ]; then + echo "󰂀 ${perc}" + elif [ $num -gt 60 ]; then + echo "󰁿 ${perc}" + elif [ $num -gt 50 ]; then + echo "󰁾 ${perc}" + elif [ $num -gt 40 ]; then + echo "󰁽 ${perc}" + elif [ $num -gt 30 ]; then + echo "󰁼 ${perc}" + elif [ $num -gt 20 ]; then + echo "󰁻 ${perc}" + elif [ $num -gt 10 ]; then + echo "󰁺 ${perc}" + else + notify-send --urgency=critical -t 2000 "󱃍 low battery, please charge" + echo "󰂎 ${perc}" + fi +fi diff --git a/desktopenvs/hyprdrive/scripts/deprecated/drawer.sh b/desktopenvs/hyprdrive/scripts/deprecated/drawer.sh new file mode 100755 index 0000000..00c030e --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/deprecated/drawer.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Open the nwg-drawer application grid. +# -fm: file manager command (kitty running yazi TUI file manager). +# -term: terminal for launching terminal-based apps. +# -wm hyprland: enables Hyprland-specific IPC integration. +# -mb/ml/mr/mt: margins (bottom/left/right/top) in pixels. +# -pb*: power-bar button commands; pbexit calls hyprctl to cleanly end the session. +nwg-drawer -fm "kitty -e yazi" -term kitty -wm hyprland -mb 20 -ml 20 -mr 20 -mt 20 -pblock hyprlock -pbpoweroff poweroff -pbexit "hyprctl dispatch exit" -pbreboot reboot + diff --git a/desktopenvs/hyprdrive/scripts/deprecated/hyprland-toggle-touchpad.sh.old b/desktopenvs/hyprdrive/scripts/deprecated/hyprland-toggle-touchpad.sh.old new file mode 100755 index 0000000..d5f65d0 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/deprecated/hyprland-toggle-touchpad.sh.old @@ -0,0 +1,40 @@ + +#!/usr/bin/env bash + +STATUS_FILE="$XDG_RUNTIME_DIR/touchpad.status" + +# Detect touchpad name dynamically +TOUCHPAD_NAME=$(hyprctl devices | awk '/type: touchpad/{getline; print $2; exit}') +TOUCHPAD_KEYWORD="device:${TOUCHPAD_NAME}:enabled" + +# Functions +enable_touchpad() { + printf "true" >"$STATUS_FILE" + notify-send -u normal "Touchpad Enabled" + hyprctl keyword "$TOUCHPAD_KEYWORD" "true" -r +} + +disable_touchpad() { + printf "false" >"$STATUS_FILE" + notify-send -u normal "Touchpad Disabled" + hyprctl keyword "$TOUCHPAD_KEYWORD" "false" -r +} + +# Get current state from Hyprland (1 = enabled, 0 = disabled) +CURRENT_STATE=$(hyprctl getoption "$TOUCHPAD_KEYWORD" | grep "int:" | awk '{print $2}') + +# Initialize status file if missing +if ! [ -f "$STATUS_FILE" ]; then + if [ "$CURRENT_STATE" -eq 1 ]; then + printf "true" >"$STATUS_FILE" + else + printf "false" >"$STATUS_FILE" + fi +fi + +# Toggle based on status file +if [ "$(cat "$STATUS_FILE")" = "true" ]; then + disable_touchpad +else + enable_touchpad +fi diff --git a/desktopenvs/hyprdrive/scripts/deprecated/screenrotationacw.sh.old b/desktopenvs/hyprdrive/scripts/deprecated/screenrotationacw.sh.old new file mode 100755 index 0000000..a6c0e9d --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/deprecated/screenrotationacw.sh.old @@ -0,0 +1,15 @@ +#!/bin/bash +curmon=$(hyprctl monitors | grep -B11 "focused: yes" | head -n1 | awk '{print $2}') +currot=$(hyprctl monitors | grep -B1 "focused: yes" | head -n1 | awk '{print $2}') + +newrot="" +if [ $currot == "3" ]; then + newrot="0" +else + newrot=$(($currot + 1)) +fi +echo $newrot + +hyprctl keyword monitor $curmon,preferred,auto,1,transform,$newrot + +eww reload diff --git a/desktopenvs/hyprdrive/scripts/deprecated/screenrotationacw.sh.proto b/desktopenvs/hyprdrive/scripts/deprecated/screenrotationacw.sh.proto new file mode 100755 index 0000000..ea1cf10 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/deprecated/screenrotationacw.sh.proto @@ -0,0 +1,22 @@ +#!/bin/bash +curmon=$(hyprctl monitors | grep -B11 "focused: yes" | head -n1 | awk '{print $2}') +currot=$(hyprctl monitors | grep -B1 "focused: yes" | head -n1 | awk '{print $2}') +touchdev=$(hyprctl devices | grep -A1 "Touch Device" | tail -n1 | xargs) + +newrot="" +if [ $currot == "3" ]; then + newrot="0" +else + newrot=$(($currot + 1)) +fi +echo $newrot + +hyprctl keyword monitor $curmon,preferred,auto,1,transform,$newrot + +eww reload + +if [ "$touchdev" != '' ]; then + hyprctl keyword device:$touchdev:transform $newrot +fi + + diff --git a/desktopenvs/hyprdrive/scripts/deprecated/screenrotationwcw.sh.old b/desktopenvs/hyprdrive/scripts/deprecated/screenrotationwcw.sh.old new file mode 100755 index 0000000..05ac3c8 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/deprecated/screenrotationwcw.sh.old @@ -0,0 +1,15 @@ +#!/bin/bash +curmon=$(hyprctl monitors | grep -B11 "focused: yes" | head -n1 | awk '{print $2}') +currot=$(hyprctl monitors | grep -B1 "focused: yes" | head -n1 | awk '{print $2}') + +newrot="" +if [ $currot == "0" ]; then + newrot="3" +else + newrot=$(($currot - 1)) +fi +echo $newrot + +hyprctl keyword monitor $curmon,preferred,auto,1,transform,$newrot + +eww reload diff --git a/desktopenvs/hyprdrive/scripts/deprecated/screenrotationwcw.sh.proto b/desktopenvs/hyprdrive/scripts/deprecated/screenrotationwcw.sh.proto new file mode 100755 index 0000000..54ab0df --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/deprecated/screenrotationwcw.sh.proto @@ -0,0 +1,21 @@ +#!/bin/bash +curmon=$(hyprctl monitors | grep -B11 "focused: yes" | head -n1 | awk '{print $2}') +currot=$(hyprctl monitors | grep -B1 "focused: yes" | head -n1 | awk '{print $2}') +touchdev=$(hyprctl devices | grep -A1 "Touch Device" | tail -n1 | xargs) + +newrot="" +if [ $currot == "0" ]; then + newrot="3" +else + newrot=$(($currot - 1)) +fi +echo $newrot + + +hyprctl keyword monitor $curmon,preferred,auto,1,transform,$newrot + +eww reload + +if [ "$touchdev" != '' ]; then + hyprctl keyword device:$touchdev:transform $newrot +fi diff --git a/desktopenvs/hyprdrive/scripts/dysk-phydisks.sh b/desktopenvs/hyprdrive/scripts/dysk-phydisks.sh new file mode 100755 index 0000000..43a2383 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/dysk-phydisks.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# List physical disk usage via dysk (a df-like tool). +# -a: show all mounts; -f 'disk=ssd | disk=hdd' filters to real physical disks only, +# excluding tmpfs, loop devices, etc. +rawdiskstr=$(dysk -a -f 'disk=ssd | disk=hdd') +# dysk always appends a blank summary/footer line — `sed '$d'` deletes the last line +# so the output is clean for Eww's label widget. +echo "$rawdiskstr" | sed '$d' diff --git a/desktopenvs/hyprdrive/scripts/enroll-biometrics.sh b/desktopenvs/hyprdrive/scripts/enroll-biometrics.sh new file mode 100755 index 0000000..09cfece --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/enroll-biometrics.sh @@ -0,0 +1,666 @@ +#!/bin/bash +# enroll-biometrics.sh — TUI for face biometric setup. +# +# Two subsystems: +# 1. Presence detection — configure/test the webcam used by presence-detect.sh +# 2. Howdy face auth — enroll/manage/test face models for PAM authentication + +BACKTITLE="Biometric Enrollment" +PRESENCE_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/presence-detect.conf" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHON_DETECT="$SCRIPT_DIR/python/presence_detect.py" + +# howdy keeps its own camera setting in this INI, entirely separate from the +# presence-detect camera. Enrollment aborts in VideoCapture before touching the +# lens if `device_path` is left at the package default of `none`, so the howdy +# menu has to write a real /dev/video* node in here. +HOWDY_CONFIG="/usr/lib/security/howdy/config.ini" + +# ── PAM 2FA (howdy face + FIDO U2F) ──────────────────────────────────────────── +# Both factors required, with the normal password stack kept as a fallback so a +# dead camera or absent key can never lock you out. +PAM_TARGETS=(/etc/pam.d/sudo /etc/pam.d/hyprlock /etc/pam.d/login) +# howdy 2.x has no pam_howdy.so — it authenticates through pam_python.so loading +# its own entrypoint script. pam_python.so ships in the AUR package `pam-python`. +PAM_PYTHON_SO="/usr/lib/security/pam_python.so" +HOWDY_PAM_PY="/usr/lib/security/howdy/pam.py" +PAM_U2F_SO="/usr/lib/security/pam_u2f.so" +U2F_MAP="/etc/u2f_mappings" +PAM_MARK_BEGIN="# enroll-biometrics:begin howdy+u2f 2fa" +PAM_MARK_END="# enroll-biometrics:end howdy+u2f 2fa" + +# ── Dialog theme (Cyberqueer) ───────────────────────────────────────────────── +TMP_D=$(mktemp -d) +trap 'rm -rf "$TMP_D"' EXIT INT TERM + +export DIALOGRC="$TMP_D/dialogrc" +cat > "$DIALOGRC" << 'RCEOF' +use_shadow = ON +use_colors = ON +screen_color = (BLACK,BLACK,ON) +shadow_color = (BLACK,BLACK,ON) +title_color = (MAGENTA,BLACK,ON) +border_color = (MAGENTA,BLACK,ON) +button_active_color = (BLACK,MAGENTA,ON) +button_inactive_color = (WHITE,BLACK,OFF) +button_key_active_color = (BLACK,CYAN,ON) +button_key_inactive_color = (CYAN,BLACK,ON) +button_label_active_color = (BLACK,MAGENTA,ON) +button_label_inactive_color = (WHITE,BLACK,OFF) +inputbox_color = (WHITE,BLACK,OFF) +inputbox_border_color = (MAGENTA,BLACK,ON) +menubox_color = (WHITE,BLACK,OFF) +menubox_border_color = (MAGENTA,BLACK,ON) +item_color = (WHITE,BLACK,OFF) +item_selected_color = (BLACK,MAGENTA,ON) +tag_color = (CYAN,BLACK,ON) +tag_selected_color = (BLACK,CYAN,ON) +check_color = (WHITE,BLACK,OFF) +check_selected_color = (BLACK,MAGENTA,ON) +uarrow_color = (MAGENTA,BLACK,ON) +darrow_color = (MAGENTA,BLACK,ON) +RCEOF + +# ── Helpers ─────────────────────────────────────────────────────────────────── +die() { clear; printf "\nError: %s\n\n" "$1" >&2; exit 1; } + +msg() { + dialog --backtitle "$BACKTITLE" --title " $1 " --msgbox "\n$2\n" "$3" "$4" +} + +require_dialog() { + command -v dialog &>/dev/null && return + sudo pacman -S --noconfirm dialog || die "dialog not found" +} + +# ── Camera helpers ───────────────────────────────────────────────────────────── +# True only for nodes that actually deliver video frames. UVC webcams expose +# extra /dev/video* nodes for metadata capture (no pixel formats); pointing +# howdy at one of those makes it fail to read a frame and abort with exit 1. +is_capture_device() { + v4l2-ctl --device="$1" --list-formats 2>/dev/null \ + | grep -qE '^[[:space:]]*\[[0-9]+\]:' +} + +# True for a v4l2loopback virtual device (card type "presence-loopback"). Kept +# as a defensive filter: presence-detect no longer uses a loopback, but should +# one ever be present it isn't a real camera, so it's excluded from the picker. +is_loopback_device() { + local card + card=$(v4l2-ctl --device="$1" --info 2>/dev/null \ + | awk -F': ' '/Card type/{print $2}' | head -1) + [[ "$card" == "presence-loopback" ]] +} + +list_cameras() { + for dev in /dev/video*; do + [[ -c "$dev" ]] || continue + is_capture_device "$dev" || continue + is_loopback_device "$dev" && continue + local id="${dev#/dev/video}" + local name + name=$(v4l2-ctl --device="$dev" --info 2>/dev/null \ + | awk -F': ' '/Card type/{print $2}' | head -1) + [[ -z "$name" ]] && name="Camera $id" + printf '%s\n%s\n' "$id" "$name" + done +} + +get_camera_id() { + if [[ -n "$PRESENCE_DETECT_CAMERA" ]]; then + echo "$PRESENCE_DETECT_CAMERA" + elif [[ -f "$PRESENCE_CFG" ]]; then + grep -oP 'CAMERA=\K[0-9]+' "$PRESENCE_CFG" 2>/dev/null || echo 0 + else + echo 0 + fi +} + +set_camera_id() { + mkdir -p "$(dirname "$PRESENCE_CFG")" + printf 'CAMERA=%s\n' "$1" > "$PRESENCE_CFG" +} + +# Grab a single still from a v4l2 node into $2. No sudo: /dev/video* is acl/group +# readable by the user. The first frame off a cold sensor is often black, so pull +# a few and keep the last. +camera_capture_frame() { + local dev="$1" out="$2" + command -v ffmpeg &>/dev/null || return 2 + ffmpeg -hide_banner -loglevel error -f v4l2 -i "$dev" \ + -frames:v 5 -update 1 -y "$out" /dev/null + [[ -s "$out" ]] +} + +# Render an image inline via kitty's graphics protocol. KITTY_WINDOW_ID is the +# reliable signal — $TERM is frequently xterm-256color even under kitty. +preview_frame() { + local img="$1" + [[ -s "$img" ]] || return 1 + command -v kitty &>/dev/null || return 1 + [[ -n "$KITTY_WINDOW_ID" || "$TERM" == *kitty* ]] || return 1 + kitty +kitten icat --align left --clear "$img" +} + +# IR sensors deliver near-zero chroma; colour webcams don't. Returns 0 when the +# still looks monochrome (likely IR), 1 otherwise. Assumes colour when it can't +# measure — that's the case that needs the extra non-IR tuning anyway. +camera_looks_ir() { + local img="$1" sat + [[ -s "$img" ]] || return 1 + command -v ffmpeg &>/dev/null || return 1 + sat=$(ffmpeg -hide_banner -loglevel error -i "$img" \ + -vf signalstats,metadata=mode=print:file=- -f null - 2>/dev/null \ + | awk -F= '/SATAVG/{print $2; exit}') + [[ -z "$sat" ]] && return 1 + awk -v s="$sat" 'BEGIN{exit !(s < 12)}' +} + +# Shared camera chooser: menu → live still preview in kitty → confirm. The pick +# is returned in the global PICKED_CAM (numeric id); returns 1 if cancelled. +PICKED_CAM="" +PICKED_CAM_FRAME="" +pick_camera_with_preview() { + local title="$1" current="$2" + local -a cam_items + mapfile -t cam_items < <(list_cameras) + + if [[ ${#cam_items[@]} -eq 0 ]]; then + msg "No Cameras Found" \ + "No video devices found at /dev/video*.\n\nMake sure your webcam is connected." \ + 10 55 + return 1 + fi + + while true; do + local choice + choice=$(dialog --backtitle "$BACKTITLE" \ + --title " $title " \ + --menu "\nCurrent: ${current}\n\nSelect a camera:" \ + 16 62 6 \ + "${cam_items[@]}" \ + 3>&1 1>&2 2>&3) || return 1 + + local dev="/dev/video${choice}" + clear + printf "\n\033[1;35m Preview — %s\033[0m\n" "$dev" + printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" + + local frame="$TMP_D/cam_preview.png" + camera_capture_frame "$dev" "$frame" + case $? in + 0) preview_frame "$frame" \ + || printf " (kitty preview unavailable; frame saved to %s)\n" "$frame" ;; + 2) printf " ffmpeg is not installed — skipping preview.\n" ;; + *) printf " Could not capture a frame from %s.\n The device may be busy or not a capture node.\n" "$dev" ;; + esac + + printf "\n" + local ans + read -rp " Use ${dev}? [Y]es / [n]o, pick another / [c]ancel: " ans + case "${ans,,}" in + ""|y|yes) PICKED_CAM="$choice"; PICKED_CAM_FRAME="$frame"; return 0 ;; + c|cancel) return 1 ;; + *) continue ;; + esac + done +} + +# ── Presence detection ──────────────────────────────────────────────────────── +presence_configure_camera() { + local current; current=$(get_camera_id) + pick_camera_with_preview "Select Camera (Presence)" "/dev/video${current}" || return + + set_camera_id "$PICKED_CAM" + msg "Camera Set" \ + "Presence detection will use /dev/video${PICKED_CAM}.\n\nRestart presence-detect.sh for the change to take effect." \ + 9 62 +} + +presence_test_camera() { + local cam; cam=$(get_camera_id) + + clear + printf "\n\033[1;35m Testing presence detection on /dev/video%s...\033[0m\n" "$cam" + printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" + printf " Move around in front of the camera.\n\n" + + python3 "$PYTHON_DETECT" "$cam" 2>/dev/null + local rc=$? + + case $rc in + 0) msg "Test Result" "Motion detected!\n\nPresence detection is working correctly." 8 52 ;; + 1) msg "Test Result" \ + "No motion detected.\n\nMake sure you are in front of the camera,\nmoving slightly, with adequate lighting." \ + 10 56 ;; + 2) msg "Camera Error" \ + "Could not open /dev/video${cam}.\n\nTry configuring a different camera first." \ + 9 56 ;; + *) msg "Error" "Unexpected exit code ($rc) from detection script." 7 52 ;; + esac +} + +# ── Howdy helpers ───────────────────────────────────────────────────────────── +# Detection must survive howdy 2.x's layout: its CLI lives under root-only +# /usr/lib/security/howdy/ with /usr/bin/howdy symlinked into it, so a normal +# user can neither resolve nor run the link — `command -v howdy` reports "not +# found" even when it is installed. Every op here invokes it via `sudo howdy` +# anyway, so fall back to the symlink itself and the package DB. +howdy_installed() { + command -v howdy &>/dev/null && return 0 + [[ -L /usr/bin/howdy || -e /usr/bin/howdy ]] && return 0 + command -v pacman &>/dev/null && pacman -Qq howdy &>/dev/null +} + +howdy_require() { + howdy_installed && return 0 + + # howdy lives only in the AUR — it is not in the official repos, so a + # plain `pacman -S howdy` always fails. Require an AUR helper. + local helper + for h in yay paru; do + command -v "$h" &>/dev/null && { helper="$h"; break; } + done + if [[ -z "$helper" ]]; then + msg "No AUR Helper" \ + "howdy is an AUR package and needs an AUR helper (yay or paru) to install.\n\nInstall yay first, then re-run this menu." \ + 10 60 + return 1 + fi + + dialog --backtitle "$BACKTITLE" --title " Howdy Not Found " \ + --yesno "\nhowdy is not installed.\n\nInstall it now via ${helper}?" 8 48 || return 1 + clear + printf "\nInstalling howdy via %s...\n\n" "$helper" + "$helper" -S --noconfirm --needed howdy + howdy_installed || { msg "Install Failed" "howdy installation failed.\nInstall it manually: ${helper} -S howdy" 8 52; return 1; } +} + +# ── Howdy camera (device_path) ──────────────────────────────────────────────── +# config.ini is root-only, so reads and writes both go through sudo. Returns the +# bare value (e.g. /dev/video1, or `none` when unconfigured). +howdy_get_device_path() { + sudo grep -oP '^\s*device_path\s*=\s*\K.*' "$HOWDY_CONFIG" 2>/dev/null | head -1 +} + +# Generic [video] key writer: replaces the key in place, or inserts it under the +# [video] header if absent. Every key we touch (device_path, recording_plugin, +# dark_threshold) lives only in that section, so a global replace is safe. +howdy_set_cfg() { + local key="$1" val="$2" + if sudo grep -qE "^\s*${key}\s*=" "$HOWDY_CONFIG"; then + sudo sed -i -E "s|^(\s*${key}\s*=).*|\1 ${val}|" "$HOWDY_CONFIG" + else + sudo sed -i -E "/^\[video\]/a ${key} = ${val}" "$HOWDY_CONFIG" + fi +} + +howdy_set_device_path() { howdy_set_cfg device_path "$1"; } + +# True when howdy points at a node that exists AND can actually capture video +# (not a metadata-only node, which would make enrollment fail with exit 1). +howdy_camera_ok() { + local dp; dp=$(howdy_get_device_path) + [[ -n "$dp" && "$dp" != none && -e "$dp" ]] || return 1 + is_capture_device "$dp" +} + +# howdy's stock tuning assumes a Windows-Hello IR sensor. A regular colour +# webcam works too — dlib doesn't care about IR — but needs two tweaks: +# • recording_plugin = ffmpeg (opencv mis-decodes many YUYV colour streams to +# grey garbage; ffmpeg reads them cleanly — the same path the preview used) +# • dark_threshold = 80 (IR's flashing emitter wants a low value; a +# colour feed in normal light gets rejected as "too dark" at the default 50) +# The camera-type question defaults from the preview's measured colourfulness. +howdy_apply_camera_profile() { + local frame="$1" dev="$2" + local dn="" + camera_looks_ir "$frame" && dn="--defaultno" # looks IR ⇒ default the answer to "No" + + dialog --backtitle "$BACKTITLE" --title " Camera Type " $dn --yesno \ +"\nIs this a regular (non-IR / colour) webcam?\n\n Yes — tune howdy for a colour camera\n (ffmpeg recorder, dark_threshold 80).\n Works in normal light; less secure.\n\n No — IR sensor (Windows Hello style).\n Keep howdy's default IR tuning." 16 62 + case $? in + 0) howdy_set_cfg recording_plugin ffmpeg + howdy_set_cfg dark_threshold 80 + msg "Howdy Camera Set" \ +"howdy will use ${dev}.\n\nTuned for a NON-IR colour webcam:\n recorder = ffmpeg\n dark_threshold = 80\n\nRun 'add face model' next. If frames come out\ngrey or fail, try the other /dev/video node." 14 62 ;; + 1) howdy_set_cfg recording_plugin opencv + howdy_set_cfg dark_threshold 50 + msg "Howdy Camera Set" \ +"howdy will use ${dev}.\n\nTuned for an IR sensor (howdy defaults):\n recorder = opencv\n dark_threshold = 50" 12 62 ;; + *) msg "Howdy Camera Set" \ +"howdy will use ${dev}.\n\nCamera-type tuning left unchanged." 9 56 ;; + esac +} + +howdy_configure_camera() { + howdy_require || return + local current; current=$(howdy_get_device_path) + [[ -z "$current" ]] && current="(unset)" + + pick_camera_with_preview "Select Camera (Howdy)" "$current" || return + + local dev="/dev/video${PICKED_CAM}" + howdy_set_device_path "$dev" + howdy_apply_camera_profile "$PICKED_CAM_FRAME" "$dev" +} + +# Howdy aborts in VideoCapture when device_path is unset/wrong, surfacing only a +# generic exit code 1. Catch that here and offer to fix it before enrolling. +howdy_ensure_camera() { + howdy_camera_ok && return 0 + local dp; dp=$(howdy_get_device_path) + dialog --backtitle "$BACKTITLE" --title " Camera Not Configured " --yesno \ + "\nhowdy has no usable camera (device_path = ${dp:-none}).\n\nEnrollment will fail until this is set.\nConfigure the howdy camera now?" 11 60 \ + || return 1 + howdy_configure_camera + howdy_camera_ok || { + msg "Still Not Configured" \ + "howdy's camera is still unset, aborting enrollment." 7 52 + return 1 + } +} + +# ── Howdy operations ────────────────────────────────────────────────────────── +howdy_add() { + howdy_require || return + howdy_ensure_camera || return + + local name + name=$(dialog --backtitle "$BACKTITLE" \ + --title " Add Face Model " \ + --inputbox "\nEnter a label for this face model:" 8 52 "$USER" \ + 3>&1 1>&2 2>&3) || return + [[ -z "$name" ]] && return + + clear + printf "\n\033[1;35m Enrolling face model '%s'...\033[0m\n" "$name" + printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" + printf " Look at the camera and hold still.\n\n" + # howdy has no label flag; the label is read from stdin during `add`. + # Tee the output so the actual reason (dark frames, no face, busy camera…) + # can be shown on failure instead of a bare exit code. + local log="$TMP_D/howdy_add.log" + printf '%s\n' "$name" | sudo howdy -U "$USER" add 2>&1 | tee "$log" + local rc=${PIPESTATUS[1]} + + if [[ $rc -eq 0 ]]; then + msg "Enrolled" \ + "Face model '$name' added.\n\nYou may need to relogin for PAM changes to take effect." \ + 9 58 + else + local why; why=$(grep -vE '^\s*$' "$log" 2>/dev/null | tail -n 4) + msg "Failed" \ + "Enrollment failed (code $rc).\n\nhowdy said:\n${why:-(no output captured)}\n\nIf it's a camera/frame error, re-run\n'configure camera' and pick another node." \ + 16 64 + fi +} + +howdy_list() { + howdy_require || return + local out; out=$(sudo howdy -U "$USER" list 2>&1) + msg "Enrolled Face Models" "$out" 20 64 +} + +howdy_remove() { + howdy_require || return + + local models; models=$(sudo howdy -U "$USER" list 2>&1) + if [[ -z "$models" || "$models" == *"no models"* ]]; then + msg "No Models" "No face models are currently enrolled." 7 44 + return + fi + + local id + id=$(dialog --backtitle "$BACKTITLE" \ + --title " Remove Face Model " \ + --inputbox "\nCurrent models:\n\n${models}\n\nEnter model ID to remove:" 20 64 \ + 3>&1 1>&2 2>&3) || return + [[ -z "$id" || ! "$id" =~ ^[0-9]+$ ]] && { + msg "Invalid ID" "Please enter a numeric model ID." 6 36; return + } + + dialog --backtitle "$BACKTITLE" --title " Confirm " \ + --yesno "\nRemove face model ID $id?" 7 40 || return + + sudo howdy -U "$USER" remove "$id" + msg "Done" "Face model $id removed." 6 38 +} + +howdy_test() { + howdy_require || return + clear + printf "\n\033[1;35m Testing howdy authentication...\033[0m\n" + printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" + printf " Look at the camera.\n\n" + sudo howdy -U "$USER" test + local rc=$? + if [[ $rc -eq 0 ]]; then + msg "Result" "Authentication successful." 6 38 + else + msg "Result" "Authentication failed (code $rc).\n\nCheck enrolled models and camera setup." 9 52 + fi +} + +# ── howdy PAM module (pam_python.so) ────────────────────────────────────────── +# howdy's PAM entrypoint is a Python script loaded by pam_python.so. The module +# is /usr/lib/security/pam_python.so directly (world-traversable, unlike howdy's +# own root-only dir), so a plain existence test is reliable here; pacman -Qq is +# the fallback in case it lands elsewhere. +pam_python_installed() { + [[ -e "$PAM_PYTHON_SO" ]] && return 0 + command -v pacman &>/dev/null && pacman -Qq pam-python &>/dev/null +} + +pam_python_require() { + pam_python_installed && return 0 + + # pam-python is AUR-only, so it needs an AUR helper just like howdy. + local helper + for h in yay paru; do + command -v "$h" &>/dev/null && { helper="$h"; break; } + done + if [[ -z "$helper" ]]; then + msg "No AUR Helper" \ + "howdy's PAM module needs the AUR package 'pam-python', which requires an AUR helper (yay or paru).\n\nInstall yay first, then re-run." \ + 11 62 + return 1 + fi + + dialog --backtitle "$BACKTITLE" --title " pam-python Needed " \ + --yesno "\nhowdy authenticates via pam_python.so, provided by the AUR package 'pam-python'.\n\nInstall it now via ${helper}?" 10 62 || return 1 + clear + printf "\nInstalling pam-python via %s...\n\n" "$helper" + "$helper" -S --noconfirm --needed pam-python + pam_python_installed || { + msg "Install Failed" "pam-python installation failed.\nInstall it manually: ${helper} -S pam-python" 8 58 + return 1 + } +} + +# ── FIDO U2F helpers ──────────────────────────────────────────────────────────── +fido_installed() { [[ -e "$PAM_U2F_SO" ]] && command -v pamu2fcfg &>/dev/null; } + +fido_require() { + fido_installed && return 0 + dialog --backtitle "$BACKTITLE" --title " FIDO U2F Not Found " \ + --yesno "\npam-u2f is not installed.\n\nInstall it now via pacman?" 8 50 || return 1 + clear + printf "\nInstalling pam-u2f...\n\n" + sudo pacman -S --noconfirm --needed pam-u2f + fido_installed || { msg "Install Failed" "pam-u2f installation failed.\nInstall it manually: sudo pacman -S pam-u2f" 8 56; return 1; } +} + +# Ensure $USER has a registered key in the central mapping; register one if not. +fido_register() { + if sudo grep -q "^${USER}:" "$U2F_MAP" 2>/dev/null; then + dialog --backtitle "$BACKTITLE" --title " FIDO Key " \ + --yesno "\nA FIDO key is already registered for $USER.\n\nRegister an additional key?" 9 56 \ + || return 0 + clear + printf "\n\033[1;35m Insert your FIDO key and touch it when it blinks...\033[0m\n\n" + local extra + extra=$(pamu2fcfg -n -u "$USER") || { msg "Registration Failed" "Could not read FIDO key." 7 44; return 1; } + [[ -z "$extra" ]] && { msg "No Key" "No FIDO key detected." 7 36; return 1; } + # pamu2fcfg -n prints just the credential; append it to the user's line. + sudo sed -i "s|^\(${USER}:.*\)\$|\1:${extra}|" "$U2F_MAP" + return 0 + fi + + clear + printf "\n\033[1;35m Registering FIDO key for %s...\033[0m\n" "$USER" + printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" + printf " Insert your FIDO key and touch it when it blinks.\n\n" + local line + line=$(pamu2fcfg -u "$USER") || { msg "Registration Failed" "Could not read FIDO key.\n\nMake sure it is plugged in." 9 48; return 1; } + [[ -z "$line" ]] && { msg "No Key" "No FIDO key detected." 7 36; return 1; } + printf '%s\n' "$line" | sudo tee -a "$U2F_MAP" >/dev/null + sudo chmod 600 "$U2F_MAP" + sudo chown root:root "$U2F_MAP" + return 0 +} + +# ── PAM file editing ────────────────────────────────────────────────────────── +# The inserted block: try howdy, then U2F. Both must pass to satisfy auth and +# skip the password stack; if either fails, control falls through to the +# existing (password) auth lines below. +pam_emit_block() { + printf '%s\n' "$PAM_MARK_BEGIN" + printf 'auth [success=ignore default=1] pam_python.so %s\n' "$HOWDY_PAM_PY" + printf 'auth sufficient pam_u2f.so authfile=%s cue\n' "$U2F_MAP" + printf '%s\n' "$PAM_MARK_END" +} + +pam_apply_file() { + local f="$1" + [[ -f "$f" ]] || { printf ' skip %s (not present)\n' "$f"; return 0; } + if sudo grep -qF "$PAM_MARK_BEGIN" "$f"; then + printf ' ok %s (already configured)\n' "$f"; return 0 + fi + sudo cp -a "$f" "${f}.bak.$(date +%s)" + local tmp first; tmp=$(mktemp); first=$(sudo head -n1 "$f") + if [[ "$first" == "#%PAM"* ]]; then + { printf '%s\n' "$first"; pam_emit_block; sudo tail -n +2 "$f"; } > "$tmp" + else + { pam_emit_block; sudo cat "$f"; } > "$tmp" + fi + sudo cp "$tmp" "$f"; rm -f "$tmp" + printf ' added %s\n' "$f" +} + +pam_remove_file() { + local f="$1" + [[ -f "$f" ]] || return 0 + sudo grep -qF "$PAM_MARK_BEGIN" "$f" || { printf ' skip %s (not configured)\n' "$f"; return 0; } + sudo cp -a "$f" "${f}.bak.$(date +%s)" + local tmp; tmp=$(mktemp) + sudo awk -v b="$PAM_MARK_BEGIN" -v e="$PAM_MARK_END" \ + '$0==b{s=1} !s{print} $0==e{s=0}' "$f" > "$tmp" + sudo cp "$tmp" "$f"; rm -f "$tmp" + printf ' removed from %s\n' "$f" +} + +# hyprlock ships as `auth include login`. Since the 2FA block also lands in +# /etc/pam.d/login, a plain include would re-run the block on the failure path. +# Give hyprlock its own clean fallback by pointing it at system-auth instead. +HYPRLOCK_REDIRECT='s/^(auth[[:space:]]+include[[:space:]]+)login[[:space:]]*$/\1system-auth/' +HYPRLOCK_RESTORE='s/^(auth[[:space:]]+include[[:space:]]+)system-auth[[:space:]]*$/\1login/' + +pam_apply_hyprlock() { + local f="/etc/pam.d/hyprlock" + [[ -f "$f" ]] || { printf ' skip %s (not present)\n' "$f"; return 0; } + if sudo grep -qF "$PAM_MARK_BEGIN" "$f"; then + printf ' ok %s (already configured)\n' "$f"; return 0 + fi + sudo cp -a "$f" "${f}.bak.$(date +%s)" + local tmp first; tmp=$(mktemp); first=$(sudo head -n1 "$f") + if [[ "$first" == "#%PAM"* ]]; then + { printf '%s\n' "$first"; pam_emit_block + sudo tail -n +2 "$f" | sed -E "$HYPRLOCK_REDIRECT"; } > "$tmp" + else + { pam_emit_block; sudo cat "$f" | sed -E "$HYPRLOCK_REDIRECT"; } > "$tmp" + fi + sudo cp "$tmp" "$f"; rm -f "$tmp" + printf ' added %s (fallback → system-auth)\n' "$f" +} + +pam_remove_hyprlock() { + local f="/etc/pam.d/hyprlock" + [[ -f "$f" ]] || return 0 + sudo grep -qF "$PAM_MARK_BEGIN" "$f" || { printf ' skip %s (not configured)\n' "$f"; return 0; } + sudo cp -a "$f" "${f}.bak.$(date +%s)" + local tmp; tmp=$(mktemp) + sudo awk -v b="$PAM_MARK_BEGIN" -v e="$PAM_MARK_END" \ + '$0==b{s=1} !s{print} $0==e{s=0}' "$f" | sed -E "$HYPRLOCK_RESTORE" > "$tmp" + sudo cp "$tmp" "$f"; rm -f "$tmp" + printf ' removed from %s (fallback → login)\n' "$f" +} + +pam_setup() { + howdy_require || return + pam_python_require || return + fido_require || return + + dialog --backtitle "$BACKTITLE" --title " Set Up PAM 2FA " --yesno \ +"\nThis will require BOTH face (howdy) and a FIDO key for:\n\n ${PAM_TARGETS[*]}\n\nYour password still works if a factor is unavailable.\nBackups of each file are saved as *.bak..\n\nContinue?" 15 64 || return + + fido_register || return + + clear + printf "\n\033[1;35m Configuring PAM...\033[0m\n" + printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" + local f + for f in "${PAM_TARGETS[@]}"; do + if [[ "$f" == */hyprlock ]]; then pam_apply_hyprlock; else pam_apply_file "$f"; fi + done + printf "\n" + msg "PAM Configured" \ +"Face + FIDO 2FA is active (password fallback kept).\n\n*** KEEP THIS TERMINAL OPEN. ***\nIn a NEW terminal, run: sudo -k; sudo true\nand confirm you can authenticate.\n\nIf locked out, restore a *.bak. file." 15 64 +} + +pam_teardown() { + dialog --backtitle "$BACKTITLE" --title " Remove PAM 2FA " --yesno \ +"\nRemove the howdy + FIDO 2FA block from:\n\n ${PAM_TARGETS[*]}\n\nContinue?" 12 64 || return + clear + printf "\n\033[1;35m Removing PAM 2FA...\033[0m\n\n" + local f + for f in "${PAM_TARGETS[@]}"; do + if [[ "$f" == */hyprlock ]]; then pam_remove_hyprlock; else pam_remove_file "$f"; fi + done + printf "\n" + msg "PAM Restored" "The 2FA block was removed.\nBackups remain as *.bak.." 8 56 +} + +# ── Main menu ───────────────────────────────────────────────────────────────── +main_menu() { + local choice + choice=$(dialog --backtitle "$BACKTITLE" \ + --title " Biometric Enrollment " \ + --menu "\nSelect an option:" 23 70 10 \ + "1" "Presence detection — configure camera" \ + "2" "Presence detection — test detection" \ + "3" "Howdy face auth — configure camera" \ + "4" "Howdy face auth — add face model" \ + "5" "Howdy face auth — list enrolled models" \ + "6" "Howdy face auth — remove face model" \ + "7" "Howdy face auth — test authentication" \ + "8" "PAM 2FA — set up howdy + FIDO key" \ + "9" "PAM 2FA — remove howdy + FIDO key" \ + 3>&1 1>&2 2>&3) || { clear; exit 0; } + + case "$choice" in + 1) presence_configure_camera ;; + 2) presence_test_camera ;; + 3) howdy_configure_camera ;; + 4) howdy_add ;; + 5) howdy_list ;; + 6) howdy_remove ;; + 7) howdy_test ;; + 8) pam_setup ;; + 9) pam_teardown ;; + esac + main_menu +} + +require_dialog +main_menu diff --git a/desktopenvs/hyprdrive/scripts/filepicker-clipboard.sh b/desktopenvs/hyprdrive/scripts/filepicker-clipboard.sh new file mode 100755 index 0000000..d702063 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/filepicker-clipboard.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# File picker dialog -> clipboard. +# path (default): copy the selected file's absolute path +# content : copy the selected file's contents +# Bound to Super+F (path) and Super+Shift+F (content) in usr/binds.lua. + +set -euo pipefail + +mode="${1:-path}" + +# zenity exits non-zero on cancel/close -> treat as a no-op. +# Float/size/cursor-centering are handled by the +filepicker window rule. +file="$(zenity --file-selection --title="Copy file ${mode} to clipboard")" || exit 0 +[ -n "$file" ] || exit 0 + +case "$mode" in + content) + if [ ! -r "$file" ]; then + notify-send -u critical "File picker" "Cannot read: $file" + exit 1 + fi + wl-copy < "$file" + notify-send "Copied file contents" "$(basename "$file")" + ;; + path | *) + # Collapse the home dir to ~ + case "$file" in + "$HOME"/*) file="~${file#"$HOME"}" ;; + "$HOME") file="~" ;; + esac + printf '%s' "$file" | wl-copy + notify-send "Copied file path" "$file" + ;; +esac diff --git a/desktopenvs/hyprdrive/scripts/getispeed.sh b/desktopenvs/hyprdrive/scripts/getispeed.sh new file mode 100755 index 0000000..e5a62a9 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/getispeed.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Run the ispeedtest Python script with argument "b" (both download + upload). +# Uses the venv Python inside ~/.config/python-script to ensure speedtest-cli +# is available without being installed system-wide. +speedvar=$(~/.config/python-script/bin/python ~/Dotfiles/desktopenvs/hyprland/scripts/python/ispeedtest.py b) +# Display the result as a persistent notification (-t 200000 ms = ~3.3 minutes) +# so it remains visible while the user finishes whatever prompted the speed test. +notify-send -t 200000 "$speedvar" diff --git a/desktopenvs/hyprdrive/scripts/getvol b/desktopenvs/hyprdrive/scripts/getvol new file mode 100755 index 0000000..c24590b --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/getvol @@ -0,0 +1,9 @@ +#!/bin/sh + +# Query PipeWire/PulseAudio for the default sink's volume via pactl. +# pactl outputs "Volume: ... / 75% / ..." — awk splits on "/" and picks field 2. +# xargs trims surrounding whitespace so we get a clean "75%" string. +perc=$(pactl get-sink-volume @DEFAULT_SINK@ | awk -F/ '{print $2}' | xargs) +# Strip the trailing "%" character using shell substring removal (${var::-1}). +num=$(echo ${perc::-1}) +echo $num diff --git a/desktopenvs/hyprdrive/scripts/helpmenu.sh b/desktopenvs/hyprdrive/scripts/helpmenu.sh new file mode 100755 index 0000000..c83b139 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/helpmenu.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Display the keybindings Lua file in a pager for quick reference. +# binds.lua is the single source of truth for all keybindings, so reading it +# directly ensures the help view is always up-to-date with the live config. +cat ~/Dotfiles/desktopenvs/hyprdrive/hypr/usr/binds.lua | less + diff --git a/desktopenvs/hyprdrive/scripts/horizon-dock-start.sh b/desktopenvs/hyprdrive/scripts/horizon-dock-start.sh new file mode 100755 index 0000000..066b44b --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/horizon-dock-start.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Resident launcher for the horizon-dock GTK4 orbital dock. +# Same LD_PRELOAD requirement and rationale as astal-menu-start.sh/orbit-menu- +# start.sh: gtk4-layer-shell must load before libwayland-client, which isn't +# guaranteed under PyGObject. + +APP="${HOME}/.config/horizon-dock/main.py" +SO="$(ldconfig -p 2>/dev/null | awk '/libgtk4-layer-shell\.so/ {print $NF; exit}')" +if [[ -n "${SO:-}" ]]; then + export LD_PRELOAD="${SO}${LD_PRELOAD:+:${LD_PRELOAD}}" +fi + +exec python3 "$APP" "$@" diff --git a/desktopenvs/hyprdrive/scripts/horizon-dock.sh b/desktopenvs/hyprdrive/scripts/horizon-dock.sh new file mode 100755 index 0000000..2bcafa2 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/horizon-dock.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Toggle the horizon-dock. Forwards a verb to the resident daemon over D-Bus; if +# the daemon isn't running yet, starts it first. Mirrors orbit-menu.sh/menu- +# toggle.sh. Not bound to a key by default — invoke manually or wire it into a +# bar button / gesture yourself. +# +# horizon-dock.sh -> --toggle (default) +# horizon-dock.sh show -> --show +# horizon-dock.sh hide -> --hide +# +# (No `set -e`: a non-zero `busctl` in the wait loop is expected and must not +# abort the script before it forwards the verb.) + +BUS="eu.abdelbaki.horizondock" +OBJ="/eu/abdelbaki/horizondock" +APP="${HOME}/.config/horizon-dock/main.py" + +case "${1:-toggle}" in + show) VERB="--show"; ACTION="show" ;; + hide) VERB="--hide"; ACTION="hide" ;; + *) VERB="--toggle"; ACTION="toggle" ;; +esac + +registered() { busctl --user list 2>/dev/null | grep -q "$BUS"; } + +if registered; then + exec gdbus call --session --dest "$BUS" --object-path "$OBJ" \ + --method org.gtk.Actions.Activate "$ACTION" "[]" "{}" >/dev/null +fi + +"${HOME}/.config/scripts/horizon-dock-start.sh" >/dev/null 2>&1 & +for _ in $(seq 1 25); do + if registered; then + exec "$0" "$@" + fi + sleep 0.2 +done + +exec python3 "$APP" "$VERB" diff --git a/desktopenvs/hyprdrive/scripts/hypr-nav b/desktopenvs/hyprdrive/scripts/hypr-nav new file mode 100755 index 0000000..c38af79 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/hypr-nav @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Screen-absolute focus / window-move for the scrolling ("scrolling"/"columns") +layouts, so the direction keys stop *rotating* with the scroller's orientation. + +Hyprland's scrolling layout resolves a direction that is *perpendicular* to the tape +onto "next in tape" (verified: with a vertical tape, Left/Right scroll DOWN; with a +horizontal tape, Up/Down jump to the next column). That makes h/j/k/l and the arrows +mean different things depending on `scrolling:direction`. This wrapper keeps every key +mapped to its literal on-screen direction: + + * Along the tape axis -> the layout's own move/focus (correct as-is). + * Perpendicular with a real neighbour + in the current column/strip -> a genuine within-column step (kept). + * Perpendicular with no neighbour -> a no-op, instead of scrolling the tape. + (Crossing to another monitor has its own explicit binds — Super+,/. and + Super+Shift+,/. — so it isn't overloaded onto the arrows here.) + +For the custom "columns" layout every command is forwarded to that layout's own +handler (focus/move drive its columns and flow across monitors at the edges, resize +adjusts column-width / window-height weights, count changes the column count). For any +other layout (dwindle/master/monocle) focus/move/resize is a straight pass-through, so +nothing else changes. + +scroll travels along the focused strip: within the focused column for "columns", along +the tape for the built-in scroller, and (unchanged) between workspaces everywhere else — +so the mouse wheel means "next thing in this strip" wherever a strip exists. + +Usage: hypr-nav focus l|r|u|d + hypr-nav move l|r|u|d + hypr-nav resize l|r|u|d + hypr-nav count h|j|k|l (columns layout only) + hypr-nav scroll u|d +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys + +DIRS = {"l": ("x", -1, "left"), "r": ("x", 1, "right"), + "u": ("y", -1, "up"), "d": ("y", 1, "down")} +SCROLLING_LAYOUTS = {"scrolling"} # built-in scrolling tape +COLUMNS_LAYOUT = "columns" # our custom independent-pan Lua layout +STATE = os.path.expanduser("~/.cache/astro-menu/layouts-state.json") + + +def hypr_json(*args): + try: + return json.loads(subprocess.check_output(["hyprctl", *args, "-j"])) + except (subprocess.CalledProcessError, json.JSONDecodeError): + return None + + +def dispatch(lua: str) -> None: + # `hyprctl dispatch` evaluates its argument as Lua here (the hl.dsp.* API). + subprocess.run(["hyprctl", "dispatch", f"hl.dispatch({lua})"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def monitor_adjacent(kind: str, key: str) -> None: + # Spatial (transform-aware) monitor jump, used as the edge-of-strip fallback below — + # see scripts/monitor-adjacent for why this isn't just hl.dsp.focus({monitor=+-1}). + script = os.path.expanduser("~/.config/scripts/monitor-adjacent") + subprocess.run([script, kind, key], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def plain(kind: str, dir_word: str) -> None: + if kind == "focus": + dispatch(f'hl.dsp.focus({{ direction = "{dir_word}" }})') + elif kind == "move": + dispatch(f'hl.dsp.window.move({{ direction = "{dir_word}" }})') + else: # resize — native, screen-relative (10px step, matches the old ALT binds) + axis, sign, _ = DIRS[{"left": "l", "right": "r", "up": "u", "down": "d"}[dir_word]] + dx = 10 * sign if axis == "x" else 0 + dy = 10 * sign if axis == "y" else 0 + dispatch(f"hl.dsp.window.resize({{ x = {dx}, y = {dy}, relative = true }})") + + +def active_layout_name(wsid) -> str | None: + try: + with open(STATE) as f: + name = json.load(f).get(str(wsid)) + if name: + return name + except (FileNotFoundError, json.JSONDecodeError, TypeError): + pass + # A workspace the user has never explicitly switched via the menu has no entry + # in STATE (write_state()/M.set only fires on a menu pick) — that's every fresh + # workspace at session start, since it's just inheriting the global default. Fall + # back to the raw Hyprland option, which for a custom Lua layout is the "lua:" + # backend string (see layouts/init.lua's M.set_default), not the registry name — strip + # the prefix so this still compares equal to COLUMNS_LAYOUT below. + opt = hypr_json("getoption", "general:layout") + raw = opt.get("str") if opt else None + if raw and raw.startswith("lua:"): + return raw[len("lua:"):] + return raw + + +def tape_axis() -> str: + opt = hypr_json("getoption", "scrolling:direction") + d = (opt.get("str") if opt else "down") or "down" + return "y" if d in ("down", "up") else "x" + + +def _idx(axis): + return 0 if axis == "x" else 1 + + +def _lo_hi(w, axis): + i = _idx(axis) + return w["at"][i], w["at"][i] + w["size"][i] + + +def _center(w, axis): + i = _idx(axis) + return w["at"][i] + w["size"][i] / 2 + + +def _overlap(a, b): + return a[0] < b[1] and b[0] < a[1] + + +def main() -> int: + # focus/move/resize take a screen key l|r|u|d; count a vim key h|j|k|l; scroll u|d. + if len(sys.argv) != 3 or sys.argv[1] not in ("focus", "move", "resize", "count", "scroll"): + print(__doc__) + return 2 + kind, key = sys.argv[1], sys.argv[2] + if kind == "count": + valid = set("hjkl") + elif kind == "scroll": + valid = {"u", "d"} + else: + valid = set(DIRS) + if key not in valid: + print(__doc__) + return 2 + + ws = hypr_json("activeworkspace") or {} + layout = active_layout_name(ws.get("id")) + + # Custom "columns" layout owns all of these — forward to its layout_msg. scroll + # travels within the focused column, i.e. focus up/down along that strip. + if layout == COLUMNS_LAYOUT: + if kind == "scroll": + dispatch(f'hl.dsp.layout("focus {key}")') + else: + dispatch(f'hl.dsp.layout("{kind} {key}")') + return 0 + + if kind == "count": + return 0 # column count only means something in columns + + if kind == "scroll": + if layout in SCROLLING_LAYOUTS: + # travel along the tape: wheel-down = forward, wheel-up = backward. + primary = tape_axis() + fwd, back = ("down", "up") if primary == "y" else ("right", "left") + plain("focus", fwd if key == "d" else back) + else: + # no strip here — keep the classic wheel-switches-workspace behaviour. + dispatch(f'hl.dsp.focus({{ workspace = "{"r+1" if key == "d" else "r-1"}" }})') + return 0 + + if kind == "resize": + # native, screen-relative resize everywhere that isn't columns (incl. scrolling). + plain("resize", DIRS[key][2]) + return 0 + + axis, sign, dir_word = DIRS[key] + if layout not in SCROLLING_LAYOUTS: + plain(kind, dir_word) # dwindle/master/monocle: unchanged + return 0 + + primary = tape_axis() + if axis == primary: + plain(kind, dir_word) # along the tape — already screen-correct + return 0 + + win = hypr_json("activewindow") + if not win or not win.get("address"): + plain(kind, dir_word) + return 0 + + # Perpendicular move: is there a real neighbour within this column/strip in the + # requested screen direction? (Same column == overlapping along the tape's primary + # axis; the requested `axis` is the secondary/stacking axis here.) + wsid = win.get("workspace", {}).get("id") + clients = [c for c in (hypr_json("clients") or []) + if c.get("mapped", True) and c.get("workspace", {}).get("id") == wsid + and c.get("address") != win["address"]] + prange = _lo_hi(win, primary) + my_c = _center(win, axis) + neighbour = any(_overlap(prange, _lo_hi(c, primary)) + and (_center(c, axis) - my_c) * sign > 1 for c in clients) + + if neighbour: + plain(kind, dir_word) # genuine within-column step + else: + # No neighbour that way. Don't let the layout scroll the tape (that sideways/ + # vertical "rotation" is exactly what we're suppressing) — instead fall back to + # jumping to whichever monitor is spatially in that direction, the same as the + # columns layout does at its own edges. No-ops if there isn't one that way. + monitor_adjacent(kind, key) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktopenvs/hyprdrive/scripts/hyprland-toggle-touchpad.sh b/desktopenvs/hyprdrive/scripts/hyprland-toggle-touchpad.sh new file mode 100755 index 0000000..cb673bd --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/hyprland-toggle-touchpad.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +# State file lives in the user's runtime dir (tmpfs, cleared on logout). +# Using XDG_RUNTIME_DIR avoids /tmp collisions on multi-user systems. +export STATUS_FILE="$XDG_RUNTIME_DIR/keyboard.status" + +enable_keyboard() { + printf "true" >"$STATUS_FILE" + notify-send -u normal "Enabling Touchpad" + # `hyprctl keyword` changes a live config value over IPC without reloading. + # The device name in square brackets scopes the change to that input device only. + hyprctl keyword 'device[synaptics-tm3053-009]:enabled' "true" +} + +disable_keyboard() { + printf "false" >"$STATUS_FILE" + notify-send -u normal "Disabling Touchpad" + hyprctl keyword 'device[synaptics-tm3053-009]:enabled' "false" +} + +# If no status file exists yet, treat as "enabled" (first run after login). +if ! [ -f "$STATUS_FILE" ]; then + enable_keyboard +else + # Toggle based on the persisted state: true → disable, false → enable. + if [ $(cat "$STATUS_FILE") = "true" ]; then + disable_keyboard + elif [ $(cat "$STATUS_FILE") = "false" ]; then + enable_keyboard + fi +fi diff --git a/desktopenvs/hyprdrive/scripts/ip b/desktopenvs/hyprdrive/scripts/ip new file mode 100755 index 0000000..b26a81d --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/ip @@ -0,0 +1,6 @@ +#!/bin/bash +# Print the first IP address of the local host. +# hostname -i returns space-separated IPs; tr converts spaces to newlines so +# head/tail can reliably extract just the first entry (typically the LAN IP). +hostname -i | tr ' ' '\n' | head -n1 | tail -n1 + diff --git a/desktopenvs/hyprdrive/scripts/journal.sh b/desktopenvs/hyprdrive/scripts/journal.sh new file mode 100755 index 0000000..2aee9c9 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/journal.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Display the personal journal file in a scrollable pager. +# Opened by a keybind inside a floating kitty terminal window. +cat ~/journal.txt | less + diff --git a/desktopenvs/hyprdrive/scripts/monitor-adjacent b/desktopenvs/hyprdrive/scripts/monitor-adjacent new file mode 100755 index 0000000..c0f54c4 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/monitor-adjacent @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Focus/move-window to the monitor spatially adjacent to the currently focused one, +in a literal screen direction (left/right/up/down) — unlike Hyprland's native +`focusmonitor -1/+1` / `movewindow mon:-1/+1`, which cycle through Hyprland's internal +monitor list *order* (roughly connection/config order), not actual position. + +That distinction matters once a monitor is rotated: a 90°/270° `transform` swaps a +monitor's *logical* width/height (see monitor-manager's `logical_width`/`logical_height`, +the same math reused here), but doesn't reorder Hyprland's monitor list. On a 3-wide +desk (e.g. a portrait-rotated monitor sitting to the right of a large landscape main +one), that can make "-1/+1" jump correctly one way and land on the wrong monitor (or a +no-op) the other way, because the list order and the physical left-to-right order have +silently diverged. This instead sorts monitors by their actual logical bounding-box +center each time, so "next"/"prev" always means the next monitor to the right/left (or +below/above) on screen, regardless of transform or connection order. + +Usage: + monitor-adjacent focus l|r|u|d + monitor-adjacent move l|r|u|d + +Silently does nothing if there's only one monitor, no monitor is focused, or there's no +monitor further in that direction (no wrap-around at the ends — see the note in +_pick_target for why). +""" + +from __future__ import annotations + +import json +import subprocess +import sys + +AXIS = {"l": ("x", -1), "r": ("x", 1), "u": ("y", -1), "d": ("y", 1)} + + +def hypr_json(*args): + try: + return json.loads(subprocess.check_output(["hyprctl", *args, "-j"])) + except (subprocess.CalledProcessError, json.JSONDecodeError, FileNotFoundError): + return None + + +def _logical_size(m: dict) -> tuple[float, float]: + scale = m.get("scale") or 1.0 + w, h = m.get("width", 0), m.get("height", 0) + if (m.get("transform", 0) & 3) in (1, 3): + w, h = h, w + return w / scale, h / scale + + +def _center(m: dict) -> tuple[float, float]: + w, h = _logical_size(m) + return m.get("x", 0) + w / 2, m.get("y", 0) + h / 2 + + +def _pick_target(monitors: list[dict], current: dict, axis: str, sign: int) -> dict | None: + cur_c = _center(current) + idx_c = 0 if axis == "x" else 1 + # Candidates strictly on the requested side of the current monitor's center. + candidates = [] + for m in monitors: + if m is current: + continue + c = _center(m) + delta = (c[idx_c] - cur_c[idx_c]) * sign + if delta > 1: # >1 logical px: ignore near-exact ties/float noise + candidates.append((delta, m)) + if not candidates: + # No wrap-around: at the physical edge of the desk, hjkl-style directional + # movement should just stop, the same way it does within a single monitor's + # windows. (Unlike Hyprland's index-based -1/+1, which does wrap — if you want + # that cyclic behaviour, that's what the explicit Super+,/. binds are for.) + return None + candidates.sort(key=lambda t: t[0]) # closest first + return candidates[0][1] + + +def main() -> int: + if len(sys.argv) != 3 or sys.argv[1] not in ("focus", "move") or sys.argv[2] not in AXIS: + print(__doc__) + return 2 + kind, key = sys.argv[1], sys.argv[2] + axis, sign = AXIS[key] + + monitors = hypr_json("monitors") + if not monitors or len(monitors) < 2: + return 0 + current = next((m for m in monitors if m.get("focused")), None) + if current is None: + return 0 + + target = _pick_target(monitors, current, axis, sign) + if target is None: + return 0 + + name = target["name"] + if kind == "focus": + subprocess.run(["hyprctl", "dispatch", "focusmonitor", name], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + subprocess.run(["hyprctl", "dispatch", "movewindow", f"mon:{name}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktopenvs/hyprdrive/scripts/monitor-manager b/desktopenvs/hyprdrive/scripts/monitor-manager new file mode 100755 index 0000000..78e7a53 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/monitor-manager @@ -0,0 +1,861 @@ +#!/usr/bin/env python3 +""" +Hyprland monitor manager — WYSIWYG curses TUI. + +Keys (normal mode): + Tab / Shift+Tab cycle selected monitor + h j k l move monitor (50 px) + H J K L move monitor (10 px fine) + u / i rotate CCW / CW + t / g scale up / scale down (valid Hyprland steps) + m toggle mirror (pick target) / un-mirror + n / N cycle display mode forward / backward + s save to hypr/usr/monitors.lua + Enter save & quit + q / Esc quit (prompts if unsaved changes) + +Mirror-pick mode: + Tab / Shift+Tab cycle target + Enter confirm + Esc cancel +""" + +import curses +import json +import math +import os +import re +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +# The live config Hyprland actually loads (hyprland.lua does `require("usr.monitors")`, +# which resolves to ~/.config/hypr/usr/monitors.lua). Writing here means `hyprctl +# reload` applies the layout immediately. The Dotfiles repo copy is the deploy +# source and is intentionally NOT written by this tool. +MONITORS_LUA = Path.home() / ".config/hypr/usr/monitors.lua" +MOVE_STEP = 50 +MOVE_STEP_FINE = 10 +MIN_SCALE = 0.25 +MAX_SCALE = 4.0 +_SCALE_MAX_DENOM = 6 # max denominator when enumerating valid Hyprland scales +MIN_BOX_W = 14 +MIN_BOX_H = 4 +INFO_W = 32 +STATUS_ROWS = 2 # status + help rows at the bottom +MAX_RESOLVE_ITERS = 8 # auto-resolve nudges before giving up + +TRANSFORM_LABEL = { + 0: "↕ 0°", + 1: "↻ 90°", + 2: "↕ 180°", + 3: "↺ 90°", + 4: "⇔ 0°", + 5: "⇔↻ 90°", + 6: "⇔ 180°", + 7: "⇔↺ 90°", +} + +_MODE_RE = re.compile(r"(\d+)x(\d+)@([\d.]+)Hz") + +# --------------------------------------------------------------------------- +# Rotation helpers +# --------------------------------------------------------------------------- + +def rotate_cw(t: int) -> int: + return (t & 4) | ((t + 1) & 3) + +def rotate_ccw(t: int) -> int: + return (t & 4) | ((t - 1) & 3) + +# --------------------------------------------------------------------------- +# Scale helpers +# --------------------------------------------------------------------------- + +def valid_scales(width: int, height: int) -> List[float]: + """Return sorted list of scales valid for (width, height). + + A scale s = p/q (in lowest terms) is valid iff both width/s and height/s + are integers, i.e. p divides gcd(width, height). We limit q ≤ + _SCALE_MAX_DENOM to keep the step count practical (~20 steps per monitor). + """ + g = math.gcd(width, height) + divisors = [k for k in range(1, g + 1) if g % k == 0] + result: set = set() + for p in divisors: + for q in range(1, _SCALE_MAX_DENOM + 1): + if math.gcd(p, q) != 1: + continue + s = p / q + if MIN_SCALE <= s <= MAX_SCALE: + result.add(round(s, 10)) + return sorted(result) + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class MonitorState: + name: str + x: int + y: int + width: int + height: int + refresh_rate: float + transform: int + scale: float + mirror_of: str + available_modes: List[str] + mode_index: int + dirty: bool = False + + @property + def logical_width(self) -> int: + if (self.transform & 3) in (1, 3): + return round(self.height / self.scale) + return round(self.width / self.scale) + + @property + def logical_height(self) -> int: + if (self.transform & 3) in (1, 3): + return round(self.width / self.scale) + return round(self.height / self.scale) + + @property + def mode_str(self) -> str: + return f"{self.width}x{self.height}@{int(round(self.refresh_rate))}" + + @classmethod + def from_json(cls, d: dict) -> "MonitorState": + modes = d.get("availableModes", []) + w = d.get("width", 1920) + h = d.get("height", 1080) + rr = d.get("refreshRate", 60.0) + # Find current mode index + mode_index = 0 + for i, m in enumerate(modes): + mo = _MODE_RE.match(m) + if mo and int(mo.group(1)) == w and int(mo.group(2)) == h: + if abs(float(mo.group(3)) - rr) < 1.0: + mode_index = i + break + return cls( + name=d.get("name", ""), + x=d.get("x", 0), + y=d.get("y", 0), + width=w, + height=h, + refresh_rate=rr, + transform=d.get("transform", 0), + scale=d.get("scale", 1.0), + mirror_of="" if d.get("mirrorOf", "none") in ("none", "") else d["mirrorOf"], + available_modes=modes, + mode_index=mode_index, + ) + +# --------------------------------------------------------------------------- +# hyprctl helpers +# --------------------------------------------------------------------------- + +def fetch_monitors() -> List[MonitorState]: + r = subprocess.run( + ["hyprctl", "monitors", "-j"], + capture_output=True, text=True, check=True, + ) + return [MonitorState.from_json(d) for d in json.loads(r.stdout)] + + +def apply_monitor(m: MonitorState) -> Optional[str]: + if m.mirror_of: + lua = f"hl.monitor({{output='{m.name}', mirror='{m.mirror_of}'}})" + else: + lua = ( + f"hl.monitor({{" + f"output='{m.name}', " + f"mode='{m.mode_str}', " + f"position='{m.x}x{m.y}', " + f"scale={m.scale}, " + f"transform={m.transform}" + f"}})" + ) + r = subprocess.run(["hyprctl", "eval", lua], capture_output=True, text=True, check=False) + if r.returncode != 0: + return (r.stderr or r.stdout).strip() + return None + +# --------------------------------------------------------------------------- +# Overlap geometry +# --------------------------------------------------------------------------- + +def _logical_rect(m: "MonitorState"): + return (m.x, m.y, m.x + m.logical_width, m.y + m.logical_height) + + +def _overlap_px(a, b): + """Return (ox, oy) — overlapping width/height of two (x0,y0,x1,y1) rects.""" + ox = max(0, min(a[2], b[2]) - max(a[0], b[0])) + oy = max(0, min(a[3], b[3]) - max(a[1], b[1])) + return ox, oy + + +def find_overlaps(monitors: List["MonitorState"]): + """Return list of (name_a, name_b, ox, oy) for every overlapping pair. + + Mirrored outputs are excluded — they intentionally share their target's + region and don't occupy independent layout space. + """ + active = [m for m in monitors if not m.mirror_of] + out = [] + for i in range(len(active)): + for j in range(i + 1, len(active)): + ox, oy = _overlap_px(_logical_rect(active[i]), _logical_rect(active[j])) + if ox > 0 and oy > 0: + out.append((active[i].name, active[j].name, ox, oy)) + return out + +# --------------------------------------------------------------------------- +# Save +# --------------------------------------------------------------------------- + +def save_monitors_lua(monitors: List[MonitorState], path: Path) -> None: + lines = ["-- generated by monitor-manager -- do not edit by hand\n"] + for m in monitors: + if m.mirror_of: + lines.append( + f'hl.monitor({{\n' + f' output = "{m.name}",\n' + f' mirror = "{m.mirror_of}",\n' + f'}})\n\n' + ) + else: + lines.append( + f'hl.monitor({{\n' + f' output = "{m.name}",\n' + f' mode = "{m.mode_str}",\n' + f' position = "{m.x}x{m.y}",\n' + f' scale = {m.scale},\n' + f' transform = {m.transform},\n' + f'}})\n\n' + ) + lines.append( + 'hl.config({\n' + ' xwayland = {\n' + ' force_zero_scaling = true,\n' + ' },\n' + '})\n' + ) + tmp = path.with_suffix(".lua.tmp") + tmp.write_text("".join(lines)) + os.replace(tmp, path) + +# --------------------------------------------------------------------------- +# Canvas math +# --------------------------------------------------------------------------- + +def compute_scale(monitors: List[MonitorState], pane_cols: int, pane_rows: int) -> float: + if not monitors: + return 1.0 + max_x = max(m.x + m.logical_width for m in monitors) + max_y = max(m.y + m.logical_height for m in monitors) + if max_x <= 0 or max_y <= 0: + return 1.0 + sx = pane_cols / (max_x * 1.15) + sy = pane_rows / (max_y * 1.15) + return min(sx, sy) + + +def to_screen(cx: int, cy: int, scale: float, margin_col: int = 1, margin_row: int = 1): + col = margin_col + int(cx * scale) + row = margin_row + int(cy * scale * 0.5) + return row, col + +# --------------------------------------------------------------------------- +# Safe addstr / addch wrappers +# --------------------------------------------------------------------------- + +def safe_addstr(win, row, col, text, attr=0): + try: + max_row, max_col = win.getmaxyx() + if row < 0 or row >= max_row or col < 0 or col >= max_col: + return + avail = max_col - col - 1 + if avail <= 0: + return + win.addstr(row, col, text[:avail], attr) + except curses.error: + pass + + +def safe_addch(win, row, col, ch, attr=0): + try: + max_row, max_col = win.getmaxyx() + if row < 0 or row >= max_row or col < 0 or col >= max_col: + return + win.addch(row, col, ch, attr) + except curses.error: + pass + +# --------------------------------------------------------------------------- +# Box drawing +# --------------------------------------------------------------------------- + +def draw_box(win, row, col, h, w, attr=0): + if h < 2 or w < 2: + return + safe_addch(win, row, col, "┌", attr) + safe_addch(win, row, col + w-1, "┐", attr) + safe_addch(win, row + h-1, col, "└", attr) + safe_addch(win, row + h-1, col + w-1, "┘", attr) + for c in range(col + 1, col + w - 1): + safe_addch(win, row, c, "─", attr) + safe_addch(win, row + h-1, c, "─", attr) + for r in range(row + 1, row + h - 1): + safe_addch(win, r, col, "│", attr) + safe_addch(win, r, col + w-1, "│", attr) + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- + +class App: + def __init__(self, stdscr): + self.stdscr = stdscr + self.monitors: List[MonitorState] = [] + self.selected_idx: int = 0 + self.mode: str = "normal" # "normal" | "mirror_pick" + self.mirror_source_idx: int = 0 + self.mirror_target_idx: int = 1 + self.dirty: bool = False + self.status_msg: str = "" + self._scale: float = 0.0 # cached canvas scale + self._scale_pane: tuple = (0, 0) # pane size used for cached scale + self._load_monitors() + self._init_colors() + + def _load_monitors(self): + self.monitors = fetch_monitors() + if self.selected_idx >= len(self.monitors): + self.selected_idx = 0 + + def _init_colors(self): + curses.start_color() + curses.use_default_colors() + # Match the rest of the config (CyberQueer): accent red #E40046 for the + # selection, electric violet #5018dd for headers/help. Define them as true + # RGB when the terminal allows (kitty does); otherwise fall back to the + # nearest ANSI colour so the tool still works on a basic console. + accent, violet = curses.COLOR_CYAN, curses.COLOR_GREEN + if curses.COLORS >= 256 and curses.can_change_color(): + try: + curses.init_color(16, 894, 0, 275) # #E40046 + curses.init_color(17, 314, 94, 867) # #5018dd + accent, violet = 16, 17 + except curses.error: + pass + # 1 = selected (accent red, bold) + curses.init_pair(1, accent, -1) + # 2 = normal (white) + curses.init_pair(2, curses.COLOR_WHITE, -1) + # 3 = mirror target (yellow) + curses.init_pair(3, curses.COLOR_YELLOW, -1) + # 4 = mirrored / dim + curses.init_pair(4, curses.COLOR_BLACK + 8 if curses.COLORS >= 16 else curses.COLOR_WHITE, -1) + # 5 = status bar (reversed) + curses.init_pair(5, -1, -1) + # 6 = help / headers (violet) + curses.init_pair(6, violet, -1) + + def _get_scale(self, pane_cols: int, pane_rows: int) -> float: + """Return cached scale; recompute only on resize or when a monitor escapes the viewport.""" + pane = (pane_cols, pane_rows) + if self._scale > 0 and self._scale_pane == pane: + # Check every monitor still fits inside the current viewport + inner_cols = pane_cols - 2 + inner_rows = pane_rows - 2 + all_fit = True + for m in self.monitors: + brow, bcol = to_screen(m.x + m.logical_width, m.y + m.logical_height, self._scale) + if bcol > inner_cols or brow > inner_rows: + all_fit = False + break + if all_fit: + return self._scale + self._scale = compute_scale(self.monitors, pane_cols - 2, pane_rows - 2) + self._scale_pane = pane + return self._scale + + # ----------------------------------------------------------------------- + # Event loop + # ----------------------------------------------------------------------- + + def run(self): + curses.curs_set(0) + self.stdscr.timeout(100) + self.draw() + while True: + ch = self.stdscr.getch() + if ch == curses.KEY_RESIZE: + self._scale_pane = (0, 0) # force scale recompute on resize + self.draw() + continue + if ch == -1: + continue + if self.mode == "normal": + result = self.handle_key_normal(ch) + if result == "quit": + break + elif self.mode == "mirror_pick": + self.handle_key_mirror(ch) + self.draw() + + # ----------------------------------------------------------------------- + # Key handlers + # ----------------------------------------------------------------------- + + def handle_key_normal(self, ch) -> Optional[str]: + mon = self.monitors[self.selected_idx] if self.monitors else None + + # Tab / Shift+Tab — cycle monitor + if ch == ord("\t"): + self.selected_idx = (self.selected_idx + 1) % max(1, len(self.monitors)) + return + if ch == curses.KEY_BTAB: + self.selected_idx = (self.selected_idx - 1) % max(1, len(self.monitors)) + return + + if mon is None: + return + + # Movement — coarse + if ch == ord("h"): + self.move_monitor(-MOVE_STEP, 0) + elif ch == ord("l"): + self.move_monitor(MOVE_STEP, 0) + elif ch == ord("k"): + self.move_monitor(0, -MOVE_STEP) + elif ch == ord("j"): + self.move_monitor(0, MOVE_STEP) + # Movement — fine + elif ch == ord("H"): + self.move_monitor(-MOVE_STEP_FINE, 0) + elif ch == ord("L"): + self.move_monitor(MOVE_STEP_FINE, 0) + elif ch == ord("K"): + self.move_monitor(0, -MOVE_STEP_FINE) + elif ch == ord("J"): + self.move_monitor(0, MOVE_STEP_FINE) + # Rotation + elif ch == ord("u"): + self.rotate_monitor(-1) + elif ch == ord("i"): + self.rotate_monitor(+1) + # Scale + elif ch == ord("t"): + self.scale_monitor(+1) + elif ch == ord("g"): + self.scale_monitor(-1) + # Mirror + elif ch == ord("m"): + if mon.mirror_of: + mon.mirror_of = "" + self._apply_and_status(mon, f"Un-mirrored {mon.name}") + elif len(self.monitors) < 2: + self.status_msg = "Need 2+ monitors to mirror" + else: + self.mirror_source_idx = self.selected_idx + self.mirror_target_idx = (self.selected_idx + 1) % len(self.monitors) + self.mode = "mirror_pick" + # Mode cycling + elif ch == ord("n"): + self.cycle_mode(+1) + elif ch == ord("N"): + self.cycle_mode(-1) + # Save + elif ch == ord("s"): + self._save() + # Save & quit + elif ch in (curses.KEY_ENTER, ord("\n"), ord("\r")): + self._save() + return "quit" + # Quit + elif ch in (ord("q"), 27): # q or Esc + if self.dirty: + action = self.prompt_save_quit() + if action == "cancel": + return None + if action == "save": + self._save() + return "quit" + + def handle_key_mirror(self, ch): + n = len(self.monitors) + + def next_target(delta: int): + t = (self.mirror_target_idx + delta) % n + # skip source + if t == self.mirror_source_idx: + t = (t + delta) % n + self.mirror_target_idx = t + + if ch == ord("\t"): + next_target(+1) + elif ch == curses.KEY_BTAB: + next_target(-1) + elif ch in (curses.KEY_ENTER, ord("\n"), ord("\r")): + self.set_mirror(self.mirror_source_idx, self.mirror_target_idx) + self.mode = "normal" + elif ch == 27: # Esc + self.mode = "normal" + self.status_msg = "Mirror cancelled" + + # ----------------------------------------------------------------------- + # Actions + # ----------------------------------------------------------------------- + + def _live_overlap_warning(self) -> Optional[str]: + """Re-fetch live monitor positions and report any actual overlap. + + Hyprland re-snaps fractionally-scaled/rotated outputs at apply time, so + the positions it ends up with can differ from what this TUI set. Only a + check against the live state catches snap-induced collisions. + """ + try: + ov = find_overlaps(fetch_monitors()) + except Exception: + return None + if not ov: + return None + a, b, ox, _ = ov[0] + extra = f" (+{len(ov) - 1} more)" if len(ov) > 1 else "" + return f"{a} overlaps {b} by {ox}px{extra}" + + def _auto_resolve(self, mon: MonitorState) -> bool: + """Nudge `mon` until its *live* rectangle no longer overlaps a neighbor. + + Hyprland may snap a fractionally-scaled/rotated output to a position + that collides with its neighbor. We can't predict the snap, so we read + the live layout, push `mon` by the minimal grid-aligned translation that + would separate it from its worst overlapper, re-apply, and repeat. Only + `mon` moves; other monitors are left where the user put them. Returns + True if any adjustment was made. + """ + if mon.mirror_of: + return False + moved = False + for _ in range(MAX_RESOLVE_ITERS): + try: + live = fetch_monitors() + except Exception: + break + me = next((lm for lm in live if lm.name == mon.name), None) + if me is None or me.mirror_of: + break + a = _logical_rect(me) + worst = None # (overlap_area, neighbor_rect) + for o in live: + if o.name == mon.name or o.mirror_of: + continue + ox, oy = _overlap_px(a, _logical_rect(o)) + if ox > 0 and oy > 0 and (worst is None or ox * oy > worst[0]): + worst = (ox * oy, _logical_rect(o)) + if worst is None: + break # no overlap left — resolved + b = worst[1] + # Minimal separating translation of `mon` along either axis. + axis, delta = min( + (("x", b[2] - a[0]), ("x", b[0] - a[2]), + ("y", b[3] - a[1]), ("y", b[1] - a[3])), + key=lambda c: abs(c[1]), + ) + # Round magnitude up to the grid so the next snap keeps a margin. + step = MOVE_STEP_FINE + mag = ((abs(delta) + step - 1) // step) * step + delta = mag if delta > 0 else -mag + if axis == "x": + mon.x = max(0, mon.x + delta) + else: + mon.y = max(0, mon.y + delta) + if apply_monitor(mon): + break + moved = True + return moved + + def _apply_and_status(self, mon: MonitorState, success_msg: str): + """Apply a monitor, mark dirty, auto-resolve snap overlap, set status.""" + err = apply_monitor(mon) + mon.dirty = True + self.dirty = True + if err: + self.status_msg = err + return + resolved = self._auto_resolve(mon) + warn = self._live_overlap_warning() + if warn: + self.status_msg = f"{success_msg} ⚠ {warn}" + elif resolved: + self.status_msg = f"{success_msg} (auto-resolved → {mon.x},{mon.y})" + else: + self.status_msg = success_msg + + def _would_overlap(self, idx: int) -> Optional[str]: + """Name of the first monitor that monitor `idx` overlaps (TUI coords).""" + mon = self.monitors[idx] + if mon.mirror_of: + return None + a = _logical_rect(mon) + for j, other in enumerate(self.monitors): + if j == idx or other.mirror_of: + continue + ox, oy = _overlap_px(a, _logical_rect(other)) + if ox > 0 and oy > 0: + return other.name + return None + + def move_monitor(self, dx: int, dy: int): + mon = self.monitors[self.selected_idx] + # Snap to a round grid so positions stay multiples of MOVE_STEP_FINE. + # The seed comes from hyprctl, which may report a snapped value (e.g. + # a fractionally-scaled/rotated monitor), so raw addition would freeze + # the last digit. Rounding normalizes any odd seed on the first move. + base = MOVE_STEP_FINE + old_x, old_y = mon.x, mon.y + mon.x = max(0, round((mon.x + dx) / base) * base) + mon.y = max(0, round((mon.y + dy) / base) * base) + # Block moves that would drive this monitor into another (TUI coords). + clash = self._would_overlap(self.selected_idx) + if clash: + mon.x, mon.y = old_x, old_y + self.status_msg = f"Blocked: {mon.name} would overlap {clash}" + return + self._apply_and_status(mon, f"Moved {mon.name} to {mon.x},{mon.y}") + + def rotate_monitor(self, direction: int): + mon = self.monitors[self.selected_idx] + if direction > 0: + mon.transform = rotate_cw(mon.transform) + else: + mon.transform = rotate_ccw(mon.transform) + self._apply_and_status(mon, f"Rotated {mon.name} → {TRANSFORM_LABEL[mon.transform]}") + + def scale_monitor(self, direction: int): + mon = self.monitors[self.selected_idx] + scales = valid_scales(mon.width, mon.height) + cur = round(mon.scale, 10) + if direction > 0: + candidates = [s for s in scales if s > cur + 1e-9] + if not candidates: + self.status_msg = f"Scale already at max ({mon.scale}x)" + return + new_scale = candidates[0] + else: + candidates = [s for s in scales if s < cur - 1e-9] + if not candidates: + self.status_msg = f"Scale already at min ({mon.scale}x)" + return + new_scale = candidates[-1] + mon.scale = new_scale + self._apply_and_status(mon, f"{mon.name} scale → {new_scale}x") + + def cycle_mode(self, delta: int): + mon = self.monitors[self.selected_idx] + if not mon.available_modes: + self.status_msg = "No mode list available" + return + mon.mode_index = (mon.mode_index + delta) % len(mon.available_modes) + mo = _MODE_RE.match(mon.available_modes[mon.mode_index]) + if mo: + mon.width = int(mo.group(1)) + mon.height = int(mo.group(2)) + mon.refresh_rate = float(mo.group(3)) + self._apply_and_status(mon, f"{mon.name} → {mon.mode_str}") + + def set_mirror(self, src_idx: int, tgt_idx: int): + src = self.monitors[src_idx] + tgt = self.monitors[tgt_idx] + src.mirror_of = tgt.name + self._apply_and_status(src, f"Mirroring {src.name} → {tgt.name}") + + def _save(self): + try: + save_monitors_lua(self.monitors, MONITORS_LUA) + for m in self.monitors: + m.dirty = False + self.dirty = False + subprocess.run(["hyprctl", "reload"], capture_output=True, check=False) + # Reload re-applies the saved config; re-check the live layout so a + # snap-induced collision is surfaced rather than silently saved. + warn = self._live_overlap_warning() + self.status_msg = ( + f"Saved — ⚠ {warn}" if warn else f"Saved to {MONITORS_LUA.name}" + ) + except Exception as e: + self.status_msg = f"Save failed: {e}" + + # ----------------------------------------------------------------------- + # Prompt + # ----------------------------------------------------------------------- + + def prompt_save_quit(self) -> str: + rows, cols = self.stdscr.getmaxyx() + prompt = "Unsaved changes. [s]ave & quit [n]o save [c]ancel" + safe_addstr(self.stdscr, rows - 1, 0, " " * (cols - 1), curses.A_REVERSE) + safe_addstr(self.stdscr, rows - 1, 0, prompt[:cols - 1], curses.A_REVERSE) + self.stdscr.timeout(-1) + while True: + ch = self.stdscr.getch() + if ch in (ord("s"), ord("S")): + self.stdscr.timeout(100) + return "save" + if ch in (ord("n"), ord("N")): + self.stdscr.timeout(100) + return "nosave" + if ch in (ord("c"), ord("C"), 27): + self.stdscr.timeout(100) + return "cancel" + + # ----------------------------------------------------------------------- + # Drawing + # ----------------------------------------------------------------------- + + def draw(self): + self.stdscr.erase() + rows, cols = self.stdscr.getmaxyx() + + use_info = cols >= 80 + canvas_w = (cols - INFO_W - 1) if use_info else cols + canvas_h = max(4, rows - STATUS_ROWS) + + self.draw_canvas(canvas_w, canvas_h) + if use_info: + self.draw_info(canvas_w, canvas_h) + # vertical separator + for r in range(canvas_h): + safe_addch(self.stdscr, r, canvas_w, "│", curses.color_pair(2)) + + self.draw_status(rows - 2, cols) + self.draw_help(rows - 1, cols) + self.stdscr.noutrefresh() + curses.doupdate() + + def draw_canvas(self, pane_cols: int, pane_rows: int): + scale = self._get_scale(pane_cols, pane_rows) + + for idx, mon in enumerate(self.monitors): + brow, bcol = to_screen(mon.x, mon.y, scale) + bw = max(MIN_BOX_W, int(mon.logical_width * scale)) + bh = max(MIN_BOX_H, int(mon.logical_height * scale * 0.5)) + + # Clamp to pane + if brow >= pane_rows or bcol >= pane_cols: + continue + + # Choose color + if self.mode == "mirror_pick" and idx == self.mirror_target_idx: + attr = curses.color_pair(3) | curses.A_BOLD + elif idx == self.selected_idx: + attr = curses.color_pair(1) | curses.A_BOLD + elif mon.mirror_of: + attr = curses.color_pair(4) + else: + attr = curses.color_pair(2) + + draw_box(self.stdscr, brow, bcol, bh, bw, attr) + + # Labels inside box + inner_w = bw - 2 + if inner_w < 1: + continue + + def label_line(row_offset: int, text: str): + r = brow + row_offset + c = bcol + 1 + if r >= pane_rows or r <= brow or r >= brow + bh - 1: + return + safe_addstr(self.stdscr, r, c, text[:inner_w], attr) + + # Line 1: name (+ dirty marker) + name_label = mon.name + (" *" if mon.dirty else "") + if mon.mirror_of: + name_label = f"{mon.name} →{mon.mirror_of}" + label_line(1, name_label) + + # Line 2: mode + if bh > 3: + label_line(2, mon.mode_str) + + # Line 3: rotation + if bh > 4: + label_line(3, TRANSFORM_LABEL.get(mon.transform, "")) + + def draw_info(self, start_col: int, pane_rows: int): + col = start_col + 1 + row = 0 + safe_addstr(self.stdscr, row, col, "Monitors:", curses.color_pair(6) | curses.A_BOLD) + row += 1 + for idx, mon in enumerate(self.monitors): + if row >= pane_rows: + break + prefix = "> " if idx == self.selected_idx else " " + attr = curses.color_pair(1) | curses.A_BOLD if idx == self.selected_idx else curses.color_pair(2) + safe_addstr(self.stdscr, row, col, f"{prefix}{mon.name}", attr) + row += 1 + if row < pane_rows: + safe_addstr(self.stdscr, row, col + 2, mon.mode_str, curses.color_pair(2)) + row += 1 + if row < pane_rows: + safe_addstr(self.stdscr, row, col + 2, f"pos {mon.x},{mon.y}", curses.color_pair(2)) + row += 1 + if row < pane_rows: + safe_addstr(self.stdscr, row, col + 2, f"scale {mon.scale}", curses.color_pair(2)) + row += 1 + if row < pane_rows: + rot_str = TRANSFORM_LABEL.get(mon.transform, "") + mirror_str = f" →{mon.mirror_of}" if mon.mirror_of else "" + safe_addstr(self.stdscr, row, col + 2, rot_str + mirror_str, curses.color_pair(2)) + row += 1 + row += 1 # blank between monitors + + def draw_status(self, row: int, cols: int): + if self.mode == "mirror_pick": + src = self.monitors[self.mirror_source_idx].name if self.monitors else "?" + tgt = self.monitors[self.mirror_target_idx].name if self.monitors else "?" + mode_tag = f"[MIRROR] {src} → {tgt}" + else: + mode_tag = "[NORMAL]" + if self.dirty: + mode_tag += " *" + + msg = f"{mode_tag} {self.status_msg}" + safe_addstr(self.stdscr, row, 0, " " * (cols - 1), curses.A_REVERSE) + safe_addstr(self.stdscr, row, 0, msg[:cols - 1], curses.A_REVERSE) + + def draw_help(self, row: int, cols: int): + if self.mode == "mirror_pick": + text = "Tab:cycle-target Enter:confirm Esc:cancel" + else: + text = "Tab:next hjkl:move HJKL:fine u/i:rot t/g:scale m:mirror n/N:mode s:save Enter:save+quit q:quit" + safe_addstr(self.stdscr, row, 0, text[:cols - 1], curses.color_pair(6)) + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main(): + try: + curses.wrapper(lambda stdscr: App(stdscr).run()) + except subprocess.CalledProcessError as e: + print(f"Error: hyprctl failed — is Hyprland running?\n{e}", file=sys.stderr) + sys.exit(1) + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/desktopenvs/hyprdrive/scripts/monitorhandler.sh b/desktopenvs/hyprdrive/scripts/monitorhandler.sh new file mode 100755 index 0000000..1366ece --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/monitorhandler.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +hyprpaper -c ~/.config/hypr/usr/wallpaper.conf diff --git a/desktopenvs/hyprdrive/scripts/onscreenkb.sh b/desktopenvs/hyprdrive/scripts/onscreenkb.sh new file mode 100755 index 0000000..65da43f --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/onscreenkb.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Toggle the wvkbd on-screen keyboard (Wayland virtual keyboard daemon). +# killall returns 0 if it killed something (keyboard was running → dismiss it). +# If killall fails (keyboard wasn't running), launch it with the cyberqueer theme. +if ! $(killall wvkbd-mobintl); then + # -L 250: height in pixels. --fn: font. --bg/fg/fg-sp: background/foreground/special-key colours. + # --press/--press-sp: key-press highlight colour. --text/--text-sp: label colours (hex, no #). + # -R 20: bottom margin so the keyboard doesn't overlap the Eww bar. + wvkbd-mobintl -L 250 --fn AgaveNerdFont --bg 1a1a1a --fg 5018dd --fg-sp 5018dd --press E40046 --press-sp E40046 --text d6abab --text-sp d6abab -R 20 +fi diff --git a/desktopenvs/hyprdrive/scripts/orbit-menu-start.sh b/desktopenvs/hyprdrive/scripts/orbit-menu-start.sh new file mode 100755 index 0000000..0abbb53 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/orbit-menu-start.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Resident launcher for the orbit-menu GTK4 radial power/utility menu. +# Same LD_PRELOAD requirement and rationale as astal-menu-start.sh: gtk4-layer-shell +# must load before libwayland-client, which isn't guaranteed under PyGObject. + +APP="${HOME}/.config/orbit-menu/main.py" +SO="$(ldconfig -p 2>/dev/null | awk '/libgtk4-layer-shell\.so/ {print $NF; exit}')" +if [[ -n "${SO:-}" ]]; then + export LD_PRELOAD="${SO}${LD_PRELOAD:+:${LD_PRELOAD}}" +fi + +exec python3 "$APP" "$@" diff --git a/desktopenvs/hyprdrive/scripts/orbit-menu.sh b/desktopenvs/hyprdrive/scripts/orbit-menu.sh new file mode 100755 index 0000000..b6660f1 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/orbit-menu.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Toggle the orbit-menu radial popup. Forwards a verb to the resident daemon over +# D-Bus; if the daemon isn't running yet, starts it first. Mirrors menu-toggle.sh. +# +# orbit-menu.sh power -> open straight into the Power ring (sleep/reboot/ +# poweroff/soft-reboot); re-press to close +# orbit-menu.sh menu -> open the 5-category root; re-press to close +# +# (No `set -e`: a non-zero `busctl` in the wait loop is expected and must not +# abort the script before it forwards the verb.) + +BUS="eu.abdelbaki.orbitmenu" +OBJ="/eu/abdelbaki/orbitmenu" +APP="${HOME}/.config/orbit-menu/main.py" + +case "${1:-menu}" in + power) VERB="--power"; ACTION="power" ;; + *) VERB="--menu"; ACTION="menu" ;; +esac + +registered() { busctl --user list 2>/dev/null | grep -q "$BUS"; } + +if registered; then + # Fast path: activate directly over D-Bus instead of cold-starting a second + # python3 + PyGObject + GTK4 process just to forward one verb. + exec gdbus call --session --dest "$BUS" --object-path "$OBJ" \ + --method org.gtk.Actions.Activate "$ACTION" "[]" "{}" >/dev/null +fi + +# Not registered yet: start the daemon and wait for it to come up, then retry. +"${HOME}/.config/scripts/orbit-menu-start.sh" >/dev/null 2>&1 & +for _ in $(seq 1 25); do + if registered; then + exec "$0" "$@" + fi + sleep 0.2 +done + +# Still not registered after 5s: fall back to becoming the primary instance +# ourselves (Gio single-instance handles the routing either way). +exec python3 "$APP" "$VERB" diff --git a/desktopenvs/hyprdrive/scripts/playerget b/desktopenvs/hyprdrive/scripts/playerget new file mode 100755 index 0000000..cf5a629 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/playerget @@ -0,0 +1,38 @@ +#!/bin/bash +# Separate truncation limits for song title vs artist name (song gets more space). +truncs=13 +trunca=10 +# playerctl queries the active MPRIS2 media player (Spotify, VLC, browser, etc.) +# and returns the metadata field using its go-template format. +song=$(playerctl metadata --format '{{ title }}') +artist=$(playerctl metadata --format '{{ artist }}') +#echo ${sample} + +# Truncate song title to truncs chars and append "…" if it exceeds the limit. +if [ ${#song} -gt $truncs ]; then + songt=$(echo $song | head -c $truncs | sed 's/$/…/') +else + if [ ${#song} -ne 0 ]; then + songt=$(echo ${song}) + else + # No media playing — use "None" so the Eww widget always has content. + songt=$(echo None) + fi +fi + +# Same truncation logic for the artist field with a shorter limit. +if [ ${#artist} -gt $trunca ]; then + artistt=$(echo $artist | head -c $trunca | sed 's/$/…/') +else + if [ ${#artist} -ne 0 ]; then + artistt=$(echo ${artist}) + else + artistt=$(echo None) + fi +fi +# Output as "song|artist" — the Eww widget splits on "|" to show them separately. +echo "${songt}|${artistt}" + + +#hyprctl activewindow | grep title: | awk -F: '{print $2}' | head -c $trunc | sed 's/$/.../' + diff --git a/desktopenvs/hyprdrive/scripts/playpause.sh b/desktopenvs/hyprdrive/scripts/playpause.sh new file mode 100755 index 0000000..24d8924 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/playpause.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Send play/pause to the first active player in the priority list via MPRIS2. +# -p specifies a comma-separated ordered list: playerctl tries each in turn +# and sends the command to the first one that is currently active/running. +playerctl play-pause -p spotify, vlc, firefox diff --git a/desktopenvs/hyprdrive/scripts/presence-detect.sh b/desktopenvs/hyprdrive/scripts/presence-detect.sh new file mode 100755 index 0000000..9a2fb65 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/presence-detect.sh @@ -0,0 +1,191 @@ +#!/bin/bash +# Webcam presence detection daemon. +# Checks for motion at an adaptive interval (20s normally, backed off to 120s +# under heavy system load) and shares caffeine's systemd-inhibit idle lock +# while the user is detected, so hypridle never fires during an active session. +# +# Reads the physical camera directly, but only briefly: each tick opens the +# device, grabs a handful of frames, and releases it, so the camera stays free +# for other apps (video calls, howdy) the rest of the time. If another app is +# already holding the camera when a tick fires, the grab fails — and that +# "busy" is itself proof the user is present (someone's on a call), so it counts +# as presence rather than an error. This replaced an earlier v4l2loopback-mirror +# design that held the camera open 24/7 and only served one reader at a time. +# +# Camera selection: set PRESENCE_DETECT_CAMERA env var or write +# CAMERA= to ~/.config/presence-detect.conf +# +# Exit codes from python helper: +# 0 = motion (present) 1 = no motion (away) +# 2 = camera unavailable -> skip the tick, leave inhibit state unchanged +# 3 = camera busy / in use -> treat as present (another app holds the camera) + +# Resolve the script's real directory so the Python helper path stays valid +# even when invoked via a symlink or from a different cwd. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHON_DETECT="$SCRIPT_DIR/python/presence_detect.py" +# Shared with caffeine.sh: both the manual toggle and this daemon drive the +# same systemd-inhibit lock, so caffeine-status.sh reflects either source. +PID_FILE="/tmp/caffeine-inhibit.pid" +# Marks that *this daemon* (not the manual caffeine toggle) currently holds +# the lock, so a "no motion" tick never releases a manually-started session. +OWNED_FLAG="/tmp/presence-inhibit-owned" +# Records whether the camera currently SEES the user (motion, or the camera +# being in use by another app, on the last tick). presence-status.sh reads this +# so the Eww widget can show presence as a distinct signal from the idle lock. +# It reflects live camera state only while the daemon is actively watching: +# during a MANUAL caffeine session the daemon stops reading the camera entirely +# (to leave it free) and clears this flag, so presence reads false there. +PRESENCE_FLAG="/tmp/presence-detected" +PRESENCE_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/presence-detect.conf" + +INTERVAL_FAST=2 # near-idle system (< LOAD_LIGHT): poll fast for responsiveness +INTERVAL_IDLE=20 # seconds between checks under normal load +INTERVAL_BUSY=120 # seconds between checks when CPU or RAM is >= LOAD_BUSY +LOAD_BUSY=0.70 # CPU-or-RAM fraction at/above which we back off to INTERVAL_BUSY +LOAD_LIGHT=0.30 # CPU-or-RAM fraction below which we speed up to INTERVAL_FAST +NPROC="$(nproc)" + +# Grace window: keep reporting "present" for this long after the last detected +# motion, so brief stillness (reading, thinking, a slow moment) never drops the +# lock the instant one tick sees nothing. Any motion tick resets the clock. +GRACE_SECONDS=90 + +# Resolve camera ID: env var takes highest priority, then config file, then default 0. +_camera_id() { + if [[ -n "$PRESENCE_DETECT_CAMERA" ]]; then + echo "$PRESENCE_DETECT_CAMERA" + elif [[ -f "$PRESENCE_CFG" ]]; then + # -oP 'CAMERA=\K[0-9]+': Perl-style look-behind strips "CAMERA=" prefix. + grep -oP 'CAMERA=\K[0-9]+' "$PRESENCE_CFG" 2>/dev/null || echo 0 + else + echo 0 + fi +} + +# True if any process OTHER than this daemon currently holds the real camera +# open — a video call, howdy, etc. `fuser` lists same-user holders without root +# (every camera app runs as us), and we only ever call this when we aren't +# reading the camera ourselves, so any holder is another app. If fuser isn't +# installed we return false: the tick then just tries the camera and the Python +# helper's busy/in-use path (exit 3) still covers an already-held device. +_camera_in_use() { + command -v fuser >/dev/null 2>&1 || return 1 + fuser -s "/dev/video${1}" 2>/dev/null +} + +# Prints current CPU-or-RAM usage as a fraction (0..1): the higher of the +# core-count-normalized 1-minute load average and the used-RAM fraction. Load +# average is a cheap, sampling-free proxy for "CPU busy" — no extra measurement +# delay per tick. +_resource_usage() { + local load1 cpu_frac mem_frac + load1="$(awk '{print $1}' /proc/loadavg)" + cpu_frac="$(awk -v l="$load1" -v n="$NPROC" 'BEGIN{print l/n}')" + mem_frac="$(free | awk '/^Mem:/ {print $3/$2}')" + awk -v c="$cpu_frac" -v m="$mem_frac" 'BEGIN{print (c>m)?c:m}' +} + +# Three-tier poll interval based on current resource usage: +# >= LOAD_BUSY -> INTERVAL_BUSY (heavy load: back off, stay out of the way) +# < LOAD_LIGHT -> INTERVAL_FAST (near-idle: poll fast, cheap and responsive) +# otherwise -> INTERVAL_IDLE (normal load) +_next_interval() { + local u; u="$(_resource_usage)" + awk -v u="$u" -v busy="$LOAD_BUSY" -v light="$LOAD_LIGHT" \ + -v fb="$INTERVAL_BUSY" -v ff="$INTERVAL_FAST" -v fi="$INTERVAL_IDLE" \ + 'BEGIN{ if (u >= busy) print fb; else if (u < light) print ff; else print fi }' +} + +# Returns true if the inhibitor sentinel process is still alive. +_inhibit_running() { + [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null +} + +_start_inhibit() { + # Guard: don't start a second inhibitor if one is already active — whether + # it's ours from a previous tick or a manually-started caffeine session. + _inhibit_running && return + # --what=idle: target the logind idle-inhibit lock that hypridle polls. + # "sleep infinity" is the sentinel; its PID is saved so we can kill it later. + systemd-inhibit --what=idle --who="presence-detect" \ + --why="User presence detected" --mode=block \ + sleep infinity & + echo $! > "$PID_FILE" + touch "$OWNED_FLAG" + # logger writes to the system journal — visible via `journalctl -t presence-detect`. + logger -t presence-detect "Motion detected — idle inhibited" +} + +_stop_inhibit() { + _inhibit_running || return + # Never release a lock we didn't start — that would be a manual caffeine + # session, which must persist regardless of presence. + [[ -f "$OWNED_FLAG" ]] || return + # Killing the sleep process releases the systemd-inhibit lock automatically. + kill "$(cat "$PID_FILE")" 2>/dev/null + rm -f "$PID_FILE" "$OWNED_FLAG" + logger -t presence-detect "No motion — idle inhibit released" +} + +_cleanup() { + # On daemon stop (systemd unit stop, user logout, etc.), release the idle + # lock (only if we're the one holding it) and clear the presence flag — + # with the daemon gone, nothing is watching. + _stop_inhibit + rm -f "$PRESENCE_FLAG" + exit 0 +} +# Intercept termination signals to ensure the inhibitor PID is never orphaned. +trap _cleanup SIGTERM SIGINT SIGHUP + +# Epoch seconds of the last tick that saw motion; drives the GRACE_SECONDS +# window below. 0 = never seen yet, so a no-motion tick at startup releases +# immediately (nothing is held anyway). +LAST_MOTION=0 + +while true; do + # When idle is inhibited MANUALLY (a caffeine toggle, not this daemon), the + # lock is already held regardless of presence, so there's no reason to look + # at the camera — skip the read entirely and leave the device free for other + # apps. Detected the same way caffeine-manual-status.sh does it: the shared + # lock is alive but we don't own it. We clear PRESENCE_FLAG because we're no + # longer watching and must not report a stale "camera sees you". + if _inhibit_running && [[ ! -f "$OWNED_FLAG" ]]; then + rm -f "$PRESENCE_FLAG" + sleep "$(_next_interval)" + continue + fi + + DEVICE="$(_camera_id)" + now="$(date +%s)" + + # Back-off: if another app already holds the camera (a video call, howdy, + # ...), don't even try to read it — attempting would be pointless (we'd just + # get EBUSY) and a simultaneous open could make the app itself fail. A camera + # in use is proof you're present, so mark presence and wait it out instead. + if _camera_in_use "$DEVICE"; then + LAST_MOTION="$now"; touch "$PRESENCE_FLAG"; _start_inhibit + sleep "$(_next_interval)" + continue + fi + + # Run the OpenCV motion detector; stderr suppressed to keep the journal clean. + python3 "$PYTHON_DETECT" "$DEVICE" 2>/dev/null + rc=$? + case $rc in + # Motion (0) or camera busy/in use (3): user is present. rc=3 means + # another app (a video call, howdy) is holding the camera, which is + # itself proof you're here, so it keeps the session awake even though we + # can't read frames. Both refresh the grace clock and mark presence. + 0|3) LAST_MOTION="$now"; touch "$PRESENCE_FLAG"; _start_inhibit ;; + # No motion: only actually release once we've been still for the whole + # grace window. Within it, leave the flag/lock exactly as they were so a + # brief pause in movement doesn't flicker presence off. + 1) if (( now - LAST_MOTION >= GRACE_SECONDS )); then + rm -f "$PRESENCE_FLAG"; _stop_inhibit + fi ;; + # rc=2 = camera unavailable (unplugged/gone) — silently skip, state unchanged + esac + sleep "$(_next_interval)" +done diff --git a/desktopenvs/hyprdrive/scripts/presence-status.sh b/desktopenvs/hyprdrive/scripts/presence-status.sh new file mode 100755 index 0000000..dcab9c2 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/presence-status.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Reports the PRESENCE input for the Eww caffeine widget: whether the webcam +# presence-detect daemon currently sees the user (motion on its last tick), +# INDEPENDENT of whether the idle lock is held or who holds it. +# +# presence-detect.sh writes PRESENCE_FLAG on every "present" tick and removes it +# on every "away" tick, so this is a clean, standalone signal. Note: during a +# MANUAL caffeine session the daemon stops reading the camera (to leave it free) +# and clears the flag, so presence reads false there. For the MANUAL-inhibit +# input, see caffeine-manual-status.sh. +# +# Prints "true" when presence is detected, else "false". +PRESENCE_FLAG="/tmp/presence-detected" + +if [[ -f "$PRESENCE_FLAG" ]]; then + echo "true" +else + echo "false" +fi diff --git a/desktopenvs/hyprdrive/scripts/python/boilerplate.py b/desktopenvs/hyprdrive/scripts/python/boilerplate.py new file mode 100644 index 0000000..923f6a1 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/python/boilerplate.py @@ -0,0 +1,7 @@ +import sys + +def main(): + + +if __name__=="__main__": + main() diff --git a/desktopenvs/hyprdrive/scripts/python/ispeedtest.py b/desktopenvs/hyprdrive/scripts/python/ispeedtest.py new file mode 100644 index 0000000..37e5eba --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/python/ispeedtest.py @@ -0,0 +1,18 @@ +import sys +import speedtest + +def main(): + s = speedtest.Speedtest(secure=True) + + if (sys.argv[1] == 'd') or (sys.argv[1] == 'b'): + gbdl = round( s.download() / (1024*1024) , 2) + print("󰛴", gbdl, "Mb/S") + + if (sys.argv[1] == 'u') or (sys.argv[1] == 'b'): + gbul = round( s.upload() / (1024*1024) , 2) + print("󰛶", gbul, "Mb/S") + +if __name__=="__main__": + main() + + diff --git a/desktopenvs/hyprdrive/scripts/python/presence_detect.py b/desktopenvs/hyprdrive/scripts/python/presence_detect.py new file mode 100755 index 0000000..a9f0214 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/python/presence_detect.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +""" +Webcam motion presence detector using frame differencing. +Exit codes: + 0 = motion detected (user present) + 1 = no motion (user likely away) + 2 = camera unavailable (device node missing / gone) -> caller skips tick + 3 = camera busy / in use (another app holds it) -> treat as present +Usage: presence_detect.py [camera_id] + +Reads the physical camera directly, only for the ~0.5s it takes to grab a few +frames, then releases it — so a video call, howdy, etc. can use the camera the +rest of the time. When one of those apps IS holding the camera, we can't read +it; rather than mistake that for "no motion", we report it as busy/in-use (3), +which the daemon treats as presence: a camera in use means the user is here. +""" +import os +import sys +import time +import cv2 +import numpy as np + +FRAMES_TO_CHECK = 8 +DIFFS_NEEDED = 2 # require motion in at least N consecutive-frame diffs +PIXEL_DELTA_THRESHOLD = 18 # per-pixel grayscale delta to count as "changed" +MOTION_AREA_RATIO = 0.008 # fraction of pixels that must change to call it motion +BLUR_KSIZE = (21, 21) # Gaussian blur kernel to suppress sensor noise +# Spacing between the frames we diff. Without it the frames are grabbed +# back-to-back within a few milliseconds, so slow, small movements barely differ +# between adjacent frames. Spacing the grabs out gives slow motion a real +# temporal baseline and guarantees each compared frame is a fresh one. +INTER_FRAME_DELAY = 0.06 # seconds between grabs (~0.5s total observation window) + + +def detect(camera_id: int) -> int: + # A missing device node means the camera is genuinely gone (unplugged, module + # not loaded) -> "unavailable" (2), and the daemon leaves presence untouched. + # A node that exists but won't open (below) or won't yield frames (further + # down) means something else is streaming it -> "busy/in use" (3) = present. + if not os.path.exists(f"/dev/video{camera_id}"): + return 2 + + cap = cv2.VideoCapture(camera_id) + if not cap.isOpened(): + # Node exists but we can't open it: another app owns the camera. + return 3 + + motion_diffs = 0 + frames_read = 0 + prev_gray = None + try: + for i in range(FRAMES_TO_CHECK): + if i > 0: + time.sleep(INTER_FRAME_DELAY) + ok, frame = cap.read() + if not ok: + continue + frames_read += 1 + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + gray = cv2.GaussianBlur(gray, BLUR_KSIZE, 0) + + if prev_gray is not None: + delta = cv2.absdiff(prev_gray, gray) + changed = np.count_nonzero(delta > PIXEL_DELTA_THRESHOLD) + if changed / delta.size >= MOTION_AREA_RATIO: + motion_diffs += 1 + + prev_gray = gray + finally: + cap.release() + + # Opened but produced no frames at all: on V4L2 a second opener can succeed + # yet have its reads starved while another app streams the device. Treat that + # as busy/in-use (present) rather than "no motion", so an active video call + # keeps the session awake even though we never see a usable frame. + if frames_read == 0: + return 3 + + return 0 if motion_diffs >= DIFFS_NEEDED else 1 + + +if __name__ == "__main__": + camera_id = int(sys.argv[1]) if len(sys.argv) > 1 else 0 + sys.exit(detect(camera_id)) diff --git a/desktopenvs/hyprdrive/scripts/screenrec.sh b/desktopenvs/hyprdrive/scripts/screenrec.sh new file mode 100755 index 0000000..9f63495 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/screenrec.sh @@ -0,0 +1,25 @@ +#!/bin/bash + + +endrec () { + killall -s SIGINT wf-recorder + dunstctl close $nid + notify-send "recording ended - output to $outfile" +} + +mkdir -p ~/Videos +statecon=$( pidof wf-recorder ) + + +outfile="$HOME/Videos/$(date +'%Y%m%d%H%M%S').mp4" +#$outfile +nid="" +if [ "$statecon" == '' ]; then + wf-recorder -g "$(slurp)" -f $outfile & + $nid=$(notify-send -p -u critical -t 0 "recording started" --action="goto endrecplace" +) +else + endrec +fi + +pidof wf-recorder && endrec diff --git a/desktopenvs/hyprdrive/scripts/screenrotationacw.sh b/desktopenvs/hyprdrive/scripts/screenrotationacw.sh new file mode 100755 index 0000000..0791ee2 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/screenrotationacw.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +~/.config/scripts/unified-rotate.sh cw \ No newline at end of file diff --git a/desktopenvs/hyprdrive/scripts/screenrotationwcw.sh b/desktopenvs/hyprdrive/scripts/screenrotationwcw.sh new file mode 100755 index 0000000..7737194 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/screenrotationwcw.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +~/.config/scripts/unified-rotate.sh ccw \ No newline at end of file diff --git a/desktopenvs/hyprdrive/scripts/screenshot.sh b/desktopenvs/hyprdrive/scripts/screenshot.sh new file mode 100755 index 0000000..c246361 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/screenshot.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +grim -g "$(slurp -d)" - | wl-copy diff --git a/desktopenvs/hyprdrive/scripts/station-bar-start.sh b/desktopenvs/hyprdrive/scripts/station-bar-start.sh new file mode 100755 index 0000000..be26556 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/station-bar-start.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Resident launcher for the station-bar GTK4 top status bar. +# Same LD_PRELOAD requirement and rationale as orbit-menu-start.sh/horizon- +# dock-start.sh/astro-menu-start.sh: gtk4-layer-shell must load before +# libwayland-client, which isn't guaranteed under PyGObject. + +APP="${HOME}/.config/station-bar/main.py" +SO="$(ldconfig -p 2>/dev/null | awk '/libgtk4-layer-shell\.so/ {print $NF; exit}')" +if [[ -n "${SO:-}" ]]; then + export LD_PRELOAD="${SO}${LD_PRELOAD:+:${LD_PRELOAD}}" +fi + +exec python3 "$APP" "$@" diff --git a/desktopenvs/hyprdrive/scripts/station-bar.sh b/desktopenvs/hyprdrive/scripts/station-bar.sh new file mode 100755 index 0000000..2f985c3 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/station-bar.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Toggle station-bar (the EWW top bar's replacement). Forwards a verb to the +# resident daemon over D-Bus; if the daemon isn't running yet, starts it +# first. Mirrors horizon-dock.sh/orbit-menu.sh's pattern. +# +# station-bar.sh -> --toggle (default) +# station-bar.sh show -> --show +# station-bar.sh hide -> --hide +# +# (No `set -e`: a non-zero `busctl` in the wait loop is expected and must not +# abort the script before it forwards the verb.) + +BUS="eu.abdelbaki.stationbar" +OBJ="/eu/abdelbaki/stationbar" +APP="${HOME}/.config/station-bar/main.py" + +case "${1:-toggle}" in + show) VERB="--show"; ACTION="show" ;; + hide) VERB="--hide"; ACTION="hide" ;; + *) VERB="--toggle"; ACTION="toggle" ;; +esac + +registered() { busctl --user list 2>/dev/null | grep -q "$BUS"; } + +if registered; then + exec gdbus call --session --dest "$BUS" --object-path "$OBJ" \ + --method org.gtk.Actions.Activate "$ACTION" "[]" "{}" >/dev/null +fi + +"${HOME}/.config/scripts/station-bar-start.sh" >/dev/null 2>&1 & +for _ in $(seq 1 25); do + if registered; then + exec "$0" "$@" + fi + sleep 0.2 +done + +exec python3 "$APP" "$VERB" diff --git a/desktopenvs/hyprdrive/scripts/time.sh b/desktopenvs/hyprdrive/scripts/time.sh new file mode 100755 index 0000000..e8e8095 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/time.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo " $(date +"%H:%M:%S") " diff --git a/desktopenvs/hyprdrive/scripts/timer-notify.sh.old b/desktopenvs/hyprdrive/scripts/timer-notify.sh.old new file mode 100755 index 0000000..756bb07 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/timer-notify.sh.old @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Background timer: sleeps, notifies, rings thrice in 30s unless dismissed +# Usage: timer-notify.sh