feat(hyprdrive): add hyprdrive DE with full Cosmonaut Shell suite
New leaner Hyprland DE (setup/modules/Desktop-Environments/hyprdrive.sh),
a sibling of hyprlua that drops the older EWW/nwg-shell/wofi/walker/
ulauncher stack in favor of a purpose-built, GTK4 layer-shell component
suite sharing one CyberQueer hologram visual language:
- orbit-menu radial power/utility menu (focal-point zoom nav, holographic
overlay, cosmetic satellites)
- horizon-dock hover-scrollable orbital app dock (open windows/favorites/
all apps orbits, SNI tray satellite)
- astro-menu astal-menu re-themed as a hologram info display (Location/
Weather/Bluetooth/Network quads, taskbar, app drawer)
- station-bar the EWW top bar's replacement ("Voidstation Status Bar"):
a thin curved layer-shell bar tracking Hyprland workspaces/
active window natively over .socket2.sock, no polling
Every component now shares one consistent hologram treatment: translucent
violet panel/node fills (replacing solid gray), a config-toggleable
scanline+sweep+noise overlay (lib/hologram.py, ported to horizon-dock and
station-bar to match orbit-menu/astro-menu), and glow-on-hover throughout.
Also adds horizon-dock to hyprlua (built there first, before hyprdrive
existed) and wires dependency checks, config-updater entries, and
apply-theme.sh color sync for every new component across both DEs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nu8JkQ95hPAT8vADeGDAmL
main
|
|
@ -42,6 +42,11 @@ USER_FILES=(
|
||||||
"desktopenvs/hyprland/nwg-panel/menu-start.css|$HOME/.config/nwg-panel/menu-start.css"
|
"desktopenvs/hyprland/nwg-panel/menu-start.css|$HOME/.config/nwg-panel/menu-start.css"
|
||||||
"desktopenvs/hyprlua/astal-menu/style/_colors.css|$HOME/.config/astal-menu/style/_colors.css"
|
"desktopenvs/hyprlua/astal-menu/style/_colors.css|$HOME/.config/astal-menu/style/_colors.css"
|
||||||
"desktopenvs/hyprlua/orbit-menu/style/_colors.css|$HOME/.config/orbit-menu/style/_colors.css"
|
"desktopenvs/hyprlua/orbit-menu/style/_colors.css|$HOME/.config/orbit-menu/style/_colors.css"
|
||||||
|
"desktopenvs/hyprlua/horizon-dock/style/_colors.css|$HOME/.config/horizon-dock/style/_colors.css"
|
||||||
|
"desktopenvs/hyprdrive/orbit-menu/style/_colors.css|$HOME/.config/orbit-menu/style/_colors.css"
|
||||||
|
"desktopenvs/hyprdrive/horizon-dock/style/_colors.css|$HOME/.config/horizon-dock/style/_colors.css"
|
||||||
|
"desktopenvs/hyprdrive/astro-menu/style/_colors.css|$HOME/.config/astro-menu/style/_colors.css"
|
||||||
|
"desktopenvs/hyprdrive/station-bar/style/_colors.css|$HOME/.config/station-bar/style/_colors.css"
|
||||||
"desktopenvs/hyprland/vicinae/cyberqueer.toml|$HOME/.config/vicinae/cyberqueer.toml"
|
"desktopenvs/hyprland/vicinae/cyberqueer.toml|$HOME/.config/vicinae/cyberqueer.toml"
|
||||||
"desktopenvs/hyprland/scripts/onscreenkb.sh|$HOME/.config/scripts/onscreenkb.sh"
|
"desktopenvs/hyprland/scripts/onscreenkb.sh|$HOME/.config/scripts/onscreenkb.sh"
|
||||||
"desktopenvs/hyprland/spicetify/Themes/cli-cyberqueer/color.ini|$HOME/.config/spicetify/Themes/cli-cyberqueer/color.ini"
|
"desktopenvs/hyprland/spicetify/Themes/cli-cyberqueer/color.ini|$HOME/.config/spicetify/Themes/cli-cyberqueer/color.ini"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"backgroundColor": "#1a1a1a",
|
||||||
|
"fontColor": "#d6abab",
|
||||||
|
"flickering": 0.2,
|
||||||
|
"horizontalSync": 0.3762,
|
||||||
|
"staticNoise": 0.0955,
|
||||||
|
"chromaColor": 1,
|
||||||
|
"saturationColor": 0.4983,
|
||||||
|
"screenCurvature": 0.1716,
|
||||||
|
"glowingLine": 0.1476,
|
||||||
|
"burnIn": 0.0754,
|
||||||
|
"bloom": 0.5017,
|
||||||
|
"rasterization": 1,
|
||||||
|
"jitter": 0.2368,
|
||||||
|
"rbgShift": 0.0224,
|
||||||
|
"brightness": 0.5014,
|
||||||
|
"contrast": 0.85,
|
||||||
|
"ambientLight": 0.0835,
|
||||||
|
"windowOpacity": 0.2561,
|
||||||
|
"fontName": "TERMINUS_SCALED",
|
||||||
|
"fontWidth": 1,
|
||||||
|
"margin": 0.1,
|
||||||
|
"blinkingCursor": true,
|
||||||
|
"frameMargin": 0,
|
||||||
|
"name": "Cyberqeer",
|
||||||
|
"version": 2
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,632 @@
|
||||||
|
{
|
||||||
|
"autoUpdate": true,
|
||||||
|
"autoUpdateNotification": true,
|
||||||
|
"useQuickCss": true,
|
||||||
|
"themeLinks": [],
|
||||||
|
"eagerPatches": false,
|
||||||
|
"enabledThemes": [
|
||||||
|
"cyberqueer.theme.css"
|
||||||
|
],
|
||||||
|
"enableReactDevtools": true,
|
||||||
|
"frameless": false,
|
||||||
|
"transparent": false,
|
||||||
|
"winCtrlQ": false,
|
||||||
|
"disableMinSize": false,
|
||||||
|
"winNativeTitleBar": false,
|
||||||
|
"plugins": {
|
||||||
|
"ChatInputButtonAPI": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"CommandsAPI": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"DynamicImageModalAPI": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"MemberListDecoratorsAPI": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"MessageAccessoriesAPI": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"MessageDecorationsAPI": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"MessageEventsAPI": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"MessagePopoverAPI": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"MessageUpdaterAPI": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"ServerListAPI": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"UserSettingsAPI": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"AccountPanelServerProfile": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"AlwaysAnimate": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"AlwaysExpandRoles": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"AlwaysTrust": {
|
||||||
|
"enabled": true,
|
||||||
|
"domain": true,
|
||||||
|
"file": true
|
||||||
|
},
|
||||||
|
"AnonymiseFileNames": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"AppleMusicRichPresence": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"BANger": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"BetterFolders": {
|
||||||
|
"enabled": false,
|
||||||
|
"sidebar": true,
|
||||||
|
"showFolderIcon": 1,
|
||||||
|
"keepIcons": false,
|
||||||
|
"closeAllHomeButton": false,
|
||||||
|
"closeAllFolders": false,
|
||||||
|
"forceOpen": false,
|
||||||
|
"sidebarAnim": true,
|
||||||
|
"closeOthers": false
|
||||||
|
},
|
||||||
|
"BetterGifAltText": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"BetterGifPicker": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"BetterNotesBox": {
|
||||||
|
"enabled": true,
|
||||||
|
"hide": false
|
||||||
|
},
|
||||||
|
"BetterRoleContext": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"BetterRoleDot": {
|
||||||
|
"enabled": true,
|
||||||
|
"bothStyles": false,
|
||||||
|
"copyRoleColorInProfilePopout": false
|
||||||
|
},
|
||||||
|
"BetterSessions": {
|
||||||
|
"enabled": true,
|
||||||
|
"backgroundCheck": false
|
||||||
|
},
|
||||||
|
"BetterSettings": {
|
||||||
|
"enabled": true,
|
||||||
|
"disableFade": true,
|
||||||
|
"eagerLoad": true,
|
||||||
|
"organizeMenu": true
|
||||||
|
},
|
||||||
|
"BetterUploadButton": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"BiggerStreamPreview": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"BlurNSFW": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"CallTimer": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"ClearURLs": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ClientTheme": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ColorSighted": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ConsoleJanitor": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ConsoleShortcuts": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"CopyEmojiMarkdown": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"CopyFileContents": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"CopyUserURLs": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"CrashHandler": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"CtrlEnterSend": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"CustomRPC": {
|
||||||
|
"enabled": true,
|
||||||
|
"type": 0
|
||||||
|
},
|
||||||
|
"CustomIdle": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"Dearrow": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"Decor": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"DisableCallIdle": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"DontRoundMyTimestamps": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"EmoteCloner": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"Experiments": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"F8Break": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"FakeNitro": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"FakeProfileThemes": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"FavoriteEmojiFirst": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"FavoriteGifSearch": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"FixCodeblockGap": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"FixImagesQuality": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"FixSpotifyEmbeds": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"FixYoutubeEmbeds": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"ForceOwnerCrown": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"FriendInvites": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"FriendsSince": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"FullSearchContext": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"FullUserInChatbox": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"GameActivityToggle": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"GifPaste": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"GreetStickerPicker": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"HideMedia": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"iLoveSpam": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"IgnoreActivities": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ImageLink": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ImageZoom": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ImplicitRelationships": {
|
||||||
|
"enabled": true,
|
||||||
|
"sortByAffinity": true
|
||||||
|
},
|
||||||
|
"InvisibleChat": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"IrcColors": {
|
||||||
|
"enabled": false,
|
||||||
|
"memberListColors": true,
|
||||||
|
"lightness": 70,
|
||||||
|
"applyColorOnlyInDms": false,
|
||||||
|
"applyColorOnlyToUsersWithoutColor": false
|
||||||
|
},
|
||||||
|
"KeepCurrentChannel": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"LastFMRichPresence": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"LoadingQuotes": {
|
||||||
|
"enabled": true,
|
||||||
|
"replaceEvents": true,
|
||||||
|
"enableDiscordPresetQuotes": false,
|
||||||
|
"additionalQuotes": "",
|
||||||
|
"additionalQuotesDelimiter": "|",
|
||||||
|
"enablePluginPresetQuotes": true
|
||||||
|
},
|
||||||
|
"MemberCount": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"MentionAvatars": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"MessageClickActions": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"MessageLatency": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"MessageLinkEmbeds": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"MessageLogger": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"MessageTags": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"MoreCommands": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"MoreKaomoji": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"Moyai": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"MutualGroupDMs": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NewGuildSettings": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NoBlockedMessages": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NoDevtoolsWarning": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NoF1": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NoMaskedUrlPaste": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NoMosaic": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NoOnboardingDelay": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NoPendingCount": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NoProfileThemes": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NoRPC": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NoReplyMention": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NoServerEmojis": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"NoSystemBadge": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NoTypingAnimation": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NoUnblockToJump": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NormalizeMessageLinks": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NotificationVolume": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"NSFWGateBypass": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"OnePingPerDM": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"oneko": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"OpenInApp": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"OverrideForumDefaults": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"PartyMode": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"PauseInvitesForever": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"PermissionFreeWill": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"PermissionsViewer": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"petpet": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"PictureInPicture": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"PinDMs": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"PlainFolderIcon": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"PlatformIndicators": {
|
||||||
|
"enabled": true,
|
||||||
|
"colorMobileIndicator": true,
|
||||||
|
"list": true,
|
||||||
|
"badges": true,
|
||||||
|
"messages": true
|
||||||
|
},
|
||||||
|
"PreviewMessage": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"QuickMention": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"QuickReply": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"ReactErrorDecoder": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ReadAllNotificationsButton": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"RelationshipNotifier": {
|
||||||
|
"enabled": true,
|
||||||
|
"offlineRemovals": true,
|
||||||
|
"groups": true,
|
||||||
|
"servers": true,
|
||||||
|
"friends": true,
|
||||||
|
"friendRequestCancels": true
|
||||||
|
},
|
||||||
|
"ReplaceGoogleSearch": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ReplyTimestamp": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"RevealAllSpoilers": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ReverseImageSearch": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"ReviewDB": {
|
||||||
|
"enabled": true,
|
||||||
|
"notifyReviews": true
|
||||||
|
},
|
||||||
|
"RoleColorEverywhere": {
|
||||||
|
"enabled": true,
|
||||||
|
"chatMentions": true,
|
||||||
|
"memberList": true,
|
||||||
|
"voiceUsers": true,
|
||||||
|
"reactorsList": true,
|
||||||
|
"pollResults": true,
|
||||||
|
"colorChatMessages": false
|
||||||
|
},
|
||||||
|
"SecretRingToneEnabler": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"Summaries": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"SendTimestamps": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ServerInfo": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ServerListIndicators": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ShikiCodeblocks": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ShowAllMessageButtons": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ShowConnections": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ShowHiddenChannels": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ShowHiddenThings": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ShowMeYourName": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ShowTimeoutDuration": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"SilentMessageToggle": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"SilentTyping": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"SortFriendRequests": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"SpotifyControls": {
|
||||||
|
"enabled": true,
|
||||||
|
"hoverControls": false
|
||||||
|
},
|
||||||
|
"SpotifyCrack": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"SpotifyShareCommands": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"StartupTimings": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"StickerPaste": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"StreamerModeOnStream": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"SuperReactionTweaks": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"TextReplace": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ThemeAttributes": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"Translate": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"TypingIndicator": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"TypingTweaks": {
|
||||||
|
"enabled": true,
|
||||||
|
"alternativeFormatting": true
|
||||||
|
},
|
||||||
|
"Unindent": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"UnlockedAvatarZoom": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"UnsuppressEmbeds": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"UserMessagesPronouns": {
|
||||||
|
"enabled": true,
|
||||||
|
"showSelf": true,
|
||||||
|
"pronounsFormat": "LOWERCASE"
|
||||||
|
},
|
||||||
|
"UserVoiceShow": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"USRBG": {
|
||||||
|
"enabled": true,
|
||||||
|
"voiceBackground": true,
|
||||||
|
"nitroFirst": true
|
||||||
|
},
|
||||||
|
"ValidReply": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"ValidUser": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"VoiceChatDoubleClick": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"VcNarrator": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"VencordToolbox": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"ViewIcons": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"ViewRaw": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"VoiceDownload": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"VoiceMessages": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"VolumeBooster": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"WhoReacted": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"XSOverlay": {
|
||||||
|
"enabled": true,
|
||||||
|
"webSocketPort": 42070,
|
||||||
|
"botNotifications": false,
|
||||||
|
"pingColor": "#7289da",
|
||||||
|
"channelPingColor": "#8a2be2",
|
||||||
|
"serverNotifications": true,
|
||||||
|
"lengthBasedTimeout": true,
|
||||||
|
"opacity": 1,
|
||||||
|
"volume": 0.2,
|
||||||
|
"soundPath": "default",
|
||||||
|
"preferUDP": false
|
||||||
|
},
|
||||||
|
"YoutubeAdblock": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"BadgeAPI": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"NoTrack": {
|
||||||
|
"enabled": true,
|
||||||
|
"disableAnalytics": true
|
||||||
|
},
|
||||||
|
"Settings": {
|
||||||
|
"enabled": true,
|
||||||
|
"settingsLocation": "aboveNitro"
|
||||||
|
},
|
||||||
|
"SupportHelper": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"timeout": 5000,
|
||||||
|
"position": "bottom-right",
|
||||||
|
"useNative": "not-focused",
|
||||||
|
"logLimit": 50
|
||||||
|
},
|
||||||
|
"cloud": {
|
||||||
|
"authenticated": false,
|
||||||
|
"url": "https://api.vencord.dev/",
|
||||||
|
"settingsSync": false,
|
||||||
|
"settingsSyncVersion": 1744550478260
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
/**
|
||||||
|
* @name system24
|
||||||
|
* @description A tui-style discord theme.
|
||||||
|
* @author refact0r
|
||||||
|
* @version 1.0.0
|
||||||
|
* @invite nz87hXyvcy
|
||||||
|
* @website https://github.com/refact0r/system24
|
||||||
|
* @source https://github.com/refact0r/system24/blob/master/system24.theme.css
|
||||||
|
* @authorId 508863359777505290
|
||||||
|
* @authorLink https://www.refact0r.dev
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
|
||||||
|
/* @import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
|
||||||
|
*{
|
||||||
|
font-family: 'Agave Nerd Font Mono';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* customize things here */
|
||||||
|
:root {
|
||||||
|
font: 'Agave Nerd Font'; /* UI font name. it must be installed on your system. */
|
||||||
|
letter-spacing: 0; /* decreases letter spacing for better readability. */
|
||||||
|
font-weight: 400; /* UI font weight. */
|
||||||
|
--label-font-weight: 400; /* font weight for panel labels. */
|
||||||
|
--corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */
|
||||||
|
--pad: 20px; /* padding between panels. */
|
||||||
|
--txt-pad: 5px; /* padding inside panels to prevent labels from clipping */
|
||||||
|
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
|
||||||
|
|
||||||
|
/* background colors */
|
||||||
|
--bg-0: #1a1a1a; /* main background color. */
|
||||||
|
--bg-1: #1a1a1a; /* background color for secondary elements like code blocks, embeds, etc. */
|
||||||
|
--bg-2: #1a1a1a; /* color of neutral buttons. */
|
||||||
|
--bg-3: #641a1a; /* color of neutral buttons when hovered. */
|
||||||
|
|
||||||
|
/* state modifiers */
|
||||||
|
--hover: #5018dd;/* color of hovered elements. */
|
||||||
|
--active: #E40046;/* color of elements when clicked. */
|
||||||
|
--selected: #5018dd; /* color of selected elements. */
|
||||||
|
|
||||||
|
/* text colors */
|
||||||
|
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
|
||||||
|
--txt-link: var(--cyan); /* color of links. */
|
||||||
|
--txt-0: #d6abab; /* color of bright/white text. */
|
||||||
|
--txt-1: #d6abab;/* main text color. */
|
||||||
|
--txt-2: #5018dd;/* color of secondary text like channel list. */
|
||||||
|
--txt-3: #d6abab;/* color of muted text. */
|
||||||
|
|
||||||
|
/* accent colors */
|
||||||
|
--acc-1: #E40046; /* main accent color. */
|
||||||
|
--acc-0: #5018dd; /* color of accent buttons when hovered. */
|
||||||
|
--acc-2: #5018dd; /* color of accent buttons when clicked. */
|
||||||
|
|
||||||
|
/* borders */
|
||||||
|
--border-width: 19px; /* panel border thickness. */
|
||||||
|
--border-color: #5018dd; /* panel border color. */
|
||||||
|
--border-hover-color: #E40046; /* panel border color when hovered. */
|
||||||
|
--border-transition: 0.2s ease; /* panel border transition. */
|
||||||
|
|
||||||
|
/* status dot colors */
|
||||||
|
--online-dot: var(--green); /* color of online dot. */
|
||||||
|
--dnd-dot: var(--pink); /* color of do not disturb dot. */
|
||||||
|
--idle-dot: var(--yellow); /* color of idle dot. */
|
||||||
|
--streaming-dot: var(--purple); /* color of streaming dot. */
|
||||||
|
|
||||||
|
/* mention/ping and message colors */
|
||||||
|
--mention-txt: #5018dd; /* color of mention text. */
|
||||||
|
--mention-bg: #E40046; /* background highlight of mention text. */
|
||||||
|
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
|
||||||
|
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
|
||||||
|
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
|
||||||
|
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
|
||||||
|
|
||||||
|
/* color shades */
|
||||||
|
--pink: oklch(73% 0.12 0);
|
||||||
|
--pink-1: oklch(63% 0.12 0);
|
||||||
|
--pink-2: oklch(53% 0.12 0);
|
||||||
|
--purple: oklch(73% 0.12 300);
|
||||||
|
--purple-1: oklch(63% 0.12 300);
|
||||||
|
--purple-2: oklch(53% 0.12 300);
|
||||||
|
--cyan: oklch(73% 0.12 200);
|
||||||
|
--yellow: oklch(78% 0.12 80);
|
||||||
|
--green: hsl(141, 76%, 48%);
|
||||||
|
--green-1: hsl(141, 73%, 42%);
|
||||||
|
--green-2: hsl(141, 73%, 36%);
|
||||||
|
}
|
||||||
|
/* .container_c48ade{
|
||||||
|
background-color: transparent;
|
||||||
|
} */
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"singleQuote": true,
|
||||||
|
"useTabs": true,
|
||||||
|
"printWidth": 200
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 refact0r
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
> [!WARNING]
|
||||||
|
> recently discord has rolled out a new UI refresh update, which breaks the theme. for now, development is paused. if and when i find the motivation, the theme will be rewritten for the new UI. until then, please check out my other theme, [midnight](https://github.com/refact0r/midnight-discord).
|
||||||
|
|
||||||
|

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

|
||||||
|
After Width: | Height: | Size: 769 KiB |
|
After Width: | Height: | Size: 629 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 620 KiB |
|
After Width: | Height: | Size: 615 KiB |
|
After Width: | Height: | Size: 640 KiB |
|
|
@ -0,0 +1,56 @@
|
||||||
|
function benchmarkSelectors(cssString) {
|
||||||
|
function extractSelectors(cssString) {
|
||||||
|
cssString = cssString.replace(/\/\*[\s\S]*?\*\//g, '') // remove comments
|
||||||
|
|
||||||
|
// remove nested brackets
|
||||||
|
let result = ''
|
||||||
|
let depth = 0
|
||||||
|
for (let char of cssString) {
|
||||||
|
if (char === '{') {
|
||||||
|
depth++
|
||||||
|
} else if (char === '}') {
|
||||||
|
depth--
|
||||||
|
} else if (depth === 0) {
|
||||||
|
result += char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cssString = result
|
||||||
|
|
||||||
|
let selectors = cssString
|
||||||
|
.split(/,(?![^(]*\))|[\n\r]+/) // split by commas or newline
|
||||||
|
.map((s) => s.trim().replace(/::(?:before|after)/, '')) // trim pseudo-elements
|
||||||
|
.filter(Boolean) // remove empty strings
|
||||||
|
|
||||||
|
return selectors
|
||||||
|
}
|
||||||
|
|
||||||
|
const benchmarkSelector = (selector) => {
|
||||||
|
const start = performance.now()
|
||||||
|
let matches = 0
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
matches = document.querySelectorAll(selector).length
|
||||||
|
}
|
||||||
|
return [(performance.now() - start) / 1000, matches]
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractSelectors(cssString)
|
||||||
|
.map((selector) => {
|
||||||
|
try {
|
||||||
|
const [time, matches] = benchmarkSelector(selector)
|
||||||
|
return { selector, time, matches }
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error benchmarking "${selector}": ${error.message}`
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.time - a.time)
|
||||||
|
.map(
|
||||||
|
({ selector, time, matches }) =>
|
||||||
|
`"${selector}",${time.toFixed(6)},${matches}`
|
||||||
|
)
|
||||||
|
.join('\n')
|
||||||
|
.replace(/^/, 'Selector,Time (ms),Matches\n')
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
Selector,Time (ms),Matches
|
||||||
|
"[class^='chatContent_']:has([class^='messagesWrapper_']:hover)",0.256000,0
|
||||||
|
"[class^='chatContent_']:has([class^='typing_']) > [class^='messagesWrapper_']",0.252800,0
|
||||||
|
"[class^=chat_] > [class*=container_]",0.191900,0
|
||||||
|
"[class*=embedFull_]",0.191100,1
|
||||||
|
"[class*=lookFilled_][class*=colorPrimary_]",0.187400,0
|
||||||
|
"#app-mount [class*=lookLink_][class*=colorPrimary_]",0.186500,0
|
||||||
|
"#app-mount [class*=controlIcon_]",0.180000,0
|
||||||
|
"[class^=container_] > [class^=wrapper_]",0.134800,1
|
||||||
|
"[class^=container_] > [class^=wrapper_]",0.133600,1
|
||||||
|
"[class^=container_] > [class^=wrapper_]",0.132400,1
|
||||||
|
"[class^=message_][class*=replying_]",0.132300,0
|
||||||
|
"[class^=embedVideoActions_] > [class^=centerContent_] > [class^=wrapper_]",0.131900,1
|
||||||
|
"[class^=panels_] > [class^=wrapper_] > [class^=container]",0.131600,0
|
||||||
|
"[class^=sidebar_] > [class^=container_]",0.131200,1
|
||||||
|
"[class^=container_] > [class^=wrapper_]",0.131200,1
|
||||||
|
"[class^='form_'] > [class^='wrapper_']",0.131000,0
|
||||||
|
"[class^=chat_] > [class^=content_] > [class^=container_]",0.130700,1
|
||||||
|
"[class^=message_][class*=mentioned_]",0.130200,0
|
||||||
|
"[class^=container_] > [class^=wrapper_]",0.130000,1
|
||||||
|
"[class^=panels_] > [class^=container_]",0.129600,1
|
||||||
|
"#app-mount [class^=sidebar_] > [class^=container_]",0.128500,1
|
||||||
|
"[class^='sidebar_'] > [class^='container_']",0.128500,1
|
||||||
|
"[class^=member_]",0.126500,28
|
||||||
|
"[class^=panels_]",0.126500,1
|
||||||
|
"[class^='membersWrap_']",0.126400,1
|
||||||
|
"[class^=navButton_][class*=navButtonActive_]",0.126000,0
|
||||||
|
"[class^='sidebar_'] > [class^='container_']",0.125800,1
|
||||||
|
"[class^='dot_'][class*='maskSecondary_']",0.125800,0
|
||||||
|
"[class^='membersWrap_']",0.125600,1
|
||||||
|
"[class^=panels_]",0.125600,1
|
||||||
|
"[class^=sidebar_]",0.125500,1
|
||||||
|
"[class^=centerContent_] > [class^=wrapper_] > [class^=anchor_]",0.125300,1
|
||||||
|
"[class^=privateChannels_] > [class^=scroller_]",0.125200,0
|
||||||
|
"[class^=childWrapper_]",0.125200,9
|
||||||
|
"#app-mount [class^=partyStatus_] [class^=anchor_]",0.125200,0
|
||||||
|
"[class^=iconBadge_]:not([class*=isCurrentUserConnected_])",0.125200,1
|
||||||
|
"[class^='toolbar_'] > [class^='search_']",0.125100,1
|
||||||
|
"[class^=container_] > [class^=base_]",0.125100,1
|
||||||
|
"[class^='panels_']",0.125000,1
|
||||||
|
"[class^='status_'][style='background-color: rgb(242, 63, 67);']",0.124900,0
|
||||||
|
"[class^=chatContent_] > [class^=messagesWrapper_]",0.124800,1
|
||||||
|
"[class^='form_'] [class^='channelTextArea_']",0.124700,1
|
||||||
|
"#app-mount [class^=sidebar_]",0.124700,1
|
||||||
|
"[class^=members_]",0.124500,1
|
||||||
|
"[class*=bannerVisible_] [class^=headerContent_]",0.124500,0
|
||||||
|
"[class^=form_] [class^=channelTextArea_]",0.124500,1
|
||||||
|
"[class^='typing_']",0.124500,0
|
||||||
|
"[class^='upperBadge_']",0.124500,1
|
||||||
|
"[class^=tabBody_]",0.124400,0
|
||||||
|
"[class^=resizeHandle_]",0.124200,0
|
||||||
|
"[class^='userPanelInner_']",0.124200,0
|
||||||
|
"[class^=attachedBars_]",0.124100,0
|
||||||
|
"[class^=container_] > [class^=base_]",0.124100,1
|
||||||
|
"[class^=gifFavoriteButton_]",0.124000,0
|
||||||
|
"[class^=membersWrap_]",0.124000,1
|
||||||
|
"[class^=panels_] > [class^=panel_]",0.124000,0
|
||||||
|
"[class^='searchResultsWrap_']",0.124000,0
|
||||||
|
"[class^=profilePanel_] > [class^=userPanelOuter_]",0.123900,0
|
||||||
|
"[class^=chat_]",0.123900,1
|
||||||
|
"[class^=sidebar_]",0.123900,1
|
||||||
|
"[class^='form_'] [class^='channelTextArea_']",0.123900,1
|
||||||
|
"[class^=panels_] > [class^=panel_]",0.123800,0
|
||||||
|
"[class^='membersWrap_']",0.123800,1
|
||||||
|
"[class^='nowPlayingColumn_']",0.123800,0
|
||||||
|
"[class^=replyBar_]",0.123700,0
|
||||||
|
"[class^=iconWrapper_]",0.123700,7
|
||||||
|
"[class^='status_'][style='background-color: rgb(35, 165, 90);']",0.123700,0
|
||||||
|
"[class^=searchResultsWrap_]",0.123700,0
|
||||||
|
"[class^='unreadPill_'][class*='endCap_']",0.123600,1
|
||||||
|
"[class^=overlayBottom_] > [class^=text-]",0.123600,0
|
||||||
|
"[class^=privateChannels_]",0.123600,0
|
||||||
|
"[class^=categoryText_]",0.123500,0
|
||||||
|
"[class^=profilePanel_]",0.123500,0
|
||||||
|
"[class^='searchResultsWrap_']",0.123400,0
|
||||||
|
"[class^='membersWrap_']",0.123400,1
|
||||||
|
"[class^='peopleListItem_']",0.123400,0
|
||||||
|
"[class^='status_'][style='background-color: rgb(240, 178, 50);']",0.123300,0
|
||||||
|
"[class^=tabBody_]",0.123200,0
|
||||||
|
"[class^=privateChannels_]",0.123200,0
|
||||||
|
"[class^='status_'][style='background-color: rgb(128, 132, 142);']",0.123200,0
|
||||||
|
"[class^='chatContent_'] > [class^='messagesWrapper_']",0.123100,1
|
||||||
|
"[class^='profilePanel_']",0.123100,0
|
||||||
|
"[class^=videoControls_]",0.123000,0
|
||||||
|
"[class^=sidebar_]",0.123000,1
|
||||||
|
"[class^=searchAnswer_]",0.122800,0
|
||||||
|
"[class^='profilePanel_']",0.122800,0
|
||||||
|
"[class^='peopleColumn_']",0.122800,0
|
||||||
|
"[class^='nowPlayingColumn_']",0.122800,0
|
||||||
|
"[class^='sectionDivider_']",0.122800,1
|
||||||
|
"[class^=notAppAsidePanel_] > [class^=layerContainer_]",0.122700,2
|
||||||
|
"[class^='privateChannels_']",0.122700,0
|
||||||
|
"[class^=privateChannels_]",0.122500,0
|
||||||
|
"[class^=container_] > [class^=base_]",0.122500,1
|
||||||
|
"[class^='lowerBadge_']",0.122400,0
|
||||||
|
"[class^=searchResultsWrap_]",0.122400,0
|
||||||
|
"[class^=threadSuggestionBar_]",0.122200,0
|
||||||
|
"[class^='chatContent_'] > [class^='messagesWrapper_']",0.122200,1
|
||||||
|
"[class^=searchResultsWrap_]",0.122200,0
|
||||||
|
"[class^=peopleColumn_]",0.122200,0
|
||||||
|
"[class^='nowPlayingColumn_']",0.122100,0
|
||||||
|
"[class^=searchFilter_]",0.122000,0
|
||||||
|
"[class^=privateChannels_]",0.122000,0
|
||||||
|
"[class^=nowPlayingColumn_]",0.122000,0
|
||||||
|
"[class^='folderIconWrapper_'][style='background-color: rgba(88, 101, 242, 0.4);']",0.121900,6
|
||||||
|
"[class^='container_'] > [class^='base_']",0.121900,1
|
||||||
|
"[class^='panels_']",0.121800,1
|
||||||
|
"[class^=friendRequestsButton_] [class^=numberBadge_]",0.121600,0
|
||||||
|
"[class^=peopleColumn_]",0.121400,0
|
||||||
|
"#app-mount [class^=partyStatus_]",0.121300,0
|
||||||
|
"[class^='form_'] [class^='channelTextArea_']",0.121100,1
|
||||||
|
"[class^='privateChannels_']",0.121100,0
|
||||||
|
"[class^='peopleColumn_']",0.120900,0
|
||||||
|
"#app-mount [class^='divider_']",0.120100,3
|
||||||
|
".wrapper_c51b4e:has(rect[fill='#593695'])",0.109200,0
|
||||||
|
".wrapper_c51b4e:has(rect[fill='#80848e'])",0.107600,0
|
||||||
|
".wrapper_c51b4e:has(rect[fill='#23a55a'])",0.096000,18
|
||||||
|
".wrapper_c51b4e:has(rect[fill='#f0b232'])",0.095800,4
|
||||||
|
".wrapper_c51b4e:has(rect[fill='#f23f43'])",0.089600,8
|
||||||
|
".wrapper_c51b4e:has(rect)",0.087900,30
|
||||||
|
"[style='opacity: 1; background-color: rgb(35, 165, 90);']",0.074800,0
|
||||||
|
"svg[style='color: rgb(88, 101, 242);']",0.062200,8
|
||||||
|
"#app-mount button path[fill='var(--white-500)']",0.061600,0
|
||||||
|
"[role='button'] path[fill='var(--white-500)']",0.060400,0
|
||||||
|
"[class^='mask_'] > svg",0.060400,2
|
||||||
|
"[class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask",0.058600,48
|
||||||
|
"[class^='bannerSVGWrapper_'] > mask > circle",0.056000,1
|
||||||
|
"svg[class^=slider_] > rect[fill='white']",0.055400,0
|
||||||
|
"[class^='mask_'] > rect",0.055400,30
|
||||||
|
":root",0.055200,1
|
||||||
|
"[class^='mask_'] > foreignObject",0.055100,32
|
||||||
|
"[class^='svg_'] > foreignObject",0.054900,70
|
||||||
|
"[class^='mask_'] > circle",0.054300,1
|
||||||
|
":root",0.053800,1
|
||||||
|
":root",0.052300,1
|
||||||
|
"[class^=jumpToPresentBar_] > button",0.051700,0
|
||||||
|
"aside[class^='profilePanel_']",0.051000,0
|
||||||
|
".custom-profile-theme [class*=color_][class*=text_]",0.049900,0
|
||||||
|
".horizontal_bba380 > .flex_bba380",0.044800,0
|
||||||
|
".container_fb4810",0.044300,0
|
||||||
|
".container_a4d4d9",0.043900,1
|
||||||
|
".clipContainer_b11c5e",0.043900,0
|
||||||
|
".content_a7d72e",0.043600,1
|
||||||
|
".container_fc4f04",0.043600,1
|
||||||
|
".scroller_e2e187",0.043400,1
|
||||||
|
"html.theme-light",0.043400,0
|
||||||
|
".platform-osx [class^=container_] > [class^=wrapper_]",0.043300,0
|
||||||
|
".container_fc4f04",0.043300,1
|
||||||
|
".container_fc4f04",0.043300,1
|
||||||
|
".container_adcaac",0.043200,0
|
||||||
|
".attachButton_f298d4",0.043200,1
|
||||||
|
".wrapper_fea3ef",0.043200,1
|
||||||
|
".container_d1c246",0.043200,0
|
||||||
|
".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.043100,0
|
||||||
|
".textArea_d0696b",0.043000,2
|
||||||
|
".chat_a7d72e",0.043000,1
|
||||||
|
".wordmarkWindows_a934d8 > svg",0.043000,1
|
||||||
|
".vc-spotify-time-right",0.042900,1
|
||||||
|
".theme-dark",0.042900,3
|
||||||
|
".container_c75f85",0.042800,0
|
||||||
|
".form_a7d72e",0.042800,1
|
||||||
|
".container_a6d69a",0.042800,0
|
||||||
|
".vc-spotify-time-left",0.042800,1
|
||||||
|
".form_a7d72e",0.042700,1
|
||||||
|
".container_b2ca13",0.042700,1
|
||||||
|
".container_a6d69a",0.042700,0
|
||||||
|
".container_c2739c",0.042700,0
|
||||||
|
".tabBody_c2739c",0.042700,0
|
||||||
|
".vc-spotify-time-right",0.042700,1
|
||||||
|
".container_e85446 > [class^='svg_'] > foreignObject > [class^='status_']",0.042600,0
|
||||||
|
".container_d1c246",0.042600,0
|
||||||
|
".panels_a4d4d9",0.042600,1
|
||||||
|
".container_adcaac",0.042600,0
|
||||||
|
".container_c2739c",0.042500,0
|
||||||
|
".scroller_fb4810",0.042500,0
|
||||||
|
".form_a7d72e",0.042400,1
|
||||||
|
".newMessagesBar_cf58b5",0.042400,1
|
||||||
|
".animatedContainer_fd6364",0.042400,1
|
||||||
|
".vc-spotify-time-left",0.042400,1
|
||||||
|
".scroller_fb4810",0.042300,0
|
||||||
|
".wordmarkWindows_a934d8",0.042300,1
|
||||||
|
".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.042300,0
|
||||||
|
".grabber_c7a159",0.042200,1
|
||||||
|
".container_a6d69a",0.042100,0
|
||||||
|
".scroller_fb4810",0.042100,0
|
||||||
|
".scroller_fb4810",0.042100,0
|
||||||
|
".container_fc4f04",0.041600,1
|
||||||
|
"*",0.036500,6407
|
||||||
|
"#vc-spotify-progress-bar [class^='bar_']",0.000300,1
|
||||||
|
"#app-mount #vc-spotify-player",0.000200,1
|
||||||
|
"#vc-spotify-player",0.000200,1
|
||||||
|
"#vc-spotify-player",0.000200,1
|
||||||
|
"#vc-spotify-progress-bar",0.000200,1
|
||||||
|
"::-webkit-scrollbar-thumb",0.000200,0
|
||||||
|
"#vc-spotify-player",0.000100,1
|
||||||
|
|
|
@ -0,0 +1,232 @@
|
||||||
|
Selector,Time (ms),Matches,Total
|
||||||
|
[class*=buttonColor_],0.4697,0,38.4736
|
||||||
|
[class*=embedFull_],0.446,2,
|
||||||
|
[class^='chatContent_']:has([class^='typing_']:hover),0.4342,0,
|
||||||
|
#app-mount [class*=lookLink_][class*=colorPrimary_],0.4331,0,
|
||||||
|
[class*=lookFilled_][class*=colorPrimary_],0.4315,0,
|
||||||
|
[class^='chatContent_']:has([class^='messagesWrapper_']:hover),0.4189,0,
|
||||||
|
[class^='chatContent_']:has([class^='typing_']) > [class^='messagesWrapper_'] > [class^='jumpToPresentBar_'],0.4146,0,
|
||||||
|
#app-mount [class*=controlIcon_],0.3911,0,
|
||||||
|
[class^=searchFilter_],0.3541,0,
|
||||||
|
[class^='newBadge_'],0.3385,0,
|
||||||
|
[class^=friendRequestsButton_] [class^=numberBadge_],0.3314,0,
|
||||||
|
[class^=chat_] > [class*=container_],0.32,0,
|
||||||
|
[class^=videoControls_],0.3099,0,
|
||||||
|
[class*=backgroundAccent_],0.3097,0,
|
||||||
|
[class^=searchAnswer_],0.3096,0,
|
||||||
|
#app-mount [class^=partyStatus_],0.3063,0,
|
||||||
|
[class^='newChannel_'],0.2995,0,
|
||||||
|
[class^=iconBadge_]:not([class*=isCurrentUserConnected_]),0.2892,0,
|
||||||
|
#app-mount [class^=partyStatus_] [class^=anchor_],0.2834,0,
|
||||||
|
[class^=childWrapper_],0.2817,15,
|
||||||
|
[class^=overlayBottom_] > [class^=text-],0.2786,0,
|
||||||
|
[class^=gifFavoriteButton_],0.2739,0,
|
||||||
|
[class^='form_'] [class^='channelTextArea_'],0.2703,1,
|
||||||
|
[class^='panels_'],0.2612,1,
|
||||||
|
[class^=panels_],0.2599,1,
|
||||||
|
[class^=sidebar_],0.2585,1,
|
||||||
|
[class^=button_][class*=bannerColor_],0.2576,0,
|
||||||
|
[class^='content_']>[class^='outer_'],0.2546,0,
|
||||||
|
[class^=channelTextArea] > [class^=scrollableContainer],0.2527,1,
|
||||||
|
[class^='content_']>[class^='outer_'],0.2511,0,
|
||||||
|
[class^=sidebar_] > [class^=container_],0.2439,1,
|
||||||
|
[class^='channelBottomBarArea_'],0.2423,1,
|
||||||
|
[class^=panels_] > [class^=wrapper_] > [class^=container],0.2386,0,
|
||||||
|
[class^='form_'] > [class^='wrapper_'],0.2365,0,
|
||||||
|
[class^=navButton_][class*=navButtonActive_],0.2356,0,
|
||||||
|
[class^=sidebar_],0.2315,1,
|
||||||
|
[class^=membersWrap_],0.2308,1,
|
||||||
|
[class^=embedVideoActions_] > [class^=centerContent_] > [class^=wrapper_],0.2273,0,
|
||||||
|
[class^=categoryText_],0.2255,0,
|
||||||
|
[class^='form_'] > [class^='wrapper_'],0.2233,0,
|
||||||
|
[class^=sidebar_],0.2232,1,
|
||||||
|
[class^=members_],0.2213,1,
|
||||||
|
"[class^='folderIconWrapper_'][style='background-color: rgba(88, 101, 242, 0.4);']",0.2209,0,
|
||||||
|
[class^=peopleColumn_],0.2206,0,
|
||||||
|
[class^=nowPlayingColumn_],0.2206,0,
|
||||||
|
[class^=message_][class*=replying_],0.2181,0,
|
||||||
|
[class^=message_][class*=mentioned_],0.2175,5,
|
||||||
|
[class^=bottomControls_],0.2173,0,
|
||||||
|
[class*=bannerVisible_] [class^=headerContent_],0.2168,0,
|
||||||
|
[class^=container_] > [class^=base_],0.2148,1,
|
||||||
|
[class^=tabBody_],0.2147,0,
|
||||||
|
[class^=privateChannels_],0.2141,0,
|
||||||
|
[class^='sidebar_'] > [class^='container_'],0.2129,1,
|
||||||
|
[class^='dot_'][class*='maskSecondary_'],0.2125,0,
|
||||||
|
"[class^='status_'][style='background-color: rgb(35, 165, 90);']",0.2124,0,
|
||||||
|
[class^=privateChannels_],0.2119,0,
|
||||||
|
[class^='sidebar_'] > [class^='container_'],0.2112,1,
|
||||||
|
[class^='content_']>[class^='outer_'],0.2105,0,
|
||||||
|
[class^=channelBottomBarArea_],0.2103,1,
|
||||||
|
[class^='form_'] [class^='channelTextArea_'],0.2101,1,
|
||||||
|
[class^=privateChannels_],0.21,0,
|
||||||
|
[class^='channelAppLauncher_'],0.2099,1,
|
||||||
|
[class^=gradientBottom_],0.2099,0,
|
||||||
|
[class^=centerContent_] > [class^=wrapper_] > [class^=anchor_],0.2099,0,
|
||||||
|
[class^='form_'] > [class^='typing_'],0.2095,0,
|
||||||
|
[class^='content_']>[class^='outer_']>[class^='inner_'],0.2094,0,
|
||||||
|
#app-mount [class^=sidebar_] > [class^=container_],0.2093,1,
|
||||||
|
[class^='nowPlayingColumn_'],0.209,0,
|
||||||
|
"[class^='status_'][style='background-color: rgb(128, 132, 142);']",0.2087,0,
|
||||||
|
[class^=peopleColumn_],0.2082,0,
|
||||||
|
[class^='nowPlayingColumn_'],0.2079,0,
|
||||||
|
[class^=session_],0.2078,0,
|
||||||
|
[class^='container_'] > [class^='base_'],0.2076,1,
|
||||||
|
[class^=searchResultsWrap_],0.2076,0,
|
||||||
|
[class^=panels_] > [class^=panel_],0.2076,0,
|
||||||
|
[class^='membersWrap_'],0.2076,1,
|
||||||
|
[class^=searchResultsWrap_],0.2073,0,
|
||||||
|
[class^=guildSeparator_],0.2072,2,
|
||||||
|
[class^=tableCell_],0.2072,0,
|
||||||
|
[class^=chat_] > [class^=content_] > [class^=container_],0.2071,1,
|
||||||
|
[class^='membersWrap_'],0.207,1,
|
||||||
|
[class^='sectionDivider_'],0.2069,0,
|
||||||
|
[class^='privateChannels_'],0.2067,0,
|
||||||
|
[class^='peopleColumn_'],0.2065,0,
|
||||||
|
[class^=attachedBars_],0.2061,0,
|
||||||
|
[class^=searchResultsWrap_],0.206,0,
|
||||||
|
[class^='callContainer_'],0.2055,0,
|
||||||
|
[class^='peopleColumn_'],0.2051,0,
|
||||||
|
[class^='notAppAsidePanel_'] > [class^='layerContainer_'],0.2049,2,
|
||||||
|
[class^='unreadPill_'][class*='endCap_'],0.2048,0,
|
||||||
|
[class^='panels_'],0.2047,1,
|
||||||
|
#app-mount [class^=sidebar_],0.2045,1,
|
||||||
|
[class^=chat_],0.2044,1,
|
||||||
|
[class^=panels_],0.2044,1,
|
||||||
|
[class^=panels_] > [class^=container_],0.2042,1,
|
||||||
|
[class^='chatContent_'] > [class^='messagesWrapper_'],0.2042,1,
|
||||||
|
[class^='nowPlayingColumn_'],0.2041,0,
|
||||||
|
[class^='membersWrap_'],0.2041,1,
|
||||||
|
[class^=container_] > [class^=base_],0.2039,1,
|
||||||
|
[class^='toolbar_'] > [class^='search_'],0.2034,1,
|
||||||
|
"[class^='status_'][style='background-color: rgb(240, 178, 50);']",0.2034,0,
|
||||||
|
[class^=tabBody_],0.2031,0,
|
||||||
|
[class^='lowerBadge_'],0.2031,0,
|
||||||
|
[class^=privateChannels_] > [class^=scroller_],0.2029,0,
|
||||||
|
[class^='searchResultsWrap_'],0.2028,0,
|
||||||
|
[class^=customizationSection_],0.2028,0,
|
||||||
|
[class^=chatContent_] > [class^=messagesWrapper_],0.2027,1,
|
||||||
|
[class^='upperBadge_'],0.2027,0,
|
||||||
|
"[class^='status_'][style='background-color: rgb(242, 63, 67);']",0.2025,0,
|
||||||
|
[class^=threadSuggestionBar_],0.2021,0,
|
||||||
|
[class^=panels_] > [class^=panel_],0.202,0,
|
||||||
|
[class^='searchResultsWrap_'],0.202,0,
|
||||||
|
[class^=columnHeading_],0.2016,0,
|
||||||
|
#app-mount [class^='divider_'],0.2016,2,
|
||||||
|
[class^=callContainer_],0.2014,0,
|
||||||
|
[class^='chatContent_'] > [class^='messagesWrapper_'],0.2011,1,
|
||||||
|
[class^='channelBottomBarArea_']:hover,0.201,0,
|
||||||
|
[class^=gradientTop_],0.2009,0,
|
||||||
|
[class^=iconWrapper_],0.2008,6,
|
||||||
|
[class^='guildSeparator_'],0.1998,2,
|
||||||
|
[class^='content_']>[class^='outer_'],0.1998,0,
|
||||||
|
[class^='privateChannels_'],0.1997,0,
|
||||||
|
[class^=container_] > [class^=base_],0.1996,1,
|
||||||
|
[class^=member_],0.1985,25,
|
||||||
|
[class^='content_']>[class^='outer_'],0.1984,0,
|
||||||
|
[class^='sectionDivider_'],0.198,0,
|
||||||
|
[class^=topControls_],0.198,0,
|
||||||
|
[class^='membersWrap_'],0.1974,1,
|
||||||
|
[class^=privateChannels_],0.1971,0,
|
||||||
|
[class^=content_] > [class^=resizeHandle_],0.1969,0,
|
||||||
|
[class^=replyBar_],0.196,0,
|
||||||
|
[class^='form_'] [class^='channelTextArea_'],0.1958,1,
|
||||||
|
[class^='divider_']:not(.isUnread_c2654d),0.194,2,
|
||||||
|
.wrapper_c51b4e:has(rect[fill='#f0b232']),0.1871,8,
|
||||||
|
[class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask,0.1624,39,
|
||||||
|
:root,0.1432,1,
|
||||||
|
.wrapper_c51b4e:has(rect[fill='#80848e']),0.1408,1,
|
||||||
|
.wrapper_c51b4e:has(rect[fill='#593695']),0.1371,0,
|
||||||
|
.wrapper_c51b4e:has(rect[fill='#f23f43']),0.1349,5,
|
||||||
|
.wrapper_c51b4e:has(rect),0.1342,26,
|
||||||
|
.wrapper_c51b4e:has(rect[fill='#23a55a']),0.1332,12,
|
||||||
|
[class^='svg_'] > foreignObject,0.123,99,
|
||||||
|
[role='button'] path[fill='var(--white-500)'],0.1159,0,
|
||||||
|
"svg[style='color: rgb(88, 101, 242);']",0.1122,13,
|
||||||
|
[class^=jumpToPresentBar_] > button,0.1117,2,
|
||||||
|
"[style='opacity: 1; background-color: rgb(35, 165, 90);']",0.1095,0,
|
||||||
|
:root,0.1094,1,
|
||||||
|
[class^='mask_'] > rect,0.1049,26,
|
||||||
|
[class^='mask_'] > foreignObject,0.1041,26,
|
||||||
|
[class^='mask_'] > svg,0.1035,1,
|
||||||
|
#app-mount button path[fill='var(--white-500)'],0.1031,0,
|
||||||
|
[class^='bannerSVGWrapper_'] > mask > circle,0.102,0,
|
||||||
|
svg[class^=slider_] > rect[fill='white'],0.1016,0,
|
||||||
|
:root,0.0946,1,
|
||||||
|
[class^='mask_'] > circle,0.0937,0,
|
||||||
|
[class^=callContainer_] [class^=topControls_] section,0.0882,0,
|
||||||
|
.container_a6d69a,0.0797,0,
|
||||||
|
.guilds_a4d4d9,0.0771,1,
|
||||||
|
.container_adcaac,0.0769,0,
|
||||||
|
.vc-spotify-time-left,0.0766,0,
|
||||||
|
.vc-spotify-time-right,0.0746,0,
|
||||||
|
.guilds_a4d4d9,0.0733,1,
|
||||||
|
.form_a7d72e,0.0731,1,
|
||||||
|
.container_fc4f04,0.0723,1,
|
||||||
|
.container_c2739c,0.072,0,
|
||||||
|
.form_a7d72e,0.0719,1,
|
||||||
|
*,0.0714,6196,
|
||||||
|
.timestamp_f9f2ca.latin24CompactTimeStamp_f9f2ca,0.0713,13,
|
||||||
|
.container_d1c246,0.0713,0,
|
||||||
|
.vc-ranb-button,0.0709,0,
|
||||||
|
.container_b2ca13,0.0707,1,
|
||||||
|
.wrapper_fea3ef,0.0706,1,
|
||||||
|
.statusBubbleOuter_d0a252,0.0706,0,
|
||||||
|
.container_fc4f04,0.0704,1,
|
||||||
|
.scroller_bf550a,0.0703,0,
|
||||||
|
.guilds_a4d4d9,0.0703,1,
|
||||||
|
.attachButton_f298d4,0.0702,1,
|
||||||
|
.container_a6d69a,0.07,0,
|
||||||
|
.clipContainer_b11c5e,0.0699,0,
|
||||||
|
.chat_a7d72e,0.0699,1,
|
||||||
|
.form_a7d72e,0.0698,1,
|
||||||
|
.panels_a4d4d9,0.0698,1,
|
||||||
|
".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.0698,0,
|
||||||
|
.textArea_bdf0de,0.0697,1,
|
||||||
|
.content_a7d72e,0.0695,1,
|
||||||
|
.container_adcaac,0.0695,0,
|
||||||
|
.animatedContainer_fd6364,0.0693,1,
|
||||||
|
.guilds_a4d4d9,0.0692,1,
|
||||||
|
.container_fb4810,0.0692,0,
|
||||||
|
.separator_a0,0.0692,0,
|
||||||
|
".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.0692,0,
|
||||||
|
.scroller_fb4810,0.0691,0,
|
||||||
|
.wordmarkWindows_a934d8 > svg,0.0691,1,
|
||||||
|
.wordmarkWindows_a934d8,0.0688,1,
|
||||||
|
.container_fc4f04,0.0688,1,
|
||||||
|
.scroller_fb4810,0.0688,0,
|
||||||
|
.guilds_a4d4d9,0.0687,1,
|
||||||
|
.container_e85446 > [class^='svg_'] > foreignObject > [class^='status_'],0.0686,0,
|
||||||
|
.newMessagesBar_cf58b5,0.0685,0,
|
||||||
|
.container_a4d4d9,0.0684,1,
|
||||||
|
.scroller_e2e187,0.0684,1,
|
||||||
|
.platform-osx .guilds_a4d4d9,0.0684,0,
|
||||||
|
.scroller_fb4810,0.0683,0,
|
||||||
|
.container_c75f85,0.0683,0,
|
||||||
|
.container_c2739c,0.0681,0,
|
||||||
|
html.theme-light,0.0681,0,
|
||||||
|
.container_a6d69a,0.0681,0,
|
||||||
|
.grabber_c7a159,0.068,0,
|
||||||
|
.scroller_fb4810,0.0679,0,
|
||||||
|
.theme-dark,0.0678,1,
|
||||||
|
.statusBubbleOuter_d0a252,0.0678,0,
|
||||||
|
.tabBody_c2739c,0.0677,0,
|
||||||
|
.container_d1c246,0.0676,0,
|
||||||
|
.horizontal_dc333f > .flex_dc333f,0.0673,0,
|
||||||
|
.separator_a0,0.0669,0,
|
||||||
|
.vc-spotify-time-left,0.0666,0,
|
||||||
|
.container_fc4f04,0.0662,1,
|
||||||
|
.vc-spotify-time-right,0.0661,0,
|
||||||
|
.wrapper_fea3ef.hidden_fea3ef,0.0661,0,
|
||||||
|
#vc-spotify-player,0.0004,0,
|
||||||
|
#vc-spotify-progress-bar [class^='bar_'],0.0004,0,
|
||||||
|
#vc-spotify-player,0.0004,0,
|
||||||
|
.overlay_f94206:after,0.0004,0,
|
||||||
|
#app-mount #vc-spotify-player,0.0003,0,
|
||||||
|
#vc-spotify-player,0.0003,0,
|
||||||
|
#vc-spotify-progress-bar,0.0003,0,
|
||||||
|
::placeholder,0.0003,0,
|
||||||
|
::selection,0.0003,0,
|
||||||
|
::-webkit-scrollbar-thumb,0.0003,0,
|
||||||
|
.overlay_f18fcb:after,0.0003,0,
|
||||||
|
#vc-spotify-player,0.0002,0,
|
||||||
|
|
|
@ -0,0 +1,231 @@
|
||||||
|
Selector,Time (ms),Matches,Total
|
||||||
|
[class^='chatContent_']:has([class^='messagesWrapper_']:hover),0.4053,0,29.2904
|
||||||
|
[class^='chatContent_']:has([class^='typing_']:hover),0.3815,0,
|
||||||
|
[class^=chat_] > [class*=container_],0.3098,0,
|
||||||
|
[class^=chat_] > [class^=content_] > [class^=container_],0.238,0,
|
||||||
|
[class*=backgroundAccent_],0.2297,0,
|
||||||
|
[class^='form_'] [class^='channelTextArea_'],0.2236,1,
|
||||||
|
[class^='membersWrap_'],0.2207,0,
|
||||||
|
[class^=panels_],0.2156,1,
|
||||||
|
[class^=centerContent_] > [class^=wrapper_] > [class^=anchor_],0.2081,0,
|
||||||
|
[class^=searchResultsWrap_],0.1998,0,
|
||||||
|
[class^='content_']>[class^='outer_'],0.1997,0,
|
||||||
|
[class^='form_'] > [class^='wrapper_'],0.1982,0,
|
||||||
|
[class^=videoControls_],0.1967,0,
|
||||||
|
[class^=sidebar_] > [class^=container_],0.1947,1,
|
||||||
|
[class^=privateChannels_],0.1911,0,
|
||||||
|
[class^='content_'] > [class^='outer_'],0.19,0,
|
||||||
|
"[class^='status_'][style='background-color: rgb(240, 178, 50);']",0.19,0,
|
||||||
|
[class^='form_'] > [class^='wrapper_'],0.1888,0,
|
||||||
|
[class^=embedVideoActions_] > [class^=centerContent_] > [class^=wrapper_],0.1887,0,
|
||||||
|
[class^=iconBadge_]:not([class*=isCurrentUserConnected_]),0.1881,1,
|
||||||
|
[class^='sidebar_'] > [class^='container_'],0.1842,1,
|
||||||
|
[class^='peopleColumn_'],0.1839,0,
|
||||||
|
[class^='lowerBadge_'],0.1839,0,
|
||||||
|
[class^='gradientBottom_'],0.1836,0,
|
||||||
|
[class^=overlayBottom_] > [class^=text-],0.1834,0,
|
||||||
|
"[class^='status_'][style='background-color: rgb(35, 165, 90);']",0.1834,0,
|
||||||
|
[class^='chatContent_']:has([class^='typing_']) > [class^='messagesWrapper_'] > [class^='jumpToPresentBar_'],0.1834,0,
|
||||||
|
"[class^='status_'][style='background-color: rgb(128, 132, 142);']",0.1834,0,
|
||||||
|
"[class^='status_'][style='background-color: rgb(242, 63, 67);']",0.1832,0,
|
||||||
|
[class^='form_'] > [class^='typing_'],0.183,1,
|
||||||
|
[class^=panels_],0.1826,1,
|
||||||
|
[class^='searchResultsWrap_'],0.1826,0,
|
||||||
|
[class^='callContainer_'],0.182,0,
|
||||||
|
[class^='upperBadge_'],0.1817,0,
|
||||||
|
[class^=childWrapper_],0.1817,15,
|
||||||
|
[class^=members_],0.1815,0,
|
||||||
|
[class^=button_][class*=bannerColor_],0.181,0,
|
||||||
|
[class^='gradientTop_'],0.181,0,
|
||||||
|
[class^='sidebar_'] > [class^='container_'],0.1808,1,
|
||||||
|
[class^=searchFilter_],0.1807,0,
|
||||||
|
[class^=privateChannels_],0.1804,0,
|
||||||
|
[class^='panels_'],0.1794,1,
|
||||||
|
[class^=member_],0.1791,0,
|
||||||
|
[class^='membersWrap_'],0.1791,0,
|
||||||
|
[class^=message_][class*=replying_],0.179,0,
|
||||||
|
[class^='unreadPill_'][class*='endCap_'],0.1788,0,
|
||||||
|
[class^='privateChannels_'],0.1787,0,
|
||||||
|
[class^='bottomControls_'],0.1787,0,
|
||||||
|
[class^=message_][class*=mentioned_],0.1784,5,
|
||||||
|
[class^='content_'] > [class^='outer_'],0.1784,0,
|
||||||
|
#app-mount [class^=partyStatus_],0.1784,0,
|
||||||
|
[class^=searchAnswer_],0.1781,0,
|
||||||
|
[class^=panels_] > [class^=wrapper_] > [class^=container],0.178,0,
|
||||||
|
[class^='peopleColumn_'],0.1779,0,
|
||||||
|
[class^='form_'] [class^='channelTextArea_'],0.1779,1,
|
||||||
|
#app-mount [class^=partyStatus_] [class^=anchor_],0.1776,0,
|
||||||
|
[class^='panels_'],0.1766,1,
|
||||||
|
[class^='form_'] [class^='channelTextArea_'],0.1766,1,
|
||||||
|
[class^=replyBar_],0.1765,0,
|
||||||
|
[class^='privateChannels_'],0.1765,0,
|
||||||
|
[class^=channelBottomBarArea_],0.1764,1,
|
||||||
|
[class^='divider_']:not(.isUnread_c2654d),0.1763,2,
|
||||||
|
[class^=navButton_][class*=navButtonActive_],0.1762,0,
|
||||||
|
[class^=container_] > [class^=base_],0.1756,1,
|
||||||
|
[class^='nowPlayingColumn_'],0.1755,0,
|
||||||
|
[class^='membersWrap_'],0.1755,0,
|
||||||
|
[class^=sidebar_],0.1753,1,
|
||||||
|
[class^='newChannel_'],0.1752,0,
|
||||||
|
[class^=nowPlayingColumn_],0.1752,0,
|
||||||
|
[class^=guildSeparator_],0.1751,2,
|
||||||
|
[class^='topControls_'],0.175,0,
|
||||||
|
[class^='container_'] > [class^='base_'],0.1748,1,
|
||||||
|
[class^=panels_] > [class^=container_],0.1742,1,
|
||||||
|
[class^='notAppAsidePanel_'] > [class^='layerContainer_'],0.1742,2,
|
||||||
|
[class^=columnHeading_],0.174,0,
|
||||||
|
[class^='channelBottomBarArea_']:hover,0.1739,0,
|
||||||
|
[class^=attachedBars_],0.1738,0,
|
||||||
|
[class^='membersWrap_'],0.1738,0,
|
||||||
|
[class^=searchResultsWrap_],0.1737,0,
|
||||||
|
[class^=gifFavoriteButton_],0.1736,0,
|
||||||
|
[class^=membersWrap_],0.1736,0,
|
||||||
|
[class^=customizationSection_],0.1732,0,
|
||||||
|
#app-mount [class^=sidebar_] > [class^=container_],0.1727,1,
|
||||||
|
[class^='newBadge_'],0.1726,0,
|
||||||
|
[class^='channelBottomBarArea_'],0.1723,1,
|
||||||
|
[class^=sidebar_],0.1722,1,
|
||||||
|
[class^=privateChannels_],0.1722,0,
|
||||||
|
[class^=sidebar_],0.1721,1,
|
||||||
|
[class^='toolbar_'] > [class^='search_'],0.172,1,
|
||||||
|
[class^=privateChannels_],0.1713,0,
|
||||||
|
[class^=friendRequestsButton_] [class^=numberBadge_],0.1713,0,
|
||||||
|
[class*=bannerVisible_] [class^=headerContent_],0.1712,0,
|
||||||
|
[class^=iconWrapper_],0.1712,7,
|
||||||
|
[class^=chatContent_] > [class^=messagesWrapper_],0.1712,1,
|
||||||
|
[class^='content_']>[class^='outer_']>[class^='inner_'],0.171,0,
|
||||||
|
[class^='nowPlayingColumn_'],0.1709,0,
|
||||||
|
[class^=peopleColumn_],0.1709,0,
|
||||||
|
[class^='content_']>[class^='outer_'],0.1707,0,
|
||||||
|
[class^=searchResultsWrap_],0.1707,0,
|
||||||
|
[class^='chatContent_'] > [class^='messagesWrapper_'],0.1707,1,
|
||||||
|
[class^=tableCell_],0.1707,0,
|
||||||
|
[class^=tabBody_],0.1706,0,
|
||||||
|
[class^=peopleColumn_],0.1705,0,
|
||||||
|
[class^=tabBody_],0.1703,0,
|
||||||
|
[class^='dot_'][class*='maskSecondary_'],0.1702,0,
|
||||||
|
[class^='sectionDivider_'],0.1701,1,
|
||||||
|
[class^='searchResultsWrap_'],0.1701,0,
|
||||||
|
[class^=privateChannels_] > [class^=scroller_],0.17,0,
|
||||||
|
[class^=container_] > [class^=base_],0.17,1,
|
||||||
|
[class^='content_'] > [class^='outer_'],0.1699,0,
|
||||||
|
[class^=chat_],0.1696,1,
|
||||||
|
[class^='channelAppLauncher_'],0.1696,1,
|
||||||
|
[class^='nowPlayingColumn_'],0.1696,0,
|
||||||
|
#app-mount [class^=sidebar_],0.1689,1,
|
||||||
|
[class^='guildSeparator_'],0.1688,2,
|
||||||
|
[class^=panels_] > [class^=panel_],0.1686,0,
|
||||||
|
[class^=threadSuggestionBar_],0.1685,0,
|
||||||
|
[class^='chatContent_'] > [class^='messagesWrapper_'],0.1684,1,
|
||||||
|
[class^=session_],0.1684,0,
|
||||||
|
[class^=categoryText_],0.1683,0,
|
||||||
|
[class^=channelTextArea] > [class^=scrollableContainer],0.1679,1,
|
||||||
|
[class^=panels_] > [class^=panel_],0.1677,0,
|
||||||
|
"[class^='folderIconWrapper_'][style='background-color: rgba(88, 101, 242, 0.4);']",0.1674,0,
|
||||||
|
[class^='sectionDivider_'],0.1671,1,
|
||||||
|
[class^='callContainer_'],0.1669,0,
|
||||||
|
[class^=content_] > [class^=resizeHandle_],0.1663,0,
|
||||||
|
[class^=container_] > [class^=base_],0.1661,1,
|
||||||
|
#app-mount [class^='divider_'],0.1639,2,
|
||||||
|
[class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask,0.123,45,
|
||||||
|
*,0.1194,5612,
|
||||||
|
[class^='mask_'] > foreignObject,0.1165,1,
|
||||||
|
[class^='bannerSVGWrapper_'] > mask > circle,0.1122,0,
|
||||||
|
[class^='mask_'] > rect,0.1074,1,
|
||||||
|
[class^='svg_'] > foreignObject,0.1069,100,
|
||||||
|
[class^='mask_'] > svg,0.1043,0,
|
||||||
|
#app-mount .controlIcon_ef18ee,0.1034,0,
|
||||||
|
#app-mount .lookLink_dd4f85.colorPrimary_dd4f85,0.1023,0,
|
||||||
|
[class^='mask_'] > circle,0.099,0,
|
||||||
|
"[style='opacity: 1; background-color: rgb(35, 165, 90);']",0.0979,0,
|
||||||
|
"svg[style='color: rgb(88, 101, 242);']",0.0884,13,
|
||||||
|
#app-mount button path[fill='var(--white-500)'],0.087,0,
|
||||||
|
[role='button'] path[fill='var(--white-500)'],0.083,0,
|
||||||
|
:root,0.0804,1,
|
||||||
|
:root,0.0792,1,
|
||||||
|
[class^='callContainer_'] [class^='topControls_'] section,0.0784,0,
|
||||||
|
svg[class^=slider_] > rect[fill='white'],0.0784,0,
|
||||||
|
:root,0.0767,1,
|
||||||
|
[class^=jumpToPresentBar_] > button,0.0735,0,
|
||||||
|
.wrapper_c51b4e:has(rect[fill='#80848e']),0.0695,1,
|
||||||
|
.textArea_bdf0de,0.0636,1,
|
||||||
|
.attachButton_f298d4,0.0619,1,
|
||||||
|
.wrapper_c51b4e:has(rect[fill='#593695']),0.0614,0,
|
||||||
|
.wrapper_c51b4e:has(rect[fill='#f0b232']),0.0609,0,
|
||||||
|
.guilds_a4d4d9,0.0607,1,
|
||||||
|
.wrapper_c51b4e:has(rect),0.0607,1,
|
||||||
|
.wrapper_c51b4e:has(rect[fill='#f23f43']),0.0606,0,
|
||||||
|
.theme-dark,0.0602,1,
|
||||||
|
.wrapper_c51b4e:has(rect[fill='#23a55a']),0.0597,0,
|
||||||
|
.lookFilled_dd4f85.colorPrimary_dd4f85,0.0584,0,
|
||||||
|
.container_fc4f04,0.0581,1,
|
||||||
|
.guilds_a4d4d9,0.058,1,
|
||||||
|
.timestamp_f9f2ca.latin24CompactTimeStamp_f9f2ca,0.0577,32,
|
||||||
|
.container_c2739c,0.0572,0,
|
||||||
|
.container_b2ca13,0.0571,1,
|
||||||
|
".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.0568,0,
|
||||||
|
.embedFull_b0068a,0.0567,1,
|
||||||
|
.separator_a0,0.0564,0,
|
||||||
|
.scroller_fb4810,0.0562,0,
|
||||||
|
.scroller_fb4810,0.0562,0,
|
||||||
|
.statusBubbleOuter_d0a252,0.056,0,
|
||||||
|
.statusBubbleOuter_d0a252,0.0559,0,
|
||||||
|
.container_a4d4d9,0.0558,1,
|
||||||
|
.container_a6d69a,0.0557,0,
|
||||||
|
.container_fc4f04,0.0557,1,
|
||||||
|
.container_a6d69a,0.0557,0,
|
||||||
|
.form_a7d72e,0.0556,1,
|
||||||
|
.wrapper_fea3ef.hidden_fea3ef,0.0556,0,
|
||||||
|
.guilds_a4d4d9,0.0555,1,
|
||||||
|
.vc-ranb-button,0.0555,0,
|
||||||
|
.horizontal_dc333f > .flex_dc333f,0.0555,0,
|
||||||
|
.wordmarkWindows_a934d8 > svg,0.0554,1,
|
||||||
|
".container_e85446:has(div[style='background-color: rgb(35, 165, 90);'])",0.0554,0,
|
||||||
|
.vc-spotify-time-right,0.0553,0,
|
||||||
|
.newMessagesBar_cf58b5,0.0553,0,
|
||||||
|
.chat_a7d72e,0.0552,1,
|
||||||
|
.panels_a4d4d9,0.0552,1,
|
||||||
|
.container_fb4810,0.0552,0,
|
||||||
|
.vc-spotify-time-right,0.0552,0,
|
||||||
|
.content_a7d72e,0.0551,1,
|
||||||
|
.container_d1c246,0.0551,0,
|
||||||
|
.form_a7d72e,0.0551,1,
|
||||||
|
.wordmarkWindows_a934d8,0.0551,1,
|
||||||
|
.vc-spotify-time-left,0.0551,0,
|
||||||
|
.scroller_bf550a,0.055,0,
|
||||||
|
.clipContainer_b11c5e,0.0549,0,
|
||||||
|
.vc-spotify-time-left,0.0549,0,
|
||||||
|
.wrapper_fea3ef,0.0549,1,
|
||||||
|
.grabber_c7a159,0.0549,0,
|
||||||
|
.platform-osx .guilds_a4d4d9,0.0548,0,
|
||||||
|
.container_fc4f04,0.0548,1,
|
||||||
|
.guilds_a4d4d9,0.0547,1,
|
||||||
|
.container_a6d69a,0.0547,0,
|
||||||
|
.scroller_e2e187,0.0546,1,
|
||||||
|
.container_c75f85,0.0546,0,
|
||||||
|
.container_c2739c,0.0546,0,
|
||||||
|
.animatedContainer_fd6364,0.0544,0,
|
||||||
|
.container_e85446 > [class^='svg_'] > foreignObject > [class^='status_'],0.0544,0,
|
||||||
|
.container_fc4f04,0.0544,1,
|
||||||
|
.container_d1c246,0.0544,0,
|
||||||
|
.separator_a0,0.0544,0,
|
||||||
|
.container_adcaac,0.0542,0,
|
||||||
|
.guilds_a4d4d9,0.0542,1,
|
||||||
|
html.theme-light,0.054,0,
|
||||||
|
.form_a7d72e,0.0539,1,
|
||||||
|
.tabBody_c2739c,0.0539,0,
|
||||||
|
.scroller_fb4810,0.0539,0,
|
||||||
|
.container_adcaac,0.0538,0,
|
||||||
|
.scroller_fb4810,0.0533,0,
|
||||||
|
::selection,0.0028,0,
|
||||||
|
#vc-spotify-player,0.0003,0,
|
||||||
|
#vc-spotify-player,0.0003,0,
|
||||||
|
#vc-spotify-player,0.0003,0,
|
||||||
|
#vc-spotify-progress-bar,0.0003,0,
|
||||||
|
#vc-spotify-progress-bar [class^='bar_'],0.0003,0,
|
||||||
|
.overlay_f94206:after,0.0003,0,
|
||||||
|
#app-mount #vc-spotify-player,0.0002,0,
|
||||||
|
#vc-spotify-player,0.0002,0,
|
||||||
|
.overlay_f18fcb:after,0.0002,0,
|
||||||
|
::-webkit-scrollbar-thumb,0.0001,0,
|
||||||
|
::placeholder,0.0001,0,
|
||||||
|
|
|
@ -0,0 +1,215 @@
|
||||||
|
{
|
||||||
|
"name": "systemtheme",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "systemtheme",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"chokidar": "^3.6.0",
|
||||||
|
"dotenv": "^16.4.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/anymatch": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"normalize-path": "^3.0.0",
|
||||||
|
"picomatch": "^2.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/binary-extensions": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/braces": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"fill-range": "^7.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"anymatch": "~3.1.2",
|
||||||
|
"braces": "~3.0.2",
|
||||||
|
"glob-parent": "~5.1.2",
|
||||||
|
"is-binary-path": "~2.1.0",
|
||||||
|
"is-glob": "~4.0.1",
|
||||||
|
"normalize-path": "~3.0.0",
|
||||||
|
"readdirp": "~3.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "16.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
|
||||||
|
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fill-range": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"to-regex-range": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/glob-parent": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"is-glob": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-binary-path": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"binary-extensions": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-extglob": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-glob": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"is-extglob": "^2.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-number": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/normalize-path": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/picomatch": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readdirp": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"picomatch": "^2.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/to-regex-range": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"is-number": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "systemtheme",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node scripts/dev.js",
|
||||||
|
"build": "node scripts/build.js"
|
||||||
|
},
|
||||||
|
"author": "refact0r",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"chokidar": "^3.6.0",
|
||||||
|
"dotenv": "^16.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const chokidar = require('chokidar');
|
||||||
|
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||||
|
|
||||||
|
const srcDir = path.join(__dirname, '..', 'src');
|
||||||
|
const baseFile = path.join(__dirname, '..', 'system24.theme.css');
|
||||||
|
const outputFile = process.env.DEV_OUTPUT_PATH;
|
||||||
|
const pathToIgnore = 'https://refact0r.github.io/system24/';
|
||||||
|
|
||||||
|
if (!outputFile) {
|
||||||
|
console.error('DEV_OUTPUT_PATH is not set in .env file');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceImports(content) {
|
||||||
|
const importRegex = /@import url\('([^']+)'\);/g;
|
||||||
|
let match;
|
||||||
|
while ((match = importRegex.exec(content)) !== null) {
|
||||||
|
const importUrl = match[1];
|
||||||
|
const filePath = importUrl.replace(pathToIgnore, '');
|
||||||
|
const localFilePath = path.join(__dirname, '..', filePath);
|
||||||
|
if (fs.existsSync(localFilePath)) {
|
||||||
|
const importedContent = fs.readFileSync(localFilePath, 'utf8');
|
||||||
|
content = content.replace(match[0], importedContent);
|
||||||
|
} else {
|
||||||
|
console.error(`File not found: ${localFilePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function combineCSS() {
|
||||||
|
let combinedCSS = fs.readFileSync(baseFile, 'utf8');
|
||||||
|
combinedCSS = await replaceImports(combinedCSS);
|
||||||
|
fs.writeFileSync(outputFile, combinedCSS);
|
||||||
|
console.log('Updated development CSS file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
combineCSS();
|
||||||
|
|
||||||
|
chokidar.watch(srcDir).on('change', (event, path) => {
|
||||||
|
console.log('Changes detected. Rebuilding...');
|
||||||
|
combineCSS();
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
/* unrounding */
|
||||||
|
::-webkit-scrollbar-thumb,
|
||||||
|
* {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
/* hide round masks */
|
||||||
|
[class^='listItem_'] [class^='wrapper_'] > [class^='svg_'] > mask,
|
||||||
|
[class^='bannerSVGWrapper_'] > mask > circle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
[class^='mask_'] > foreignObject,
|
||||||
|
[class^='svg_'] > foreignObject {
|
||||||
|
mask-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* fiil circle mask on profile avatar */
|
||||||
|
.overlay__75742:after, /* profile popup */
|
||||||
|
.overlay_c0bea0:after {
|
||||||
|
/* user settings */
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hide status indicators */
|
||||||
|
[class^='mask_'] > rect,
|
||||||
|
[class^='mask_'] > circle,
|
||||||
|
[class^='mask_'] > svg {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* re-add status indicators */
|
||||||
|
.wrapper__44b0c:has(rect)::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
height: 8px;
|
||||||
|
width: 8px;
|
||||||
|
bottom: -4px;
|
||||||
|
right: -4px;
|
||||||
|
border: 2px solid var(--bg-0);
|
||||||
|
}
|
||||||
|
.wrapper__44b0c:has(rect[fill='#23a55a'])::after {
|
||||||
|
background: var(--online-dot) !important;
|
||||||
|
}
|
||||||
|
.wrapper__44b0c:has(rect[fill='#f23f43'])::after {
|
||||||
|
background: var(--dnd-dot) !important;
|
||||||
|
}
|
||||||
|
.wrapper__44b0c:has(rect[fill='#f0b232'])::after {
|
||||||
|
background: var(--idle-dot) !important;
|
||||||
|
}
|
||||||
|
.wrapper__44b0c:has(rect[fill='#80848e'])::after {
|
||||||
|
background: var(--txt-3) !important;
|
||||||
|
}
|
||||||
|
.wrapper__44b0c:has(rect[fill='#593695'])::after {
|
||||||
|
background: var(--streaming-dot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* fix server list badges */
|
||||||
|
[class^='lowerBadge_'] {
|
||||||
|
border: 2px solid var(--bg-0);
|
||||||
|
bottom: -4px;
|
||||||
|
right: -4px;
|
||||||
|
}
|
||||||
|
[class^='upperBadge_'] {
|
||||||
|
border: 2px solid var(--bg-0);
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* @name system24 (catppuccin macchiato lavender)
|
||||||
|
* @description A tui-style discord theme. Based on the catppuccin maccchiato color palette.
|
||||||
|
* @author refact0r, bean
|
||||||
|
* @version 1.0.0
|
||||||
|
* @invite nz87hXyvcy
|
||||||
|
* @website https://github.com/refact0r/system24
|
||||||
|
* @source https://github.com/refact0r/system24/blob/master/flavors/catppuccin-mocha.theme.css
|
||||||
|
* @authorId 508863359777505290
|
||||||
|
* @authorLink https://www.refact0r.dev
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* import theme modules */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
|
||||||
|
|
||||||
|
/* customize things here */
|
||||||
|
:root {
|
||||||
|
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
|
||||||
|
letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */
|
||||||
|
font-weight: 300; /* UI font weight. */
|
||||||
|
--label-font-weight: 500; /* font weight for panel labels. */
|
||||||
|
--corner-text: 'macchiato24'; /* custom text to display in the corner. only works on windows. */
|
||||||
|
--pad: 16px; /* padding between panels. */
|
||||||
|
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
|
||||||
|
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
|
||||||
|
|
||||||
|
/* background colors */
|
||||||
|
--bg-0: #24273a; /* main background color. */
|
||||||
|
--bg-1: #1e2030; /* background color for secondary elements like code blocks, embeds, etc. */
|
||||||
|
--bg-2: #363a4f; /* color of neutral buttons. */
|
||||||
|
--bg-3: #494d64; /* color of neutral buttons when hovered. */
|
||||||
|
|
||||||
|
/* state modifiers */
|
||||||
|
--hover: color-mix(in oklch, var(--txt-3), transparent 80%); /* color of hovered elements. */
|
||||||
|
--active: color-mix(in oklch, var(--txt-3), transparent 60%); /* color of elements when clicked. */
|
||||||
|
--selected: var(--active); /* color of selected elements. */
|
||||||
|
|
||||||
|
/* text colors */
|
||||||
|
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
|
||||||
|
--txt-link: var(--cyan); /* color of links. */
|
||||||
|
--txt-0: #eaeefa; /* color of bright/white text. */
|
||||||
|
--txt-1: #cad3f5; /* main text color. */
|
||||||
|
--txt-2: #939ab7; /* color of secondary text like channel list. */
|
||||||
|
--txt-3: #5b6078; /* color of muted text. */
|
||||||
|
|
||||||
|
/* accent colors */
|
||||||
|
--acc-0: var(--lavender); /* main accent color. */
|
||||||
|
--acc-1: var(--lavender-1); /* color of accent buttons when hovered. */
|
||||||
|
--acc-2: var(--lavender-2); /* color of accent buttons when clicked. */
|
||||||
|
|
||||||
|
/* borders */
|
||||||
|
--border-width: 2px; /* panel border thickness. */
|
||||||
|
--border-color: var(--bg-2); /* panel border color. */
|
||||||
|
--border-hover-color: var(--acc-0); /* panel border color when hovered. */
|
||||||
|
--border-transition: 0.2s ease; /* panel border transition. */
|
||||||
|
|
||||||
|
/* status dot colors */
|
||||||
|
--online-dot: var(--cyan); /* color of online dot. */
|
||||||
|
--dnd-dot: var(--pink); /* color of do not disturb dot. */
|
||||||
|
--idle-dot: var(--yellow); /* color of idle dot. */
|
||||||
|
--streaming-dot: var(--purple); /* color of streaming dot. */
|
||||||
|
|
||||||
|
/* mention/ping and message colors */
|
||||||
|
--mention-txt: var(--acc-0); /* color of mention text. */
|
||||||
|
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
|
||||||
|
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
|
||||||
|
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
|
||||||
|
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
|
||||||
|
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
|
||||||
|
|
||||||
|
/* color shades */
|
||||||
|
--pink: oklch(73.7% 0.125 11.19);
|
||||||
|
--pink-1: oklch(63.7% 0.125 11.19);
|
||||||
|
--pink-2: oklch(53.7% 0.125 11.19);
|
||||||
|
--purple: oklch(77.15% 0.126 303.9);
|
||||||
|
--purple-1: oklch(67.15% 0.126 303.9);
|
||||||
|
--purple-2: oklch(57.15% 0.126 303.9);
|
||||||
|
--cyan: oklch(78.51% 0.085 228.38);
|
||||||
|
--yellow: oklch(87.9% 0.074 84.75);
|
||||||
|
--green: oklch(83.5% 0.108 138.15);
|
||||||
|
--green-1: oklch(73.5% 0.108 138.15);
|
||||||
|
--green-2: oklch(63.5% 0.108 138.15);
|
||||||
|
--lavender: oklch(81.66% 0.091 277.31);
|
||||||
|
--lavender-1: oklch(71.66% 0.091 277.31);
|
||||||
|
--lavender-2: oklch(61.66% 0.091 277.31);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* @name system24 (catppuccin mocha)
|
||||||
|
* @description A tui-style discord theme. Based on the catppuccin mocha color palette.
|
||||||
|
* @author refact0r
|
||||||
|
* @version 1.0.0
|
||||||
|
* @invite nz87hXyvcy
|
||||||
|
* @website https://github.com/refact0r/system24
|
||||||
|
* @source https://github.com/refact0r/system24/blob/master/flavors/catppuccin-mocha.theme.css
|
||||||
|
* @authorId 508863359777505290
|
||||||
|
* @authorLink https://www.refact0r.dev
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* import theme modules */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
|
||||||
|
|
||||||
|
/* customize things here */
|
||||||
|
:root {
|
||||||
|
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
|
||||||
|
letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */
|
||||||
|
font-weight: 300; /* UI font weight. */
|
||||||
|
--label-font-weight: 500; /* font weight for panel labels. */
|
||||||
|
--corner-text: 'catppuccin24'; /* custom text to display in the corner. only works on windows. */
|
||||||
|
--pad: 16px; /* padding between panels. */
|
||||||
|
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
|
||||||
|
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
|
||||||
|
|
||||||
|
/* background colors */
|
||||||
|
--bg-0: #1e1e2e; /* main background color. */
|
||||||
|
--bg-1: #181825; /* background color for secondary elements like code blocks, embeds, etc. */
|
||||||
|
--bg-2: #313244; /* color of neutral buttons. */
|
||||||
|
--bg-3: #45475a; /* color of neutral buttons when hovered. */
|
||||||
|
|
||||||
|
/* state modifiers */
|
||||||
|
--hover: color-mix(in oklch, var(--txt-3), transparent 80%); /* color of hovered elements. */
|
||||||
|
--active: color-mix(in oklch, var(--txt-3), transparent 60%); /* color of elements when clicked. */
|
||||||
|
--selected: var(--active); /* color of selected elements. */
|
||||||
|
|
||||||
|
/* text colors */
|
||||||
|
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
|
||||||
|
--txt-link: var(--cyan); /* color of links. */
|
||||||
|
--txt-0: #eaeefa; /* color of bright/white text. */
|
||||||
|
--txt-1: #cdd6f4; /* main text color. */
|
||||||
|
--txt-2: #9399b2; /* color of secondary text like channel list. */
|
||||||
|
--txt-3: #585b70; /* color of muted text. */
|
||||||
|
|
||||||
|
/* accent colors */
|
||||||
|
--acc-0: var(--purple); /* main accent color. */
|
||||||
|
--acc-1: var(--purple-1); /* color of accent buttons when hovered. */
|
||||||
|
--acc-2: var(--purple-2); /* color of accent buttons when clicked. */
|
||||||
|
|
||||||
|
/* borders */
|
||||||
|
--border-width: 2px; /* panel border thickness. */
|
||||||
|
--border-color: var(--bg-2); /* panel border color. */
|
||||||
|
--border-hover-color: var(--acc-0); /* panel border color when hovered. */
|
||||||
|
--border-transition: 0.2s ease; /* panel border transition. */
|
||||||
|
|
||||||
|
/* status dot colors */
|
||||||
|
--online-dot: var(--green); /* color of online dot. */
|
||||||
|
--dnd-dot: var(--pink); /* color of do not disturb dot. */
|
||||||
|
--idle-dot: var(--yellow); /* color of idle dot. */
|
||||||
|
--streaming-dot: var(--purple); /* color of streaming dot. */
|
||||||
|
|
||||||
|
/* mention/ping and message colors */
|
||||||
|
--mention-txt: var(--acc-0); /* color of mention text. */
|
||||||
|
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
|
||||||
|
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
|
||||||
|
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
|
||||||
|
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
|
||||||
|
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
|
||||||
|
|
||||||
|
/* color shades */
|
||||||
|
--pink: #f38ba8;
|
||||||
|
--pink-1: #d16c89;
|
||||||
|
--pink-2: #af4e6c;
|
||||||
|
--purple: #cba6f7;
|
||||||
|
--purple-1: #ab87d6;
|
||||||
|
--purple-2: #8d69b5;
|
||||||
|
--cyan: #74c7ec;
|
||||||
|
--yellow: #f9e2af;
|
||||||
|
--green: #a6e3a1;
|
||||||
|
--green-1: #87c282;
|
||||||
|
--green-2: #68a364;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* @name system24
|
||||||
|
* @description A tui-style discord theme.
|
||||||
|
* @author refact0r
|
||||||
|
* @version 1.0.0
|
||||||
|
* @invite nz87hXyvcy
|
||||||
|
* @website https://github.com/refact0r/system24
|
||||||
|
* @source https://github.com/refact0r/system24/blob/master/system24.theme.css
|
||||||
|
* @authorId 508863359777505290
|
||||||
|
* @authorLink https://www.refact0r.dev
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* import theme modules */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
|
||||||
|
|
||||||
|
/* customize things here */
|
||||||
|
:root {
|
||||||
|
--font: 'Agave Nerd Font'; /* UI font name. it must be installed on your system. */
|
||||||
|
letter-spacing: 0; /* decreases letter spacing for better readability. */
|
||||||
|
font-weight: 400; /* UI font weight. */
|
||||||
|
--label-font-weight: 400; /* font weight for panel labels. */
|
||||||
|
--corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */
|
||||||
|
--pad: 18px; /* padding between panels. */
|
||||||
|
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
|
||||||
|
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
|
||||||
|
|
||||||
|
/* background colors */
|
||||||
|
--bg-0: #1a1a1a; /* main background color. */
|
||||||
|
--bg-1: #1a1a1a; /* background color for secondary elements like code blocks, embeds, etc. */
|
||||||
|
--bg-2: #1a1a1a; /* color of neutral buttons. */
|
||||||
|
--bg-3: #641a1a; /* color of neutral buttons when hovered. */
|
||||||
|
|
||||||
|
/* state modifiers */
|
||||||
|
--hover: #641a1a; /* color of hovered elements. */
|
||||||
|
--active: #5018dd; /* color of elements when clicked. */
|
||||||
|
--selected: #E40046; /* color of selected elements. */
|
||||||
|
|
||||||
|
/* text colors */
|
||||||
|
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
|
||||||
|
--txt-link: var(--cyan); /* color of links. */
|
||||||
|
--txt-0: #d6abab; /* color of bright/white text. */
|
||||||
|
--txt-1: #d6abab;/* main text color. */
|
||||||
|
--txt-2: #5018dd;/* color of secondary text like channel list. */
|
||||||
|
--txt-3: #d6abab;/* color of muted text. */
|
||||||
|
|
||||||
|
/* accent colors */
|
||||||
|
--acc-1: #E40046; /* main accent color. */
|
||||||
|
--acc-0: #5018dd; /* color of accent buttons when hovered. */
|
||||||
|
--acc-2: #5018dd; /* color of accent buttons when clicked. */
|
||||||
|
|
||||||
|
/* borders */
|
||||||
|
--border-width: 1px; /* panel border thickness. */
|
||||||
|
--border-color: #5018dd; /* panel border color. */
|
||||||
|
--border-hover-color: #E40046; /* panel border color when hovered. */
|
||||||
|
--border-transition: 0.2s ease; /* panel border transition. */
|
||||||
|
|
||||||
|
/* status dot colors */
|
||||||
|
--online-dot: var(--green); /* color of online dot. */
|
||||||
|
--dnd-dot: var(--pink); /* color of do not disturb dot. */
|
||||||
|
--idle-dot: var(--yellow); /* color of idle dot. */
|
||||||
|
--streaming-dot: var(--purple); /* color of streaming dot. */
|
||||||
|
|
||||||
|
/* mention/ping and message colors */
|
||||||
|
--mention-txt: #f50505; /* color of mention text. */
|
||||||
|
--mention-bg: #5018dd; /* background highlight of mention text. */
|
||||||
|
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
|
||||||
|
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
|
||||||
|
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
|
||||||
|
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
|
||||||
|
|
||||||
|
/* color shades */
|
||||||
|
--pink: oklch(73% 0.12 0);
|
||||||
|
--pink-1: oklch(63% 0.12 0);
|
||||||
|
--pink-2: oklch(53% 0.12 0);
|
||||||
|
--purple: oklch(73% 0.12 300);
|
||||||
|
--purple-1: oklch(63% 0.12 300);
|
||||||
|
--purple-2: oklch(53% 0.12 300);
|
||||||
|
--cyan: oklch(73% 0.12 200);
|
||||||
|
--yellow: oklch(78% 0.12 80);
|
||||||
|
--green: hsl(141, 76%, 48%);
|
||||||
|
--green-1: hsl(141, 73%, 42%);
|
||||||
|
--green-2: hsl(141, 73%, 36%);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* @name system24 (gruvbox material)
|
||||||
|
* @description A tui-style discord theme. Based on the gruvbox material color palette.
|
||||||
|
* @author refact0r, Sophed
|
||||||
|
* @version 1.0.0
|
||||||
|
* @invite nz87hXyvcy
|
||||||
|
* @website https://github.com/refact0r/system24
|
||||||
|
* @authorId 508863359777505290
|
||||||
|
* @authorLink https://www.refact0r.dev
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* import theme modules */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
|
||||||
|
|
||||||
|
/* customize things here */
|
||||||
|
:root {
|
||||||
|
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
|
||||||
|
letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */
|
||||||
|
font-weight: 400; /* UI font weight. */
|
||||||
|
--label-font-weight: 500; /* font weight for panel labels. */
|
||||||
|
--corner-text: 'gruvbox material'; /* custom text to display in the corner. only works on windows. */
|
||||||
|
--pad: 16px; /* padding between panels. */
|
||||||
|
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
|
||||||
|
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
|
||||||
|
|
||||||
|
/* background colors */
|
||||||
|
--bg-0: #282828; /* main background color. */
|
||||||
|
--bg-1: #1d2021; /* background color for secondary elements like code blocks, embeds, etc. */
|
||||||
|
--bg-2: #3c3836; /* color of neutral buttons. */
|
||||||
|
--bg-3: #504945; /* color of neutral buttons when hovered. */
|
||||||
|
|
||||||
|
/* state modifiers */
|
||||||
|
--hover: color-mix(in oklch, var(--txt-3), transparent 80%); /* color of hovered elements. */
|
||||||
|
--active: color-mix(in oklch, var(--txt-3), transparent 60%); /* color of elements when clicked. */
|
||||||
|
--selected: var(--active); /* color of selected elements. */
|
||||||
|
|
||||||
|
/* text colors */
|
||||||
|
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
|
||||||
|
--txt-link: var(--aqua); /* color of links. */
|
||||||
|
--txt-0: #d4be98; /* color of bright/white text. */
|
||||||
|
--txt-1: #ddc7a1; /* main text color. */
|
||||||
|
--txt-2: #d4be98; /* color of secondary text like channel list. */
|
||||||
|
--txt-3: #928374; /* color of muted text. */
|
||||||
|
|
||||||
|
/* accent colors */
|
||||||
|
--acc-0: var(--aqua); /* main accent color. */
|
||||||
|
--acc-1: var(--green); /* color of accent buttons when hovered. */
|
||||||
|
--acc-2: var(--blue); /* color of accent buttons when clicked. */
|
||||||
|
|
||||||
|
/* borders */
|
||||||
|
--border-width: 2px; /* panel border thickness. */
|
||||||
|
--border-color: var(--bg-2); /* panel border color. */
|
||||||
|
--border-hover-color: var(--acc-0); /* panel border color when hovered. */
|
||||||
|
--border-transition: 0.2s ease; /* panel border transition. */
|
||||||
|
|
||||||
|
/* status dot colors */
|
||||||
|
--online-dot: var(--green); /* color of online dot. */
|
||||||
|
--dnd-dot: var(--red); /* color of do not disturb dot. */
|
||||||
|
--idle-dot: var(--yellow); /* color of idle dot. */
|
||||||
|
--streaming-dot: var(--purple); /* color of streaming dot. */
|
||||||
|
|
||||||
|
/* mention/ping and message colors */
|
||||||
|
--mention-txt: var(--acc-0); /* color of mention text. */
|
||||||
|
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
|
||||||
|
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
|
||||||
|
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
|
||||||
|
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
|
||||||
|
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
|
||||||
|
|
||||||
|
/* color shades */
|
||||||
|
--red: #ea6962;
|
||||||
|
--purple: #d3869b;
|
||||||
|
--yellow: #f9e2af;
|
||||||
|
--aqua: #89b482;
|
||||||
|
--blue: #7daea3;
|
||||||
|
--green: #a9b665;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
/**
|
||||||
|
* @name system24 (light)
|
||||||
|
* @description A tui-style discord theme. light mode.
|
||||||
|
* @author refact0r
|
||||||
|
* @version 1.0.0
|
||||||
|
* @invite nz87hXyvcy
|
||||||
|
* @website https://github.com/refact0r/system24
|
||||||
|
* @source https://github.com/refact0r/system24/blob/master/flavors/light.theme.css
|
||||||
|
* @authorId 508863359777505290
|
||||||
|
* @authorLink https://www.refact0r.dev
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* import theme modules */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
|
||||||
|
|
||||||
|
/* customize things here */
|
||||||
|
:root {
|
||||||
|
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
|
||||||
|
letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */
|
||||||
|
font-weight: 300; /* UI font weight. */
|
||||||
|
--label-font-weight: 500; /* font weight for panel labels. */
|
||||||
|
--corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */
|
||||||
|
--pad: 16px; /* padding between panels. */
|
||||||
|
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
|
||||||
|
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
|
||||||
|
|
||||||
|
/* background colors */
|
||||||
|
--bg-0: oklch(97.6% 0 0); /* main background color. */
|
||||||
|
--bg-1: oklch(92.6% 0 0); /* background color for secondary elements like code blocks, embeds, etc. */
|
||||||
|
--bg-2: oklch(87.6% 0 0); /* color of neutral buttons. */
|
||||||
|
--bg-3: oklch(82.6% 0 0); /* color of neutral buttons when hovered. */
|
||||||
|
|
||||||
|
/* state modifiers */
|
||||||
|
--hover: oklch(40% 0 0 / 0.1); /* color of hovered elements. */
|
||||||
|
--active: oklch(40% 0 0 / 0.2); /* color of elements when clicked. */
|
||||||
|
--selected: var(--active); /* color of selected elements. */
|
||||||
|
|
||||||
|
/* text colors */
|
||||||
|
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
|
||||||
|
--txt-link: var(--cyan); /* color of links. */
|
||||||
|
--txt-0: oklch(100% 0 0); /* color of bright/white text. */
|
||||||
|
--txt-1: oklch(30% 0 0); /* main text color. */
|
||||||
|
--txt-2: oklch(50% 0 0); /* color of secondary text like channel list. */
|
||||||
|
--txt-3: oklch(70% 0 0); /* color of muted text. */
|
||||||
|
|
||||||
|
/* accent colors */
|
||||||
|
--acc-0: var(--purple); /* main accent color. */
|
||||||
|
--acc-1: var(--purple-1); /* color of accent buttons when hovered. */
|
||||||
|
--acc-2: var(--purple-2); /* color of accent buttons when clicked. */
|
||||||
|
|
||||||
|
/* borders */
|
||||||
|
--border-width: 2px; /* panel border thickness. */
|
||||||
|
--border-color: var(--bg-2); /* panel border color. */
|
||||||
|
--border-hover-color: var(--acc-0); /* panel border color when hovered. */
|
||||||
|
--border-transition: 0.2s ease; /* panel border transition. */
|
||||||
|
|
||||||
|
/* status dot colors */
|
||||||
|
--online-dot: var(--green); /* color of online dot. */
|
||||||
|
--dnd-dot: var(--pink); /* color of do not disturb dot. */
|
||||||
|
--idle-dot: var(--yellow); /* color of idle dot. */
|
||||||
|
--streaming-dot: var(--purple); /* color of streaming dot. */
|
||||||
|
|
||||||
|
/* mention/ping and message colors */
|
||||||
|
--mention-txt: var(--acc-0); /* color of mention text. */
|
||||||
|
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 85%); /* background highlight of mention text. */
|
||||||
|
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 85%); /* overlay color of messages that mention you. */
|
||||||
|
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you when hovered. */
|
||||||
|
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
|
||||||
|
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
|
||||||
|
|
||||||
|
/* color shades */
|
||||||
|
--pink: oklch(57% 0.13 0);
|
||||||
|
--pink-1: oklch(50% 0.13 0);
|
||||||
|
--pink-2: oklch(43% 0.13 0);
|
||||||
|
--purple: oklch(57% 0.13 300);
|
||||||
|
--purple-1: oklch(50% 0.13 300);
|
||||||
|
--purple-2: oklch(43% 0.13 300);
|
||||||
|
--cyan: oklch(57% 0.13 200);
|
||||||
|
--yellow: oklch(71% 0.13 80);
|
||||||
|
--green: oklch(57% 0.13 160);
|
||||||
|
--green-1: oklch(50% 0.13 160);
|
||||||
|
--green-2: oklch(43% 0.13 160);
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^=jumpToPresentBar_] > button {
|
||||||
|
color: var(--txt-1);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
/**
|
||||||
|
* @name system24 (monochrome)
|
||||||
|
* @description A tui-style discord theme. Has less colors and more neutral greys and whites.
|
||||||
|
* @author refact0r, DeadGrip
|
||||||
|
* @version 1.0.0
|
||||||
|
* @invite nz87hXyvcy
|
||||||
|
* @website https://github.com/refact0r/system24
|
||||||
|
* @authorId 508863359777505290
|
||||||
|
* @authorLink https://refact0r.dev/
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* import theme modules */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
|
||||||
|
|
||||||
|
/* customize things here */
|
||||||
|
:root {
|
||||||
|
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
|
||||||
|
letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */
|
||||||
|
font-weight: 300; /* UI font weight. */
|
||||||
|
--label-font-weight: 500; /* font weight for panel labels. */
|
||||||
|
--corner-text: 'monochrome'; /* custom text to display in the corner. only works on windows. */
|
||||||
|
--pad: 16px; /* padding between panels. */
|
||||||
|
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
|
||||||
|
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
|
||||||
|
|
||||||
|
/* background colors */
|
||||||
|
--bg-0: oklch(19% 0 0); /* main background color. */
|
||||||
|
--bg-1: oklch(23% 0 0); /* background color for secondary elements like code blocks, embeds, etc. */
|
||||||
|
--bg-2: oklch(27% 0 0); /* color of neutral buttons. */
|
||||||
|
--bg-3: oklch(31% 0 0); /* color of neutral buttons when hovered. */
|
||||||
|
|
||||||
|
/* state modifiers */
|
||||||
|
--hover: oklch(54% 0 0 / 0.1); /* color of hovered elements. */
|
||||||
|
--active: oklch(54% 0 0 / 0.2); /* color of elements when clicked. */
|
||||||
|
--selected: var(--active); /* color of selected elements. */
|
||||||
|
|
||||||
|
/* text colors */
|
||||||
|
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
|
||||||
|
--txt-link: var(--shade-0); /* color of links. */
|
||||||
|
--txt-0: oklch(90% 0 0); /* color of bright/white text. */
|
||||||
|
--txt-1: oklch(80% 0 0); /* main text color. */
|
||||||
|
--txt-2: oklch(60% 0 0); /* color of secondary text like channel list. */
|
||||||
|
--txt-3: oklch(40% 0 0); /* color of muted text. */
|
||||||
|
|
||||||
|
/* accent colors */
|
||||||
|
--acc-0: var(--shade-0); /* main accent color. */
|
||||||
|
--acc-1: var(--shade-1); /* color of accent buttons when hovered. */
|
||||||
|
--acc-2: var(--shade-2); /* color of accent buttons when clicked. */
|
||||||
|
|
||||||
|
/* borders */
|
||||||
|
--border-width: 2px; /* panel border thickness. */
|
||||||
|
--border-color: var(--bg-3); /* panel border color. */
|
||||||
|
--border-hover-color: var(--acc-1); /* panel border color when hovered. */
|
||||||
|
--border-transition: 0.2s ease; /* panel border transition. */
|
||||||
|
|
||||||
|
/* status dot colors */
|
||||||
|
--online-dot: var(--shade-0); /* color of online dot. */
|
||||||
|
--dnd-dot: oklch(80% 0.08 0); /* color of do not disturb dot. */
|
||||||
|
--idle-dot: oklch(80% 0.08 100); /* color of idle dot. */
|
||||||
|
--streaming-dot: oklch(80% 0.08 300); /* color of streaming dot. */
|
||||||
|
|
||||||
|
/* mention/ping and message colors */
|
||||||
|
--mention-txt: var(--acc-0); /* color of mention text. */
|
||||||
|
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
|
||||||
|
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
|
||||||
|
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
|
||||||
|
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
|
||||||
|
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
|
||||||
|
|
||||||
|
/* color shades */
|
||||||
|
--pink: oklch(90% 0 0);
|
||||||
|
--pink-1: oklch(70% 0 0);
|
||||||
|
--pink-2: oklch(50% 0 0);
|
||||||
|
--purple: oklch(90% 0 0);
|
||||||
|
--purple-1: oklch(70% 0 0);
|
||||||
|
--purple-2: oklch(50% 0 0);
|
||||||
|
--cyan: oklch(90% 0 0);
|
||||||
|
--yellow: oklch(90% 0 0);
|
||||||
|
--green: oklch(90% 0 0);
|
||||||
|
--green-1: oklch(70% 0 0);
|
||||||
|
--green-2: oklch(50% 0 0);
|
||||||
|
--shade-0: oklch(90% 0 0);
|
||||||
|
--shade-1: oklch(70% 0 0);
|
||||||
|
--shade-2: oklch(50% 0 0);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* @name system24
|
||||||
|
* @description A tui-style discord theme.
|
||||||
|
* @author refact0r
|
||||||
|
* @version 1.0.0
|
||||||
|
* @invite nz87hXyvcy
|
||||||
|
* @website https://github.com/refact0r/system24
|
||||||
|
* @source https://github.com/refact0r/system24/blob/master/system24.theme.css
|
||||||
|
* @authorId 508863359777505290
|
||||||
|
* @authorLink https://www.refact0r.dev
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* import theme modules */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
|
||||||
|
|
||||||
|
/* customize things here */
|
||||||
|
:root {
|
||||||
|
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
|
||||||
|
letter-spacing: 0; /* decreases letter spacing for better readability. */
|
||||||
|
font-weight: 400; /* UI font weight. */
|
||||||
|
--label-font-weight: 400; /* font weight for panel labels. */
|
||||||
|
--corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */
|
||||||
|
--pad: 16px; /* padding between panels. */
|
||||||
|
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
|
||||||
|
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
|
||||||
|
|
||||||
|
/* background colors */
|
||||||
|
--bg-0: #191724; /* main background color. */
|
||||||
|
--bg-1: #1f1d2e; /* background color for secondary elements like code blocks, embeds, etc. */
|
||||||
|
--bg-2: #26233a; /* color of neutral buttons. */
|
||||||
|
--bg-3: #403d52; /* color of neutral buttons when hovered. */
|
||||||
|
|
||||||
|
/* state modifiers */
|
||||||
|
--hover: color-mix(in srgb, var(--bg-3), transparent 60%); /* color of hovered elements. */
|
||||||
|
--active: color-mix(in srgb, var(--bg-3), transparent 80%); /* color of elements when clicked. */
|
||||||
|
--selected: var(--active); /* color of selected elements. */
|
||||||
|
|
||||||
|
/* text colors */
|
||||||
|
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
|
||||||
|
--txt-link: var(--rose); /* color of links. */
|
||||||
|
--txt-0: #ffffff; /* color of bright/white text. */
|
||||||
|
--txt-1: #e0def4; /* main text color. */
|
||||||
|
--txt-2: #908caa; /* color of secondary text like channel list. */
|
||||||
|
--txt-3: #6e6a86; /* color of muted text. */
|
||||||
|
|
||||||
|
/* accent colors */
|
||||||
|
--acc-0: var(--rose); /* main accent color. */
|
||||||
|
--acc-1: var(--rose-1); /* color of accent buttons when hovered. */
|
||||||
|
--acc-2: var(--rose-2); /* color of accent buttons when clicked. */
|
||||||
|
|
||||||
|
/* borders */
|
||||||
|
--border-width: 2px; /* panel border thickness. */
|
||||||
|
--border-color: var(--bg-2); /* panel border color. */
|
||||||
|
--border-hover-color: var(--acc-0); /* panel border color when hovered. */
|
||||||
|
--border-transition: 0.2s ease; /* panel border transition. */
|
||||||
|
|
||||||
|
/* status dot colors */
|
||||||
|
--online-dot: var(--green); /* color of online dot. */
|
||||||
|
--dnd-dot: var(--pink); /* color of do not disturb dot. */
|
||||||
|
--idle-dot: var(--yellow); /* color of idle dot. */
|
||||||
|
--streaming-dot: var(--purple); /* color of streaming dot. */
|
||||||
|
|
||||||
|
/* mention/ping and message colors */
|
||||||
|
--mention-txt: var(--acc-0); /* color of mention text. */
|
||||||
|
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
|
||||||
|
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
|
||||||
|
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
|
||||||
|
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
|
||||||
|
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
|
||||||
|
|
||||||
|
/* color shades */
|
||||||
|
--pink: oklch(69.77% 0.1565 4.22);
|
||||||
|
--pink-1: oklch(59.77% 0.1565 4.22);
|
||||||
|
--pink-2: oklch(49.77% 0.1565 4.22);
|
||||||
|
--purple: oklch(77.6% 0.0945 304.99);
|
||||||
|
--purple-1: oklch(67.6% 0.0945 304.99);
|
||||||
|
--purple-2: oklch(57.6% 0.0945 304.99);
|
||||||
|
--cyan: oklch(52.77% 0.0793 227.72);
|
||||||
|
--yellow: oklch(84.29% 0.11 74.6);
|
||||||
|
--green: oklch(82.19% 0.0543 209.56);
|
||||||
|
--green-1: oklch(72.19% 0.0543 209.56);
|
||||||
|
--green-2: oklch(62.19% 0.0543 209.56);
|
||||||
|
--rose: oklch(83.63% 0.0544 21.14);
|
||||||
|
--rose-1: oklch(73.63% 0.0544 21.14);
|
||||||
|
--rose-2: oklch(63.63% 0.0544 21.14);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* @name system24
|
||||||
|
* @description A tui-style discord theme.
|
||||||
|
* @author refact0r
|
||||||
|
* @version 1.0.0
|
||||||
|
* @invite nz87hXyvcy
|
||||||
|
* @website https://github.com/refact0r/system24
|
||||||
|
* @source https://github.com/refact0r/system24/blob/master/system24.theme.css
|
||||||
|
* @authorId 508863359777505290
|
||||||
|
* @authorLink https://www.refact0r.dev
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* import theme modules */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
|
||||||
|
|
||||||
|
/* customize things here */
|
||||||
|
:root {
|
||||||
|
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
|
||||||
|
letter-spacing: 0; /* decreases letter spacing for better readability. */
|
||||||
|
font-weight: 400; /* UI font weight. */
|
||||||
|
--label-font-weight: 400; /* font weight for panel labels. */
|
||||||
|
--corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */
|
||||||
|
--pad: 18px; /* padding between panels. */
|
||||||
|
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
|
||||||
|
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
|
||||||
|
|
||||||
|
/* background colors */
|
||||||
|
--bg-0: hsl(0, 0%, 7%); /* main background color. */
|
||||||
|
--bg-1: hsl(0, 0%, 10%); /* background color for secondary elements like code blocks, embeds, etc. */
|
||||||
|
--bg-2: hsl(0, 0%, 13%); /* color of neutral buttons. */
|
||||||
|
--bg-3: hsl(0, 0%, 16%); /* color of neutral buttons when hovered. */
|
||||||
|
|
||||||
|
/* state modifiers */
|
||||||
|
--hover: hsla(0, 0%, 40%, 0.1); /* color of hovered elements. */
|
||||||
|
--active: hsla(0, 0%, 40%, 0.2); /* color of elements when clicked. */
|
||||||
|
--selected: var(--active); /* color of selected elements. */
|
||||||
|
|
||||||
|
/* text colors */
|
||||||
|
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
|
||||||
|
--txt-link: var(--cyan); /* color of links. */
|
||||||
|
--txt-0: hsl(0, 0%, 100%); /* color of bright/white text. */
|
||||||
|
--txt-1: hsl(0, 0%, 70%); /* main text color. */
|
||||||
|
--txt-2: hsl(0, 0%, 50%); /* color of secondary text like channel list. */
|
||||||
|
--txt-3: hsl(0, 0%, 30%); /* color of muted text. */
|
||||||
|
|
||||||
|
/* accent colors */
|
||||||
|
--acc-0: var(--green); /* main accent color. */
|
||||||
|
--acc-1: var(--green-1); /* color of accent buttons when hovered. */
|
||||||
|
--acc-2: var(--green-2); /* color of accent buttons when clicked. */
|
||||||
|
|
||||||
|
/* borders */
|
||||||
|
--border-width: 1px; /* panel border thickness. */
|
||||||
|
--border-color: #535353; /* panel border color. */
|
||||||
|
--border-hover-color: #1ed760; /* panel border color when hovered. */
|
||||||
|
--border-transition: 0.2s ease; /* panel border transition. */
|
||||||
|
|
||||||
|
/* status dot colors */
|
||||||
|
--online-dot: var(--green); /* color of online dot. */
|
||||||
|
--dnd-dot: var(--pink); /* color of do not disturb dot. */
|
||||||
|
--idle-dot: var(--yellow); /* color of idle dot. */
|
||||||
|
--streaming-dot: var(--purple); /* color of streaming dot. */
|
||||||
|
|
||||||
|
/* mention/ping and message colors */
|
||||||
|
--mention-txt: var(--acc-0); /* color of mention text. */
|
||||||
|
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
|
||||||
|
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
|
||||||
|
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
|
||||||
|
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
|
||||||
|
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
|
||||||
|
|
||||||
|
/* color shades */
|
||||||
|
--pink: oklch(73% 0.12 0);
|
||||||
|
--pink-1: oklch(63% 0.12 0);
|
||||||
|
--pink-2: oklch(53% 0.12 0);
|
||||||
|
--purple: oklch(73% 0.12 300);
|
||||||
|
--purple-1: oklch(63% 0.12 300);
|
||||||
|
--purple-2: oklch(53% 0.12 300);
|
||||||
|
--cyan: oklch(73% 0.12 200);
|
||||||
|
--yellow: oklch(78% 0.12 80);
|
||||||
|
--green: hsl(141, 76%, 48%);
|
||||||
|
--green-1: hsl(141, 73%, 42%);
|
||||||
|
--green-2: hsl(141, 73%, 36%);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* @name system24
|
||||||
|
* @description A tui-style discord theme.
|
||||||
|
* @author refact0r, Liyua
|
||||||
|
* @version 1.0.0
|
||||||
|
* @invite nz87hXyvcy
|
||||||
|
* @website https://github.com/refact0r/system24
|
||||||
|
* @source https://github.com/refact0r/system24/blob/master/system24.theme.css
|
||||||
|
* @authorId 508863359777505290
|
||||||
|
* @authorLink https://www.refact0r.dev
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* import theme modules */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
|
||||||
|
|
||||||
|
/* customize things here */
|
||||||
|
:root {{
|
||||||
|
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
|
||||||
|
letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */
|
||||||
|
font-weight: 300; /* UI font weight. */
|
||||||
|
--label-font-weight: 500; /* font weight for panel labels. */
|
||||||
|
--corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */
|
||||||
|
--pad: 16px; /* padding between panels. */
|
||||||
|
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
|
||||||
|
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
|
||||||
|
|
||||||
|
/* background colors */
|
||||||
|
--bg-0: oklch(from {background} l c h); /* main background color. */
|
||||||
|
--bg-1: oklch(from {background} calc(l + 0.04) c h); /* background color for secondary elements like code blocks, embeds, etc. */
|
||||||
|
--bg-2: oklch(from {background} calc(l + 0.08) c h); /* color of neutral buttons. */
|
||||||
|
--bg-3: oklch(from {background} calc(l + 0.12) c h); /* color of neutral buttons when hovered. */
|
||||||
|
|
||||||
|
/* state modifiers */
|
||||||
|
--hover: oklch(54% 0 0 / 0.1); /* color of hovered elements. */
|
||||||
|
--active: oklch(54% 0 0 / 0.2); /* color of elements when clicked. */
|
||||||
|
--selected: var(--active); /* color of selected elements. */
|
||||||
|
|
||||||
|
/* text colors */
|
||||||
|
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
|
||||||
|
--txt-link: var(--cyan); /* color of links. */
|
||||||
|
--txt-0: oklch(from {foreground} 100% c h); /* color of bright/white text. */
|
||||||
|
--txt-1: oklch(from {foreground} 80% c h); /* main text color. */
|
||||||
|
--txt-2: oklch(from {foreground} 60% c h); /* color of secondary text like channel list. */
|
||||||
|
--txt-3: oklch(from {foreground} 40% c h); /* color of muted text. */
|
||||||
|
|
||||||
|
/* accent colors */
|
||||||
|
--acc-0: var(--purple); /* main accent color. */
|
||||||
|
--acc-1: var(--purple-1); /* color of accent buttons when hovered. */
|
||||||
|
--acc-2: var(--purple-2); /* color of accent buttons when clicked. */
|
||||||
|
|
||||||
|
/* borders */
|
||||||
|
--border-width: 2px; /* panel border thickness. */
|
||||||
|
--border-color: var(--bg-3); /* panel border color. */
|
||||||
|
--border-hover-color: var(--acc-0); /* panel border color when hovered. */
|
||||||
|
--border-transition: 0.2s ease; /* panel border transition. */
|
||||||
|
|
||||||
|
/* status dot colors */
|
||||||
|
--online-dot: var(--green); /* color of online dot. */
|
||||||
|
--dnd-dot: var(--pink); /* color of do not disturb dot. */
|
||||||
|
--idle-dot: var(--yellow); /* color of idle dot. */
|
||||||
|
--streaming-dot: var(--purple); /* color of streaming dot. */
|
||||||
|
|
||||||
|
/* mention/ping and message colors */
|
||||||
|
--mention-txt: var(--acc-0); /* color of mention text. */
|
||||||
|
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
|
||||||
|
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
|
||||||
|
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
|
||||||
|
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
|
||||||
|
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
|
||||||
|
|
||||||
|
/* color shades */
|
||||||
|
--pink: oklch(from {color0} l c h);
|
||||||
|
--pink-1: oklch(from var(--pink) calc(l - 0.1) c h);
|
||||||
|
--pink-2: oklch(from var(--pink) calc(l - 0.2) c h);
|
||||||
|
--purple: oklch(from {color1} l c h);
|
||||||
|
--purple-1: oklch(from var(--purple) calc(l - 0.1) c h);
|
||||||
|
--purple-2: oklch(from var(--purple) calc(l - 0.2) c h);
|
||||||
|
--cyan: oklch(from {color2} l c h);
|
||||||
|
--yellow: oklch(from {color3} l c h);
|
||||||
|
--green: oklch(from {color4} l c h);
|
||||||
|
--green-1: oklch(from var(--green) calc(l - 0.1) c h);
|
||||||
|
--green-2: oklch(from var(--green) calc(l - 0.2) c h);
|
||||||
|
}}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
/**
|
||||||
|
* @name system24 (vencord)
|
||||||
|
* @description A tui-style discord theme. Based on Vencord's color palette. (https://vencord.dev/)
|
||||||
|
* @author refact0r, makimwah
|
||||||
|
* @version 1.0.0
|
||||||
|
* @invite nz87hXyvcy
|
||||||
|
* @website https://github.com/refact0r/system24
|
||||||
|
* @source https://github.com/refact0r/system24/blob/master/system24.theme.css
|
||||||
|
* @authorId 508863359777505290
|
||||||
|
* @authorLink https://www.refact0r.dev
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* import theme modules */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
|
||||||
|
|
||||||
|
/* customize things here */
|
||||||
|
:root {
|
||||||
|
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
|
||||||
|
letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */
|
||||||
|
font-weight: 300; /* UI font weight. */
|
||||||
|
--label-font-weight: 500; /* font weight for panel labels. */
|
||||||
|
--corner-text: 'vencord24'; /* custom text to display in the corner. only works on windows. */
|
||||||
|
--pad: 16px; /* padding between panels. */
|
||||||
|
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
|
||||||
|
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
|
||||||
|
|
||||||
|
/* background colors */
|
||||||
|
--bg-0: oklch(28% 0.002 48); /* main background color. */
|
||||||
|
--bg-1: oklch(25% 0.001 48); /* background color for secondary elements like code blocks, embeds, etc. */
|
||||||
|
--bg-2: oklch(34% 0.004 48); /* color of neutral buttons. */
|
||||||
|
--bg-3: oklch(38% 0.006 48); /* color of neutral buttons when hovered. */
|
||||||
|
|
||||||
|
/* state modifiers */
|
||||||
|
--hover: oklch(54% 0.006 48 / 0.1); /* color of hovered elements. */
|
||||||
|
--active: oklch(54% 0.006 48 / 0.2); /* color of elements when clicked. */
|
||||||
|
--selected: var(--active); /* color of selected elements. */
|
||||||
|
|
||||||
|
/* text colors */
|
||||||
|
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
|
||||||
|
--txt-link: var(--cyan); /* color of links. */
|
||||||
|
--txt-0: oklch(90% 0 0); /* color of bright/white text. */
|
||||||
|
--txt-1: #ddc7a0; /* main text color. */
|
||||||
|
--txt-2: #918274; /* color of secondary text like channel list. */
|
||||||
|
--txt-3: #918274; /* color of muted text. */
|
||||||
|
|
||||||
|
/* accent colors */
|
||||||
|
--acc-0: var(--purple); /* main accent color. */
|
||||||
|
--acc-1: var(--purple-1); /* color of accent buttons when hovered. */
|
||||||
|
--acc-2: var(--purple-1); /* color of accent buttons when clicked. */
|
||||||
|
|
||||||
|
/* borders */
|
||||||
|
--border-width: 2px; /* panel border thickness. */
|
||||||
|
--border-color: var(--bg-2); /* panel border color. */
|
||||||
|
--border-hover-color: var(--acc-0); /* panel border color when hovered. */
|
||||||
|
--border-transition: 0.2s ease; /* panel border transition. */
|
||||||
|
|
||||||
|
/* status dot colors */
|
||||||
|
--online-dot: var(--green); /* color of online dot. */
|
||||||
|
--dnd-dot: var(--pink); /* color of do not disturb dot. */
|
||||||
|
--idle-dot: var(--yellow); /* color of idle dot. */
|
||||||
|
--streaming-dot: var(--purple); /* color of streaming dot. */
|
||||||
|
|
||||||
|
/* mention/ping and message colors */
|
||||||
|
--mention-txt: var(--acc-0); /* color of mention text. */
|
||||||
|
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
|
||||||
|
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
|
||||||
|
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
|
||||||
|
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
|
||||||
|
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
|
||||||
|
|
||||||
|
/* color shades */
|
||||||
|
--pink: #ea6962;
|
||||||
|
--pink-1: oklch(63% 0.12 0);
|
||||||
|
--pink-2: oklch(53% 0.12 0);
|
||||||
|
--purple: #d3869b;
|
||||||
|
--purple-1: #d27390;
|
||||||
|
--cyan: #7caea3;
|
||||||
|
--yellow: #d8a656;
|
||||||
|
--green: #a8b665;
|
||||||
|
--green-1: oklch(63% 0.12 160);
|
||||||
|
--green-2: oklch(53% 0.12 160);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* @name system24
|
||||||
|
* @description A tui-style discord theme.
|
||||||
|
* @author refact0r
|
||||||
|
* @version 1.0.0
|
||||||
|
* @invite nz87hXyvcy
|
||||||
|
* @website https://github.com/refact0r/system24
|
||||||
|
* @source https://github.com/refact0r/system24/blob/master/system24.theme.css
|
||||||
|
* @authorId 508863359777505290
|
||||||
|
* @authorLink https://www.refact0r.dev
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* import theme modules */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/main.css'); /* main theme css. DO NOT REMOVE */
|
||||||
|
@import url('https://refact0r.github.io/system24/src/unrounding.css'); /* gets rid of all rounded corners. remove if you want rounded corners. */
|
||||||
|
|
||||||
|
/* customize things here */
|
||||||
|
:root {
|
||||||
|
--font: 'DM Mono'; /* UI font name. it must be installed on your system. */
|
||||||
|
letter-spacing: -0.05ch; /* decreases letter spacing for better readability. */
|
||||||
|
font-weight: 300; /* UI font weight. */
|
||||||
|
--label-font-weight: 500; /* font weight for panel labels. */
|
||||||
|
--corner-text: 'system24'; /* custom text to display in the corner. only works on windows. */
|
||||||
|
--pad: 16px; /* padding between panels. */
|
||||||
|
--txt-pad: 10px; /* padding inside panels to prevent labels from clipping */
|
||||||
|
--panel-roundness: 0px; /* corner roundness of panels. ONLY WORKS IF unrounding.css IS REMOVED (see above). */
|
||||||
|
|
||||||
|
/* background colors */
|
||||||
|
--bg-0: oklch(19% 0 0); /* main background color. */
|
||||||
|
--bg-1: oklch(23% 0 0); /* background color for secondary elements like code blocks, embeds, etc. */
|
||||||
|
--bg-2: oklch(27% 0 0); /* color of neutral buttons. */
|
||||||
|
--bg-3: oklch(31% 0 0); /* color of neutral buttons when hovered. */
|
||||||
|
|
||||||
|
/* state modifiers */
|
||||||
|
--hover: oklch(54% 0 0 / 0.1); /* color of hovered elements. */
|
||||||
|
--active: oklch(54% 0 0 / 0.2); /* color of elements when clicked. */
|
||||||
|
--selected: var(--active); /* color of selected elements. */
|
||||||
|
|
||||||
|
/* text colors */
|
||||||
|
--txt-dark: var(--bg-0); /* color of dark text on colored backgrounds. */
|
||||||
|
--txt-link: var(--cyan); /* color of links. */
|
||||||
|
--txt-0: oklch(90% 0 0); /* color of bright/white text. */
|
||||||
|
--txt-1: oklch(80% 0 0); /* main text color. */
|
||||||
|
--txt-2: oklch(60% 0 0); /* color of secondary text like channel list. */
|
||||||
|
--txt-3: oklch(40% 0 0); /* color of muted text. */
|
||||||
|
|
||||||
|
/* accent colors */
|
||||||
|
--acc-0: var(--purple); /* main accent color. */
|
||||||
|
--acc-1: var(--purple-1); /* color of accent buttons when hovered. */
|
||||||
|
--acc-2: var(--purple-2); /* color of accent buttons when clicked. */
|
||||||
|
|
||||||
|
/* borders */
|
||||||
|
--border-width: 2px; /* panel border thickness. */
|
||||||
|
--border-color: var(--bg-3); /* panel border color. */
|
||||||
|
--border-hover-color: var(--acc-0); /* panel border color when hovered. */
|
||||||
|
--border-transition: 0.2s ease; /* panel border transition. */
|
||||||
|
|
||||||
|
/* status dot colors */
|
||||||
|
--online-dot: var(--green); /* color of online dot. */
|
||||||
|
--dnd-dot: var(--pink); /* color of do not disturb dot. */
|
||||||
|
--idle-dot: var(--yellow); /* color of idle dot. */
|
||||||
|
--streaming-dot: var(--purple); /* color of streaming dot. */
|
||||||
|
|
||||||
|
/* mention/ping and message colors */
|
||||||
|
--mention-txt: var(--acc-0); /* color of mention text. */
|
||||||
|
--mention-bg: color-mix(in oklch, var(--acc-0), transparent 90%); /* background highlight of mention text. */
|
||||||
|
--mention-overlay: color-mix(in oklch, var(--acc-0), transparent 90%); /* overlay color of messages that mention you. */
|
||||||
|
--mention-hover-overlay: color-mix(in oklch, var(--acc-0), transparent 95%); /* overlay color of messages that mention you when hovered. */
|
||||||
|
--reply-overlay: var(--active); /* overlay color of message you are replying to. */
|
||||||
|
--reply-hover-overlay: var(--hover); /* overlay color of message you are replying to when hovered. */
|
||||||
|
|
||||||
|
/* color shades */
|
||||||
|
--pink: oklch(73% 0.12 0);
|
||||||
|
--pink-1: oklch(63% 0.12 0);
|
||||||
|
--pink-2: oklch(53% 0.12 0);
|
||||||
|
--purple: oklch(73% 0.12 300);
|
||||||
|
--purple-1: oklch(63% 0.12 300);
|
||||||
|
--purple-2: oklch(53% 0.12 300);
|
||||||
|
--cyan: oklch(73% 0.12 200);
|
||||||
|
--yellow: oklch(78% 0.12 80);
|
||||||
|
--green: oklch(73% 0.12 160);
|
||||||
|
--green-1: oklch(63% 0.12 160);
|
||||||
|
--green-2: oklch(53% 0.12 160);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/home/themiro/Dotfiles/desktopenvs/hyprland/alacritty
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
[font]
|
||||||
|
normal = {family = "Agave NerdFont", style = "Regular"}
|
||||||
|
|
||||||
|
[terminal]
|
||||||
|
[terminal.shell]
|
||||||
|
program = "/usr/bin/zsh"
|
||||||
|
args = ["-l"]
|
||||||
|
|
||||||
|
[window]
|
||||||
|
opacity = 0.3
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# astro-menu
|
||||||
|
|
||||||
|
A touch-friendly GTK4 popup control centre for the hyprlua desktop, replacing
|
||||||
|
`nwg-dock` and `nwg-drawer`. Triggered from the EWW top bar (or `Super+D`).
|
||||||
|
|
||||||
|
Layout: a **2×2 quad grid** of feature modules over a full-width **application
|
||||||
|
drawer**. Any quad can expand to overlay the other three; the drawer can expand to
|
||||||
|
the bottom of the screen. A single margined root box letterboxes the menu identically
|
||||||
|
in every state.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Frontend:** Python + PyGObject + **GTK4**, as a `wlr-layer-shell` surface
|
||||||
|
(`gtk4-layer-shell`). Chosen over Lua because lgi/Astal-Lua only support GTK3.
|
||||||
|
- **Location map:** a static OSM image stitched from tiles (`backend/staticmap.py`,
|
||||||
|
Pillow). libshumate does not paint tiles in this environment (the official
|
||||||
|
`shumate-demo` shows the same blank map), though tile downloads work — hence the
|
||||||
|
static fallback.
|
||||||
|
- **Services:** Astal GObject libraries via introspection — `AstalNetwork`,
|
||||||
|
`AstalBluetooth`, `AstalApps` — plus our own IP-geolocation singleton.
|
||||||
|
- **Backends:** Python/Bash scripts in `backend/` (JSON on stdout), run async via
|
||||||
|
`Gio.Subprocess` so nothing blocks the UI.
|
||||||
|
- **Theme:** `style/_colors.css` (`@define-color`, generated from
|
||||||
|
`~/Dotfiles/colors.conf` by `apply-theme.sh`) + `style/style.css`.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
`main.py` is single-instance. The autostart launches a hidden resident daemon;
|
||||||
|
verbs are forwarded over D-Bus:
|
||||||
|
|
||||||
|
scripts/astro-menu-start.sh # resident daemon (hidden); sets LD_PRELOAD
|
||||||
|
scripts/menu-toggle.sh # --toggle
|
||||||
|
scripts/menu-toggle.sh appdrawer # open with the app drawer expanded
|
||||||
|
|
||||||
|
`astro-menu-start.sh` must `LD_PRELOAD` libgtk4-layer-shell (it loads after
|
||||||
|
libwayland under PyGObject otherwise).
|
||||||
|
|
||||||
|
## Adding a module
|
||||||
|
|
||||||
|
1. Create `modules/<name>.py` exposing a top-level `SPEC = ModuleSpec(...)` whose
|
||||||
|
`build(ctx)` returns a `ModuleInstance(compact=…, expanded=…, …)`.
|
||||||
|
- `compact` shows in the 2×2 cell; a distinct `expanded` widget enables the
|
||||||
|
expand button (they must be separate instances — GTK widgets have one parent).
|
||||||
|
- Declare per-feature toggles via `features=[Feature("id", "Label", default)]`;
|
||||||
|
read them with `ctx.feature("id")`. A disabled quad never calls `build()`.
|
||||||
|
- Shared state (network, bluetooth, location) is on `ctx.services`.
|
||||||
|
2. Append its `SPEC` to `ALL_SPECS` in `registry.py`. Nothing else changes.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
main.py app + single-instance IPC window.py layer-shell + letterbox
|
||||||
|
registry.py module list (extension seam) settings.py JSON toggles/order
|
||||||
|
module_base.py ModuleSpec / ModuleInstance / ModuleContext
|
||||||
|
appservices.py shared Astal services + location
|
||||||
|
ui/ quadcard, quadgrid, appdrawer
|
||||||
|
modules/ location, weather, bluetooth, network
|
||||||
|
services/ location (geolocation singleton)
|
||||||
|
lib/ ansi (SGR→TextTag), proc (async subprocess)
|
||||||
|
backend/ geolocate.py, weather.sh, network.sh
|
||||||
|
style/ _colors.css, style.css
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Lazily-built shared services handed to every module via ModuleContext.
|
||||||
|
|
||||||
|
Astal's GObject service libraries (AstalNetwork, AstalBluetooth) are consumed here
|
||||||
|
through GObject-introspection; modules connect to their `notify::` signals instead
|
||||||
|
of polling. The location service is our own singleton.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("AstalNetwork", "0.1")
|
||||||
|
gi.require_version("AstalBluetooth", "0.1")
|
||||||
|
from gi.repository import AstalBluetooth, AstalNetwork # noqa: E402
|
||||||
|
|
||||||
|
from services.location import get_location_service
|
||||||
|
|
||||||
|
|
||||||
|
class Services:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._network = None
|
||||||
|
self._bluetooth = None
|
||||||
|
self.location = get_location_service()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def network(self):
|
||||||
|
if self._network is None:
|
||||||
|
self._network = AstalNetwork.Network.get_default()
|
||||||
|
return self._network
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bluetooth(self):
|
||||||
|
if self._bluetooth is None:
|
||||||
|
self._bluetooth = AstalBluetooth.Bluetooth.get_default()
|
||||||
|
return self._bluetooth
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""IP geolocation, cached with a TTL.
|
||||||
|
|
||||||
|
Discovers the public IP by tracerouting to 1.1.1.1 and taking the first
|
||||||
|
globally-routable hop (the ISP egress nearest the user), then resolves that IP to a
|
||||||
|
location through a public geolocation API. Falls back to locating this host's own
|
||||||
|
public IP when traceroute is unavailable or yields no public hop.
|
||||||
|
|
||||||
|
Prints JSON: {lat, lon, city, country, ip, source}. Diagnostics go to stderr,
|
||||||
|
non-zero exit on total failure. Used by services/location.py (and runnable
|
||||||
|
standalone for testing).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astro-menu" / "location.json"
|
||||||
|
TTL = 1800 # seconds
|
||||||
|
|
||||||
|
TARGET = "1.1.1.1"
|
||||||
|
_IPV4 = re.compile(r"\b(\d{1,3}(?:\.\d{1,3}){3})\b")
|
||||||
|
|
||||||
|
# Geolocation providers. Each builds a URL for a given IP; an empty IP asks the
|
||||||
|
# provider to resolve the caller's own public address (the self-IP fallback).
|
||||||
|
PROVIDERS = [
|
||||||
|
("ip-api",
|
||||||
|
lambda ip: f"http://ip-api.com/json/{ip}?fields=lat,lon,city,country,query",
|
||||||
|
lambda d: {"lat": d["lat"], "lon": d["lon"], "city": d.get("city"),
|
||||||
|
"country": d.get("country"), "ip": d.get("query")}),
|
||||||
|
("ipapi.co",
|
||||||
|
lambda ip: f"https://ipapi.co/{ip}/json/" if ip else "https://ipapi.co/json/",
|
||||||
|
lambda d: {"lat": d["latitude"], "lon": d["longitude"], "city": d.get("city"),
|
||||||
|
"country": d.get("country_name"), "ip": d.get("ip")}),
|
||||||
|
("ipinfo",
|
||||||
|
lambda ip: f"https://ipinfo.io/{ip}/json" if ip else "https://ipinfo.io/json",
|
||||||
|
lambda d: {"lat": float(d["loc"].split(",")[0]), "lon": float(d["loc"].split(",")[1]),
|
||||||
|
"city": d.get("city"), "country": d.get("country"), "ip": d.get("ip")}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _cached() -> dict | None:
|
||||||
|
try:
|
||||||
|
blob = json.loads(CACHE.read_text())
|
||||||
|
if time.time() - blob.get("_ts", 0) < TTL:
|
||||||
|
return blob["data"]
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _store(data: dict) -> None:
|
||||||
|
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
CACHE.write_text(json.dumps({"_ts": time.time(), "data": data}))
|
||||||
|
|
||||||
|
|
||||||
|
def _public_hop_ip() -> str | None:
|
||||||
|
"""First globally-routable hop on the path to 1.1.1.1 — i.e. the ISP egress
|
||||||
|
closest to the user. The private/CGNAT hops before it and the anycast target
|
||||||
|
itself (Cloudflare, useless for locating the user) are skipped."""
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["traceroute", "-n", "-q", "1", "-w", "2", "-m", "12", TARGET],
|
||||||
|
capture_output=True, text=True, timeout=40,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.SubprocessError) as exc:
|
||||||
|
print(f"traceroute: {exc}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
for line in proc.stdout.splitlines():
|
||||||
|
if line.lower().startswith("traceroute to"):
|
||||||
|
continue # header line names the target; not a hop
|
||||||
|
for ip in _IPV4.findall(line):
|
||||||
|
if ip == TARGET:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if ipaddress.ip_address(ip).is_global:
|
||||||
|
return ip
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch(url: str) -> dict:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "astro-menu/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if "--no-cache" not in sys.argv:
|
||||||
|
hit = _cached()
|
||||||
|
if hit:
|
||||||
|
print(json.dumps(hit))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
hop = _public_hop_ip()
|
||||||
|
# Try the traced public hop first, then fall back to our own public IP.
|
||||||
|
candidates = ([hop] if hop else []) + [""]
|
||||||
|
for ip in candidates:
|
||||||
|
for name, url_of, parse in PROVIDERS:
|
||||||
|
try:
|
||||||
|
data = parse(_fetch(url_of(ip)))
|
||||||
|
if data.get("lat") is None or data.get("lon") is None:
|
||||||
|
raise ValueError("no coordinates")
|
||||||
|
data["source"] = f"{name} via {ip}" if ip else name
|
||||||
|
_store(data)
|
||||||
|
print(json.dumps(data))
|
||||||
|
return 0
|
||||||
|
except Exception as exc: # noqa: BLE001 — try the next provider/candidate
|
||||||
|
print(f"{name}({ip or 'self'}): {exc}", file=sys.stderr)
|
||||||
|
print("all geolocation attempts failed", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Networking backend for the astro-menu Network quad.
|
||||||
|
# Each subcommand prints JSON (or plain text for pubip) to stdout. Wi-Fi listing and
|
||||||
|
# state come from AstalNetwork in the UI; this covers the rest (ip/routes/ports,
|
||||||
|
# public IP, wifi connect via nmcli, and the manual/DHCP switch).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
active_conn() {
|
||||||
|
# First active connection on a real device (wifi/ethernet).
|
||||||
|
nmcli -t -g NAME,TYPE,DEVICE connection show --active 2>/dev/null \
|
||||||
|
| awk -F: '$2 ~ /wireless|ethernet/ {print $1; exit}'
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd="${1:-}"; shift || true
|
||||||
|
case "$cmd" in
|
||||||
|
ip)
|
||||||
|
ip -j addr show 2>/dev/null || echo '[]'
|
||||||
|
;;
|
||||||
|
routes)
|
||||||
|
ip -j route show 2>/dev/null || echo '[]'
|
||||||
|
;;
|
||||||
|
ports)
|
||||||
|
# Listening TCP/UDP sockets -> JSON array.
|
||||||
|
ss -tulnH 2>/dev/null | awk '
|
||||||
|
BEGIN { print "["; sep="" }
|
||||||
|
{ printf "%s{\"proto\":\"%s\",\"local\":\"%s\"}", sep, $1, $5; sep="," }
|
||||||
|
END { print "]" }'
|
||||||
|
;;
|
||||||
|
pubip)
|
||||||
|
curl -sf --max-time 8 https://ifconfig.co 2>/dev/null \
|
||||||
|
|| curl -sf --max-time 8 https://api.ipify.org 2>/dev/null \
|
||||||
|
|| echo "unavailable"
|
||||||
|
;;
|
||||||
|
wifi-connect)
|
||||||
|
ssid="${1:-}"; pass="${2:-}"
|
||||||
|
if [[ -n "$pass" ]]; then
|
||||||
|
nmcli dev wifi connect "$ssid" password "$pass"
|
||||||
|
else
|
||||||
|
nmcli dev wifi connect "$ssid"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
wifi-disconnect)
|
||||||
|
con="$(active_conn)"
|
||||||
|
[[ -n "$con" ]] && nmcli connection down "$con"
|
||||||
|
;;
|
||||||
|
set-manual)
|
||||||
|
con="${4:-$(active_conn)}"; addr="${1:-}"; gw="${2:-}"; dns="${3:-}"
|
||||||
|
[[ -z "$con" ]] && { echo "no active connection" >&2; exit 1; }
|
||||||
|
nmcli connection modify "$con" ipv4.method manual \
|
||||||
|
ipv4.addresses "$addr" ipv4.gateway "$gw" ipv4.dns "$dns"
|
||||||
|
nmcli connection up "$con"
|
||||||
|
;;
|
||||||
|
set-dhcp)
|
||||||
|
con="${1:-$(active_conn)}"
|
||||||
|
[[ -z "$con" ]] && { echo "no active connection" >&2; exit 1; }
|
||||||
|
nmcli connection modify "$con" ipv4.method auto
|
||||||
|
nmcli connection up "$con"
|
||||||
|
;;
|
||||||
|
active-conn)
|
||||||
|
active_conn
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "usage: network.sh {ip|routes|ports|pubip|wifi-connect|wifi-disconnect|set-manual|set-dhcp|active-conn}" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""NetworkManager backend for the Network quad's expanded settings.
|
||||||
|
|
||||||
|
Every subcommand prints JSON to stdout ({"ok": bool, ...} for mutations) and
|
||||||
|
diagnostics to stderr. Reads use `nmcli -g` (raw, unescaped single fields) and
|
||||||
|
`ip -j`; writes go through `nmcli connection modify/up`, so persistence and polkit
|
||||||
|
auth are handled by NetworkManager. Kept as one script so the UI has a single,
|
||||||
|
stable contract (see modules/network.py).
|
||||||
|
|
||||||
|
adapters list manageable devices + their connection
|
||||||
|
ipconfig <con> ipv4/ipv6 method/addresses/gateway/dns
|
||||||
|
set-ip <con> ipv4|ipv6 <method> [addr_cidr] [gateway] [dns_space_sep]
|
||||||
|
routes kernel routing table (read-only)
|
||||||
|
vlans configured VLAN connections
|
||||||
|
vlan-add <parent_dev> <id> [name] create + bring up a VLAN
|
||||||
|
vlan-del <name> delete a VLAN connection
|
||||||
|
vlan-up <name> activate a VLAN connection
|
||||||
|
dns effective DNS servers per device
|
||||||
|
set-dns <con> ipv4|ipv6 <servers_space_sep> <yes|no ignore-auto>
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _run(argv: list[str], timeout: int = 20) -> tuple[bool, str, str]:
|
||||||
|
try:
|
||||||
|
p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
|
||||||
|
except (FileNotFoundError, subprocess.SubprocessError) as exc:
|
||||||
|
return False, "", str(exc)
|
||||||
|
return p.returncode == 0, p.stdout, p.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def _get(field: str, con: str) -> str:
|
||||||
|
ok, out, _ = _run(["nmcli", "-g", field, "connection", "show", con])
|
||||||
|
return out.strip() if ok else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _list(field: str, con: str) -> list[str]:
|
||||||
|
raw = _get(field, con)
|
||||||
|
return [v.strip() for v in raw.replace(",", " ").split() if v.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _emit(obj) -> int:
|
||||||
|
print(json.dumps(obj))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(ok: bool, err: str = "") -> int:
|
||||||
|
print(json.dumps({"ok": bool(ok), "error": err.strip()}))
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
# -- reads -----------------------------------------------------------------
|
||||||
|
def adapters() -> int:
|
||||||
|
ok, out, _ = _run(["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
|
||||||
|
rows = []
|
||||||
|
if ok:
|
||||||
|
for line in out.splitlines():
|
||||||
|
# DEVICE may contain escaped colons (bt MACs); split from the right on
|
||||||
|
# the three trailing fields we know are colon-free-ish.
|
||||||
|
parts = line.split(":")
|
||||||
|
if len(parts) < 4:
|
||||||
|
continue
|
||||||
|
connection = parts[-1]
|
||||||
|
state = parts[-2]
|
||||||
|
typ = parts[-3]
|
||||||
|
device = ":".join(parts[:-3]).replace("\\:", ":")
|
||||||
|
if typ not in ("ethernet", "wifi"):
|
||||||
|
continue
|
||||||
|
rows.append({"device": device, "type": typ, "state": state,
|
||||||
|
"connection": connection.replace("\\:", ":")})
|
||||||
|
return _emit(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def ipconfig(con: str) -> int:
|
||||||
|
def family(fam: str) -> dict:
|
||||||
|
return {
|
||||||
|
"method": _get(f"{fam}.method", con) or "auto",
|
||||||
|
"addresses": _list(f"{fam}.addresses", con),
|
||||||
|
"gateway": _get(f"{fam}.gateway", con),
|
||||||
|
"dns": _list(f"{fam}.dns", con),
|
||||||
|
"ignore_auto_dns": _get(f"{fam}.ignore-auto-dns", con) == "yes",
|
||||||
|
}
|
||||||
|
return _emit({"connection": con, "ipv4": family("ipv4"), "ipv6": family("ipv6")})
|
||||||
|
|
||||||
|
|
||||||
|
def routes() -> int:
|
||||||
|
ok, out, err = _run(["ip", "-j", "route", "show"])
|
||||||
|
if not ok:
|
||||||
|
return _emit([])
|
||||||
|
try:
|
||||||
|
return _emit(json.loads(out))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return _emit([])
|
||||||
|
|
||||||
|
|
||||||
|
def vlans() -> int:
|
||||||
|
ok, out, _ = _run(["nmcli", "-t", "-f", "NAME,TYPE,DEVICE,ACTIVE", "connection", "show"])
|
||||||
|
rows = []
|
||||||
|
if ok:
|
||||||
|
for line in out.splitlines():
|
||||||
|
parts = line.rsplit(":", 3)
|
||||||
|
if len(parts) < 4 or parts[1] != "vlan":
|
||||||
|
continue
|
||||||
|
name, _typ, device, active = parts
|
||||||
|
rows.append({"name": name, "device": device, "active": active == "yes"})
|
||||||
|
return _emit(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def dns() -> int:
|
||||||
|
"""Effective DNS servers, per device, from `nmcli device show`."""
|
||||||
|
ok, out, _ = _run(["nmcli", "-t", "-f",
|
||||||
|
"GENERAL.DEVICE,IP4.DNS,IP6.DNS", "device", "show"])
|
||||||
|
devices: dict[str, dict] = {}
|
||||||
|
cur = None
|
||||||
|
if ok:
|
||||||
|
for line in out.splitlines():
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
key, _, val = line.partition(":")
|
||||||
|
val = val.strip()
|
||||||
|
if key == "GENERAL.DEVICE":
|
||||||
|
cur = val
|
||||||
|
devices.setdefault(cur, {"device": cur, "servers": []})
|
||||||
|
elif cur and key.startswith(("IP4.DNS", "IP6.DNS")) and val:
|
||||||
|
devices[cur]["servers"].append(val)
|
||||||
|
return _emit([d for d in devices.values() if d["servers"]])
|
||||||
|
|
||||||
|
|
||||||
|
# -- writes ----------------------------------------------------------------
|
||||||
|
def set_ip(con: str, fam: str, method: str, addr: str = "",
|
||||||
|
gateway: str = "", dns: str = "") -> int:
|
||||||
|
if fam not in ("ipv4", "ipv6"):
|
||||||
|
return _ok(False, "family must be ipv4 or ipv6")
|
||||||
|
args = ["nmcli", "connection", "modify", con, f"{fam}.method", method]
|
||||||
|
if method == "manual":
|
||||||
|
args += [f"{fam}.addresses", addr or ""]
|
||||||
|
args += [f"{fam}.gateway", gateway or ""]
|
||||||
|
if dns:
|
||||||
|
args += [f"{fam}.dns", dns]
|
||||||
|
else: # auto: clear any manual leftovers so DHCP is clean
|
||||||
|
args += [f"{fam}.addresses", "", f"{fam}.gateway", ""]
|
||||||
|
ok, _, err = _run(args)
|
||||||
|
if ok:
|
||||||
|
ok, _, err = _run(["nmcli", "connection", "up", con])
|
||||||
|
return _ok(ok, err)
|
||||||
|
|
||||||
|
|
||||||
|
def set_dns(con: str, fam: str, servers: str, ignore_auto: str) -> int:
|
||||||
|
if fam not in ("ipv4", "ipv6"):
|
||||||
|
return _ok(False, "family must be ipv4 or ipv6")
|
||||||
|
ok, _, err = _run(["nmcli", "connection", "modify", con,
|
||||||
|
f"{fam}.dns", servers,
|
||||||
|
f"{fam}.ignore-auto-dns", "yes" if ignore_auto == "yes" else "no"])
|
||||||
|
if ok:
|
||||||
|
ok, _, err = _run(["nmcli", "connection", "up", con])
|
||||||
|
return _ok(ok, err)
|
||||||
|
|
||||||
|
|
||||||
|
def vlan_add(parent: str, vid: str, name: str = "") -> int:
|
||||||
|
name = name or f"vlan{vid}"
|
||||||
|
ok, _, err = _run(["nmcli", "connection", "add", "type", "vlan",
|
||||||
|
"con-name", name, "dev", parent, "id", str(vid)])
|
||||||
|
if ok:
|
||||||
|
ok, _, err = _run(["nmcli", "connection", "up", name])
|
||||||
|
return _ok(ok, err)
|
||||||
|
|
||||||
|
|
||||||
|
def vlan_del(name: str) -> int:
|
||||||
|
ok, _, err = _run(["nmcli", "connection", "delete", name])
|
||||||
|
return _ok(ok, err)
|
||||||
|
|
||||||
|
|
||||||
|
def vlan_up(name: str) -> int:
|
||||||
|
ok, _, err = _run(["nmcli", "connection", "up", name])
|
||||||
|
return _ok(ok, err)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if not argv:
|
||||||
|
print("no subcommand", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
cmd, args = argv[0], argv[1:]
|
||||||
|
table = {
|
||||||
|
"adapters": lambda: adapters(),
|
||||||
|
"ipconfig": lambda: ipconfig(args[0]),
|
||||||
|
"set-ip": lambda: set_ip(*args),
|
||||||
|
"routes": lambda: routes(),
|
||||||
|
"vlans": lambda: vlans(),
|
||||||
|
"vlan-add": lambda: vlan_add(*args),
|
||||||
|
"vlan-del": lambda: vlan_del(args[0]),
|
||||||
|
"vlan-up": lambda: vlan_up(args[0]),
|
||||||
|
"dns": lambda: dns(),
|
||||||
|
"set-dns": lambda: set_dns(*args),
|
||||||
|
}
|
||||||
|
fn = table.get(cmd)
|
||||||
|
if fn is None:
|
||||||
|
print(f"unknown subcommand: {cmd}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
return fn()
|
||||||
|
except TypeError as exc:
|
||||||
|
print(f"bad arguments for {cmd}: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render a static slippy-map image centred on a coordinate, with a marker.
|
||||||
|
|
||||||
|
Used by the Location quad because libshumate does not render tiles in this
|
||||||
|
environment (the official shumate-demo shows the same blank map — a library/GTK
|
||||||
|
render bug), while plain tile downloads work fine. Stitches raster tiles into one
|
||||||
|
PNG with Pillow and caches them. The quad re-renders this at a new centre to pan.
|
||||||
|
|
||||||
|
staticmap.py <center_lat> <center_lon> <zoom> <w> <h> <out> \
|
||||||
|
[style] [marker_lat] [marker_lon]
|
||||||
|
|
||||||
|
style: dark (default, CartoDB dark_matter) | satellite (Esri) | standard (OSM)
|
||||||
|
marker_*: where to draw the location pin; defaults to the centre. When the centre
|
||||||
|
is panned away, the pin clamps to the nearest edge so it stays visible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
TILE = 256
|
||||||
|
CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astro-menu" / "tiles"
|
||||||
|
UA = "astro-menu/1.0 (personal dotfiles)"
|
||||||
|
BG = (26, 26, 26)
|
||||||
|
ACCENT = (228, 0, 70)
|
||||||
|
|
||||||
|
STYLES = {
|
||||||
|
"standard": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
"dark": "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
|
||||||
|
"satellite": "https://server.arcgisonline.com/ArcGIS/rest/services/"
|
||||||
|
"World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _center_px(lat: float, lon: float, zoom: int) -> tuple[float, float]:
|
||||||
|
n = 2 ** zoom
|
||||||
|
lat_r = math.radians(lat)
|
||||||
|
x = (lon + 180.0) / 360.0 * n
|
||||||
|
y = (1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n
|
||||||
|
return x * TILE, y * TILE
|
||||||
|
|
||||||
|
|
||||||
|
def _tile(z: int, x: int, y: int, style: str) -> Image.Image | None:
|
||||||
|
CACHE.mkdir(parents=True, exist_ok=True)
|
||||||
|
fp = CACHE / f"{style}_{z}_{x}_{y}.png"
|
||||||
|
if fp.exists():
|
||||||
|
try:
|
||||||
|
return Image.open(fp).convert("RGB")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
url = STYLES[style].format(z=z, x=x, y=y)
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
||||||
|
data = urllib.request.urlopen(req, timeout=8).read()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"tile {style} {z}/{x}/{y}: {exc}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
img = Image.open(io.BytesIO(data)).convert("RGB")
|
||||||
|
img.save(fp) # normalise to PNG in the cache (satellite is served as JPEG)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def render(lat: float, lon: float, zoom: int, w: int, h: int, out: str,
|
||||||
|
style: str = "dark", marker: tuple[float, float] | None = None) -> None:
|
||||||
|
if style not in STYLES:
|
||||||
|
style = "dark"
|
||||||
|
n = 2 ** zoom
|
||||||
|
cx, cy = _center_px(lat, lon, zoom)
|
||||||
|
left, top = cx - w / 2, cy - h / 2
|
||||||
|
img = Image.new("RGB", (w, h), BG)
|
||||||
|
x0, x1 = int(left // TILE), int((left + w) // TILE)
|
||||||
|
y0, y1 = int(top // TILE), int((top + h) // TILE)
|
||||||
|
for tx in range(x0, x1 + 1):
|
||||||
|
for ty in range(y0, y1 + 1):
|
||||||
|
if ty < 0 or ty >= n:
|
||||||
|
continue
|
||||||
|
tile = _tile(zoom, tx % n, ty, style)
|
||||||
|
if tile is None:
|
||||||
|
continue
|
||||||
|
img.paste(tile, (int(tx * TILE - left), int(ty * TILE - top)))
|
||||||
|
|
||||||
|
# marker at the real location (defaults to centre); clamp to the edge so it stays
|
||||||
|
# on-screen when the view is panned away from it.
|
||||||
|
mlat, mlon = marker if marker else (lat, lon)
|
||||||
|
mwx, mwy = _center_px(mlat, mlon, zoom)
|
||||||
|
r = 8
|
||||||
|
mx = max(r, min(w - r, mwx - left))
|
||||||
|
my = max(r, min(h - r, mwy - top))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
d.ellipse([mx - r, my - r, mx + r, my + r], fill=ACCENT, outline=(255, 255, 255), width=2)
|
||||||
|
|
||||||
|
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
img.save(out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
a = sys.argv
|
||||||
|
style = a[7] if len(a) > 7 else "dark"
|
||||||
|
marker = (float(a[8]), float(a[9])) if len(a) > 9 else None
|
||||||
|
render(float(a[1]), float(a[2]), int(a[3]), int(a[4]), int(a[5]), a[6], style, marker)
|
||||||
|
print(a[6])
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Weather backend: wttr.in with its native ANSI art (curl UA gets terminal colours).
|
||||||
|
# Args: $1 = location (may be empty -> wttr.in geolocates the caller), $2 = query opts.
|
||||||
|
# Prints the wttr.in body (ANSI or plain, per opts) to stdout.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
loc="${1:-}"
|
||||||
|
# Note: ${2-0}, not ${2:-0} — the expanded view passes an *empty* opts to request
|
||||||
|
# wttr.in's default 3-day forecast, and :- would wrongly rewrite that empty string
|
||||||
|
# to "0" (current conditions only). Default to "0" only when no opts arg is given.
|
||||||
|
opts="${2-0}"
|
||||||
|
|
||||||
|
# URL-encode spaces in a city name.
|
||||||
|
loc="${loc// /+}"
|
||||||
|
|
||||||
|
url="https://wttr.in/${loc}?${opts}"
|
||||||
|
# -A curl makes wttr.in return terminal (ANSI) output regardless of the real UA.
|
||||||
|
curl -sf -A curl --max-time 10 "$url"
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Tiny user-editable config file: ~/.local/state/astro-menu/config.json.
|
||||||
|
|
||||||
|
Same pattern as orbit-menu/config.py and horizon-dock/config.py. Deliberately
|
||||||
|
separate from settings.py's Settings class: that one persists quad enable/
|
||||||
|
feature toggles and favorites (module-scoped, read via ctx.feature()), while
|
||||||
|
this is a single whole-window flag read once at startup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from paths import CONFIG_FILE, ensure_dirs
|
||||||
|
|
||||||
|
_DEFAULTS = {"hologram": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _load() -> dict:
|
||||||
|
try:
|
||||||
|
data = json.loads(CONFIG_FILE.read_text())
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
data = {}
|
||||||
|
merged = {**_DEFAULTS, **data}
|
||||||
|
if not CONFIG_FILE.exists():
|
||||||
|
ensure_dirs()
|
||||||
|
CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n")
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def hologram_enabled() -> bool:
|
||||||
|
return bool(_load().get("hologram", True))
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
"""Render ANSI/SGR-coloured terminal text (e.g. wttr.in) into a Gtk.TextView.
|
||||||
|
|
||||||
|
wttr.in returns real terminal escape sequences when curled. We parse the SGR
|
||||||
|
subset it uses (basic 8/16 colours, xterm-256, truecolor, bold, reset) and emit
|
||||||
|
Gtk.TextTags so the CLI art keeps its colours and block-glyph alignment. This is
|
||||||
|
deliberately reusable by any future "show me some CLI art" widget.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gdk, Gtk # noqa: E402
|
||||||
|
|
||||||
|
_SGR = re.compile(r"\x1b\[([0-9;]*)m")
|
||||||
|
|
||||||
|
# xterm 16-colour base palette (approx, tuned to the CyberQueer dark background).
|
||||||
|
_BASE16 = [
|
||||||
|
(0x00, 0x00, 0x00), (0xCC, 0x24, 0x24), (0x33, 0xCC, 0x33), (0xCC, 0xCC, 0x33),
|
||||||
|
(0x33, 0x66, 0xCC), (0xCC, 0x33, 0xCC), (0x33, 0xCC, 0xCC), (0xD6, 0xAB, 0xAB),
|
||||||
|
(0x66, 0x66, 0x66), (0xF5, 0x05, 0x05), (0x55, 0xFF, 0x55), (0xFF, 0xFF, 0x55),
|
||||||
|
(0x55, 0x88, 0xFF), (0xE4, 0x00, 0x46), (0x55, 0xFF, 0xFF), (0xFF, 0xFF, 0xFF),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _xterm256(n: int) -> tuple[int, int, int]:
|
||||||
|
if n < 16:
|
||||||
|
return _BASE16[n]
|
||||||
|
if n < 232: # 6x6x6 colour cube
|
||||||
|
n -= 16
|
||||||
|
r, g, b = n // 36, (n // 6) % 6, n % 6
|
||||||
|
conv = lambda c: 55 + c * 40 if c else 0
|
||||||
|
return conv(r), conv(g), conv(b)
|
||||||
|
v = 8 + (n - 232) * 10 # grayscale ramp
|
||||||
|
return v, v, v
|
||||||
|
|
||||||
|
|
||||||
|
def _rgba(rgb: tuple[int, int, int]) -> Gdk.RGBA:
|
||||||
|
c = Gdk.RGBA()
|
||||||
|
c.red, c.green, c.blue, c.alpha = rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1.0
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
class AnsiRenderer:
|
||||||
|
"""Owns a TextView and repaints it from ANSI text. Tags are cached by state."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.view = Gtk.TextView(
|
||||||
|
editable=False, cursor_visible=False, monospace=True,
|
||||||
|
wrap_mode=Gtk.WrapMode.NONE,
|
||||||
|
)
|
||||||
|
self.view.add_css_class("ansi-view")
|
||||||
|
self.buffer = self.view.get_buffer()
|
||||||
|
self._tag_cache: dict[tuple, Gtk.TextTag] = {}
|
||||||
|
|
||||||
|
def _tag(self, fg, bg, bold) -> Gtk.TextTag | None:
|
||||||
|
if fg is None and bg is None and not bold:
|
||||||
|
return None
|
||||||
|
key = (fg, bg, bold)
|
||||||
|
tag = self._tag_cache.get(key)
|
||||||
|
if tag is None:
|
||||||
|
tag = self.buffer.create_tag()
|
||||||
|
if fg is not None:
|
||||||
|
tag.set_property("foreground-rgba", _rgba(fg))
|
||||||
|
if bg is not None:
|
||||||
|
tag.set_property("background-rgba", _rgba(bg))
|
||||||
|
if bold:
|
||||||
|
tag.set_property("weight", 700)
|
||||||
|
self._tag_cache[key] = tag
|
||||||
|
return tag
|
||||||
|
|
||||||
|
def set_text(self, text: str) -> None:
|
||||||
|
self.buffer.set_text("", 0)
|
||||||
|
fg = bg = None
|
||||||
|
bold = False
|
||||||
|
pos = 0
|
||||||
|
for m in _SGR.finditer(text):
|
||||||
|
chunk = text[pos:m.start()]
|
||||||
|
if chunk:
|
||||||
|
self._insert(chunk, fg, bg, bold)
|
||||||
|
fg, bg, bold = self._apply(m.group(1), fg, bg, bold)
|
||||||
|
pos = m.end()
|
||||||
|
tail = text[pos:]
|
||||||
|
if tail:
|
||||||
|
self._insert(tail, fg, bg, bold)
|
||||||
|
|
||||||
|
def _insert(self, chunk, fg, bg, bold) -> None:
|
||||||
|
end = self.buffer.get_end_iter()
|
||||||
|
tag = self._tag(fg, bg, bold)
|
||||||
|
if tag is None:
|
||||||
|
self.buffer.insert(end, chunk)
|
||||||
|
else:
|
||||||
|
self.buffer.insert_with_tags(end, chunk, tag)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply(params: str, fg, bg, bold):
|
||||||
|
codes = [int(x) if x else 0 for x in params.split(";")] if params else [0]
|
||||||
|
i = 0
|
||||||
|
while i < len(codes):
|
||||||
|
c = codes[i]
|
||||||
|
if c == 0:
|
||||||
|
fg = bg = None
|
||||||
|
bold = False
|
||||||
|
elif c == 1:
|
||||||
|
bold = True
|
||||||
|
elif c == 22:
|
||||||
|
bold = False
|
||||||
|
elif c == 39:
|
||||||
|
fg = None
|
||||||
|
elif c == 49:
|
||||||
|
bg = None
|
||||||
|
elif 30 <= c <= 37:
|
||||||
|
fg = _BASE16[c - 30]
|
||||||
|
elif 90 <= c <= 97:
|
||||||
|
fg = _BASE16[c - 90 + 8]
|
||||||
|
elif 40 <= c <= 47:
|
||||||
|
bg = _BASE16[c - 40]
|
||||||
|
elif 100 <= c <= 107:
|
||||||
|
bg = _BASE16[c - 100 + 8]
|
||||||
|
elif c in (38, 48):
|
||||||
|
target = "fg" if c == 38 else "bg"
|
||||||
|
if i + 1 < len(codes) and codes[i + 1] == 5:
|
||||||
|
val = _xterm256(codes[i + 2]) if i + 2 < len(codes) else None
|
||||||
|
i += 2
|
||||||
|
elif i + 1 < len(codes) and codes[i + 1] == 2:
|
||||||
|
val = tuple(codes[i + 2:i + 5]) if i + 4 < len(codes) else None
|
||||||
|
i += 4
|
||||||
|
else:
|
||||||
|
val = None
|
||||||
|
if target == "fg":
|
||||||
|
fg = val
|
||||||
|
else:
|
||||||
|
bg = val
|
||||||
|
i += 1
|
||||||
|
return fg, bg, bold
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
"""Draw a rounded border around any widget with a Cairo DrawingArea overlay.
|
||||||
|
|
||||||
|
This GTK build's renderer does not paint CSS border/background nodes on plain
|
||||||
|
container widgets (Box/Frame) — only on buttons/entries — but it renders Cairo
|
||||||
|
draw funcs and textures fine (the map image proves it). So module 'borders' are
|
||||||
|
drawn explicitly here instead of via CSS.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gtk # noqa: E402
|
||||||
|
|
||||||
|
# CyberQueer accent/violet (matches @accent/@violet / COLOR_HIGHLIGHT/COLOR_DARK
|
||||||
|
# in colors.conf).
|
||||||
|
ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
|
||||||
|
VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
|
||||||
|
# Module fills are translucent violet rather than solid gray/black — lets the
|
||||||
|
# hologram overlay's scanlines/noise (drawn above, on window.py's overlay) and
|
||||||
|
# the desktop behind the panel both read through, so the whole thing looks
|
||||||
|
# like projected light/glass instead of an opaque card.
|
||||||
|
FILL_COLOR = VIOLET
|
||||||
|
FILL_ALPHA = 0.34
|
||||||
|
|
||||||
|
|
||||||
|
def _rounded_rect(cr, x, y, w, h, r) -> None:
|
||||||
|
r = min(r, w / 2, h / 2)
|
||||||
|
cr.new_sub_path()
|
||||||
|
cr.arc(x + w - r, y + r, r, -math.pi / 2, 0)
|
||||||
|
cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2)
|
||||||
|
cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi)
|
||||||
|
cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2)
|
||||||
|
cr.close_path()
|
||||||
|
|
||||||
|
|
||||||
|
# Soft outer glow around the border ring: Cairo has no native blur, so this is
|
||||||
|
# the usual poor-man's bloom — the same crisp stroke redrawn a few times at
|
||||||
|
# increasing width and decreasing alpha, underneath the final crisp line.
|
||||||
|
_GLOW_LAYERS = [(10, 0.05), (6, 0.09), (3, 0.15)]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_draw(border: int, radius: int, color, fill_bg: bool, glow: bool):
|
||||||
|
def draw(_area, cr, w, h, *_a) -> None:
|
||||||
|
inset = border / 2
|
||||||
|
r, g, b = color
|
||||||
|
if glow:
|
||||||
|
for extra_w, alpha in _GLOW_LAYERS:
|
||||||
|
_rounded_rect(cr, inset, inset, w - border, h - border, radius)
|
||||||
|
cr.set_source_rgba(r, g, b, alpha)
|
||||||
|
cr.set_line_width(border + extra_w)
|
||||||
|
cr.stroke()
|
||||||
|
_rounded_rect(cr, inset, inset, w - border, h - border, radius)
|
||||||
|
if fill_bg:
|
||||||
|
cr.set_source_rgba(*FILL_COLOR, FILL_ALPHA)
|
||||||
|
cr.fill_preserve()
|
||||||
|
cr.set_source_rgb(*color)
|
||||||
|
cr.set_line_width(border)
|
||||||
|
cr.stroke()
|
||||||
|
return draw
|
||||||
|
|
||||||
|
|
||||||
|
def bordered(child: Gtk.Widget, border: int = 3, radius: int = 16,
|
||||||
|
color=ACCENT, fill_bg: bool = False, glow: bool = True) -> Gtk.Overlay:
|
||||||
|
"""Wrap child in an overlay whose background is a drawn rounded border —
|
||||||
|
with a soft holographic glow around it by default, matching orbit-menu/
|
||||||
|
horizon-dock's aesthetic.
|
||||||
|
|
||||||
|
The DrawingArea is the overlay's main child (drawn first, behind); the content
|
||||||
|
is an overlay child on top and drives the size. A small margin keeps the content
|
||||||
|
clear of the drawn border ring.
|
||||||
|
"""
|
||||||
|
overlay = Gtk.Overlay()
|
||||||
|
area = Gtk.DrawingArea()
|
||||||
|
area.set_draw_func(_make_draw(border, radius, color, fill_bg, glow))
|
||||||
|
overlay.set_child(area) # background: the border ring
|
||||||
|
child.set_margin_start(border + 4)
|
||||||
|
child.set_margin_end(border + 4)
|
||||||
|
child.set_margin_top(border + 4)
|
||||||
|
child.set_margin_bottom(border + 4)
|
||||||
|
overlay.add_overlay(child) # content on top
|
||||||
|
overlay.set_measure_overlay(child, True) # size the overlay to the content
|
||||||
|
return overlay
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""Holographic scanline/sweep/noise overlay — the same visual treatment as
|
||||||
|
orbit-menu's hologram effect, adapted for astro-menu's rectangular panel
|
||||||
|
instead of orbit-menu's circular canvas: no radial vignette mask here, since
|
||||||
|
the panel's own Cairo-drawn module borders (lib/border.py) already bound it —
|
||||||
|
covering the full rectangle reads as one continuous "HUD screen" rather than
|
||||||
|
tinting past its own edges.
|
||||||
|
|
||||||
|
Owns its own animation clock (advanced by window.py's tick callback via
|
||||||
|
.tick(dt)) and a persistent particle list for the noise specs, exactly like
|
||||||
|
orbit-menu's _update_hologram_particles: each spec keeps its position/color
|
||||||
|
for a real randomized lifetime (fade in, hold, fade out) instead of every
|
||||||
|
spec teleporting to a new position every frame.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
|
||||||
|
import cairo
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gtk # noqa: E402
|
||||||
|
|
||||||
|
# Same CyberQueer violet/magenta/red combo as orbit-menu's hologram overlay.
|
||||||
|
_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
|
||||||
|
_MAGENTA = (0.92, 0.0, 0.65)
|
||||||
|
_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
|
||||||
|
|
||||||
|
|
||||||
|
class HologramOverlay:
|
||||||
|
SCANLINE_GAP = 4.0
|
||||||
|
SCANLINE_ALPHA = 0.16 # fixed grid — kept clearly visible, not just a faint texture
|
||||||
|
SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass
|
||||||
|
SWEEP_HALF_HEIGHT = 90.0
|
||||||
|
NOISE_COUNT = 95 # specs alive at once (each with its own lifetime)
|
||||||
|
NOISE_COLORS = [_MAGENTA, _ACCENT]
|
||||||
|
NOISE_LIFETIME = (0.5, 1.4)
|
||||||
|
NOISE_FADE_IN = 0.2
|
||||||
|
NOISE_FADE_OUT = 0.35
|
||||||
|
NOISE_ALPHA_RANGE = (0.10, 0.34)
|
||||||
|
|
||||||
|
def __init__(self, enabled: bool = True) -> None:
|
||||||
|
self.enabled = enabled
|
||||||
|
self._sat_time = 0.0
|
||||||
|
self._particles: list[dict] = []
|
||||||
|
|
||||||
|
self.widget = Gtk.DrawingArea()
|
||||||
|
self.widget.set_can_target(False) # never steals clicks from content underneath
|
||||||
|
self.widget.add_css_class("astro-hologram")
|
||||||
|
self.widget.set_hexpand(True)
|
||||||
|
self.widget.set_vexpand(True)
|
||||||
|
self.widget.set_halign(Gtk.Align.FILL)
|
||||||
|
self.widget.set_valign(Gtk.Align.FILL)
|
||||||
|
self.widget.set_draw_func(self._draw_frame)
|
||||||
|
|
||||||
|
def tick(self, dt: float) -> None:
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
self._sat_time += dt
|
||||||
|
self.widget.queue_draw()
|
||||||
|
|
||||||
|
# -- drawing --------------------------------------------------------------
|
||||||
|
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
|
||||||
|
if not self.enabled or width <= 0 or height <= 0:
|
||||||
|
return
|
||||||
|
r, g, b = _VIOLET
|
||||||
|
|
||||||
|
cr.save()
|
||||||
|
cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA)
|
||||||
|
cr.set_line_width(1.0)
|
||||||
|
y = 0.0
|
||||||
|
while y < height:
|
||||||
|
cr.move_to(0, y)
|
||||||
|
cr.line_to(width, y)
|
||||||
|
y += self.SCANLINE_GAP
|
||||||
|
cr.stroke()
|
||||||
|
cr.restore()
|
||||||
|
|
||||||
|
phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD
|
||||||
|
sweep_y = phase * height
|
||||||
|
hh = self.SWEEP_HALF_HEIGHT
|
||||||
|
grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh)
|
||||||
|
grad.add_color_stop_rgba(0.0, r, g, b, 0.0)
|
||||||
|
grad.add_color_stop_rgba(0.5, r, g, b, 0.07)
|
||||||
|
grad.add_color_stop_rgba(1.0, r, g, b, 0.0)
|
||||||
|
cr.set_source(grad)
|
||||||
|
cr.rectangle(0, sweep_y - hh, width, hh * 2)
|
||||||
|
cr.fill()
|
||||||
|
|
||||||
|
flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0)
|
||||||
|
cr.set_source_rgba(r, g, b, max(0.0, flicker))
|
||||||
|
cr.paint()
|
||||||
|
|
||||||
|
self._draw_noise(cr, width, height)
|
||||||
|
|
||||||
|
def _draw_noise(self, cr, width: float, height: float) -> None:
|
||||||
|
now = self._sat_time
|
||||||
|
self._particles = [p for p in self._particles if now - p["birth"] < p["life"]]
|
||||||
|
while len(self._particles) < self.NOISE_COUNT:
|
||||||
|
self._particles.append({
|
||||||
|
"x": random.uniform(0, width),
|
||||||
|
"y": random.uniform(0, height),
|
||||||
|
"w": random.uniform(1.0, 2.6),
|
||||||
|
"h": random.uniform(1.0, 2.0),
|
||||||
|
"color": random.choice(self.NOISE_COLORS),
|
||||||
|
"peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE),
|
||||||
|
"birth": now,
|
||||||
|
"life": random.uniform(*self.NOISE_LIFETIME),
|
||||||
|
})
|
||||||
|
|
||||||
|
for p in self._particles:
|
||||||
|
t = (now - p["birth"]) / p["life"]
|
||||||
|
if t < self.NOISE_FADE_IN:
|
||||||
|
envelope = t / self.NOISE_FADE_IN
|
||||||
|
elif t > 1.0 - self.NOISE_FADE_OUT:
|
||||||
|
envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT)
|
||||||
|
else:
|
||||||
|
envelope = 1.0
|
||||||
|
r, g, b = p["color"]
|
||||||
|
cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope)
|
||||||
|
cr.rectangle(p["x"], p["y"], p["w"], p["h"])
|
||||||
|
cr.fill()
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""Subprocess helpers built on Gio so nothing blocks the GTK main loop.
|
||||||
|
|
||||||
|
The whole backend contract (see backend/*.sh, backend/geolocate.py) is: a command
|
||||||
|
prints JSON (or plain text) to stdout, diagnostics to stderr, non-zero exit on
|
||||||
|
failure. These helpers run such commands asynchronously and hand the parsed result
|
||||||
|
back on the main thread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shlex
|
||||||
|
from typing import Callable, Sequence
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gio", "2.0")
|
||||||
|
from gi.repository import Gio, GLib # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _as_argv(cmd: Sequence[str] | str) -> list[str]:
|
||||||
|
return shlex.split(cmd) if isinstance(cmd, str) else list(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def run_text(cmd: Sequence[str] | str, cb: Callable[[bool, str, str], None]) -> None:
|
||||||
|
"""Run cmd, call cb(ok, stdout, stderr) on the main thread when done."""
|
||||||
|
try:
|
||||||
|
proc = Gio.Subprocess.new(
|
||||||
|
_as_argv(cmd),
|
||||||
|
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE,
|
||||||
|
)
|
||||||
|
except GLib.Error as err:
|
||||||
|
GLib.idle_add(cb, False, "", str(err))
|
||||||
|
return
|
||||||
|
|
||||||
|
def _done(p: Gio.Subprocess, res: Gio.AsyncResult) -> None:
|
||||||
|
try:
|
||||||
|
_, out, errout = p.communicate_utf8_finish(res)
|
||||||
|
except GLib.Error as err:
|
||||||
|
cb(False, "", str(err))
|
||||||
|
return
|
||||||
|
cb(p.get_successful(), out or "", errout or "")
|
||||||
|
|
||||||
|
proc.communicate_utf8_async(None, None, _done)
|
||||||
|
|
||||||
|
|
||||||
|
def run_json(cmd: Sequence[str] | str, cb: Callable[[bool, object], None]) -> None:
|
||||||
|
"""Run cmd expecting JSON on stdout; call cb(ok, data)."""
|
||||||
|
|
||||||
|
def _text(ok: bool, out: str, err: str) -> None:
|
||||||
|
if not ok or not out.strip():
|
||||||
|
cb(False, err.strip() or "no output")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cb(True, json.loads(out))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
cb(False, f"bad json: {exc}")
|
||||||
|
|
||||||
|
run_text(cmd, _text)
|
||||||
|
|
||||||
|
|
||||||
|
class Poller:
|
||||||
|
"""Repeatedly run a command (or callable) on an interval, main-thread callback.
|
||||||
|
|
||||||
|
Used for lightweight state that has no change signal (open ports, public IP,
|
||||||
|
weather refresh). Modules that back onto an Astal GObject service should prefer
|
||||||
|
connecting to that service's `notify::` signals instead of polling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, interval_s: float, cmd: Sequence[str] | str, cb, json_mode: bool = False):
|
||||||
|
self.interval_s = interval_s
|
||||||
|
self.cmd = cmd
|
||||||
|
self.cb = cb
|
||||||
|
self.json_mode = json_mode
|
||||||
|
self._source_id: int | None = None
|
||||||
|
self._stopped = False
|
||||||
|
|
||||||
|
def start(self) -> "Poller":
|
||||||
|
self._stopped = False
|
||||||
|
self._tick()
|
||||||
|
self._source_id = GLib.timeout_add_seconds(int(self.interval_s), self._tick)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _tick(self) -> bool:
|
||||||
|
if self._stopped:
|
||||||
|
return GLib.SOURCE_REMOVE
|
||||||
|
if self.json_mode:
|
||||||
|
run_json(self.cmd, lambda ok, data: None if self._stopped else self.cb(ok, data))
|
||||||
|
else:
|
||||||
|
run_text(self.cmd, lambda ok, out, err: None if self._stopped else self.cb(ok, out, err))
|
||||||
|
return GLib.SOURCE_CONTINUE
|
||||||
|
|
||||||
|
def refresh_now(self) -> None:
|
||||||
|
self._tick()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stopped = True
|
||||||
|
if self._source_id is not None:
|
||||||
|
GLib.source_remove(self._source_id)
|
||||||
|
self._source_id = None
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""astro-menu — a touch-friendly GTK4 popup control centre (Location, Weather,
|
||||||
|
Bluetooth, Network) plus an application drawer, replacing nwg-dock/nwg-drawer.
|
||||||
|
|
||||||
|
Single-instance: the first launch builds the (hidden) window and holds. Later
|
||||||
|
invocations forward their arguments to it over D-Bus, so `main.py --toggle`
|
||||||
|
toggles the running instance without spawning a new process.
|
||||||
|
|
||||||
|
main.py run the resident instance (stays hidden until toggled)
|
||||||
|
main.py --toggle toggle visibility
|
||||||
|
main.py --show show | --hide hide | --appdrawer show with drawer open
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# astro-menu-start.sh LD_PRELOADs libgtk4-layer-shell so it loads before
|
||||||
|
# libwayland-client (a load-ordering requirement of the layer-shell library). That
|
||||||
|
# only matters at *this* process's exec: the library is already resident now, so the
|
||||||
|
# variable is never read again. Drop it here so the GUI apps we launch via
|
||||||
|
# AstalApps.launch() (and the backend subprocesses) don't inherit it — Firefox, for
|
||||||
|
# one, aborts at startup with libgtk4-layer-shell preloaded.
|
||||||
|
os.environ.pop("LD_PRELOAD", None)
|
||||||
|
|
||||||
|
# Make sibling modules importable no matter the CWD.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
import gi # noqa: E402
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gio, GLib, Gtk # noqa: E402
|
||||||
|
|
||||||
|
import theme # noqa: E402
|
||||||
|
from appservices import Services # noqa: E402
|
||||||
|
from paths import APP_ID, ensure_dirs # noqa: E402
|
||||||
|
from settings import Settings # noqa: E402
|
||||||
|
from window import MenuWindow # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class AstalMenuApp(Gtk.Application):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(application_id=APP_ID,
|
||||||
|
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
|
||||||
|
self.window: MenuWindow | None = None
|
||||||
|
|
||||||
|
def do_startup(self) -> None:
|
||||||
|
Gtk.Application.do_startup(self)
|
||||||
|
ensure_dirs()
|
||||||
|
theme.load_css()
|
||||||
|
settings = Settings()
|
||||||
|
services = Services()
|
||||||
|
self.window = MenuWindow(self, settings, services)
|
||||||
|
self._register_actions()
|
||||||
|
self.hold() # stay alive with no visible window
|
||||||
|
|
||||||
|
def _register_actions(self) -> None:
|
||||||
|
# Mirrors the --show/--hide/--toggle/--appdrawer verbs from do_command_line, but
|
||||||
|
# reachable via org.gtk.Actions.Activate. menu-toggle.sh calls these directly with
|
||||||
|
# `gdbus call` for already-running instances, which skips spawning a whole second
|
||||||
|
# python3 + GTK process (~0.5s) just to forward one verb over D-Bus.
|
||||||
|
def add(name: str, callback, has_side: bool) -> None:
|
||||||
|
action = Gio.SimpleAction.new(name, GLib.VariantType.new("s") if has_side else None)
|
||||||
|
action.connect("activate", callback)
|
||||||
|
self.add_action(action)
|
||||||
|
|
||||||
|
def side_of(param: GLib.Variant | None) -> str:
|
||||||
|
return param.get_string() if param is not None else ""
|
||||||
|
|
||||||
|
def on_show(_action, param) -> None:
|
||||||
|
assert self.window is not None
|
||||||
|
if side_of(param):
|
||||||
|
self.window.set_side(side_of(param))
|
||||||
|
self.window.show_menu()
|
||||||
|
|
||||||
|
def on_hide(_action, _param) -> None:
|
||||||
|
assert self.window is not None
|
||||||
|
self.window.hide_menu()
|
||||||
|
|
||||||
|
def on_toggle(_action, param) -> None:
|
||||||
|
assert self.window is not None
|
||||||
|
if side_of(param):
|
||||||
|
self.window.set_side(side_of(param))
|
||||||
|
self.window.toggle()
|
||||||
|
|
||||||
|
def on_appdrawer(_action, param) -> None:
|
||||||
|
assert self.window is not None
|
||||||
|
if side_of(param):
|
||||||
|
self.window.set_side(side_of(param))
|
||||||
|
self.window.show_menu(focus_appdrawer=True)
|
||||||
|
|
||||||
|
add("show", on_show, has_side=True)
|
||||||
|
add("hide", on_hide, has_side=False)
|
||||||
|
add("toggle", on_toggle, has_side=True)
|
||||||
|
add("appdrawer", on_appdrawer, has_side=True)
|
||||||
|
|
||||||
|
def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int:
|
||||||
|
args = list(cmdline.get_arguments()[1:])
|
||||||
|
# Optional "--side top|bottom|left|right": the menu slides in from and pins to
|
||||||
|
# that monitor edge. Applied before the verb so the surface maps animated.
|
||||||
|
side = None
|
||||||
|
if "--side" in args:
|
||||||
|
i = args.index("--side")
|
||||||
|
if i + 1 < len(args):
|
||||||
|
side = args[i + 1]
|
||||||
|
del args[i:i + 2]
|
||||||
|
# No args = start (or keep) the resident instance hidden. Only explicit
|
||||||
|
# verbs change visibility, so the autostart launch never pops the menu.
|
||||||
|
action = args[0] if args else "--daemon"
|
||||||
|
if self.window is None:
|
||||||
|
return 0
|
||||||
|
if side:
|
||||||
|
self.window.set_side(side)
|
||||||
|
if action == "--show":
|
||||||
|
self.window.show_menu()
|
||||||
|
elif action == "--hide":
|
||||||
|
self.window.hide_menu()
|
||||||
|
elif action == "--appdrawer":
|
||||||
|
self.window.show_menu(focus_appdrawer=True)
|
||||||
|
elif action == "--toggle":
|
||||||
|
self.window.toggle()
|
||||||
|
# --daemon and anything else: no-op (stay as-is)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def do_activate(self) -> None:
|
||||||
|
# Resident instance: nothing to do on plain activate.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
GLib.set_prgname("astro-menu")
|
||||||
|
return AstalMenuApp().run(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
"""The contract every quad module implements.
|
||||||
|
|
||||||
|
Adding a module = drop a file in modules/ that defines a top-level `SPEC`
|
||||||
|
(a ModuleSpec), then append its import to registry.py. Nothing else needs to
|
||||||
|
change: enable/disable, feature toggles, the card chrome, and the expand/collapse
|
||||||
|
plumbing are all provided generically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gtk # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Feature:
|
||||||
|
"""A per-quad on/off switch surfaced in the card's settings popover."""
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
default: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModuleInstance:
|
||||||
|
"""What a module's build() returns."""
|
||||||
|
compact: Gtk.Widget # shown in the 2x2 cell
|
||||||
|
expanded: Optional[Gtk.Widget] = None # shown when the quad is expanded
|
||||||
|
scroll_expanded: bool = True # wrap the expanded view in a scroller
|
||||||
|
on_show: Optional[Callable[[], None]] = None
|
||||||
|
on_hide: Optional[Callable[[], None]] = None
|
||||||
|
destroy: Optional[Callable[[], None]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModuleSpec:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
icon: str # nerd-font glyph
|
||||||
|
build: Callable[["ModuleContext"], ModuleInstance]
|
||||||
|
default_enabled: bool = True
|
||||||
|
features: list[Feature] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleContext:
|
||||||
|
"""Handed to a module's build(). Scopes settings to the module and exposes the
|
||||||
|
expand/collapse requests so a module can drive the layout without knowing it."""
|
||||||
|
|
||||||
|
def __init__(self, spec: ModuleSpec, settings, services,
|
||||||
|
request_expand: Callable[[str], None],
|
||||||
|
request_collapse: Callable[[], None]):
|
||||||
|
self.spec = spec
|
||||||
|
self.settings = settings
|
||||||
|
self.services = services
|
||||||
|
self._request_expand = request_expand
|
||||||
|
self._request_collapse = request_collapse
|
||||||
|
|
||||||
|
def expand(self) -> None:
|
||||||
|
self._request_expand(self.spec.id)
|
||||||
|
|
||||||
|
def collapse(self) -> None:
|
||||||
|
self._request_collapse()
|
||||||
|
|
||||||
|
# feature toggles, scoped to this module
|
||||||
|
def feature(self, feature_id: str, default: bool = True) -> bool:
|
||||||
|
return self.settings.feature(self.spec.id, feature_id, default)
|
||||||
|
|
||||||
|
def on_settings_changed(self, cb: Callable[[], None]) -> None:
|
||||||
|
self.settings.subscribe(cb)
|
||||||
|
|
@ -0,0 +1,292 @@
|
||||||
|
"""Bluetooth quad: powered by AstalBluetooth (bluez wrapper).
|
||||||
|
|
||||||
|
Discovery, connect, disconnect and a local connection history (bluez keeps none, so
|
||||||
|
we record successful connects in ~/.cache/astro-menu/bt-history.json). 'discovery'
|
||||||
|
and 'history' are per-quad feature toggles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import GLib, Gtk # noqa: E402
|
||||||
|
|
||||||
|
from lib.proc import run_text
|
||||||
|
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
|
||||||
|
from paths import CACHE_DIR
|
||||||
|
|
||||||
|
_HISTORY = CACHE_DIR / "bt-history.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_history() -> list[dict]:
|
||||||
|
try:
|
||||||
|
return json.loads(_HISTORY.read_text())
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _record_history(address: str, name: str) -> None:
|
||||||
|
hist = [h for h in _load_history() if h.get("address") != address]
|
||||||
|
hist.insert(0, {"address": address, "name": name, "ts": int(time.time())})
|
||||||
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
_HISTORY.write_text(json.dumps(hist[:50], indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
class _BluetoothView(Gtk.Box):
|
||||||
|
def __init__(self, ctx: ModuleContext, full: bool):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
|
self.add_css_class("bt-view")
|
||||||
|
self.ctx = ctx
|
||||||
|
self.full = full
|
||||||
|
self.bt = ctx.services.bluetooth
|
||||||
|
self._recorded: set[str] = set()
|
||||||
|
self._hooked: set[str] = set() # devices whose state signals we've wired
|
||||||
|
self._pending: set[str] = set() # addresses with an in-flight connect
|
||||||
|
self._failed: set[str] = set() # addresses whose last connect failed
|
||||||
|
|
||||||
|
self.append(self._build_header())
|
||||||
|
|
||||||
|
self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||||
|
self._list.add_css_class("bt-list")
|
||||||
|
if full:
|
||||||
|
scroller = Gtk.ScrolledWindow(vexpand=True,
|
||||||
|
hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||||
|
scroller.set_child(self._list)
|
||||||
|
self.append(scroller)
|
||||||
|
else:
|
||||||
|
self.append(self._list)
|
||||||
|
|
||||||
|
if self.bt is not None:
|
||||||
|
self.bt.connect("notify::devices", lambda *_: self._refresh())
|
||||||
|
self.bt.connect("notify::is-powered", lambda *_: self._refresh())
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _adapter(self):
|
||||||
|
return self.bt.get_adapter() if self.bt else None
|
||||||
|
|
||||||
|
def _build_header(self) -> Gtk.Widget:
|
||||||
|
header = Gtk.CenterBox()
|
||||||
|
header.add_css_class("bt-header")
|
||||||
|
left = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
left.append(Gtk.Label(label="Power"))
|
||||||
|
self._power = Gtk.Switch(active=bool(self.bt and self.bt.get_is_powered()),
|
||||||
|
valign=Gtk.Align.CENTER)
|
||||||
|
self._power.connect("state-set", self._on_power)
|
||||||
|
left.append(self._power)
|
||||||
|
header.set_start_widget(left)
|
||||||
|
|
||||||
|
if self.full and self.ctx.feature("discovery", True):
|
||||||
|
self._scan = Gtk.ToggleButton(label=" Scan")
|
||||||
|
self._scan.add_css_class("quad-action")
|
||||||
|
self._scan.connect("toggled", self._on_scan)
|
||||||
|
header.set_end_widget(self._scan)
|
||||||
|
return header
|
||||||
|
|
||||||
|
def _on_power(self, _sw, value: bool) -> bool:
|
||||||
|
ad = self._adapter()
|
||||||
|
if value:
|
||||||
|
# The adapter is frequently rfkill soft-blocked at boot; while blocked,
|
||||||
|
# AstalBluetooth.set_powered(True) is a silent no-op. Unblock first, then
|
||||||
|
# power on (bluez usually auto-powers on unblock, so set_powered is a
|
||||||
|
# belt-and-suspenders follow-up).
|
||||||
|
run_text(["rfkill", "unblock", "bluetooth"],
|
||||||
|
lambda *_a: ad.set_powered(True) if ad else None)
|
||||||
|
elif ad:
|
||||||
|
ad.set_powered(False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _on_scan(self, btn: Gtk.ToggleButton) -> None:
|
||||||
|
ad = self._adapter()
|
||||||
|
if not ad:
|
||||||
|
return
|
||||||
|
if btn.get_active():
|
||||||
|
ad.start_discovery()
|
||||||
|
else:
|
||||||
|
ad.stop_discovery()
|
||||||
|
|
||||||
|
# -- device list -------------------------------------------------------
|
||||||
|
def _refresh(self) -> None:
|
||||||
|
child = self._list.get_first_child()
|
||||||
|
while child:
|
||||||
|
self._list.remove(child)
|
||||||
|
child = self._list.get_first_child()
|
||||||
|
if not self.bt:
|
||||||
|
self._list.append(Gtk.Label(label="No Bluetooth adapter"))
|
||||||
|
return
|
||||||
|
|
||||||
|
devices = list(self.bt.get_devices())
|
||||||
|
devices.sort(key=lambda d: (not d.get_connected(), not d.get_paired(),
|
||||||
|
(d.get_name() or d.get_address() or "").lower()))
|
||||||
|
if not self.full:
|
||||||
|
devices = [d for d in devices if d.get_connected() or d.get_paired()][:4]
|
||||||
|
for dev in devices:
|
||||||
|
self._hook_device(dev)
|
||||||
|
self._list.append(self._device_row(dev))
|
||||||
|
|
||||||
|
if self.full and self.ctx.feature("history", True):
|
||||||
|
self._list.append(self._history_section())
|
||||||
|
|
||||||
|
def _hook_device(self, dev) -> None:
|
||||||
|
# React to a device's own state changes (adapter-level notify::devices doesn't
|
||||||
|
# fire for per-device connect/disconnect). Hook once each.
|
||||||
|
key = dev.get_address() or ""
|
||||||
|
if key in self._hooked:
|
||||||
|
return
|
||||||
|
self._hooked.add(key)
|
||||||
|
for sig in ("notify::connected", "notify::connecting", "notify::paired"):
|
||||||
|
dev.connect(sig, lambda *_a, d=dev: self._on_device_state(d))
|
||||||
|
|
||||||
|
def _on_device_state(self, dev) -> None:
|
||||||
|
addr = dev.get_address() or ""
|
||||||
|
if dev.get_connected():
|
||||||
|
self._pending.discard(addr)
|
||||||
|
self._failed.discard(addr)
|
||||||
|
if self.ctx.feature("history", True):
|
||||||
|
self._maybe_record(dev, force=True)
|
||||||
|
elif addr in self._pending and not dev.get_connecting():
|
||||||
|
# bluez stopped trying without establishing a link → the connect failed
|
||||||
|
self._pending.discard(addr)
|
||||||
|
self._failed.add(addr)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _device_row(self, dev) -> Gtk.Widget:
|
||||||
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||||
|
row.add_css_class("bt-row")
|
||||||
|
addr = dev.get_address() or ""
|
||||||
|
icon = Gtk.Image.new_from_icon_name((dev.get_icon() or "bluetooth") + "-symbolic")
|
||||||
|
row.append(icon)
|
||||||
|
name = dev.get_name() or addr or "Unknown"
|
||||||
|
|
||||||
|
connected = dev.get_connected()
|
||||||
|
connecting = dev.get_connecting() or addr in self._pending
|
||||||
|
failed = addr in self._failed
|
||||||
|
|
||||||
|
lbl = Gtk.Label(label=name, xalign=0.0, hexpand=True)
|
||||||
|
row.append(lbl)
|
||||||
|
|
||||||
|
status = ("connected" if connected else "connecting…" if connecting else
|
||||||
|
"failed" if failed else "paired" if dev.get_paired() else "")
|
||||||
|
if status:
|
||||||
|
tag = Gtk.Label(label=status)
|
||||||
|
tag.add_css_class("bt-status")
|
||||||
|
if failed:
|
||||||
|
tag.add_css_class("bt-failed")
|
||||||
|
row.append(tag)
|
||||||
|
|
||||||
|
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||||
|
if connecting:
|
||||||
|
spinner = Gtk.Spinner(valign=Gtk.Align.CENTER)
|
||||||
|
spinner.start()
|
||||||
|
actions.append(spinner)
|
||||||
|
actions.append(self._act("Cancel", lambda: self._cancel(dev)))
|
||||||
|
else:
|
||||||
|
if dev.get_paired():
|
||||||
|
actions.append(self._act("Forget", lambda: self._forget(dev)))
|
||||||
|
if connected:
|
||||||
|
actions.append(self._act("Disconnect",
|
||||||
|
lambda: dev.disconnect_device(None, self._noop)))
|
||||||
|
else:
|
||||||
|
actions.append(self._act("Retry" if failed else "Connect",
|
||||||
|
lambda: self._start_connect(dev)))
|
||||||
|
row.append(actions)
|
||||||
|
|
||||||
|
if connected and self.ctx.feature("history", True):
|
||||||
|
self._maybe_record(dev)
|
||||||
|
return row
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _act(label: str, cb) -> Gtk.Button:
|
||||||
|
b = Gtk.Button(label=label, valign=Gtk.Align.CENTER)
|
||||||
|
b.add_css_class("quad-action")
|
||||||
|
b.connect("clicked", lambda *_a: cb())
|
||||||
|
return b
|
||||||
|
|
||||||
|
# -- connect / cancel / forget ----------------------------------------
|
||||||
|
def _start_connect(self, dev) -> None:
|
||||||
|
addr = dev.get_address() or ""
|
||||||
|
self._failed.discard(addr)
|
||||||
|
self._pending.add(addr)
|
||||||
|
|
||||||
|
def done(d, res):
|
||||||
|
try:
|
||||||
|
d.connect_device_finish(res)
|
||||||
|
except Exception:
|
||||||
|
a = d.get_address() or ""
|
||||||
|
if not d.get_connected():
|
||||||
|
self._pending.discard(a)
|
||||||
|
self._failed.add(a)
|
||||||
|
self._refresh()
|
||||||
|
dev.connect_device(None, done)
|
||||||
|
# Backstop: some failures never resolve the async call, so time out.
|
||||||
|
GLib.timeout_add_seconds(25, lambda: self._connect_timeout(addr))
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _connect_timeout(self, addr: str) -> bool:
|
||||||
|
if addr in self._pending:
|
||||||
|
self._pending.discard(addr)
|
||||||
|
self._failed.add(addr)
|
||||||
|
self._refresh()
|
||||||
|
return GLib.SOURCE_REMOVE
|
||||||
|
|
||||||
|
def _cancel(self, dev) -> None:
|
||||||
|
addr = dev.get_address() or ""
|
||||||
|
self._pending.discard(addr)
|
||||||
|
dev.disconnect_device(None, self._noop) # abort the in-flight attempt
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _forget(self, dev) -> None:
|
||||||
|
ad = self._adapter()
|
||||||
|
addr = dev.get_address() or ""
|
||||||
|
self._pending.discard(addr)
|
||||||
|
self._failed.discard(addr)
|
||||||
|
self._hooked.discard(addr)
|
||||||
|
if ad:
|
||||||
|
ad.remove_device(dev) # fires notify::devices → refresh
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _maybe_record(self, dev, force: bool = False) -> None:
|
||||||
|
addr = dev.get_address() or ""
|
||||||
|
if not addr or (addr in self._recorded and not force):
|
||||||
|
return
|
||||||
|
self._recorded.add(addr)
|
||||||
|
_record_history(addr, dev.get_name() or addr)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _noop(obj, res) -> None:
|
||||||
|
try:
|
||||||
|
obj.disconnect_device_finish(res)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _history_section(self) -> Gtk.Widget:
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||||
|
box.add_css_class("bt-history")
|
||||||
|
box.append(Gtk.Separator())
|
||||||
|
box.append(Gtk.Label(label="History", xalign=0.0))
|
||||||
|
for h in _load_history()[:8]:
|
||||||
|
when = time.strftime("%d.%m %H:%M", time.localtime(h.get("ts", 0)))
|
||||||
|
box.append(Gtk.Label(label=f"{h.get('name', '?')} · {when}", xalign=0.0))
|
||||||
|
return box
|
||||||
|
|
||||||
|
|
||||||
|
def build(ctx: ModuleContext) -> ModuleInstance:
|
||||||
|
# Feature toggles (discovery/history) rebuild the whole card via QuadCard, so
|
||||||
|
# both views are recreated with the current features — no partial refresh here.
|
||||||
|
compact = _BluetoothView(ctx, full=False)
|
||||||
|
expanded = _BluetoothView(ctx, full=True)
|
||||||
|
return ModuleInstance(compact=compact, expanded=expanded)
|
||||||
|
|
||||||
|
|
||||||
|
SPEC = ModuleSpec(
|
||||||
|
id="bluetooth",
|
||||||
|
title="Bluetooth",
|
||||||
|
icon="", # nf-fa-bluetooth
|
||||||
|
build=build,
|
||||||
|
default_enabled=True,
|
||||||
|
features=[Feature("discovery", "Device discovery", True),
|
||||||
|
Feature("history", "Connection history", True)],
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
"""Location quad: a slippy-map image centred on the device's IP-geolocated position,
|
||||||
|
with a marker. Drag to pan, scroll to zoom, double-click to recentre.
|
||||||
|
|
||||||
|
The position comes from the shared LocationService (backend/geolocate.py), which
|
||||||
|
traceroutes to 1.1.1.1, takes the first public hop (the ISP egress) and resolves it
|
||||||
|
through a public geolocation API. The "Locate via IP" feature toggle gates that
|
||||||
|
lookup entirely; when off, this quad shows a placeholder instead. "Satellite view"
|
||||||
|
switches the tiles between the dark night map and Esri satellite imagery.
|
||||||
|
|
||||||
|
We render a static map (backend/staticmap.py) rather than an interactive libshumate
|
||||||
|
map: Shumate does not paint tiles in this environment (the official shumate-demo
|
||||||
|
shows the same blank map), while tile downloads themselves work fine — so panning is
|
||||||
|
done by re-rendering at a new centre. The Weather quad consumes the same service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gdk, GLib, Gtk # noqa: E402
|
||||||
|
|
||||||
|
from lib.proc import run_text
|
||||||
|
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
|
||||||
|
from paths import BACKEND_DIR, CACHE_DIR
|
||||||
|
|
||||||
|
_SCRIPT = str(BACKEND_DIR / "staticmap.py")
|
||||||
|
_TILE = 256
|
||||||
|
|
||||||
|
|
||||||
|
def _center_px(lat: float, lon: float, zoom: int) -> tuple[float, float]:
|
||||||
|
n = 2 ** zoom
|
||||||
|
y = (1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n
|
||||||
|
x = (lon + 180.0) / 360.0 * n
|
||||||
|
return x * _TILE, y * _TILE
|
||||||
|
|
||||||
|
|
||||||
|
def _px_latlon(px: float, py: float, zoom: int) -> tuple[float, float]:
|
||||||
|
n = 2 ** zoom
|
||||||
|
lon = px / (_TILE * n) * 360.0 - 180.0
|
||||||
|
lat = math.degrees(math.atan(math.sinh(math.pi * (1.0 - 2.0 * py / (_TILE * n)))))
|
||||||
|
return lat, lon
|
||||||
|
|
||||||
|
|
||||||
|
class _MapView(Gtk.Box):
|
||||||
|
def __init__(self, ctx: ModuleContext, zoom: int, size: tuple[int, int],
|
||||||
|
tag: str, show_info: bool):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
self.add_css_class("map-view")
|
||||||
|
self.ctx = ctx
|
||||||
|
self.zoom = zoom
|
||||||
|
self.size = size
|
||||||
|
self._out = str(CACHE_DIR / f"map_{tag}.png")
|
||||||
|
self._loc: tuple[float, float] | None = None # geolocated position (marker)
|
||||||
|
self._view: tuple[float, float] | None = None # current view centre
|
||||||
|
self._drag_from: tuple[float, float] | None = None
|
||||||
|
self._render_pending = False
|
||||||
|
|
||||||
|
self.picture = Gtk.Picture(content_fit=Gtk.ContentFit.COVER, vexpand=True)
|
||||||
|
self.picture.add_css_class("map-picture")
|
||||||
|
self.append(self.picture)
|
||||||
|
|
||||||
|
self._info = None
|
||||||
|
if show_info:
|
||||||
|
self._info = Gtk.Label(label="Locating…", xalign=0.0)
|
||||||
|
self._info.add_css_class("map-info")
|
||||||
|
self.append(self._info)
|
||||||
|
|
||||||
|
self._wire_gestures()
|
||||||
|
ctx.services.location.subscribe(self._on_location)
|
||||||
|
|
||||||
|
def _style(self) -> str:
|
||||||
|
return "satellite" if self.ctx.feature("satellite", False) else "dark"
|
||||||
|
|
||||||
|
# -- data / render -----------------------------------------------------
|
||||||
|
def _on_location(self, data: dict) -> None:
|
||||||
|
self._loc = (data["lat"], data["lon"])
|
||||||
|
self._view = (data["lat"], data["lon"]) # recentre on a fresh fix
|
||||||
|
self._render()
|
||||||
|
if self._info is not None:
|
||||||
|
city = data.get("city") or "Unknown"
|
||||||
|
country = data.get("country") or ""
|
||||||
|
self._info.set_text(f"{city}, {country} · {data['lat']:.3f}, {data['lon']:.3f}"
|
||||||
|
f" · drag to pan · scroll to zoom · double-click to recentre")
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
if self._view is None:
|
||||||
|
return
|
||||||
|
vlat, vlon = self._view
|
||||||
|
mlat, mlon = self._loc or self._view
|
||||||
|
w, h = self.size
|
||||||
|
run_text([sys.executable, _SCRIPT, str(vlat), str(vlon), str(self.zoom),
|
||||||
|
str(w), str(h), self._out, self._style(), str(mlat), str(mlon)],
|
||||||
|
self._on_rendered)
|
||||||
|
|
||||||
|
def _schedule_render(self) -> None:
|
||||||
|
# coalesce the flood of drag/scroll updates into ~11 renders/sec
|
||||||
|
if self._render_pending:
|
||||||
|
return
|
||||||
|
self._render_pending = True
|
||||||
|
|
||||||
|
def go() -> bool:
|
||||||
|
self._render_pending = False
|
||||||
|
self._render()
|
||||||
|
return GLib.SOURCE_REMOVE
|
||||||
|
GLib.timeout_add(90, go)
|
||||||
|
|
||||||
|
def _on_rendered(self, ok: bool, out: str, err: str) -> None:
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
# load a fresh texture; set_filename would ignore an unchanged path on re-pan
|
||||||
|
try:
|
||||||
|
self.picture.set_paintable(Gdk.Texture.new_from_filename(self._out))
|
||||||
|
except GLib.Error:
|
||||||
|
self.picture.set_filename(self._out)
|
||||||
|
|
||||||
|
# -- gestures: drag to pan, scroll to zoom, double-click to recentre ----
|
||||||
|
def _wire_gestures(self) -> None:
|
||||||
|
drag = Gtk.GestureDrag()
|
||||||
|
drag.connect("drag-begin", lambda *_a: setattr(self, "_drag_from", self._view))
|
||||||
|
drag.connect("drag-update", self._on_drag)
|
||||||
|
self.picture.add_controller(drag)
|
||||||
|
|
||||||
|
scroll = Gtk.EventControllerScroll(
|
||||||
|
flags=Gtk.EventControllerScrollFlags.VERTICAL)
|
||||||
|
scroll.connect("scroll", self._on_scroll)
|
||||||
|
self.picture.add_controller(scroll)
|
||||||
|
|
||||||
|
click = Gtk.GestureClick()
|
||||||
|
click.connect("pressed", self._on_click)
|
||||||
|
self.picture.add_controller(click)
|
||||||
|
|
||||||
|
def _on_drag(self, _gesture, ox: float, oy: float) -> None:
|
||||||
|
if self._drag_from is None:
|
||||||
|
return
|
||||||
|
cx, cy = _center_px(self._drag_from[0], self._drag_from[1], self.zoom)
|
||||||
|
# drag right → the view centre moves left (map content follows the cursor)
|
||||||
|
self._view = _px_latlon(cx - ox, cy - oy, self.zoom)
|
||||||
|
self._schedule_render()
|
||||||
|
|
||||||
|
def _on_scroll(self, _controller, _dx: float, dy: float) -> bool:
|
||||||
|
self.zoom = max(3, min(18, self.zoom + (1 if dy < 0 else -1)))
|
||||||
|
self._schedule_render()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _on_click(self, _gesture, n_press: int, _x: float, _y: float) -> None:
|
||||||
|
if n_press >= 2 and self._loc is not None:
|
||||||
|
self._view = self._loc
|
||||||
|
self._schedule_render()
|
||||||
|
|
||||||
|
|
||||||
|
def _placeholder() -> Gtk.Widget:
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8,
|
||||||
|
valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER)
|
||||||
|
box.add_css_class("map-view")
|
||||||
|
box.append(Gtk.Label(label="IP location is off"))
|
||||||
|
return box
|
||||||
|
|
||||||
|
|
||||||
|
def build(ctx: ModuleContext) -> ModuleInstance:
|
||||||
|
# "Locate via IP" gates geolocation entirely: when off we neither traceroute nor
|
||||||
|
# hit any geolocation API, and just show a placeholder. Toggling it rebuilds the
|
||||||
|
# card (via QuadCard), so flipping it back on re-triggers the lookup.
|
||||||
|
if not ctx.feature("ip_locate", True):
|
||||||
|
return ModuleInstance(compact=_placeholder(), expanded=None)
|
||||||
|
ctx.services.location.get() # kick off geolocation (traceroute → API) if not started
|
||||||
|
compact = _MapView(ctx, zoom=12, size=(620, 150), tag="compact", show_info=False)
|
||||||
|
expanded = _MapView(ctx, zoom=13, size=(1100, 620), tag="expanded", show_info=True)
|
||||||
|
return ModuleInstance(compact=compact, expanded=expanded, scroll_expanded=False)
|
||||||
|
|
||||||
|
|
||||||
|
SPEC = ModuleSpec(
|
||||||
|
id="location",
|
||||||
|
title="Location",
|
||||||
|
icon="", # nf-fa-map_marker
|
||||||
|
build=build,
|
||||||
|
features=[Feature("ip_locate", "Locate via IP", True),
|
||||||
|
Feature("satellite", "Satellite view", False)],
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,529 @@
|
||||||
|
"""Network quad.
|
||||||
|
|
||||||
|
Compact: Wi-Fi enable + primary connection/IP (AstalNetwork + backend/network.sh).
|
||||||
|
Expanded: a small NetworkManager control panel over backend/nm.py, as four tabs —
|
||||||
|
Adapters : each device foldout with editable IPv4/IPv6 (DHCP toggle, address,
|
||||||
|
subnet-mask <-> CIDR, gateway); applied via `nmcli connection modify/up`
|
||||||
|
Routes : the kernel routing table (read-only; editing kernel routes needs root)
|
||||||
|
VLAN : list / create / activate / delete VLAN connections
|
||||||
|
DNS : effective servers per device + a per-connection override table
|
||||||
|
Every tab is an independently toggle-able feature.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gtk # noqa: E402
|
||||||
|
|
||||||
|
from lib.proc import run_json
|
||||||
|
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
|
||||||
|
from paths import BACKEND_DIR
|
||||||
|
|
||||||
|
_NET = str(BACKEND_DIR / "network.sh")
|
||||||
|
_NM = str(BACKEND_DIR / "nm.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _nm(args: list[str], cb) -> None:
|
||||||
|
"""Run a backend/nm.py subcommand expecting JSON on stdout."""
|
||||||
|
run_json([sys.executable, _NM, *args], cb)
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_ip(data) -> str:
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return "—"
|
||||||
|
for iface in data:
|
||||||
|
if iface.get("ifname") == "lo":
|
||||||
|
continue
|
||||||
|
for a in iface.get("addr_info", []):
|
||||||
|
if a.get("family") == "inet":
|
||||||
|
return f"{a['local']}/{a.get('prefixlen', '')} ({iface.get('ifname')})"
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
|
||||||
|
# -- subnet mask <-> CIDR prefix ------------------------------------------
|
||||||
|
def _mask_to_prefix(mask: str) -> int | None:
|
||||||
|
try:
|
||||||
|
return ipaddress.IPv4Network(f"0.0.0.0/{mask.strip()}").prefixlen
|
||||||
|
except (ipaddress.NetmaskValueError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _prefix_to_mask(prefix: int) -> str:
|
||||||
|
return str(ipaddress.IPv4Network(f"0.0.0.0/{prefix}").netmask)
|
||||||
|
|
||||||
|
|
||||||
|
class _Compact(Gtk.Box):
|
||||||
|
def __init__(self, ctx: ModuleContext):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
|
self.add_css_class("net-view")
|
||||||
|
self.ctx = ctx
|
||||||
|
self.net = ctx.services.network
|
||||||
|
|
||||||
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
self._primary = Gtk.Label(label="…", xalign=0.0, hexpand=True)
|
||||||
|
row.append(Gtk.Label(label="")) # nf wifi
|
||||||
|
row.append(self._primary)
|
||||||
|
wifi = self.net.get_wifi() if self.net else None
|
||||||
|
self._wifi_switch = Gtk.Switch(active=bool(wifi and wifi.get_enabled()),
|
||||||
|
valign=Gtk.Align.CENTER)
|
||||||
|
self._wifi_switch.set_tooltip_text("Wi-Fi")
|
||||||
|
self._wifi_switch.connect("state-set", self._on_wifi_toggle)
|
||||||
|
row.append(self._wifi_switch)
|
||||||
|
self.append(row)
|
||||||
|
|
||||||
|
self._ip = Gtk.Label(label="", xalign=0.0)
|
||||||
|
self._ip.add_css_class("net-ip")
|
||||||
|
self.append(self._ip)
|
||||||
|
|
||||||
|
if self.net:
|
||||||
|
self.net.connect("notify::primary", lambda *_: self.refresh())
|
||||||
|
if wifi:
|
||||||
|
wifi.connect("notify::ssid", lambda *_: self.refresh())
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def _on_wifi_toggle(self, _sw, value: bool) -> bool:
|
||||||
|
wifi = self.net.get_wifi() if self.net else None
|
||||||
|
if wifi:
|
||||||
|
wifi.set_enabled(value)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
wifi = self.net.get_wifi() if self.net else None
|
||||||
|
if wifi and wifi.get_active_access_point():
|
||||||
|
self._primary.set_text(f"{wifi.get_ssid() or '?'} · {wifi.get_strength()}%")
|
||||||
|
elif self.net and self.net.get_wired() and self.net.get_wired().get_internet():
|
||||||
|
self._primary.set_text("Wired")
|
||||||
|
else:
|
||||||
|
self._primary.set_text("Disconnected")
|
||||||
|
run_json([_NET, "ip"], lambda ok, d: self._ip.set_text(_primary_ip(d) if ok else "—"))
|
||||||
|
|
||||||
|
|
||||||
|
# -- shared little widgets -------------------------------------------------
|
||||||
|
def _clear(box: Gtk.Box) -> None:
|
||||||
|
child = box.get_first_child()
|
||||||
|
while child:
|
||||||
|
box.remove(child)
|
||||||
|
child = box.get_first_child()
|
||||||
|
|
||||||
|
|
||||||
|
def _field(placeholder: str, text: str = "") -> Gtk.Entry:
|
||||||
|
e = Gtk.Entry(placeholder_text=placeholder, text=text, hexpand=True)
|
||||||
|
e.add_css_class("net-entry")
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def _labeled(label: str, widget: Gtk.Widget) -> Gtk.Widget:
|
||||||
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
lbl = Gtk.Label(label=label, xalign=0.0)
|
||||||
|
lbl.set_size_request(90, -1)
|
||||||
|
row.append(lbl)
|
||||||
|
row.append(widget)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _pill(label: str, cb) -> Gtk.Button:
|
||||||
|
b = Gtk.Button(label=label)
|
||||||
|
b.add_css_class("quad-action")
|
||||||
|
b.connect("clicked", lambda *_: cb())
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
# -- IPv4 / IPv6 editor for one connection --------------------------------
|
||||||
|
class _IPSection(Gtk.Box):
|
||||||
|
"""One address family's editor. IPv4 exposes a linked subnet-mask/CIDR pair;
|
||||||
|
IPv6 exposes a prefix-length field. Fields are only sensitive in Manual mode."""
|
||||||
|
|
||||||
|
def __init__(self, con: str, family: str, fam_data: dict, on_result):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||||
|
self.con = con
|
||||||
|
self.family = family
|
||||||
|
self._on_result = on_result
|
||||||
|
self.add_css_class("net-view")
|
||||||
|
|
||||||
|
title = "IPv4" if family == "ipv4" else "IPv6"
|
||||||
|
head = Gtk.CenterBox()
|
||||||
|
head.set_start_widget(Gtk.Label(label=title, xalign=0.0))
|
||||||
|
auto = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
auto.append(Gtk.Label(label="Automatic"))
|
||||||
|
self._dhcp = Gtk.Switch(active=(fam_data.get("method", "auto") != "manual"),
|
||||||
|
valign=Gtk.Align.CENTER)
|
||||||
|
self._dhcp.connect("state-set", self._on_mode)
|
||||||
|
auto.append(self._dhcp)
|
||||||
|
head.set_end_widget(auto)
|
||||||
|
self.append(head)
|
||||||
|
|
||||||
|
addr, prefix = self._split(fam_data.get("addresses", []))
|
||||||
|
self._addr = _field("Address", addr)
|
||||||
|
self._gw = _field("Gateway", fam_data.get("gateway", ""))
|
||||||
|
|
||||||
|
self._fields = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||||
|
self._fields.append(_labeled("Address", self._addr))
|
||||||
|
if family == "ipv4":
|
||||||
|
self._guard = False
|
||||||
|
self._mask = _field("255.255.255.0", _prefix_to_mask(prefix) if prefix else "")
|
||||||
|
self._cidr = _field("/24", f"/{prefix}" if prefix else "")
|
||||||
|
self._cidr.set_size_request(70, -1)
|
||||||
|
self._mask.connect("changed", self._sync_from_mask)
|
||||||
|
self._cidr.connect("changed", self._sync_from_cidr)
|
||||||
|
mask_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
mask_row.append(self._mask)
|
||||||
|
mask_row.append(self._cidr)
|
||||||
|
self._fields.append(_labeled("Netmask", mask_row))
|
||||||
|
else:
|
||||||
|
self._prefix = _field("64", str(prefix) if prefix else "")
|
||||||
|
self._fields.append(_labeled("Prefix", self._prefix))
|
||||||
|
self._fields.append(_labeled("Gateway", self._gw))
|
||||||
|
apply = Gtk.Button(label=f"Apply {title}")
|
||||||
|
apply.add_css_class("enable-btn")
|
||||||
|
apply.connect("clicked", lambda *_: self._apply())
|
||||||
|
self._fields.append(apply)
|
||||||
|
self.append(self._fields)
|
||||||
|
|
||||||
|
self._set_manual_enabled(not self._dhcp.get_active())
|
||||||
|
|
||||||
|
def _set_manual_enabled(self, on: bool) -> None:
|
||||||
|
# The reset GTK theme doesn't dim insensitive widgets, so fade the manual
|
||||||
|
# fields explicitly when DHCP/Automatic is active.
|
||||||
|
self._fields.set_sensitive(on)
|
||||||
|
self._fields.set_opacity(1.0 if on else 0.45)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split(addresses: list[str]) -> tuple[str, int | None]:
|
||||||
|
if not addresses:
|
||||||
|
return "", None
|
||||||
|
addr, _, prefix = addresses[0].partition("/")
|
||||||
|
try:
|
||||||
|
return addr, int(prefix)
|
||||||
|
except ValueError:
|
||||||
|
return addr, None
|
||||||
|
|
||||||
|
def _on_mode(self, _sw, auto: bool) -> bool:
|
||||||
|
self._set_manual_enabled(not auto)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# keep the dotted mask and /CIDR fields in lock-step (IPv4 only)
|
||||||
|
def _sync_from_mask(self, _e) -> None:
|
||||||
|
if self._guard:
|
||||||
|
return
|
||||||
|
p = _mask_to_prefix(self._mask.get_text())
|
||||||
|
if p is not None:
|
||||||
|
self._guard = True
|
||||||
|
self._cidr.set_text(f"/{p}")
|
||||||
|
self._guard = False
|
||||||
|
|
||||||
|
def _sync_from_cidr(self, _e) -> None:
|
||||||
|
if self._guard:
|
||||||
|
return
|
||||||
|
raw = self._cidr.get_text().lstrip("/").strip()
|
||||||
|
if raw.isdigit() and 0 <= int(raw) <= 32:
|
||||||
|
self._guard = True
|
||||||
|
self._mask.set_text(_prefix_to_mask(int(raw)))
|
||||||
|
self._guard = False
|
||||||
|
|
||||||
|
def _prefixlen(self) -> str:
|
||||||
|
if self.family == "ipv4":
|
||||||
|
raw = self._cidr.get_text().lstrip("/").strip()
|
||||||
|
if raw.isdigit():
|
||||||
|
return raw
|
||||||
|
p = _mask_to_prefix(self._mask.get_text())
|
||||||
|
return str(p) if p is not None else ""
|
||||||
|
return self._prefix.get_text().strip()
|
||||||
|
|
||||||
|
def _apply(self) -> None:
|
||||||
|
if self._dhcp.get_active():
|
||||||
|
args = ["set-ip", self.con, self.family, "auto"]
|
||||||
|
else:
|
||||||
|
addr, plen = self._addr.get_text().strip(), self._prefixlen()
|
||||||
|
cidr = f"{addr}/{plen}" if addr and plen else addr
|
||||||
|
args = ["set-ip", self.con, self.family, "manual",
|
||||||
|
cidr, self._gw.get_text().strip(), ""]
|
||||||
|
_nm(args, lambda ok, d: self._on_result(
|
||||||
|
ok and isinstance(d, dict) and d.get("ok"),
|
||||||
|
(d.get("error") if isinstance(d, dict) else "") or ""))
|
||||||
|
|
||||||
|
|
||||||
|
class _AdaptersPage(Gtk.Box):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||||
|
self._status = Gtk.Label(xalign=0.0)
|
||||||
|
self._status.add_css_class("net-ip")
|
||||||
|
self._status.set_visible(False)
|
||||||
|
self.append(self._status)
|
||||||
|
self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
|
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||||
|
scroller.set_child(self._list)
|
||||||
|
self.append(scroller)
|
||||||
|
self._reload()
|
||||||
|
|
||||||
|
def _reload(self) -> None:
|
||||||
|
_nm(["adapters"], self._populate)
|
||||||
|
|
||||||
|
def _populate(self, ok: bool, adapters) -> None:
|
||||||
|
_clear(self._list)
|
||||||
|
if not ok or not isinstance(adapters, list) or not adapters:
|
||||||
|
self._list.append(Gtk.Label(label="No manageable adapters", xalign=0.0))
|
||||||
|
return
|
||||||
|
for a in adapters:
|
||||||
|
self._list.append(self._adapter_row(a))
|
||||||
|
|
||||||
|
def _adapter_row(self, a: dict) -> Gtk.Widget:
|
||||||
|
icon = "" if a.get("type") == "wifi" else "" # nf wifi / ethernet
|
||||||
|
exp = Gtk.Expander(
|
||||||
|
label=f"{icon} {a['device']} · {a.get('connection') or '—'} [{a.get('state')}]")
|
||||||
|
exp.add_css_class("net-adapter")
|
||||||
|
body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||||
|
body.set_margin_top(8)
|
||||||
|
con = a.get("connection")
|
||||||
|
if not con:
|
||||||
|
body.append(Gtk.Label(label="No connection profile", xalign=0.0))
|
||||||
|
else:
|
||||||
|
loading = Gtk.Label(label="Loading…", xalign=0.0)
|
||||||
|
body.append(loading)
|
||||||
|
_nm(["ipconfig", con], lambda ok, d, b=body, l=loading, c=con:
|
||||||
|
self._fill(ok, d, b, l, c))
|
||||||
|
exp.set_child(body)
|
||||||
|
return exp
|
||||||
|
|
||||||
|
def _fill(self, ok, data, body, loading, con) -> None:
|
||||||
|
body.remove(loading)
|
||||||
|
if not ok or not isinstance(data, dict):
|
||||||
|
body.append(Gtk.Label(label="Could not read config", xalign=0.0))
|
||||||
|
return
|
||||||
|
result = Gtk.Label(xalign=0.0)
|
||||||
|
result.set_visible(False)
|
||||||
|
|
||||||
|
def on_result(good: bool, err: str) -> None:
|
||||||
|
result.set_text("Applied" if good else f"Failed: {err or 'error'}")
|
||||||
|
result.set_visible(True)
|
||||||
|
|
||||||
|
body.append(_IPSection(con, "ipv4", data.get("ipv4", {}), on_result))
|
||||||
|
body.append(Gtk.Separator())
|
||||||
|
body.append(_IPSection(con, "ipv6", data.get("ipv6", {}), on_result))
|
||||||
|
body.append(result)
|
||||||
|
|
||||||
|
|
||||||
|
class _RoutesPage(Gtk.Box):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||||
|
header = Gtk.CenterBox()
|
||||||
|
header.set_start_widget(Gtk.Label(label="Kernel routing table", xalign=0.0))
|
||||||
|
header.set_end_widget(_pill(" Refresh", self._reload))
|
||||||
|
self.append(header)
|
||||||
|
self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||||
|
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||||
|
scroller.set_child(self._list)
|
||||||
|
self.append(scroller)
|
||||||
|
self._reload()
|
||||||
|
|
||||||
|
def _reload(self) -> None:
|
||||||
|
_nm(["routes"], self._populate)
|
||||||
|
|
||||||
|
def _populate(self, ok: bool, routes) -> None:
|
||||||
|
_clear(self._list)
|
||||||
|
if not ok or not isinstance(routes, list) or not routes:
|
||||||
|
self._list.append(Gtk.Label(label="No routes", xalign=0.0))
|
||||||
|
return
|
||||||
|
for r in routes:
|
||||||
|
self._list.append(Gtk.Label(label=self._fmt(r), xalign=0.0,
|
||||||
|
selectable=True, wrap=True))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fmt(r: dict) -> str:
|
||||||
|
dst = r.get("dst", "?")
|
||||||
|
via = f" via {r['gateway']}" if r.get("gateway") else ""
|
||||||
|
dev = f" dev {r['dev']}" if r.get("dev") else ""
|
||||||
|
metric = f" metric {r['metric']}" if r.get("metric") is not None else ""
|
||||||
|
proto = f" ({r['proto']})" if r.get("proto") else ""
|
||||||
|
return f"{dst}{via}{dev}{metric}{proto}"
|
||||||
|
|
||||||
|
|
||||||
|
class _VlanPage(Gtk.Box):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
|
self._adapters: list[dict] = []
|
||||||
|
|
||||||
|
add = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||||
|
self._parent = Gtk.DropDown.new_from_strings(["…"])
|
||||||
|
self._vid = _field("VLAN ID")
|
||||||
|
self._vid.set_size_request(90, -1)
|
||||||
|
add.append(self._parent)
|
||||||
|
add.append(self._vid)
|
||||||
|
add.append(_pill("Add", self._add))
|
||||||
|
self.append(_labeled("New VLAN", add))
|
||||||
|
|
||||||
|
self._status = Gtk.Label(xalign=0.0)
|
||||||
|
self._status.add_css_class("net-ip")
|
||||||
|
self._status.set_visible(False)
|
||||||
|
self.append(self._status)
|
||||||
|
|
||||||
|
self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||||
|
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||||
|
scroller.set_child(self._list)
|
||||||
|
self.append(scroller)
|
||||||
|
|
||||||
|
_nm(["adapters"], self._set_adapters)
|
||||||
|
self._reload()
|
||||||
|
|
||||||
|
def _set_adapters(self, ok, adapters) -> None:
|
||||||
|
self._adapters = adapters if ok and isinstance(adapters, list) else []
|
||||||
|
names = [a["device"] for a in self._adapters] or ["(no devices)"]
|
||||||
|
self._parent.set_model(Gtk.StringList.new(names))
|
||||||
|
|
||||||
|
def _reload(self) -> None:
|
||||||
|
_nm(["vlans"], self._populate)
|
||||||
|
|
||||||
|
def _populate(self, ok, vlans) -> None:
|
||||||
|
_clear(self._list)
|
||||||
|
if not ok or not isinstance(vlans, list) or not vlans:
|
||||||
|
self._list.append(Gtk.Label(label="No VLANs configured", xalign=0.0))
|
||||||
|
return
|
||||||
|
for v in vlans:
|
||||||
|
self._list.append(self._vlan_row(v))
|
||||||
|
|
||||||
|
def _vlan_row(self, v: dict) -> Gtk.Widget:
|
||||||
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
row.add_css_class("net-row")
|
||||||
|
state = "active" if v.get("active") else "inactive"
|
||||||
|
row.append(Gtk.Label(label=f"{v['name']} · {v.get('device') or '—'} [{state}]",
|
||||||
|
xalign=0.0, hexpand=True))
|
||||||
|
if not v.get("active"):
|
||||||
|
row.append(_pill("Up", lambda n=v["name"]: self._act("vlan-up", n)))
|
||||||
|
row.append(_pill("Delete", lambda n=v["name"]: self._act("vlan-del", n)))
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _add(self) -> None:
|
||||||
|
idx = self._parent.get_selected()
|
||||||
|
vid = self._vid.get_text().strip()
|
||||||
|
if idx < 0 or idx >= len(self._adapters) or not vid.isdigit():
|
||||||
|
self._flash("Pick a parent device and numeric VLAN ID")
|
||||||
|
return
|
||||||
|
parent = self._adapters[idx]["device"]
|
||||||
|
_nm(["vlan-add", parent, vid], self._after_write)
|
||||||
|
|
||||||
|
def _act(self, cmd: str, name: str) -> None:
|
||||||
|
_nm([cmd, name], self._after_write)
|
||||||
|
|
||||||
|
def _after_write(self, ok, d) -> None:
|
||||||
|
good = ok and isinstance(d, dict) and d.get("ok")
|
||||||
|
self._flash("Done" if good else
|
||||||
|
f"Failed: {(d.get('error') if isinstance(d, dict) else '') or 'error'}")
|
||||||
|
self._reload()
|
||||||
|
|
||||||
|
def _flash(self, msg: str) -> None:
|
||||||
|
self._status.set_text(msg)
|
||||||
|
self._status.set_visible(True)
|
||||||
|
|
||||||
|
|
||||||
|
class _DnsPage(Gtk.Box):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
|
self._adapters: list[dict] = []
|
||||||
|
|
||||||
|
self.append(Gtk.Label(label="Effective servers", xalign=0.0))
|
||||||
|
self._effective = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||||
|
self.append(self._effective)
|
||||||
|
|
||||||
|
self.append(Gtk.Separator())
|
||||||
|
self.append(Gtk.Label(label="Override", xalign=0.0))
|
||||||
|
self._conn = Gtk.DropDown.new_from_strings(["…"])
|
||||||
|
self.append(_labeled("Connection", self._conn))
|
||||||
|
self._servers = _field("1.1.1.1 8.8.8.8")
|
||||||
|
self.append(_labeled("Servers", self._servers))
|
||||||
|
ignore = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
ignore.append(Gtk.Label(label="Ignore automatic", hexpand=True, xalign=0.0))
|
||||||
|
self._ignore = Gtk.Switch(active=True, valign=Gtk.Align.CENTER)
|
||||||
|
ignore.append(self._ignore)
|
||||||
|
self.append(ignore)
|
||||||
|
self.append(_pill("Apply DNS", self._apply))
|
||||||
|
self._status = Gtk.Label(xalign=0.0)
|
||||||
|
self._status.add_css_class("net-ip")
|
||||||
|
self._status.set_visible(False)
|
||||||
|
self.append(self._status)
|
||||||
|
|
||||||
|
_nm(["adapters"], self._set_adapters)
|
||||||
|
_nm(["dns"], self._show_effective)
|
||||||
|
|
||||||
|
def _set_adapters(self, ok, adapters) -> None:
|
||||||
|
self._adapters = [a for a in (adapters or []) if a.get("connection")] \
|
||||||
|
if ok and isinstance(adapters, list) else []
|
||||||
|
names = [f"{a['connection']} ({a['device']})" for a in self._adapters] or ["(none)"]
|
||||||
|
self._conn.set_model(Gtk.StringList.new(names))
|
||||||
|
|
||||||
|
def _show_effective(self, ok, data) -> None:
|
||||||
|
_clear(self._effective)
|
||||||
|
if not ok or not isinstance(data, list) or not data:
|
||||||
|
self._effective.append(Gtk.Label(label="—", xalign=0.0))
|
||||||
|
return
|
||||||
|
for d in data:
|
||||||
|
self._effective.append(Gtk.Label(
|
||||||
|
label=f"{d['device']}: {', '.join(d.get('servers', []))}",
|
||||||
|
xalign=0.0, selectable=True))
|
||||||
|
|
||||||
|
def _apply(self) -> None:
|
||||||
|
idx = self._conn.get_selected()
|
||||||
|
if idx < 0 or idx >= len(self._adapters):
|
||||||
|
self._flash("Pick a connection")
|
||||||
|
return
|
||||||
|
con = self._adapters[idx]["connection"]
|
||||||
|
servers = self._servers.get_text().strip()
|
||||||
|
ign = "yes" if self._ignore.get_active() else "no"
|
||||||
|
_nm(["set-dns", con, "ipv4", servers, ign], self._after)
|
||||||
|
|
||||||
|
def _after(self, ok, d) -> None:
|
||||||
|
good = ok and isinstance(d, dict) and d.get("ok")
|
||||||
|
self._flash("Applied" if good else
|
||||||
|
f"Failed: {(d.get('error') if isinstance(d, dict) else '') or 'error'}")
|
||||||
|
_nm(["dns"], self._show_effective)
|
||||||
|
|
||||||
|
def _flash(self, msg: str) -> None:
|
||||||
|
self._status.set_text(msg)
|
||||||
|
self._status.set_visible(True)
|
||||||
|
|
||||||
|
|
||||||
|
class _Expanded(Gtk.Box):
|
||||||
|
def __init__(self, ctx: ModuleContext):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
|
self.add_css_class("net-view")
|
||||||
|
self.ctx = ctx
|
||||||
|
|
||||||
|
self.stack = Gtk.Stack(vexpand=True)
|
||||||
|
switcher = Gtk.StackSwitcher(stack=self.stack)
|
||||||
|
switcher.add_css_class("net-switcher")
|
||||||
|
self.append(switcher)
|
||||||
|
self.append(self.stack)
|
||||||
|
|
||||||
|
f = ctx.feature
|
||||||
|
if f("adapters", True):
|
||||||
|
self.stack.add_titled(_AdaptersPage(), "adapters", "Adapters")
|
||||||
|
if f("routes", True):
|
||||||
|
self.stack.add_titled(_RoutesPage(), "routes", "Routes")
|
||||||
|
if f("vlan", True):
|
||||||
|
self.stack.add_titled(_VlanPage(), "vlan", "VLAN")
|
||||||
|
if f("dns", True):
|
||||||
|
self.stack.add_titled(_DnsPage(), "dns", "DNS")
|
||||||
|
|
||||||
|
|
||||||
|
def build(ctx: ModuleContext) -> ModuleInstance:
|
||||||
|
# Feature toggles rebuild the whole card via QuadCard, so _Expanded is recreated
|
||||||
|
# with the enabled tabs — no in-place page rebuild needed here.
|
||||||
|
compact = _Compact(ctx)
|
||||||
|
expanded = _Expanded(ctx)
|
||||||
|
return ModuleInstance(compact=compact, expanded=expanded)
|
||||||
|
|
||||||
|
|
||||||
|
SPEC = ModuleSpec(
|
||||||
|
id="network",
|
||||||
|
title="Network",
|
||||||
|
icon="", # nf-md-lan
|
||||||
|
build=build,
|
||||||
|
default_enabled=True,
|
||||||
|
features=[Feature("adapters", "Adapters", True),
|
||||||
|
Feature("routes", "Routes", True),
|
||||||
|
Feature("vlan", "VLAN", True),
|
||||||
|
Feature("dns", "DNS", True)],
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""Weather quad: wttr.in rendered with its original ANSI/CLI art (via AnsiRenderer).
|
||||||
|
|
||||||
|
Reuses the shared LocationService for the city; if none is known yet, wttr.in
|
||||||
|
geolocates the caller's IP itself, so the widget still works standalone. The
|
||||||
|
'ascii_art' feature toggle swaps the art for a compact one-line text summary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gtk # noqa: E402
|
||||||
|
|
||||||
|
from lib.ansi import AnsiRenderer
|
||||||
|
from lib.proc import run_text
|
||||||
|
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
|
||||||
|
from paths import BACKEND_DIR
|
||||||
|
|
||||||
|
_SCRIPT = str(BACKEND_DIR / "weather.sh")
|
||||||
|
|
||||||
|
|
||||||
|
class _WeatherView(Gtk.Box):
|
||||||
|
def __init__(self, ctx: ModuleContext, opts: str):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
self.add_css_class("weather-view")
|
||||||
|
self.ctx = ctx
|
||||||
|
self.opts = opts
|
||||||
|
self._loc = ""
|
||||||
|
|
||||||
|
self.renderer = AnsiRenderer()
|
||||||
|
self.append(self.renderer.view)
|
||||||
|
self._status = Gtk.Label(label="Loading weather…")
|
||||||
|
self._status.add_css_class("weather-status")
|
||||||
|
self.append(self._status)
|
||||||
|
|
||||||
|
ctx.services.location.subscribe(self._on_location)
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def _on_location(self, data: dict) -> None:
|
||||||
|
self._loc = data.get("city") or ""
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
art = self.ctx.feature("ascii_art", True)
|
||||||
|
opts = self.opts if art else "format=%l:+%c+%t,+%w"
|
||||||
|
run_text([_SCRIPT, self._loc, opts], self._on_result)
|
||||||
|
|
||||||
|
def _on_result(self, ok: bool, out: str, err: str) -> None:
|
||||||
|
if ok and out.strip() and "<html" not in out.lower():
|
||||||
|
self.renderer.set_text(out.rstrip("\n"))
|
||||||
|
self._status.set_visible(False)
|
||||||
|
else:
|
||||||
|
self._status.set_text("Weather unavailable")
|
||||||
|
self._status.set_visible(True)
|
||||||
|
|
||||||
|
|
||||||
|
def build(ctx: ModuleContext) -> ModuleInstance:
|
||||||
|
# The ascii_art toggle rebuilds the whole card via QuadCard, so both views are
|
||||||
|
# recreated and re-fetch with the current format — no partial refresh here.
|
||||||
|
compact = _WeatherView(ctx, opts="0") # current conditions only
|
||||||
|
expanded = _WeatherView(ctx, opts="") # full 3-day forecast
|
||||||
|
return ModuleInstance(compact=compact, expanded=expanded)
|
||||||
|
|
||||||
|
|
||||||
|
SPEC = ModuleSpec(
|
||||||
|
id="weather",
|
||||||
|
title="Weather",
|
||||||
|
icon="", # nf-weather
|
||||||
|
build=build,
|
||||||
|
features=[Feature("ascii_art", "CLI art", True)],
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""Shared filesystem locations. Works whether run from the repo or ~/.config."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
BACKEND_DIR = BASE_DIR / "backend"
|
||||||
|
STYLE_DIR = BASE_DIR / "style"
|
||||||
|
|
||||||
|
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "astro-menu"
|
||||||
|
CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astro-menu"
|
||||||
|
# User settings live under XDG_STATE_HOME, NOT CONFIG_DIR: the config-updater does
|
||||||
|
# `rm -rf ~/.config/astro-menu` on every deploy, which would wipe pinned favourites
|
||||||
|
# and quad toggles. The state dir is never touched by config sync.
|
||||||
|
STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "astro-menu"
|
||||||
|
|
||||||
|
SETTINGS_FILE = STATE_DIR / "settings.json"
|
||||||
|
CONFIG_FILE = STATE_DIR / "config.json"
|
||||||
|
|
||||||
|
APP_ID = "eu.abdelbaki.astromenu"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dirs() -> None:
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""The module registry — the single place that lists available quads in order.
|
||||||
|
|
||||||
|
To add a new module: create modules/<name>.py exposing a top-level `SPEC`
|
||||||
|
(ModuleSpec), then import it and append its SPEC here. The first four *enabled*
|
||||||
|
specs fill the 2x2 grid; extra specs are kept for future paginated layouts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from modules import bluetooth, location, network, weather
|
||||||
|
from module_base import ModuleSpec
|
||||||
|
|
||||||
|
ALL_SPECS: list[ModuleSpec] = [
|
||||||
|
location.SPEC,
|
||||||
|
weather.SPEC,
|
||||||
|
bluetooth.SPEC,
|
||||||
|
network.SPEC,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ordered_specs(settings) -> list[ModuleSpec]:
|
||||||
|
"""Return specs in the user's saved order, unknown/new ones appended."""
|
||||||
|
order = settings.order()
|
||||||
|
if not order:
|
||||||
|
return list(ALL_SPECS)
|
||||||
|
by_id = {s.id: s for s in ALL_SPECS}
|
||||||
|
result = [by_id[i] for i in order if i in by_id]
|
||||||
|
result += [s for s in ALL_SPECS if s.id not in order]
|
||||||
|
return result
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""Shared geolocation singleton.
|
||||||
|
|
||||||
|
Runs backend/geolocate.py (IP geolocation, cached) exactly once and notifies
|
||||||
|
subscribers. Both the Location map and the Weather widget subscribe, so there is a
|
||||||
|
single source of truth for "where am I" and only one network lookup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from lib.proc import run_json
|
||||||
|
from paths import BACKEND_DIR
|
||||||
|
|
||||||
|
|
||||||
|
class LocationService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.data: Optional[dict] = None
|
||||||
|
self._subs: list[Callable[[dict], None]] = []
|
||||||
|
self._inflight = False
|
||||||
|
|
||||||
|
def subscribe(self, cb: Callable[[dict], None]) -> None:
|
||||||
|
self._subs.append(cb)
|
||||||
|
if self.data is not None:
|
||||||
|
cb(self.data)
|
||||||
|
|
||||||
|
def get(self) -> Optional[dict]:
|
||||||
|
if self.data is None and not self._inflight:
|
||||||
|
self.refresh()
|
||||||
|
return self.data
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
if self._inflight:
|
||||||
|
return
|
||||||
|
self._inflight = True
|
||||||
|
argv = [sys.executable, str(BACKEND_DIR / "geolocate.py")]
|
||||||
|
run_json(argv, self._on_result)
|
||||||
|
|
||||||
|
def _on_result(self, ok: bool, data) -> None:
|
||||||
|
self._inflight = False
|
||||||
|
if ok and isinstance(data, dict) and "lat" in data:
|
||||||
|
self.data = data
|
||||||
|
for cb in list(self._subs):
|
||||||
|
cb(data)
|
||||||
|
|
||||||
|
|
||||||
|
_instance: LocationService | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_location_service() -> LocationService:
|
||||||
|
global _instance
|
||||||
|
if _instance is None:
|
||||||
|
_instance = LocationService()
|
||||||
|
return _instance
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
"""Persisted user settings: which quads are enabled, per-module feature toggles,
|
||||||
|
and quad ordering. Single JSON file, read at startup, written on every change.
|
||||||
|
|
||||||
|
Modules never import this directly for their own feature flags; they receive a
|
||||||
|
scoped view via ctx.feature(...) so the persistence format stays centralised.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from paths import CONFIG_DIR, SETTINGS_FILE, ensure_dirs
|
||||||
|
|
||||||
|
|
||||||
|
class Settings:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._data: dict = {"quads": {}, "features": {}, "order": None, "favorites": []}
|
||||||
|
self._listeners: list[Callable[[], None]] = []
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
# -- persistence -------------------------------------------------------
|
||||||
|
def load(self) -> None:
|
||||||
|
path = SETTINGS_FILE
|
||||||
|
if not path.exists():
|
||||||
|
# one-time migration from the old CONFIG_DIR location (pre state-dir)
|
||||||
|
legacy = CONFIG_DIR / "settings.json"
|
||||||
|
if legacy.exists():
|
||||||
|
path = legacy
|
||||||
|
try:
|
||||||
|
self._data.update(json.loads(path.read_text()))
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
ensure_dirs()
|
||||||
|
SETTINGS_FILE.write_text(json.dumps(self._data, indent=2))
|
||||||
|
for cb in list(self._listeners):
|
||||||
|
cb()
|
||||||
|
|
||||||
|
def subscribe(self, cb: Callable[[], None]) -> None:
|
||||||
|
self._listeners.append(cb)
|
||||||
|
|
||||||
|
# -- quad enable/disable ----------------------------------------------
|
||||||
|
def quad_enabled(self, module_id: str, default: bool = True) -> bool:
|
||||||
|
return bool(self._data["quads"].get(module_id, default))
|
||||||
|
|
||||||
|
def set_quad_enabled(self, module_id: str, value: bool) -> None:
|
||||||
|
self._data["quads"][module_id] = bool(value)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# -- per-module feature toggles ---------------------------------------
|
||||||
|
def feature(self, module_id: str, feature_id: str, default: bool = True) -> bool:
|
||||||
|
return bool(self._data["features"].get(module_id, {}).get(feature_id, default))
|
||||||
|
|
||||||
|
def set_feature(self, module_id: str, feature_id: str, value: bool) -> None:
|
||||||
|
self._data["features"].setdefault(module_id, {})[feature_id] = bool(value)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# -- favorites ---------------------------------------------------------
|
||||||
|
def favorites(self) -> list[str]:
|
||||||
|
return list(self._data.get("favorites", []))
|
||||||
|
|
||||||
|
def is_favorite(self, entry: str) -> bool:
|
||||||
|
return entry in self._data.get("favorites", [])
|
||||||
|
|
||||||
|
def toggle_favorite(self, entry: str) -> None:
|
||||||
|
if not entry:
|
||||||
|
return
|
||||||
|
favs = self._data.setdefault("favorites", [])
|
||||||
|
if entry in favs:
|
||||||
|
favs.remove(entry)
|
||||||
|
else:
|
||||||
|
favs.append(entry)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# -- ordering ----------------------------------------------------------
|
||||||
|
def order(self) -> list[str] | None:
|
||||||
|
return self._data.get("order")
|
||||||
|
|
||||||
|
def set_order(self, order: list[str]) -> None:
|
||||||
|
self._data["order"] = order
|
||||||
|
self.save()
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the
|
||||||
|
* hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the
|
||||||
|
* sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */
|
||||||
|
@define-color text #D6ABAB;
|
||||||
|
@define-color bg #1A1A1A;
|
||||||
|
@define-color accent #E40046;
|
||||||
|
@define-color violet #5018DD;
|
||||||
|
@define-color danger #F50505;
|
||||||
|
|
@ -0,0 +1,332 @@
|
||||||
|
/* astro-menu — "hologram info display" theme. Colours come from _colors.css
|
||||||
|
* (@text/@bg/@accent/@violet/@danger). Mirrors the existing bar idiom: Agave
|
||||||
|
* Nerd Font Mono, 3px borders, ~25px pill radii. Touch-friendly hit targets
|
||||||
|
* throughout. Module card borders get a Cairo-drawn glow (lib/border.py);
|
||||||
|
* this file adds matching CSS box-shadow glow + smooth transitions on every
|
||||||
|
* interactive element, and a scanline/sweep/noise hologram overlay (see
|
||||||
|
* .astro-hologram, painted by window.py) so the whole panel reads as one
|
||||||
|
* continuous piece with orbit-menu and horizon-dock. */
|
||||||
|
|
||||||
|
* {
|
||||||
|
font-family: "Agave Nerd Font Mono", monospace;
|
||||||
|
font-size: 13pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- window / letterbox --------------------------------------------- */
|
||||||
|
/* The CyberQueer GTK theme paints `* { background-color: #1a1a1a }` on EVERY node,
|
||||||
|
* which fills the gaps between modules with a solid slab. Each module carries its
|
||||||
|
* own drawn (Cairo) card background via bordered(fill_bg=True), so blank the
|
||||||
|
* structural container nodes here to let the desktop show between the floating
|
||||||
|
* modules. `drawingarea` is the bordered() ring canvas — transparent so only its
|
||||||
|
* Cairo-drawn rounded fill shows, not a full-bleed square. */
|
||||||
|
window,
|
||||||
|
window.background,
|
||||||
|
.menu-window,
|
||||||
|
#menu-window,
|
||||||
|
.panel,
|
||||||
|
#panel-root,
|
||||||
|
overlay,
|
||||||
|
revealer,
|
||||||
|
grid,
|
||||||
|
scrolledwindow,
|
||||||
|
viewport,
|
||||||
|
drawingarea {
|
||||||
|
background: transparent;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#backdrop {
|
||||||
|
background: alpha(black, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Letterbox margins are set in code (window.py) as a fraction of the monitor so
|
||||||
|
* the inset scales per display; this box just carries the panel background. */
|
||||||
|
|
||||||
|
/* ---- quad grid + cards ---------------------------------------------- */
|
||||||
|
.quad-grid.dimmed {
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* bouncy pop when a quad expands (added in ui/quadgrid.py on expand) */
|
||||||
|
@keyframes quad-pop {
|
||||||
|
0% { transform: scale(0.82); opacity: 0.4; }
|
||||||
|
55% { transform: scale(1.05); opacity: 1; }
|
||||||
|
78% { transform: scale(0.985); }
|
||||||
|
100% { transform: scale(1.0); }
|
||||||
|
}
|
||||||
|
.quad-pop { animation: quad-pop 300ms ease-out; }
|
||||||
|
|
||||||
|
/* bouncy pop-expand for the app drawer (added in ui/appdrawer.py on expand) */
|
||||||
|
@keyframes drawer-pop {
|
||||||
|
0% { transform: scale(0.90); opacity: 0.45; }
|
||||||
|
60% { transform: scale(1.025); opacity: 1; }
|
||||||
|
82% { transform: scale(0.995); }
|
||||||
|
100% { transform: scale(1.0); }
|
||||||
|
}
|
||||||
|
.drawer-pop { animation: drawer-pop 280ms ease-out; }
|
||||||
|
|
||||||
|
.quad-card { min-height: 100px; }
|
||||||
|
.section-title { color: @text; font-weight: bold; opacity: 0.85; }
|
||||||
|
|
||||||
|
/* The single module border + background is drawn with Cairo by bordered(fill_bg=True)
|
||||||
|
* (see lib/border.py); the CSS border/background here would render a second,
|
||||||
|
* concentric ring now that the app stylesheet sits above the theme, so only keep
|
||||||
|
* the inner content padding. */
|
||||||
|
.quad-card,
|
||||||
|
.quad-expanded,
|
||||||
|
.appdrawer,
|
||||||
|
.taskbar,
|
||||||
|
.favorites {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quad-header,
|
||||||
|
.appdrawer-header,
|
||||||
|
.expanded-header {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quad-title { color: @text; font-weight: bold; }
|
||||||
|
.quad-icon { color: @accent; font-size: 15pt; }
|
||||||
|
|
||||||
|
/* minimalist system-stats line (top of the panel) */
|
||||||
|
.statsbar { padding: 19px 8px; }
|
||||||
|
.statsbar .stat-icon { color: @accent; font-size: 11pt; }
|
||||||
|
.statsbar .stat-value { color: @text; font-size: 10pt; }
|
||||||
|
|
||||||
|
.quad-body { color: @text; }
|
||||||
|
|
||||||
|
/* pill action buttons */
|
||||||
|
.quad-action,
|
||||||
|
.enable-btn {
|
||||||
|
color: @text;
|
||||||
|
background: alpha(@violet, 0.4);
|
||||||
|
border: 3px solid @violet;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 1px 14px; /* tight vertically so pills don't waste vertical space */
|
||||||
|
min-height: 22px;
|
||||||
|
min-width: 22px;
|
||||||
|
transition: border-color 180ms ease, color 180ms ease, box-shadow 220ms ease, background 180ms ease;
|
||||||
|
box-shadow: 0 0 0 0 alpha(@accent, 0);
|
||||||
|
}
|
||||||
|
.quad-action:hover,
|
||||||
|
.enable-btn:hover {
|
||||||
|
border-color: @accent;
|
||||||
|
color: @accent;
|
||||||
|
box-shadow: 0 0 12px 1px alpha(@accent, 0.55);
|
||||||
|
}
|
||||||
|
.quad-action:active,
|
||||||
|
button:checked.quad-action { background: @accent; color: @bg; border-color: @accent; }
|
||||||
|
|
||||||
|
/* Columns count stepper value (between the − / + pills) */
|
||||||
|
.stepper-value {
|
||||||
|
min-width: 34px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: @accent;
|
||||||
|
font-feature-settings: "tnum";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A MenuButton (the settings cog) wraps an inner `button` node that would render a
|
||||||
|
* second ring inside the outer .quad-action pill — flatten it so only one border
|
||||||
|
* shows. */
|
||||||
|
.quad-action > button {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quad-disabled { color: @text; opacity: 0.7; }
|
||||||
|
.enable-btn { border-color: @accent; }
|
||||||
|
|
||||||
|
/* settings popover */
|
||||||
|
.quad-settings { background: alpha(@violet, 0.4); border: 3px solid @violet; border-radius: 16px; padding: 10px; }
|
||||||
|
.switch-row { min-height: 36px; }
|
||||||
|
|
||||||
|
.expanded-header .quad-title { font-size: 14pt; }
|
||||||
|
|
||||||
|
/* ---- appdrawer ------------------------------------------------------ */
|
||||||
|
.appdrawer-search {
|
||||||
|
border: 3px solid @violet;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: @text;
|
||||||
|
background: alpha(@violet, 0.4);
|
||||||
|
transition: border-color 180ms ease, box-shadow 220ms ease;
|
||||||
|
box-shadow: 0 0 0 0 alpha(@accent, 0);
|
||||||
|
}
|
||||||
|
.appdrawer-search:focus-within {
|
||||||
|
border-color: @accent;
|
||||||
|
box-shadow: 0 0 14px 1px alpha(@accent, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
min-width: 92px;
|
||||||
|
transition: border-color 180ms ease, background 180ms ease, box-shadow 220ms ease;
|
||||||
|
box-shadow: 0 0 0 0 alpha(@accent, 0);
|
||||||
|
}
|
||||||
|
.app-tile:hover {
|
||||||
|
border-color: @accent;
|
||||||
|
background: alpha(@violet, 0.18);
|
||||||
|
box-shadow: 0 0 14px 1px alpha(@accent, 0.4);
|
||||||
|
}
|
||||||
|
.app-tile label { color: @text; font-size: 10pt; }
|
||||||
|
.fav-star { color: @accent; font-size: 13pt; margin: 2px 4px; }
|
||||||
|
|
||||||
|
/* ---- favourites row (top of drawer) --------------------------------- */
|
||||||
|
.favorites-row { padding: 4px 0; }
|
||||||
|
.fav-tile {
|
||||||
|
background: alpha(@violet, 0.4);
|
||||||
|
border: 2px solid @violet;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
min-height: 40px;
|
||||||
|
transition: border-color 180ms ease, box-shadow 220ms ease;
|
||||||
|
box-shadow: 0 0 0 0 alpha(@violet, 0);
|
||||||
|
}
|
||||||
|
.fav-tile:hover {
|
||||||
|
border-color: @accent;
|
||||||
|
box-shadow: 0 0 12px 1px alpha(@accent, 0.5);
|
||||||
|
}
|
||||||
|
.fav-tile label { color: @text; font-size: 11pt; }
|
||||||
|
|
||||||
|
/* ---- taskbar (open windows) ----------------------------------------- */
|
||||||
|
.taskbar-row { padding: 4px 0; }
|
||||||
|
.task-tile {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
transition: border-color 180ms ease, background 180ms ease, box-shadow 220ms ease;
|
||||||
|
box-shadow: 0 0 0 0 alpha(@accent, 0);
|
||||||
|
}
|
||||||
|
.task-tile:hover {
|
||||||
|
border-color: @accent;
|
||||||
|
background: alpha(@violet, 0.18);
|
||||||
|
box-shadow: 0 0 14px 1px alpha(@accent, 0.4);
|
||||||
|
}
|
||||||
|
.task-badge {
|
||||||
|
color: @bg; background: @accent;
|
||||||
|
border-radius: 10px; padding: 0 6px; font-size: 9pt;
|
||||||
|
}
|
||||||
|
.task-popover { background: alpha(@violet, 0.4); border: 3px solid @violet; border-radius: 14px; padding: 6px; }
|
||||||
|
.task-window { color: @text; background: transparent; border-radius: 10px; padding: 6px 12px; }
|
||||||
|
.task-window:hover { color: @accent; }
|
||||||
|
|
||||||
|
/* pop-open panel: layout controls + per-window rows */
|
||||||
|
.task-panel { padding: 4px 2px; }
|
||||||
|
.layout-page { padding: 6px 4px 2px 4px; }
|
||||||
|
.task-row { padding: 2px 4px; border-radius: 10px; min-height: 30px; }
|
||||||
|
.task-row:hover { background: alpha(@violet, 0.12); }
|
||||||
|
.task-name { background: transparent; border: none; padding: 2px 6px; min-height: 26px; }
|
||||||
|
.task-name:hover { color: @accent; }
|
||||||
|
.task-name label { color: @text; }
|
||||||
|
|
||||||
|
/* ---- location map --------------------------------------------------- */
|
||||||
|
.map-view { border-radius: 14px; }
|
||||||
|
.map-info { color: @text; padding: 6px 2px 0 2px; font-size: 11pt; }
|
||||||
|
.map-marker { color: @accent; }
|
||||||
|
|
||||||
|
/* ---- weather -------------------------------------------------------- */
|
||||||
|
.ansi-view,
|
||||||
|
.ansi-view text {
|
||||||
|
background: alpha(@violet, 0.4);
|
||||||
|
color: @text;
|
||||||
|
font-family: "Agave Nerd Font Mono", monospace;
|
||||||
|
font-size: 11pt;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.weather-status { color: @text; opacity: 0.7; }
|
||||||
|
|
||||||
|
/* ---- bluetooth / network shared rows -------------------------------- */
|
||||||
|
.bt-row,
|
||||||
|
.net-row {
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 12px;
|
||||||
|
min-height: 30px;
|
||||||
|
transition: background 180ms ease;
|
||||||
|
}
|
||||||
|
.bt-row:hover,
|
||||||
|
.net-row:hover { background: alpha(@violet, 0.15); }
|
||||||
|
.bt-status { color: @accent; font-size: 10pt; }
|
||||||
|
.bt-status.bt-failed { color: @danger; }
|
||||||
|
.bt-history label,
|
||||||
|
.net-ip { color: @text; opacity: 0.85; font-size: 11pt; }
|
||||||
|
|
||||||
|
.net-switcher { margin-bottom: 8px; }
|
||||||
|
.net-adapter { padding: 4px 0; }
|
||||||
|
.net-adapter > box { padding-left: 6px; }
|
||||||
|
.net-entry {
|
||||||
|
border: 2px solid @violet;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: @text;
|
||||||
|
background: alpha(@violet, 0.4);
|
||||||
|
transition: border-color 180ms ease, box-shadow 220ms ease;
|
||||||
|
box-shadow: 0 0 0 0 alpha(@accent, 0);
|
||||||
|
}
|
||||||
|
.net-entry:focus-within {
|
||||||
|
border-color: @accent;
|
||||||
|
box-shadow: 0 0 12px 1px alpha(@accent, 0.45);
|
||||||
|
}
|
||||||
|
.pubip { color: @accent; font-size: 15pt; }
|
||||||
|
|
||||||
|
/* switches: fixed pill with a bordered round knob (the reset theme leaves them
|
||||||
|
* shapeless otherwise) */
|
||||||
|
switch {
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 26px;
|
||||||
|
border: 2px solid @violet;
|
||||||
|
border-radius: 15px;
|
||||||
|
background: alpha(@violet, 0.4);
|
||||||
|
padding: 0;
|
||||||
|
transition: background 180ms ease, border-color 180ms ease, box-shadow 220ms ease;
|
||||||
|
}
|
||||||
|
switch:checked {
|
||||||
|
background: @accent;
|
||||||
|
border-color: @accent;
|
||||||
|
box-shadow: 0 0 10px 1px alpha(@accent, 0.5);
|
||||||
|
}
|
||||||
|
switch > slider {
|
||||||
|
min-width: 18px;
|
||||||
|
min-height: 18px;
|
||||||
|
margin: 2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid @violet;
|
||||||
|
background: @text;
|
||||||
|
}
|
||||||
|
switch:checked > slider { border-color: @accent; }
|
||||||
|
scrollbar slider { background: @violet; border-radius: 8px; min-width: 6px; }
|
||||||
|
scrollbar slider:hover { background: @accent; }
|
||||||
|
|
||||||
|
/* module frames — Gtk.Frame paints borders reliably where boxes do not */
|
||||||
|
.module-frame { border: 3px solid @accent; border-radius: 16px; background: alpha(@violet, 0.4); }
|
||||||
|
.module-frame > box { padding: 10px 12px; }
|
||||||
|
|
||||||
|
/* floating panel + close button */
|
||||||
|
.panel { padding: 2px; }
|
||||||
|
.close-btn {
|
||||||
|
color: @text; background: alpha(@violet, 0.4);
|
||||||
|
border: none; border-radius: 20px;
|
||||||
|
min-width: 34px; min-height: 34px;
|
||||||
|
margin: 16px 20px; /* keep it clear of the module's drawn border */
|
||||||
|
transition: background 180ms ease, color 180ms ease, box-shadow 220ms ease;
|
||||||
|
}
|
||||||
|
.close-btn:hover {
|
||||||
|
background: @accent;
|
||||||
|
color: @bg;
|
||||||
|
box-shadow: 0 0 14px 2px alpha(@accent, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- holographic overlay (see window.py's Gtk.Overlay + _draw_hologram) --- */
|
||||||
|
.astro-hologram {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Load the two stylesheets as ordered CSS providers.
|
||||||
|
|
||||||
|
_colors.css (generated from ~/Dotfiles/colors.conf by apply-theme.sh) defines the
|
||||||
|
five CyberQueer @define-color names; style.css consumes them. They are loaded as
|
||||||
|
two separate providers rather than via @import, because GTK4 resolves @import
|
||||||
|
paths unreliably.
|
||||||
|
|
||||||
|
Priority is USER+1, not APPLICATION: the CyberQueer GTK theme is installed as
|
||||||
|
~/.config/gtk-4.0/gtk.css (a symlink), which GTK4 loads at PRIORITY_USER (800) —
|
||||||
|
above APPLICATION (600). Its aggressive `* { background-color: #1a1a1a }` would
|
||||||
|
otherwise beat our rules (e.g. the transparent structural containers that let the
|
||||||
|
modules float), so we must sit just above the user-level theme.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gdk, Gtk # noqa: E402
|
||||||
|
|
||||||
|
from paths import STYLE_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def load_css() -> None:
|
||||||
|
display = Gdk.Display.get_default()
|
||||||
|
for name in ("_colors.css", "style.css"):
|
||||||
|
path = STYLE_DIR / name
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
provider = Gtk.CssProvider()
|
||||||
|
provider.load_from_path(str(path))
|
||||||
|
Gtk.StyleContext.add_provider_for_display(
|
||||||
|
display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
"""Section 5: the full-width application drawer that replaces nwg-drawer.
|
||||||
|
|
||||||
|
Top to bottom: a favourites row (full-width module), then search, then a FlowBox of
|
||||||
|
all apps. Collapsed it is a bottom strip; expanded it fills down to the bottom.
|
||||||
|
Right-click / long-press an app tile to pin or unpin it from favourites.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
gi.require_version("AstalApps", "0.1")
|
||||||
|
from gi.repository import AstalApps, GLib, Gtk # noqa: E402
|
||||||
|
|
||||||
|
from ui.favorites import Favorites, add_pin_gestures
|
||||||
|
|
||||||
|
_STRIP_HEIGHT = 150 # collapsed grid height (logical px)
|
||||||
|
|
||||||
|
|
||||||
|
class AppDrawer(Gtk.Box):
|
||||||
|
def __init__(self, settings, on_launch, on_toggle_expand):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
|
self.add_css_class("appdrawer")
|
||||||
|
self.settings = settings
|
||||||
|
self._on_launch = on_launch
|
||||||
|
self._on_toggle_expand = on_toggle_expand
|
||||||
|
self._apps = AstalApps.Apps()
|
||||||
|
self._expanded = False
|
||||||
|
|
||||||
|
self.favorites = Favorites(settings, on_launch)
|
||||||
|
self.append(self.favorites)
|
||||||
|
self.append(Gtk.Separator())
|
||||||
|
|
||||||
|
self.append(self._build_header())
|
||||||
|
|
||||||
|
self._search = Gtk.SearchEntry(placeholder_text="Search applications")
|
||||||
|
self._search.add_css_class("appdrawer-search")
|
||||||
|
self._search.connect("search-changed", self._on_search)
|
||||||
|
self.append(self._search)
|
||||||
|
|
||||||
|
self._flow = Gtk.FlowBox(
|
||||||
|
selection_mode=Gtk.SelectionMode.NONE, homogeneous=True,
|
||||||
|
min_children_per_line=4, max_children_per_line=12,
|
||||||
|
row_spacing=8, column_spacing=8,
|
||||||
|
valign=Gtk.Align.START) # keep rows their natural height (no stretch)
|
||||||
|
self._flow.add_css_class("appdrawer-flow")
|
||||||
|
self._flow.connect("child-activated", self._on_child_activated)
|
||||||
|
|
||||||
|
self._scroll = Gtk.ScrolledWindow(
|
||||||
|
hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True)
|
||||||
|
self._scroll.set_propagate_natural_height(False)
|
||||||
|
self._scroll.set_child(self._flow)
|
||||||
|
self.append(self._scroll)
|
||||||
|
|
||||||
|
self._apply_mode()
|
||||||
|
self._populate(self._sorted_all())
|
||||||
|
# re-render tiles when favourites change so the pinned star stays in sync
|
||||||
|
settings.subscribe(self._on_settings_changed)
|
||||||
|
|
||||||
|
def _on_settings_changed(self) -> None:
|
||||||
|
# Deferred so we never rebuild the FlowBox from inside a tile's own gesture.
|
||||||
|
GLib.idle_add(lambda: (self._on_search(self._search), False)[1])
|
||||||
|
|
||||||
|
def _build_header(self) -> Gtk.Widget:
|
||||||
|
header = Gtk.CenterBox()
|
||||||
|
header.add_css_class("appdrawer-header")
|
||||||
|
title = Gtk.Label(label="Applications", xalign=0.0)
|
||||||
|
title.add_css_class("section-title")
|
||||||
|
header.set_start_widget(title)
|
||||||
|
self._expand_btn = Gtk.Button(label="") # nf-fa-expand (uniform with quad expand)
|
||||||
|
self._expand_btn.add_css_class("quad-action")
|
||||||
|
self._expand_btn.set_tooltip_text("Expand")
|
||||||
|
self._expand_btn.connect("clicked", lambda *_: self._toggle_expand())
|
||||||
|
header.set_end_widget(self._expand_btn)
|
||||||
|
return header
|
||||||
|
|
||||||
|
# -- expansion ---------------------------------------------------------
|
||||||
|
def _apply_mode(self) -> None:
|
||||||
|
if self._expanded:
|
||||||
|
self.set_vexpand(True)
|
||||||
|
self._scroll.set_min_content_height(_STRIP_HEIGHT)
|
||||||
|
self._scroll.set_max_content_height(100000)
|
||||||
|
self._expand_btn.set_label("") # nf-fa-compress
|
||||||
|
else:
|
||||||
|
self.set_vexpand(False)
|
||||||
|
self._scroll.set_min_content_height(_STRIP_HEIGHT)
|
||||||
|
self._scroll.set_max_content_height(_STRIP_HEIGHT)
|
||||||
|
self._expand_btn.set_label("") # nf-fa-expand
|
||||||
|
|
||||||
|
def _pop(self) -> None:
|
||||||
|
# bouncy scale pop on the app grid when the drawer expands; cleared after the
|
||||||
|
# animation so a re-layout (tiles loading) can't restart it into a loop.
|
||||||
|
self._scroll.add_css_class("drawer-pop")
|
||||||
|
GLib.timeout_add(320, self._clear_pop)
|
||||||
|
|
||||||
|
def _clear_pop(self) -> bool:
|
||||||
|
self._scroll.remove_css_class("drawer-pop")
|
||||||
|
return GLib.SOURCE_REMOVE
|
||||||
|
|
||||||
|
def _toggle_expand(self) -> None:
|
||||||
|
self._expanded = not self._expanded
|
||||||
|
self._apply_mode()
|
||||||
|
if self._expanded:
|
||||||
|
self._pop()
|
||||||
|
self._on_toggle_expand(self._expanded)
|
||||||
|
|
||||||
|
def set_expanded(self, value: bool) -> None:
|
||||||
|
if value != self._expanded:
|
||||||
|
self._toggle_expand()
|
||||||
|
|
||||||
|
# -- data --------------------------------------------------------------
|
||||||
|
def _sorted_all(self) -> list:
|
||||||
|
apps = list(self._apps.get_list())
|
||||||
|
apps.sort(key=lambda a: (-a.get_frequency(), a.get_name().lower()))
|
||||||
|
return apps
|
||||||
|
|
||||||
|
def _on_search(self, entry: Gtk.SearchEntry) -> None:
|
||||||
|
text = entry.get_text().strip()
|
||||||
|
results = self._apps.fuzzy_query(text) if text else self._sorted_all()
|
||||||
|
self._populate(results)
|
||||||
|
|
||||||
|
def _populate(self, apps: list) -> None:
|
||||||
|
child = self._flow.get_first_child()
|
||||||
|
while child:
|
||||||
|
self._flow.remove(child)
|
||||||
|
child = self._flow.get_first_child()
|
||||||
|
for app in apps:
|
||||||
|
self._flow.append(self._app_button(app))
|
||||||
|
|
||||||
|
def _app_button(self, app) -> Gtk.Widget:
|
||||||
|
btn = Gtk.Button(valign=Gtk.Align.START)
|
||||||
|
btn.add_css_class("app-tile")
|
||||||
|
btn.app = app
|
||||||
|
overlay = Gtk.Overlay()
|
||||||
|
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||||
|
icon = Gtk.Image.new_from_icon_name(app.get_icon_name() or "application-x-executable")
|
||||||
|
icon.set_pixel_size(48)
|
||||||
|
label = Gtk.Label(label=app.get_name(), ellipsize=3, max_width_chars=12,
|
||||||
|
justify=Gtk.Justification.CENTER)
|
||||||
|
content.append(icon)
|
||||||
|
content.append(label)
|
||||||
|
overlay.set_child(content)
|
||||||
|
# a star marks pinned apps (visible only when favourited)
|
||||||
|
star = Gtk.Label(label="★")
|
||||||
|
star.add_css_class("fav-star")
|
||||||
|
star.set_halign(Gtk.Align.END)
|
||||||
|
star.set_valign(Gtk.Align.START)
|
||||||
|
star.set_visible(self.settings.is_favorite(app.get_entry()))
|
||||||
|
overlay.add_overlay(star)
|
||||||
|
btn.set_child(overlay)
|
||||||
|
btn.connect("clicked", lambda *_: self._launch(app))
|
||||||
|
add_pin_gestures(btn, lambda: self._toggle_fav(app))
|
||||||
|
return btn
|
||||||
|
|
||||||
|
def _toggle_fav(self, app) -> None:
|
||||||
|
self.settings.toggle_favorite(app.get_entry())
|
||||||
|
|
||||||
|
def _on_child_activated(self, _flow, child) -> None:
|
||||||
|
btn = child.get_child()
|
||||||
|
if btn and getattr(btn, "app", None):
|
||||||
|
self._launch(btn.app)
|
||||||
|
|
||||||
|
def _launch(self, app) -> None:
|
||||||
|
try:
|
||||||
|
app.launch()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._on_launch()
|
||||||
|
|
||||||
|
def on_show(self) -> None:
|
||||||
|
self._search.set_text("")
|
||||||
|
self._populate(self._sorted_all())
|
||||||
|
self.favorites.refresh()
|
||||||
|
self._search.grab_focus()
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
"""Full-width favourites row at the top of the app drawer.
|
||||||
|
|
||||||
|
Shows pinned apps (settings["favorites"], a list of .desktop entry ids). If nothing
|
||||||
|
is pinned yet it falls back to the most-frequently-launched apps. Pin/unpin from the
|
||||||
|
drawer grid via right-click / long-press.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
gi.require_version("AstalApps", "0.1")
|
||||||
|
from gi.repository import AstalApps, Gtk # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def add_pin_gestures(widget: Gtk.Widget, on_toggle) -> None:
|
||||||
|
"""Wire right-click and long-press on `widget` to pin/unpin.
|
||||||
|
|
||||||
|
Uses the CAPTURE phase and claims the sequence so the gesture fires reliably on a
|
||||||
|
Gtk.Button (whose own primary-click gesture would otherwise swallow it) and does
|
||||||
|
not also trigger the button's launch action."""
|
||||||
|
def fire(gesture, *_a) -> None:
|
||||||
|
on_toggle()
|
||||||
|
gesture.set_state(Gtk.EventSequenceState.CLAIMED)
|
||||||
|
|
||||||
|
rclick = Gtk.GestureClick(button=3)
|
||||||
|
rclick.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
|
||||||
|
rclick.connect("pressed", fire)
|
||||||
|
widget.add_controller(rclick)
|
||||||
|
|
||||||
|
longpress = Gtk.GestureLongPress()
|
||||||
|
longpress.set_touch_only(False)
|
||||||
|
longpress.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
|
||||||
|
longpress.connect("pressed", fire)
|
||||||
|
widget.add_controller(longpress)
|
||||||
|
|
||||||
|
|
||||||
|
class Favorites(Gtk.Box):
|
||||||
|
def __init__(self, settings, on_launch):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
self.add_css_class("favorites")
|
||||||
|
self.settings = settings
|
||||||
|
self._on_launch = on_launch
|
||||||
|
self._apps = AstalApps.Apps()
|
||||||
|
|
||||||
|
title = Gtk.Label(label="Favorites", xalign=0.0)
|
||||||
|
title.add_css_class("section-title")
|
||||||
|
self.append(title)
|
||||||
|
|
||||||
|
self._row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
self._row.add_css_class("favorites-row")
|
||||||
|
scroller = Gtk.ScrolledWindow(
|
||||||
|
vscrollbar_policy=Gtk.PolicyType.NEVER,
|
||||||
|
hscrollbar_policy=Gtk.PolicyType.AUTOMATIC)
|
||||||
|
scroller.set_child(self._row)
|
||||||
|
self.append(scroller)
|
||||||
|
|
||||||
|
settings.subscribe(self.refresh)
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def _by_entry(self) -> dict:
|
||||||
|
return {a.get_entry(): a for a in self._apps.get_list() if a.get_entry()}
|
||||||
|
|
||||||
|
def _resolve(self) -> list:
|
||||||
|
by_entry = self._by_entry()
|
||||||
|
entries = self.settings.favorites()
|
||||||
|
if entries:
|
||||||
|
return [by_entry[e] for e in entries if e in by_entry]
|
||||||
|
# fallback: most-used apps
|
||||||
|
apps = sorted(self._apps.get_list(), key=lambda a: -a.get_frequency())
|
||||||
|
return [a for a in apps if a.get_frequency() > 0][:8]
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
child = self._row.get_first_child()
|
||||||
|
while child:
|
||||||
|
self._row.remove(child)
|
||||||
|
child = self._row.get_first_child()
|
||||||
|
apps = self._resolve()
|
||||||
|
if not apps:
|
||||||
|
self._row.append(Gtk.Label(label="Right-click an app below to pin it",
|
||||||
|
xalign=0.0))
|
||||||
|
return
|
||||||
|
for app in apps:
|
||||||
|
self._row.append(self._tile(app))
|
||||||
|
|
||||||
|
def _tile(self, app) -> Gtk.Widget:
|
||||||
|
btn = Gtk.Button()
|
||||||
|
btn.add_css_class("fav-tile")
|
||||||
|
content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
icon = Gtk.Image.new_from_icon_name(app.get_icon_name() or "application-x-executable")
|
||||||
|
icon.set_pixel_size(28)
|
||||||
|
content.append(icon)
|
||||||
|
content.append(Gtk.Label(label=app.get_name(), ellipsize=3, max_width_chars=14))
|
||||||
|
btn.set_child(content)
|
||||||
|
btn.connect("clicked", lambda *_a: self._launch(app))
|
||||||
|
# right-click / long-press unpins
|
||||||
|
add_pin_gestures(btn, lambda: self.settings.toggle_favorite(app.get_entry()))
|
||||||
|
return btn
|
||||||
|
|
||||||
|
def _launch(self, app) -> None:
|
||||||
|
try:
|
||||||
|
app.launch()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._on_launch()
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
"""Generic chrome around any module: a header (icon, title, expand, settings) and
|
||||||
|
a body that is either the module's compact widget or an 'enable me' placeholder.
|
||||||
|
|
||||||
|
A disabled quad never calls the module's build(), so a disabled Bluetooth/Network
|
||||||
|
quad spawns no backend work at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gtk # noqa: E402
|
||||||
|
|
||||||
|
from module_base import ModuleContext, ModuleInstance, ModuleSpec
|
||||||
|
|
||||||
|
|
||||||
|
class QuadCard(Gtk.Box):
|
||||||
|
def __init__(self, spec: ModuleSpec, settings, services,
|
||||||
|
request_expand, request_collapse):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
self.add_css_class("quad-card")
|
||||||
|
self.spec = spec
|
||||||
|
self.settings = settings
|
||||||
|
self.services = services
|
||||||
|
self.ctx = ModuleContext(spec, settings, services, request_expand, request_collapse)
|
||||||
|
self.instance: ModuleInstance | None = None
|
||||||
|
|
||||||
|
self._header = self._build_header()
|
||||||
|
self.append(self._header)
|
||||||
|
|
||||||
|
self._body_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
self._body_holder.add_css_class("quad-body")
|
||||||
|
self._body_holder.set_vexpand(True)
|
||||||
|
self.append(self._body_holder)
|
||||||
|
|
||||||
|
self._features: dict[str, bool] = {}
|
||||||
|
self._rebuild_body()
|
||||||
|
|
||||||
|
# -- header ------------------------------------------------------------
|
||||||
|
def _build_header(self) -> Gtk.Widget:
|
||||||
|
header = Gtk.CenterBox()
|
||||||
|
header.add_css_class("quad-header")
|
||||||
|
|
||||||
|
title = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
icon = Gtk.Label(label=self.spec.icon)
|
||||||
|
icon.add_css_class("quad-icon")
|
||||||
|
name = Gtk.Label(label=self.spec.title, xalign=0.0)
|
||||||
|
name.add_css_class("quad-title")
|
||||||
|
title.append(icon)
|
||||||
|
title.append(name)
|
||||||
|
header.set_start_widget(title)
|
||||||
|
|
||||||
|
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
||||||
|
self._expand_btn = Gtk.Button(label="") # nf-fa-expand
|
||||||
|
self._expand_btn.add_css_class("quad-action")
|
||||||
|
self._expand_btn.set_tooltip_text("Expand")
|
||||||
|
self._expand_btn.connect("clicked", lambda *_: self.ctx.expand())
|
||||||
|
actions.append(self._build_settings_button())
|
||||||
|
actions.append(self._expand_btn)
|
||||||
|
header.set_end_widget(actions)
|
||||||
|
return header
|
||||||
|
|
||||||
|
def _build_settings_button(self) -> Gtk.Widget:
|
||||||
|
btn = Gtk.MenuButton(label="") # nf-fa-cog
|
||||||
|
btn.add_css_class("quad-action")
|
||||||
|
btn.set_tooltip_text("Settings")
|
||||||
|
pop = Gtk.Popover()
|
||||||
|
pop.add_css_class("quad-settings")
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||||
|
|
||||||
|
box.append(self._switch_row("Enabled", self.settings.quad_enabled(
|
||||||
|
self.spec.id, self.spec.default_enabled),
|
||||||
|
lambda v: self.settings.set_quad_enabled(self.spec.id, v)))
|
||||||
|
|
||||||
|
for feat in self.spec.features:
|
||||||
|
box.append(Gtk.Separator())
|
||||||
|
box.append(self._switch_row(
|
||||||
|
feat.label, self.settings.feature(self.spec.id, feat.id, feat.default),
|
||||||
|
lambda v, fid=feat.id: self.settings.set_feature(self.spec.id, fid, v)))
|
||||||
|
|
||||||
|
pop.set_child(box)
|
||||||
|
btn.set_popover(pop)
|
||||||
|
return btn
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _switch_row(label: str, value: bool, on_change) -> Gtk.Widget:
|
||||||
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||||
|
row.add_css_class("switch-row")
|
||||||
|
lbl = Gtk.Label(label=label, xalign=0.0, hexpand=True)
|
||||||
|
sw = Gtk.Switch(active=value, valign=Gtk.Align.CENTER)
|
||||||
|
sw.connect("state-set", lambda _sw, v: (on_change(v), False)[1])
|
||||||
|
row.append(lbl)
|
||||||
|
row.append(sw)
|
||||||
|
return row
|
||||||
|
|
||||||
|
# -- body --------------------------------------------------------------
|
||||||
|
def _clear_body(self) -> None:
|
||||||
|
if self.instance and self.instance.destroy:
|
||||||
|
self.instance.destroy()
|
||||||
|
self.instance = None
|
||||||
|
child = self._body_holder.get_first_child()
|
||||||
|
while child:
|
||||||
|
self._body_holder.remove(child)
|
||||||
|
child = self._body_holder.get_first_child()
|
||||||
|
|
||||||
|
def _rebuild_body(self) -> None:
|
||||||
|
self._clear_body()
|
||||||
|
self._features = self._feature_snapshot()
|
||||||
|
enabled = self.settings.quad_enabled(self.spec.id, self.spec.default_enabled)
|
||||||
|
if enabled:
|
||||||
|
inst = self.spec.build(self.ctx)
|
||||||
|
self.instance = inst
|
||||||
|
self._body_holder.append(inst.compact)
|
||||||
|
self._expand_btn.set_sensitive(inst.expanded is not None)
|
||||||
|
else:
|
||||||
|
self._expand_btn.set_sensitive(False)
|
||||||
|
self._body_holder.append(self._disabled_placeholder())
|
||||||
|
|
||||||
|
def _feature_snapshot(self) -> dict[str, bool]:
|
||||||
|
return {f.id: self.settings.feature(self.spec.id, f.id, f.default)
|
||||||
|
for f in self.spec.features}
|
||||||
|
|
||||||
|
def _disabled_placeholder(self) -> Gtk.Widget:
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10,
|
||||||
|
valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER)
|
||||||
|
box.add_css_class("quad-disabled")
|
||||||
|
box.append(Gtk.Label(label=f"{self.spec.title} is off"))
|
||||||
|
btn = Gtk.Button(label="Enable")
|
||||||
|
btn.add_css_class("enable-btn")
|
||||||
|
btn.connect("clicked",
|
||||||
|
lambda *_: self.settings.set_quad_enabled(self.spec.id, True))
|
||||||
|
box.append(btn)
|
||||||
|
return box
|
||||||
|
|
||||||
|
# -- lifecycle ---------------------------------------------------------
|
||||||
|
def on_settings_changed(self) -> None:
|
||||||
|
"""Called by the grid when settings.json changes; rebuild if this quad's
|
||||||
|
enablement flipped or any of its feature toggles changed, so every switch in
|
||||||
|
the settings popover takes effect live (the popover is only reachable while
|
||||||
|
the grid is collapsed, so no expanded view is ever reparented mid-rebuild)."""
|
||||||
|
enabled = self.settings.quad_enabled(self.spec.id, self.spec.default_enabled)
|
||||||
|
has_module = self.instance is not None
|
||||||
|
features_changed = enabled and self._feature_snapshot() != self._features
|
||||||
|
if enabled != has_module or features_changed:
|
||||||
|
self._rebuild_body()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expanded_widget(self) -> Gtk.Widget | None:
|
||||||
|
return self.instance.expanded if self.instance else None
|
||||||
|
|
||||||
|
def on_show(self) -> None:
|
||||||
|
if self.instance and self.instance.on_show:
|
||||||
|
self.instance.on_show()
|
||||||
|
|
||||||
|
def on_hide(self) -> None:
|
||||||
|
if self.instance and self.instance.on_hide:
|
||||||
|
self.instance.on_hide()
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
"""The 2x2 quad region and its expand-over-the-others behaviour.
|
||||||
|
|
||||||
|
A Gtk.Overlay stacks two things in the same space:
|
||||||
|
* base : a 2x2 Gtk.Grid of QuadCards
|
||||||
|
* overlay: a Revealer that, when a quad expands, fills the whole region (the full
|
||||||
|
content width, covering all four cells) with that module's expanded
|
||||||
|
view wrapped in a small header carrying a collapse button.
|
||||||
|
|
||||||
|
Because the overlay fills the region exactly, the expanded quad is as wide as the
|
||||||
|
appdrawer below it, and the outer letterbox margins (applied further up the tree)
|
||||||
|
are untouched — so letterboxing stays identical in every state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import GLib, Gtk # noqa: E402
|
||||||
|
|
||||||
|
from lib.border import bordered
|
||||||
|
from module_base import ModuleSpec
|
||||||
|
from ui.quadcard import QuadCard
|
||||||
|
|
||||||
|
|
||||||
|
class QuadGrid(Gtk.Overlay):
|
||||||
|
def __init__(self, specs: list[ModuleSpec], settings, services):
|
||||||
|
super().__init__()
|
||||||
|
self.add_css_class("quad-region")
|
||||||
|
self.set_vexpand(True)
|
||||||
|
self.settings = settings
|
||||||
|
self._expanded_id: str | None = None
|
||||||
|
|
||||||
|
self.grid = Gtk.Grid(column_homogeneous=True, row_homogeneous=True,
|
||||||
|
column_spacing=10, row_spacing=10)
|
||||||
|
self.grid.add_css_class("quad-grid")
|
||||||
|
self.set_child(self.grid)
|
||||||
|
|
||||||
|
self.cards: dict[str, QuadCard] = {}
|
||||||
|
for index, spec in enumerate(specs[:4]):
|
||||||
|
card = QuadCard(spec, settings, services,
|
||||||
|
self.request_expand, self.request_collapse)
|
||||||
|
self.cards[spec.id] = card
|
||||||
|
self.grid.attach(bordered(card, radius=16, fill_bg=True),
|
||||||
|
index % 2, index // 2, 1, 1)
|
||||||
|
|
||||||
|
# overlay used for the expanded quad
|
||||||
|
self._expand_reveal = Gtk.Revealer(
|
||||||
|
transition_type=Gtk.RevealerTransitionType.CROSSFADE,
|
||||||
|
transition_duration=180, reveal_child=False)
|
||||||
|
self._expand_reveal.set_halign(Gtk.Align.FILL)
|
||||||
|
self._expand_reveal.set_valign(Gtk.Align.FILL)
|
||||||
|
self._expand_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
self._expand_holder.add_css_class("quad-expanded")
|
||||||
|
self._expand_card = bordered(self._expand_holder, radius=16, fill_bg=True)
|
||||||
|
self._expand_reveal.set_child(self._expand_card)
|
||||||
|
self.add_overlay(self._expand_reveal)
|
||||||
|
# The overlay's holder is opaque; keep the whole overlay hidden unless a quad
|
||||||
|
# is actually expanded, otherwise it paints over the 2x2 grid.
|
||||||
|
self._expand_reveal.set_visible(False)
|
||||||
|
|
||||||
|
# A second overlay: an external "takeover" widget (the taskbar's workspace/
|
||||||
|
# window panel) that, when shown, covers the whole quad region.
|
||||||
|
self._takeover_reveal = Gtk.Revealer(
|
||||||
|
transition_type=Gtk.RevealerTransitionType.CROSSFADE,
|
||||||
|
transition_duration=180, reveal_child=False)
|
||||||
|
self._takeover_reveal.set_halign(Gtk.Align.FILL)
|
||||||
|
self._takeover_reveal.set_valign(Gtk.Align.FILL)
|
||||||
|
self._takeover_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
self._takeover_holder.add_css_class("quad-expanded")
|
||||||
|
self._takeover_card = bordered(self._takeover_holder, radius=16, fill_bg=True)
|
||||||
|
self._takeover_reveal.set_child(self._takeover_card)
|
||||||
|
self.add_overlay(self._takeover_reveal)
|
||||||
|
self._takeover_reveal.set_visible(False)
|
||||||
|
|
||||||
|
settings.subscribe(self._on_settings_changed)
|
||||||
|
|
||||||
|
# -- external takeover (taskbar panel) ---------------------------------
|
||||||
|
def set_takeover_widget(self, widget: Gtk.Widget, on_back) -> None:
|
||||||
|
"""Mount an external widget (once) that will cover the quad region when shown.
|
||||||
|
A Back row is prepended so the covered quads can be restored."""
|
||||||
|
header = Gtk.CenterBox()
|
||||||
|
header.add_css_class("expanded-header")
|
||||||
|
back = Gtk.Button(label="") # nf-fa-compress (unfullscreen)
|
||||||
|
back.add_css_class("quad-action")
|
||||||
|
back.set_tooltip_text("Collapse")
|
||||||
|
back.connect("clicked", lambda *_: on_back())
|
||||||
|
header.set_start_widget(back)
|
||||||
|
self._takeover_holder.append(header)
|
||||||
|
widget.set_vexpand(True)
|
||||||
|
self._takeover_holder.append(widget)
|
||||||
|
|
||||||
|
def show_takeover(self) -> None:
|
||||||
|
self._takeover_reveal.set_visible(True)
|
||||||
|
self._takeover_card.add_css_class("quad-pop") # same bouncy scale as a quad
|
||||||
|
GLib.timeout_add(340, lambda: (self._takeover_card.remove_css_class("quad-pop"),
|
||||||
|
GLib.SOURCE_REMOVE)[1])
|
||||||
|
self._takeover_reveal.set_reveal_child(True)
|
||||||
|
self.grid.add_css_class("dimmed")
|
||||||
|
|
||||||
|
def hide_takeover(self) -> None:
|
||||||
|
self._takeover_reveal.set_reveal_child(False)
|
||||||
|
self._takeover_reveal.set_visible(False)
|
||||||
|
self.grid.remove_css_class("dimmed")
|
||||||
|
|
||||||
|
# -- expansion ---------------------------------------------------------
|
||||||
|
def request_expand(self, module_id: str) -> None:
|
||||||
|
card = self.cards.get(module_id)
|
||||||
|
if not card or card.instance is None or card.instance.expanded is None:
|
||||||
|
return
|
||||||
|
# A module opts into expansion by supplying a distinct expanded widget
|
||||||
|
# (a separate instance), so no reparenting of the compact cell is needed.
|
||||||
|
content = card.instance.expanded
|
||||||
|
# reparent content into the expanded holder
|
||||||
|
self._clear_expand_holder()
|
||||||
|
self._expand_reveal.set_visible(True)
|
||||||
|
self._expand_holder.append(self._expanded_header(card.spec.title))
|
||||||
|
if card.instance.scroll_expanded:
|
||||||
|
wrap = Gtk.ScrolledWindow(vexpand=True,
|
||||||
|
hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||||
|
wrap.set_child(content)
|
||||||
|
self._expand_holder.append(wrap)
|
||||||
|
else:
|
||||||
|
content.set_vexpand(True)
|
||||||
|
self._expand_holder.append(content)
|
||||||
|
self._expanded_id = module_id
|
||||||
|
self._expand_reveal.set_reveal_child(True)
|
||||||
|
# bouncy scale pop (CSS @keyframes). Keep the class only for the animation's
|
||||||
|
# duration, then drop it: while it's applied, any re-layout of the card (module
|
||||||
|
# content streaming in, the grid updating underneath) restarts the transform
|
||||||
|
# animation, which made the pop loop forever.
|
||||||
|
self._expand_card.add_css_class("quad-pop")
|
||||||
|
GLib.timeout_add(340, self._clear_pop)
|
||||||
|
self.grid.add_css_class("dimmed")
|
||||||
|
card.on_show()
|
||||||
|
|
||||||
|
def _clear_pop(self) -> bool:
|
||||||
|
self._expand_card.remove_css_class("quad-pop")
|
||||||
|
return GLib.SOURCE_REMOVE
|
||||||
|
|
||||||
|
def request_collapse(self) -> None:
|
||||||
|
self._expand_reveal.set_reveal_child(False)
|
||||||
|
self._expand_reveal.set_visible(False)
|
||||||
|
self._expand_card.remove_css_class("quad-pop") # reset so the pop replays
|
||||||
|
self._expanded_id = None
|
||||||
|
self.grid.remove_css_class("dimmed")
|
||||||
|
# Drop the reference to the reparented widget so the card can reuse it.
|
||||||
|
self._clear_expand_holder()
|
||||||
|
|
||||||
|
def _clear_expand_holder(self) -> None:
|
||||||
|
child = self._expand_holder.get_first_child()
|
||||||
|
while child:
|
||||||
|
# detach any ScrolledWindow's child so it survives for the card
|
||||||
|
if isinstance(child, Gtk.ScrolledWindow):
|
||||||
|
inner = child.get_child()
|
||||||
|
if inner:
|
||||||
|
child.set_child(None)
|
||||||
|
self._expand_holder.remove(child)
|
||||||
|
child = self._expand_holder.get_first_child()
|
||||||
|
|
||||||
|
def _expanded_header(self, title: str) -> Gtk.Widget:
|
||||||
|
header = Gtk.CenterBox()
|
||||||
|
header.add_css_class("expanded-header")
|
||||||
|
back = Gtk.Button(label="") # nf-fa-compress (unfullscreen)
|
||||||
|
back.add_css_class("quad-action")
|
||||||
|
back.set_tooltip_text("Collapse")
|
||||||
|
back.connect("clicked", lambda *_: self.request_collapse())
|
||||||
|
header.set_start_widget(back)
|
||||||
|
lbl = Gtk.Label(label=title)
|
||||||
|
lbl.add_css_class("quad-title")
|
||||||
|
header.set_center_widget(lbl)
|
||||||
|
return header
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expanded(self) -> bool:
|
||||||
|
return self._expanded_id is not None
|
||||||
|
|
||||||
|
# -- lifecycle ---------------------------------------------------------
|
||||||
|
def _on_settings_changed(self) -> None:
|
||||||
|
for card in self.cards.values():
|
||||||
|
card.on_settings_changed()
|
||||||
|
|
||||||
|
def on_show(self) -> None:
|
||||||
|
for card in self.cards.values():
|
||||||
|
card.on_show()
|
||||||
|
|
||||||
|
def on_hide(self) -> None:
|
||||||
|
if self.is_expanded:
|
||||||
|
self.request_collapse()
|
||||||
|
for card in self.cards.values():
|
||||||
|
card.on_hide()
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""A minimalist system-stats line for the top of the panel.
|
||||||
|
|
||||||
|
CPU / RAM / GPU / disk utilisation plus network up/down rate, refreshed on a timer.
|
||||||
|
Everything is read straight from /proc and /sys on the main thread — these are local
|
||||||
|
file reads that take microseconds, so no subprocess or worker thread is needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import GLib, Gtk # noqa: E402
|
||||||
|
|
||||||
|
_GPU_FILES = glob.glob("/sys/class/drm/card*/device/gpu_busy_percent")
|
||||||
|
_NET_SKIP = ("lo", "docker", "veth", "br-", "virbr", "tun", "tap")
|
||||||
|
|
||||||
|
|
||||||
|
def _human(rate: float) -> str:
|
||||||
|
for unit in ("B", "K", "M", "G"):
|
||||||
|
if rate < 1024 or unit == "G":
|
||||||
|
return f"{rate:4.1f}{unit}"
|
||||||
|
rate /= 1024
|
||||||
|
return f"{rate:4.1f}G"
|
||||||
|
|
||||||
|
|
||||||
|
class Statsbar(Gtk.Box):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=18,
|
||||||
|
halign=Gtk.Align.CENTER)
|
||||||
|
self.add_css_class("statsbar")
|
||||||
|
|
||||||
|
# short text labels rather than nerd glyphs (some don't exist in the font and
|
||||||
|
# render as tofu); keeps the line legible on any theme.
|
||||||
|
self._cpu = self._cell("CPU")
|
||||||
|
self._ram = self._cell("MEM")
|
||||||
|
self._gpu = self._cell("GPU")
|
||||||
|
self._disk = self._cell("DISK")
|
||||||
|
self._down = self._cell("↓")
|
||||||
|
self._up = self._cell("↑")
|
||||||
|
|
||||||
|
self._prev_cpu: tuple[int, int] | None = None
|
||||||
|
self._prev_net: tuple[int, int] | None = None
|
||||||
|
self._prev_t: float | None = None
|
||||||
|
|
||||||
|
self._refresh()
|
||||||
|
self._source = GLib.timeout_add_seconds(2, self._tick)
|
||||||
|
|
||||||
|
def _cell(self, icon: str) -> Gtk.Label:
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||||
|
ic = Gtk.Label(label=icon)
|
||||||
|
ic.add_css_class("stat-icon")
|
||||||
|
val = Gtk.Label(label="…")
|
||||||
|
val.add_css_class("stat-value")
|
||||||
|
box.append(ic)
|
||||||
|
box.append(val)
|
||||||
|
self.append(box)
|
||||||
|
return val
|
||||||
|
|
||||||
|
def _tick(self) -> bool:
|
||||||
|
self._refresh()
|
||||||
|
return GLib.SOURCE_CONTINUE
|
||||||
|
|
||||||
|
# -- readers -----------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def _cpu_pct(prev) -> tuple[float, tuple[int, int]]:
|
||||||
|
parts = [int(x) for x in open("/proc/stat").readline().split()[1:]]
|
||||||
|
idle = parts[3] + parts[4] # idle + iowait
|
||||||
|
total = sum(parts)
|
||||||
|
if prev is None:
|
||||||
|
return 0.0, (total, idle)
|
||||||
|
dt, di = total - prev[0], idle - prev[1]
|
||||||
|
pct = (1 - di / dt) * 100 if dt > 0 else 0.0
|
||||||
|
return max(0.0, min(100.0, pct)), (total, idle)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ram_pct() -> float:
|
||||||
|
mem = {}
|
||||||
|
for line in open("/proc/meminfo"):
|
||||||
|
k, _, v = line.partition(":")
|
||||||
|
mem[k] = int(v.split()[0])
|
||||||
|
if len(mem) > 4 and "MemAvailable" in mem:
|
||||||
|
break
|
||||||
|
total, avail = mem.get("MemTotal", 1), mem.get("MemAvailable", 0)
|
||||||
|
return (1 - avail / total) * 100
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _gpu_pct() -> float:
|
||||||
|
best = 0
|
||||||
|
for f in _GPU_FILES:
|
||||||
|
try:
|
||||||
|
best = max(best, int(open(f).read().strip()))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return float(best)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _disk_pct() -> float:
|
||||||
|
s = os.statvfs("/")
|
||||||
|
return (1 - s.f_bfree / s.f_blocks) * 100 if s.f_blocks else 0.0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _net_bytes() -> tuple[int, int]:
|
||||||
|
rx = tx = 0
|
||||||
|
with open("/proc/net/dev") as fh:
|
||||||
|
for line in fh.readlines()[2:]:
|
||||||
|
name, _, rest = line.partition(":")
|
||||||
|
name = name.strip()
|
||||||
|
if name.startswith(_NET_SKIP):
|
||||||
|
continue
|
||||||
|
cols = rest.split()
|
||||||
|
rx += int(cols[0])
|
||||||
|
tx += int(cols[8])
|
||||||
|
return rx, tx
|
||||||
|
|
||||||
|
def _refresh(self) -> None:
|
||||||
|
cpu, self._prev_cpu = self._cpu_pct(self._prev_cpu)
|
||||||
|
self._cpu.set_text(f"{cpu:2.0f}%")
|
||||||
|
self._ram.set_text(f"{self._ram_pct():2.0f}%")
|
||||||
|
self._gpu.set_text(f"{self._gpu_pct():2.0f}%")
|
||||||
|
self._disk.set_text(f"{self._disk_pct():2.0f}%")
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
rx, tx = self._net_bytes()
|
||||||
|
if self._prev_net is not None and self._prev_t is not None and now > self._prev_t:
|
||||||
|
dt = now - self._prev_t
|
||||||
|
self._down.set_text(_human((rx - self._prev_net[0]) / dt) + "/s")
|
||||||
|
self._up.set_text(_human((tx - self._prev_net[1]) / dt) + "/s")
|
||||||
|
self._prev_net, self._prev_t = (rx, tx), now
|
||||||
|
|
@ -0,0 +1,477 @@
|
||||||
|
"""Full-width taskbar strip: jump to any open window, plus an expand-over panel.
|
||||||
|
|
||||||
|
Compact: app-grouped icons; click focuses the window (single) or opens a pop-out of
|
||||||
|
instances (grouped). The ⤢ toggle collapses this compact strip and hands its panel
|
||||||
|
(exposed as `panel_widget`) to the menu window, which mounts it over the quad region
|
||||||
|
so it *covers* the 2x2 quads rather than pushing anything down. The panel holds:
|
||||||
|
* workspace/layout controls — pick the current workspace's layout (scrolling /
|
||||||
|
dwindle / master / monocle, enumerated from ~/.cache/astro-menu/layouts.json,
|
||||||
|
written by hypr/layouts) and, for directional layouts, its direction. Applied
|
||||||
|
live via `hyprctl eval 'layouts.set(ws, name, dir)'`.
|
||||||
|
* a per-window row list: [icon + title → focus/jump] [⇤ pull to this workspace].
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
gi.require_version("AstalApps", "0.1")
|
||||||
|
from gi.repository import AstalApps, Gtk # noqa: E402
|
||||||
|
|
||||||
|
from lib.proc import run_json, run_text
|
||||||
|
from paths import CACHE_DIR
|
||||||
|
|
||||||
|
_LAYOUTS_MANIFEST = CACHE_DIR / "layouts.json"
|
||||||
|
_COLUMNS_STATE = CACHE_DIR / "columns-state.json"
|
||||||
|
_LAYOUTS_STATE = CACHE_DIR / "layouts-state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _eval(lua: str) -> None:
|
||||||
|
run_text(["hyprctl", "eval", lua], lambda *_a: None)
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch(lua: str) -> None:
|
||||||
|
# In hyprlua, `hyprctl dispatch` evaluates its argument as Lua (the hl.dsp.* API).
|
||||||
|
run_text(["hyprctl", "dispatch", lua], lambda *_a: None)
|
||||||
|
|
||||||
|
|
||||||
|
class Taskbar(Gtk.Box):
|
||||||
|
def __init__(self, on_activate, on_toggle_panel=None):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||||
|
self.add_css_class("taskbar")
|
||||||
|
self._on_activate = on_activate
|
||||||
|
self._on_toggle_panel = on_toggle_panel # window mounts the panel over the quads
|
||||||
|
self._apps = AstalApps.Apps()
|
||||||
|
self._wm_index = self._build_wm_index()
|
||||||
|
self._layouts = self._load_layouts()
|
||||||
|
self._active_ws = None
|
||||||
|
self._clients: list = []
|
||||||
|
self._dir_dds = {}
|
||||||
|
self._fit_sws = {}
|
||||||
|
|
||||||
|
# The workspace/window panel. It is NOT appended here: the menu window mounts
|
||||||
|
# `panel_widget` into the quad region so that expanding COLLAPSES this strip's
|
||||||
|
# body (below) and the panel COVERS the 2x2 quads instead of pushing anything.
|
||||||
|
self._panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
|
self._panel.add_css_class("task-panel")
|
||||||
|
self._panel_scroll = Gtk.ScrolledWindow(
|
||||||
|
hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True)
|
||||||
|
self._panel_scroll.set_child(self._panel)
|
||||||
|
|
||||||
|
header = Gtk.CenterBox()
|
||||||
|
title = Gtk.Label(label="Open windows", xalign=0.0)
|
||||||
|
title.add_css_class("section-title")
|
||||||
|
header.set_start_widget(title)
|
||||||
|
self._expand_btn = Gtk.ToggleButton(label="") # nf-fa-expand
|
||||||
|
self._expand_btn.add_css_class("quad-action")
|
||||||
|
self._expand_btn.set_tooltip_text("Workspace & window controls")
|
||||||
|
self._expand_btn.connect("toggled", lambda b: self._toggle_panel(b.get_active()))
|
||||||
|
header.set_end_widget(self._expand_btn)
|
||||||
|
self.append(header)
|
||||||
|
|
||||||
|
# compact icon strip (hidden while the panel is expanded)
|
||||||
|
self._row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
self._row.add_css_class("taskbar-row")
|
||||||
|
self._strip = Gtk.ScrolledWindow(
|
||||||
|
vscrollbar_policy=Gtk.PolicyType.NEVER,
|
||||||
|
hscrollbar_policy=Gtk.PolicyType.AUTOMATIC)
|
||||||
|
self._strip.set_child(self._row)
|
||||||
|
self.append(self._strip)
|
||||||
|
|
||||||
|
# -- expand-over-the-quads panel --------------------------------------
|
||||||
|
@property
|
||||||
|
def panel_widget(self) -> Gtk.Widget:
|
||||||
|
"""The workspace/window panel body; the window mounts this over the quads."""
|
||||||
|
return self._panel_scroll
|
||||||
|
|
||||||
|
def _toggle_panel(self, active: bool) -> None:
|
||||||
|
# Collapse this strip's compact body; the window reveals/hides the panel that
|
||||||
|
# it has mounted over the quad region (via on_toggle_panel).
|
||||||
|
self._strip.set_visible(not active)
|
||||||
|
if self._on_toggle_panel:
|
||||||
|
self._on_toggle_panel(active)
|
||||||
|
if active:
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def collapse_panel(self) -> None:
|
||||||
|
"""Return to the compact strip (called on menu hide / from the panel's Back)."""
|
||||||
|
if self._expand_btn.get_active():
|
||||||
|
self._expand_btn.set_active(False) # fires toggled -> _toggle_panel(False)
|
||||||
|
else:
|
||||||
|
self._strip.set_visible(True)
|
||||||
|
|
||||||
|
# -- setup helpers -----------------------------------------------------
|
||||||
|
def _build_wm_index(self) -> dict:
|
||||||
|
idx = {}
|
||||||
|
for app in self._apps.get_list():
|
||||||
|
for key in (app.get_wm_class(), app.get_executable(), app.get_name()):
|
||||||
|
if key:
|
||||||
|
idx.setdefault(key.lower(), app)
|
||||||
|
return idx
|
||||||
|
|
||||||
|
def _icon_for(self, cls: str) -> str:
|
||||||
|
app = self._wm_index.get((cls or "").lower())
|
||||||
|
if app and app.get_icon_name():
|
||||||
|
return app.get_icon_name()
|
||||||
|
return (cls or "application-x-executable").lower()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_layouts() -> list:
|
||||||
|
try:
|
||||||
|
return json.loads(_LAYOUTS_MANIFEST.read_text())
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
# fallback if hypr/layouts hasn't written the manifest yet
|
||||||
|
return [{"name": "scrolling", "label": "Scrolling", "directional": True,
|
||||||
|
"dirs": ["down", "up", "right", "left"], "default_dir": "down",
|
||||||
|
"fit_method": True},
|
||||||
|
{"name": "columns", "label": "Columns", "directional": True,
|
||||||
|
"dirs": ["right", "down"], "dir_labels": ["Left / Right", "Up / Down"],
|
||||||
|
"default_dir": "right", "fit_method": True},
|
||||||
|
{"name": "dwindle", "label": "Dwindle", "directional": False, "dirs": []},
|
||||||
|
{"name": "master", "label": "Master", "directional": False, "dirs": []},
|
||||||
|
{"name": "monocle", "label": "Monocle", "directional": False, "dirs": []}]
|
||||||
|
|
||||||
|
# -- populate ----------------------------------------------------------
|
||||||
|
def refresh(self) -> None:
|
||||||
|
run_json(["hyprctl", "clients", "-j"], self._on_clients)
|
||||||
|
run_json(["hyprctl", "activeworkspace", "-j"], self._on_ws)
|
||||||
|
|
||||||
|
def _on_ws(self, ok: bool, data) -> None:
|
||||||
|
if ok and isinstance(data, dict):
|
||||||
|
self._active_ws = data.get("id")
|
||||||
|
self._rebuild_panel()
|
||||||
|
|
||||||
|
def _on_clients(self, ok: bool, data) -> None:
|
||||||
|
self._clients = data if ok and isinstance(data, list) else []
|
||||||
|
self._rebuild_strip()
|
||||||
|
self._rebuild_panel()
|
||||||
|
|
||||||
|
# -- compact strip -----------------------------------------------------
|
||||||
|
def _rebuild_strip(self) -> None:
|
||||||
|
self._clear(self._row)
|
||||||
|
groups: dict[str, list] = {}
|
||||||
|
for w in self._clients:
|
||||||
|
if not w.get("mapped", True) or not w.get("class"):
|
||||||
|
continue
|
||||||
|
groups.setdefault(w["class"], []).append(w)
|
||||||
|
if not groups:
|
||||||
|
self._row.append(Gtk.Label(label="No open windows"))
|
||||||
|
return
|
||||||
|
for cls, wins in sorted(groups.items()):
|
||||||
|
self._row.append(self._group_button(cls, wins))
|
||||||
|
|
||||||
|
def _group_button(self, cls: str, wins: list) -> Gtk.Widget:
|
||||||
|
icon = Gtk.Image.new_from_icon_name(self._icon_for(cls))
|
||||||
|
icon.set_pixel_size(32)
|
||||||
|
if len(wins) == 1:
|
||||||
|
btn = Gtk.Button()
|
||||||
|
btn.add_css_class("task-tile")
|
||||||
|
btn.set_child(icon)
|
||||||
|
btn.set_tooltip_text(wins[0].get("title") or cls)
|
||||||
|
btn.connect("clicked", lambda *_a, w=wins[0]: self._focus(w))
|
||||||
|
return btn
|
||||||
|
btn = Gtk.MenuButton()
|
||||||
|
btn.add_css_class("task-tile")
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||||
|
box.append(icon)
|
||||||
|
badge = Gtk.Label(label=str(len(wins)))
|
||||||
|
badge.add_css_class("task-badge")
|
||||||
|
box.append(badge)
|
||||||
|
btn.set_child(box)
|
||||||
|
btn.set_tooltip_text(f"{cls} ({len(wins)})")
|
||||||
|
pop = Gtk.Popover()
|
||||||
|
pop.add_css_class("task-popover")
|
||||||
|
plist = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||||
|
for w in wins:
|
||||||
|
item = Gtk.Button(label=w.get("title") or cls)
|
||||||
|
item.add_css_class("task-window")
|
||||||
|
item.connect("clicked", lambda *_a, ww=w: (pop.popdown(), self._focus(ww)))
|
||||||
|
plist.append(item)
|
||||||
|
pop.set_child(plist)
|
||||||
|
btn.set_popover(pop)
|
||||||
|
return btn
|
||||||
|
|
||||||
|
# -- pop-open panel: layout controls + window list ---------------------
|
||||||
|
def _rebuild_panel(self) -> None:
|
||||||
|
self._clear(self._panel)
|
||||||
|
|
||||||
|
# tabbed layout selector for the active workspace: a tab per layout, and a
|
||||||
|
# per-layout options page underneath that switches with the tab. Selecting a
|
||||||
|
# tab applies that layout to the currently focused workspace.
|
||||||
|
ws = self._active_ws
|
||||||
|
hdr = Gtk.Label(label=f"Workspace {ws if ws is not None else '?'} layout", xalign=0.0)
|
||||||
|
hdr.add_css_class("section-title")
|
||||||
|
self._panel.append(hdr)
|
||||||
|
|
||||||
|
self._dir_dds = {} # layout name -> (Gtk.DropDown, [dir values])
|
||||||
|
self._fit_sws = {} # layout name -> Gtk.Switch
|
||||||
|
self._layout_stack = Gtk.Stack()
|
||||||
|
for ly in self._layouts:
|
||||||
|
self._layout_stack.add_titled(self._layout_page(ly), ly["name"], ly["label"])
|
||||||
|
switcher = Gtk.StackSwitcher(stack=self._layout_stack)
|
||||||
|
switcher.add_css_class("net-switcher") # reuse the tab pill styling
|
||||||
|
self._panel.append(switcher)
|
||||||
|
self._panel.append(self._layout_stack)
|
||||||
|
self._layout_stack.connect("notify::visible-child-name", self._on_tab_switch)
|
||||||
|
self._sync_layout_controls()
|
||||||
|
|
||||||
|
self._panel.append(Gtk.Separator())
|
||||||
|
|
||||||
|
# per-window rows: [focus/jump] [pull here]
|
||||||
|
wins = [w for w in self._clients if w.get("mapped", True) and w.get("class")]
|
||||||
|
wins.sort(key=lambda w: (w.get("workspace", {}).get("id", 0),
|
||||||
|
(w.get("title") or w.get("class") or "").lower()))
|
||||||
|
if not wins:
|
||||||
|
self._panel.append(Gtk.Label(label="No open windows", xalign=0.0))
|
||||||
|
return
|
||||||
|
listing = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||||
|
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||||
|
scroller.set_child(listing)
|
||||||
|
for w in wins:
|
||||||
|
listing.append(self._window_row(w))
|
||||||
|
self._panel.append(scroller)
|
||||||
|
|
||||||
|
def _window_row(self, w) -> Gtk.Widget:
|
||||||
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
row.add_css_class("task-row")
|
||||||
|
icon = Gtk.Image.new_from_icon_name(self._icon_for(w.get("class", "")))
|
||||||
|
icon.set_pixel_size(22)
|
||||||
|
wsid = w.get("workspace", {}).get("id")
|
||||||
|
title = (w.get("title") or w.get("class") or "?")
|
||||||
|
name = Gtk.Button(hexpand=True)
|
||||||
|
name.add_css_class("task-name")
|
||||||
|
lbl = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
lbl.append(icon)
|
||||||
|
wl = Gtk.Label(label=f"{title}", xalign=0.0, hexpand=True, ellipsize=3, max_width_chars=32)
|
||||||
|
lbl.append(wl)
|
||||||
|
lbl.append(Gtk.Label(label=f"ws {wsid}", xalign=1.0))
|
||||||
|
name.set_child(lbl)
|
||||||
|
name.set_tooltip_text("Jump to window")
|
||||||
|
name.connect("clicked", lambda *_a: self._focus(w))
|
||||||
|
row.append(name)
|
||||||
|
|
||||||
|
pull = Gtk.Button(label="⇤ here")
|
||||||
|
pull.add_css_class("quad-action")
|
||||||
|
pull.set_tooltip_text("Pull this window to the current workspace")
|
||||||
|
pull.connect("clicked", lambda *_a: self._pull(w))
|
||||||
|
row.append(pull)
|
||||||
|
return row
|
||||||
|
|
||||||
|
# -- per-layout options page -------------------------------------------
|
||||||
|
def _layout_page(self, ly: dict) -> Gtk.Widget:
|
||||||
|
page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||||
|
page.add_css_class("layout-page")
|
||||||
|
if ly.get("dirs"):
|
||||||
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
row.append(Gtk.Label(label="direction", xalign=0.0, hexpand=True))
|
||||||
|
# dir_labels (optional) are friendly labels shown in place of the raw
|
||||||
|
# scrolling:direction values (e.g. "Left / Right" for "right").
|
||||||
|
labels = ly.get("dir_labels") or ly["dirs"]
|
||||||
|
dd = Gtk.DropDown.new_from_strings(labels)
|
||||||
|
dd.connect("notify::selected", self._on_opt_change)
|
||||||
|
row.append(dd)
|
||||||
|
page.append(row)
|
||||||
|
self._dir_dds[ly["name"]] = (dd, list(ly["dirs"]))
|
||||||
|
if ly.get("fit_method"):
|
||||||
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
row.append(Gtk.Label(label="center-focused", xalign=0.0, hexpand=True))
|
||||||
|
sw = Gtk.Switch(valign=Gtk.Align.CENTER)
|
||||||
|
if ly["name"] == "columns":
|
||||||
|
sw.set_tooltip_text("Keep the focused window centred in its column as "
|
||||||
|
"it scrolls, instead of the minimal-movement default")
|
||||||
|
else:
|
||||||
|
sw.set_tooltip_text("Keep the focused column centred so the prev/next "
|
||||||
|
"columns stay on-screen and tappable")
|
||||||
|
sw.connect("state-set", lambda s, state, name=ly["name"]: self._on_fit_toggle(s, state, name))
|
||||||
|
row.append(sw)
|
||||||
|
page.append(row)
|
||||||
|
self._fit_sws[ly["name"]] = sw
|
||||||
|
if ly.get("stepper"): # Columns: (-)[N](+)
|
||||||
|
page.append(self._cols_stepper())
|
||||||
|
if not ly.get("dirs") and not ly.get("fit_method") and not ly.get("stepper"):
|
||||||
|
page.append(Gtk.Label(label="No adjustable options", xalign=0.0,
|
||||||
|
css_classes=["net-ip"]))
|
||||||
|
return page
|
||||||
|
|
||||||
|
# -- columns count stepper --------------------------------------------
|
||||||
|
def _cols_stepper(self) -> Gtk.Widget:
|
||||||
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
row.append(Gtk.Label(label="Columns", xalign=0.0, hexpand=True))
|
||||||
|
minus = Gtk.Button(label="−")
|
||||||
|
minus.add_css_class("quad-action")
|
||||||
|
self._cols_lbl = Gtk.Label(label=str(self._read_cols()))
|
||||||
|
self._cols_lbl.add_css_class("stepper-value")
|
||||||
|
plus = Gtk.Button(label="+")
|
||||||
|
plus.add_css_class("quad-action")
|
||||||
|
minus.connect("clicked", lambda *_a: self._step_cols(-1))
|
||||||
|
plus.connect("clicked", lambda *_a: self._step_cols(1))
|
||||||
|
row.append(minus)
|
||||||
|
row.append(self._cols_lbl)
|
||||||
|
row.append(plus)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _read_cols(self) -> int:
|
||||||
|
try:
|
||||||
|
data = json.loads(_COLUMNS_STATE.read_text())
|
||||||
|
return int(data.get(str(self._active_ws), {}).get("cols", 2))
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, ValueError, TypeError):
|
||||||
|
return 2
|
||||||
|
|
||||||
|
def _step_cols(self, delta: int) -> None:
|
||||||
|
try:
|
||||||
|
cur = int(self._cols_lbl.get_label())
|
||||||
|
except ValueError:
|
||||||
|
cur = self._read_cols()
|
||||||
|
new = max(1, cur + delta)
|
||||||
|
self._cols_lbl.set_label(str(new))
|
||||||
|
_eval(f'hl.dispatch(hl.dsp.layout("cols {"+1" if delta > 0 else "-1"}"))')
|
||||||
|
|
||||||
|
def _read_center(self) -> bool:
|
||||||
|
# columns.lua's own "center-focused" switch — global, published in the same
|
||||||
|
# cache file as the columns stepper's count (see columns.lua's publish()).
|
||||||
|
try:
|
||||||
|
data = json.loads(_COLUMNS_STATE.read_text())
|
||||||
|
return bool(data.get("_center", False))
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -- sync + handlers ---------------------------------------------------
|
||||||
|
def _sync_layout_controls(self) -> None:
|
||||||
|
# Reflect the current per-ws layout / direction / focus_fit in the tabs+options.
|
||||||
|
#
|
||||||
|
# This fires 3 concurrent hyprctl subprocess calls with no guaranteed resolution
|
||||||
|
# order, and refresh() can trigger _rebuild_panel() (hence this) twice in a row
|
||||||
|
# (once from the clients query, once from the activeworkspace one) — each
|
||||||
|
# rebuilding a fresh _layout_stack/_dir_dds/_fit_sws. Two failure modes existed:
|
||||||
|
# 1. The 3 callbacks used to each toggle the shared `self._syncing` flag
|
||||||
|
# independently (True at start, False at end). Whichever resolved *first*
|
||||||
|
# dropped the guard while the others — in particular the tab-selection one,
|
||||||
|
# which programmatically flips the visible stack child — were still in
|
||||||
|
# flight, letting `_on_tab_switch`/`_on_opt_change` misfire as if the user
|
||||||
|
# had clicked and re-apply stale data via `layouts.set(...)`.
|
||||||
|
# 2. A second `_sync_layout_controls()` call (from the redundant rebuild) could
|
||||||
|
# land while the first's callbacks were still pending, so a stale callback
|
||||||
|
# from sync #1 could decrement sync #2's pending-count or apply sync #1's
|
||||||
|
# (possibly wrong-workspace) data into sync #2's freshly built widgets.
|
||||||
|
# A generation token invalidates any sync superseded by a newer one outright —
|
||||||
|
# if the user switches workspaces (or a redundant rebuild fires) mid-flight, only
|
||||||
|
# the latest call's callbacks are allowed to touch anything, and self._syncing
|
||||||
|
# only lifts once *that* generation's 3 calls have all resolved.
|
||||||
|
self._sync_generation = getattr(self, "_sync_generation", 0) + 1
|
||||||
|
gen = self._sync_generation
|
||||||
|
ws = self._active_ws
|
||||||
|
self._syncing = True
|
||||||
|
pending = [3]
|
||||||
|
|
||||||
|
def _done() -> None:
|
||||||
|
if gen != self._sync_generation:
|
||||||
|
return # superseded — a newer sync owns self._syncing now
|
||||||
|
pending[0] -= 1
|
||||||
|
if pending[0] <= 0:
|
||||||
|
self._syncing = False
|
||||||
|
|
||||||
|
def _tab_cb(ok, data) -> None:
|
||||||
|
if gen == self._sync_generation:
|
||||||
|
self._apply_tab_sel(ws, ok, data)
|
||||||
|
_done()
|
||||||
|
|
||||||
|
def _dir_cb(ok, data) -> None:
|
||||||
|
if gen == self._sync_generation:
|
||||||
|
self._apply_dir_sel(ok, data)
|
||||||
|
_done()
|
||||||
|
|
||||||
|
def _fit_cb(ok, data) -> None:
|
||||||
|
if gen == self._sync_generation:
|
||||||
|
self._apply_fit_sel(ok, data)
|
||||||
|
_done()
|
||||||
|
|
||||||
|
run_json(["hyprctl", "getoption", "general:layout", "-j"], _tab_cb)
|
||||||
|
run_json(["hyprctl", "getoption", "scrolling:direction", "-j"], _dir_cb)
|
||||||
|
run_json(["hyprctl", "getoption", "scrolling:focus_fit_method", "-j"], _fit_cb)
|
||||||
|
# columns' own center-focused state lives in columns-state.json, not a hyprctl
|
||||||
|
# option — read it synchronously rather than round-tripping a subprocess.
|
||||||
|
columns_sw = self._fit_sws.get("columns")
|
||||||
|
if columns_sw is not None:
|
||||||
|
columns_sw.set_active(self._read_center())
|
||||||
|
|
||||||
|
def _apply_tab_sel(self, ws, ok, data) -> None:
|
||||||
|
cur = data.get("str") if ok and isinstance(data, dict) else None
|
||||||
|
# prefer the per-workspace layout recorded by hypr/layouts (layouts.set)
|
||||||
|
try:
|
||||||
|
state = json.loads(_LAYOUTS_STATE.read_text())
|
||||||
|
cur = state.get(str(ws), cur)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
if cur and any(ly["name"] == cur for ly in self._layouts):
|
||||||
|
self._layout_stack.set_visible_child_name(cur)
|
||||||
|
|
||||||
|
def _apply_dir_sel(self, ok, data) -> None:
|
||||||
|
# scrolling:direction is global; reflect it in every directional layout's
|
||||||
|
# dropdown whose value set contains it.
|
||||||
|
cur = data.get("str") if ok and isinstance(data, dict) else None
|
||||||
|
for dd, values in self._dir_dds.values():
|
||||||
|
if cur in values:
|
||||||
|
dd.set_selected(values.index(cur))
|
||||||
|
|
||||||
|
def _apply_fit_sel(self, ok, data) -> None:
|
||||||
|
# scrolling:focus_fit_method only describes the scrolling layout's own switch;
|
||||||
|
# columns' switch is synced separately from columns-state.json (see
|
||||||
|
# _sync_layout_controls) since it isn't backed by a hyprctl option at all.
|
||||||
|
val = data.get("int") if ok and isinstance(data, dict) else 0
|
||||||
|
sw = self._fit_sws.get("scrolling")
|
||||||
|
if sw is not None:
|
||||||
|
sw.set_active(val == 1)
|
||||||
|
|
||||||
|
def _cur_dir(self) -> str:
|
||||||
|
entry = self._dir_dds.get(self._layout_stack.get_visible_child_name())
|
||||||
|
if not entry:
|
||||||
|
return ""
|
||||||
|
dd, values = entry
|
||||||
|
i = dd.get_selected()
|
||||||
|
return values[i] if 0 <= i < len(values) else ""
|
||||||
|
|
||||||
|
def _on_tab_switch(self, *_a) -> None:
|
||||||
|
if getattr(self, "_syncing", False) or self._active_ws is None:
|
||||||
|
return
|
||||||
|
name = self._layout_stack.get_visible_child_name()
|
||||||
|
if name:
|
||||||
|
_eval(f'layouts.set("{self._active_ws}", "{name}", "{self._cur_dir()}")')
|
||||||
|
|
||||||
|
def _on_opt_change(self, *_a) -> None:
|
||||||
|
if getattr(self, "_syncing", False) or self._active_ws is None:
|
||||||
|
return
|
||||||
|
name = self._layout_stack.get_visible_child_name()
|
||||||
|
if name:
|
||||||
|
_eval(f'layouts.set("{self._active_ws}", "{name}", "{self._cur_dir()}")')
|
||||||
|
|
||||||
|
def _on_fit_toggle(self, _sw, state, name) -> bool:
|
||||||
|
if not getattr(self, "_syncing", False):
|
||||||
|
if name == "columns":
|
||||||
|
_eval(f'hl.dispatch(hl.dsp.layout("center {"on" if state else "off"}"))')
|
||||||
|
else:
|
||||||
|
_eval(f"layouts.set_fit({1 if state else 0})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -- window actions ----------------------------------------------------
|
||||||
|
def _focus(self, w) -> None:
|
||||||
|
addr = w.get("address")
|
||||||
|
if addr:
|
||||||
|
_dispatch(f'hl.dsp.focus({{ window = "address:{addr}" }})')
|
||||||
|
self._on_activate()
|
||||||
|
|
||||||
|
def _pull(self, w) -> None:
|
||||||
|
addr = w.get("address")
|
||||||
|
if addr and self._active_ws is not None:
|
||||||
|
_dispatch(f'hl.dsp.window.move({{ window = "address:{addr}", '
|
||||||
|
f'workspace = "{self._active_ws}" }})')
|
||||||
|
run_json(["hyprctl", "clients", "-j"], self._on_clients) # reflect the move
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clear(box: Gtk.Box) -> None:
|
||||||
|
child = box.get_first_child()
|
||||||
|
while child:
|
||||||
|
box.remove(child)
|
||||||
|
child = box.get_first_child()
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
"""The popup: a content-sized floating layer-shell panel anchored top-centre.
|
||||||
|
|
||||||
|
Earlier this was a full-monitor overlay with a dim backdrop, but that blocks the
|
||||||
|
whole screen — the invisible full-screen surface intercepts every click. Instead the
|
||||||
|
window now sizes to its own content and only occupies that area, leaving the rest of
|
||||||
|
the screen usable. It is dismissed with the launcher toggle, Esc, or the ✕ button
|
||||||
|
(there is no click-outside-to-close, since that would require a blocking full-screen
|
||||||
|
surface).
|
||||||
|
|
||||||
|
Gtk.Window (layer TOP, anchored TOP → horizontally centred, height = content)
|
||||||
|
└ Gtk.Overlay
|
||||||
|
main : #panel-root (Taskbar / QuadGrid / AppDrawer, each drawn-bordered)
|
||||||
|
over : ✕ close button (top-right)
|
||||||
|
|
||||||
|
Expanding the app drawer additionally anchors the BOTTOM edge so the panel stretches
|
||||||
|
down and the drawer fills to the bottom; collapsing removes that anchor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
gi.require_version("Gdk", "4.0")
|
||||||
|
gi.require_version("Gtk4LayerShell", "1.0")
|
||||||
|
from gi.repository import Gdk, GLib, Gtk # noqa: E402
|
||||||
|
from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402
|
||||||
|
|
||||||
|
import config
|
||||||
|
from appservices import Services
|
||||||
|
from lib.border import bordered
|
||||||
|
from lib.hologram import HologramOverlay
|
||||||
|
from registry import ordered_specs
|
||||||
|
from ui.appdrawer import AppDrawer
|
||||||
|
from ui.quadgrid import QuadGrid
|
||||||
|
from ui.statsbar import Statsbar
|
||||||
|
from ui.taskbar import Taskbar
|
||||||
|
|
||||||
|
PANEL_WIDTH_FRACTION = 0.5 # of the monitor width...
|
||||||
|
MAX_PANEL_WIDTH = 1100 # ...clamped to this
|
||||||
|
EDGE_MARGIN = 28 # gap from the anchored edge
|
||||||
|
BOTTOM_MARGIN = 34 # gap from the far edge when the drawer is expanded
|
||||||
|
SIDES = ("top", "bottom", "left", "right")
|
||||||
|
|
||||||
|
|
||||||
|
class MenuWindow(Gtk.ApplicationWindow):
|
||||||
|
def __init__(self, app, settings, services: Services):
|
||||||
|
super().__init__(application=app)
|
||||||
|
self.set_name("menu-window")
|
||||||
|
self.add_css_class("menu-window")
|
||||||
|
self.settings = settings
|
||||||
|
self.services = services
|
||||||
|
self._drawer_expanded = False
|
||||||
|
self._side = "top"
|
||||||
|
|
||||||
|
self._init_layer_shell()
|
||||||
|
|
||||||
|
self.root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
|
self.root.set_name("panel-root")
|
||||||
|
self.root.add_css_class("panel")
|
||||||
|
|
||||||
|
# minimalist system-stats line (full-width, very top)
|
||||||
|
self.statsbar = Statsbar()
|
||||||
|
self.statsbar_reveal = Gtk.Revealer(
|
||||||
|
transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN,
|
||||||
|
transition_duration=160, reveal_child=True)
|
||||||
|
self.statsbar_reveal.set_child(bordered(self.statsbar, border=2, radius=12, fill_bg=True))
|
||||||
|
self.root.append(self.statsbar_reveal)
|
||||||
|
|
||||||
|
# taskbar (full-width, top)
|
||||||
|
self.taskbar = Taskbar(on_activate=self.hide_menu,
|
||||||
|
on_toggle_panel=self._on_taskbar_panel)
|
||||||
|
self.taskbar_reveal = Gtk.Revealer(
|
||||||
|
transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN,
|
||||||
|
transition_duration=160, reveal_child=True)
|
||||||
|
self.taskbar_reveal.set_child(bordered(self.taskbar, fill_bg=True))
|
||||||
|
self.root.append(self.taskbar_reveal)
|
||||||
|
|
||||||
|
# quads
|
||||||
|
specs = list(ordered_specs(settings))
|
||||||
|
self.grid = QuadGrid(specs, settings, services)
|
||||||
|
# The taskbar's workspace/window panel is mounted over the quad region so that
|
||||||
|
# expanding it collapses the taskbar strip and covers the 2x2 quads.
|
||||||
|
self.grid.set_takeover_widget(self.taskbar.panel_widget,
|
||||||
|
on_back=self.taskbar.collapse_panel)
|
||||||
|
self.quad_reveal = Gtk.Revealer(
|
||||||
|
transition_type=Gtk.RevealerTransitionType.SLIDE_UP,
|
||||||
|
transition_duration=200, reveal_child=True)
|
||||||
|
self.quad_reveal.set_child(self.grid)
|
||||||
|
self.root.append(self.quad_reveal)
|
||||||
|
|
||||||
|
# appdrawer
|
||||||
|
self.appdrawer = AppDrawer(settings, on_launch=self.hide_menu,
|
||||||
|
on_toggle_expand=self._on_appdrawer_expand)
|
||||||
|
self.appdrawer_wrap = bordered(self.appdrawer, fill_bg=True)
|
||||||
|
self.appdrawer_wrap.set_valign(Gtk.Align.FILL)
|
||||||
|
self.root.append(self.appdrawer_wrap)
|
||||||
|
|
||||||
|
overlay = Gtk.Overlay()
|
||||||
|
overlay.set_child(self.root)
|
||||||
|
|
||||||
|
self._hologram = HologramOverlay(enabled=config.hologram_enabled())
|
||||||
|
overlay.add_overlay(self._hologram.widget)
|
||||||
|
|
||||||
|
close = Gtk.Button(label="✕")
|
||||||
|
close.add_css_class("close-btn")
|
||||||
|
close.set_halign(Gtk.Align.END)
|
||||||
|
close.set_valign(Gtk.Align.START)
|
||||||
|
close.connect("clicked", lambda *_: self.hide_menu())
|
||||||
|
overlay.add_overlay(close)
|
||||||
|
self.set_child(overlay)
|
||||||
|
|
||||||
|
key = Gtk.EventControllerKey()
|
||||||
|
key.connect("key-pressed", self._on_key)
|
||||||
|
self.add_controller(key)
|
||||||
|
|
||||||
|
self._last_tick: float | None = None
|
||||||
|
self._tick_id: int | None = None
|
||||||
|
|
||||||
|
self.connect("map", lambda *_: self._apply_size())
|
||||||
|
self.set_visible(False)
|
||||||
|
|
||||||
|
# -- layer shell / size -----------------------------------------------
|
||||||
|
def _init_layer_shell(self) -> None:
|
||||||
|
LayerShell.init_for_window(self)
|
||||||
|
LayerShell.set_layer(self, LayerShell.Layer.TOP)
|
||||||
|
LayerShell.set_namespace(self, "astro-menu")
|
||||||
|
LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.ON_DEMAND)
|
||||||
|
self._apply_anchor()
|
||||||
|
|
||||||
|
def set_side(self, side: str) -> None:
|
||||||
|
"""Anchor the panel to the given monitor edge ('top'|'bottom'|'left'|'right').
|
||||||
|
The compositor's layer 'slide' animation then slides it in from that edge, and
|
||||||
|
the panel sits flush against it. Set before showing so the map animates."""
|
||||||
|
if side in SIDES:
|
||||||
|
self._side = side
|
||||||
|
self._apply_anchor()
|
||||||
|
|
||||||
|
def _apply_anchor(self) -> None:
|
||||||
|
"""Pin the panel to its side edge; while the drawer is expanded, also pin the
|
||||||
|
perpendicular edges so it fills the screen height."""
|
||||||
|
E = LayerShell.Edge
|
||||||
|
for edge in (E.TOP, E.BOTTOM, E.LEFT, E.RIGHT):
|
||||||
|
LayerShell.set_anchor(self, edge, False)
|
||||||
|
LayerShell.set_margin(self, edge, 0)
|
||||||
|
primary = {"top": E.TOP, "bottom": E.BOTTOM, "left": E.LEFT, "right": E.RIGHT}[self._side]
|
||||||
|
LayerShell.set_anchor(self, primary, True)
|
||||||
|
LayerShell.set_margin(self, primary, EDGE_MARGIN)
|
||||||
|
if self._drawer_expanded:
|
||||||
|
# fill vertically so the expanded drawer reaches top and bottom
|
||||||
|
for edge in (E.TOP, E.BOTTOM):
|
||||||
|
LayerShell.set_anchor(self, edge, True)
|
||||||
|
if edge != primary:
|
||||||
|
LayerShell.set_margin(self, edge, BOTTOM_MARGIN)
|
||||||
|
|
||||||
|
def _monitor_width(self) -> int:
|
||||||
|
display = Gdk.Display.get_default()
|
||||||
|
surface = self.get_surface()
|
||||||
|
mon = display.get_monitor_at_surface(surface) if surface is not None else None
|
||||||
|
if mon is None:
|
||||||
|
monitors = display.get_monitors()
|
||||||
|
mon = monitors.get_item(0) if monitors.get_n_items() else None
|
||||||
|
return mon.get_geometry().width if mon is not None else 1920
|
||||||
|
|
||||||
|
def _apply_size(self) -> None:
|
||||||
|
width = min(int(self._monitor_width() * PANEL_WIDTH_FRACTION), MAX_PANEL_WIDTH)
|
||||||
|
self.root.set_size_request(width, -1)
|
||||||
|
|
||||||
|
# -- taskbar panel expansion ------------------------------------------
|
||||||
|
def _on_taskbar_panel(self, expanded: bool) -> None:
|
||||||
|
# Cover the quads with the taskbar's workspace/window panel. Fully collapse the
|
||||||
|
# taskbar strip (header included) so the expanded panel reads as ONE module
|
||||||
|
# rather than a thin strip on top plus a detached card. Collapse any quad first.
|
||||||
|
if expanded and self.grid.is_expanded:
|
||||||
|
self.grid.request_collapse()
|
||||||
|
self.taskbar_reveal.set_reveal_child(not expanded)
|
||||||
|
self.taskbar_reveal.set_visible(not expanded)
|
||||||
|
self.grid.show_takeover() if expanded else self.grid.hide_takeover()
|
||||||
|
|
||||||
|
# -- appdrawer expansion ----------------------------------------------
|
||||||
|
def _on_appdrawer_expand(self, expanded: bool) -> None:
|
||||||
|
self._drawer_expanded = expanded
|
||||||
|
self._apply_anchor()
|
||||||
|
# Hide (not just un-reveal) the stats/taskbar/quads so they reserve zero space
|
||||||
|
# and the drawer fills the whole panel.
|
||||||
|
for rev in (self.statsbar_reveal, self.quad_reveal, self.taskbar_reveal):
|
||||||
|
rev.set_reveal_child(not expanded)
|
||||||
|
rev.set_visible(not expanded)
|
||||||
|
self.appdrawer_wrap.set_vexpand(expanded)
|
||||||
|
|
||||||
|
# -- visibility --------------------------------------------------------
|
||||||
|
def show_menu(self, focus_appdrawer: bool = False) -> None:
|
||||||
|
self.appdrawer.set_expanded(False)
|
||||||
|
self.taskbar.refresh()
|
||||||
|
self.grid.on_show()
|
||||||
|
self.appdrawer.on_show()
|
||||||
|
self.set_visible(True)
|
||||||
|
self.present()
|
||||||
|
GLib.timeout_add(30, lambda: (self._apply_size(), False)[1])
|
||||||
|
if focus_appdrawer:
|
||||||
|
self.appdrawer.set_expanded(True)
|
||||||
|
if self._hologram.enabled and self._tick_id is None:
|
||||||
|
self._tick_id = self.add_tick_callback(self._on_tick)
|
||||||
|
|
||||||
|
def hide_menu(self) -> None:
|
||||||
|
self.grid.on_hide()
|
||||||
|
self.taskbar.collapse_panel()
|
||||||
|
self.grid.hide_takeover()
|
||||||
|
self.appdrawer.set_expanded(False)
|
||||||
|
self.set_visible(False)
|
||||||
|
if self._tick_id is not None:
|
||||||
|
self.remove_tick_callback(self._tick_id)
|
||||||
|
self._tick_id = None
|
||||||
|
self._last_tick = None
|
||||||
|
|
||||||
|
def toggle(self, focus_appdrawer: bool = False) -> None:
|
||||||
|
if self.get_visible():
|
||||||
|
self.hide_menu()
|
||||||
|
else:
|
||||||
|
self.show_menu(focus_appdrawer)
|
||||||
|
|
||||||
|
# -- hologram animation loop --------------------------------------------
|
||||||
|
def _on_tick(self, _widget, frame_clock) -> bool:
|
||||||
|
now = frame_clock.get_frame_time() / 1_000_000
|
||||||
|
dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick)
|
||||||
|
self._last_tick = now
|
||||||
|
self._hologram.tick(dt)
|
||||||
|
return True # keep ticking every frame while the menu is visible
|
||||||
|
|
||||||
|
def _on_key(self, _c, keyval, _kc, _state) -> bool:
|
||||||
|
if keyval == Gdk.KEY_Escape:
|
||||||
|
self.hide_menu()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
@ -0,0 +1,272 @@
|
||||||
|
#? Config file for btop v.1.4.6
|
||||||
|
|
||||||
|
#* Name of a btop++/bpytop/bashtop formatted ".theme" file, "Default" and "TTY" for builtin themes.
|
||||||
|
#* Themes should be placed in "../share/btop/themes" relative to binary or "$HOME/.config/btop/themes"
|
||||||
|
color_theme = "/usr/share/btop/themes/cyberqueer.theme"
|
||||||
|
|
||||||
|
#* If the theme set background should be shown, set to False if you want terminal background transparency.
|
||||||
|
theme_background = true
|
||||||
|
|
||||||
|
#* Sets if 24-bit truecolor should be used, will convert 24-bit colors to 256 color (6x6x6 color cube) if false.
|
||||||
|
truecolor = true
|
||||||
|
|
||||||
|
#* Set to true to force tty mode regardless if a real tty has been detected or not.
|
||||||
|
#* Will force 16-color mode and TTY theme, set all graph symbols to "tty" and swap out other non tty friendly symbols.
|
||||||
|
force_tty = false
|
||||||
|
|
||||||
|
#* Define presets for the layout of the boxes. Preset 0 is always all boxes shown with default settings. Max 9 presets.
|
||||||
|
#* Format: "box_name:P:G,box_name:P:G" P=(0 or 1) for alternate positions, G=graph symbol to use for box.
|
||||||
|
#* Use whitespace " " as separator between different presets.
|
||||||
|
#* Example: "cpu:0:default,mem:0:tty,proc:1:default cpu:0:braille,proc:0:tty"
|
||||||
|
presets = "cpu:1:default,proc:0:default cpu:0:default,mem:0:default,net:0:default cpu:0:block,net:0:tty"
|
||||||
|
|
||||||
|
#* Set to True to enable "h,j,k,l,g,G" keys for directional control in lists.
|
||||||
|
#* Conflicting keys for h:"help" and k:"kill" is accessible while holding shift.
|
||||||
|
vim_keys = false
|
||||||
|
|
||||||
|
#* Rounded corners on boxes, is ignored if TTY mode is ON.
|
||||||
|
rounded_corners = true
|
||||||
|
|
||||||
|
#* Use terminal synchronized output sequences to reduce flickering on supported terminals.
|
||||||
|
terminal_sync = true
|
||||||
|
|
||||||
|
#* Default symbols to use for graph creation, "braille", "block" or "tty".
|
||||||
|
#* "braille" offers the highest resolution but might not be included in all fonts.
|
||||||
|
#* "block" has half the resolution of braille but uses more common characters.
|
||||||
|
#* "tty" uses only 3 different symbols but will work with most fonts and should work in a real TTY.
|
||||||
|
#* Note that "tty" only has half the horizontal resolution of the other two, so will show a shorter historical view.
|
||||||
|
graph_symbol = "braille"
|
||||||
|
|
||||||
|
# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty".
|
||||||
|
graph_symbol_cpu = "default"
|
||||||
|
|
||||||
|
# Graph symbol to use for graphs in gpu box, "default", "braille", "block" or "tty".
|
||||||
|
graph_symbol_gpu = "default"
|
||||||
|
|
||||||
|
# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty".
|
||||||
|
graph_symbol_mem = "default"
|
||||||
|
|
||||||
|
# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty".
|
||||||
|
graph_symbol_net = "default"
|
||||||
|
|
||||||
|
# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty".
|
||||||
|
graph_symbol_proc = "default"
|
||||||
|
|
||||||
|
#* Manually set which boxes to show. Available values are "cpu mem net proc" and "gpu0" through "gpu5", separate values with whitespace.
|
||||||
|
shown_boxes = "cpu mem net proc"
|
||||||
|
|
||||||
|
#* Update time in milliseconds, recommended 2000 ms or above for better sample times for graphs.
|
||||||
|
update_ms = 100
|
||||||
|
|
||||||
|
#* Processes sorting, "pid" "program" "arguments" "threads" "user" "memory" "cpu lazy" "cpu direct",
|
||||||
|
#* "cpu lazy" sorts top process over time (easier to follow), "cpu direct" updates top process directly.
|
||||||
|
proc_sorting = "cpu lazy"
|
||||||
|
|
||||||
|
#* Reverse sorting order, True or False.
|
||||||
|
proc_reversed = false
|
||||||
|
|
||||||
|
#* Show processes as a tree.
|
||||||
|
proc_tree = false
|
||||||
|
|
||||||
|
#* Use the cpu graph colors in the process list.
|
||||||
|
proc_colors = true
|
||||||
|
|
||||||
|
#* Use a darkening gradient in the process list.
|
||||||
|
proc_gradient = true
|
||||||
|
|
||||||
|
#* If process cpu usage should be of the core it's running on or usage of the total available cpu power.
|
||||||
|
proc_per_core = false
|
||||||
|
|
||||||
|
#* Show process memory as bytes instead of percent.
|
||||||
|
proc_mem_bytes = true
|
||||||
|
|
||||||
|
#* Show cpu graph for each process.
|
||||||
|
proc_cpu_graphs = true
|
||||||
|
|
||||||
|
#* Use /proc/[pid]/smaps for memory information in the process info box (very slow but more accurate)
|
||||||
|
proc_info_smaps = false
|
||||||
|
|
||||||
|
#* Show proc box on left side of screen instead of right.
|
||||||
|
proc_left = false
|
||||||
|
|
||||||
|
#* (Linux) Filter processes tied to the Linux kernel(similar behavior to htop).
|
||||||
|
proc_filter_kernel = false
|
||||||
|
|
||||||
|
#* In tree-view, always accumulate child process resources in the parent process.
|
||||||
|
proc_aggregate = false
|
||||||
|
|
||||||
|
#* Should cpu and memory usage display be preserved for dead processes when paused.
|
||||||
|
keep_dead_proc_usage = false
|
||||||
|
|
||||||
|
#* Sets the CPU stat shown in upper half of the CPU graph, "total" is always available.
|
||||||
|
#* Select from a list of detected attributes from the options menu.
|
||||||
|
cpu_graph_upper = "Auto"
|
||||||
|
|
||||||
|
#* Sets the CPU stat shown in lower half of the CPU graph, "total" is always available.
|
||||||
|
#* Select from a list of detected attributes from the options menu.
|
||||||
|
cpu_graph_lower = "Auto"
|
||||||
|
|
||||||
|
#* If gpu info should be shown in the cpu box. Available values = "Auto", "On" and "Off".
|
||||||
|
show_gpu_info = "Auto"
|
||||||
|
|
||||||
|
#* Toggles if the lower CPU graph should be inverted.
|
||||||
|
cpu_invert_lower = true
|
||||||
|
|
||||||
|
#* Set to True to completely disable the lower CPU graph.
|
||||||
|
cpu_single_graph = false
|
||||||
|
|
||||||
|
#* Show cpu box at bottom of screen instead of top.
|
||||||
|
cpu_bottom = false
|
||||||
|
|
||||||
|
#* Shows the system uptime in the CPU box.
|
||||||
|
show_uptime = true
|
||||||
|
|
||||||
|
#* Shows the CPU package current power consumption in watts. Requires running `make setcap` or `make setuid` or running with sudo.
|
||||||
|
show_cpu_watts = true
|
||||||
|
|
||||||
|
#* Show cpu temperature.
|
||||||
|
check_temp = true
|
||||||
|
|
||||||
|
#* Which sensor to use for cpu temperature, use options menu to select from list of available sensors.
|
||||||
|
cpu_sensor = "Auto"
|
||||||
|
|
||||||
|
#* Show temperatures for cpu cores also if check_temp is True and sensors has been found.
|
||||||
|
show_coretemp = true
|
||||||
|
|
||||||
|
#* Set a custom mapping between core and coretemp, can be needed on certain cpus to get correct temperature for correct core.
|
||||||
|
#* Use lm-sensors or similar to see which cores are reporting temperatures on your machine.
|
||||||
|
#* Format "x:y" x=core with wrong temp, y=core with correct temp, use space as separator between multiple entries.
|
||||||
|
#* Example: "4:0 5:1 6:3"
|
||||||
|
cpu_core_map = ""
|
||||||
|
|
||||||
|
#* Which temperature scale to use, available values: "celsius", "fahrenheit", "kelvin" and "rankine".
|
||||||
|
temp_scale = "celsius"
|
||||||
|
|
||||||
|
#* Use base 10 for bits/bytes sizes, KB = 1000 instead of KiB = 1024.
|
||||||
|
base_10_sizes = false
|
||||||
|
|
||||||
|
#* Show CPU frequency.
|
||||||
|
show_cpu_freq = true
|
||||||
|
|
||||||
|
#* How to calculate CPU frequency, available values: "first", "range", "lowest", "highest" and "average".
|
||||||
|
freq_mode = "first"
|
||||||
|
|
||||||
|
#* Draw a clock at top of screen, formatting according to strftime, empty string to disable.
|
||||||
|
#* Special formatting: /host = hostname | /user = username | /uptime = system uptime
|
||||||
|
clock_format = "%X"
|
||||||
|
|
||||||
|
#* Update main ui in background when menus are showing, set this to false if the menus is flickering too much for comfort.
|
||||||
|
background_update = true
|
||||||
|
|
||||||
|
#* Custom cpu model name, empty string to disable.
|
||||||
|
custom_cpu_name = ""
|
||||||
|
|
||||||
|
#* Optional filter for shown disks, should be full path of a mountpoint, separate multiple values with whitespace " ".
|
||||||
|
#* Only disks matching the filter will be shown. Prepend exclude= to only show disks not matching the filter. Examples: disk_filter="/boot /home/user", disks_filter="exclude=/boot /home/user"
|
||||||
|
disks_filter = ""
|
||||||
|
|
||||||
|
#* Show graphs instead of meters for memory values.
|
||||||
|
mem_graphs = true
|
||||||
|
|
||||||
|
#* Show mem box below net box instead of above.
|
||||||
|
mem_below_net = false
|
||||||
|
|
||||||
|
#* Count ZFS ARC in cached and available memory.
|
||||||
|
zfs_arc_cached = true
|
||||||
|
|
||||||
|
#* If swap memory should be shown in memory box.
|
||||||
|
show_swap = true
|
||||||
|
|
||||||
|
#* Show swap as a disk, ignores show_swap value above, inserts itself after first disk.
|
||||||
|
swap_disk = true
|
||||||
|
|
||||||
|
#* If mem box should be split to also show disks info.
|
||||||
|
show_disks = true
|
||||||
|
|
||||||
|
#* Filter out non physical disks. Set this to False to include network disks, RAM disks and similar.
|
||||||
|
only_physical = true
|
||||||
|
|
||||||
|
#* Read disks list from /etc/fstab. This also disables only_physical.
|
||||||
|
use_fstab = true
|
||||||
|
|
||||||
|
#* Setting this to True will hide all datasets, and only show ZFS pools. (IO stats will be calculated per-pool)
|
||||||
|
zfs_hide_datasets = false
|
||||||
|
|
||||||
|
#* Set to true to show available disk space for privileged users.
|
||||||
|
disk_free_priv = false
|
||||||
|
|
||||||
|
#* Toggles if io activity % (disk busy time) should be shown in regular disk usage view.
|
||||||
|
show_io_stat = true
|
||||||
|
|
||||||
|
#* Toggles io mode for disks, showing big graphs for disk read/write speeds.
|
||||||
|
io_mode = false
|
||||||
|
|
||||||
|
#* Set to True to show combined read/write io graphs in io mode.
|
||||||
|
io_graph_combined = false
|
||||||
|
|
||||||
|
#* Set the top speed for the io graphs in MiB/s (100 by default), use format "mountpoint:speed" separate disks with whitespace " ".
|
||||||
|
#* Example: "/mnt/media:100 /:20 /boot:1".
|
||||||
|
io_graph_speeds = ""
|
||||||
|
|
||||||
|
#* Set fixed values for network graphs in Mebibits. Is only used if net_auto is also set to False.
|
||||||
|
net_download = 100
|
||||||
|
|
||||||
|
net_upload = 100
|
||||||
|
|
||||||
|
#* Use network graphs auto rescaling mode, ignores any values set above and rescales down to 10 Kibibytes at the lowest.
|
||||||
|
net_auto = true
|
||||||
|
|
||||||
|
#* Sync the auto scaling for download and upload to whichever currently has the highest scale.
|
||||||
|
net_sync = true
|
||||||
|
|
||||||
|
#* Starts with the Network Interface specified here.
|
||||||
|
net_iface = ""
|
||||||
|
|
||||||
|
#* "True" shows bitrates in base 10 (Kbps, Mbps). "False" shows bitrates in binary sizes (Kibps, Mibps, etc.). "Auto" uses base_10_sizes.
|
||||||
|
base_10_bitrate = "Auto"
|
||||||
|
|
||||||
|
#* Show battery stats in top right if battery is present.
|
||||||
|
show_battery = true
|
||||||
|
|
||||||
|
#* Which battery to use if multiple are present. "Auto" for auto detection.
|
||||||
|
selected_battery = "Auto"
|
||||||
|
|
||||||
|
#* Show power stats of battery next to charge indicator.
|
||||||
|
show_battery_watts = true
|
||||||
|
|
||||||
|
#* Set loglevel for "~/.local/state/btop.log" levels are: "ERROR" "WARNING" "INFO" "DEBUG".
|
||||||
|
#* The level set includes all lower levels, i.e. "DEBUG" will show all logging info.
|
||||||
|
log_level = "WARNING"
|
||||||
|
|
||||||
|
#* Automatically save current settings to config file on exit.
|
||||||
|
save_config_on_exit = true
|
||||||
|
|
||||||
|
#* Measure PCIe throughput on NVIDIA cards, may impact performance on certain cards.
|
||||||
|
nvml_measure_pcie_speeds = true
|
||||||
|
|
||||||
|
#* Measure PCIe throughput on AMD cards, may impact performance on certain cards.
|
||||||
|
rsmi_measure_pcie_speeds = true
|
||||||
|
|
||||||
|
#* Horizontally mirror the GPU graph.
|
||||||
|
gpu_mirror_graph = true
|
||||||
|
|
||||||
|
#* Set which GPU vendors to show. Available values are "nvidia amd intel"
|
||||||
|
shown_gpus = "nvidia amd intel"
|
||||||
|
|
||||||
|
#* Custom gpu0 model name, empty string to disable.
|
||||||
|
custom_gpu_name0 = ""
|
||||||
|
|
||||||
|
#* Custom gpu1 model name, empty string to disable.
|
||||||
|
custom_gpu_name1 = ""
|
||||||
|
|
||||||
|
#* Custom gpu2 model name, empty string to disable.
|
||||||
|
custom_gpu_name2 = ""
|
||||||
|
|
||||||
|
#* Custom gpu3 model name, empty string to disable.
|
||||||
|
custom_gpu_name3 = ""
|
||||||
|
|
||||||
|
#* Custom gpu4 model name, empty string to disable.
|
||||||
|
custom_gpu_name4 = ""
|
||||||
|
|
||||||
|
#* Custom gpu5 model name, empty string to disable.
|
||||||
|
custom_gpu_name5 = ""
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
#Bashtop monokai theme
|
||||||
|
#by aristocratos
|
||||||
|
|
||||||
|
# Colors should be in 6 or 2 character hexadecimal or single spaced rgb decimal: "#RRGGBB", "#BW" or "0-255 0-255 0-255"
|
||||||
|
# example for white: "#FFFFFF", "#ff" or "255 255 255".
|
||||||
|
|
||||||
|
# All graphs and meters can be gradients
|
||||||
|
# For single color graphs leave "mid" and "end" variable empty.
|
||||||
|
# Use "start" and "end" variables for two color gradient
|
||||||
|
# Use "start", "mid" and "end" for three color gradient
|
||||||
|
|
||||||
|
# Main background, empty for terminal default, need to be empty if you want transparent background
|
||||||
|
theme[main_bg]="#1a1a1a"
|
||||||
|
|
||||||
|
# Main text color
|
||||||
|
theme[main_fg]="#E40046"
|
||||||
|
|
||||||
|
# Title color for boxes
|
||||||
|
theme[title]="#d6abab"
|
||||||
|
|
||||||
|
# Highlight color for keyboard shortcuts
|
||||||
|
theme[hi_fg]="#f50505"
|
||||||
|
|
||||||
|
# Background color of selected item in processes box
|
||||||
|
theme[selected_bg]="#7a1137"
|
||||||
|
|
||||||
|
# Foreground color of selected item in processes box
|
||||||
|
theme[selected_fg]="#F8F8F2"
|
||||||
|
|
||||||
|
# Color of inactive/disabled text
|
||||||
|
theme[inactive_fg]="#5018dd"
|
||||||
|
|
||||||
|
# Color of text appearing on top of graphs, i.e uptime and current network graph scaling
|
||||||
|
theme[graph_text]="#797667"
|
||||||
|
|
||||||
|
# Misc colors for processes box including mini cpu graphs, details memory graph and details status text
|
||||||
|
theme[proc_misc]="#A6E22E"
|
||||||
|
|
||||||
|
# Cpu box outline color
|
||||||
|
theme[cpu_box]="#5018dd"
|
||||||
|
|
||||||
|
# Memory/disks box outline color
|
||||||
|
theme[mem_box]="#5018dd"
|
||||||
|
|
||||||
|
# Net up/down box outline color
|
||||||
|
theme[net_box]="#5018dd"
|
||||||
|
|
||||||
|
# Processes box outline color
|
||||||
|
theme[proc_box]="#5018dd"
|
||||||
|
|
||||||
|
# Box divider line and small boxes line color
|
||||||
|
theme[div_line]="#5018dd"
|
||||||
|
|
||||||
|
# Temperature graph colors
|
||||||
|
theme[temp_start]="#5018dd"
|
||||||
|
theme[temp_mid]="#E40046"
|
||||||
|
theme[temp_end]="#f50505"
|
||||||
|
|
||||||
|
# CPU graph colors
|
||||||
|
theme[cpu_start]="#5018dd"
|
||||||
|
theme[cpu_mid]="#E40046"
|
||||||
|
theme[cpu_end]="#f50505"
|
||||||
|
|
||||||
|
# Mem/Disk free meter
|
||||||
|
theme[free_start]="#5018dd"
|
||||||
|
theme[free_mid]="#E40046"
|
||||||
|
theme[free_end]="#f50505"
|
||||||
|
|
||||||
|
# Mem/Disk cached meter
|
||||||
|
theme[cached_start]="#5018dd"
|
||||||
|
theme[cached_mid]="#E40046"
|
||||||
|
theme[cached_end]="#f50505"
|
||||||
|
|
||||||
|
# Mem/Disk available meter
|
||||||
|
theme[available_start]="#5018dd"
|
||||||
|
theme[available_mid]="#E40046"
|
||||||
|
theme[available_end]="#f50505"
|
||||||
|
|
||||||
|
# Mem/Disk used meter
|
||||||
|
theme[used_start]="#5018dd"
|
||||||
|
theme[used_mid]="#E40046"
|
||||||
|
theme[used_end]="#f50505"
|
||||||
|
|
||||||
|
# Download graph colors
|
||||||
|
theme[download_start]="#5018dd"
|
||||||
|
theme[download_mid]="#E40046"
|
||||||
|
theme[download_end]="#f50505"
|
||||||
|
|
||||||
|
# Upload graph colors
|
||||||
|
theme[upload_start]="#5018dd"
|
||||||
|
theme[upload_mid]="#E40046"
|
||||||
|
theme[upload_end]="#f50505"
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# update-configs — sync dotfiles configs into ~/.config
|
||||||
|
#
|
||||||
|
# Config: ~/.config/config-updater/updater.conf
|
||||||
|
# Manifest: ~/.config/config-updater/manifest
|
||||||
|
#
|
||||||
|
# Syntax in updater.conf:
|
||||||
|
# config <name> [except <sub1> <sub2> ...]
|
||||||
|
# copy SOURCE_BASE/<name> to ~/.config/<name>, optionally skipping subdirs
|
||||||
|
# flat <name>
|
||||||
|
# copy contents of SOURCE_BASE/<name> directly into ~/.config/
|
||||||
|
# ignore <name>
|
||||||
|
# present in source but intentionally not managed here
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/config-updater"
|
||||||
|
CONF_FILE="${CONF_DIR}/updater.conf"
|
||||||
|
MANIFEST="${CONF_DIR}/manifest"
|
||||||
|
TARGET="${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||||
|
|
||||||
|
RED='\033[0;31m'; YEL='\033[1;33m'; GRN='\033[0;32m'; DIM='\033[2m'; RST='\033[0m'
|
||||||
|
err() { printf "${RED}✖ %s${RST}\n" "$*" >&2; }
|
||||||
|
warn() { printf "${YEL}⚠ %s${RST}\n" "$*"; }
|
||||||
|
ok() { printf "${GRN}✔ %s${RST}\n" "$*"; }
|
||||||
|
note() { printf "${DIM} %s${RST}\n" "$*"; }
|
||||||
|
die() { err "$*"; exit 1; }
|
||||||
|
|
||||||
|
[[ -f "$CONF_FILE" ]] || die "Config not found: $CONF_FILE"
|
||||||
|
|
||||||
|
# ── parse updater.conf ────────────────────────────────────────────────────────
|
||||||
|
SOURCE_BASE=""
|
||||||
|
declare -A ENTRY_TYPE # name → config | flat | ignore
|
||||||
|
declare -A ENTRY_EXCL # name → space-separated list of excluded subdirs
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
line="${line%%#*}"
|
||||||
|
line="${line#"${line%%[! ]*}"}"
|
||||||
|
line="${line%"${line##*[! ]}"}"
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
|
||||||
|
if [[ "$line" =~ ^SOURCE_BASE[[:space:]]*=[[:space:]]*(.+)$ ]]; then
|
||||||
|
src="${BASH_REMATCH[1]%"${BASH_REMATCH[1]##*[! ]}"}"
|
||||||
|
SOURCE_BASE="${src/#\~/$HOME}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -r type name rest <<< "$line"
|
||||||
|
[[ -z "${name:-}" ]] && continue
|
||||||
|
|
||||||
|
case "$type" in
|
||||||
|
config|flat|ignore)
|
||||||
|
ENTRY_TYPE["$name"]="$type"
|
||||||
|
# Parse optional "except sub1 sub2 ..."
|
||||||
|
if [[ "${rest:-}" =~ ^except[[:space:]]+(.+)$ ]]; then
|
||||||
|
ENTRY_EXCL["$name"]="${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
ENTRY_EXCL["$name"]=""
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*) warn "Unknown type '$type' for '$name' — skipping" ;;
|
||||||
|
esac
|
||||||
|
done < "$CONF_FILE"
|
||||||
|
|
||||||
|
[[ -n "$SOURCE_BASE" ]] || die "SOURCE_BASE not defined in $CONF_FILE"
|
||||||
|
[[ -d "$SOURCE_BASE" ]] || die "SOURCE_BASE not found: $SOURCE_BASE"
|
||||||
|
|
||||||
|
# ── load previous manifest ────────────────────────────────────────────────────
|
||||||
|
declare -A PREV_MANIFEST
|
||||||
|
if [[ -f "$MANIFEST" ]]; then
|
||||||
|
while IFS=: read -r mtype mname; do
|
||||||
|
[[ -n "${mtype:-}" && -n "${mname:-}" ]] && PREV_MANIFEST["$mname"]="$mtype"
|
||||||
|
done < "$MANIFEST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
warned=0
|
||||||
|
|
||||||
|
# ── warn: manifest entries no longer in updater.conf ─────────────────────────
|
||||||
|
for mname in "${!PREV_MANIFEST[@]}"; do
|
||||||
|
[[ -n "${ENTRY_TYPE[$mname]:-}" ]] && continue
|
||||||
|
warn "'$mname' was previously managed but is no longer in updater.conf"
|
||||||
|
note "Remove ~/.config/$mname manually if it is no longer needed"
|
||||||
|
warned=1
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── warn: source items not covered by updater.conf ───────────────────────────
|
||||||
|
while IFS= read -r -d '' item; do
|
||||||
|
name="$(basename "$item")"
|
||||||
|
[[ "$name" == .* ]] && continue
|
||||||
|
[[ -n "${ENTRY_TYPE[$name]:-}" ]] && continue
|
||||||
|
warn "Untracked source item: '$name' — add to updater.conf (config / flat / ignore)"
|
||||||
|
warned=1
|
||||||
|
done < <(find "$SOURCE_BASE" -maxdepth 1 -mindepth 1 -print0 | sort -z)
|
||||||
|
|
||||||
|
(( warned )) && printf '\n'
|
||||||
|
|
||||||
|
# ── apply ─────────────────────────────────────────────────────────────────────
|
||||||
|
errors=0
|
||||||
|
new_manifest=()
|
||||||
|
|
||||||
|
for name in "${!ENTRY_TYPE[@]}"; do
|
||||||
|
type="${ENTRY_TYPE[$name]}"
|
||||||
|
[[ "$type" == ignore ]] && continue
|
||||||
|
|
||||||
|
src="${SOURCE_BASE}/${name}"
|
||||||
|
if [[ ! -e "$src" ]]; then
|
||||||
|
err "Source missing: $src"
|
||||||
|
(( errors++ )) || true
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
excl="${ENTRY_EXCL[$name]:-}"
|
||||||
|
|
||||||
|
case "$type" in
|
||||||
|
config)
|
||||||
|
if [[ -z "$excl" ]]; then
|
||||||
|
rm -rf "${TARGET:?}/${name}"
|
||||||
|
cp -r "$src" "$TARGET/$name"
|
||||||
|
ok "config $name"
|
||||||
|
else
|
||||||
|
# Copy all top-level items of $src except excluded subdirs
|
||||||
|
mkdir -p "${TARGET}/${name}"
|
||||||
|
item_errors=0
|
||||||
|
while IFS= read -r -d '' item; do
|
||||||
|
item_name="$(basename "$item")"
|
||||||
|
skip_item=false
|
||||||
|
for e in $excl; do
|
||||||
|
[[ "$item_name" == "$e" ]] && skip_item=true && break
|
||||||
|
done
|
||||||
|
[[ "$skip_item" == true ]] && continue
|
||||||
|
rm -rf "${TARGET:?}/${name}/${item_name}"
|
||||||
|
cp -r "$item" "${TARGET}/${name}/" || (( item_errors++ )) || true
|
||||||
|
done < <(find "$src" -maxdepth 1 -mindepth 1 -print0 | sort -z)
|
||||||
|
if (( item_errors > 0 )); then
|
||||||
|
err "config $name (${item_errors} error(s))"
|
||||||
|
(( errors++ )) || true
|
||||||
|
else
|
||||||
|
ok "config $name (excl: $excl)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
flat)
|
||||||
|
[[ -d "$src" ]] || {
|
||||||
|
err "flat entry '$name' must be a directory: $src"
|
||||||
|
(( errors++ )) || true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cp -r "${src}/." "$TARGET/"
|
||||||
|
ok "flat $name → (contents into ~/.config/)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
new_manifest+=("${type}:${name}")
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── write manifest ────────────────────────────────────────────────────────────
|
||||||
|
printf '%s\n' "${new_manifest[@]}" | sort > "$MANIFEST"
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
if (( errors > 0 )); then
|
||||||
|
err "$errors error(s) — manifest may be incomplete"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
ok "Done — manifest updated at $MANIFEST"
|
||||||
|
fi
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Config updater — defines which dotfiles configs are deployed to ~/.config
|
||||||
|
#
|
||||||
|
# SOURCE_BASE base directory containing all configs in the dotfiles
|
||||||
|
#
|
||||||
|
# config <name> copy SOURCE_BASE/<name> to ~/.config/<name>
|
||||||
|
# flat <name> copy contents of SOURCE_BASE/<name> directly into ~/.config/
|
||||||
|
# (for installation-specific files that live at the ~/.config root)
|
||||||
|
# ignore <name> present in source but intentionally not managed here
|
||||||
|
|
||||||
|
SOURCE_BASE = ~/Dotfiles/desktopenvs/hyprdrive
|
||||||
|
|
||||||
|
# ── deployed as ~/.config/<name> ─────────────────────────────────────────────
|
||||||
|
config alacritty
|
||||||
|
config astro-menu
|
||||||
|
config btop
|
||||||
|
config dunst
|
||||||
|
config gtk-3.0
|
||||||
|
config hypr except usr
|
||||||
|
config horizon-dock
|
||||||
|
config kitty
|
||||||
|
config mimeapps.list
|
||||||
|
config orbit-menu
|
||||||
|
config scripts
|
||||||
|
config station-bar
|
||||||
|
config vicinae
|
||||||
|
config xfce4
|
||||||
|
|
||||||
|
# ── intentionally not managed here ───────────────────────────────────────────
|
||||||
|
ignore config-updater # the updater itself
|
||||||
|
ignore CRT # referenced from dotfiles path directly in binds.conf
|
||||||
|
ignore greetd-tuigreet # deployed to /etc/greetd/ at install time
|
||||||
|
ignore spicetify # managed separately (spicetify handles its own config)
|
||||||
|
ignore Vencord # managed separately
|
||||||
|
ignore wallpaper # deployed to ~/Pictures/ at install time, not ~/.config
|
||||||
|
# hypr/usr/ contains device-specific lua files (monitors, binds, input, etc.).
|
||||||
|
# They are excluded from automated syncs to preserve per-device customisations.
|
||||||
|
# Deploy manually on a new device:
|
||||||
|
# cp -r ~/Dotfiles/desktopenvs/hyprdrive/hypr/usr/ ~/.config/hypr/
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/home/themiro/Dotfiles/desktopenvs/hyprland/dunst/
|
||||||
|
|
@ -0,0 +1,492 @@
|
||||||
|
# See dunst(5) for all configuration options
|
||||||
|
|
||||||
|
[global]
|
||||||
|
### Display ###
|
||||||
|
|
||||||
|
# Which monitor should the notifications be displayed on.
|
||||||
|
monitor = 0
|
||||||
|
|
||||||
|
# Display notification on focused monitor. Possible modes are:
|
||||||
|
# mouse: follow mouse pointer
|
||||||
|
# keyboard: follow window with keyboard focus
|
||||||
|
# none: don't follow anything
|
||||||
|
#
|
||||||
|
# "keyboard" needs a window manager that exports the
|
||||||
|
# _NET_ACTIVE_WINDOW property.
|
||||||
|
# This should be the case for almost all modern window managers.
|
||||||
|
#
|
||||||
|
# If this option is set to mouse or keyboard, the monitor option
|
||||||
|
# will be ignored.
|
||||||
|
follow = none
|
||||||
|
|
||||||
|
### Geometry ###
|
||||||
|
|
||||||
|
# The width of the window, excluding the frame.
|
||||||
|
# dynamic width from 0 to 300
|
||||||
|
# width = (0, 300)
|
||||||
|
# constant width of 300
|
||||||
|
width = 300
|
||||||
|
|
||||||
|
# The height of a single notification, excluding the frame.
|
||||||
|
# dynamic height from 0 to 300
|
||||||
|
height = (0, 300)
|
||||||
|
# constant height of 300
|
||||||
|
# height = 300
|
||||||
|
|
||||||
|
# Position the notification in the top right corner
|
||||||
|
origin = top-center
|
||||||
|
|
||||||
|
# Offset from the origin
|
||||||
|
offset = (10, 50)
|
||||||
|
|
||||||
|
# Scale factor. It is auto-detected if value is 0.
|
||||||
|
scale = 0
|
||||||
|
|
||||||
|
# Maximum number of notification (0 means no limit)
|
||||||
|
notification_limit = 20
|
||||||
|
|
||||||
|
### Progress bar ###
|
||||||
|
|
||||||
|
# Turn on the progress bar. It appears when a progress hint is passed with
|
||||||
|
# for example dunstify -h int:value:12
|
||||||
|
progress_bar = true
|
||||||
|
|
||||||
|
# Set the progress bar height. This includes the frame, so make sure
|
||||||
|
# it's at least twice as big as the frame width.
|
||||||
|
progress_bar_height = 10
|
||||||
|
|
||||||
|
# Set the frame width of the progress bar
|
||||||
|
progress_bar_frame_width = 1
|
||||||
|
|
||||||
|
# Set the minimum width for the progress bar
|
||||||
|
progress_bar_min_width = 150
|
||||||
|
|
||||||
|
# Set the maximum width for the progress bar
|
||||||
|
progress_bar_max_width = 300
|
||||||
|
|
||||||
|
# Corner radius for the progress bar. 0 disables rounded corners.
|
||||||
|
progress_bar_corner_radius = 0
|
||||||
|
|
||||||
|
# Define which corners to round when drawing the progress bar. If progress_bar_corner_radius
|
||||||
|
# is set to 0 this option will be ignored.
|
||||||
|
progress_bar_corners = all
|
||||||
|
|
||||||
|
# Corner radius for the image.
|
||||||
|
icon_corner_radius = 23
|
||||||
|
|
||||||
|
# Define which corners to round when drawing the icon image. If icon_corner_radius
|
||||||
|
# is set to 0 this option will be ignored.
|
||||||
|
icon_corners = all
|
||||||
|
|
||||||
|
# Show how many messages are currently hidden (because of
|
||||||
|
# notification_limit).
|
||||||
|
indicate_hidden = yes
|
||||||
|
|
||||||
|
# The transparency of the window. Range: [0; 100].
|
||||||
|
# This option will only work if a compositing window manager is
|
||||||
|
# present (e.g. xcompmgr, compiz, etc.). (X11 only)
|
||||||
|
transparency = 0
|
||||||
|
|
||||||
|
# Draw a line of "separator_height" pixel height between two
|
||||||
|
# notifications.
|
||||||
|
# Set to 0 to disable.
|
||||||
|
# If gap_size is greater than 0, this setting will be ignored.
|
||||||
|
separator_height = 2
|
||||||
|
|
||||||
|
# Padding between text and separator.
|
||||||
|
padding = 4
|
||||||
|
|
||||||
|
# Horizontal padding.
|
||||||
|
horizontal_padding = 8
|
||||||
|
|
||||||
|
# Padding between text and icon.
|
||||||
|
text_icon_padding = 0
|
||||||
|
|
||||||
|
# Defines width in pixels of frame around the notification window.
|
||||||
|
# Set to 0 to disable.
|
||||||
|
frame_width = 3
|
||||||
|
|
||||||
|
# Defines color of the frame around the notification window.
|
||||||
|
frame_color = "#5018DD"
|
||||||
|
|
||||||
|
# Size of gap to display between notifications - requires a compositor.
|
||||||
|
# If value is greater than 0, separator_height will be ignored and a border
|
||||||
|
# of size frame_width will be drawn around each notification instead.
|
||||||
|
# Click events on gaps do not currently propagate to applications below.
|
||||||
|
gap_size = 5
|
||||||
|
|
||||||
|
# Define a color for the separator.
|
||||||
|
# possible values are:
|
||||||
|
# * auto: dunst tries to find a color fitting to the background;
|
||||||
|
# * foreground: use the same color as the foreground;
|
||||||
|
# * frame: use the same color as the frame;
|
||||||
|
# * anything else will be interpreted as a X color.
|
||||||
|
separator_color = frame
|
||||||
|
|
||||||
|
# Sort type.
|
||||||
|
# possible values are:
|
||||||
|
# * id: sort by id
|
||||||
|
# * urgency_ascending: sort by urgency (low then normal then critical)
|
||||||
|
# * urgency_descending: sort by urgency (critical then normal then low)
|
||||||
|
# * update: sort by update (most recent always at the top)
|
||||||
|
sort = yes
|
||||||
|
|
||||||
|
# Don't remove messages, if the user is idle (no mouse or keyboard input)
|
||||||
|
# for longer than idle_threshold seconds.
|
||||||
|
# Set to 0 to disable.
|
||||||
|
# A client can set the 'transient' hint to bypass this. See the rules
|
||||||
|
# section for how to disable this if necessary
|
||||||
|
# idle_threshold = 120
|
||||||
|
|
||||||
|
### Text ###
|
||||||
|
|
||||||
|
font = Agave Nerd Font 12
|
||||||
|
|
||||||
|
# The spacing between lines. If the height is smaller than the
|
||||||
|
# font height, it will get raised to the font height.
|
||||||
|
line_height = 0
|
||||||
|
|
||||||
|
# Possible values are:
|
||||||
|
# full: Allow a small subset of html markup in notifications:
|
||||||
|
# <b>bold</b>
|
||||||
|
# <i>italic</i>
|
||||||
|
# <s>strikethrough</s>
|
||||||
|
# <u>underline</u>
|
||||||
|
#
|
||||||
|
# For a complete reference see
|
||||||
|
# <https://docs.gtk.org/Pango/pango_markup.html>.
|
||||||
|
#
|
||||||
|
# strip: This setting is provided for compatibility with some broken
|
||||||
|
# clients that send markup even though it's not enabled on the
|
||||||
|
# server. Dunst will try to strip the markup but the parsing is
|
||||||
|
# simplistic so using this option outside of matching rules for
|
||||||
|
# specific applications *IS GREATLY DISCOURAGED*.
|
||||||
|
#
|
||||||
|
# no: Disable markup parsing, incoming notifications will be treated as
|
||||||
|
# plain text. Dunst will not advertise that it has the body-markup
|
||||||
|
# capability if this is set as a global setting.
|
||||||
|
#
|
||||||
|
# It's important to note that markup inside the format option will be parsed
|
||||||
|
# regardless of what this is set to.
|
||||||
|
markup = full
|
||||||
|
|
||||||
|
# The format of the message. Possible variables are:
|
||||||
|
# %a appname
|
||||||
|
# %s summary
|
||||||
|
# %b body
|
||||||
|
# %i iconname (including its path)
|
||||||
|
# %I iconname (without its path)
|
||||||
|
# %p progress value if set ([ 0%] to [100%]) or nothing
|
||||||
|
# %n progress value if set without any extra characters
|
||||||
|
# %% Literal %
|
||||||
|
# Markup is allowed
|
||||||
|
format = "<b>%s</b>\n%b"
|
||||||
|
|
||||||
|
# Alignment of message text.
|
||||||
|
# Possible values are "left", "center" and "right".
|
||||||
|
alignment = center
|
||||||
|
|
||||||
|
# Vertical alignment of message text and icon.
|
||||||
|
# Possible values are "top", "center" and "bottom".
|
||||||
|
vertical_alignment = center
|
||||||
|
|
||||||
|
# Show age of message if message is older than show_age_threshold
|
||||||
|
# seconds.
|
||||||
|
# Set to -1 to disable.
|
||||||
|
show_age_threshold = 60
|
||||||
|
|
||||||
|
# Specify where to make an ellipsis in long lines.
|
||||||
|
# Possible values are "start", "middle" and "end".
|
||||||
|
ellipsize = middle
|
||||||
|
|
||||||
|
# Ignore newlines '\n' in notifications.
|
||||||
|
ignore_newline = no
|
||||||
|
|
||||||
|
# Stack together notifications with the same content
|
||||||
|
stack_duplicates = true
|
||||||
|
|
||||||
|
# Hide the count of stacked notifications with the same content
|
||||||
|
hide_duplicate_count = false
|
||||||
|
|
||||||
|
# Display indicators for URLs (U) and actions (A).
|
||||||
|
show_indicators = yes
|
||||||
|
|
||||||
|
### Icons ###
|
||||||
|
|
||||||
|
# Recursive icon lookup. You can set a single theme, instead of having to
|
||||||
|
# define all lookup paths.
|
||||||
|
enable_recursive_icon_lookup = true
|
||||||
|
|
||||||
|
# Set icon theme (only used for recursive icon lookup)
|
||||||
|
icon_theme = "Papirus-Dark, Adwaita"
|
||||||
|
# You can also set multiple icon themes, with the leftmost one being used first.
|
||||||
|
# icon_theme = "Adwaita, breeze"
|
||||||
|
|
||||||
|
# Align icons left/right/top/off
|
||||||
|
icon_position = top
|
||||||
|
|
||||||
|
# Scale small icons up to this size, set to 0 to disable. Helpful
|
||||||
|
# for e.g. small files or high-dpi screens. In case of conflict,
|
||||||
|
# max_icon_size takes precedence over this.
|
||||||
|
min_icon_size = 32
|
||||||
|
|
||||||
|
# Scale larger icons down to this size, set to 0 to disable
|
||||||
|
max_icon_size = 128
|
||||||
|
|
||||||
|
# Paths to default icons (only necessary when not using recursive icon lookup)
|
||||||
|
icon_path = /usr/share/icons/gnome/16x16/status/:/usr/share/icons/gnome/16x16/devices/
|
||||||
|
|
||||||
|
### History ###
|
||||||
|
|
||||||
|
# Should a notification popped up from history be sticky or timeout
|
||||||
|
# as if it would normally do.
|
||||||
|
sticky_history = yes
|
||||||
|
|
||||||
|
# Maximum amount of notifications kept in history
|
||||||
|
history_length = 20
|
||||||
|
|
||||||
|
### Misc/Advanced ###
|
||||||
|
|
||||||
|
# dmenu path.
|
||||||
|
dmenu = /usr/bin/dmenu -p dunst:
|
||||||
|
|
||||||
|
# Browser for opening urls in context menu.
|
||||||
|
browser = /usr/bin/xdg-open
|
||||||
|
|
||||||
|
# Always run rule-defined scripts, even if the notification is suppressed
|
||||||
|
always_run_script = true
|
||||||
|
|
||||||
|
# Define the title of the windows spawned by dunst (X11 only)
|
||||||
|
title = Dunst
|
||||||
|
|
||||||
|
# Define the class of the windows spawned by dunst (X11 only)
|
||||||
|
class = Dunst
|
||||||
|
|
||||||
|
# Define the corner radius of the notification window
|
||||||
|
# in pixel size. If the radius is 0, you have no rounded
|
||||||
|
# corners.
|
||||||
|
# The radius will be automatically lowered if it exceeds half of the
|
||||||
|
# notification height to avoid clipping text and/or icons.
|
||||||
|
corner_radius = 20
|
||||||
|
|
||||||
|
# Define which corners to round when drawing the window. If the corner radius
|
||||||
|
# is set to 0 this option will be ignored.
|
||||||
|
#
|
||||||
|
# Comma-separated list of the corners. The accepted corner values are bottom-right,
|
||||||
|
# bottom-left, top-right, top-left, top, bottom, left, right or all.
|
||||||
|
corners = all
|
||||||
|
|
||||||
|
# Ignore the dbus closeNotification message.
|
||||||
|
# Useful to enforce the timeout set by dunst configuration. Without this
|
||||||
|
# parameter, an application may close the notification sent before the
|
||||||
|
# user defined timeout.
|
||||||
|
ignore_dbusclose = false
|
||||||
|
|
||||||
|
### Wayland ###
|
||||||
|
# These settings are Wayland-specific. They have no effect when using X11
|
||||||
|
|
||||||
|
# Uncomment this if you want to let notifications appear under fullscreen
|
||||||
|
# applications (default: overlay)
|
||||||
|
# layer = top
|
||||||
|
|
||||||
|
# Set this to true to use X11 output on Wayland.
|
||||||
|
force_xwayland = false
|
||||||
|
|
||||||
|
### Legacy
|
||||||
|
|
||||||
|
# Use the Xinerama extension instead of RandR for multi-monitor support.
|
||||||
|
# This setting is provided for compatibility with older nVidia drivers that
|
||||||
|
# do not support RandR and using it on systems that support RandR is highly
|
||||||
|
# discouraged.
|
||||||
|
#
|
||||||
|
# By enabling this setting dunst will not be able to detect when a monitor
|
||||||
|
# is connected or disconnected which might break follow mode if the screen
|
||||||
|
# layout changes.
|
||||||
|
force_xinerama = false
|
||||||
|
|
||||||
|
### mouse
|
||||||
|
|
||||||
|
# Defines list of actions for each mouse event
|
||||||
|
# Possible values are:
|
||||||
|
# * none: Don't do anything.
|
||||||
|
# * do_action: Invoke the action determined by the action_name rule. If there is no
|
||||||
|
# such action, open the context menu.
|
||||||
|
# * open_url: If the notification has exactly one url, open it. If there are multiple
|
||||||
|
# ones, open the context menu.
|
||||||
|
# * close_current: Close current notification.
|
||||||
|
# * close_all: Close all notifications.
|
||||||
|
# * context: Open context menu for the notification.
|
||||||
|
# * context_all: Open context menu for all notifications.
|
||||||
|
# These values can be strung together for each mouse event, and
|
||||||
|
# will be executed in sequence.
|
||||||
|
mouse_left_click = close_current
|
||||||
|
mouse_middle_click = do_action, close_current
|
||||||
|
mouse_right_click = close_all
|
||||||
|
|
||||||
|
# Experimental features that may or may not work correctly. Do not expect them
|
||||||
|
# to have a consistent behaviour across releases.
|
||||||
|
[experimental]
|
||||||
|
# Calculate the dpi to use on a per-monitor basis.
|
||||||
|
# If this setting is enabled the Xft.dpi value will be ignored and instead
|
||||||
|
# dunst will attempt to calculate an appropriate dpi value for each monitor
|
||||||
|
# using the resolution and physical size. This might be useful in setups
|
||||||
|
# where there are multiple screens with very different dpi values.
|
||||||
|
per_monitor_dpi = false
|
||||||
|
|
||||||
|
|
||||||
|
[urgency_low]
|
||||||
|
# IMPORTANT: colors have to be defined in quotation marks.
|
||||||
|
# Otherwise the "#" and following would be interpreted as a comment.
|
||||||
|
background = "#1a1a1a"
|
||||||
|
foreground = "#5018dd"
|
||||||
|
frame_color = "#5018dd"
|
||||||
|
timeout = 10
|
||||||
|
# Icon for notifications with low urgency
|
||||||
|
default_icon = dialog-information
|
||||||
|
|
||||||
|
[urgency_normal]
|
||||||
|
background = "#1a1a1a"
|
||||||
|
foreground = "#E40046"
|
||||||
|
frame_color = "#5018dd"
|
||||||
|
timeout = 10
|
||||||
|
override_pause_level = 30
|
||||||
|
# Icon for notifications with normal urgency
|
||||||
|
default_icon = dialog-information
|
||||||
|
|
||||||
|
[urgency_critical]
|
||||||
|
background = "#E40046"
|
||||||
|
foreground = "#5018dd"
|
||||||
|
frame_color = "#5018dd"
|
||||||
|
timeout = 0
|
||||||
|
override_pause_level = 60
|
||||||
|
# Icon for notifications with critical urgency
|
||||||
|
default_icon = dialog-warning
|
||||||
|
|
||||||
|
# Every section that isn't one of the above is interpreted as a rules to
|
||||||
|
# override settings for certain messages.
|
||||||
|
#
|
||||||
|
# Messages can be matched by
|
||||||
|
# appname (discouraged, see desktop_entry)
|
||||||
|
# body
|
||||||
|
# category
|
||||||
|
# desktop_entry
|
||||||
|
# icon
|
||||||
|
# match_transient
|
||||||
|
# msg_urgency
|
||||||
|
# stack_tag
|
||||||
|
# summary
|
||||||
|
#
|
||||||
|
# and you can override the
|
||||||
|
# background
|
||||||
|
# foreground
|
||||||
|
# format
|
||||||
|
# frame_color
|
||||||
|
# fullscreen
|
||||||
|
# new_icon
|
||||||
|
# set_stack_tag
|
||||||
|
# set_transient
|
||||||
|
# set_category
|
||||||
|
# timeout
|
||||||
|
# urgency
|
||||||
|
# icon_position
|
||||||
|
# skip_display
|
||||||
|
# history_ignore
|
||||||
|
# action_name
|
||||||
|
# word_wrap
|
||||||
|
# ellipsize
|
||||||
|
# alignment
|
||||||
|
# hide_text
|
||||||
|
# override_pause_level
|
||||||
|
#
|
||||||
|
# Shell-like globbing will get expanded.
|
||||||
|
#
|
||||||
|
# Instead of the appname filter, it's recommended to use the desktop_entry filter.
|
||||||
|
# GLib based applications export their desktop-entry name. In comparison to the appname,
|
||||||
|
# the desktop-entry won't get localized.
|
||||||
|
#
|
||||||
|
# You can also allow a notification to appear even when paused. Notification will appear whenever notification's override_pause_level >= dunst's paused level.
|
||||||
|
# This can be used to set partial pause modes, where more urgent notifications get through, but less urgent stay paused. To do that, you can override the following in the rules:
|
||||||
|
# override_pause_level = X
|
||||||
|
|
||||||
|
# SCRIPTING
|
||||||
|
# You can specify a script that gets run when the rule matches by
|
||||||
|
# setting the "script" option.
|
||||||
|
# The script will be called as follows:
|
||||||
|
# script appname summary body icon urgency
|
||||||
|
# where urgency can be "LOW", "NORMAL" or "CRITICAL".
|
||||||
|
#
|
||||||
|
# NOTE: It might be helpful to run dunst -print in a terminal in order
|
||||||
|
# to find fitting options for rules.
|
||||||
|
|
||||||
|
# Disable the transient hint so that idle_threshold cannot be bypassed from the
|
||||||
|
# client
|
||||||
|
#[transient_disable]
|
||||||
|
# match_transient = yes
|
||||||
|
# set_transient = no
|
||||||
|
#
|
||||||
|
# Make the handling of transient notifications more strict by making them not
|
||||||
|
# be placed in history.
|
||||||
|
#[transient_history_ignore]
|
||||||
|
# match_transient = yes
|
||||||
|
# history_ignore = yes
|
||||||
|
|
||||||
|
# fullscreen values
|
||||||
|
# show: show the notifications, regardless if there is a fullscreen window opened
|
||||||
|
# delay: displays the new notification, if there is no fullscreen window active
|
||||||
|
# If the notification is already drawn, it won't get undrawn.
|
||||||
|
# pushback: same as delay, but when switching into fullscreen, the notification will get
|
||||||
|
# withdrawn from screen again and will get delayed like a new notification
|
||||||
|
#[fullscreen_delay_everything]
|
||||||
|
# fullscreen = delay
|
||||||
|
#[fullscreen_show_critical]
|
||||||
|
# msg_urgency = critical
|
||||||
|
# fullscreen = show
|
||||||
|
|
||||||
|
#[espeak]
|
||||||
|
# summary = "*"
|
||||||
|
# script = dunst_espeak.sh
|
||||||
|
|
||||||
|
#[script-test]
|
||||||
|
# summary = "*script*"
|
||||||
|
# script = dunst_test.sh
|
||||||
|
|
||||||
|
#[ignore]
|
||||||
|
# # This notification will not be displayed
|
||||||
|
# summary = "foobar"
|
||||||
|
# skip_display = true
|
||||||
|
|
||||||
|
#[history-ignore]
|
||||||
|
# # This notification will not be saved in history
|
||||||
|
# summary = "foobar"
|
||||||
|
# history_ignore = yes
|
||||||
|
|
||||||
|
#[skip-display]
|
||||||
|
# # This notification will not be displayed, but will be included in the history
|
||||||
|
# summary = "foobar"
|
||||||
|
# skip_display = yes
|
||||||
|
|
||||||
|
#[signed_on]
|
||||||
|
# appname = Pidgin
|
||||||
|
# summary = "*signed on*"
|
||||||
|
# urgency = low
|
||||||
|
#
|
||||||
|
#[signed_off]
|
||||||
|
# appname = Pidgin
|
||||||
|
# summary = *signed off*
|
||||||
|
# urgency = low
|
||||||
|
#
|
||||||
|
#[says]
|
||||||
|
# appname = Pidgin
|
||||||
|
# summary = *says*
|
||||||
|
# urgency = critical
|
||||||
|
#
|
||||||
|
#[twitter]
|
||||||
|
# appname = Pidgin
|
||||||
|
# summary = *twitter.com*
|
||||||
|
# urgency = normal
|
||||||
|
#
|
||||||
|
#[stack-volumes]
|
||||||
|
# appname = "some_volume_notifiers"
|
||||||
|
# set_stack_tag = "volume"
|
||||||
|
#
|
||||||
|
# vim: ft=cfg
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
[terminal]
|
||||||
|
# The VT to run the greeter on. Can be "next", "current" or a number
|
||||||
|
# designating the VT.
|
||||||
|
vt = 1
|
||||||
|
|
||||||
|
# The default session, also known as the greeter.
|
||||||
|
[default_session]
|
||||||
|
|
||||||
|
# `agreety` is the bundled agetty/login-lookalike. You can replace `/bin/sh`
|
||||||
|
# with whatever you want started, such as `sway`.
|
||||||
|
command = "tuigreet --cmd Hyprland"
|
||||||
|
|
||||||
|
# The user to run the command as. The privileges this user must have depends
|
||||||
|
# on the greeter. A graphical greeter may for example require the user to be
|
||||||
|
# in the `video` group.
|
||||||
|
user = "greeter"
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
file://$HOME/Nextcloud
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
[Settings]
|
||||||
|
gtk-theme-name=cyberqueer
|
||||||
|
gtk-icon-theme-name=Papirus-Dark
|
||||||
|
gtk-font-name=Adwaita Sans 11
|
||||||
|
gtk-cursor-theme-name=Nordzy-cursors-lefthand
|
||||||
|
gtk-cursor-theme-size=50
|
||||||
|
gtk-toolbar-style=GTK_TOOLBAR_ICONS
|
||||||
|
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
|
||||||
|
gtk-button-images=0
|
||||||
|
gtk-menu-images=0
|
||||||
|
gtk-enable-event-sounds=1
|
||||||
|
gtk-enable-input-feedback-sounds=0
|
||||||
|
gtk-xft-antialias=1
|
||||||
|
gtk-xft-hinting=1
|
||||||
|
gtk-xft-hintstyle=hintslight
|
||||||
|
gtk-xft-rgba=rgb
|
||||||
|
gtk-application-prefer-dark-theme=1
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""All-apps data source (AstalApps, same library astal-menu's appdrawer uses) plus
|
||||||
|
favorite-app persistence — a plain JSON list of desktop-entry IDs under
|
||||||
|
~/.local/state/horizon-dock/favorites.json (see paths.py for why STATE_DIR, not
|
||||||
|
CONFIG_DIR)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("AstalApps", "0.1")
|
||||||
|
from gi.repository import AstalApps # noqa: E402
|
||||||
|
|
||||||
|
from paths import FAVORITES_FILE, ensure_dirs
|
||||||
|
|
||||||
|
|
||||||
|
class AppSource:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._apps = AstalApps.Apps()
|
||||||
|
self._favorites: list[str] = self._load_favorites()
|
||||||
|
self._wm_index: dict[str, str] = {}
|
||||||
|
|
||||||
|
# -- listing -------------------------------------------------------------
|
||||||
|
def all_apps(self) -> list:
|
||||||
|
"""Every installed app, alphabetical by name."""
|
||||||
|
apps = list(self._apps.get_list())
|
||||||
|
apps.sort(key=lambda a: (a.get_name() or "").lower())
|
||||||
|
return apps
|
||||||
|
|
||||||
|
def icon_for_window(self, window: dict) -> str:
|
||||||
|
"""Map an open window's wm class to its installed app's icon — same
|
||||||
|
wm-class/executable/name index astal-menu/ui/taskbar.py builds for its
|
||||||
|
own taskbar, rebuilt lazily here on first use."""
|
||||||
|
if not self._wm_index:
|
||||||
|
for app in self._apps.get_list():
|
||||||
|
for key in (app.get_wm_class(), app.get_executable(), app.get_name()):
|
||||||
|
if key:
|
||||||
|
self._wm_index.setdefault(key.lower(), app.get_icon_name())
|
||||||
|
cls = (window.get("class") or "").lower()
|
||||||
|
return self._wm_index.get(cls) or cls or "application-x-executable"
|
||||||
|
|
||||||
|
def favorite_apps(self) -> list:
|
||||||
|
"""Favorited apps, in the order they were pinned. Entries whose app has
|
||||||
|
since been uninstalled are silently dropped (not un-favorited — the
|
||||||
|
desktop file may just be temporarily missing, e.g. mid-update)."""
|
||||||
|
by_entry = {a.get_entry(): a for a in self._apps.get_list()}
|
||||||
|
return [by_entry[e] for e in self._favorites if e in by_entry]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def launch(app) -> None:
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
# -- favorites persistence ------------------------------------------------
|
||||||
|
def is_favorite(self, app) -> bool:
|
||||||
|
return app.get_entry() in self._favorites
|
||||||
|
|
||||||
|
def toggle_favorite(self, app) -> None:
|
||||||
|
entry = app.get_entry()
|
||||||
|
if not entry:
|
||||||
|
return
|
||||||
|
if entry in self._favorites:
|
||||||
|
self._favorites.remove(entry)
|
||||||
|
else:
|
||||||
|
self._favorites.append(entry)
|
||||||
|
self._save_favorites()
|
||||||
|
|
||||||
|
def _load_favorites(self) -> list[str]:
|
||||||
|
try:
|
||||||
|
data = json.loads(FAVORITES_FILE.read_text())
|
||||||
|
return [e for e in data if isinstance(e, str)]
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _save_favorites(self) -> None:
|
||||||
|
ensure_dirs()
|
||||||
|
FAVORITES_FILE.write_text(json.dumps(self._favorites, indent=2) + "\n")
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""Tiny user-editable config file: ~/.local/state/horizon-dock/config.json.
|
||||||
|
|
||||||
|
Read once at startup (main.py); flags are passed into HorizonDock's constructor
|
||||||
|
rather than polled, so a change takes effect on the next horizon-dock-start.sh
|
||||||
|
restart, not live. Same pattern as orbit-menu/config.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from paths import CONFIG_FILE, ensure_dirs
|
||||||
|
|
||||||
|
# "tray": still undecided whether the full StatusNotifierItem satellite earns its
|
||||||
|
# keep vs a simpler dock — default on, but easy to switch off without touching code.
|
||||||
|
# "hologram": same toggle orbit-menu/astro-menu expose for their own overlay.
|
||||||
|
_DEFAULTS = {"tray": True, "hologram": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _load() -> dict:
|
||||||
|
try:
|
||||||
|
data = json.loads(CONFIG_FILE.read_text())
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
data = {}
|
||||||
|
merged = {**_DEFAULTS, **data}
|
||||||
|
if not CONFIG_FILE.exists():
|
||||||
|
ensure_dirs()
|
||||||
|
CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n")
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def tray_enabled() -> bool:
|
||||||
|
return bool(_load().get("tray", True))
|
||||||
|
|
||||||
|
|
||||||
|
def hologram_enabled() -> bool:
|
||||||
|
return bool(_load().get("hologram", True))
|
||||||
|
|
@ -0,0 +1,380 @@
|
||||||
|
"""horizon-dock — a hover-scrollable orbital dock, matching orbit-menu's visual
|
||||||
|
language (same CyberQueer glow/hover "planet" node styling).
|
||||||
|
|
||||||
|
Anchored full-width to the bottom of the screen. Toggled show/hide (not an
|
||||||
|
always-resident hover-reveal dock); every time it's shown it fades+slides up
|
||||||
|
into place from below the screen edge, and hiding reverses that — driven by the
|
||||||
|
same per-frame tick-callback+easing pattern orbit-menu uses for its own
|
||||||
|
animations. Three rows of circular "planet" icon buttons — Open Windows,
|
||||||
|
Favorites, All Apps — each laid out evenly-spaced along a shared giant-circle
|
||||||
|
arc (the "horizon" curve: a shallow dip toward the screen edges, radius derived
|
||||||
|
from the monitor width so the sag reads consistently at any resolution).
|
||||||
|
|
||||||
|
Hovering a row's Y-band selects it as the active scroll target — mouse-wheel
|
||||||
|
scrolling only ever moves the currently-hovered row (Gtk.EventControllerScroll),
|
||||||
|
independent of the other two. The tray satellite sits at the fixed right edge of
|
||||||
|
the Favorites row: its position is never touched by that row's scroll
|
||||||
|
repositioning, and it's added to the canvas last so it paints on top — meaning
|
||||||
|
overflowing favorites scrolling under it are simply covered, reading as "going
|
||||||
|
behind" the satellite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
gi.require_version("Gdk", "4.0")
|
||||||
|
gi.require_version("Gsk", "4.0")
|
||||||
|
gi.require_version("Graphene", "1.0")
|
||||||
|
gi.require_version("Gtk4LayerShell", "1.0")
|
||||||
|
from gi.repository import Gdk, Graphene, Gsk, Gtk # noqa: E402
|
||||||
|
from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402
|
||||||
|
|
||||||
|
import apps as apps_source
|
||||||
|
import windows as windows_source
|
||||||
|
from lib.hologram import HologramOverlay
|
||||||
|
from tray import TrayHost
|
||||||
|
|
||||||
|
# CyberQueer accent/violet — hardcoded here as orbit-menu's own orbit_menu.py
|
||||||
|
# does for its Cairo drawing, since Cairo paints directly and doesn't see GTK
|
||||||
|
# CSS @define-color names. Kept in sync by eye with style/_colors.css.
|
||||||
|
_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
|
||||||
|
_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
|
||||||
|
|
||||||
|
|
||||||
|
class HorizonDock(Gtk.Window):
|
||||||
|
DOCK_HEIGHT = 214
|
||||||
|
PLANET_SIZE = 52
|
||||||
|
ITEM_SPACING = 66.0 # center-to-center distance between planets in a row
|
||||||
|
ARC_SAG = 34.0 # how far a row dips toward the screen edges vs. its center
|
||||||
|
|
||||||
|
ROW_ORDER = ["windows", "favorites", "apps"] # back -> front, top -> bottom
|
||||||
|
ROW_Y = {"windows": 46.0, "favorites": 112.0, "apps": 176.0}
|
||||||
|
ROW_BAND_TOP = {"windows": 0.0, "favorites": 79.0, "apps": 144.0}
|
||||||
|
ROW_TITLE = {"windows": "Open Windows", "favorites": "Favorites", "apps": "All Apps"}
|
||||||
|
|
||||||
|
TRAY_SIZE = 40
|
||||||
|
TRAY_MARGIN = 30.0
|
||||||
|
SCROLL_SENSITIVITY = 0.9
|
||||||
|
|
||||||
|
REVEAL_DURATION = 0.28
|
||||||
|
|
||||||
|
def __init__(self, on_close: Optional[Callable[[], None]] = None, tray_enabled: bool = True,
|
||||||
|
hologram_enabled: bool = True):
|
||||||
|
super().__init__()
|
||||||
|
self.add_css_class("horizon-dock-window")
|
||||||
|
self._on_close = on_close
|
||||||
|
self._tray_enabled = tray_enabled
|
||||||
|
|
||||||
|
self._apps = apps_source.AppSource()
|
||||||
|
self._tray = TrayHost() if tray_enabled else None
|
||||||
|
|
||||||
|
self._width = self._monitor_width()
|
||||||
|
self._scroll_offset = {row: 0.0 for row in self.ROW_ORDER}
|
||||||
|
self._items: dict[str, list] = {row: [] for row in self.ROW_ORDER} # source objects
|
||||||
|
self._widgets: dict[str, list[Gtk.Widget]] = {row: [] for row in self.ROW_ORDER}
|
||||||
|
self._hovered_row: Optional[str] = None
|
||||||
|
|
||||||
|
self._reveal_progress = 0.0
|
||||||
|
self._reveal_target = 0.0
|
||||||
|
self._sat_time = 0.0
|
||||||
|
self._last_tick: Optional[float] = None
|
||||||
|
self._tick_id: Optional[int] = None
|
||||||
|
|
||||||
|
self._init_layer_shell()
|
||||||
|
|
||||||
|
self._bg = Gtk.DrawingArea()
|
||||||
|
self._bg.set_size_request(int(self._width), self.DOCK_HEIGHT)
|
||||||
|
self._bg.set_draw_func(self._draw_background)
|
||||||
|
self._bg.add_css_class("horizon-canvas")
|
||||||
|
|
||||||
|
self._content = Gtk.Fixed()
|
||||||
|
self._content.set_size_request(int(self._width), self.DOCK_HEIGHT)
|
||||||
|
self._content.put(self._bg, 0, 0)
|
||||||
|
|
||||||
|
self._viewport = Gtk.Fixed()
|
||||||
|
self._viewport.set_size_request(int(self._width), self.DOCK_HEIGHT)
|
||||||
|
self._viewport.put(self._content, 0, 0)
|
||||||
|
|
||||||
|
self._hologram = HologramOverlay(enabled=hologram_enabled)
|
||||||
|
overlay = Gtk.Overlay()
|
||||||
|
overlay.set_child(self._viewport)
|
||||||
|
overlay.add_overlay(self._hologram.widget)
|
||||||
|
self.set_child(overlay)
|
||||||
|
|
||||||
|
motion = Gtk.EventControllerMotion()
|
||||||
|
motion.connect("motion", self._on_motion)
|
||||||
|
motion.connect("leave", self._on_motion_leave)
|
||||||
|
self.add_controller(motion)
|
||||||
|
|
||||||
|
scroll = Gtk.EventControllerScroll()
|
||||||
|
scroll.set_flags(Gtk.EventControllerScrollFlags.VERTICAL)
|
||||||
|
scroll.connect("scroll", self._on_scroll)
|
||||||
|
self.add_controller(scroll)
|
||||||
|
|
||||||
|
self._tray_satellite: Optional[Gtk.Widget] = None
|
||||||
|
self._tray_popover: Optional[Gtk.Popover] = None
|
||||||
|
|
||||||
|
self.set_visible(False)
|
||||||
|
self._rebuild_all()
|
||||||
|
|
||||||
|
# -- layer shell ------------------------------------------------------
|
||||||
|
def _init_layer_shell(self) -> None:
|
||||||
|
LayerShell.init_for_window(self)
|
||||||
|
LayerShell.set_layer(self, LayerShell.Layer.TOP)
|
||||||
|
LayerShell.set_namespace(self, "horizon-dock")
|
||||||
|
LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.NONE)
|
||||||
|
LayerShell.set_exclusive_zone(self, 0)
|
||||||
|
for edge in (LayerShell.Edge.LEFT, LayerShell.Edge.RIGHT, LayerShell.Edge.BOTTOM):
|
||||||
|
LayerShell.set_anchor(self, edge, True)
|
||||||
|
LayerShell.set_margin(self, edge, 0)
|
||||||
|
|
||||||
|
def _monitor_width(self) -> int:
|
||||||
|
display = Gdk.Display.get_default()
|
||||||
|
monitors = display.get_monitors() if display is not None else None
|
||||||
|
mon = monitors.get_item(0) if monitors is not None and monitors.get_n_items() else None
|
||||||
|
return mon.get_geometry().width if mon is not None else 1920
|
||||||
|
|
||||||
|
# -- layout math --------------------------------------------------------
|
||||||
|
def _arc_radius(self) -> float:
|
||||||
|
half_w = self._width / 2
|
||||||
|
sag = self.ARC_SAG
|
||||||
|
return (sag * sag + half_w * half_w) / (2 * sag)
|
||||||
|
|
||||||
|
def _row_y_at(self, row: str, x: float) -> float:
|
||||||
|
row_y = self.ROW_Y[row]
|
||||||
|
dx = x - self._width / 2
|
||||||
|
r = self._arc_radius()
|
||||||
|
dip = r - math.sqrt(max(r * r - dx * dx, 0.0))
|
||||||
|
return row_y + dip
|
||||||
|
|
||||||
|
def _item_position(self, row: str, index: int) -> tuple[float, float]:
|
||||||
|
slot = index - self._scroll_offset[row]
|
||||||
|
x = self._width / 2 + slot * self.ITEM_SPACING
|
||||||
|
return x, self._row_y_at(row, x)
|
||||||
|
|
||||||
|
def _clamp_scroll(self, row: str) -> None:
|
||||||
|
count = len(self._items[row])
|
||||||
|
max_offset = max(0.0, count - 1)
|
||||||
|
self._scroll_offset[row] = min(max(self._scroll_offset[row], 0.0), max_offset)
|
||||||
|
|
||||||
|
# -- planet buttons -------------------------------------------------------
|
||||||
|
def _make_planet(self, icon_name: str, tooltip: str, size: int = PLANET_SIZE) -> Gtk.Button:
|
||||||
|
btn = Gtk.Button()
|
||||||
|
btn.set_has_frame(False)
|
||||||
|
btn.add_css_class("horizon-planet")
|
||||||
|
btn.set_size_request(size, size)
|
||||||
|
btn.set_tooltip_text(tooltip)
|
||||||
|
image = Gtk.Image.new_from_icon_name(icon_name or "application-x-executable")
|
||||||
|
image.set_pixel_size(int(size * 0.55))
|
||||||
|
btn.set_child(image)
|
||||||
|
return btn
|
||||||
|
|
||||||
|
def _rebuild_row(self, row: str) -> None:
|
||||||
|
for w in self._widgets[row]:
|
||||||
|
if w.get_parent() is not None:
|
||||||
|
self._content.remove(w)
|
||||||
|
self._widgets[row] = []
|
||||||
|
|
||||||
|
if row == "windows":
|
||||||
|
self._items[row] = windows_source.open_windows()
|
||||||
|
elif row == "favorites":
|
||||||
|
self._items[row] = self._apps.favorite_apps()
|
||||||
|
else:
|
||||||
|
self._items[row] = self._apps.all_apps()
|
||||||
|
|
||||||
|
self._clamp_scroll(row)
|
||||||
|
|
||||||
|
for i, item in enumerate(self._items[row]):
|
||||||
|
btn = self._build_item_button(row, item)
|
||||||
|
x, y = self._item_position(row, i)
|
||||||
|
self._content.put(btn, x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2)
|
||||||
|
self._widgets[row].append(btn)
|
||||||
|
|
||||||
|
if row == "favorites":
|
||||||
|
self._place_tray_satellite()
|
||||||
|
|
||||||
|
self._bg.queue_draw()
|
||||||
|
|
||||||
|
def _build_item_button(self, row: str, item) -> Gtk.Button:
|
||||||
|
if row == "windows":
|
||||||
|
icon = self._apps.icon_for_window(item)
|
||||||
|
title = item.get("title") or item.get("class") or "?"
|
||||||
|
btn = self._make_planet(icon, title)
|
||||||
|
addr = item.get("address")
|
||||||
|
btn.connect("clicked", lambda *_a, a=addr: windows_source.focus_window(a))
|
||||||
|
else:
|
||||||
|
btn = self._make_planet(item.get_icon_name(), item.get_name() or "")
|
||||||
|
btn.connect("clicked", lambda *_a, a=item: self._apps.launch(a))
|
||||||
|
if row == "apps":
|
||||||
|
right_click = Gtk.GestureClick(button=3)
|
||||||
|
right_click.connect("pressed", lambda *_a, a=item: self._toggle_favorite(a))
|
||||||
|
btn.add_controller(right_click)
|
||||||
|
return btn
|
||||||
|
|
||||||
|
def _toggle_favorite(self, item) -> None:
|
||||||
|
self._apps.toggle_favorite(item)
|
||||||
|
self._rebuild_row("favorites")
|
||||||
|
|
||||||
|
def _reflow_row(self, row: str) -> None:
|
||||||
|
for i, w in enumerate(self._widgets[row]):
|
||||||
|
x, y = self._item_position(row, i)
|
||||||
|
self._content.move(w, x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2)
|
||||||
|
|
||||||
|
def _rebuild_all(self) -> None:
|
||||||
|
for row in self.ROW_ORDER:
|
||||||
|
self._rebuild_row(row)
|
||||||
|
|
||||||
|
# -- tray satellite -------------------------------------------------------
|
||||||
|
def _place_tray_satellite(self) -> None:
|
||||||
|
if not self._tray_enabled:
|
||||||
|
return
|
||||||
|
if self._tray_satellite is not None and self._tray_satellite.get_parent() is not None:
|
||||||
|
self._content.remove(self._tray_satellite)
|
||||||
|
|
||||||
|
x = self._width - self.TRAY_MARGIN
|
||||||
|
y = self.ROW_Y["favorites"]
|
||||||
|
btn = Gtk.MenuButton()
|
||||||
|
btn.add_css_class("horizon-planet")
|
||||||
|
btn.add_css_class("horizon-tray-satellite")
|
||||||
|
btn.set_size_request(self.TRAY_SIZE, self.TRAY_SIZE)
|
||||||
|
btn.set_tooltip_text("Tray")
|
||||||
|
icon = Gtk.Image.new_from_icon_name("view-list-symbolic")
|
||||||
|
icon.set_pixel_size(int(self.TRAY_SIZE * 0.5))
|
||||||
|
btn.set_child(icon)
|
||||||
|
|
||||||
|
popover = Gtk.Popover()
|
||||||
|
popover.add_css_class("horizon-tray-popover")
|
||||||
|
popover.set_child(self._build_tray_box())
|
||||||
|
btn.set_popover(popover)
|
||||||
|
self._tray_popover = popover
|
||||||
|
|
||||||
|
self._content.put(btn, x - self.TRAY_SIZE / 2, y - self.TRAY_SIZE / 2)
|
||||||
|
self._tray_satellite = btn
|
||||||
|
|
||||||
|
def _build_tray_box(self) -> Gtk.Widget:
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
box.add_css_class("horizon-tray-box")
|
||||||
|
if self._tray is None:
|
||||||
|
box.append(Gtk.Label(label="Tray disabled"))
|
||||||
|
return box
|
||||||
|
self._tray.build_into(box)
|
||||||
|
return box
|
||||||
|
|
||||||
|
# -- hover / scroll routing -------------------------------------------------
|
||||||
|
def _row_at_y(self, y: float) -> Optional[str]:
|
||||||
|
for row in reversed(self.ROW_ORDER):
|
||||||
|
if y >= self.ROW_BAND_TOP[row]:
|
||||||
|
return row
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _on_motion(self, _ctrl, _x: float, y: float) -> None:
|
||||||
|
row = self._row_at_y(y)
|
||||||
|
if row != self._hovered_row:
|
||||||
|
self._hovered_row = row
|
||||||
|
self._bg.queue_draw()
|
||||||
|
|
||||||
|
def _on_motion_leave(self, _ctrl) -> None:
|
||||||
|
if self._hovered_row is not None:
|
||||||
|
self._hovered_row = None
|
||||||
|
self._bg.queue_draw()
|
||||||
|
|
||||||
|
def _on_scroll(self, _ctrl, _dx: float, dy: float) -> bool:
|
||||||
|
row = self._hovered_row
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
self._scroll_offset[row] += dy * self.SCROLL_SENSITIVITY
|
||||||
|
self._clamp_scroll(row)
|
||||||
|
self._reflow_row(row)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# -- background: the horizon arcs + hover band --------------------------
|
||||||
|
def _draw_background(self, _area, cr, width: float, height: float) -> None:
|
||||||
|
for row in self.ROW_ORDER:
|
||||||
|
self._draw_row_arc(cr, row, width)
|
||||||
|
if self._hovered_row is not None:
|
||||||
|
self._draw_hover_band(cr, self._hovered_row, width)
|
||||||
|
|
||||||
|
def _draw_row_arc(self, cr, row: str, width: float) -> None:
|
||||||
|
cr.save()
|
||||||
|
cr.set_source_rgba(*_VIOLET, 0.16)
|
||||||
|
cr.set_line_width(1.2)
|
||||||
|
cr.move_to(0, self._row_y_at(row, 0))
|
||||||
|
steps = 48
|
||||||
|
for s in range(1, steps + 1):
|
||||||
|
x = width * s / steps
|
||||||
|
cr.line_to(x, self._row_y_at(row, x))
|
||||||
|
cr.stroke()
|
||||||
|
cr.restore()
|
||||||
|
|
||||||
|
def _draw_hover_band(self, cr, row: str, width: float) -> None:
|
||||||
|
top = self.ROW_BAND_TOP[row]
|
||||||
|
bottom_candidates = [self.ROW_BAND_TOP[r] for r in self.ROW_ORDER if self.ROW_BAND_TOP[r] > top]
|
||||||
|
bottom = min(bottom_candidates) if bottom_candidates else self.DOCK_HEIGHT
|
||||||
|
cr.save()
|
||||||
|
cr.set_source_rgba(*_ACCENT, 0.05)
|
||||||
|
cr.rectangle(0, top, width, bottom - top)
|
||||||
|
cr.fill()
|
||||||
|
cr.restore()
|
||||||
|
|
||||||
|
# -- reveal animation -----------------------------------------------------
|
||||||
|
def _reveal_transform(self, progress: float) -> Gsk.Transform:
|
||||||
|
offset = (1.0 - progress) * self.DOCK_HEIGHT
|
||||||
|
t = Gsk.Transform.new()
|
||||||
|
t = t.translate(Graphene.Point().init(0, offset))
|
||||||
|
return t
|
||||||
|
|
||||||
|
def _on_tick(self, _widget, frame_clock) -> bool:
|
||||||
|
now = frame_clock.get_frame_time() / 1_000_000
|
||||||
|
dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick)
|
||||||
|
self._last_tick = now
|
||||||
|
self._sat_time += dt
|
||||||
|
self._hologram.tick(dt)
|
||||||
|
|
||||||
|
ease = 1 - math.exp(-dt * (1.0 / self.REVEAL_DURATION) * 3.2)
|
||||||
|
self._reveal_progress += (self._reveal_target - self._reveal_progress) * ease
|
||||||
|
if abs(self._reveal_target - self._reveal_progress) < 0.002:
|
||||||
|
self._reveal_progress = self._reveal_target
|
||||||
|
|
||||||
|
self._viewport.set_child_transform(self._content, self._reveal_transform(self._reveal_progress))
|
||||||
|
self._content.set_opacity(max(0.0, min(1.0, self._reveal_progress)))
|
||||||
|
|
||||||
|
if self._reveal_progress == self._reveal_target and self._reveal_target == 0.0:
|
||||||
|
self.set_visible(False)
|
||||||
|
self._tick_id = None # GTK drops the callback itself on a False return
|
||||||
|
if self._on_close:
|
||||||
|
self._on_close()
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _ensure_tick(self) -> None:
|
||||||
|
if self._tick_id is None:
|
||||||
|
self._tick_id = self.add_tick_callback(self._on_tick)
|
||||||
|
|
||||||
|
def _stop_tick(self) -> None:
|
||||||
|
if self._tick_id is not None:
|
||||||
|
self.remove_tick_callback(self._tick_id)
|
||||||
|
self._tick_id = None
|
||||||
|
|
||||||
|
# -- external control ----------------------------------------------------
|
||||||
|
def show_dock(self) -> None:
|
||||||
|
self._width = self._monitor_width()
|
||||||
|
self._rebuild_all()
|
||||||
|
self._reveal_target = 1.0
|
||||||
|
self.set_visible(True)
|
||||||
|
self.present()
|
||||||
|
self._ensure_tick()
|
||||||
|
|
||||||
|
def hide_dock(self) -> None:
|
||||||
|
self._reveal_target = 0.0
|
||||||
|
self._ensure_tick()
|
||||||
|
|
||||||
|
def toggle(self) -> None:
|
||||||
|
if self.get_visible() and self._reveal_target == 1.0:
|
||||||
|
self.hide_dock()
|
||||||
|
else:
|
||||||
|
self.show_dock()
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
"""Holographic scanline/sweep/noise overlay — same treatment and tuning as
|
||||||
|
astro-menu's lib/hologram.py (itself matching orbit-menu's), reused here so
|
||||||
|
horizon-dock reads as one more orbit of the same Cosmonaut Shell suite. No
|
||||||
|
radial vignette mask: the dock's own module borders/canvas already bound it,
|
||||||
|
so covering the full rectangle reads as one continuous "HUD screen".
|
||||||
|
|
||||||
|
Owns its own animation clock (advanced by dock.py's tick callback via
|
||||||
|
.tick(dt)) and a persistent particle list for the noise specs, exactly like
|
||||||
|
astro-menu/orbit-menu's hologram: each spec keeps its position/color for a
|
||||||
|
real randomized lifetime (fade in, hold, fade out) instead of every spec
|
||||||
|
teleporting to a new position every frame.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
|
||||||
|
import cairo
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gtk # noqa: E402
|
||||||
|
|
||||||
|
# Same CyberQueer violet/magenta/red combo as orbit-menu's/astro-menu's hologram.
|
||||||
|
_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
|
||||||
|
_MAGENTA = (0.92, 0.0, 0.65)
|
||||||
|
_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
|
||||||
|
|
||||||
|
|
||||||
|
class HologramOverlay:
|
||||||
|
SCANLINE_GAP = 4.0
|
||||||
|
SCANLINE_ALPHA = 0.16 # fixed grid — kept clearly visible, not just a faint texture
|
||||||
|
SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass
|
||||||
|
SWEEP_HALF_HEIGHT = 90.0
|
||||||
|
NOISE_COUNT = 95 # specs alive at once (each with its own lifetime)
|
||||||
|
NOISE_COLORS = [_MAGENTA, _ACCENT]
|
||||||
|
NOISE_LIFETIME = (0.5, 1.4)
|
||||||
|
NOISE_FADE_IN = 0.2
|
||||||
|
NOISE_FADE_OUT = 0.35
|
||||||
|
NOISE_ALPHA_RANGE = (0.10, 0.34)
|
||||||
|
|
||||||
|
def __init__(self, enabled: bool = True) -> None:
|
||||||
|
self.enabled = enabled
|
||||||
|
self._sat_time = 0.0
|
||||||
|
self._particles: list[dict] = []
|
||||||
|
|
||||||
|
self.widget = Gtk.DrawingArea()
|
||||||
|
self.widget.set_can_target(False) # never steals clicks from content underneath
|
||||||
|
self.widget.add_css_class("horizon-hologram")
|
||||||
|
self.widget.set_hexpand(True)
|
||||||
|
self.widget.set_vexpand(True)
|
||||||
|
self.widget.set_halign(Gtk.Align.FILL)
|
||||||
|
self.widget.set_valign(Gtk.Align.FILL)
|
||||||
|
self.widget.set_draw_func(self._draw_frame)
|
||||||
|
|
||||||
|
def tick(self, dt: float) -> None:
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
self._sat_time += dt
|
||||||
|
self.widget.queue_draw()
|
||||||
|
|
||||||
|
# -- drawing --------------------------------------------------------------
|
||||||
|
def _draw_frame(self, _area, cr, width: float, height: float) -> None:
|
||||||
|
if not self.enabled or width <= 0 or height <= 0:
|
||||||
|
return
|
||||||
|
r, g, b = _VIOLET
|
||||||
|
|
||||||
|
cr.save()
|
||||||
|
cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA)
|
||||||
|
cr.set_line_width(1.0)
|
||||||
|
y = 0.0
|
||||||
|
while y < height:
|
||||||
|
cr.move_to(0, y)
|
||||||
|
cr.line_to(width, y)
|
||||||
|
y += self.SCANLINE_GAP
|
||||||
|
cr.stroke()
|
||||||
|
cr.restore()
|
||||||
|
|
||||||
|
phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD
|
||||||
|
sweep_y = phase * height
|
||||||
|
hh = self.SWEEP_HALF_HEIGHT
|
||||||
|
grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh)
|
||||||
|
grad.add_color_stop_rgba(0.0, r, g, b, 0.0)
|
||||||
|
grad.add_color_stop_rgba(0.5, r, g, b, 0.07)
|
||||||
|
grad.add_color_stop_rgba(1.0, r, g, b, 0.0)
|
||||||
|
cr.set_source(grad)
|
||||||
|
cr.rectangle(0, sweep_y - hh, width, hh * 2)
|
||||||
|
cr.fill()
|
||||||
|
|
||||||
|
flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0)
|
||||||
|
cr.set_source_rgba(r, g, b, max(0.0, flicker))
|
||||||
|
cr.paint()
|
||||||
|
|
||||||
|
self._draw_noise(cr, width, height)
|
||||||
|
|
||||||
|
def _draw_noise(self, cr, width: float, height: float) -> None:
|
||||||
|
now = self._sat_time
|
||||||
|
self._particles = [p for p in self._particles if now - p["birth"] < p["life"]]
|
||||||
|
while len(self._particles) < self.NOISE_COUNT:
|
||||||
|
self._particles.append({
|
||||||
|
"x": random.uniform(0, width),
|
||||||
|
"y": random.uniform(0, height),
|
||||||
|
"w": random.uniform(1.0, 2.6),
|
||||||
|
"h": random.uniform(1.0, 2.0),
|
||||||
|
"color": random.choice(self.NOISE_COLORS),
|
||||||
|
"peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE),
|
||||||
|
"birth": now,
|
||||||
|
"life": random.uniform(*self.NOISE_LIFETIME),
|
||||||
|
})
|
||||||
|
|
||||||
|
for p in self._particles:
|
||||||
|
t = (now - p["birth"]) / p["life"]
|
||||||
|
if t < self.NOISE_FADE_IN:
|
||||||
|
envelope = t / self.NOISE_FADE_IN
|
||||||
|
elif t > 1.0 - self.NOISE_FADE_OUT:
|
||||||
|
envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT)
|
||||||
|
else:
|
||||||
|
envelope = 1.0
|
||||||
|
r, g, b = p["color"]
|
||||||
|
cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope)
|
||||||
|
cr.rectangle(p["x"], p["y"], p["w"], p["h"])
|
||||||
|
cr.fill()
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""horizon-dock — a hover-scrollable orbital dock for hyprdrive, matching
|
||||||
|
orbit-menu's visual language.
|
||||||
|
|
||||||
|
Single-instance, same pattern as astal-menu/orbit-menu's main.py: the first
|
||||||
|
launch builds the (hidden) window and holds; later invocations forward their
|
||||||
|
verb over D-Bus via scripts/horizon-dock.sh instead of spawning a second
|
||||||
|
python3+GTK4 process.
|
||||||
|
|
||||||
|
main.py run the resident instance (stays hidden until toggled)
|
||||||
|
main.py --show slide/fade in
|
||||||
|
main.py --hide slide/fade out
|
||||||
|
main.py --toggle whichever of the above applies
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# horizon-dock-start.sh LD_PRELOADs libgtk4-layer-shell (load-ordering requirement
|
||||||
|
# ahead of libwayland-client). Drop it once resident so it isn't inherited by
|
||||||
|
# anything this process launches (see astal-menu/orbit-menu's main.py for the same
|
||||||
|
# rationale — some GTK4 apps abort at startup with the layer-shell lib preloaded).
|
||||||
|
os.environ.pop("LD_PRELOAD", None)
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
import gi # noqa: E402
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gio, GLib, Gtk # noqa: E402
|
||||||
|
|
||||||
|
import config # noqa: E402
|
||||||
|
import theme # noqa: E402
|
||||||
|
from dock import HorizonDock # noqa: E402
|
||||||
|
from paths import APP_ID # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class HorizonDockApp(Gtk.Application):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(application_id=APP_ID,
|
||||||
|
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
|
||||||
|
self.window: HorizonDock | None = None
|
||||||
|
|
||||||
|
def do_startup(self) -> None:
|
||||||
|
Gtk.Application.do_startup(self)
|
||||||
|
theme.load_css()
|
||||||
|
self.window = HorizonDock(tray_enabled=config.tray_enabled(),
|
||||||
|
hologram_enabled=config.hologram_enabled())
|
||||||
|
self._register_actions()
|
||||||
|
self.hold() # stay alive with no visible window
|
||||||
|
|
||||||
|
def _register_actions(self) -> None:
|
||||||
|
def add(name: str, callback) -> None:
|
||||||
|
action = Gio.SimpleAction.new(name, None)
|
||||||
|
action.connect("activate", callback)
|
||||||
|
self.add_action(action)
|
||||||
|
|
||||||
|
def guarded(fn):
|
||||||
|
def wrapper(*_a) -> None:
|
||||||
|
assert self.window is not None
|
||||||
|
fn(self.window)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
add("show", guarded(lambda w: w.show_dock()))
|
||||||
|
add("hide", guarded(lambda w: w.hide_dock()))
|
||||||
|
add("toggle", guarded(lambda w: w.toggle()))
|
||||||
|
|
||||||
|
def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int:
|
||||||
|
args = list(cmdline.get_arguments()[1:])
|
||||||
|
verb = args[0] if args else "--daemon"
|
||||||
|
if self.window is None:
|
||||||
|
return 0
|
||||||
|
if verb == "--show":
|
||||||
|
self.window.show_dock()
|
||||||
|
elif verb == "--hide":
|
||||||
|
self.window.hide_dock()
|
||||||
|
elif verb == "--toggle":
|
||||||
|
self.window.toggle()
|
||||||
|
# --daemon and anything else: no-op (stay resident, hidden)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def do_activate(self) -> None:
|
||||||
|
pass # resident instance: nothing to do on plain activate
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
GLib.set_prgname("horizon-dock")
|
||||||
|
return HorizonDockApp().run(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""Shared filesystem locations. Works whether run from the repo or ~/.config."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
STYLE_DIR = BASE_DIR / "style"
|
||||||
|
|
||||||
|
# User settings/favorites live under XDG_STATE_HOME, NOT ~/.config — config-updater
|
||||||
|
# does `rm -rf ~/.config/horizon-dock` on every dotfiles sync (see orbit-menu/
|
||||||
|
# paths.py and astal-menu/paths.py for the same rationale), which would wipe a
|
||||||
|
# hand-edited config or pinned favorites on the spot.
|
||||||
|
STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "horizon-dock"
|
||||||
|
CONFIG_FILE = STATE_DIR / "config.json"
|
||||||
|
FAVORITES_FILE = STATE_DIR / "favorites.json"
|
||||||
|
|
||||||
|
APP_ID = "eu.abdelbaki.horizondock"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dirs() -> None:
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the
|
||||||
|
* hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the
|
||||||
|
* sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */
|
||||||
|
@define-color text #D6ABAB;
|
||||||
|
@define-color bg #1A1A1A;
|
||||||
|
@define-color accent #E40046;
|
||||||
|
@define-color violet #5018DD;
|
||||||
|
@define-color danger #F50505;
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
/* horizon-dock — CyberQueer orbital dock. Colors come from _colors.css
|
||||||
|
* (@text/@bg/@accent/@violet/@danger), loaded first by theme.py. Every icon is
|
||||||
|
* a "planet" node styled identically to orbit-menu's .orbit-node/.orbit-center
|
||||||
|
* (same glow/hover treatment, same radial-gradient formula) so the two
|
||||||
|
* components read as one continuous aesthetic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
window.horizon-dock-window {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The cyberqueer theme's `* { background-color: #1a1a1a }` paints DrawingArea
|
||||||
|
* nodes specifically (see orbit-menu/style/style.css's .orbit-hologram fix for
|
||||||
|
* the same discovery) — the background arc canvas is a DrawingArea sitting
|
||||||
|
* behind everything, so without this it paints an opaque rectangle that blots
|
||||||
|
* out the transparent window, which is what reads as "the system GTK theme". */
|
||||||
|
.horizon-canvas,
|
||||||
|
.horizon-hologram {
|
||||||
|
background: transparent;
|
||||||
|
background-color: transparent;
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- planet nodes: identical language to orbit-menu's .orbit-node ------------ */
|
||||||
|
|
||||||
|
.horizon-planet {
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1.5px solid alpha(@violet, 0.5);
|
||||||
|
color: @text;
|
||||||
|
background-image: radial-gradient(circle at 35% 30%, alpha(@violet, 0.18), alpha(@violet, 0.32) 80%);
|
||||||
|
transition: background 200ms ease, border-color 200ms ease,
|
||||||
|
box-shadow 220ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
box-shadow: 0 0 0 0 alpha(@violet, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizon-planet * {
|
||||||
|
background: transparent;
|
||||||
|
background-color: transparent;
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizon-planet:hover {
|
||||||
|
background-image: radial-gradient(circle at 35% 30%, alpha(@violet, 0.55), alpha(@violet, 0.32) 80%);
|
||||||
|
border-color: @violet;
|
||||||
|
box-shadow: 0 0 16px 1px alpha(@violet, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizon-planet:active {
|
||||||
|
box-shadow: 0 0 22px 3px alpha(@violet, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the tray satellite reads as "special" — accent-tinted instead of violet, and
|
||||||
|
* an OPAQUE background (not the translucent radial-gradient the other planets
|
||||||
|
* use) so overflowing favorites scrolling underneath it visibly disappear
|
||||||
|
* behind it rather than showing through. Opaque, but still violet-tinted
|
||||||
|
* rather than flat gray (a literal near-black-violet, not @bg/@violet, since
|
||||||
|
* this one spot genuinely needs full opacity for the occlusion to work). */
|
||||||
|
.horizon-tray-satellite {
|
||||||
|
border-color: alpha(@accent, 0.55);
|
||||||
|
background-image: none;
|
||||||
|
background-color: #140b28;
|
||||||
|
}
|
||||||
|
.horizon-tray-satellite:hover {
|
||||||
|
border-color: @accent;
|
||||||
|
box-shadow: 0 0 16px 1px alpha(@accent, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizon-tray-popover {
|
||||||
|
background-color: #140b28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizon-tray-box {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizon-tray-item {
|
||||||
|
border-radius: 9999px;
|
||||||
|
min-width: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""Load the two stylesheets as ordered CSS providers — same scheme as orbit-menu's
|
||||||
|
and astal-menu's theme.py. _colors.css (generated from ~/Dotfiles/colors.conf by
|
||||||
|
apply-theme.sh) defines the CyberQueer @define-color names; style.css consumes them.
|
||||||
|
|
||||||
|
Priority is USER+1 for the same reason as the other two apps: the CyberQueer GTK
|
||||||
|
theme at ~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above APPLICATION
|
||||||
|
(600), and would beat our transparent structural containers otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gdk, Gtk # noqa: E402
|
||||||
|
|
||||||
|
from paths import STYLE_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def load_css() -> None:
|
||||||
|
display = Gdk.Display.get_default()
|
||||||
|
for name in ("_colors.css", "style.css"):
|
||||||
|
path = STYLE_DIR / name
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
provider = Gtk.CssProvider()
|
||||||
|
provider.load_from_path(str(path))
|
||||||
|
Gtk.StyleContext.add_provider_for_display(
|
||||||
|
display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
"""Minimal StatusNotifierItem (SNI) HOST — connects to whichever
|
||||||
|
org.kde.StatusNotifierWatcher is already running (the existing eww bars' own
|
||||||
|
systray widget registers one) and renders the currently registered tray items
|
||||||
|
as clickable planet buttons. The SNI spec allows multiple hosts, so registering
|
||||||
|
alongside eww's own host is expected and fine, not a conflict.
|
||||||
|
|
||||||
|
Rebuilt fresh each time the tray popover box is built (dock.py calls
|
||||||
|
build_into() on every dock reveal, since favorites — and therefore the tray
|
||||||
|
satellite — rebuild then) rather than live-subscribed to Registered/
|
||||||
|
Unregistered signals — simple and correct for a popover that's only open
|
||||||
|
briefly; if no watcher is running at all, build_into() shows a small
|
||||||
|
placeholder instead of raising.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
gi.require_version("Gio", "2.0")
|
||||||
|
from gi.repository import Gio, GLib, Gtk # noqa: E402
|
||||||
|
|
||||||
|
_WATCHER_BUS = "org.kde.StatusNotifierWatcher"
|
||||||
|
_WATCHER_PATH = "/StatusNotifierWatcher"
|
||||||
|
_WATCHER_IFACE = "org.kde.StatusNotifierWatcher"
|
||||||
|
_ITEM_IFACE = "org.kde.StatusNotifierItem"
|
||||||
|
_HOST_NAME = "org.kde.StatusNotifierHost-horizon-dock"
|
||||||
|
|
||||||
|
|
||||||
|
class TrayHost:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._watcher: Optional[Gio.DBusProxy] = None
|
||||||
|
self._registered_host = False
|
||||||
|
self._connect()
|
||||||
|
|
||||||
|
def _connect(self) -> None:
|
||||||
|
try:
|
||||||
|
proxy = Gio.DBusProxy.new_for_bus_sync(
|
||||||
|
Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None,
|
||||||
|
_WATCHER_BUS, _WATCHER_PATH, _WATCHER_IFACE, None,
|
||||||
|
)
|
||||||
|
# A proxy can be constructed even for a name with no owner yet — only
|
||||||
|
# a real name-owner query tells us whether a watcher is actually up.
|
||||||
|
self._watcher = proxy if proxy.get_name_owner() is not None else None
|
||||||
|
except GLib.Error:
|
||||||
|
self._watcher = None
|
||||||
|
|
||||||
|
def _ensure_host_registered(self) -> None:
|
||||||
|
if self._registered_host or self._watcher is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._watcher.call_sync(
|
||||||
|
"RegisterStatusNotifierHost", GLib.Variant("(s)", (_HOST_NAME,)),
|
||||||
|
Gio.DBusCallFlags.NONE, 500, None,
|
||||||
|
)
|
||||||
|
except GLib.Error:
|
||||||
|
pass # some watchers don't require/support this explicitly — fine either way
|
||||||
|
self._registered_host = True
|
||||||
|
|
||||||
|
def _registered_items(self) -> list[str]:
|
||||||
|
if self._watcher is None:
|
||||||
|
return []
|
||||||
|
value = self._watcher.get_cached_property("RegisteredStatusNotifierItems")
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
return list(value.unpack())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_item(entry: str) -> tuple[str, str]:
|
||||||
|
"""Registration strings are either just a bus name (path defaults to
|
||||||
|
/StatusNotifierItem) or "busname/ObjectPath" — implementations disagree,
|
||||||
|
so accept both."""
|
||||||
|
if "/" in entry:
|
||||||
|
bus, _, path = entry.partition("/")
|
||||||
|
return bus, "/" + path
|
||||||
|
return entry, "/StatusNotifierItem"
|
||||||
|
|
||||||
|
# -- public: build the popover contents -----------------------------------
|
||||||
|
def build_into(self, box: Gtk.Box) -> None:
|
||||||
|
"""Populate `box` with one planet button per currently registered tray
|
||||||
|
item. Safe to call repeatedly (dock.py does, on every dock reveal)."""
|
||||||
|
if self._watcher is None:
|
||||||
|
box.append(Gtk.Label(label="No tray watcher running"))
|
||||||
|
return
|
||||||
|
self._ensure_host_registered()
|
||||||
|
|
||||||
|
entries = self._registered_items()
|
||||||
|
if not entries:
|
||||||
|
box.append(Gtk.Label(label="(empty)"))
|
||||||
|
return
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
bus, path = self._parse_item(entry)
|
||||||
|
btn = self._build_item_button(bus, path)
|
||||||
|
if btn is not None:
|
||||||
|
box.append(btn)
|
||||||
|
|
||||||
|
def _build_item_button(self, bus: str, path: str) -> Optional[Gtk.Button]:
|
||||||
|
try:
|
||||||
|
proxy = Gio.DBusProxy.new_for_bus_sync(
|
||||||
|
Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None,
|
||||||
|
bus, path, _ITEM_IFACE, None,
|
||||||
|
)
|
||||||
|
except GLib.Error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
icon_name = self._prop_str(proxy, "IconName") or "application-x-executable"
|
||||||
|
title = self._prop_str(proxy, "Title") or self._prop_str(proxy, "IconName") or bus
|
||||||
|
|
||||||
|
btn = Gtk.Button()
|
||||||
|
btn.set_has_frame(False)
|
||||||
|
btn.add_css_class("horizon-planet")
|
||||||
|
btn.add_css_class("horizon-tray-item")
|
||||||
|
btn.set_tooltip_text(title)
|
||||||
|
image = Gtk.Image.new_from_icon_name(icon_name)
|
||||||
|
image.set_pixel_size(20)
|
||||||
|
btn.set_child(image)
|
||||||
|
btn.connect("clicked", lambda *_a, p=proxy: self._activate(p))
|
||||||
|
|
||||||
|
right_click = Gtk.GestureClick(button=3)
|
||||||
|
right_click.connect("pressed", lambda *_a, p=proxy: self._context_menu(p))
|
||||||
|
btn.add_controller(right_click)
|
||||||
|
return btn
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _prop_str(proxy: Gio.DBusProxy, name: str) -> str:
|
||||||
|
value = proxy.get_cached_property(name)
|
||||||
|
return value.unpack() if value is not None else ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _activate(proxy: Gio.DBusProxy) -> None:
|
||||||
|
proxy.call("Activate", GLib.Variant("(ii)", (0, 0)),
|
||||||
|
Gio.DBusCallFlags.NONE, -1, None, None, None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _context_menu(proxy: Gio.DBusProxy) -> None:
|
||||||
|
proxy.call("ContextMenu", GLib.Variant("(ii)", (0, 0)),
|
||||||
|
Gio.DBusCallFlags.NONE, -1, None, None, None)
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""Open-window data source — synchronous `hyprctl clients -j`, same pattern as
|
||||||
|
orbit-menu/menu_tree.py's _hyprctl_json (a local unix-socket round trip, cheap
|
||||||
|
enough to call straight from the UI thread; refreshed each time the dock is
|
||||||
|
revealed, not polled continuously)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def open_windows() -> list[dict]:
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["hyprctl", "clients", "-j"], capture_output=True,
|
||||||
|
text=True, timeout=1.5, check=True).stdout
|
||||||
|
clients = json.loads(out)
|
||||||
|
except (subprocess.SubprocessError, json.JSONDecodeError, OSError):
|
||||||
|
return []
|
||||||
|
wins = [w for w in clients if w.get("mapped", True) and w.get("class")]
|
||||||
|
wins.sort(key=lambda w: (w.get("workspace", {}).get("id", 0),
|
||||||
|
(w.get("title") or w.get("class") or "").lower()))
|
||||||
|
return wins
|
||||||
|
|
||||||
|
|
||||||
|
def focus_window(address: str) -> None:
|
||||||
|
subprocess.Popen(["hyprctl", "dispatch", f'hl.dsp.focus({{ window = "address:{address}" }})'],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# =============================================================================
|
||||||
|
# application-style.conf — Placeholder for per-application Hyprland styling
|
||||||
|
#
|
||||||
|
# This file is intentionally empty. It is reserved for future use as a place
|
||||||
|
# to override decoration, opacity, or blur settings on a per-application basis
|
||||||
|
# (e.g., forcing a specific app to use a different rounding or border colour).
|
||||||
|
#
|
||||||
|
# Any rules added here will be sourced by hyprland.lua or the main conf chain.
|
||||||
|
# =============================================================================
|
||||||
|
|
@ -0,0 +1,384 @@
|
||||||
|
|
||||||
|
source = ~/.config/input.conf
|
||||||
|
source = ~/.config/monitors.conf
|
||||||
|
|
||||||
|
|
||||||
|
# https://wiki.hyprland.org/Configuring/Variables/#gestures
|
||||||
|
gestures {
|
||||||
|
workspace_swipe = true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Example per-device config
|
||||||
|
# See https://wiki.hyprland.org/Configuring/Keywords/#per-device-input-configs for more
|
||||||
|
device {
|
||||||
|
name = epic-mouse-v1
|
||||||
|
sensitivity = -0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#autogenerated = 0 # remove this line to remove the warning
|
||||||
|
|
||||||
|
# This is an example Hyprland config file.
|
||||||
|
# Refer to the wiki for more information.
|
||||||
|
# https://wiki.hyprland.org/Configuring/Configuring-Hyprland/
|
||||||
|
|
||||||
|
# Please note not all available settings / options are set here.
|
||||||
|
# For a full list, see the wiki
|
||||||
|
|
||||||
|
# You can split this configuration into multiple files
|
||||||
|
# Create your files separately and then link them to this file like this:
|
||||||
|
# source = ~/.config/hypr/myColors.conf
|
||||||
|
|
||||||
|
|
||||||
|
################
|
||||||
|
### MONITORS ###
|
||||||
|
################
|
||||||
|
|
||||||
|
# See https://wiki.hyprland.org/Configuring/Monitors/
|
||||||
|
monitor=,preferred,auto,auto
|
||||||
|
|
||||||
|
################
|
||||||
|
### XWayland ###
|
||||||
|
################
|
||||||
|
|
||||||
|
# change monitor to high resolution, the last argument is the scale factor
|
||||||
|
#monitor = , highres, auto, 2
|
||||||
|
|
||||||
|
# unscale XWayland
|
||||||
|
xwayland {
|
||||||
|
force_zero_scaling = true
|
||||||
|
}
|
||||||
|
|
||||||
|
# toolkit-specific scale
|
||||||
|
env = GDK_SCALE,2
|
||||||
|
env = XCURSOR_SIZE,32
|
||||||
|
|
||||||
|
###################
|
||||||
|
### MY PROGRAMS ###
|
||||||
|
###################
|
||||||
|
|
||||||
|
# See https://wiki.hyprland.org/Configuring/Keywords/
|
||||||
|
|
||||||
|
# Set programs that you use
|
||||||
|
$terminal = kitty
|
||||||
|
$fileManager = kitty -e yazi
|
||||||
|
$editor = kitty micro
|
||||||
|
$menu = wofi --show=drun
|
||||||
|
|
||||||
|
|
||||||
|
#################
|
||||||
|
### AUTOSTART ###
|
||||||
|
#################
|
||||||
|
|
||||||
|
# Autostart necessary processes (like notifications daemons, status bars, etc.)
|
||||||
|
# Or execute your favorite apps at launch like this:
|
||||||
|
|
||||||
|
exec-once=bash ~/.config/hypr/ewwstart.sh
|
||||||
|
|
||||||
|
#exec-once = waybar
|
||||||
|
exec-once = dunst
|
||||||
|
# exec-once = nm-applet &
|
||||||
|
exec-once = swww-daemon & swww img /ast/bg.jpg & sleep 1s & swww restore
|
||||||
|
|
||||||
|
|
||||||
|
#############################
|
||||||
|
### ENVIRONMENT VARIABLES ###
|
||||||
|
#############################
|
||||||
|
|
||||||
|
# See https://wiki.hyprland.org/Configuring/Environment-variables/
|
||||||
|
|
||||||
|
env = XCURSOR_SIZE,40
|
||||||
|
env = HYPRCURSOR_SIZE,40
|
||||||
|
|
||||||
|
|
||||||
|
#####################
|
||||||
|
### LOOK AND FEEL ###
|
||||||
|
#####################
|
||||||
|
|
||||||
|
# Refer to https://wiki.hyprland.org/Configuring/Variables/
|
||||||
|
|
||||||
|
# https://wiki.hyprland.org/Configuring/Variables/#general
|
||||||
|
general {
|
||||||
|
gaps_in = 5
|
||||||
|
gaps_out = 20
|
||||||
|
|
||||||
|
border_size = 4
|
||||||
|
|
||||||
|
# https://wiki.hyprland.org/Configuring/Variables/#variable-types for info about colors
|
||||||
|
col.active_border = rgb(E40046) rgb(fc0588) 40deg
|
||||||
|
col.inactive_border = rgb(5018dd)
|
||||||
|
|
||||||
|
# Set to true enable resizing windows by clicking and dragging on borders and gaps
|
||||||
|
resize_on_border = false
|
||||||
|
|
||||||
|
# Please see https://wiki.hyprland.org/Configuring/Tearing/ before you turn this on
|
||||||
|
allow_tearing = false
|
||||||
|
|
||||||
|
layout = dwindle
|
||||||
|
}
|
||||||
|
|
||||||
|
# https://wiki.hyprland.org/Configuring/Variables/#decoration
|
||||||
|
decoration {
|
||||||
|
rounding = 20
|
||||||
|
|
||||||
|
# Change transparency of focused and unfocused windows
|
||||||
|
active_opacity = 1
|
||||||
|
inactive_opacity = 0.8
|
||||||
|
#drop_shadow = true
|
||||||
|
#shadow_range = 4
|
||||||
|
#shadow_render_power = 3
|
||||||
|
#col.shadow = rgba(1a1a1aee)
|
||||||
|
|
||||||
|
# https://wiki.hyprland.org/Configuring/Variables/#blur
|
||||||
|
blur {
|
||||||
|
enabled = true
|
||||||
|
size = 3
|
||||||
|
passes = 3
|
||||||
|
|
||||||
|
vibrancy = 0.1696
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# https://wiki.hyprland.org/Configuring/Variables/#animations
|
||||||
|
animations {
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
# Default animations, see https://wiki.hyprland.org/Configuring/Animations/ for more
|
||||||
|
|
||||||
|
bezier = myBezier, 0.05, 0.9, 0.1, 1.05
|
||||||
|
|
||||||
|
animation = windows, 1, 7, myBezier
|
||||||
|
animation = windowsOut, 1, 7, default, popin 80%
|
||||||
|
animation = border, 1, 10, default
|
||||||
|
animation = borderangle, 1, 8, default
|
||||||
|
animation = fade, 1, 7, default
|
||||||
|
#animation = workspaces, 1, 6, default
|
||||||
|
#animation = workspaces,1,5,default,slidevert
|
||||||
|
animation = specialWorkspace, 1, 10, default, slidevert
|
||||||
|
}
|
||||||
|
|
||||||
|
# See https://wiki.hyprland.org/Configuring/Dwindle-Layout/ for more
|
||||||
|
dwindle {
|
||||||
|
pseudotile = true # Master switch for pseudotiling. Enabling is bound to mainMod + P in the keybinds section below
|
||||||
|
preserve_split = true # You probably want this
|
||||||
|
}
|
||||||
|
|
||||||
|
# See https://wiki.hyprland.org/Configuring/Master-Layout/ for more
|
||||||
|
master {
|
||||||
|
new_status = master
|
||||||
|
}
|
||||||
|
|
||||||
|
# https://wiki.hyprland.org/Configuring/Variables/#misc
|
||||||
|
misc {
|
||||||
|
force_default_wallpaper = 0 # Set to 0 or 1 to disable the anime mascot wallpapers
|
||||||
|
disable_hyprland_logo = true # If true disables the random hyprland logo / anime girl background. :(
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
####################
|
||||||
|
### KEYBINDINGSS ###
|
||||||
|
####################
|
||||||
|
|
||||||
|
# See https://wiki.hyprland.org/Configuring/Keywords/
|
||||||
|
$mainMod = SUPER # Sets "Windows" key as main modifier
|
||||||
|
|
||||||
|
# Example binds, see https://wiki.hyprland.org/Configuring/Binds/ for more
|
||||||
|
bind = $mainMod, T, exec, $terminal
|
||||||
|
bind = $mainMod, Z, exec, $terminal btop
|
||||||
|
bind = $mainMod, K, killactive,
|
||||||
|
bind = $mainMod, M, exec, $editor
|
||||||
|
bind = $mainMod, F, exec, $fileManager
|
||||||
|
bind = $mainMod, V, togglefloating,
|
||||||
|
bind = $mainMod, H, movecurrentworkspacetomonitor
|
||||||
|
bind = $mainMod, D, pseudo, # dwindle
|
||||||
|
bind = $mainMod, J, togglesplit, # dwindle
|
||||||
|
bind = $mainMod, L, exec, hyprctl dispatch exit 1
|
||||||
|
#bind = $mainMod, A, exec, killall waybar ; waybar
|
||||||
|
bind = $mainMod, A, exec, eww reload
|
||||||
|
bind = $mainMod, N, exec, nextcloud
|
||||||
|
bind = $mainMod, I, exec, kitty nmtui
|
||||||
|
bind = $mainMod, S, exec, kitty pulsemixer
|
||||||
|
|
||||||
|
|
||||||
|
# Move focus with mainMod + arrow keys
|
||||||
|
bind = $mainMod, left, movefocus, l
|
||||||
|
bind = $mainMod, right, movefocus, r
|
||||||
|
bind = $mainMod, up, movefocus, u
|
||||||
|
bind = $mainMod, down, movefocus, d
|
||||||
|
|
||||||
|
# Switch workspaces with mainMod + [0-9]
|
||||||
|
bind = $mainMod, 1, workspace, 1
|
||||||
|
bind = $mainMod, 2, workspace, 2
|
||||||
|
bind = $mainMod, 3, workspace, 3
|
||||||
|
bind = $mainMod, 4, workspace, 4
|
||||||
|
bind = $mainMod, 5, workspace, 5
|
||||||
|
bind = $mainMod, 6, workspace, 6
|
||||||
|
bind = $mainMod, 7, workspace, 7
|
||||||
|
bind = $mainMod, 8, workspace, 8
|
||||||
|
bind = $mainMod, 9, workspace, 9
|
||||||
|
bind = $mainMod, 0, workspace, 10
|
||||||
|
|
||||||
|
# Move active window to a workspace with mainMod + SHIFT + [0-9]
|
||||||
|
bind = $mainMod SHIFT, 1, movetoworkspace, 1
|
||||||
|
bind = $mainMod SHIFT, 2, movetoworkspace, 2
|
||||||
|
bind = $mainMod SHIFT, 3, movetoworkspace, 3
|
||||||
|
bind = $mainMod SHIFT, 4, movetoworkspace, 4
|
||||||
|
bind = $mainMod SHIFT, 5, movetoworkspace, 5
|
||||||
|
bind = $mainMod SHIFT, 6, movetoworkspace, 6
|
||||||
|
bind = $mainMod SHIFT, 7, movetoworkspace, 7
|
||||||
|
bind = $mainMod SHIFT, 8, movetoworkspace, 8
|
||||||
|
bind = $mainMod SHIFT, 9, movetoworkspace, 9
|
||||||
|
bind = $mainMod SHIFT, 0, movetoworkspace, 10
|
||||||
|
|
||||||
|
# Example special workspace (scratchpad)
|
||||||
|
bind = $mainMod, SPACE, togglespecialworkspace, magic
|
||||||
|
bind = $mainMod SHIFT, SPACE, movetoworkspace, special:magic
|
||||||
|
|
||||||
|
bind = $mainMod CTRL, up, togglespecialworkspace, magic
|
||||||
|
bind = $mainMod CTRL SHIFT, up, movetoworkspace, special:magic
|
||||||
|
|
||||||
|
|
||||||
|
# Scroll through existing workspaces with mainMod + scroll
|
||||||
|
bind = $mainMod, mouse_down, workspace, e+1
|
||||||
|
bind = $mainMod, mouse_up, workspace, e-1
|
||||||
|
|
||||||
|
binde = $mainMod, XF86AudioLowerVolume, workspace, e-1
|
||||||
|
binde = $mainMod, XF86AudioRaiseVolume, workspace, e+1
|
||||||
|
|
||||||
|
bind = $mainMod SHIFT, XF86AudioLowerVolume, movetoworkspace, e-1
|
||||||
|
bind = $mainMod SHIFT, XF86AudioRaiseVolume, movetoworkspace, e+1
|
||||||
|
|
||||||
|
bind = $mainMod CTRL, left, workspace, e-1
|
||||||
|
bind = $mainMod CTRL, right, workspace, e+1
|
||||||
|
|
||||||
|
bind = $mainMod CTRL SHIFT, left, movetoworkspace, e-1
|
||||||
|
bind = $mainMod CTRL SHIFT, right, movetoworkspace, e+1
|
||||||
|
|
||||||
|
# Move/resize windows with mainMod + LMB/RMB and dragging
|
||||||
|
bindm = $mainMod, mouse:272, movewindow
|
||||||
|
bindm = $mainMod, mouse:273, resizewindow
|
||||||
|
bindm = $mainMod SHIFT, mouse:272, resizewindow
|
||||||
|
|
||||||
|
# Move/rezie windows with Keyboard
|
||||||
|
|
||||||
|
#rofi keybind
|
||||||
|
bind = $mainMod, R, exec, wofi --show drun
|
||||||
|
bind = CTRL SHIFT, R, exec, wofi --show drun
|
||||||
|
|
||||||
|
#window location edit mode source: https://www.reddit.com/r/hyprland/comments/14jehzj/comment/jpl5c8f/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button
|
||||||
|
|
||||||
|
#bind = $mainMod, A, submap, fastedit
|
||||||
|
#submap = fastedit
|
||||||
|
bind = $mainMod ALT, right, resizeactive, 10 0
|
||||||
|
bind = $mainMod ALT, left, resizeactive, -10 0
|
||||||
|
bind = $mainMod ALT, up, resizeactive, 0 -10
|
||||||
|
bind = $mainMod ALT, down, resizeactive, 0 10
|
||||||
|
|
||||||
|
bind = $mainMod SHIFT, left, movewindow, l
|
||||||
|
bind = $mainMod SHIFT, right, movewindow, r
|
||||||
|
bind = $mainMod SHIFT, up, movewindow, u
|
||||||
|
bind = $mainMod SHIFT, down, movewindow, d
|
||||||
|
#bind = , escape, submap, reset
|
||||||
|
#submap = reset
|
||||||
|
|
||||||
|
bind = $mainMod SHIFT, K , exec, hyprctl kill
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#flameshot
|
||||||
|
#monitor=eDP-1, preferred, 0x0, 1
|
||||||
|
#monitor=DP-1, preferred, 1920x0, 1
|
||||||
|
#
|
||||||
|
#windowrule=fakefullscreen, flameshot
|
||||||
|
#windowrule=float,flameshot
|
||||||
|
#windowrule=monitor 0,flameshot
|
||||||
|
#windowrule=move 0 0,flameshot
|
||||||
|
#windowrule=workspace 0,flameshot
|
||||||
|
#windowrule=stayfocused,flameshot
|
||||||
|
|
||||||
|
#screenshot capture
|
||||||
|
bind = , Print, exec, grim -g "$(slurp -d)" - | wl-copy
|
||||||
|
bind = $mainMod, P, exec, grim -g "$(slurp -d)" - | wl-copy
|
||||||
|
|
||||||
|
#audio controls
|
||||||
|
binde =, XF86AudioRaiseVolume, exec, wpctl set-volume -l 1.4 @DEFAULT_AUDIO_SINK@ 5%+
|
||||||
|
|
||||||
|
binde =, XF86AudioLowerVolume, exec, wpctl set-volume -l 1.4 @DEFAULT_AUDIO_SINK@ 5%-
|
||||||
|
|
||||||
|
bind =, XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle
|
||||||
|
|
||||||
|
bindle=, XF86MonBrightnessUp, exec, bri --up
|
||||||
|
bindle=, XF86MonBrightnessDown, exec, bri --down
|
||||||
|
|
||||||
|
bindl=, XF86AudioPlay, exec, playerctl play-pause -p spotify, vlc
|
||||||
|
|
||||||
|
#exec-once = flameshot
|
||||||
|
#bind = $mainMod, Print, exec, flameshot gui
|
||||||
|
#bind = $mainMod, Print, exec, XDG_CURRENT_DESKTOP=hyprland flameshot gui -r | wl-copy
|
||||||
|
|
||||||
|
##############################
|
||||||
|
### WINDOWS AND WORKSPACES ###
|
||||||
|
##############################
|
||||||
|
|
||||||
|
# See https://wiki.hyprland.org/Configuring/Window-Rules/ for more
|
||||||
|
# See https://wiki.hyprland.org/Configuring/Workspace-Rules/ for workspace rules
|
||||||
|
|
||||||
|
# Example windowrule v1
|
||||||
|
windowrule = float, ^(rofi)$
|
||||||
|
|
||||||
|
# Example windowrule v2
|
||||||
|
# windowrulev2 = float,class:^(kitty)$,title:^(kitty)$
|
||||||
|
|
||||||
|
windowrulev2 = suppressevent maximize, class:.* # You'll probably like this.
|
||||||
|
|
||||||
|
#windowrulev2 = noborder, class:wofi
|
||||||
|
windowrulev2 = float, class:wofi
|
||||||
|
windowrulev2 = pin, class:wofi
|
||||||
|
windowrulev2 = center, class:wofi
|
||||||
|
windowrulev2 = stayfocused, class:wofi
|
||||||
|
windowrulev2 = nomaxsize, class:wofi
|
||||||
|
windowrulev2 = minsize 5 5, class:wofi
|
||||||
|
windowrulev2 = noshadow, class:wofi
|
||||||
|
|
||||||
|
windowrulev2 = opacity 1 0.8, class:kitty
|
||||||
|
windowrulev2 = opacity 0.8 0.3, initialTitle:Spotify
|
||||||
|
windowrulev2 = float, initialTitle:Spotify
|
||||||
|
|
||||||
|
|
||||||
|
#sefix
|
||||||
|
windowrulev2 = float, class:Spaceengineers
|
||||||
|
windowrulev2 = stayfocused, class:Spaceengineers
|
||||||
|
|
||||||
|
#rwfix
|
||||||
|
windowrulev2 = tile, class:RimWorldLinux
|
||||||
|
|
||||||
|
windowrulev2 = float, class:Drill.+
|
||||||
|
#windowrulev2 = fakefullscreen, title:Drill.+
|
||||||
|
|
||||||
|
#windowrule = fakefullscreen, class:steam_app_1217060
|
||||||
|
#windowrule = fakefullscreen, title:Gunfire Reborn
|
||||||
|
|
||||||
|
windowrule = float, class:steam_app_1217060
|
||||||
|
windowrule = float, title:Gunfire Reborn
|
||||||
|
|
||||||
|
#windowrulev2 = fullscreen, class:steam_app_1217060
|
||||||
|
#windowrulev2 = fullscreen, title:Gunfire Reborn
|
||||||
|
|
||||||
|
windowrulev2 = float, title:pico.+
|
||||||
|
|
||||||
|
windowrulev2 = minsize 60 70, title:.+btop.+
|
||||||
|
|
||||||
|
windowrulev2 = stayfocused, class:.+dmenu.+
|
||||||
|
|
||||||
|
windowrulev2 = move 70% 10%, class:com.nextcloud.desktopclient.nextcloud
|
||||||
|
windowrulev2 = float, class:com.nextcloud.desktopclient.nextcloud
|
||||||
|
windowrulev2 = pin, class:com.nextcloud.desktopclient.nextcloud
|
||||||
|
windowrulev2 = size 28% 50%, class:com.nextcloud.desktopclient.nextcloud
|
||||||
|
|
||||||
|
#exec-once = nextcloud
|
||||||
|
exec-once=bash swww img ~/Pictures/background.jpg && swww restore
|
||||||
|
#exec-once=bash dunst
|
||||||
|
#exec-once=bash nm-applet --indicator
|
||||||
|
#exec-once=exec, [workspace 2 silent; float; move 0 0] kitty
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
# /* ---- 💫 https://github.com/JaKooLit 💫 ---- */ #
|
||||||
|
# Hyprlock
|
||||||
|
# Original config submitted by https://github.com/SherLock707
|
||||||
|
|
||||||
|
# Sourcing colors generated by wallust
|
||||||
|
source = $HOME/.config/hypr/wallust/wallust-hyprland.conf
|
||||||
|
$Scripts = $HOME/.config/hypr/scripts
|
||||||
|
|
||||||
|
general {
|
||||||
|
grace = 2
|
||||||
|
hide_cursor = true
|
||||||
|
ignore_empty_input = true
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
pam:module = sudo
|
||||||
|
}
|
||||||
|
|
||||||
|
background {
|
||||||
|
monitor =
|
||||||
|
#path = screenshot # screenshot of your desktop
|
||||||
|
path = ~/Pictures/background.jpg # NOTE only png supported for now
|
||||||
|
#color = $color7
|
||||||
|
|
||||||
|
# all these options are taken from hyprland, see https://wiki.hyprland.org/Configuring/Variables/#blur for explanations
|
||||||
|
blur_size = 5
|
||||||
|
blur_passes = 1 # 0 disables blurring
|
||||||
|
noise = 0.0117
|
||||||
|
contrast = 1.3000 # Vibrant!!!
|
||||||
|
brightness = 0.8000
|
||||||
|
vibrancy = 0.2100
|
||||||
|
vibrancy_darkness = 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
input-field {
|
||||||
|
monitor =
|
||||||
|
size = 250, 50
|
||||||
|
outline_thickness = 8
|
||||||
|
dots_size = 0.33 # Scale of input-field height, 0.2 - 0.8
|
||||||
|
dots_spacing = 0.15 # Scale of dots' absolute size, 0.0 - 1.0
|
||||||
|
dots_center = true
|
||||||
|
#fade_on_empty = true
|
||||||
|
placeholder_text = <i>Password...</i> # Text rendered in the input box when it's empty.
|
||||||
|
hide_input = false
|
||||||
|
|
||||||
|
position = 0, 200
|
||||||
|
halign = center
|
||||||
|
valign = bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
# Date
|
||||||
|
label {
|
||||||
|
monitor =
|
||||||
|
text = cmd[update:18000000] echo "<b> "$(date +'%A, %-d %B %Y')" </b>"
|
||||||
|
color = $color12
|
||||||
|
font_size = 34
|
||||||
|
font_family = Agave NerdFont 10
|
||||||
|
|
||||||
|
position = 0, -150
|
||||||
|
halign = center
|
||||||
|
valign = top
|
||||||
|
}
|
||||||
|
|
||||||
|
# Week
|
||||||
|
label {
|
||||||
|
monitor =
|
||||||
|
text = cmd[update:18000000] echo "<b> "$(date +'Week %U')" </b>"
|
||||||
|
color = $color5
|
||||||
|
font_size = 24
|
||||||
|
font_family = Agave NerdFont 10
|
||||||
|
position = 0, -250
|
||||||
|
halign = center
|
||||||
|
valign = top
|
||||||
|
}
|
||||||
|
|
||||||
|
# Time
|
||||||
|
label {
|
||||||
|
monitor =
|
||||||
|
#text = cmd[update:1000] echo "<b><big> $(date +"%I:%M:%S %p") </big></b>" # AM/PM
|
||||||
|
text = cmd[update:1000] echo "<b><big> $(date +"%H:%M:%S") </big></b>" # 24H
|
||||||
|
color = $color15
|
||||||
|
font_size = 94
|
||||||
|
font_family = Agave NerdFont 10
|
||||||
|
|
||||||
|
position = 0, 0
|
||||||
|
halign = center
|
||||||
|
valign = center
|
||||||
|
}
|
||||||
|
|
||||||
|
# User
|
||||||
|
label {
|
||||||
|
monitor =
|
||||||
|
text = $USER
|
||||||
|
color = $color12
|
||||||
|
font_size = 18
|
||||||
|
font_family = Agave NerdFont 10
|
||||||
|
|
||||||
|
position = 0, 100
|
||||||
|
halign = center
|
||||||
|
valign = bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
# uptime
|
||||||
|
label {
|
||||||
|
monitor =
|
||||||
|
text = cmd[update:60000] echo "<b> "$(uptime -p || $Scripts/UptimeNixOS.sh)" </b>"
|
||||||
|
color = $color12
|
||||||
|
font_size = 24
|
||||||
|
font_family = Agave NerdFont 10
|
||||||
|
|
||||||
|
position = 0, 0
|
||||||
|
halign = right
|
||||||
|
valign = bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
# weather edit specific location. Note, this cause a 2-4 seconds delay in locking
|
||||||
|
label {
|
||||||
|
monitor =
|
||||||
|
text = cmd[update:10000] sh ~/Dotfiles/desktopenvs/hyprland/eww/scripts/batteryperc
|
||||||
|
color = $color12
|
||||||
|
font_size = 24
|
||||||
|
font_family = Agave NerdFont 10
|
||||||
|
position = 50, 0
|
||||||
|
halign = left
|
||||||
|
valign = bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
# Put a picture of choice here. Default is the current wallpaper
|
||||||
|
image {
|
||||||
|
monitor =
|
||||||
|
path = $HOME/.config/hypr/wallpaper_effects/.wallpaper_current
|
||||||
|
size = 230
|
||||||
|
rounding = -1
|
||||||
|
border_size = 2
|
||||||
|
border_color = $color11
|
||||||
|
rotate = 0
|
||||||
|
reload_time = -1
|
||||||
|
position = 0, 300
|
||||||
|
halign = center
|
||||||
|
valign = bottom
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
# =============================================================================
|
||||||
|
# hypridle.conf — Idle management daemon configuration
|
||||||
|
#
|
||||||
|
# hypridle watches for user inactivity and triggers actions (lock, suspend)
|
||||||
|
# after configurable timeouts. It integrates with the presence-detection daemon
|
||||||
|
# (presence-detect.sh) which resets the idle timer every 20s (backed off to 120s
|
||||||
|
# under heavy CPU/RAM load) while the webcam detects motion, so these timeouts
|
||||||
|
# only fire when the user has truly stepped away from the machine.
|
||||||
|
#
|
||||||
|
# Reference: https://wiki.hypr.land/Hypr-Ecosystem/hypridle/
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
general {
|
||||||
|
# Command to run when the lock screen should be shown.
|
||||||
|
# `pidof hyprlock || hyprlock` first checks if hyprlock is already running
|
||||||
|
# (pidof returns 0 if found) and only launches a new instance if it is not.
|
||||||
|
# This prevents stacking multiple lock screen instances on repeated trigger.
|
||||||
|
lock_cmd = pidof hyprlock || hyprlock
|
||||||
|
|
||||||
|
# Lock the session immediately when the system is about to suspend.
|
||||||
|
# loginctl lock-session sends the Lock D-Bus signal to the session manager,
|
||||||
|
# which hyprlock listens to, ensuring the screen is locked before the
|
||||||
|
# display blanks during suspend.
|
||||||
|
before_sleep_cmd = loginctl lock-session
|
||||||
|
|
||||||
|
# fprintd restart ensures fingerprint sensor is ready after resume
|
||||||
|
# Turn the display back on after the system wakes from suspend.
|
||||||
|
# `hyprctl dispatch dpms on` sends DPMS power-on to all connected monitors.
|
||||||
|
after_sleep_cmd = hyprctl dispatch dpms on
|
||||||
|
|
||||||
|
# ignore_dbus_inhibit = false means hypridle honours D-Bus idle-inhibit locks.
|
||||||
|
# This allows systemd-inhibit (used by the presence-detect daemon and the
|
||||||
|
# caffeine script) to pause the idle timer while they are active.
|
||||||
|
ignore_dbus_inhibit = false # respect systemd-inhibit locks (presence-detect, caffeine)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Presence detection resets the idle timer every 20s (up to 120s under load)
|
||||||
|
# while motion is detected, so these timeouts only run when you've actually
|
||||||
|
# stepped away.
|
||||||
|
|
||||||
|
# First idle listener: lock the screen after 30s of inactivity.
|
||||||
|
listener {
|
||||||
|
timeout = 30 # 30s — lock screen
|
||||||
|
# loginctl lock-session triggers the session lock signal; hyprlock picks it up.
|
||||||
|
on-timeout = loginctl lock-session
|
||||||
|
}
|
||||||
|
|
||||||
|
# Second idle listener: sleep after 1 minute of inactivity. suspend-then-hibernate
|
||||||
|
# suspends to RAM immediately (fast resume); HibernateDelaySec — set system-wide by
|
||||||
|
# hypr/systemd-sleep.conf, deployed to /etc/systemd/sleep.conf.d/ by sysupdate.sh —
|
||||||
|
# then hibernates (writes RAM to disk, fully powers it down) 79 minutes later, so
|
||||||
|
# hibernate lands ~80 minutes after the machine first went idle (1min + 79min).
|
||||||
|
listener {
|
||||||
|
timeout = 60 # 1 min — sleep
|
||||||
|
on-timeout = systemctl suspend-then-hibernate
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
-- =============================================================================
|
||||||
|
-- hyprland.lua — Root Hyprland Lua configuration entry point
|
||||||
|
--
|
||||||
|
-- This is the top-level config file loaded by Hyprland when the Lua backend
|
||||||
|
-- is active. It is intentionally kept thin: device-specific settings (monitors,
|
||||||
|
-- keybinds, environment variables, input, window rules, autostart programs) all
|
||||||
|
-- live in the usr/ subdirectory so they can be deployed per-machine without
|
||||||
|
-- touching this shared file.
|
||||||
|
--
|
||||||
|
-- Reference: https://wiki.hypr.land/Configuring/Start/
|
||||||
|
-- Device-specific files live in ~/.config/hypr/usr/ (deployed from hypr/usr/).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Pull in per-device configuration modules.
|
||||||
|
-- monitors is loaded from the Lua search path root (monitors.lua, managed by
|
||||||
|
-- hyprmoncfg); the remaining modules live in usr/ and are per-machine overrides.
|
||||||
|
-- They are loaded in this specific order so that later modules can safely
|
||||||
|
-- depend on earlier ones (e.g., binds may reference programs launched by
|
||||||
|
-- autostart; input-device-exceptions builds on the global input baseline).
|
||||||
|
require("usr.monitors") -- monitor layout, resolution, scale (managed by hyprmoncfg)
|
||||||
|
require("usr.envvars") -- environment variables injected into every child process
|
||||||
|
require("usr.input") -- keyboard layout, mouse sensitivity, touchpad behaviour
|
||||||
|
require("usr.binds") -- all keybindings and mouse button bindings
|
||||||
|
require("usr.windowrules") -- per-app float/pin/opacity/tag/size rules
|
||||||
|
require("usr.autostart") -- programs launched once when the compositor starts
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
---- MY PROGRAMS ---
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
-- Convenience variables naming the preferred application for each role.
|
||||||
|
-- These are referenced by keybind definitions in usr/binds.lua so that
|
||||||
|
-- changing the preferred terminal, editor, etc. requires only a single edit here.
|
||||||
|
local terminal = "kitty" -- GPU-accelerated terminal emulator
|
||||||
|
local fileManager = "thunar" -- GTK file manager (Xfce/GNOME ecosystem)
|
||||||
|
local editor = "kitty nvim" -- Neovim inside kitty; avoids a separate GUI wrapper
|
||||||
|
local menu = "vicinae toggle" -- vicinae is the custom app-launcher; toggle shows/hides it
|
||||||
|
|
||||||
|
---------------------
|
||||||
|
---- LOOK & FEEL ----
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
-- hl.config() is the Lua API equivalent of Hyprland's declarative keyword blocks.
|
||||||
|
-- All visual and layout settings for the compositor are collected here so the full
|
||||||
|
-- theming picture is visible in one place rather than spread across multiple files.
|
||||||
|
hl.config({
|
||||||
|
general = {
|
||||||
|
-- Inner gap between tiled windows (px).
|
||||||
|
-- Kept small (3 px) so screen real-estate is not wasted on multi-monitor setups.
|
||||||
|
gaps_in = 3,
|
||||||
|
-- Outer gap between the outermost windows and the monitor edge (px).
|
||||||
|
-- Slightly larger than gaps_in (6 px) to give the desktop a "framed" look,
|
||||||
|
-- ensuring windows never touch the absolute screen edge.
|
||||||
|
gaps_out = 6,
|
||||||
|
-- Border width around every window (px). 4 px is thick enough to show the
|
||||||
|
-- animated gradient colour clearly at 1.5x HiDPI scaling.
|
||||||
|
border_size = 4,
|
||||||
|
col = {
|
||||||
|
-- Active window border: an animated gradient cycling through red hues at 35 degrees.
|
||||||
|
-- Five colour stops alternating between #E40046 and #f50505 create a slow pulsing
|
||||||
|
-- effect. The angle = 35 rotates the gradient continuously (see borderangle animation).
|
||||||
|
active_border = { colors = { "rgb(E40046)", "rgb(f50505)", "rgb(E40046)", "rgb(f50505)", "rgb(E40046)" }, angle = 35 },
|
||||||
|
-- Inactive border: deep electric blue (#5018dd). Blue recedes visually relative to
|
||||||
|
-- the hot red active border, making focus immediately obvious without being distracting.
|
||||||
|
inactive_border = "rgb(5018dd)",
|
||||||
|
},
|
||||||
|
-- Disable resize-by-grabbing the window border. This prevents accidental resizes
|
||||||
|
-- when the cursor clips the edge during fast workspace switching or window dragging.
|
||||||
|
resize_on_border = false,
|
||||||
|
-- Tearing introduces screen-tear artefacts in exchange for lower latency.
|
||||||
|
-- Disabled here for a clean image; enable per-window via windowrules for games.
|
||||||
|
allow_tearing = false,
|
||||||
|
-- The default tiling algorithm is chosen at the end of this file by the layout
|
||||||
|
-- registry (require("layouts.init") → layouts.set_default), not hard-coded here.
|
||||||
|
},
|
||||||
|
group = {
|
||||||
|
-- Window groups stack multiple windows into one tiled slot with a tab bar.
|
||||||
|
-- These colours keep the group tabs visually consistent with the main border theme.
|
||||||
|
col = {
|
||||||
|
-- Active tab in a window group: accent red to match the active window border.
|
||||||
|
border_active = "rgb(E40046)",
|
||||||
|
-- Inactive tabs fade to dark blue to de-emphasise them.
|
||||||
|
border_inactive = "rgb(5018dd)",
|
||||||
|
-- "Locked" groups cannot be accidentally moved between workspaces.
|
||||||
|
-- A slightly different red (#f50505) distinguishes them from ordinary active groups.
|
||||||
|
border_locked_active = "rgb(f50505)",
|
||||||
|
border_locked_inactive = "rgb(5018dd)",
|
||||||
|
},
|
||||||
|
groupbar = {
|
||||||
|
-- Agave NerdFont provides both monospace text and NerdFont icon glyphs,
|
||||||
|
-- letting the bar display icons without requiring a separate symbol font.
|
||||||
|
font_family = "Agave NerdFont",
|
||||||
|
font_size = 20, -- px; large enough to be readable at 1.5x HiDPI scale
|
||||||
|
height = 25, -- px; matches indicator_height for a full-height flat pill
|
||||||
|
-- round_only_edges = false: every tab gets the same border-radius, not just
|
||||||
|
-- the first and last, giving a uniform pill row rather than a single rounded strip.
|
||||||
|
round_only_edges = false,
|
||||||
|
-- indicator_height = height (25 px): the selected-tab indicator fills the entire
|
||||||
|
-- bar height instead of being a thin underline, making the active tab unmissable.
|
||||||
|
indicator_height = 25,
|
||||||
|
-- stacked = false: tabs appear side-by-side horizontally (breadcrumb style),
|
||||||
|
-- not vertically stacked. Horizontal layout conserves vertical screen space.
|
||||||
|
stacked = false,
|
||||||
|
-- Tab label text colour: accent red, consistent with the active border colour.
|
||||||
|
text_color = "rgb(E40046)",
|
||||||
|
-- priority = 3 ensures the groupbar is rendered above regular window decorations
|
||||||
|
-- (shadows, rounded corners) so it is never visually clipped.
|
||||||
|
priority = 3,
|
||||||
|
-- Rounded corners on the groupbar container (13 px radius). The value is chosen
|
||||||
|
-- to complement the 20 px window rounding without looking disproportionate.
|
||||||
|
rounding = 13,
|
||||||
|
col = {
|
||||||
|
-- Active tab fill: solid accent red, clearly selected.
|
||||||
|
active = "rgb(E40046)",
|
||||||
|
-- Inactive tab fill: blue background recedes visually.
|
||||||
|
inactive = "rgb(5018dd)",
|
||||||
|
-- Locked-group tab colours mirror the border locked colours for consistency.
|
||||||
|
locked_active = "rgb(E40046)",
|
||||||
|
locked_inactive = "rgb(5018dd)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decoration = {
|
||||||
|
-- Window corner radius (px). 20 px gives a modern "card" look that matches
|
||||||
|
-- the GTK cyberqueer theme's rounded widgets.
|
||||||
|
rounding = 20,
|
||||||
|
-- Focused window: fully opaque (1.0) so content is crisp and readable.
|
||||||
|
active_opacity = 1,
|
||||||
|
-- Unfocused windows: 80% opacity hints at the depth/focus hierarchy and lets
|
||||||
|
-- the wallpaper or background windows bleed through subtly without obscuring content.
|
||||||
|
inactive_opacity = 0.8,
|
||||||
|
blur = {
|
||||||
|
-- Gaussian blur applied behind transparent/translucent surfaces (terminals,
|
||||||
|
-- unfocused windows). This creates the "frosted glass" depth effect.
|
||||||
|
enabled = true,
|
||||||
|
size = 3, -- blur kernel radius in pixels; small = tight, localised blur halo
|
||||||
|
passes = 3, -- number of successive blur passes; more = smoother but higher GPU cost
|
||||||
|
-- vibrancy boosts colour saturation of the blurred background so the wallpaper
|
||||||
|
-- colours stay vivid even when blurred under a translucent surface.
|
||||||
|
vibrancy = 0.1696,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animations = {
|
||||||
|
-- Master switch for all compositor animations.
|
||||||
|
-- Individual animation types are configured below in the animations section.
|
||||||
|
enabled = true,
|
||||||
|
},
|
||||||
|
-- Per-layout settings (dwindle/master/scrolling/monocle) live in hypr/layouts/*.lua
|
||||||
|
-- and are applied by the layout registry, so they are not repeated here.
|
||||||
|
misc = {
|
||||||
|
-- force_default_wallpaper = 0 disables Hyprland's built-in anime wallpaper so
|
||||||
|
-- hyprpaper (or any other wallpaper daemon) has exclusive control of the background.
|
||||||
|
force_default_wallpaper = 0,
|
||||||
|
-- Remove the Hyprland logo / splash animation that plays on startup.
|
||||||
|
-- The custom wallpaper is shown instead from the first frame.
|
||||||
|
disable_hyprland_logo = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
-----------------
|
||||||
|
---- ANIMATIONS --
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
-- Define a custom cubic Bezier easing curve named "myBezier".
|
||||||
|
-- Control points P1 = {0.05, 0.9} and P2 = {0.1, 1.05} produce a slight overshoot
|
||||||
|
-- (spring-like bounce) at the end of the motion — windows "pop" snappily into their
|
||||||
|
-- final position rather than gliding to a dead stop. This gives the DE an energetic feel.
|
||||||
|
hl.curve("myBezier", { type = "bezier", points = { {0.05, 0.9}, {0.1, 1.05} } })
|
||||||
|
|
||||||
|
-- Window open and resize animation uses the custom spring curve.
|
||||||
|
-- speed = 7 (Hyprland internal units; higher = faster) keeps the open feeling snappy.
|
||||||
|
hl.animation({ leaf = "windows", enabled = true, speed = 7, bezier = "myBezier" })
|
||||||
|
|
||||||
|
-- Window close animation: "popin 80%" shrinks the window to 80% of its original size
|
||||||
|
-- while it fades out, giving a quick snappy dismiss effect rather than a slow full collapse.
|
||||||
|
hl.animation({ leaf = "windowsOut", enabled = true, speed = 7, bezier = "default", style = "popin 80%" })
|
||||||
|
|
||||||
|
-- Border colour crossfade when focus changes (active red <-> inactive blue).
|
||||||
|
-- speed = 10 makes this almost instantaneous so focus changes feel immediate.
|
||||||
|
hl.animation({ leaf = "border", enabled = true, speed = 10, bezier = "default" })
|
||||||
|
|
||||||
|
-- Animated gradient angle for the active border (the rotating multi-stop red gradient).
|
||||||
|
-- speed = 8 produces a slow, ambient-pulse sweep rather than a distracting fast spin.
|
||||||
|
hl.animation({ leaf = "borderangle", enabled = true, speed = 8, bezier = "default" })
|
||||||
|
|
||||||
|
-- Opacity crossfade when a window gains or loses focus (1.0 <-> 0.8 transition).
|
||||||
|
hl.animation({ leaf = "fade", enabled = true, speed = 7, bezier = "default" })
|
||||||
|
|
||||||
|
-- Special (scratchpad) workspace entrance/exit animation.
|
||||||
|
-- "slidevert" makes it drop in from the top / slide back up, distinguishing it clearly
|
||||||
|
-- from regular workspace transitions which use horizontal slides by default.
|
||||||
|
hl.animation({ leaf = "specialWorkspace", enabled = true, speed = 10, bezier = "default", style = "slidevert" })
|
||||||
|
|
||||||
|
-- Layer-shell surfaces (the astal-menu control centre, notifications, the bar) slide
|
||||||
|
-- in from their anchored edge with the bouncy myBezier curve, so opening the menu
|
||||||
|
-- reads as a springy slide-down rather than a plain fade. (A per-namespace layerrule
|
||||||
|
-- was tried but this hyprlua build doesn't honour its animation timing; the global
|
||||||
|
-- `layers` leaf does.)
|
||||||
|
hl.animation({ leaf = "layers", enabled = true, speed = 5, bezier = "myBezier", style = "slide" })
|
||||||
|
|
||||||
|
--------------
|
||||||
|
---- DEVICE ---
|
||||||
|
--------------
|
||||||
|
|
||||||
|
-- Per-device input override for a specific mouse identified by its kernel event name.
|
||||||
|
-- Only fields that differ from the global input settings need to be listed here.
|
||||||
|
-- sensitivity = -0.5 (valid range -1 to 1) reduces pointer speed for this mouse model
|
||||||
|
-- without touching the global sensitivity, allowing different mice to have different feels.
|
||||||
|
hl.device({
|
||||||
|
name = "epic-mouse-v1",
|
||||||
|
sensitivity = -0.5,
|
||||||
|
})
|
||||||
|
|
||||||
|
---------------
|
||||||
|
---- LAYOUTS ---
|
||||||
|
---------------
|
||||||
|
|
||||||
|
-- Load the per-file layout registry (hypr/layouts/*.lua) and select the session
|
||||||
|
-- default. Switching a workspace's layout at runtime is done from the astal-menu
|
||||||
|
-- popup via `hyprctl eval 'layouts.set(ws, name, dir)'`.
|
||||||
|
require("layouts.init")
|
||||||
|
layouts.set_default("columns")
|
||||||
|
-- special:magic defaults to a downward scrolling layout instead of the session
|
||||||
|
-- default; layouts.restore() below still wins if the user has since picked
|
||||||
|
-- something else for it via the astal-menu.
|
||||||
|
layouts.set("special:magic", "scrolling", "down")
|
||||||
|
layouts.restore() -- re-apply per-workspace layout choices so a reload doesn't drop them
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# default — overwritten by ~/.config/scripts/wallpaper-picker
|
||||||
|
background {
|
||||||
|
monitor =
|
||||||
|
path = ~/Pictures/background.jpg
|
||||||
|
blur_size = 6
|
||||||
|
blur_passes = 2
|
||||||
|
noise = 0.015
|
||||||
|
contrast = 1.25
|
||||||
|
brightness = 0.85
|
||||||
|
vibrancy = 0.2
|
||||||
|
vibrancy_darkness = 0.1
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
# Hyprlock Themed Config 💫 Matching EWW
|
||||||
|
|
||||||
|
|
||||||
|
general {
|
||||||
|
# grace: seconds after lock is triggered during which the lock can be dismissed
|
||||||
|
# without a password (e.g. accidental idle). Set to 0 to require password immediately.
|
||||||
|
grace = 2
|
||||||
|
|
||||||
|
hide_cursor = true
|
||||||
|
|
||||||
|
# Do not attempt authentication if the input field is empty; prevents accidental
|
||||||
|
# PAM lockouts from empty-string submissions when brushing the keyboard.
|
||||||
|
ignore_empty_input = true
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
# PAM service name to use for authentication (/etc/pam.d/hyprlock).
|
||||||
|
# The hyprlock PAM stack is where howdy (biometric) and FIDO2 are wired in.
|
||||||
|
pam:module = hyprlock
|
||||||
|
|
||||||
|
# Enable fingerprint unlock via fprintd. Works alongside the password field;
|
||||||
|
# whichever succeeds first (finger or typed password) dismisses the lock.
|
||||||
|
fingerprint:enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
# per-monitor backgrounds written by ~/.config/scripts/wallpaper-picker
|
||||||
|
source = ~/.config/hypr/hyprlock-backgrounds.conf
|
||||||
|
|
||||||
|
input-field {
|
||||||
|
# Empty monitor = field appears on all monitors. Name a specific output (e.g. DP-1)
|
||||||
|
# to restrict the field to one screen.
|
||||||
|
monitor =
|
||||||
|
|
||||||
|
# Width × height in pixels of the password box.
|
||||||
|
size = 300, 60
|
||||||
|
|
||||||
|
# Pixel thickness of the coloured outline drawn around the input box.
|
||||||
|
outline_thickness = 4
|
||||||
|
|
||||||
|
# Dot diameter as a fraction of the input-field height (0.0–1.0).
|
||||||
|
# 0.5 = dots are half the field height, giving a balanced look.
|
||||||
|
dots_size = 0.5
|
||||||
|
|
||||||
|
# Gap between dots as a fraction of dot size. 0.2 adds a small breathing room.
|
||||||
|
dots_spacing = 0.2
|
||||||
|
|
||||||
|
dots_center = true
|
||||||
|
hide_input = false
|
||||||
|
|
||||||
|
# Pango markup is supported; <i> renders italic placeholder text.
|
||||||
|
placeholder_text = <i>Password...</i>
|
||||||
|
|
||||||
|
# Position is (x-offset, y-offset) from the anchor set by halign/valign.
|
||||||
|
# 20% pushes the field 20% of screen height above the bottom edge.
|
||||||
|
position = 0, 20%
|
||||||
|
halign = center
|
||||||
|
valign = bottom
|
||||||
|
|
||||||
|
# Accent border & color matching EWW
|
||||||
|
# Dark near-black fill so text/dots are readable against any wallpaper.
|
||||||
|
inner_color = rgb(1a1a1a)
|
||||||
|
font_color = rgb(b0b4bc)
|
||||||
|
font_family = Agave Nerd Font Mono
|
||||||
|
}
|
||||||
|
|
||||||
|
# DATE
|
||||||
|
label {
|
||||||
|
# cmd[update:N] re-runs the command every N milliseconds and replaces the label text.
|
||||||
|
# 18000000 ms = 5 hours — the date only changes once a day, so this is generous.
|
||||||
|
text = cmd[update:18000000] sh ~/Dotfiles/desktopenvs/hyprland/scripts/date.sh
|
||||||
|
color = rgb(5018dd)
|
||||||
|
font_size = 34
|
||||||
|
font_family = Agave Nerd Font Mono
|
||||||
|
# position = (x-offset, y-offset) from the halign/valign anchor.
|
||||||
|
# 0, 40% = horizontally centred, 40% of screen height above the screen centre.
|
||||||
|
position = 0, 40%
|
||||||
|
halign = center
|
||||||
|
valign = center
|
||||||
|
}
|
||||||
|
|
||||||
|
# TIME
|
||||||
|
label {
|
||||||
|
# update:1000 = refresh every second so the clock ticks in real time.
|
||||||
|
text = cmd[update:1000] sh ~/Dotfiles/desktopenvs/hyprland/scripts/time.sh
|
||||||
|
color = rgb(E40046)
|
||||||
|
# Large font makes the time the dominant visual element on the lock screen.
|
||||||
|
font_size = 95
|
||||||
|
font_family = Agave Nerd Font Mono
|
||||||
|
# 25% above centre keeps the time above the input field while staying mid-screen.
|
||||||
|
position = 0, 25%
|
||||||
|
halign = center
|
||||||
|
valign = center
|
||||||
|
}
|
||||||
|
|
||||||
|
# USER
|
||||||
|
label {
|
||||||
|
text = $USER
|
||||||
|
color = rgb(5018dd)
|
||||||
|
font_size = 18
|
||||||
|
font_family = Agave Nerd Font Mono
|
||||||
|
position = 0, 10%
|
||||||
|
halign = center
|
||||||
|
valign = bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
# UPTIME
|
||||||
|
label {
|
||||||
|
text = cmd[update:60000] sh ~/Dotfiles/desktopenvs/hyprland/scripts/uptime.sh
|
||||||
|
font_size = 24
|
||||||
|
color = rgb(5018dd)
|
||||||
|
font_family = Agave Nerd Font Mono
|
||||||
|
position = 0, 0
|
||||||
|
halign = right
|
||||||
|
valign = bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
# BATTERY
|
||||||
|
label {
|
||||||
|
text = cmd[update:10000] sh ~/Dotfiles/desktopenvs/hyprland/scripts/batteryperc
|
||||||
|
color = rgb(5018dd)
|
||||||
|
font_size = 24
|
||||||
|
font_family = Agave Nerd Font Mono
|
||||||
|
position = 50, 0
|
||||||
|
halign = left
|
||||||
|
valign = bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
# PROFILE PICTURE OR LOGO
|
||||||
|
image {
|
||||||
|
path = ~/Pictures/profile.jpg
|
||||||
|
size = 230
|
||||||
|
rounding = 110
|
||||||
|
border_size = 4
|
||||||
|
border_color = rgb(5018dd)
|
||||||
|
rotate = 0
|
||||||
|
position = 0, 40%
|
||||||
|
halign = center
|
||||||
|
valign = bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
splash = false
|
||||||
|
|
||||||
|
# fallback if no per-monitor entries exist (first boot)
|
||||||
|
wallpaper {
|
||||||
|
monitor =
|
||||||
|
path = ~/Pictures/background.jpg
|
||||||
|
fit_mode = cover
|
||||||
|
}
|
||||||
|
|
||||||
|
# per-monitor state, written by ~/.config/scripts/wallpaper-picker
|
||||||
|
source = ~/.config/hypr/usr/wallpaper.conf
|
||||||