feat(hyprdrive): add hyprdrive DE with full Cosmonaut Shell suite

New leaner Hyprland DE (setup/modules/Desktop-Environments/hyprdrive.sh),
a sibling of hyprlua that drops the older EWW/nwg-shell/wofi/walker/
ulauncher stack in favor of a purpose-built, GTK4 layer-shell component
suite sharing one CyberQueer hologram visual language:

- orbit-menu    radial power/utility menu (focal-point zoom nav, holographic
                overlay, cosmetic satellites)
- horizon-dock  hover-scrollable orbital app dock (open windows/favorites/
                all apps orbits, SNI tray satellite)
- astro-menu    astal-menu re-themed as a hologram info display (Location/
                Weather/Bluetooth/Network quads, taskbar, app drawer)
- station-bar   the EWW top bar's replacement ("Voidstation Status Bar"):
                a thin curved layer-shell bar tracking Hyprland workspaces/
                active window natively over .socket2.sock, no polling

Every component now shares one consistent hologram treatment: translucent
violet panel/node fills (replacing solid gray), a config-toggleable
scanline+sweep+noise overlay (lib/hologram.py, ported to horizon-dock and
station-bar to match orbit-menu/astro-menu), and glow-on-hover throughout.

Also adds horizon-dock to hyprlua (built there first, before hyprdrive
existed) and wires dependency checks, config-updater entries, and
apply-theme.sh color sync for every new component across both DEs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nu8JkQ95hPAT8vADeGDAmL
main
Amir Alexander Abdelbaki 2026-07-17 15:28:07 +02:00
parent 4fbffee5a0
commit 90375a5733
254 changed files with 25696 additions and 2 deletions

View File

@ -42,6 +42,11 @@ USER_FILES=(
"desktopenvs/hyprland/nwg-panel/menu-start.css|$HOME/.config/nwg-panel/menu-start.css" "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/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/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/vicinae/cyberqueer.toml|$HOME/.config/vicinae/cyberqueer.toml"
"desktopenvs/hyprland/scripts/onscreenkb.sh|$HOME/.config/scripts/onscreenkb.sh" "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" "desktopenvs/hyprland/spicetify/Themes/cli-cyberqueer/color.ini|$HOME/.config/spicetify/Themes/cli-cyberqueer/color.ini"

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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;
} */

View File

@ -0,0 +1,6 @@
{
"tabWidth": 4,
"singleQuote": true,
"useTabs": true,
"printWidth": 200
}

View File

@ -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.

View File

@ -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 <https://discord.gg/nz87hXyvcy>
## 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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

View File

@ -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')
}

View File

