Compare commits

..

No commits in common. "main" and "feat/astal-menu" have entirely different histories.

346 changed files with 753 additions and 32774 deletions

1
.gitignore vendored
View File

@ -46,7 +46,6 @@ lazy-lock.json
*.vhdx *.vhdx
*.deb *.deb
*.rpm *.rpm
*/nohup.out
# Logs # Logs
**/*.log **/*.log

View File

@ -36,17 +36,11 @@ USER_FILES=(
"desktopenvs/hyprland/kitty/current-theme.conf|$HOME/.config/kitty/current-theme.conf" "desktopenvs/hyprland/kitty/current-theme.conf|$HOME/.config/kitty/current-theme.conf"
"desktopenvs/hyprland/kitty/kitty.conf|$HOME/.config/kitty/kitty.conf" "desktopenvs/hyprland/kitty/kitty.conf|$HOME/.config/kitty/kitty.conf"
"desktopenvs/hyprland/kitty/themes/cyberqueer.conf|$HOME/.config/kitty/themes/cyberqueer.conf" "desktopenvs/hyprland/kitty/themes/cyberqueer.conf|$HOME/.config/kitty/themes/cyberqueer.conf"
"desktopenvs/hyprdrive/alacritty/alacritty.toml|$HOME/.config/alacritty/alacritty.toml"
"desktopenvs/hyprdrive/ghostty/config|$HOME/.config/ghostty/config"
"desktopenvs/hyprland/waybar/style.css|$HOME/.config/waybar/style.css" "desktopenvs/hyprland/waybar/style.css|$HOME/.config/waybar/style.css"
"desktopenvs/hyprland/wofi/style.css|$HOME/.config/wofi/style.css" "desktopenvs/hyprland/wofi/style.css|$HOME/.config/wofi/style.css"
"desktopenvs/hyprland/walker/themes/cyberqueer.css|$HOME/.config/walker/themes/cyberqueer.css" "desktopenvs/hyprland/walker/themes/cyberqueer.css|$HOME/.config/walker/themes/cyberqueer.css"
"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/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

@ -1,27 +0,0 @@
{
"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

@ -1,632 +0,0 @@
{
"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

@ -1,89 +0,0 @@
/**
* @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

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

View File

@ -1,21 +0,0 @@
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

@ -1,72 +0,0 @@
> [!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.

Before

Width:  |  Height:  |  Size: 769 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 640 KiB

View File

@ -1,56 +0,0 @@
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

@ -1,195 +0,0 @@
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

@ -1,232 +0,0 @@
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

@ -1,231 +0,0 @@
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

@ -1,215 +0,0 @@
{
"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

@ -1,14 +0,0 @@
{
"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

@ -1,46 +0,0 @@
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

@ -1,67 +0,0 @@
/* 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

@ -1,87 +0,0 @@
/**
* @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

@ -1,84 +0,0 @@
/**
* @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

@ -1,84 +0,0 @@
/**
* @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

@ -1,78 +0,0 @@
/**
* @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

@ -1,88 +0,0 @@
/**
* @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

@ -1,86 +0,0 @@
/**
* @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

@ -1,87 +0,0 @@
/**
* @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

@ -1,84 +0,0 @@
/**
* @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

@ -1,84 +0,0 @@
/**
* @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

@ -1,83 +0,0 @@
/**
* @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

@ -1,84 +0,0 @@
/**
* @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

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

View File

@ -1,44 +0,0 @@
[font]
normal = {family = "Agave NerdFont", style = "Regular"}
[terminal]
[terminal.shell]
program = "/usr/bin/zsh"
args = ["-l"]
[window]
opacity = 0.3
# CyberQueer — matches ../kitty/themes/cyberqueer.conf and ../ghostty/config
# (colors.conf COLOR_TEXT/BG/HIGHLIGHT/DARK/RED plus the same neon tints)
[colors.primary]
background = "#1A1A1A"
foreground = "#D6ABAB"
[colors.cursor]
cursor = "#E40046"
text = "#5018DD"
[colors.selection]
background = "#5018DD"
text = "#E40046"
[colors.normal]
black = "#1A1A1A"
red = "#E40046"
green = "#33E6A0"
yellow = "#E8B23D"
blue = "#5018DD"
magenta = "#A6178C"
cyan = "#34C6C6"
white = "#D6ABAB"
[colors.bright]
black = "#4B3A55"
red = "#F50505"
green = "#5CFFC2"
yellow = "#FFD873"
blue = "#8C6FFF"
magenta = "#FF5FC0"
cyan = "#6FF5F0"
white = "#F2DEDE"

View File

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

View File

@ -1,60 +0,0 @@
# 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

@ -1,35 +0,0 @@
"""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

@ -1,124 +0,0 @@
#!/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

@ -1,66 +0,0 @@
#!/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

@ -1,212 +0,0 @@
#!/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

@ -1,108 +0,0 @@
#!/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

@ -1,101 +0,0 @@
#!/usr/bin/env bash
# astro-menu system-monitor backend — the ship's "Systems Diagnostic".
# Emits one TAB-separated record per subsystem:
#
# CATEGORY <TAB> NAME <TAB> USAGE_PCT <TAB> TEMP_C <TAB> DETAIL
#
# CATEGORY is cpu|gpu|ram|disk (drives the icon). USAGE_PCT is 0..100. TEMP_C is
# an integer °C or empty when no sensor exists. One `disk` row per mounted real
# filesystem. Reads everything from /proc + /sys (no extra deps).
set -uo pipefail
milli_to_c() { awk '{printf "%d", ($1 + 500) / 1000}'; }
hwmon_temp() { # $1 = chip-name substring; first temp input of that chip, in °C
local h n t
for h in /sys/class/hwmon/hwmon*; do
n=$(cat "$h/name" 2>/dev/null) || continue
[[ "$n" == *"$1"* ]] || continue
for t in "$h"/temp*_input; do
[[ -r "$t" ]] && { milli_to_c < "$t"; return 0; }
done
done
return 1
}
hwmon_dir_temp() { # $1 = hwmon dir, $2 = preferred label substring
local d="$1" pref="$2" lf lbl base
for lf in "$d"/temp*_label; do
[[ -r "$lf" ]] || continue
[[ "$(cat "$lf" 2>/dev/null)" == *"$pref"* ]] || continue
base="${lf%_label}_input"
[[ -r "$base" ]] && { milli_to_c < "$base"; return 0; }
done
for base in "$d"/temp*_input; do
[[ -r "$base" ]] && { milli_to_c < "$base"; return 0; }
done
return 1
}
nvme_temp() { # $1 = disk (e.g. nvme0n1); temp of that specific controller, in °C
local ctrl h t
ctrl=$(basename "$(readlink -f "/sys/block/$1/device" 2>/dev/null)")
for h in /sys/class/hwmon/hwmon*; do
[[ "$(cat "$h/name" 2>/dev/null)" == "nvme" ]] || continue
[[ "$(basename "$(readlink -f "$h/device" 2>/dev/null)")" == "$ctrl" ]] || continue
for t in "$h"/temp*_input; do
[[ -r "$t" ]] && { milli_to_c < "$t"; return 0; }
done
done
return 1
}
# --- CPU: Thrusters ---------------------------------------------------------
cpu_snapshot() { awk '/^cpu /{t=0; for (i=2; i<=NF; i++) t+=$i; print t, $5}' /proc/stat; }
read t1 idle1 < <(cpu_snapshot); sleep 0.2; read t2 idle2 < <(cpu_snapshot)
dtotal=$(( t2 - t1 )); didle=$(( idle2 - idle1 )); cpu_usage=0
(( dtotal > 0 )) && cpu_usage=$(( ((dtotal - didle) * 100 + dtotal / 2) / dtotal ))
cpu_temp=$(hwmon_temp k10temp) || cpu_temp=$(hwmon_temp coretemp) || cpu_temp=""
printf 'cpu\tThrusters\t%s\t%s\t%s cores online\n' "$cpu_usage" "$cpu_temp" "$(nproc 2>/dev/null || echo '?')"
# --- GPU: Hyperdrive (discrete = the card with the most VRAM) ---------------
gpu_dev=""; best_vram=-1
for d in /sys/class/drm/card[0-9]*/device; do
[[ -r "$d/gpu_busy_percent" ]] || continue
v=$(cat "$d/mem_info_vram_total" 2>/dev/null || echo 0)
(( v > best_vram )) && { best_vram=$v; gpu_dev=$d; }
done
gpu_usage=0; gpu_temp=""; gpu_detail="offline"
if [[ -n "$gpu_dev" ]]; then
gpu_usage=$(cat "$gpu_dev/gpu_busy_percent" 2>/dev/null || echo 0)
for hd in "$gpu_dev"/hwmon/hwmon*; do
[[ -d "$hd" ]] && { gpu_temp=$(hwmon_dir_temp "$hd" junction) || gpu_temp=""; break; }
done
(( best_vram > 0 )) && gpu_detail=$(awk -v b="$best_vram" 'BEGIN{printf "%.0f GiB core", b/1073741824}')
fi
printf 'gpu\tHyperdrive\t%s\t%s\t%s\n' "$gpu_usage" "$gpu_temp" "$gpu_detail"
# --- RAM: Life Support Systems ---------------------------------------------
read ram_used ram_total < <(free -b | awk '/^Mem:/{print $3, $2}')
ram_usage=0; (( ram_total > 0 )) && ram_usage=$(( (ram_used * 100 + ram_total / 2) / ram_total ))
ram_temp=$(hwmon_temp spd5118) || ram_temp=$(hwmon_temp jc42) || ram_temp=""
ram_detail=$(awk -v u="$ram_used" -v t="$ram_total" 'BEGIN{printf "%.1f/%.1f GiB", u/1073741824, t/1073741824}')
printf 'ram\tLife Support Systems\t%s\t%s\t%s\n' "$ram_usage" "$ram_temp" "$ram_detail"
# --- Storage: Cargo Hold (one row per real filesystem, deduped by device) ---
declare -A seen
while read -r src used size; do
[[ "$src" == /dev/* ]] || continue
[[ -n "${seen[$src]:-}" ]] && continue
seen[$src]=1
dev=$(basename "$src") # e.g. sda1 / nvme0n1p2
disk=$(lsblk -no pkname "$src" 2>/dev/null | head -1) # parent disk, if partitioned
[[ -z "$disk" ]] && disk="$dev"
(( size > 0 )) || continue
usage=$(( (used * 100 + size / 2) / size ))
temp=""; [[ "$disk" == nvme* ]] && { temp=$(nvme_temp "$disk") || temp=""; }
[[ -z "$temp" ]] && temp=$(hwmon_temp drivetemp 2>/dev/null) || true
detail=$(awk -v d="$dev" -v u="$used" -v t="$size" \
'BEGIN{printf "%-10s %.0f/%.0f GiB", d, u/1073741824, t/1073741824}')
printf 'disk\tCargo Hold\t%s\t%s\t%s\n' "$usage" "$temp" "$detail"
done < <(df -B1 --output=source,used,size -x tmpfs -x devtmpfs -x overlay -x squashfs 2>/dev/null | tail -n +2)

View File

@ -1,18 +0,0 @@
#!/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

@ -1,31 +0,0 @@
"""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

@ -1,138 +0,0 @@
"""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

@ -1,100 +0,0 @@
"""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.28
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. Wider
# and stronger than before so the thin border reads as glowing, not just drawn.
_GLOW_LAYERS = [(22, 0.05), (15, 0.08), (9, 0.13), (4, 0.22)]
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:
# The bloom strokes are up to 24px wide, so half of each spills OUTWARD
# past the ring. GTK clips a DrawingArea to its own (square) allocation,
# which along the straight edges trims that spill to nothing but inside
# each corner leaves the whole square patch outside the arc painted —
# solid red corners poking out from behind the rounded border. Clip the
# glow to the ring's own outer silhouette (the widget rect rounded by
# radius + inset, i.e. exactly where the crisp stroke's outer edge runs)
# so the bloom only ever reads inward and the card keeps a clean rounded
# outline.
cr.save()
_rounded_rect(cr, 0, 0, w, h, radius + inset)
cr.clip()
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()
cr.restore()
_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 = 2, 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

@ -1,266 +0,0 @@
"""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, _MAGENTA, _ACCENT] # magenta-biased specks
NOISE_LIFETIME = (0.5, 1.4)
NOISE_FADE_IN = 0.2
NOISE_FADE_OUT = 0.35
NOISE_ALPHA_RANGE = (0.10, 0.34)
EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field
EDGE_FADE_Y = 40.0 # smooth vertical fade-out
INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in
INTRO_STATIC = 1200 # static specks at the very start of the intro
OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close
def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None,
intro_duration: float | None = None) -> None:
self.enabled = enabled
self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape
# widget whose opacity is ramped 0->1 during the intro so the UI genuinely
# fades in (a gradual reveal), rather than a solid haze block popping on
self._fade_widget = fade_widget
if intro_duration is not None:
self.INTRO_DURATION = intro_duration # per-instance override of the class default
self._sat_time = 0.0
self._particles: list[dict] = []
self._intro_t: float | None = None # >=0 while the materialise intro plays
self._outro_t: float | None = None # >=0 while the closing dissolve plays
self._outro_done = None # callback fired when the dissolve finishes
self._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask
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
if self._intro_t is not None:
self._intro_t += dt
if self._intro_t >= self.INTRO_DURATION:
self._intro_t = None
if self._fade_widget is not None:
self._fade_widget.set_opacity(1.0)
elif self._fade_widget is not None:
p = self._intro_t / self.INTRO_DURATION
self._fade_widget.set_opacity(p * p * (3 - 2 * p)) # smooth ramp 0->1
if self._outro_t is not None:
self._outro_t += dt
po = min(1.0, self._outro_t / self.OUTRO_DURATION)
if self._fade_widget is not None:
self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) # ramp 1->0
if self._outro_t >= self.OUTRO_DURATION:
done = self._outro_done
self._outro_t = None
self._outro_done = None
if done is not None:
done()
self.widget.queue_draw()
def start_intro(self) -> None:
"""Kick off the 'hologram materialising out of static' opening effect."""
if self.enabled:
self._outro_t = None # cancel any in-flight closing dissolve
self._outro_done = None
self._intro_t = 0.0
if self._fade_widget is not None:
self._fade_widget.set_opacity(0.0) # start hidden; tick() ramps it up
def start_outro(self, on_done) -> None:
"""Play a quick reverse of the intro (content dissolving back into static),
then call on_done to actually hide. If disabled, hide immediately."""
if not self.enabled:
on_done()
return
self._intro_t = None # cancel any in-flight opening intro
self._outro_t = 0.0
self._outro_done = on_done
# -- drawing --------------------------------------------------------------
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
if not self.enabled or width <= 0 or height <= 0:
return
if self._clip_func is not None:
cr.save()
self._clip_func(cr, width, height) # clip the scanlines to the UI shape
cr.clip()
# Render the holo field into a group, then composite it back through a
# soft edge-fade mask so the scanlines dissolve at the borders (reads far
# more like a projected hologram than a hard-edged rectangle).
cr.push_group()
self._draw_content(cr, width, height)
cr.pop_group_to_source()
cr.mask(self._edge_fade_mask(width, height))
if self._intro_t is not None:
self._draw_intro(cr, width, height)
elif self._outro_t is not None:
self._draw_outro(cr, width, height)
if self._clip_func is not None:
cr.restore()
def _edge_fade_mask(self, width: float, height: float):
key = (int(width), int(height))
if self._mask_cache is not None and self._mask_cache[0] == key:
return self._mask_cache[1]
w, h = max(1, key[0]), max(1, key[1])
surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h)
m = cairo.Context(surf)
m.set_source_rgba(0, 0, 0, 1)
m.paint()
m.set_operator(cairo.OPERATOR_DEST_OUT) # subtract edge gradients from the solid
fx = min(self.EDGE_FADE_X, w / 2)
fy = min(self.EDGE_FADE_Y, h / 2)
def band(x0, y0, x1, y1, rx, ry, rw, rh):
gr = cairo.LinearGradient(x0, y0, x1, y1)
gr.add_color_stop_rgba(0.0, 0, 0, 0, 1)
gr.add_color_stop_rgba(1.0, 0, 0, 0, 0)
m.set_source(gr)
m.rectangle(rx, ry, rw, rh)
m.fill()
band(0, 0, fx, 0, 0, 0, fx, h) # left
band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right
band(0, 0, 0, fy, 0, 0, w, fy) # top
band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom
pattern = cairo.SurfacePattern(surf)
self._mask_cache = (key, pattern)
return pattern
def _draw_intro(self, cr, width: float, height: float) -> None:
p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION))
strength = 1.0 - p
# No solid veil block: the content itself fades in (see tick's fade_widget
# ramp). Here we only lay churning static over it — dense at first, thinning
# to nothing — so the UI resolves out of noise as it fades up.
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
count = int(self.INTRO_STATIC * (strength ** 0.5)) # dense, thinning to none
for _ in range(count):
x = random.uniform(0, width)
y = random.uniform(0, height)
col = random.choice(colors)
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength))
sz = random.uniform(1.0, 2.8)
cr.rectangle(x, y, sz, sz)
cr.fill()
band = p * height # a bright scan wiping down as it resolves
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1)))
cr.rectangle(0, band - 2.0, width, 4.0)
cr.fill()
def _draw_outro(self, cr, width: float, height: float) -> None:
# Reverse of the intro: the content is already fading back out (tick's
# fade_widget ramp 1->0); here the static thickens from nothing as it goes,
# so the panel dissolves into noise just before it vanishes.
po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION))
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
count = int(self.INTRO_STATIC * (po ** 0.5))
for _ in range(count):
x = random.uniform(0, width)
y = random.uniform(0, height)
col = random.choice(colors)
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po))
sz = random.uniform(1.0, 2.8)
cr.rectangle(x, y, sz, sz)
cr.fill()
band = (1.0 - po) * height # scan wiping back up as it dissolves
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1)))
cr.rectangle(0, band - 2.0, width, 4.0)
cr.fill()
def _draw_content(self, cr, width: float, height: float) -> None:
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

@ -1,100 +0,0 @@
"""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

@ -1,138 +0,0 @@
#!/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

@ -1,73 +0,0 @@
"""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

@ -1,292 +0,0 @@
"""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 System Radio hardware"))
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="System Radio",
icon="", # nf-fa-bluetooth
build=build,
default_enabled=True,
features=[Feature("discovery", "Device discovery", True),
Feature("history", "Connection history", True)],
)

View File

@ -1,185 +0,0 @@
"""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")
# clip the map texture to the rounded .map-view corners (the Picture is a
# texture, so without this its square corners poke out of the card)
self.set_overflow(Gtk.Overflow.HIDDEN)
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

@ -1,529 +0,0 @@
"""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="Laser Antenna Uplink",
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

@ -1,195 +0,0 @@
"""System-monitor quad ("Ship Systems"): the machine's vitals reskinned as a
starship diagnostic panel. A small backend script (backend/sysmon.sh) reports
usage + temperature for each subsystem, renamed in the ship's language:
CPU -> Thrusters
GPU -> Hyperdrive
RAM -> Life Support Systems
Storage -> Cargo Hold (one entry per mounted drive)
Each is drawn as a bracketed usage meter (-<>-) with a temperature
readout (-( 54°C )-). Rows are laid out in a Gtk.Grid so every column lines up
regardless of the (variable-width) nerd-font icons. The compact cell shows the
four headline subsystems (first drive only); the expanded view lists every drive
with its capacity.
"""
from __future__ import annotations
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 ModuleContext, ModuleInstance, ModuleSpec
from paths import BACKEND_DIR
_SCRIPT = str(BACKEND_DIR / "sysmon.sh")
_MONO = "Agave Nerd Font Mono"
# palette (kept in sync by eye with style/_colors.css / the hologram violets)
_MAGENTA = "#EB00A6"
_ACCENT = "#E40046"
_DIM_EMPTY = "#3B2A6E" # unfilled meter cells
_DIM_TEXT = "#9385C9" # detail line
_COOL = "#22D3EE"
_WARM = "#F5A623"
_HOT = "#E40046"
_GOOD = "#2BE08A"
# category -> nerd-font glyph (verified to render in Agave Nerd Font)
_ICONS = {
"cpu": chr(0xf135), # nf-fa-rocket
"gpu": chr(0xf0e7), # nf-fa-bolt
"ram": chr(0xf004), # nf-fa-heart
"disk": chr(0xf0a0), # nf-fa-hdd_o
}
def _usage_color(pct: int) -> str:
if pct >= 85:
return _HOT
if pct >= 60:
return _WARM
return _GOOD
def _temp_color(temp: int) -> str:
if temp >= 85:
return _HOT
if temp >= 65:
return _WARM
return _COOL
def _esc(text: str) -> str:
return GLib.markup_escape_text(text)
class _SystemMonitor(Gtk.Box):
REFRESH_MS = 2500
def __init__(self, ctx: ModuleContext, compact: bool):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.ctx = ctx
self.compact = compact
self.add_css_class("sysmon-view")
self.set_valign(Gtk.Align.CENTER)
self._size = 10 if compact else 12
self._bar_w = 9 if compact else 16
self._timer: int | None = None
self._grid = Gtk.Grid()
self._grid.set_row_spacing(3 if compact else 7)
self._grid.set_column_spacing(12)
self.append(self._grid)
# -- lifecycle ---------------------------------------------------------
def start(self) -> None:
self.refresh()
if self._timer is None:
self._timer = GLib.timeout_add(self.REFRESH_MS, self._tick)
def stop(self) -> None:
if self._timer is not None:
GLib.source_remove(self._timer)
self._timer = None
def _tick(self) -> bool:
self.refresh()
return True
def refresh(self) -> None:
run_text([_SCRIPT], self._on_result)
def _on_result(self, ok: bool, out: str, _err: str) -> None:
if not ok:
return
rows = []
for line in out.splitlines():
parts = line.split("\t")
if len(parts) < 5:
continue
rows.append(dict(zip(("cat", "name", "usage", "temp", "detail"), parts)))
if self.compact:
# headline subsystems + only the first (root) drive
trimmed, seen_disk = [], False
for r in rows:
if r["cat"] == "disk":
if seen_disk:
continue
seen_disk = True
trimmed.append(r)
rows = trimmed
self._render(rows)
# -- rendering ---------------------------------------------------------
def _clear(self) -> None:
child = self._grid.get_first_child()
while child is not None:
nxt = child.get_next_sibling()
self._grid.remove(child)
child = nxt
def _span(self, markup: str, xalign: float = 0.0) -> Gtk.Label:
lbl = Gtk.Label(xalign=xalign)
lbl.add_css_class("sysmon-row")
lbl.set_markup(f"<span font_family='{_MONO}' size='{int(self._size * 1024)}'>{markup}</span>")
return lbl
def _render(self, rows: list[dict]) -> None:
self._clear()
for i, r in enumerate(rows):
try:
usage = int(r["usage"])
except ValueError:
usage = 0
fill_col = _usage_color(usage)
filled = max(0, min(self._bar_w, round(usage / 100 * self._bar_w)))
bar = (f"<span foreground='{fill_col}'>{'' * filled}</span>"
f"<span foreground='{_DIM_EMPTY}'>{'' * (self._bar_w - filled)}</span>")
meter = (f"<span foreground='{_ACCENT}'>-&lt;</span>{bar}"
f"<span foreground='{_ACCENT}'>&gt;-</span>")
try:
temp = int(r["temp"])
temp_txt, temp_col = f"{temp}°C", _temp_color(temp)
except ValueError:
temp_txt, temp_col = "--°C", _DIM_TEXT
temp_field = (f"<span foreground='{_ACCENT}'>-(</span> "
f"<span foreground='{temp_col}'>{temp_txt:>5}</span> "
f"<span foreground='{_ACCENT}'>)-</span>")
icon = _ICONS.get(r["cat"], "")
self._grid.attach(self._span(f"<span foreground='{_MAGENTA}'>{icon}</span>"), 0, i, 1, 1)
self._grid.attach(self._span(f"<span foreground='{_MAGENTA}'>{_esc(r['name'])}</span>"), 1, i, 1, 1)
self._grid.attach(self._span(meter), 2, i, 1, 1)
self._grid.attach(self._span(f"<span foreground='{fill_col}'>{usage:>3}%</span>", 1.0), 3, i, 1, 1)
self._grid.attach(self._span(temp_field), 4, i, 1, 1)
if not self.compact:
self._grid.attach(
self._span(f"<span foreground='{_DIM_TEXT}'>{_esc(r['detail'])}</span>"), 5, i, 1, 1)
def build(ctx: ModuleContext) -> ModuleInstance:
compact = _SystemMonitor(ctx, compact=True)
expanded = _SystemMonitor(ctx, compact=False)
def on_show() -> None:
compact.start()
expanded.start()
def on_hide() -> None:
compact.stop()
expanded.stop()
return ModuleInstance(compact=compact, expanded=expanded,
scroll_expanded=True, on_show=on_show, on_hide=on_hide)
SPEC = ModuleSpec(
id="sysmon",
title="Ship Systems",
icon=chr(0xf085), # nf-fa-cogs
build=build,
)

View File

@ -1,78 +0,0 @@
"""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")
gi.require_version("Pango", "1.0")
from gi.repository import Gtk, Pango # 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 = ""
headline = Gtk.Label(label="Planetary Environment Report", xalign=0.0)
headline.add_css_class("weather-headline")
headline.set_ellipsize(Pango.EllipsizeMode.END)
self.append(headline)
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

@ -1,28 +0,0 @@
"""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

@ -1,29 +0,0 @@
"""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, network, sysmon, weather
from module_base import ModuleSpec
ALL_SPECS: list[ModuleSpec] = [
sysmon.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

@ -1,55 +0,0 @@
"""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

@ -1,83 +0,0 @@
"""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

@ -1,8 +0,0 @@
/* 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

@ -1,368 +0,0 @@
/* 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. */
/* Text colour override: bright magenta reads far better than the default dusty
* @text (#D6ABAB) now that the panel is transparent + blurred glass. @accent
* (red) stays the accent/alt colour. This @define-color is added after
* _colors.css by theme.py (same USER+1 priority, later wins) and only affects
* this process's widgets, so other Cosmonaut Shell apps keep their @text. */
@define-color text #EB00A6;
* {
font-family: "Agave Nerd Font Mono", monospace;
font-size: 13pt;
}
/* Neutralise the CyberQueer GTK theme's `* { background-color:#1a1a1a }`, which
* otherwise paints EVERY node (every Gtk.Box, label, centerbox, ) an opaque
* dark slab behind the floating modules. Enumerating node types by name is
* fragile (it missed plain `box`), so blank them all here this provider sits
* at USER+1, above the theme. Interactive elements re-assert their own fills
* via the higher-specificity .class rules below; module cards are Cairo-drawn
* (lib/border.py, fill_bg=True), not CSS, so they are unaffected. */
* {
background-color: transparent;
}
/* ---- 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; }
/* weather "Planetary Environment Report" headline */
.weather-headline {
color: @text;
font-weight: 700;
font-size: 12pt;
letter-spacing: 1px;
margin: 0 0 6px 2px;
}
/* Ship Systems (system monitor): the row labels carry their own inline Pango
* markup (monospace, colour-coded meters), so this only spaces them out. */
.sysmon-view { margin: 4px 6px; }
.sysmon-row { margin: 1px 0; }
/* 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 -------------------------------------------------------- */
/* Transparent, not a translucent slab: its own alpha(@violet) fill stacked on
* top of the module's Cairo card fill, reading as a brighter square with hard
* corners. Let the card fill show through instead. */
.ansi-view,
.ansi-view text {
background: transparent;
background-color: transparent;
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

@ -1,35 +0,0 @@
"""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

@ -1,175 +0,0 @@
"""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

@ -1,106 +0,0 @@
"""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

@ -1,158 +0,0 @@
"""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

@ -1,191 +0,0 @@
"""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

@ -1,133 +0,0 @@
"""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

@ -1,477 +0,0 @@
"""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

@ -1,243 +0,0 @@
"""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(), fade_widget=self.root)
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)
self._hologram.start_intro()
def hide_menu(self) -> None:
self.grid.on_hide()
self.taskbar.collapse_panel()
self.grid.hide_takeover()
self.appdrawer.set_expanded(False)
# Play the closing dissolve first (content fades back into static), then
# actually hide via _finish_hide. If the hologram is off, hide at once.
if self._hologram.enabled and self._tick_id is not None:
self._hologram.start_outro(self._finish_hide)
else:
self._finish_hide()
def _finish_hide(self) -> None:
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

@ -1,12 +0,0 @@
[D-BUS Service]
# Route D-Bus activation of the freedesktop notification name to beacon instead
# of dunst. The dunst package ships /usr/share/dbus-1/services/org.knopwob.dunst.service
# which also claims Name=org.freedesktop.Notifications, so without this file D-Bus
# auto-activates dunst the moment any app posts a notification and dunst grabs the
# name before beacon can (beacon then exits on name-lost — see beacon/main.py).
# A service file in ~/.local/share/dbus-1/services/ takes precedence over /usr/share,
# so this wins the name resolution for org.freedesktop.Notifications.
#
# Deployed to ~/.local/share/dbus-1/services/ at install time (see updater.conf).
Name=org.freedesktop.Notifications
Exec=/home/themiro/Dotfiles/desktopenvs/hyprdrive/scripts/beacon-start.sh

View File

@ -1,278 +0,0 @@
"""Holographic scanline/sweep/noise overlay — the same treatment and tuning as
the rest of the Cosmonaut Shell suite (astro-menu / station-bar / orbit-menu /
horizon-dock lib/hologram.py), reused here so notification cards read as one more
orbit of the same look: scanline grid + a slow vertical sweep + drifting noise
specks, and a 'materialise out of static' intro when a card first appears.
Each notification card owns its own overlay (see notification.py); the stack
window (window.py) runs a single frame-clock tick and feeds every visible card's
overlay via .tick(dt).
"""
from __future__ import annotations
import math
import random
import cairo
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import GLib, Gtk # noqa: E402
# Same CyberQueer violet/magenta/red combo as the rest of the suite'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.18 # a touch stronger than the bar's — cards are the focus
SWEEP_PERIOD = 3.2 # seconds for one top-to-bottom pass
SWEEP_HALF_HEIGHT = 34.0 # card-sized panel, taller than the thin bar strip
NOISE_COUNT = 26
NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks
NOISE_LIFETIME = (0.5, 1.4)
NOISE_FADE_IN = 0.2
NOISE_FADE_OUT = 0.35
NOISE_ALPHA_RANGE = (0.10, 0.34)
EDGE_FADE_X = 40.0 # smooth horizontal fade-out of the scanline field
EDGE_FADE_Y = 34.0 # smooth vertical fade-out
INTRO_DURATION = 0.9 # brisk 'materialise out of static' when a card pops in
INTRO_STATIC = 900 # static specks at the very start of the intro
OUTRO_DURATION = 0.2 # snappy reverse dissolve back into static on dismiss
def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None,
intro_duration: float | None = None) -> None:
self.enabled = enabled
self._clip_func = clip_func # optional path-setter to clip the holo to the card shape
# widget whose opacity is ramped 0->1 during the intro so the card content
# genuinely fades in, rather than a solid haze block popping on
self._fade_widget = fade_widget
if intro_duration is not None:
self.INTRO_DURATION = intro_duration
self._sat_time = 0.0
self._particles: list[dict] = []
self._intro_t: float | None = None
self._outro_t: float | None = None
self._outro_done = None
# Wall-clock safety net: the frame-clock tick only advances while the
# compositor sends frame callbacks; if those stall the intro could freeze
# with content stuck at opacity 0. This timeout force-resolves it anyway.
self._intro_deadline_id: int | None = None
self._mask_cache: tuple | None = None
self.widget = Gtk.DrawingArea()
self.widget.set_can_target(False) # never steals clicks from the card underneath
self.widget.add_css_class("beacon-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
if self._intro_t is not None:
self._intro_t += dt
if self._intro_t >= self.INTRO_DURATION:
self._finish_intro()
elif self._fade_widget is not None:
p = self._intro_t / self.INTRO_DURATION
self._fade_widget.set_opacity(p * p * (3 - 2 * p))
if self._outro_t is not None:
self._outro_t += dt
po = min(1.0, self._outro_t / self.OUTRO_DURATION)
if self._fade_widget is not None:
self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po))
if self._outro_t >= self.OUTRO_DURATION:
done = self._outro_done
self._outro_t = None
self._outro_done = None
if done is not None:
done()
self.widget.queue_draw()
def _finish_intro(self) -> None:
self._intro_t = None
if self._intro_deadline_id is not None:
GLib.source_remove(self._intro_deadline_id)
self._intro_deadline_id = None
if self._fade_widget is not None:
self._fade_widget.set_opacity(1.0)
self.widget.queue_draw()
def start_intro(self) -> None:
"""Kick off the 'card materialising out of static' opening effect."""
if self.enabled:
self._outro_t = None
self._outro_done = None
self._intro_t = 0.0
if self._fade_widget is not None:
self._fade_widget.set_opacity(0.0)
if self._intro_deadline_id is not None:
GLib.source_remove(self._intro_deadline_id)
self._intro_deadline_id = GLib.timeout_add(
int(self.INTRO_DURATION * 1000) + 150, self._on_intro_deadline)
def _on_intro_deadline(self) -> bool:
self._intro_deadline_id = None
if self._intro_t is not None:
self._finish_intro()
return False # one-shot
def start_outro(self, on_done) -> None:
"""Reverse of the intro (card dissolving back into static), then on_done."""
if not self.enabled:
on_done()
return
self._intro_t = None
if self._intro_deadline_id is not None:
GLib.source_remove(self._intro_deadline_id)
self._intro_deadline_id = None
self._outro_t = 0.0
self._outro_done = on_done
# -- drawing --------------------------------------------------------------
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
if not self.enabled or width <= 0 or height <= 0:
return
if self._clip_func is not None:
cr.save()
self._clip_func(cr, width, height) # clip the scanlines to the card's rounded shape
cr.clip()
cr.push_group()
self._draw_content(cr, width, height)
cr.pop_group_to_source()
cr.mask(self._edge_fade_mask(width, height))
if self._intro_t is not None:
self._draw_intro(cr, width, height)
elif self._outro_t is not None:
self._draw_outro(cr, width, height)
if self._clip_func is not None:
cr.restore()
def _edge_fade_mask(self, width: float, height: float):
key = (int(width), int(height))
if self._mask_cache is not None and self._mask_cache[0] == key:
return self._mask_cache[1]
w, h = max(1, key[0]), max(1, key[1])
surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h)
m = cairo.Context(surf)
m.set_source_rgba(0, 0, 0, 1)
m.paint()
m.set_operator(cairo.OPERATOR_DEST_OUT)
fx = min(self.EDGE_FADE_X, w / 2)
fy = min(self.EDGE_FADE_Y, h / 2)
def band(x0, y0, x1, y1, rx, ry, rw, rh):
gr = cairo.LinearGradient(x0, y0, x1, y1)
gr.add_color_stop_rgba(0.0, 0, 0, 0, 1)
gr.add_color_stop_rgba(1.0, 0, 0, 0, 0)
m.set_source(gr)
m.rectangle(rx, ry, rw, rh)
m.fill()
band(0, 0, fx, 0, 0, 0, fx, h) # left
band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right
band(0, 0, 0, fy, 0, 0, w, fy) # top
band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom
pattern = cairo.SurfacePattern(surf)
self._mask_cache = (key, pattern)
return pattern
def _draw_intro(self, cr, width: float, height: float) -> None:
p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION))
strength = 1.0 - p
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
count = int(self.INTRO_STATIC * (strength ** 0.5))
for _ in range(count):
x = random.uniform(0, width)
y = random.uniform(0, height)
col = random.choice(colors)
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength))
sz = random.uniform(1.0, 2.8)
cr.rectangle(x, y, sz, sz)
cr.fill()
band = p * height
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1)))
cr.rectangle(0, band - 2.0, width, 4.0)
cr.fill()
def _draw_outro(self, cr, width: float, height: float) -> None:
po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION))
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
count = int(self.INTRO_STATIC * (po ** 0.5))
for _ in range(count):
x = random.uniform(0, width)
y = random.uniform(0, height)
col = random.choice(colors)
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po))
sz = random.uniform(1.0, 2.8)
cr.rectangle(x, y, sz, sz)
cr.fill()
band = (1.0 - po) * height
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1)))
cr.rectangle(0, band - 2.0, width, 4.0)
cr.fill()
def _draw_content(self, cr, width: float, height: float) -> None:
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.09)
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

