Compare commits
No commits in common. "main" and "feat/astal-menu" have entirely different histories.
main
...
feat/astal
|
|
@ -46,7 +46,6 @@ lazy-lock.json
|
||||||
*.vhdx
|
*.vhdx
|
||||||
*.deb
|
*.deb
|
||||||
*.rpm
|
*.rpm
|
||||||
*/nohup.out
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
**/*.log
|
**/*.log
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
} */
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"tabWidth": 4,
|
|
||||||
"singleQuote": true,
|
|
||||||
"useTabs": true,
|
|
||||||
"printWidth": 200
|
|
||||||
}
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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).
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
# system24
|
|
||||||
|
|
||||||
a tui-style discord theme, inspired by [spicetify text theme](https://github.com/spicetify/spicetify-themes/tree/master/text).
|
|
||||||
|
|
||||||

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

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

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

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

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

|
|
||||||
|
Before Width: | Height: | Size: 769 KiB |
|
Before Width: | Height: | Size: 629 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 620 KiB |
|
Before Width: | Height: | Size: 615 KiB |
|
Before Width: | Height: | Size: 640 KiB |
|
|
@ -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')
|
|
||||||
}
|
|
||||||
|
|
@ -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,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,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,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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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%);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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%);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
/home/themiro/Dotfiles/desktopenvs/hyprland/alacritty
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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:]))
|
|
||||||
|
|
@ -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])
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)],
|
|
||||||
)
|
|
||||||
|
|
@ -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)],
|
|
||||||
)
|
|
||||||
|
|
@ -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)],
|
|
||||||
)
|
|
||||||
|
|
@ -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}'>-<</span>{bar}"
|
|
||||||
f"<span foreground='{_ACCENT}'>>-</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,
|
|
||||||
)
|
|
||||||
|
|
@ -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)],
|
|
||||||
)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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; }
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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 = ""
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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/
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
/home/themiro/Dotfiles/desktopenvs/hyprland/dunst/
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
file://$HOME/Nextcloud
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -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 3–7 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()
|
|
||||||
|
|
@ -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()
|
|
||||||