@ -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
1 Selector Time (ms) Matches
2 [class^='chatContent_']:has([class^='messagesWrapper_']:hover) 0.256000 0
3 [class^='chatContent_']:has([class^='typing_']) > [class^='messagesWrapper_'] 0.252800 0
4 [class^=chat_] > [class*=container_] 0.191900 0
5 [class*=embedFull_] 0.191100 1
6 [class*=lookFilled_][class*=colorPrimary_] 0.187400 0
7 #app-mount [class*=lookLink_][class*=colorPrimary_] 0.186500 0
8 #app-mount [class*=controlIcon_] 0.180000 0
9 [class^=container_] > [class^=wrapper_] 0.134800 1
10 [class^=container_] > [class^=wrapper_] 0.133600 1
11 [class^=container_] > [class^=wrapper_] 0.132400 1
12 [class^=message_][class*=replying_] 0.132300 0
13 [class^=embedVideoActions_] > [class^=centerContent_] > [class^=wrapper_] 0.131900 1
14 [class^=panels_] > [class^=wrapper_] > [class^=container] 0.131600 0
15 [class^=sidebar_] > [class^=container_] 0.131200 1
16 [class^=container_] > [class^=wrapper_] 0.131200 1
17 [class^='form_'] > [class^='wrapper_'] 0.131000 0
18 [class^=chat_] > [class^=content_] > [class^=container_] 0.130700 1
19 [class^=message_][class*=mentioned_] 0.130200 0
20 [class^=container_] > [class^=wrapper_] 0.130000 1
21 [class^=panels_] > [class^=container_] 0.129600 1
22 #app-mount [class^=sidebar_] > [class^=container_] 0.128500 1
23 [class^='sidebar_'] > [class^='container_'] 0.128500 1
24 [class^=member_] 0.126500 28
25 [class^=panels_] 0.126500 1
26 [class^='membersWrap_'] 0.126400 1
27 [class^=navButton_][class*=navButtonActive_] 0.126000 0
28 [class^='sidebar_'] > [class^='container_'] 0.125800 1
29 [class^='dot_'][class*='maskSecondary_'] 0.125800 0
30 [class^='membersWrap_'] 0.125600 1
31 [class^=panels_] 0.125600 1
32 [class^=sidebar_] 0.125500 1
33 [class^=centerContent_] > [class^=wrapper_] > [class^=anchor_] 0.125300 1
34 [class^=privateChannels_] > [class^=scroller_] 0.125200 0
35 [class^=childWrapper_] 0.125200 9
36 #app-mount [class^=partyStatus_] [class^=anchor_] 0.125200 0
37 [class^=iconBadge_]:not([class*=isCurrentUserConnected_]) 0.125200 1
38 [class^='toolbar_'] > [class^='search_'] 0.125100 1
39 [class^=container_] > [class^=base_] 0.125100 1
40 [class^='panels_'] 0.125000 1
41 [class^='status_'][style='background-color: rgb(242, 63, 67);'] 0.124900 0
42 [class^=chatContent_] > [class^=messagesWrapper_] 0.124800 1
43 [class^='form_'] [class^='channelTextArea_'] 0.124700 1
44 #app-mount [class^=sidebar_] 0.124700 1
45 [class^=members_] 0.124500 1
46 [class*=bannerVisible_] [class^=headerContent_] 0.124500 0
47 [class^=form_] [class^=channelTextArea_] 0.124500 1
48 [class^='typing_'] 0.124500 0
49 [class^='upperBadge_'] 0.124500 1
50 [class^=tabBody_] 0.124400 0
51 [class^=resizeHandle_] 0.124200 0
52 [class^='userPanelInner_'] 0.124200 0
53 [class^=attachedBars_] 0.124100 0
54 [class^=container_] > [class^=base_] 0.124100 1
55 [class^=gifFavoriteButton_] 0.124000 0
56 [class^=membersWrap_] 0.124000 1
57 [class^=panels_] > [class^=panel_] 0.124000 0
58 [class^='searchResultsWrap_'] 0.124000 0
59 [class^=profilePanel_] > [class^=userPanelOuter_] 0.123900 0
60 [class^=chat_] 0.123900 1
61 [class^=sidebar_] 0.123900 1
62 [class^='form_'] [class^='channelTextArea_'] 0.123900 1
63 [class^=panels_] > [class^=panel_] 0.123800 0
64 [class^='membersWrap_'] 0.123800 1
65 [class^='nowPlayingColumn_'] 0.123800 0
66 [class^=replyBar_] 0.123700 0
67 [class^=iconWrapper_] 0.123700 7
68 [class^='status_'][style='background-color: rgb(35, 165, 90);'] 0.123700 0
69 [class^=searchResultsWrap_] 0.123700 0
70 [class^='unreadPill_'][class*='endCap_'] 0.123600 1
71 [class^=overlayBottom_] > [class^=text-] 0.123600 0
72 [class^=privateChannels_] 0.123600 0
73 [class^=categoryText_] 0.123500 0
74 [class^=profilePanel_] 0.123500 0
75 [class^='searchResultsWrap_'] 0.123400 0
76 [class^='membersWrap_'] 0.123400 1
77 [class^='peopleListItem_'] 0.123400 0
78 [class^='status_'][style='background-color: rgb(240, 178, 50);'] 0.123300 0
79 [class^=tabBody_] 0.123200 0
80 [class^=privateChannels_] 0.123200 0
81 [class^='status_'][style='background-color: rgb(128, 132, 142);'] 0.123200 0
82 [class^='chatContent_'] > [class^='messagesWrapper_'] 0.123100 1
83 [class^='profilePanel_'] 0.123100 0
84 [class^=videoControls_] 0.123000 0
85 [class^=sidebar_] 0.123000 1
86 [class^=searchAnswer_] 0.122800 0
87 [class^='profilePanel_'] 0.122800 0
88 [class^='peopleColumn_'] 0.122800 0
89 [class^='nowPlayingColumn_'] 0.122800 0
90 [class^='sectionDivider_'] 0.122800 1
91 [class^=notAppAsidePanel_] > [class^=layerContainer_] 0.122700 2
92 [class^='privateChannels_'] 0.122700 0
93 [class^=privateChannels_] 0.122500 0
94 [class^=container_] > [class^=base_] 0.122500 1
95 [class^='lowerBadge_'] 0.122400 0
96 [class^=searchResultsWrap_] 0.122400 0
97 [class^=threadSuggestionBar_] 0.122200 0
98 [class^='chatContent_'] > [class^='messagesWrapper_'] 0.122200 1
99 [class^=searchResultsWrap_] 0.122200 0
100 [class^=peopleColumn_] 0.122200 0
101 [class^='nowPlayingColumn_'] 0.122100 0
102 [class^=searchFilter_] 0.122000 0
103 [class^=privateChannels_] 0.122000 0
104 [class^=nowPlayingColumn_] 0.122000 0
105 [class^='folderIconWrapper_'][style='background-color: rgba(88, 101, 242, 0.4);'] 0.121900 6
106 [class^='container_'] > [class^='base_'] 0.121900 1
107 [class^='panels_'] 0.121800 1
108 [class^=friendRequestsButton_] [class^=numberBadge_] 0.121600 0
109 [class^=peopleColumn_] 0.121400 0
110 #app-mount [class^=partyStatus_] 0.121300 0
111 [class^='form_'] [class^='channelTextArea_'] 0.121100 1
112 [class^='privateChannels_'] 0.121100 0
113 [class^='peopleColumn_'] 0.120900 0
114 #app-mount [class^='divider_'] 0.120100 3
115 .wrapper_c51b4e:has(rect[fill='#593695']) 0.109200 0
116 .wrapper_c51b4e:has(rect[fill='#80848e']) 0.107600 0
117 .wrapper_c51b4e:has(rect[fill='#23a55a']) 0.096000 18
118 .wrapper_c51b4e:has(rect[fill='#f0b232']) 0.095800 4
119 .wrapper_c51b4e:has(rect[fill='#f23f43']) 0.089600 8
120 .wrapper_c51b4e:has(rect) 0.087900 30
121 [style='opacity: 1; background-color: rgb(35, 165, 90);'] 0.074800 0
122 svg[style='color: rgb(88, 101, 242);'] 0.062200 8
123 #app-mount button path[fill='var(--white-500)'] 0.061600 0
124 [role='button'] path[fill='var(--white-500)'] 0.060400 0
125 [class^='mask_'] > svg 0.060400 2
126 [class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask 0.058600 48
127 [class^='bannerSVGWrapper_'] > mask > circle 0.056000 1
128 svg[class^=slider_] > rect[fill='white'] 0.055400 0
129 [class^='mask_'] > rect 0.055400 30
130 :root 0.055200 1
131 [class^='mask_'] > foreignObject 0.055100 32
132 [class^='svg_'] > foreignObject 0.054900 70
133 [class^='mask_'] > circle 0.054300 1
134 :root 0.053800 1
135 :root 0.052300 1
136 [class^=jumpToPresentBar_] > button 0.051700 0
137 aside[class^='profilePanel_'] 0.051000 0
138 .custom-profile-theme [class*=color_][class*=text_] 0.049900 0
139 .horizontal_bba380 > .flex_bba380 0.044800 0
140 .container_fb4810 0.044300 0
141 .container_a4d4d9 0.043900 1
142 .clipContainer_b11c5e 0.043900 0
143 .content_a7d72e 0.043600 1
144 .container_fc4f04 0.043600 1
145 .scroller_e2e187 0.043400 1
146 html.theme-light 0.043400 0
147 .platform-osx [class^=container_] > [class^=wrapper_] 0.043300 0
148 .container_fc4f04 0.043300 1
149 .container_fc4f04 0.043300 1
150 .container_adcaac 0.043200 0
151 .attachButton_f298d4 0.043200 1
152 .wrapper_fea3ef 0.043200 1
153 .container_d1c246 0.043200 0
154 .container_e85446:has(div[style='background-color: rgb(35, 165, 90);']) 0.043100 0
155 .textArea_d0696b 0.043000 2
156 .chat_a7d72e 0.043000 1
157 .wordmarkWindows_a934d8 > svg 0.043000 1
158 .vc-spotify-time-right 0.042900 1
159 .theme-dark 0.042900 3
160 .container_c75f85 0.042800 0
161 .form_a7d72e 0.042800 1
162 .container_a6d69a 0.042800 0
163 .vc-spotify-time-left 0.042800 1
164 .form_a7d72e 0.042700 1
165 .container_b2ca13 0.042700 1
166 .container_a6d69a 0.042700 0
167 .container_c2739c 0.042700 0
168 .tabBody_c2739c 0.042700 0
169 .vc-spotify-time-right 0.042700 1
170 .container_e85446 > [class^='svg_'] > foreignObject > [class^='status_'] 0.042600 0
171 .container_d1c246 0.042600 0
172 .panels_a4d4d9 0.042600 1
173 .container_adcaac 0.042600 0
174 .container_c2739c 0.042500 0
175 .scroller_fb4810 0.042500 0
176 .form_a7d72e 0.042400 1
177 .newMessagesBar_cf58b5 0.042400 1
178 .animatedContainer_fd6364 0.042400 1
179 .vc-spotify-time-left 0.042400 1
180 .scroller_fb4810 0.042300 0
181 .wordmarkWindows_a934d8 0.042300 1
182 .container_e85446:has(div[style='background-color: rgb(35, 165, 90);']) 0.042300 0
183 .grabber_c7a159 0.042200 1
184 .container_a6d69a 0.042100 0
185 .scroller_fb4810 0.042100 0
186 .scroller_fb4810 0.042100 0
187 .container_fc4f04 0.041600 1
188 * 0.036500 6407
189 #vc-spotify-progress-bar [class^='bar_'] 0.000300 1
190 #app-mount #vc-spotify-player 0.000200 1
191 #vc-spotify-player 0.000200 1
192 #vc-spotify-player 0.000200 1
193 #vc-spotify-progress-bar 0.000200 1
194 ::-webkit-scrollbar-thumb 0.000200 0
195 #vc-spotify-player 0.000100 1

View File

@ -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,
1 Selector Time (ms) Matches Total
2 [class*=buttonColor_] 0.4697 0 38.4736
3 [class*=embedFull_] 0.446 2
4 [class^='chatContent_']:has([class^='typing_']:hover) 0.4342 0
5 #app-mount [class*=lookLink_][class*=colorPrimary_] 0.4331 0
6 [class*=lookFilled_][class*=colorPrimary_] 0.4315 0
7 [class^='chatContent_']:has([class^='messagesWrapper_']:hover) 0.4189 0
8 [class^='chatContent_']:has([class^='typing_']) > [class^='messagesWrapper_'] > [class^='jumpToPresentBar_'] 0.4146 0
9 #app-mount [class*=controlIcon_] 0.3911 0
10 [class^=searchFilter_] 0.3541 0
11 [class^='newBadge_'] 0.3385 0
12 [class^=friendRequestsButton_] [class^=numberBadge_] 0.3314 0
13 [class^=chat_] > [class*=container_] 0.32 0
14 [class^=videoControls_] 0.3099 0
15 [class*=backgroundAccent_] 0.3097 0
16 [class^=searchAnswer_] 0.3096 0
17 #app-mount [class^=partyStatus_] 0.3063 0
18 [class^='newChannel_'] 0.2995 0
19 [class^=iconBadge_]:not([class*=isCurrentUserConnected_]) 0.2892 0
20 #app-mount [class^=partyStatus_] [class^=anchor_] 0.2834 0
21 [class^=childWrapper_] 0.2817 15
22 [class^=overlayBottom_] > [class^=text-] 0.2786 0
23 [class^=gifFavoriteButton_] 0.2739 0
24 [class^='form_'] [class^='channelTextArea_'] 0.2703 1
25 [class^='panels_'] 0.2612 1
26 [class^=panels_] 0.2599 1
27 [class^=sidebar_] 0.2585 1
28 [class^=button_][class*=bannerColor_] 0.2576 0
29 [class^='content_']>[class^='outer_'] 0.2546 0
30 [class^=channelTextArea] > [class^=scrollableContainer] 0.2527 1
31 [class^='content_']>[class^='outer_'] 0.2511 0
32 [class^=sidebar_] > [class^=container_] 0.2439 1
33 [class^='channelBottomBarArea_'] 0.2423 1
34 [class^=panels_] > [class^=wrapper_] > [class^=container] 0.2386 0
35 [class^='form_'] > [class^='wrapper_'] 0.2365 0
36 [class^=navButton_][class*=navButtonActive_] 0.2356 0
37 [class^=sidebar_] 0.2315 1
38 [class^=membersWrap_] 0.2308 1
39 [class^=embedVideoActions_] > [class^=centerContent_] > [class^=wrapper_] 0.2273 0
40 [class^=categoryText_] 0.2255 0
41 [class^='form_'] > [class^='wrapper_'] 0.2233 0
42 [class^=sidebar_] 0.2232 1
43 [class^=members_] 0.2213 1
44 [class^='folderIconWrapper_'][style='background-color: rgba(88, 101, 242, 0.4);'] 0.2209 0
45 [class^=peopleColumn_] 0.2206 0
46 [class^=nowPlayingColumn_] 0.2206 0
47 [class^=message_][class*=replying_] 0.2181 0
48 [class^=message_][class*=mentioned_] 0.2175 5
49 [class^=bottomControls_] 0.2173 0
50 [class*=bannerVisible_] [class^=headerContent_] 0.2168 0
51 [class^=container_] > [class^=base_] 0.2148 1
52 [class^=tabBody_] 0.2147 0
53 [class^=privateChannels_] 0.2141 0
54 [class^='sidebar_'] > [class^='container_'] 0.2129 1
55 [class^='dot_'][class*='maskSecondary_'] 0.2125 0
56 [class^='status_'][style='background-color: rgb(35, 165, 90);'] 0.2124 0
57 [class^=privateChannels_] 0.2119 0
58 [class^='sidebar_'] > [class^='container_'] 0.2112 1
59 [class^='content_']>[class^='outer_'] 0.2105 0
60 [class^=channelBottomBarArea_] 0.2103 1
61 [class^='form_'] [class^='channelTextArea_'] 0.2101 1
62 [class^=privateChannels_] 0.21 0
63 [class^='channelAppLauncher_'] 0.2099 1
64 [class^=gradientBottom_] 0.2099 0
65 [class^=centerContent_] > [class^=wrapper_] > [class^=anchor_] 0.2099 0
66 [class^='form_'] > [class^='typing_'] 0.2095 0
67 [class^='content_']>[class^='outer_']>[class^='inner_'] 0.2094 0
68 #app-mount [class^=sidebar_] > [class^=container_] 0.2093 1
69 [class^='nowPlayingColumn_'] 0.209 0
70 [class^='status_'][style='background-color: rgb(128, 132, 142);'] 0.2087 0
71 [class^=peopleColumn_] 0.2082 0
72 [class^='nowPlayingColumn_'] 0.2079 0
73 [class^=session_] 0.2078 0
74 [class^='container_'] > [class^='base_'] 0.2076 1
75 [class^=searchResultsWrap_] 0.2076 0
76 [class^=panels_] > [class^=panel_] 0.2076 0
77 [class^='membersWrap_'] 0.2076 1
78 [class^=searchResultsWrap_] 0.2073 0
79 [class^=guildSeparator_] 0.2072 2
80 [class^=tableCell_] 0.2072 0
81 [class^=chat_] > [class^=content_] > [class^=container_] 0.2071 1
82 [class^='membersWrap_'] 0.207 1
83 [class^='sectionDivider_'] 0.2069 0
84 [class^='privateChannels_'] 0.2067 0
85 [class^='peopleColumn_'] 0.2065 0
86 [class^=attachedBars_] 0.2061 0
87 [class^=searchResultsWrap_] 0.206 0
88 [class^='callContainer_'] 0.2055 0
89 [class^='peopleColumn_'] 0.2051 0
90 [class^='notAppAsidePanel_'] > [class^='layerContainer_'] 0.2049 2
91 [class^='unreadPill_'][class*='endCap_'] 0.2048 0
92 [class^='panels_'] 0.2047 1
93 #app-mount [class^=sidebar_] 0.2045 1
94 [class^=chat_] 0.2044 1
95 [class^=panels_] 0.2044 1
96 [class^=panels_] > [class^=container_] 0.2042 1
97 [class^='chatContent_'] > [class^='messagesWrapper_'] 0.2042 1
98 [class^='nowPlayingColumn_'] 0.2041 0
99 [class^='membersWrap_'] 0.2041 1
100 [class^=container_] > [class^=base_] 0.2039 1
101 [class^='toolbar_'] > [class^='search_'] 0.2034 1
102 [class^='status_'][style='background-color: rgb(240, 178, 50);'] 0.2034 0
103 [class^=tabBody_] 0.2031 0
104 [class^='lowerBadge_'] 0.2031 0
105 [class^=privateChannels_] > [class^=scroller_] 0.2029 0
106 [class^='searchResultsWrap_'] 0.2028 0
107 [class^=customizationSection_] 0.2028 0
108 [class^=chatContent_] > [class^=messagesWrapper_] 0.2027 1
109 [class^='upperBadge_'] 0.2027 0
110 [class^='status_'][style='background-color: rgb(242, 63, 67);'] 0.2025 0
111 [class^=threadSuggestionBar_] 0.2021 0
112 [class^=panels_] > [class^=panel_] 0.202 0
113 [class^='searchResultsWrap_'] 0.202 0
114 [class^=columnHeading_] 0.2016 0
115 #app-mount [class^='divider_'] 0.2016 2
116 [class^=callContainer_] 0.2014 0
117 [class^='chatContent_'] > [class^='messagesWrapper_'] 0.2011 1
118 [class^='channelBottomBarArea_']:hover 0.201 0
119 [class^=gradientTop_] 0.2009 0
120 [class^=iconWrapper_] 0.2008 6
121 [class^='guildSeparator_'] 0.1998 2
122 [class^='content_']>[class^='outer_'] 0.1998 0
123 [class^='privateChannels_'] 0.1997 0
124 [class^=container_] > [class^=base_] 0.1996 1
125 [class^=member_] 0.1985 25
126 [class^='content_']>[class^='outer_'] 0.1984 0
127 [class^='sectionDivider_'] 0.198 0
128 [class^=topControls_] 0.198 0
129 [class^='membersWrap_'] 0.1974 1
130 [class^=privateChannels_] 0.1971 0
131 [class^=content_] > [class^=resizeHandle_] 0.1969 0
132 [class^=replyBar_] 0.196 0
133 [class^='form_'] [class^='channelTextArea_'] 0.1958 1
134 [class^='divider_']:not(.isUnread_c2654d) 0.194 2
135 .wrapper_c51b4e:has(rect[fill='#f0b232']) 0.1871 8
136 [class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask 0.1624 39
137 :root 0.1432 1
138 .wrapper_c51b4e:has(rect[fill='#80848e']) 0.1408 1
139 .wrapper_c51b4e:has(rect[fill='#593695']) 0.1371 0
140 .wrapper_c51b4e:has(rect[fill='#f23f43']) 0.1349 5
141 .wrapper_c51b4e:has(rect) 0.1342 26
142 .wrapper_c51b4e:has(rect[fill='#23a55a']) 0.1332 12
143 [class^='svg_'] > foreignObject 0.123 99
144 [role='button'] path[fill='var(--white-500)'] 0.1159 0
145 svg[style='color: rgb(88, 101, 242);'] 0.1122 13
146 [class^=jumpToPresentBar_] > button 0.1117 2
147 [style='opacity: 1; background-color: rgb(35, 165, 90);'] 0.1095 0
148 :root 0.1094 1
149 [class^='mask_'] > rect 0.1049 26
150 [class^='mask_'] > foreignObject 0.1041 26
151 [class^='mask_'] > svg 0.1035 1
152 #app-mount button path[fill='var(--white-500)'] 0.1031 0
153 [class^='bannerSVGWrapper_'] > mask > circle 0.102 0
154 svg[class^=slider_] > rect[fill='white'] 0.1016 0
155 :root 0.0946 1
156 [class^='mask_'] > circle 0.0937 0
157 [class^=callContainer_] [class^=topControls_] section 0.0882 0
158 .container_a6d69a 0.0797 0
159 .guilds_a4d4d9 0.0771 1
160 .container_adcaac 0.0769 0
161 .vc-spotify-time-left 0.0766 0
162 .vc-spotify-time-right 0.0746 0
163 .guilds_a4d4d9 0.0733 1
164 .form_a7d72e 0.0731 1
165 .container_fc4f04 0.0723 1
166 .container_c2739c 0.072 0
167 .form_a7d72e 0.0719 1
168 * 0.0714 6196
169 .timestamp_f9f2ca.latin24CompactTimeStamp_f9f2ca 0.0713 13
170 .container_d1c246 0.0713 0
171 .vc-ranb-button 0.0709 0
172 .container_b2ca13 0.0707 1
173 .wrapper_fea3ef 0.0706 1
174 .statusBubbleOuter_d0a252 0.0706 0
175 .container_fc4f04 0.0704 1
176 .scroller_bf550a 0.0703 0
177 .guilds_a4d4d9 0.0703 1
178 .attachButton_f298d4 0.0702 1
179 .container_a6d69a 0.07 0
180 .clipContainer_b11c5e 0.0699 0
181 .chat_a7d72e 0.0699 1
182 .form_a7d72e 0.0698 1
183 .panels_a4d4d9 0.0698 1
184 .container_e85446:has(div[style='background-color: rgb(35, 165, 90);']) 0.0698 0
185 .textArea_bdf0de 0.0697 1
186 .content_a7d72e 0.0695 1
187 .container_adcaac 0.0695 0
188 .animatedContainer_fd6364 0.0693 1
189 .guilds_a4d4d9 0.0692 1
190 .container_fb4810 0.0692 0
191 .separator_a0 0.0692 0
192 .container_e85446:has(div[style='background-color: rgb(35, 165, 90);']) 0.0692 0
193 .scroller_fb4810 0.0691 0
194 .wordmarkWindows_a934d8 > svg 0.0691 1
195 .wordmarkWindows_a934d8 0.0688 1
196 .container_fc4f04 0.0688 1
197 .scroller_fb4810 0.0688 0
198 .guilds_a4d4d9 0.0687 1
199 .container_e85446 > [class^='svg_'] > foreignObject > [class^='status_'] 0.0686 0
200 .newMessagesBar_cf58b5 0.0685 0
201 .container_a4d4d9 0.0684 1
202 .scroller_e2e187 0.0684 1
203 .platform-osx .guilds_a4d4d9 0.0684 0
204 .scroller_fb4810 0.0683 0
205 .container_c75f85 0.0683 0
206 .container_c2739c 0.0681 0
207 html.theme-light 0.0681 0
208 .container_a6d69a 0.0681 0
209 .grabber_c7a159 0.068 0
210 .scroller_fb4810 0.0679 0
211 .theme-dark 0.0678 1
212 .statusBubbleOuter_d0a252 0.0678 0
213 .tabBody_c2739c 0.0677 0
214 .container_d1c246 0.0676 0
215 .horizontal_dc333f > .flex_dc333f 0.0673 0
216 .separator_a0 0.0669 0
217 .vc-spotify-time-left 0.0666 0
218 .container_fc4f04 0.0662 1
219 .vc-spotify-time-right 0.0661 0
220 .wrapper_fea3ef.hidden_fea3ef 0.0661 0
221 #vc-spotify-player 0.0004 0
222 #vc-spotify-progress-bar [class^='bar_'] 0.0004 0
223 #vc-spotify-player 0.0004 0
224 .overlay_f94206:after 0.0004 0
225 #app-mount #vc-spotify-player 0.0003 0
226 #vc-spotify-player 0.0003 0
227 #vc-spotify-progress-bar 0.0003 0
228 ::placeholder 0.0003 0
229 ::selection 0.0003 0
230 ::-webkit-scrollbar-thumb 0.0003 0
231 .overlay_f18fcb:after 0.0003 0
232 #vc-spotify-player 0.0002 0

View File

@ -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,
1 Selector Time (ms) Matches Total
2 [class^='chatContent_']:has([class^='messagesWrapper_']:hover) 0.4053 0 29.2904
3 [class^='chatContent_']:has([class^='typing_']:hover) 0.3815 0
4 [class^=chat_] > [class*=container_] 0.3098 0
5 [class^=chat_] > [class^=content_] > [class^=container_] 0.238 0
6 [class*=backgroundAccent_] 0.2297 0
7 [class^='form_'] [class^='channelTextArea_'] 0.2236 1
8 [class^='membersWrap_'] 0.2207 0
9 [class^=panels_] 0.2156 1
10 [class^=centerContent_] > [class^=wrapper_] > [class^=anchor_] 0.2081 0
11 [class^=searchResultsWrap_] 0.1998 0
12 [class^='content_']>[class^='outer_'] 0.1997 0
13 [class^='form_'] > [class^='wrapper_'] 0.1982 0
14 [class^=videoControls_] 0.1967 0
15 [class^=sidebar_] > [class^=container_] 0.1947 1
16 [class^=privateChannels_] 0.1911 0
17 [class^='content_'] > [class^='outer_'] 0.19 0
18 [class^='status_'][style='background-color: rgb(240, 178, 50);'] 0.19 0
19 [class^='form_'] > [class^='wrapper_'] 0.1888 0
20 [class^=embedVideoActions_] > [class^=centerContent_] > [class^=wrapper_] 0.1887 0
21 [class^=iconBadge_]:not([class*=isCurrentUserConnected_]) 0.1881 1
22 [class^='sidebar_'] > [class^='container_'] 0.1842 1
23 [class^='peopleColumn_'] 0.1839 0
24 [class^='lowerBadge_'] 0.1839 0
25 [class^='gradientBottom_'] 0.1836 0
26 [class^=overlayBottom_] > [class^=text-] 0.1834 0
27 [class^='status_'][style='background-color: rgb(35, 165, 90);'] 0.1834 0
28 [class^='chatContent_']:has([class^='typing_']) > [class^='messagesWrapper_'] > [class^='jumpToPresentBar_'] 0.1834 0
29 [class^='status_'][style='background-color: rgb(128, 132, 142);'] 0.1834 0
30 [class^='status_'][style='background-color: rgb(242, 63, 67);'] 0.1832 0
31 [class^='form_'] > [class^='typing_'] 0.183 1
32 [class^=panels_] 0.1826 1
33 [class^='searchResultsWrap_'] 0.1826 0
34 [class^='callContainer_'] 0.182 0
35 [class^='upperBadge_'] 0.1817 0
36 [class^=childWrapper_] 0.1817 15
37 [class^=members_] 0.1815 0
38 [class^=button_][class*=bannerColor_] 0.181 0
39 [class^='gradientTop_'] 0.181 0
40 [class^='sidebar_'] > [class^='container_'] 0.1808 1
41 [class^=searchFilter_] 0.1807 0
42 [class^=privateChannels_] 0.1804 0
43 [class^='panels_'] 0.1794 1
44 [class^=member_] 0.1791 0
45 [class^='membersWrap_'] 0.1791 0
46 [class^=message_][class*=replying_] 0.179 0
47 [class^='unreadPill_'][class*='endCap_'] 0.1788 0
48 [class^='privateChannels_'] 0.1787 0
49 [class^='bottomControls_'] 0.1787 0
50 [class^=message_][class*=mentioned_] 0.1784 5
51 [class^='content_'] > [class^='outer_'] 0.1784 0
52 #app-mount [class^=partyStatus_] 0.1784 0
53 [class^=searchAnswer_] 0.1781 0
54 [class^=panels_] > [class^=wrapper_] > [class^=container] 0.178 0
55 [class^='peopleColumn_'] 0.1779 0
56 [class^='form_'] [class^='channelTextArea_'] 0.1779 1
57 #app-mount [class^=partyStatus_] [class^=anchor_] 0.1776 0
58 [class^='panels_'] 0.1766 1
59 [class^='form_'] [class^='channelTextArea_'] 0.1766 1
60 [class^=replyBar_] 0.1765 0
61 [class^='privateChannels_'] 0.1765 0
62 [class^=channelBottomBarArea_] 0.1764 1
63 [class^='divider_']:not(.isUnread_c2654d) 0.1763 2
64 [class^=navButton_][class*=navButtonActive_] 0.1762 0
65 [class^=container_] > [class^=base_] 0.1756 1
66 [class^='nowPlayingColumn_'] 0.1755 0
67 [class^='membersWrap_'] 0.1755 0
68 [class^=sidebar_] 0.1753 1
69 [class^='newChannel_'] 0.1752 0
70 [class^=nowPlayingColumn_] 0.1752 0
71 [class^=guildSeparator_] 0.1751 2
72 [class^='topControls_'] 0.175 0
73 [class^='container_'] > [class^='base_'] 0.1748 1
74 [class^=panels_] > [class^=container_] 0.1742 1
75 [class^='notAppAsidePanel_'] > [class^='layerContainer_'] 0.1742 2
76 [class^=columnHeading_] 0.174 0
77 [class^='channelBottomBarArea_']:hover 0.1739 0
78 [class^=attachedBars_] 0.1738 0
79 [class^='membersWrap_'] 0.1738 0
80 [class^=searchResultsWrap_] 0.1737 0
81 [class^=gifFavoriteButton_] 0.1736 0
82 [class^=membersWrap_] 0.1736 0
83 [class^=customizationSection_] 0.1732 0
84 #app-mount [class^=sidebar_] > [class^=container_] 0.1727 1
85 [class^='newBadge_'] 0.1726 0
86 [class^='channelBottomBarArea_'] 0.1723 1
87 [class^=sidebar_] 0.1722 1
88 [class^=privateChannels_] 0.1722 0
89 [class^=sidebar_] 0.1721 1
90 [class^='toolbar_'] > [class^='search_'] 0.172 1
91 [class^=privateChannels_] 0.1713 0
92 [class^=friendRequestsButton_] [class^=numberBadge_] 0.1713 0
93 [class*=bannerVisible_] [class^=headerContent_] 0.1712 0
94 [class^=iconWrapper_] 0.1712 7
95 [class^=chatContent_] > [class^=messagesWrapper_] 0.1712 1
96 [class^='content_']>[class^='outer_']>[class^='inner_'] 0.171 0
97 [class^='nowPlayingColumn_'] 0.1709 0
98 [class^=peopleColumn_] 0.1709 0
99 [class^='content_']>[class^='outer_'] 0.1707 0
100 [class^=searchResultsWrap_] 0.1707 0
101 [class^='chatContent_'] > [class^='messagesWrapper_'] 0.1707 1
102 [class^=tableCell_] 0.1707 0
103 [class^=tabBody_] 0.1706 0
104 [class^=peopleColumn_] 0.1705 0
105 [class^=tabBody_] 0.1703 0
106 [class^='dot_'][class*='maskSecondary_'] 0.1702 0
107 [class^='sectionDivider_'] 0.1701 1
108 [class^='searchResultsWrap_'] 0.1701 0
109 [class^=privateChannels_] > [class^=scroller_] 0.17 0
110 [class^=container_] > [class^=base_] 0.17 1
111 [class^='content_'] > [class^='outer_'] 0.1699 0
112 [class^=chat_] 0.1696 1
113 [class^='channelAppLauncher_'] 0.1696 1
114 [class^='nowPlayingColumn_'] 0.1696 0
115 #app-mount [class^=sidebar_] 0.1689 1
116 [class^='guildSeparator_'] 0.1688 2
117 [class^=panels_] > [class^=panel_] 0.1686 0
118 [class^=threadSuggestionBar_] 0.1685 0
119 [class^='chatContent_'] > [class^='messagesWrapper_'] 0.1684 1
120 [class^=session_] 0.1684 0
121 [class^=categoryText_] 0.1683 0
122 [class^=channelTextArea] > [class^=scrollableContainer] 0.1679 1
123 [class^=panels_] > [class^=panel_] 0.1677 0
124 [class^='folderIconWrapper_'][style='background-color: rgba(88, 101, 242, 0.4);'] 0.1674 0
125 [class^='sectionDivider_'] 0.1671 1
126 [class^='callContainer_'] 0.1669 0
127 [class^=content_] > [class^=resizeHandle_] 0.1663 0
128 [class^=container_] > [class^=base_] 0.1661 1
129 #app-mount [class^='divider_'] 0.1639 2
130 [class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask 0.123 45
131 * 0.1194 5612
132 [class^='mask_'] > foreignObject 0.1165 1
133 [class^='bannerSVGWrapper_'] > mask > circle 0.1122 0
134 [class^='mask_'] > rect 0.1074 1
135 [class^='svg_'] > foreignObject 0.1069 100
136 [class^='mask_'] > svg 0.1043 0
137 #app-mount .controlIcon_ef18ee 0.1034 0
138 #app-mount .lookLink_dd4f85.colorPrimary_dd4f85 0.1023 0
139 [class^='mask_'] > circle 0.099 0
140 [style='opacity: 1; background-color: rgb(35, 165, 90);'] 0.0979 0
141 svg[style='color: rgb(88, 101, 242);'] 0.0884 13
142 #app-mount button path[fill='var(--white-500)'] 0.087 0
143 [role='button'] path[fill='var(--white-500)'] 0.083 0
144 :root 0.0804 1
145 :root 0.0792 1
146 [class^='callContainer_'] [class^='topControls_'] section 0.0784 0
147 svg[class^=slider_] > rect[fill='white'] 0.0784 0
148 :root 0.0767 1
149 [class^=jumpToPresentBar_] > button 0.0735 0
150 .wrapper_c51b4e:has(rect[fill='#80848e']) 0.0695 1
151 .textArea_bdf0de 0.0636 1
152 .attachButton_f298d4 0.0619 1
153 .wrapper_c51b4e:has(rect[fill='#593695']) 0.0614 0
154 .wrapper_c51b4e:has(rect[fill='#f0b232']) 0.0609 0
155 .guilds_a4d4d9 0.0607 1
156 .wrapper_c51b4e:has(rect) 0.0607 1
157 .wrapper_c51b4e:has(rect[fill='#f23f43']) 0.0606 0
158 .theme-dark 0.0602 1
159 .wrapper_c51b4e:has(rect[fill='#23a55a']) 0.0597 0
160 .lookFilled_dd4f85.colorPrimary_dd4f85 0.0584 0
161 .container_fc4f04 0.0581 1
162 .guilds_a4d4d9 0.058 1
163 .timestamp_f9f2ca.latin24CompactTimeStamp_f9f2ca 0.0577 32
164 .container_c2739c 0.0572 0
165 .container_b2ca13 0.0571 1
166 .container_e85446:has(div[style='background-color: rgb(35, 165, 90);']) 0.0568 0
167 .embedFull_b0068a 0.0567 1
168 .separator_a0 0.0564 0
169 .scroller_fb4810 0.0562 0
170 .scroller_fb4810 0.0562 0
171 .statusBubbleOuter_d0a252 0.056 0
172 .statusBubbleOuter_d0a252 0.0559 0
173 .container_a4d4d9 0.0558 1
174 .container_a6d69a 0.0557 0
175 .container_fc4f04 0.0557 1
176 .container_a6d69a 0.0557 0
177 .form_a7d72e 0.0556 1
178 .wrapper_fea3ef.hidden_fea3ef 0.0556 0
179 .guilds_a4d4d9 0.0555 1
180 .vc-ranb-button 0.0555 0
181 .horizontal_dc333f > .flex_dc333f 0.0555 0
182 .wordmarkWindows_a934d8 > svg 0.0554 1
183 .container_e85446:has(div[style='background-color: rgb(35, 165, 90);']) 0.0554 0
184 .vc-spotify-time-right 0.0553 0
185 .newMessagesBar_cf58b5 0.0553 0
186 .chat_a7d72e 0.0552 1
187 .panels_a4d4d9 0.0552 1
188 .container_fb4810 0.0552 0
189 .vc-spotify-time-right 0.0552 0
190 .content_a7d72e 0.0551 1
191 .container_d1c246 0.0551 0
192 .form_a7d72e 0.0551 1
193 .wordmarkWindows_a934d8 0.0551 1
194 .vc-spotify-time-left 0.0551 0
195 .scroller_bf550a 0.055 0
196 .clipContainer_b11c5e 0.0549 0
197 .vc-spotify-time-left 0.0549 0
198 .wrapper_fea3ef 0.0549 1
199 .grabber_c7a159 0.0549 0
200 .platform-osx .guilds_a4d4d9 0.0548 0
201 .container_fc4f04 0.0548 1
202 .guilds_a4d4d9 0.0547 1
203 .container_a6d69a 0.0547 0
204 .scroller_e2e187 0.0546 1
205 .container_c75f85 0.0546 0
206 .container_c2739c 0.0546 0
207 .animatedContainer_fd6364 0.0544 0
208 .container_e85446 > [class^='svg_'] > foreignObject > [class^='status_'] 0.0544 0
209 .container_fc4f04 0.0544 1
210 .container_d1c246 0.0544 0
211 .separator_a0 0.0544 0
212 .container_adcaac 0.0542 0
213 .guilds_a4d4d9 0.0542 1
214 html.theme-light 0.054 0
215 .form_a7d72e 0.0539 1
216 .tabBody_c2739c 0.0539 0
217 .scroller_fb4810 0.0539 0
218 .container_adcaac 0.0538 0
219 .scroller_fb4810 0.0533 0
220 ::selection 0.0028 0
221 #vc-spotify-player 0.0003 0
222 #vc-spotify-player 0.0003 0
223 #vc-spotify-player 0.0003 0
224 #vc-spotify-progress-bar 0.0003 0
225 #vc-spotify-progress-bar [class^='bar_'] 0.0003 0
226 .overlay_f94206:after 0.0003 0
227 #app-mount #vc-spotify-player 0.0002 0
228 #vc-spotify-player 0.0002 0
229 .overlay_f18fcb:after 0.0002 0
230 ::-webkit-scrollbar-thumb 0.0001 0
231 ::placeholder 0.0001 0

View File

@ -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"
}
}
}
}