@ -1,92 +0,0 @@
#!/usr/bin/env python3
"""beacon — the Cosmonaut Shell notification daemon for hyprdrive.
Replaces dunst: owns org.freedesktop.Notifications and renders each notification
as a holographic card (scanline/sweep/noise overlay, emitted-magenta text, violet
holo-glass, radio-squiggle divider) in a top-centre layer-shell stack, so
notifications read as one more orbit of the astro-menu / orbit-menu / station-bar
look instead of a plain popup.
main.py run the resident daemon (owns the notification bus name, stays hidden
until a notification arrives)
"""
from __future__ import annotations
import os
import signal
import sys
from pathlib import Path
# beacon-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 (same rationale as the rest of the suite's main.py).
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 theme # noqa: E402
from paths import APP_ID, FDN_NAME # noqa: E402
from server import NotificationServer # noqa: E402
from window import BeaconWindow # noqa: E402
class BeaconApp(Gtk.Application):
def __init__(self) -> None:
super().__init__(application_id=APP_ID,
flags=Gio.ApplicationFlags.DEFAULT_FLAGS)
self.window: BeaconWindow | None = None
self._server: NotificationServer | None = None
self._name_id = 0
def do_startup(self) -> None:
Gtk.Application.do_startup(self)
theme.load_css()
self.window = BeaconWindow(self, on_closed=self._on_closed)
# Own the freedesktop notification name; the server is created once the bus
# connection is in hand.
self._name_id = Gio.bus_own_name(
Gio.BusType.SESSION, FDN_NAME, Gio.BusNameOwnerFlags.NONE,
self._on_bus_acquired, None, self._on_name_lost)
# SIGUSR1 = close every visible card (the `Super+Ctrl+C` keybind, which
# used to run `dunstctl close-all`).
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGUSR1,
self._on_sigusr1)
self.hold() # stay alive with no visible window
def _on_sigusr1(self) -> bool:
if self.window is not None:
self.window.close_all()
return GLib.SOURCE_CONTINUE
def _on_bus_acquired(self, connection: Gio.DBusConnection, _name: str) -> None:
assert self.window is not None
self._server = NotificationServer(connection, self.window)
def _on_closed(self, nid: int, reason: int) -> None:
if self._server is not None:
self._server.emit_closed(nid, reason)
def _on_name_lost(self, _connection, _name: str) -> None:
# Another notification daemon (e.g. a still-running dunst) already owns it.
sys.stderr.write(
"beacon: could not acquire org.freedesktop.Notifications "
"(another notification daemon is running); exiting.\n")
self.quit()
def do_activate(self) -> None:
pass # resident daemon: nothing to do on activate
def main() -> int:
GLib.set_prgname("beacon")
return BeaconApp().run(sys.argv)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,225 +0,0 @@
"""A single holographic notification card.
Layout: [app icon] [ summary (emitted-magenta, letter-spaced) / radio-squiggle
divider / body ] with an optional row of action-pill buttons, all under a
scanline/sweep/noise HologramOverlay (lib/hologram.py) clipped to the card's
rounded rectangle. The card materialises out of static (holo intro) when it
appears and dissolves back into static when dismissed.
"""
from __future__ import annotations
import math
from typing import Callable, Optional
import cairo
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # noqa: E402
from lib.hologram import HologramOverlay
_CARD_RADIUS = 16.0
# Divider "radio wave" colour (Cairo, not CSS): a magenta wave line on a
# transparent background. Critical keeps an accent wave to match its frame.
_MAGENTA = (0xEB / 255, 0x00 / 255, 0xA6 / 255) # foreground wave (normal)
_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) # foreground wave (critical)
_SQUIGGLE_H = 14 # divider row height
_SQUIGGLE_AMP = 3.0 # wave amplitude (px)
_SQUIGGLE_CYCLES = 0.045 # cycles per px of width
_SQUIGGLE_SPEED = 2.4 # phase advance (rad/s) — a slowly travelling signal
def _rounded_path(cr, w: float, h: float, r: float = _CARD_RADIUS) -> None:
r = min(r, w / 2, h / 2)
cr.new_sub_path()
cr.arc(w - r, r, r, -math.pi / 2, 0)
cr.arc(w - r, h - r, r, 0, math.pi / 2)
cr.arc(r, h - r, r, math.pi / 2, math.pi)
cr.arc(r, r, r, math.pi, 3 * math.pi / 2)
cr.close_path()
class NotificationCard:
def __init__(self, nid: int, summary: str, body: str, urgency: int,
icon: str, image_data, actions: list[str],
on_action: Callable[[int, str], None],
on_dismiss: Callable[[int], None]) -> None:
self.nid = nid
self._on_action = on_action
self._on_dismiss = on_dismiss
self._dismissing = False
critical = urgency >= 2
# -- content -----------------------------------------------------------
content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
content.add_css_class("beacon-content")
img = self._build_icon(icon, image_data)
if img is not None:
content.append(img)
textcol = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
textcol.set_hexpand(True)
textcol.set_valign(Gtk.Align.CENTER)
if summary:
lbl = Gtk.Label(label=summary, xalign=0.0)
lbl.add_css_class("beacon-summary")
lbl.set_wrap(True)
lbl.set_wrap_mode(2) # WORD_CHAR
lbl.set_max_width_chars(30)
textcol.append(lbl)
self._squiggle_color = _ACCENT if critical else _MAGENTA
self._squiggle_phase = 0.0
self._squiggle = Gtk.DrawingArea()
self._squiggle.add_css_class("beacon-squiggle")
self._squiggle.set_content_height(_SQUIGGLE_H)
self._squiggle.set_hexpand(True)
self._squiggle.set_draw_func(self._draw_squiggle)
textcol.append(self._squiggle)
if body:
blbl = Gtk.Label(xalign=0.0)
blbl.add_css_class("beacon-body")
blbl.set_wrap(True)
blbl.set_wrap_mode(2)
blbl.set_max_width_chars(34)
# bodies may carry a small Pango-markup subset (<b>/<i>/<u>/<a>…);
# fall back to plain text if the sender's markup won't parse.
try:
blbl.set_markup(body)
except GLib.GError:
blbl.set_text(body)
textcol.append(blbl)
content.append(textcol)
if actions:
row = self._build_actions(actions)
if row is not None:
textcol.append(row)
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
card.add_css_class("beacon-card")
if critical:
card.add_css_class("critical")
card.append(content)
card.set_size_request(340, -1)
# -- hologram overlay --------------------------------------------------
self._holo = HologramOverlay(enabled=True, clip_func=_rounded_path,
fade_widget=content)
overlay = Gtk.Overlay()
overlay.set_child(card)
overlay.add_overlay(self._holo.widget)
overlay.set_measure_overlay(self._holo.widget, False)
# left-click anywhere on the card = invoke default action if present, else
# dismiss; right-click always dismisses.
self._default_action = "default" if "default" in actions else None
left = Gtk.GestureClick(button=1)
left.connect("released", self._on_left_click)
overlay.add_controller(left)
right = Gtk.GestureClick(button=3)
right.connect("released", lambda *_a: self.dismiss())
overlay.add_controller(right)
self.widget = overlay
self._holo.start_intro()
# -- public ---------------------------------------------------------------
def tick(self, dt: float) -> None:
self._holo.tick(dt)
self._squiggle_phase += dt * _SQUIGGLE_SPEED
self._squiggle.queue_draw() # travel the wave along like a live signal
def _draw_squiggle(self, _area, cr, width: int, height: int) -> None:
if width <= 0:
return
# transparent background — just the magenta (accent for critical) radio wave
mid = height / 2.0
amp = min(_SQUIGGLE_AMP, mid - 2.0)
k = _SQUIGGLE_CYCLES * 2.0 * math.pi # angular freq per px
steps = max(2, int(width))
cr.set_line_cap(cairo.LINE_CAP_ROUND)
cr.set_line_join(cairo.LINE_JOIN_ROUND)
# two passes: a soft wide glow, then a bright thin core — reads as emitted
# light over the purple bar, matching the card's holographic frame.
for line_w, alpha in ((3.0, 0.30), (1.4, 1.0)):
cr.set_line_width(line_w)
cr.set_source_rgba(*self._squiggle_color, alpha)
for i in range(steps + 1):
x = width * i / steps
y = mid + amp * math.sin(x * k + self._squiggle_phase)
cr.line_to(x, y) if i else cr.move_to(x, y)
cr.stroke()
def dismiss(self) -> None:
"""Play the dissolve, then hand the id back to the window for removal."""
if self._dismissing:
return
self._dismissing = True
self._holo.start_outro(lambda: self._on_dismiss(self.nid))
# -- internals ------------------------------------------------------------
def _on_left_click(self, *_a) -> None:
if self._default_action is not None:
self._on_action(self.nid, self._default_action)
else:
self.dismiss()
def _build_actions(self, actions: list[str]) -> Optional[Gtk.Box]:
# actions is a flat [key, label, key, label, …] list; "default" is the
# implicit click action and isn't shown as a button.
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
row.add_css_class("beacon-actions")
row.set_margin_top(6)
shown = 0
for i in range(0, len(actions) - 1, 2):
key, label = actions[i], actions[i + 1]
if key == "default":
continue
btn = Gtk.Button(label=label or key)
btn.add_css_class("beacon-action")
btn.connect("clicked", lambda _b, k=key: self._on_action(self.nid, k))
row.append(btn)
shown += 1
return row if shown else None
def _build_icon(self, icon: str, image_data) -> Optional[Gtk.Image]:
img: Optional[Gtk.Image] = None
if image_data is not None:
pb = self._pixbuf_from_hint(image_data)
if pb is not None:
img = Gtk.Image.new_from_paintable(Gdk.Texture.new_for_pixbuf(pb))
if img is None and icon:
if icon.startswith("file://"):
icon = icon[len("file://"):]
if icon.startswith("/"):
img = Gtk.Image.new_from_file(icon)
else:
img = Gtk.Image.new_from_icon_name(icon)
if img is None:
return None
img.add_css_class("beacon-icon")
img.set_pixel_size(44)
img.set_valign(Gtk.Align.START)
return img
@staticmethod
def _pixbuf_from_hint(data) -> Optional[GdkPixbuf.Pixbuf]:
# Spec "image-data": (width, height, rowstride, has_alpha, bits, channels, bytes)
try:
w, h, rowstride, has_alpha, bits, channels, raw = data
return GdkPixbuf.Pixbuf.new_from_bytes(
GLib.Bytes.new(bytes(raw)), GdkPixbuf.Colorspace.RGB,
has_alpha, bits, w, h, rowstride)
except (ValueError, TypeError):
return None

View File

@ -1,17 +0,0 @@
"""Shared filesystem locations. Works whether run from the repo or ~/.config."""
from __future__ import annotations
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
STYLE_DIR = BASE_DIR / "style"
# GApplication single-instance id (our own process). NOT the freedesktop
# notification name — that well-known name (org.freedesktop.Notifications) is
# owned separately in main.py, the same one dunst used to hold.
APP_ID = "eu.abdelbaki.beacon"
FDN_NAME = "org.freedesktop.Notifications"
FDN_PATH = "/org/freedesktop/Notifications"
FDN_IFACE = "org.freedesktop.Notifications"

View File

@ -1,146 +0,0 @@
"""org.freedesktop.Notifications D-Bus service — the well-known name dunst used
to own. Parses each Notify call and hands it to the stack window (window.py) to
render as a holographic card; owns per-notification expiry timers and emits the
spec's NotificationClosed / ActionInvoked signals.
Deliberately small: the visible/interactive spec subset (body markup, actions,
icons, urgency, replaces_id, timeouts, persistence) no sound/markup-hint
gymnastics.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gio, GLib # noqa: E402
from paths import FDN_IFACE, FDN_PATH
# reasons per the spec: 1 expired, 2 dismissed by user, 3 closed by call, 4 undefined
_INTROSPECTION_XML = """
<node>
<interface name="org.freedesktop.Notifications">
<method name="GetCapabilities">
<arg type="as" name="capabilities" direction="out"/>
</method>
<method name="Notify">
<arg type="s" name="app_name" direction="in"/>
<arg type="u" name="replaces_id" direction="in"/>
<arg type="s" name="app_icon" direction="in"/>
<arg type="s" name="summary" direction="in"/>
<arg type="s" name="body" direction="in"/>
<arg type="as" name="actions" direction="in"/>
<arg type="a{sv}" name="hints" direction="in"/>
<arg type="i" name="expire_timeout" direction="in"/>
<arg type="u" name="id" direction="out"/>
</method>
<method name="CloseNotification">
<arg type="u" name="id" direction="in"/>
</method>
<method name="GetServerInformation">
<arg type="s" name="name" direction="out"/>
<arg type="s" name="vendor" direction="out"/>
<arg type="s" name="version" direction="out"/>
<arg type="s" name="spec_version" direction="out"/>
</method>
<signal name="NotificationClosed">
<arg type="u" name="id"/>
<arg type="u" name="reason"/>
</signal>
<signal name="ActionInvoked">
<arg type="u" name="id"/>
<arg type="s" name="action_key"/>
</signal>
</interface>
</node>
"""
# server default timeouts (ms) by urgency when the client passes -1
_DEFAULT_TIMEOUT = {0: 5000, 1: 8000, 2: 0} # low / normal / critical(never)
class NotificationServer:
def __init__(self, connection: Gio.DBusConnection, window) -> None:
self._conn = connection
self._window = window
self._next_id = 1
self._timers: dict[int, int] = {}
node = Gio.DBusNodeInfo.new_for_xml(_INTROSPECTION_XML)
self._iface = node.interfaces[0]
connection.register_object(
FDN_PATH, self._iface, self._on_method_call, None, None)
# -- D-Bus dispatch -------------------------------------------------------
def _on_method_call(self, _conn, _sender, _path, _iface, method, params, invocation):
if method == "Notify":
invocation.return_value(GLib.Variant("(u)", (self._notify(params),)))
elif method == "CloseNotification":
(nid,) = params.unpack()
self._window.close_notification(int(nid), reason=3)
self._cancel_timer(int(nid))
invocation.return_value(None)
elif method == "GetCapabilities":
invocation.return_value(GLib.Variant("(as)", (
["body", "body-markup", "icon-static", "actions", "persistence"],)))
elif method == "GetServerInformation":
invocation.return_value(GLib.Variant("(ssss)", (
"beacon", "abdelbaki.eu", "1.0", "1.2")))
else:
invocation.return_error_literal(
Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD, method)
def _notify(self, params) -> int:
(app_name, replaces_id, app_icon, summary, body,
actions, hints, expire_timeout) = params.unpack()
nid = int(replaces_id) if replaces_id else self._next_id
if not replaces_id:
self._next_id += 1
urgency = int(hints.get("urgency", 1))
image_data = (hints.get("image-data") or hints.get("image_data")
or hints.get("icon_data"))
image_path = hints.get("image-path") or hints.get("image_path")
icon = image_path or app_icon or ""
self._window.show_notification(
nid, summary, body, urgency, icon, image_data, list(actions),
self._emit_action)
self._arm_timer(nid, int(expire_timeout), urgency)
return nid
# -- expiry timers --------------------------------------------------------
def _arm_timer(self, nid: int, expire_timeout: int, urgency: int) -> None:
self._cancel_timer(nid)
if expire_timeout < 0:
ms = _DEFAULT_TIMEOUT.get(urgency, 8000)
else:
ms = expire_timeout
if ms <= 0:
return # 0 = never expire (or critical default)
self._timers[nid] = GLib.timeout_add(ms, self._on_expire, nid)
def _on_expire(self, nid: int) -> bool:
self._timers.pop(nid, None)
self._window.close_notification(nid, reason=1) # 1 = expired
return False
def _cancel_timer(self, nid: int) -> None:
tid = self._timers.pop(nid, None)
if tid is not None:
GLib.source_remove(tid)
# -- signals (also the window's on_closed / on_action callbacks) -----------
def emit_closed(self, nid: int, reason: int) -> None:
self._cancel_timer(nid)
self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "NotificationClosed",
GLib.Variant("(uu)", (nid, reason)))
def _emit_action(self, nid: int, key: str) -> None:
self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "ActionInvoked",
GLib.Variant("(us)", (nid, key)))
# dismiss the card once its action fired, matching typical daemon behaviour
self._window.close_notification(nid, reason=2)

View File

@ -1,8 +0,0 @@
/* 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

@ -1,93 +0,0 @@
/* beacon holographic notification cards. Matches the astro-menu idiom
* (astro-menu/style/style.css): emitted-magenta text, violet holo-glass fills,
* glow-violet / accent frames, Agave Nerd Font Mono, rounded cards. The compositor
* blurs behind the translucent card fills (see the `beacon` layer-rule in
* hypr/usr/windowrules.lua); the scanline/sweep/noise depth is Cairo-drawn on top
* by lib/hologram.py. */
/* Emitted magenta reads as projected light on the blurred glass, exactly as the
* astro panel overrides its @text. */
@define-color text #EB00A6;
* {
font-family: "Agave Nerd Font Mono", monospace;
font-size: 12pt;
}
/* The CyberQueer GTK theme paints `* { background-color:#1a1a1a }` on every node,
* which would fill the surface and the gaps between cards with an opaque slab.
* Blank the structural nodes; the card asserts its own glass fill below. */
window,
window.background,
.beacon-window,
.beacon-stack,
.beacon-content,
box,
overlay,
label,
image,
drawingarea {
background: transparent;
background-color: transparent;
}
/* the holo-glass card */
.beacon-card {
background-color: alpha(@violet, 0.40);
border: 2px solid #8A5CFF; /* glow_violet — emitted-light frame */
border-radius: 16px;
padding: 12px 14px;
box-shadow: 0 0 16px 1px alpha(#8A5CFF, 0.35);
}
.beacon-card.critical {
border-color: @accent;
background-color: alpha(@accent, 0.14);
box-shadow: 0 0 18px 1px alpha(@accent, 0.40);
}
/* summary = emitted magenta, letter-spaced like the astro HUD headlines */
.beacon-summary {
color: @text;
font-weight: bold;
font-size: 12pt;
letter-spacing: 1px;
}
.beacon-card.critical .beacon-summary { color: @accent; }
/* radio-wave divider a Cairo sine path drawn in notification.py (colour handled
* there, per urgency); this only spaces it from the title/body. The explicit
* transparent bg beats the CyberQueer theme's `* { background-color:#1a1a1a }`
* (a class selector out-specifies its universal one), so no dark pill shows. */
.beacon-squiggle {
margin: 2px 0;
background: none;
background-color: transparent;
border: none;
box-shadow: none;
}
.beacon-body {
color: @text;
font-size: 11pt;
opacity: 0.95;
}
.beacon-icon { margin-right: 2px; }
/* action pills — astro-menu's .quad-action idiom */
.beacon-action {
color: @text;
background-color: alpha(@violet, 0.4);
border: 2px solid #8A5CFF;
border-radius: 20px;
padding: 2px 12px;
min-height: 22px;
transition: border-color 180ms ease, color 180ms ease, box-shadow 220ms ease, background 180ms ease;
}
.beacon-action:hover {
border-color: @accent;
color: @accent;
box-shadow: 0 0 12px 1px alpha(@accent, 0.55);
}
.beacon-hologram { background: transparent; }

View File

@ -1,30 +0,0 @@
"""Load the two stylesheets as ordered CSS providers — same scheme as the rest
of the Cosmonaut Shell suite (orbit-menu, horizon-dock, astro-menu, station-bar).
_colors.css defines the CyberQueer @define-color names; style.css consumes them.
Priority is USER+1 for the same reason as the others: 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