View File

@ -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"
}
}

View File

@ -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();
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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%);
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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%);
}

View File

@ -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);
}}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -0,0 +1 @@
/home/themiro/Dotfiles/desktopenvs/hyprland/alacritty

View File

@ -0,0 +1,10 @@
[font]
normal = {family = "Agave NerdFont", style = "Regular"}
[terminal]
[terminal.shell]
program = "/usr/bin/zsh"
args = ["-l"]
[window]
opacity = 0.3

View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

View File

@ -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/<name>.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

View File

@ -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

View File

@ -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())

View File

@ -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

View File

@ -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 <con> ipv4/ipv6 method/addresses/gateway/dns
set-ip <con> ipv4|ipv6 <method> [addr_cidr] [gateway] [dns_space_sep]
routes kernel routing table (read-only)
vlans configured VLAN connections
vlan-add <parent_dev> <id> [name] create + bring up a VLAN
vlan-del <name> delete a VLAN connection
vlan-up <name> activate a VLAN connection
dns effective DNS servers per device
set-dns <con> ipv4|ipv6 <servers_space_sep> <yes|no ignore-auto>
"""
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:]))

View File

@ -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 <center_lat> <center_lon> <zoom> <w> <h> <out> \
[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])

View File

@ -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"

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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())

View File

@ -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)

View File

@ -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)],
)

View File

@ -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)],
)

View File

@ -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)],
)

View File

@ -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 "<html" not in out.lower():
self.renderer.set_text(out.rstrip("\n"))
self._status.set_visible(False)
else:
self._status.set_text("Weather unavailable")
self._status.set_visible(True)
def build(ctx: ModuleContext) -> 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)],
)

View File

@ -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)

View File

@ -0,0 +1,29 @@
"""The module registry — the single place that lists available quads in order.
To add a new module: create modules/<name>.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