@ -1,135 +0,0 @@
"""The notification stack: a top-centre layer-shell surface that holds the live
notification cards and drives one frame-clock tick for all their holograms.
Sized to its content (anchored TOP only, so it floats centred like dunst did),
on the OVERLAY layer above normal windows. Newest card on top. The surface is
hidden whenever no cards are showing, so it never eats clicks on an empty screen.
"""
from __future__ import annotations
from typing import Callable, Optional
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gtk4LayerShell", "1.0")
from gi.repository import Gtk # noqa: E402
from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402
from notification import NotificationCard
MARGIN_TOP = 46
MAX_VISIBLE = 6
class BeaconWindow(Gtk.ApplicationWindow):
def __init__(self, app: Gtk.Application,
on_closed: Callable[[int, int], None]) -> None:
super().__init__(application=app)
self._on_closed = on_closed # (id, reason) -> emit NotificationClosed
self._cards: dict[int, NotificationCard] = {}
self._order: list[int] = [] # newest first
self._tick_id: Optional[int] = None
self._last_tick: Optional[float] = None
self.set_decorated(False)
self.add_css_class("beacon-window")
self._stack = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
self._stack.add_css_class("beacon-stack")
self._stack.set_halign(Gtk.Align.CENTER)
self._stack.set_valign(Gtk.Align.START)
self.set_child(self._stack)
self._init_layer_shell()
self.set_visible(False)
def _init_layer_shell(self) -> None:
LayerShell.init_for_window(self)
LayerShell.set_layer(self, LayerShell.Layer.OVERLAY)
LayerShell.set_namespace(self, "beacon")
LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.NONE)
LayerShell.set_anchor(self, LayerShell.Edge.TOP, True)
LayerShell.set_margin(self, LayerShell.Edge.TOP, MARGIN_TOP)
LayerShell.set_exclusive_zone(self, 0)
# -- public: driven by the D-Bus server -----------------------------------
def show_notification(self, nid: int, summary: str, body: str, urgency: int,
icon: str, image_data, actions: list[str],
on_action: Callable[[int, str], None]) -> None:
if nid in self._cards: # replaces_id: swap in place
self._drop_card(nid)
card = NotificationCard(nid, summary, body, urgency, icon, image_data,
actions, on_action, self._card_dismissed)
self._cards[nid] = card
self._order.insert(0, nid)
self._stack.prepend(card.widget)
# cap the stack: quietly expire the oldest beyond the limit
while len(self._order) > MAX_VISIBLE:
old = self._order[-1]
self._card_dismissed(old, reason=4) # 4 = undefined/expired-by-limit
self.set_visible(True)
self._ensure_tick()
def close_notification(self, nid: int, reason: int = 3) -> bool:
"""Server-/user-requested close. reason 3 = closed by CloseNotification."""
if nid not in self._cards:
return False
card = self._cards[nid]
# play the dissolve; removal + the NotificationClosed signal follow
card.dismiss()
self._pending_reason[nid] = reason
return True
def close_all(self, reason: int = 2) -> None:
"""Dismiss every visible card (the old `dunstctl close-all` keybind).
reason 2 = dismissed by user. Each card plays its dissolve; removal and
the NotificationClosed signals follow as the outros finish."""
for nid in list(self._order):
self.close_notification(nid, reason=reason)
# -- card lifecycle -------------------------------------------------------
_pending_reason: dict[int, int] = {}
def _card_dismissed(self, nid: int, reason: int = 2) -> None:
"""Called after a card's dissolve finishes (reason 2 = dismissed by user),
or directly for cap-expiry. Removes it and signals the closure."""
if nid not in self._cards:
return
reason = self._pending_reason.pop(nid, reason)
self._drop_card(nid)
self._on_closed(nid, reason)
if not self._cards:
self.set_visible(False)
self._stop_tick()
def _drop_card(self, nid: int) -> None:
card = self._cards.pop(nid, None)
if card is not None:
self._stack.remove(card.widget)
if nid in self._order:
self._order.remove(nid)
# -- animation tick -------------------------------------------------------
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
for card in list(self._cards.values()):
card.tick(dt)
return True
def _ensure_tick(self) -> None:
if self._tick_id is None:
self._last_tick = 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
self._last_tick = None

View File

@ -1,272 +0,0 @@
#? 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

@ -1,92 +0,0 @@
#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

@ -1,165 +0,0 @@
#!/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

@ -1,47 +0,0 @@
# 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 beacon
config btop
config ghostty
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 dunst # superseded by beacon (the Cosmonaut Shell notif daemon)
# NOTE: the dunst package still ships a D-Bus activation file
# (org.knopwob.dunst.service, Name=org.freedesktop.Notifications).
# Shadow it so D-Bus activates beacon, not dunst, by deploying
# beacon/dbus/ at install time:
# mkdir -p ~/.local/share/dbus-1/services
# cp ~/Dotfiles/desktopenvs/hyprdrive/beacon/dbus/*.service \
# ~/.local/share/dbus-1/services/
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

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

View File

@ -1,511 +0,0 @@
# 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.
# Bright "glow" violet (astro-menu's glow_violet) rather than the raw @violet —
# reads as emitted light over the blurred glass, since dunst can't Cairo-glow.
frame_color = "#8A5CFF"
# Progress-bar fill: violet -> accent gradient, same sweep as the menus.
highlight = "#8A5CFF, #EB00A6"
# 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 Mono 11
# 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.
# Cosmonaut Shell / astro-menu hologram idiom: the title as emitted magenta
# light (letter-spaced, like the astro HUD headlines), then a glow-violet
# "radio squiggle" divider (a sine-wave line, like a tuned signal), then the
# body. urgency_critical overrides this to shift title+squiggle to the accent.
format = "<b><span letter_spacing='1536'>%s</span></b>\n<span foreground='#8A5CFF' size='small' rise='2000'>∿∿∿∿∿∿∿∿∿∿∿∿∿∿</span>\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
# astro-menu hologram idiom (see astro-menu/style/style.css):
# emitted text = magenta #EB00A6 (glow_magenta), glass = violet-tinted + very
# transparent so the compositor blur reads as projected glass, glow frame =
# #8A5CFF (glow_violet), accent = #E40046. The astro panel gets its depth from
# Cairo scanlines/glow dunst can't draw, so we lean on colour + blur instead.
[urgency_low]
# IMPORTANT: colors have to be defined in quotation marks.
# Otherwise the "#" and following would be interpreted as a comment.
# Violet-tinted glass, ~63% opaque (…A0) so the blur reads as strong holo glass.
background = "#1C1442A0"
foreground = "#EB00A6"
frame_color = "#8A5CFF"
timeout = 10
# Icon for notifications with low urgency
default_icon = dialog-information
[urgency_normal]
background = "#1C1442A0"
foreground = "#EB00A6"
frame_color = "#8A5CFF"
timeout = 10
override_pause_level = 30
# Icon for notifications with normal urgency
default_icon = dialog-information
[urgency_critical]
# Accent-tinted violet glass + accent glow frame; emitted magenta text stays.
background = "#2E1030A0"
foreground = "#EB00A6"
frame_color = "#E40046"
# Shift the title + squiggle to the accent so they match the critical frame.
format = "<b><span letter_spacing='1536' foreground='#E40046'>%s</span></b>\n<span foreground='#E40046' size='small' rise='2000'>∿∿∿∿∿∿∿∿∿∿∿∿∿∿</span>\n%b"
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

@ -1,64 +0,0 @@
# CyberQueer — Ghostty config
# Ported from ../kitty/{kitty.conf,themes/cyberqueer.conf}. Palette matches
# ~/Dotfiles/colors.conf (COLOR_TEXT/BG/HIGHLIGHT/DARK/RED) plus the same
# neon green/yellow/magenta/cyan tints used in the kitty theme so ANSI
# output looks identical across both terminals. Kitty could only fake a
# glow with a static background image (../kitty/scanlines.png); here it's
# a real per-frame bloom shader — see shaders/cyberqueer-glow.glsl.
#
# NOT wired up as the active terminal yet: no keybinds or $TERMINAL changes
# point at Ghostty. This file just needs `ghostty --config-file=<this path>`
# (or deploying to ~/.config/ghostty/config) to try it.
font-family = Agave Nerd Font
font-size = 13
cursor-style = bar
cursor-style-blink = false
shell-integration-features = no-cursor
background-opacity = 0.4
background-blur = true
window-padding-x = 4
window-padding-y = 4
scrollback-limit = 10485760
confirm-close-surface = false
# No titlebar, no tab bar — Hyprland handles window placement/borders itself
# (matches kitty's borderless look under this compositor). Note: an old
# Ghostty bug (ghostty-org/ghostty#3178, fixed in 1.0.1) made this exact
# Arch+Hyprland combo render as a single flat color with gtk-titlebar=false;
# using window-decoration=none instead of gtk-titlebar avoids that specific
# combo, but if the "flat pane, no text" symptom ever comes back, this is
# the first place to check by reverting.
window-decoration = none
window-show-tab-bar = never
custom-shader = shaders/cyberqueer-glow.glsl
custom-shader-animation = always
foreground = D6ABAB
background = 1A1A1A
selection-foreground = E40046
selection-background = 5018DD
cursor-color = E40046
cursor-text = 5018DD
split-divider-color = 5018DD
palette = 0=1A1A1A
palette = 1=E40046
palette = 2=33E6A0
palette = 3=E8B23D
palette = 4=5018DD
palette = 5=A6178C
palette = 6=34C6C6
palette = 7=D6ABAB
palette = 8=4B3A55
palette = 9=F50505
palette = 10=5CFFC2
palette = 11=FFD873
palette = 12=8C6FFF
palette = 13=FF5FC0
palette = 14=6FF5F0
palette = 15=F2DEDE