View File

@ -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

View File

@ -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()

View File

@ -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;

View File

@ -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;
}

View File

@ -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
)

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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 = ""

View File

@ -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"

View File

@ -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 <name> [except <sub1> <sub2> ...]
# copy SOURCE_BASE/<name> to ~/.config/<name>, optionally skipping subdirs
# flat <name>
# copy contents of SOURCE_BASE/<name> directly into ~/.config/
# ignore <name>
# 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

View File

@ -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 <name> copy SOURCE_BASE/<name> to ~/.config/<name>
# flat <name> copy contents of SOURCE_BASE/<name> directly into ~/.config/
# (for installation-specific files that live at the ~/.config root)
# ignore <name> present in source but intentionally not managed here
SOURCE_BASE = ~/Dotfiles/desktopenvs/hyprdrive
# ── deployed as ~/.config/<name> ─────────────────────────────────────────────
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/

View File

@ -0,0 +1 @@
/home/themiro/Dotfiles/desktopenvs/hyprland/dunst/

View File

@ -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:
# <b>bold</b>
# <i>italic</i>
# <s>strikethrough</s>
# <u>underline</u>
#
# For a complete reference see
# <https://docs.gtk.org/Pango/pango_markup.html>.
#
# 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 = "<b>%s</b>\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

View File

@ -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"

View File

@ -0,0 +1 @@
file://$HOME/Nextcloud

View File

@ -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

View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

View File

@ -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")

View File

@ -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))

View File

@ -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()

View File

@ -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()

View File

@ -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())

View File

@ -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)

View File

@ -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;

View File

@ -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;
}

View File

@ -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
)

View File

@ -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)

View File

@ -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)

View File

@ -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.
# =============================================================================

View File

@ -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

View File

@ -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 = <i>Password...</i> # 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 "<b> "$(date +'%A, %-d %B %Y')" </b>"
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 "<b> "$(date +'Week %U')" </b>"
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 "<b><big> $(date +"%I:%M:%S %p") </big></b>" # AM/PM
text = cmd[update:1000] echo "<b><big> $(date +"%H:%M:%S") </big></b>" # 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 "<b> "$(uptime -p || $Scripts/UptimeNixOS.sh)" </b>"
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
}

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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.01.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; <i> renders italic placeholder text.
placeholder_text = <i>Password...</i>
# 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
}

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More