View File

@ -1,202 +0,0 @@
// CyberQueer glow shader for Ghostty's custom-shader hook.
// Real per-pixel bloom (unlike kitty, which can only fake this with a static
// background image — see ../../kitty/scanlines.png). Deliberately
// color-preserving: it blooms whatever color a glyph already is, so the
// suite's violet/crimson/neon ANSI palette blooms in its own hues instead of
// a single hardcoded tint.
//
// Only one custom-shader reliably renders on Linux/OpenGL (stacking multiple
// silently drops all but the last), so bloom + scanlines + grain + glitch +
// the boot intro all live here as a single pass rather than several files.
//
// Four effects, composited in order:
// 1. occasional glitch/roll burst (analog-signal tear + vertical roll +
// chromatic aberration), ported from the technique in
// github.com/0xhckr/ghostty-shaders/blob/main/glitchy.glsl
// 2. bloom (existing, sampled post-glitch-warp)
// 3. scanlines + ambient grain (existing)
// 4. a one-time hologram boot intro: the picture resolves out of colored
// static via a downward sweep over the first ~1.8s of iTime.
// ---------- shared noise helpers (glitch §1 + intro §4) ----------
// SS: a windowed pulse — 0 outside [a,b], rising then falling inside it.
#define SS(a, b, x) (smoothstep(a, b, x) * smoothstep(b, a, x))
#define UI0 1597334673u
#define UI1 3812015801u
#define UI2 uvec2(UI0, UI1)
#define UI3 uvec3(UI0, UI1, 2798796415u)
#define UIF (1.0 / float(0xffffffffu))
// Hash by David Hoskins — reused from glitchy.glsl for the glitch burst's
// analog distortion and the intro's static field.
vec3 hash33(vec3 p) {
uvec3 q = uvec3(ivec3(p)) * UI3;
q = (q.x ^ q.y ^ q.z) * UI3;
return -1.0 + 2.0 * vec3(q) * UIF;
}
// Gradient noise by iq — continuous in space and time, so animating it
// reads as organic shimmer rather than a hard per-frame strobe (deliberate:
// this drives a full-window effect, so avoiding flicker matters).
float gnoise(vec3 x) {
vec3 p = floor(x);
vec3 w = fract(x);
vec3 u = w * w * w * (w * (w * 6.0 - 15.0) + 10.0);
vec3 ga = hash33(p + vec3(0.0, 0.0, 0.0));
vec3 gb = hash33(p + vec3(1.0, 0.0, 0.0));
vec3 gc = hash33(p + vec3(0.0, 1.0, 0.0));
vec3 gd = hash33(p + vec3(1.0, 1.0, 0.0));
vec3 ge = hash33(p + vec3(0.0, 0.0, 1.0));
vec3 gf = hash33(p + vec3(1.0, 0.0, 1.0));
vec3 gg = hash33(p + vec3(0.0, 1.0, 1.0));
vec3 gh = hash33(p + vec3(1.0, 1.0, 1.0));
float va = dot(ga, w - vec3(0.0, 0.0, 0.0));
float vb = dot(gb, w - vec3(1.0, 0.0, 0.0));
float vc = dot(gc, w - vec3(0.0, 1.0, 0.0));
float vd = dot(gd, w - vec3(1.0, 1.0, 0.0));
float ve = dot(ge, w - vec3(0.0, 0.0, 1.0));
float vf = dot(gf, w - vec3(1.0, 0.0, 1.0));
float vg = dot(gg, w - vec3(0.0, 1.0, 1.0));
float vh = dot(gh, w - vec3(1.0, 1.0, 1.0));
return 2.0 * (va + u.x * (vb - va) +
u.y * (vc - va) +
u.z * (ve - va) +
u.x * u.y * (va - vb - vc + vd) +
u.y * u.z * (va - vc - ve + vg) +
u.z * u.x * (va - vb - ve + vf) +
u.x * u.y * u.z * (-va + vb + vc - vd + ve - vf - vg + vh));
}
float gnoise01(vec3 x) {
return 0.5 + 0.5 * gnoise(x);
}
// Golden-spiral sample offsets + inverse-distance weights for the bloom,
// reused verbatim from the community bloom kernel (0xhckr/ghostty-shaders).
const vec3[24] samples = vec3[24](
vec3(0.1693761725038636, 0.9855514761735895, 1.0),
vec3(-1.333070830962943, 0.4721463328627773, 0.7071067811865475),
vec3(-0.8464394909806497, -1.51113870578065, 0.5773502691896258),
vec3(1.554155680728463, -1.2588090085709776, 0.5),
vec3(1.681364377589461, 1.4741145918052656, 0.4472135954999579),
vec3(-1.2795157692199817, 2.088741103228784, 0.4082482904638631),
vec3(-2.4575847530631187, -0.9799373355024756, 0.3779644730092272),
vec3(0.5874641440200847, -2.7667464429345077, 0.35355339059327373),
vec3(2.997715703369726, 0.11704939884745152, 0.3333333333333333),
vec3(0.41360842451688395, 3.1351121305574803, 0.31622776601683794),
vec3(-3.167149933769243, 0.9844599011770256, 0.30151134457776363),
vec3(-1.5736713846521535, -3.0860263079123245, 0.2886751345948129),
vec3(2.888202648340422, -2.1583061557896213, 0.2773500981126146),
vec3(2.7150778983300325, 2.5745586041105715, 0.2672612419124244),
vec3(-2.1504069972377464, 3.2211410627650165, 0.2581988897471611),
vec3(-3.6548858794907493, -1.6253643308191343, 0.25),
vec3(1.0130775986052671, -3.9967078676335834, 0.24253562503633297),
vec3(4.229723673607257, 0.33081361055181563, 0.23570226039551587),
vec3(0.40107790291173834, 4.340407413572593, 0.22941573387056174),
vec3(-4.319124570236028, 1.159811599693438, 0.22360679774997896),
vec3(-1.9209044802827355, -4.160543952132907, 0.2182178902359924),
vec3(3.8639122286635708, -2.6589814382925123, 0.21320071635561041),
vec3(3.3486228404946234, 3.4331800232609, 0.20851441405707477),
vec3(-2.8769733643574344, 3.9652268864187157, 0.20412414523193154)
);
float lum(vec4 c) {
return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;
}
// Cheap 2D hash for the ambient grain speckles — no texture lookup needed.
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
// How often (seconds) an occasional glitch/roll burst fires, and what
// fraction of that cycle it's actually active for — "occasional" means
// rare and brief, not a constant tic.
#define GLITCH_DURATION 18.0
#define GLITCH_AMT 0.045
// How long the hologram takes to resolve out of static when it first boots.
#define INTRO_DURATION 1.8
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord.xy / iResolution.xy;
float t = iTime;
// --- 1. occasional glitch/roll burst ---
// glitchAmount is ~0 almost all the time and spikes for under a second
// once every GLITCH_DURATION seconds.
float glitchAmount = SS(GLITCH_DURATION * 0.002, GLITCH_DURATION * GLITCH_AMT, mod(t, GLITCH_DURATION));
float rowY = fragCoord.y;
float tear = gnoise(vec3(0.0, rowY * 0.01, t * 500.0)) * (glitchAmount * 0.03 + 0.0015);
float roll = glitchAmount * gnoise(vec3(t * 3.0, 0.0, 0.0)) * 0.05;
vec2 guv = uv + vec2(tear, roll);
guv.y = fract(guv.y); // vertical roll wraps top/bottom, like a CRT losing vertical hold
vec2 caOffset = vec2(2.0 / iResolution.x, 0.0) * (1.0 + glitchAmount * 3.0);
vec4 color;
color.r = texture(iChannel0, guv + caOffset).r;
color.g = texture(iChannel0, guv).g;
color.b = texture(iChannel0, guv - caOffset).b;
color.a = texture(iChannel0, guv).a;
// --- 2. bloom: color-preserving glow around bright glyphs ---
vec2 bstep = vec2(1.414) / iResolution.xy;
for (int i = 0; i < 24; i++) {
vec3 s = samples[i];
vec4 c = texture(iChannel0, guv + s.xy * bstep);
float l = lum(c);
if (l > 0.25) {
color += l * s.z * c * 0.15;
}
}
// burst noise: a brief flash of static riding on top of the tear/roll
float burst = hash33(vec3(fragCoord, mod(float(iFrame), 1000.0))).r;
color.rgb += burst * glitchAmount * 0.35;
// --- 3. scanlines: a hard-edged dark line every 3rd physical row ---
float scan = mod(fragCoord.y, 3.0) < 1.0 ? 0.90 : 1.0;
color.rgb *= scan;
// holographic grain: sparse, low-alpha, slowly time-varying speckles in
// the theme's accent/violet/danger hues. Update rate capped at ~8Hz and
// intensity kept low deliberately — this sits behind text all day, so it
// should read as ambient shimmer, not a strobe.
vec2 grainCell = floor(fragCoord.xy / 2.0) + floor(iTime * 8.0);
float n = hash(grainCell);
if (n > 0.985) {
vec3 speck = n > 0.9925 ? vec3(0.894, 0.0, 0.271) // accent E40046
: n > 0.99 ? vec3(0.961, 0.020, 0.020) // danger F50505
: vec3(0.314, 0.094, 0.867); // violet 5018DD
color.rgb += speck * 0.10;
}
// --- 4. hologram boot intro: resolve out of colored static over
// INTRO_DURATION seconds via a downward sweep, then never run again for
// the life of the shader (t only grows). Uses gnoise (continuous) rather
// than a per-frame hash flip, so the static shimmers instead of strobing.
float introProgress = clamp(t / INTRO_DURATION, 0.0, 1.0);
float sweepFrac = introProgress * 1.15 - 0.05;
float resolved = 1.0 - smoothstep(sweepFrac - 0.05, sweepFrac + 0.05, uv.y);
float n1 = gnoise01(vec3(fragCoord * 0.08, t * 6.0));
float n2 = gnoise01(vec3(fragCoord * 0.08 + 19.0, t * 6.0));
vec3 violet = vec3(0.314, 0.094, 0.867);
vec3 accent = vec3(0.894, 0.0, 0.271);
vec3 danger = vec3(0.961, 0.020, 0.020);
vec3 introField = mix(violet, accent, n1);
introField = mix(introField, danger, smoothstep(0.75, 1.0, n2) * 0.5);
introField *= (0.18 + 0.22 * n2); // keep it dim — ambient static, not a flash
float sweepBand = 1.0 - smoothstep(0.0, 0.12, abs(uv.y - sweepFrac));
vec3 sweepGlow = violet * sweepBand * 1.2;
vec3 finalRGB = mix(introField, color.rgb, resolved) + sweepGlow;
fragColor = vec4(finalRGB, color.a);
}

View File

@ -1,16 +0,0 @@
[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

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

View File

@ -1,17 +0,0 @@
[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

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

View File

@ -1,77 +0,0 @@
"""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

@ -1,37 +0,0 @@
"""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

@ -1,660 +0,0 @@
"""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 json
import math
import random
import subprocess
import time
from typing import Callable, Optional
import cairo
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 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):
# The dock is one big "orbit node" (a glowing violet planet) whose centre sits
# below the screen, so only its top cap rises above the bottom edge. The three
# item rows are concentric rings on that cap and their icons ride it like
# satellites orbiting the node — the same visual language as orbit-menu.
# DOCK_HEIGHT is derived per-monitor from the two shape constraints below.
# A horizon-perspective dock: the orbits are foreshortened ellipse arcs that
# all converge to a HORIZON_Y line near the top, so they read as concentric
# rings lying on a plane receding to the horizon (à la a low-angle view of a
# ringed planet). Icons ride the front (near) edge of each ring. Short.
DOCK_HEIGHT = 210 # placeholder; recomputed per width (see _dock_height)
PLANET_SIZE = 52
ITEM_SPACING = 66.0 # center-to-center distance between planets in a row
BASE_MARGIN = 16.0 # the horizon line sits this far above the bottom edge
PERSPECTIVE_SQUASH = 0.19 # vertical foreshortening: low = flat, far-away horizon
ROW_ORDER = ["windows", "favorites", "apps"] # far -> near, top(horizon) -> bottom(front)
# each ring's horizontal radius as a fraction of the screen width; nearer rings
# (apps) are wider, farther rings (windows) narrower — that's the perspective.
ROW_HALFWIDTH = {"windows": 0.17, "favorites": 0.245, "apps": 0.32}
ROW_TITLE = {"windows": "Open Windows", "favorites": "Favorites", "apps": "All Apps"}
HOVER_BAND = 46.0 # how near the cursor must be to a ring to select it
GLYPH_FONT = "Agave Nerd Font Mono"
# decorative glyphs sprinkled along the orbits between icons (nerd-font)
ORBIT_GLYPHS = ["\uf444", "\uf10c", "\U000f0471", "\U000f1383", "\uf005"]
# Two ornamental satellite rings framing the (innermost) windows orbit \u2014 one
# just inside it, one just outside \u2014 as fractions of the windows ring radius.
SAT_RING_FACTORS = (0.60, 1.20)
TRAY_SIZE = 40
TRAY_MARGIN = 30.0
SCROLL_SENSITIVITY = 0.9
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 = False # tray removed — the dock is a clean orbit node now
self._apps = apps_source.AppSource()
self._tray = None
self._width = self._monitor_width()
self.DOCK_HEIGHT = self._dock_height() # per-monitor, shadows the class default
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._sat_time = 0.0
self._last_tick: Optional[float] = None
self._tick_id: Optional[int] = None
# cursor tracking for the mouse-reactive ornamental satellites
self._mouse_x = self._width / 2
self._mouse_y = 0.0
self._mouse_sx = self._mouse_x # smoothed (eased) toward the raw position
self._mouse_sy = self._mouse_y
self._satellites = self._make_satellites()
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)
# A Gtk.Fixed grows to the bounding box of ALL its children, and the dock
# places every app button at an absolute position (many far off-screen,
# each with an arc "dip" that grows unbounded with distance from centre).
# So the viewport's natural size balloons to thousands of px in both axes,
# and the layer-shell surface adopts that size — covering the whole screen
# with an (input-grabbing) surface. Clip it: a base Overlay sized ONLY to a
# DOCK_HEIGHT sizer, with the viewport added as a non-measured overlay
# child and overflow hidden, pins the window to exactly (width x
# DOCK_HEIGHT) no matter how large the Fixed inside gets.
self._sizer = Gtk.DrawingArea()
self._sizer.set_size_request(int(self._width), self.DOCK_HEIGHT)
clip = Gtk.Overlay()
clip.set_overflow(Gtk.Overflow.HIDDEN)
clip.set_child(self._sizer)
clip.add_overlay(self._viewport)
clip.set_measure_overlay(self._viewport, False)
self._hologram = HologramOverlay(enabled=hologram_enabled, clip_func=self._holo_clip,
fade_widget=self._content, intro_duration=2.4)
overlay = Gtk.Overlay()
overlay.set_child(clip)
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 _focused_gdk_monitor(self):
"""The Gdk monitor for Hyprland's currently-focused output, so the dock
opens on (and is sized to) whichever monitor you're on — not always
monitor 0. Falls back to monitor 0 if the lookup fails."""
display = Gdk.Display.get_default()
if display is None:
return None
monitors = display.get_monitors()
n = monitors.get_n_items() if monitors is not None else 0
name = None
try:
out = subprocess.run(["hyprctl", "-j", "monitors"],
capture_output=True, text=True, timeout=1).stdout
for m in json.loads(out):
if m.get("focused"):
name = m.get("name")
break
except Exception:
name = None
if name is not None:
for i in range(n):
mon = monitors.get_item(i)
if mon is not None and mon.get_connector() == name:
return mon
return monitors.get_item(0) if n else None
def _monitor_width(self) -> int:
mon = self._focused_gdk_monitor()
# Gdk logical width (already divided by the monitor's scale), which is the
# coordinate space layer-shell / GTK lay out in — not hyprctl's raw px.
return mon.get_geometry().width if mon is not None else 1920
# -- layout math (horizon perspective) ----------------------------------
def _ring_rx(self, row: str) -> float:
return self.ROW_HALFWIDTH[row] * self._width
def _ring_ry(self, row: str) -> float:
return self._ring_rx(row) * self.PERSPECTIVE_SQUASH
def _dock_height(self) -> int:
ry_max = max(self._ring_ry(r) for r in self.ROW_ORDER)
return int(self.BASE_MARGIN + ry_max + self.PLANET_SIZE / 2 + 14)
def _base_y(self) -> float:
"""The horizon line: near the BOTTOM of the dock. Orbits arch UP from it."""
return self.DOCK_HEIGHT - self.BASE_MARGIN
def _row_y_at(self, row: str, x: float) -> float:
"""y on the near edge of a ring's foreshortened ellipse — highest at the
centre, curving back down to the horizon line toward the sides."""
cx = self._width / 2
rx, ry = self._ring_rx(row), self._ring_ry(row)
dx = x - cx
if abs(dx) >= rx:
return self._base_y()
return self._base_y() - ry * math.sqrt(max(1.0 - (dx / rx) ** 2, 0.0))
def _center_offset(self, row: str) -> float:
"""The scroll offset that centres a row's items on the arc: the middle item
lands at the centre, so a short row is a centred cluster and a long row
fills the arc symmetrically (overflowing/fading equally on both sides)."""
return max(0.0, (len(self._items[row]) - 1) / 2.0)
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()
# start each orbit centred on the arc (re-centred whenever it's rebuilt)
self._scroll_offset[row] = self._center_offset(row)
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._place_item(row, btn, x, y, put=True)
self._widgets[row].append(btn)
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 _edge_fade(self, row: str, x: float) -> float:
"""1.0 in the middle of an orbit, smoothly fading to 0 as an icon nears the
ring's horizon extremity — so overflowing icons dissolve into the horizon
at the sides instead of piling up / being hard-clipped off the edge."""
rx = self._ring_rx(row)
dx = abs(x - self._width / 2)
start = rx * 0.68
if dx <= start:
return 1.0
if dx >= rx:
return 0.0
t = (dx - start) / (rx - start)
return 1.0 - t * t * (3 - 2 * t) # smoothstep down
def _place_item(self, row: str, w: Gtk.Widget, x: float, y: float, put: bool) -> None:
fx, fy = x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2
if put:
self._content.put(w, fx, fy)
else:
self._content.move(w, fx, fy)
fade = self._edge_fade(row, x)
w.set_opacity(fade)
w.set_can_target(fade > 0.05) # faded-out icons don't grab clicks
w.set_sensitive(fade > 0.05)
def _reflow_row(self, row: str) -> None:
for i, w in enumerate(self._widgets[row]):
x, y = self._item_position(row, i)
self._place_item(row, w, x, y, put=False)
def _rebuild_all(self) -> None:
for row in self.ROW_ORDER:
self._rebuild_row(row)
# -- hover / scroll routing -------------------------------------------------
def _row_at(self, x: float, y: float) -> Optional[str]:
"""The ring nearest the cursor (by vertical distance to its arc at x),
within HOVER_BAND so scrolling targets whichever orbit you're over."""
best, best_d = None, self.HOVER_BAND
for row in self.ROW_ORDER:
d = abs(y - self._row_y_at(row, x))
if d < best_d:
best, best_d = row, d
return best
def _on_motion(self, _ctrl, x: float, y: float) -> None:
self._mouse_x, self._mouse_y = x, y # drives the ornamental satellites
row = self._row_at(x, 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 giant orbit node + its rings -----------------------
def _draw_background(self, _area, cr, width: float, height: float) -> None:
self._draw_node(cr)
for row in self.ROW_ORDER:
self._draw_ring(cr, row, hovered=(row == self._hovered_row))
self._draw_satellites(cr)
self._draw_center_sphere(cr)
def _front_arc_path(self, cr, rx: float, ry: float, close_on_horizon: bool) -> None:
"""Trace the near edge of a ring's foreshortened ellipse (arching UP) from
the left horizon point across to the right one; optionally close it back
along the horizon line to make a fillable semi-ellipse dome."""
cx = self._width / 2
base = self._base_y()
steps = 72
cr.move_to(cx - rx, base)
for s in range(1, steps + 1):
x = cx - rx + 2 * rx * s / steps
dx = x - cx
y = base - ry * math.sqrt(max(1.0 - (dx / rx) ** 2, 0.0))
cr.line_to(x, y)
if close_on_horizon:
cr.close_path()
def _draw_node(self, cr) -> None:
"""The 'planet' the orbits sit on: the widest ring's foreshortened dome
filled with a vertical violet gradient (brighter at the arching near rim),
with a soft glowing edge a lit surface curving up from the horizon."""
rx = self._ring_rx("apps") * 1.05
ry = self._ring_ry("apps") * 1.05
base = self._base_y()
cr.save()
self._front_arc_path(cr, rx, ry, close_on_horizon=True)
grad = cairo.LinearGradient(0, base - ry, 0, base)
grad.add_color_stop_rgba(0.0, *_VIOLET, 0.30)
grad.add_color_stop_rgba(1.0, *_VIOLET, 0.06)
cr.set_source(grad)
cr.fill()
for lw, a in ((12.0, 0.05), (6.0, 0.10), (2.2, 0.5)):
self._front_arc_path(cr, rx, ry, close_on_horizon=False)
cr.set_source_rgba(*_ACCENT, a)
cr.set_line_width(lw)
cr.stroke()
cr.restore()
def _draw_ring(self, cr, row: str, hovered: bool) -> None:
"""A faint guide arc along a row's foreshortened orbit; the hovered ring
brightens to accent so you can see which orbit the scroll will move."""
cr.save()
if hovered:
cr.set_source_rgba(*_ACCENT, 0.4)
cr.set_line_width(2.0)
else:
cr.set_source_rgba(*_VIOLET, 0.22)
cr.set_line_width(1.2)
self._front_arc_path(cr, self._ring_rx(row), self._ring_ry(row), close_on_horizon=False)
cr.stroke()
cr.restore()
def _holo_clip(self, cr, width: float, height: float) -> None:
"""Path-setter handed to the hologram overlay: trace the node dome (widest
ring, expanded to cover the icon tops) so the scanlines are clipped to the
UI shape instead of painting a full-height rectangle above it."""
pad = self.PLANET_SIZE / 2 + 12
self._front_arc_path(cr, self._ring_rx("apps") + pad,
self._ring_ry("apps") + pad, close_on_horizon=True)
def _draw_center_sphere(self, cr) -> None:
"""The 'planet': a big holographic world rising from the horizon. Its top
fills the clear central band (icon buttons float in front of it), while its
bottom quarter sinks below the screen edge and is clipped away by the dock's
overflow so it reads as a huge planet, not a small bead. Translucent body
(the desktop/scanlines show through) with a strong outer glow + rim."""
cx = self._width / 2
base = self._base_y()
# Clear band below the lowest icon arch (windows); the readout lives here.
band_top = base - self._ring_ry("windows") + self.PLANET_SIZE / 2
bottom_edge = float(self.DOCK_HEIGHT) # dock surface bottom = screen bottom
vis_h = bottom_edge - band_top # visible vertical extent of the planet
# top anchored at band_top; radius sized so ~1/4 of the disc falls past the
# bottom edge (top 3/4 == the visible band): vis_h = 1.5 * rad.
rad = max(48.0, vis_h / 1.5)
cy = band_top + rad
cr.save()
# gentle breathing pulse so the planet reads as a live, radiant body
pulse = 0.82 + 0.18 * math.sin(self._sat_time * 2.2)
# wide outer bloom halo — the planet glows well beyond its own disc
halo_r = rad * 1.9
halo = cairo.RadialGradient(cx, cy, rad * 0.5, cx, cy, halo_r)
halo.add_color_stop_rgba(0.0, *_ACCENT, 0.40 * pulse)
halo.add_color_stop_rgba(0.45, *_VIOLET, 0.18 * pulse)
halo.add_color_stop_rgba(1.0, *_VIOLET, 0.0)
cr.arc(cx, cy, halo_r, 0, 2 * math.pi)
cr.set_source(halo)
cr.fill()
# translucent holographic body: a soft see-through core fading to a nearly
# transparent rim, so the blur/scanlines/desktop read through it
grad = cairo.RadialGradient(cx - rad * 0.3, cy - rad * 0.35, rad * 0.05, cx, cy, rad)
grad.add_color_stop_rgba(0.0, 1.0, 0.82, 0.98, 0.48)
grad.add_color_stop_rgba(0.4, *_VIOLET, 0.36)
grad.add_color_stop_rgba(1.0, *_VIOLET, 0.10)
cr.arc(cx, cy, rad, 0, 2 * math.pi)
cr.set_source(grad)
cr.fill()
# faint specular sheen (top-left) for a glassy 3D read
spec = cairo.RadialGradient(cx - rad * 0.34, cy - rad * 0.42, 0,
cx - rad * 0.34, cy - rad * 0.42, rad * 0.5)
spec.add_color_stop_rgba(0.0, 1, 1, 1, 0.38 * pulse)
spec.add_color_stop_rgba(1.0, 1, 1, 1, 0.0)
cr.arc(cx, cy, rad, 0, 2 * math.pi)
cr.set_source(spec)
cr.fill()
# strong, wide rim glow rings, capped by a crisp bright edge
for lw, a in ((24.0, 0.06 * pulse), (14.0, 0.12 * pulse),
(7.0, 0.22 * pulse), (2.6, 0.85)):
cr.arc(cx, cy, rad, 0, 2 * math.pi)
cr.set_source_rgba(*_ACCENT, a)
cr.set_line_width(lw)
cr.stroke()
# date + time readout, centred in the VISIBLE band (not at cy, which is low)
vis_cy = (band_top + bottom_edge) / 2
cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
clock = time.strftime("%H:%M")
cr.set_font_size(vis_h * 0.40)
ext = cr.text_extents(clock)
cr.move_to(cx - ext.width / 2 - ext.x_bearing,
vis_cy - ext.height / 2 - ext.y_bearing - vis_h * 0.05)
cr.set_source_rgba(0.98, 0.92, 0.99, 0.98)
cr.show_text(clock)
date = time.strftime("%a %d %b")
cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
cr.set_font_size(vis_h * 0.20)
ext2 = cr.text_extents(date)
cr.move_to(cx - ext2.width / 2 - ext2.x_bearing, vis_cy + vis_h * 0.30)
cr.set_source_rgba(*_ACCENT, 0.95)
cr.show_text(date)
cr.restore()
def _make_satellites(self) -> list[dict]:
"""A handful of ornamental glyphs, split across the two satellite rings that
frame the windows orbit (SAT_RING_FACTORS). Each ring gets a random 37 of
them at fixed base angles, and each satellite's *motion source* is picked at
random it either leans TOWARD the cursor, or is driven by the cursor's X,
or by the cursor's Y — so the pair of rings reads as a lively little swarm."""
sats: list[dict] = []
for fac in self.SAT_RING_FACTORS:
for _ in range(random.randint(3, 7)):
sats.append({
"fac": fac,
# base position on the visible (upper) half of the ring ellipse
"angle": random.uniform(0.16 * math.pi, 0.84 * math.pi),
"glyph": random.choice(self.ORBIT_GLYPHS),
"size": random.uniform(11.0, 15.0),
"source": random.choice(("toward", "mouseX", "mouseY")),
"amp": random.uniform(16.0, 32.0),
"wob_amp": random.uniform(1.5, 3.5),
"wob_speed": random.uniform(1.0, 2.0),
"phase": random.uniform(0.0, 2.0 * math.pi),
})
return sats
def _draw_satellites(self, cr) -> None:
"""Draw the two satellite rings (faint guide arcs) and their mouse-reactive
glyphs. Drawn on the background, so they sit behind the icons and sphere."""
cx = self._width / 2
base = self._base_y()
win_rx = self._ring_rx("windows")
mx, my = self._mouse_sx, self._mouse_sy
half_w = max(1.0, self._width / 2)
# faint guide arcs for the two rings
cr.save()
for fac in self.SAT_RING_FACTORS:
self._front_arc_path(cr, win_rx * fac, win_rx * fac * self.PERSPECTIVE_SQUASH,
close_on_horizon=False)
cr.set_source_rgba(*_VIOLET, 0.14)
cr.set_line_width(1.0)
cr.stroke()
cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
for s in self._satellites:
rx = win_rx * s["fac"]
ry = rx * self.PERSPECTIVE_SQUASH
bx = cx + rx * math.cos(s["angle"])
by = base - ry * math.sin(s["angle"])
wob = s["wob_amp"] * math.sin(self._sat_time * s["wob_speed"] + s["phase"])
ox = oy = 0.0
if s["source"] == "toward":
ddx, ddy = mx - bx, my - by
d = math.hypot(ddx, ddy) or 1.0
ox, oy = ddx / d * s["amp"], ddy / d * s["amp"] # lean toward the cursor
elif s["source"] == "mouseX":
ox = (mx - cx) / half_w * s["amp"] * 2.0 # driven by cursor X
else: # mouseY
oy = (my - base) / half_w * s["amp"] * 2.0 # driven by cursor Y
x = bx + ox + wob
y = by + oy + wob * 0.5
border_w = min(1.0, x / 44.0, (self._width - x) / 44.0)
if border_w <= 0.0:
continue
glyph = s["glyph"]
cr.set_font_size(s["size"])
ext = cr.text_extents(glyph)
cr.move_to(x - ext.width / 2 - ext.x_bearing, y - ext.height / 2 - ext.y_bearing)
cr.set_source_rgba(*_VIOLET, 0.7 * border_w)
cr.show_text(glyph)
cr.restore()
# -- animation loop -------------------------------------------------------
# No slide/fade reveal: the dock simply appears and the hologram's
# "materialise from static" intro (start_intro, below) is the whole opening
# effect — matching every other Cosmonaut Shell surface. The tick only drives
# the hologram animation while the dock is shown.
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
# ease the smoothed cursor toward the raw position (frame-rate independent)
e = 1.0 - math.exp(-dt * 6.0)
self._mouse_sx += (self._mouse_x - self._mouse_sx) * e
self._mouse_sy += (self._mouse_y - self._mouse_sy) * e
self._hologram.tick(dt)
self._bg.queue_draw() # animate the planet's glow + the satellites
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
# Reset the frame-time baseline so the next show's first tick has dt=0.
# Otherwise a warm reopen sees dt = (time the dock was hidden), which would
# blow past the whole intro in one frame and skip the materialise animation.
self._last_tick = None
# -- external control ----------------------------------------------------
def _apply_width(self, width: int) -> None:
self._width = width
self.DOCK_HEIGHT = self._dock_height()
for w in (self._bg, self._content, self._viewport, self._sizer):
w.set_size_request(int(width), self.DOCK_HEIGHT)
def show_dock(self) -> None:
mon = self._focused_gdk_monitor()
if mon is not None:
LayerShell.set_monitor(self, mon)
self._apply_width(mon.get_geometry().width)
else:
self._apply_width(self._monitor_width())
self._rebuild_all()
self.set_visible(True)
self.present()
self._ensure_tick()
self._hologram.start_intro()
if getattr(self, "_clock_timer_id", None) is None:
self._clock_timer_id = GLib.timeout_add_seconds(15, self._refresh_clock)
def _refresh_clock(self) -> bool:
if self.get_visible():
self._bg.queue_draw() # repaint the planet's date/time
return True
self._clock_timer_id = None
return False
def hide_dock(self) -> None:
# Dissolve into static first, then hide via _finish_hide.
if self._hologram.enabled and self._tick_id is not None:
self._hologram.start_outro(self._finish_hide)
else:
self._finish_hide()
def _finish_hide(self) -> None:
self.set_visible(False)
self._stop_tick()
if self._on_close:
self._on_close()
def toggle(self) -> None:
if self.get_visible():
self.hide_dock()
else:
self.show_dock()

View File

@ -1,265 +0,0 @@
"""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, _MAGENTA, _ACCENT] # magenta-biased specks
NOISE_LIFETIME = (0.5, 1.4)
NOISE_FADE_IN = 0.2
NOISE_FADE_OUT = 0.35
NOISE_ALPHA_RANGE = (0.10, 0.34)
EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field
EDGE_FADE_Y = 40.0 # smooth vertical fade-out
INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in
INTRO_STATIC = 1200 # static specks at the very start of the intro
OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close
def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None,
intro_duration: float | None = None) -> None:
self.enabled = enabled
self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape
# widget whose opacity is ramped 0->1 during the intro so the UI genuinely
# fades in (a gradual reveal), rather than a solid haze block popping on
self._fade_widget = fade_widget
if intro_duration is not None:
self.INTRO_DURATION = intro_duration # per-instance override of the class default
self._sat_time = 0.0
self._particles: list[dict] = []
self._intro_t: float | None = None # >=0 while the materialise intro plays
self._outro_t: float | None = None # >=0 while the closing dissolve plays
self._outro_done = None # callback fired when the dissolve finishes
self._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask
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
if self._intro_t is not None:
self._intro_t += dt
if self._intro_t >= self.INTRO_DURATION:
self._intro_t = None
if self._fade_widget is not None:
self._fade_widget.set_opacity(1.0)
elif self._fade_widget is not None:
p = self._intro_t / self.INTRO_DURATION
self._fade_widget.set_opacity(p * p * (3 - 2 * p)) # smooth ramp 0->1
if self._outro_t is not None:
self._outro_t += dt
po = min(1.0, self._outro_t / self.OUTRO_DURATION)
if self._fade_widget is not None:
self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) # ramp 1->0
if self._outro_t >= self.OUTRO_DURATION:
done = self._outro_done
self._outro_t = None
self._outro_done = None
if done is not None:
done()
self.widget.queue_draw()
def start_intro(self) -> None:
"""Kick off the 'hologram materialising out of static' opening effect."""
if self.enabled:
self._outro_t = None # cancel any in-flight closing dissolve
self._outro_done = None
self._intro_t = 0.0
if self._fade_widget is not None:
self._fade_widget.set_opacity(0.0) # start hidden; tick() ramps it up
def start_outro(self, on_done) -> None:
"""Play a quick reverse of the intro (content dissolving back into static),
then call on_done to actually hide. If disabled, hide immediately."""
if not self.enabled:
on_done()
return
self._intro_t = None # cancel any in-flight opening intro
self._outro_t = 0.0
self._outro_done = on_done
# -- drawing --------------------------------------------------------------
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
if not self.enabled or width <= 0 or height <= 0:
return
if self._clip_func is not None:
cr.save()
self._clip_func(cr, width, height) # clip the scanlines to the UI shape
cr.clip()
# Render the holo field into a group, then composite it back through a
# soft edge-fade mask so the scanlines dissolve at the borders (reads far
# more like a projected hologram than a hard-edged rectangle).
cr.push_group()
self._draw_content(cr, width, height)
cr.pop_group_to_source()
cr.mask(self._edge_fade_mask(width, height))
if self._intro_t is not None:
self._draw_intro(cr, width, height)
elif self._outro_t is not None:
self._draw_outro(cr, width, height)
if self._clip_func is not None:
cr.restore()
def _edge_fade_mask(self, width: float, height: float):
key = (int(width), int(height))
if self._mask_cache is not None and self._mask_cache[0] == key:
return self._mask_cache[1]
w, h = max(1, key[0]), max(1, key[1])
surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h)
m = cairo.Context(surf)
m.set_source_rgba(0, 0, 0, 1)
m.paint()
m.set_operator(cairo.OPERATOR_DEST_OUT) # subtract edge gradients from the solid
fx = min(self.EDGE_FADE_X, w / 2)
fy = min(self.EDGE_FADE_Y, h / 2)
def band(x0, y0, x1, y1, rx, ry, rw, rh):
gr = cairo.LinearGradient(x0, y0, x1, y1)
gr.add_color_stop_rgba(0.0, 0, 0, 0, 1)
gr.add_color_stop_rgba(1.0, 0, 0, 0, 0)
m.set_source(gr)
m.rectangle(rx, ry, rw, rh)
m.fill()
band(0, 0, fx, 0, 0, 0, fx, h) # left
band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right
band(0, 0, 0, fy, 0, 0, w, fy) # top
band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom
pattern = cairo.SurfacePattern(surf)
self._mask_cache = (key, pattern)
return pattern
def _draw_intro(self, cr, width: float, height: float) -> None:
p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION))
strength = 1.0 - p
# No solid veil block: the content itself fades in (see tick's fade_widget
# ramp). Here we only lay churning static over it — dense at first, thinning
# to nothing — so the UI resolves out of noise as it fades up.
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
count = int(self.INTRO_STATIC * (strength ** 0.5)) # dense, thinning to none
for _ in range(count):
x = random.uniform(0, width)
y = random.uniform(0, height)
col = random.choice(colors)
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength))
sz = random.uniform(1.0, 2.8)
cr.rectangle(x, y, sz, sz)
cr.fill()
band = p * height # a bright scan wiping down as it resolves
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1)))
cr.rectangle(0, band - 2.0, width, 4.0)
cr.fill()
def _draw_outro(self, cr, width: float, height: float) -> None:
# Reverse of the intro: the content is already fading back out (tick's
# fade_widget ramp 1->0); here the static thickens from nothing as it goes,
# so the panel dissolves into noise just before it vanishes.
po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION))
colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
count = int(self.INTRO_STATIC * (po ** 0.5))
for _ in range(count):
x = random.uniform(0, width)
y = random.uniform(0, height)
col = random.choice(colors)
cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po))
sz = random.uniform(1.0, 2.8)
cr.rectangle(x, y, sz, sz)
cr.fill()
band = (1.0 - po) * height # scan wiping back up as it dissolves
cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1)))
cr.rectangle(0, band - 2.0, width, 4.0)
cr.fill()
def _draw_content(self, cr, width: float, height: float) -> None:
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()

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