From f5399141ed9b02bfa6f983b05f9e609cd2761f32 Mon Sep 17 00:00:00 2001 From: FinnPL Date: Fri, 28 Nov 2025 13:57:31 +0100 Subject: [PATCH 01/18] feat: add quickshell integration with Stylix colors Adapted end-4/dots-hyprland quickshell config for NixOS with Stylix theming. Features: - Quickshell bar with weather (Aachen), clock, system tray, workspaces - Overview/app launcher (ALT+SPACE) - Sidebar with calendar, quick toggles, sliders - Notification popups and OSD - Session screen for logout/shutdown - Material Design 3 colors generated from Stylix base16 theme Removed: - Waffle (Windows-like UI alternative) - Cheatsheet, on-screen keyboard, wallpaper selector - AI/Gemini features, anime/booru references - Color generation scripts (using Stylix instead) Based on: https://github.com/end-4/dots-hyprland Original author: end-4 (https://github.com/end-4) --- flake.lock | 402 +++++--- flake.nix | 17 +- hosts/centaur/configuration.nix | 27 +- hosts/centaur/home.nix | 17 +- modules/home-manager/discord.nix | 159 +++- modules/home-manager/firefox.nix | 12 +- modules/home-manager/jetbrains.nix | 35 +- modules/home-manager/kitty.nix | 4 + modules/home-manager/neovim.nix | 377 ++++++++ modules/home-manager/python-packages.nix | 3 + modules/home-manager/rust.nix | 21 + modules/hyprland/default.nix | 7 +- modules/hyprland/hyprland-config.nix | 30 +- modules/hyprland/waybar.nix | 7 +- modules/quickshell/config/.qmlformat.ini | 8 + modules/quickshell/config/GlobalStates.qml | 70 ++ modules/quickshell/config/ReloadPopup.qml | 160 ++++ .../assets/icons/cloudflare-dns-symbolic.svg | 10 + .../assets/icons/crosshair-symbolic.svg | 65 ++ .../config/assets/icons/desktop-symbolic.svg | 4 + .../config/assets/icons/flatpak-symbolic.svg | 52 ++ .../config/assets/icons/fluent/README.md | 5 + .../config/assets/icons/fluent/add-filled.svg | 1 + .../config/assets/icons/fluent/add.svg | 1 + .../assets/icons/fluent/alert-filled.svg | 4 + .../assets/icons/fluent/alert-off-filled.svg | 4 + .../config/assets/icons/fluent/alert-off.svg | 4 + .../icons/fluent/alert-snooze-filled.svg | 4 + .../assets/icons/fluent/alert-snooze.svg | 4 + .../config/assets/icons/fluent/alert.svg | 4 + .../assets/icons/fluent/apps-filled.svg | 4 + .../config/assets/icons/fluent/apps.svg | 4 + .../icons/fluent/arrow-clockwise-filled.svg | 1 + .../assets/icons/fluent/arrow-clockwise.svg | 1 + .../fluent/arrow-counterclockwise-filled.svg | 1 + .../icons/fluent/arrow-counterclockwise.svg | 1 + .../assets/icons/fluent/arrow-left-filled.svg | 1 + .../config/assets/icons/fluent/arrow-left.svg | 1 + .../config/assets/icons/fluent/arrow-sync.svg | 39 + .../assets/icons/fluent/auto-filled.svg | 41 + .../config/assets/icons/fluent/auto.svg | 41 + .../config/assets/icons/fluent/battery-0.svg | 12 + .../config/assets/icons/fluent/battery-1.svg | 12 + .../config/assets/icons/fluent/battery-2.svg | 12 + .../config/assets/icons/fluent/battery-3.svg | 12 + .../config/assets/icons/fluent/battery-4.svg | 12 + .../config/assets/icons/fluent/battery-5.svg | 12 + .../config/assets/icons/fluent/battery-6.svg | 12 + .../config/assets/icons/fluent/battery-7.svg | 12 + .../config/assets/icons/fluent/battery-8.svg | 12 + .../config/assets/icons/fluent/battery-9.svg | 12 + .../assets/icons/fluent/battery-charge.svg | 12 + .../assets/icons/fluent/battery-full.svg | 12 + .../assets/icons/fluent/battery-saver.svg | 12 + .../assets/icons/fluent/battery-warning.svg | 12 + .../fluent/bluetooth-connected-filled.svg | 4 + .../icons/fluent/bluetooth-connected.svg | 12 + .../fluent/bluetooth-disabled-filled.svg | 4 + .../icons/fluent/bluetooth-disabled.svg | 4 + .../assets/icons/fluent/bluetooth-filled.svg | 4 + .../icons/fluent/bluetooth-searching.svg | 12 + .../config/assets/icons/fluent/bluetooth.svg | 4 + .../assets/icons/fluent/caret-down-filled.svg | 1 + .../config/assets/icons/fluent/caret-down.svg | 1 + .../assets/icons/fluent/caret-up-filled.svg | 1 + .../config/assets/icons/fluent/caret-up.svg | 1 + .../icons/fluent/chevron-down-filled.svg | 1 + .../assets/icons/fluent/chevron-down.svg | 1 + .../icons/fluent/chevron-left-filled.svg | 1 + .../assets/icons/fluent/chevron-left.svg | 1 + .../icons/fluent/chevron-right-filled.svg | 1 + .../assets/icons/fluent/chevron-right.svg | 1 + .../assets/icons/fluent/chevron-up-filled.svg | 1 + .../config/assets/icons/fluent/chevron-up.svg | 1 + .../assets/icons/fluent/cloudflare-filled.svg | 1 + .../config/assets/icons/fluent/cloudflare.svg | 1 + .../config/assets/icons/fluent/cut-filled.svg | 4 + .../config/assets/icons/fluent/cut.svg | 4 + .../assets/icons/fluent/dark-theme-filled.svg | 4 + .../config/assets/icons/fluent/dark-theme.svg | 4 + .../icons/fluent/desktop-speaker-filled.svg | 1 + .../assets/icons/fluent/desktop-speaker.svg | 1 + .../assets/icons/fluent/device-eq-filled.svg | 1 + .../config/assets/icons/fluent/device-eq.svg | 1 + .../config/assets/icons/fluent/dismiss.svg | 1 + .../icons/fluent/drink-coffee-filled.svg | 4 + .../assets/icons/fluent/drink-coffee.svg | 4 + .../config/assets/icons/fluent/ethernet.svg | 12 + .../assets/icons/fluent/eyedropper-filled.svg | 4 + .../config/assets/icons/fluent/eyedropper.svg | 4 + .../assets/icons/fluent/fire-filled.svg | 4 + .../config/assets/icons/fluent/fire.svg | 4 + .../assets/icons/fluent/flash-off-filled.svg | 4 + .../config/assets/icons/fluent/flash-off.svg | 4 + .../assets/icons/fluent/flash-on-filled.svg | 1 + .../config/assets/icons/fluent/flash-on.svg | 1 + .../assets/icons/fluent/games-filled.svg | 4 + .../config/assets/icons/fluent/games.svg | 4 + .../icons/fluent/globe-shield-filled.svg | 4 + .../assets/icons/fluent/globe-shield.svg | 4 + .../assets/icons/fluent/headphones-filled.svg | 1 + .../config/assets/icons/fluent/headphones.svg | 1 + .../icons/fluent/keyboard-dock-filled.svg | 4 + .../assets/icons/fluent/keyboard-dock.svg | 4 + .../assets/icons/fluent/keyboard-filled.svg | 4 + .../config/assets/icons/fluent/keyboard.svg | 4 + .../assets/icons/fluent/leaf-two-filled.svg | 1 + .../config/assets/icons/fluent/leaf-two.svg | 4 + .../icons/fluent/lock-closed-filled.svg | 1 + .../assets/icons/fluent/lock-closed.svg | 1 + .../assets/icons/fluent/lock-open-filled.svg | 1 + .../config/assets/icons/fluent/lock-open.svg | 1 + .../config/assets/icons/fluent/mic-filled.svg | 4 + .../assets/icons/fluent/mic-off-filled.svg | 4 + .../config/assets/icons/fluent/mic-off.svg | 4 + .../config/assets/icons/fluent/mic-on.svg | 12 + .../config/assets/icons/fluent/mic.svg | 4 + .../icons/fluent/more-horizontal-filled.svg | 4 + .../assets/icons/fluent/more-horizontal.svg | 4 + .../icons/fluent/music-note-2-filled.svg | 4 + .../assets/icons/fluent/music-note-2.svg | 4 + .../assets/icons/fluent/next-filled.svg | 1 + .../config/assets/icons/fluent/next.svg | 1 + .../assets/icons/fluent/options-filled.svg | 4 + .../config/assets/icons/fluent/options.svg | 4 + .../assets/icons/fluent/pause-filled.svg | 1 + .../config/assets/icons/fluent/pause.svg | 1 + .../assets/icons/fluent/phone-filled.svg | 1 + .../config/assets/icons/fluent/phone.svg | 1 + .../config/assets/icons/fluent/pin-off.svg | 1 + .../config/assets/icons/fluent/pin.svg | 1 + .../assets/icons/fluent/play-filled.svg | 1 + .../config/assets/icons/fluent/play.svg | 1 + .../assets/icons/fluent/previous-filled.svg | 1 + .../config/assets/icons/fluent/previous.svg | 1 + .../fluent/settings-cog-multiple-filled.svg | 4 + .../icons/fluent/settings-cog-multiple.svg | 4 + .../config/assets/icons/fluent/settings.svg | 1 + .../config/assets/icons/fluent/speaker-0.svg | 12 + .../config/assets/icons/fluent/speaker-1.svg | 12 + .../assets/icons/fluent/speaker-2-filled.svg | 4 + .../icons/fluent/speaker-mute-filled.svg | 1 + .../assets/icons/fluent/speaker-mute.svg | 1 + .../assets/icons/fluent/speaker-none.svg | 5 + .../assets/icons/fluent/speaker-off.svg | 7 + .../assets/icons/fluent/speaker-settings.svg | 1 + .../config/assets/icons/fluent/speaker.svg | 6 + .../icons/fluent/start-here-pressed.svg | 24 + .../config/assets/icons/fluent/start-here.svg | 24 + .../assets/icons/fluent/stop-filled.svg | 1 + .../config/assets/icons/fluent/stop.svg | 1 + .../assets/icons/fluent/subtract-filled.svg | 1 + .../config/assets/icons/fluent/subtract.svg | 1 + .../fluent/system-search-checked-dark.svg | 105 +++ .../fluent/system-search-checked-light.svg | 81 ++ .../icons/fluent/system-search-dark.svg | 104 +++ .../icons/fluent/system-search-light.svg | 82 ++ .../assets/icons/fluent/task-view-dark.svg | 128 +++ .../assets/icons/fluent/task-view-light.svg | 104 +++ .../icons/fluent/task-view-pressed-dark.svg | 132 +++ .../icons/fluent/task-view-pressed-light.svg | 108 +++ .../icons/fluent/temperature-filled.svg | 1 + .../assets/icons/fluent/temperature.svg | 1 + .../icons/fluent/weather-moon-filled.svg | 4 + .../icons/fluent/weather-moon-off-filled.svg | 4 + .../assets/icons/fluent/weather-moon-off.svg | 4 + .../assets/icons/fluent/weather-moon.svg | 4 + .../icons/fluent/weather-sunny-filled.svg | 1 + .../assets/icons/fluent/weather-sunny.svg | 1 + .../config/assets/icons/fluent/widgets.svg | 92 ++ .../assets/icons/fluent/wifi-1-filled.svg | 1 + .../config/assets/icons/fluent/wifi-1.svg | 1 + .../assets/icons/fluent/wifi-2-filled.svg | 1 + .../config/assets/icons/fluent/wifi-2.svg | 1 + .../assets/icons/fluent/wifi-3-filled.svg | 1 + .../config/assets/icons/fluent/wifi-3.svg | 1 + .../assets/icons/fluent/wifi-4-filled.svg | 1 + .../config/assets/icons/fluent/wifi-4.svg | 1 + .../assets/icons/fluent/wifi-lock-filled.svg | 1 + .../config/assets/icons/fluent/wifi-lock.svg | 1 + .../assets/icons/fluent/wifi-off-filled.svg | 1 + .../config/assets/icons/fluent/wifi-off.svg | 1 + .../icons/fluent/wifi-warning-filled.svg | 1 + .../assets/icons/fluent/wifi-warning.svg | 1 + .../config/assets/icons/github-symbolic.svg | 40 + .../config/assets/icons/linux-symbolic.svg | 113 +++ .../assets/icons/microsoft-symbolic.svg | 54 ++ .../config/assets/icons/nixos-symbolic.svg | 77 ++ modules/quickshell/config/killDialog.qml | 198 ++++ .../config/modules/common/Appearance.qml | 393 ++++++++ .../config/modules/common/Config.qml | 579 ++++++++++++ .../config/modules/common/Directories.qml | 51 + .../config/modules/common/Icons.qml | 82 ++ .../config/modules/common/Images.qml | 31 + .../config/modules/common/Persistent.qml | 154 ++++ .../modules/common/functions/ColorUtils.qml | 138 +++ .../modules/common/functions/DateUtils.qml | 27 + .../modules/common/functions/FileUtils.qml | 71 ++ .../config/modules/common/functions/Fuzzy.qml | 18 + .../modules/common/functions/Levendist.qml | 18 + .../common/functions/NotificationUtils.qml | 87 ++ .../modules/common/functions/ObjectUtils.qml | 98 ++ .../modules/common/functions/Session.qml | 50 + .../modules/common/functions/StringUtils.qml | 298 ++++++ .../modules/common/functions/fuzzysort.js | 682 ++++++++++++++ .../modules/common/functions/levendist.js | 141 +++ .../common/models/AdaptedMaterialScheme.qml | 27 + .../common/models/AnimatedTabIndexPair.qml | 26 + .../models/FolderListModelWithHistory.qml | 53 ++ .../quickToggles/AntiFlashbangToggle.qml | 17 + .../models/quickToggles/AudioToggle.qml | 17 + .../models/quickToggles/BluetoothToggle.qml | 23 + .../quickToggles/CloudflareWarpToggle.qml | 78 ++ .../models/quickToggles/ColorPickerToggle.qml | 29 + .../models/quickToggles/DarkModeToggle.qml | 25 + .../models/quickToggles/EasyEffectsToggle.qml | 30 + .../models/quickToggles/GameModeToggle.qml | 33 + .../quickToggles/IdleInhibitorToggle.qml | 18 + .../common/models/quickToggles/MicToggle.qml | 20 + .../quickToggles/MusicRecognitionToggle.qml | 25 + .../models/quickToggles/NetworkToggle.qml | 16 + .../models/quickToggles/NightLightToggle.qml | 28 + .../quickToggles/NotificationToggle.qml | 20 + .../quickToggles/OnScreenKeyboardToggle.qml | 19 + .../quickToggles/PowerProfilesToggle.qml | 39 + .../models/quickToggles/QuickToggleModel.qml | 22 + .../models/quickToggles/ScreenSnipToggle.qml | 29 + .../common/utils/ImageDownloaderProcess.qml | 31 + .../modules/common/widgets/AddressBar.qml | 120 +++ .../common/widgets/AddressBreadcrumb.qml | 41 + .../modules/common/widgets/ButtonGroup.qml | 49 + .../modules/common/widgets/CalendarView.qml | 121 +++ .../config/modules/common/widgets/Circle.qml | 9 + .../common/widgets/CircularProgress.qml | 88 ++ .../modules/common/widgets/CliphistImage.qml | 131 +++ .../widgets/ClippedFilledCircularProgress.qml | 98 ++ .../common/widgets/ClippedProgressBar.qml | 101 ++ .../modules/common/widgets/ConfigRow.qml | 8 + .../common/widgets/ConfigSelectionArray.qml | 54 ++ .../modules/common/widgets/ConfigSpinBox.qml | 38 + .../modules/common/widgets/ConfigSwitch.qml | 43 + .../modules/common/widgets/ContentPage.qml | 29 + .../modules/common/widgets/ContentSection.qml | 36 + .../common/widgets/ContentSubsection.qml | 46 + .../common/widgets/ContentSubsectionLabel.qml | 10 + .../modules/common/widgets/CustomIcon.qml | 37 + .../modules/common/widgets/DialogButton.qml | 40 + .../modules/common/widgets/DialogListItem.qml | 25 + .../modules/common/widgets/DirectoryIcon.qml | 41 + .../modules/common/widgets/DragManager.qml | 72 ++ .../modules/common/widgets/FadeLoader.qml | 18 + .../config/modules/common/widgets/Favicon.qml | 48 + .../common/widgets/FloatingActionButton.qml | 64 ++ .../common/widgets/FlowButtonGroup.qml | 8 + .../common/widgets/FocusedScrollMouseArea.qml | 56 ++ .../config/modules/common/widgets/Graph.qml | 51 + .../modules/common/widgets/GroupButton.qml | 141 +++ .../widgets/IconAndTextToolbarButton.qml | 33 + .../common/widgets/IconToolbarButton.qml | 23 + .../modules/common/widgets/KeyboardKey.qml | 43 + .../widgets/LightDarkPreferenceButton.qml | 122 +++ .../modules/common/widgets/MaterialCookie.qml | 39 + .../widgets/MaterialLoadingIndicator.qml | 89 ++ .../modules/common/widgets/MaterialShape.qml | 88 ++ .../MaterialShapeWrappedMaterialSymbol.qml | 23 + .../modules/common/widgets/MaterialSymbol.qml | 30 + .../common/widgets/MaterialTextArea.qml | 53 ++ .../common/widgets/MaterialTextField.qml | 40 + .../modules/common/widgets/MenuButton.qml | 26 + .../modules/common/widgets/NavigationRail.qml | 11 + .../common/widgets/NavigationRailButton.qml | 150 +++ .../widgets/NavigationRailExpandButton.qml | 30 + .../common/widgets/NavigationRailTabArray.qml | 42 + .../modules/common/widgets/NoticeBox.qml | 51 + .../widgets/NotificationActionButton.qml | 24 + .../common/widgets/NotificationAppIcon.qml | 104 +++ .../common/widgets/NotificationGroup.qml | 259 ++++++ .../widgets/NotificationGroupExpandButton.qml | 47 + .../common/widgets/NotificationItem.qml | 342 +++++++ .../common/widgets/NotificationListView.qml | 26 + .../common/widgets/OptionalMaterialSymbol.qml | 27 + .../common/widgets/PagePlaceholder.qml | 61 ++ .../widgets/PointingHandInteraction.qml | 7 + .../common/widgets/PointingHandLinkHover.qml | 8 + .../modules/common/widgets/PopupToolTip.qml | 60 ++ .../modules/common/widgets/Revealer.qml | 27 + .../modules/common/widgets/RippleButton.qml | 186 ++++ .../common/widgets/RippleButtonWithIcon.qml | 58 ++ .../modules/common/widgets/RoundCorner.qml | 80 ++ .../modules/common/widgets/ScrollEdgeFade.qml | 59 ++ .../common/widgets/SecondaryTabBar.qml | 53 ++ .../common/widgets/SecondaryTabButton.qml | 180 ++++ .../common/widgets/SelectionDialog.qml | 128 +++ .../common/widgets/SelectionGroupButton.qml | 62 ++ .../modules/common/widgets/SineCookie.qml | 70 ++ .../common/widgets/StyledBlurEffect.qml | 12 + .../modules/common/widgets/StyledComboBox.qml | 208 +++++ .../common/widgets/StyledDropShadow.qml | 13 + .../common/widgets/StyledFlickable.qml | 54 ++ .../modules/common/widgets/StyledImage.qml | 15 + .../StyledIndeterminateProgressBar.qml | 9 + .../modules/common/widgets/StyledListView.qml | 153 +++ .../common/widgets/StyledProgressBar.qml | 97 ++ .../common/widgets/StyledRadioButton.qml | 88 ++ .../widgets/StyledRectangularShadow.qml | 14 + .../common/widgets/StyledScrollBar.qml | 28 + .../modules/common/widgets/StyledSlider.qml | 199 ++++ .../modules/common/widgets/StyledSpinBox.qml | 96 ++ .../modules/common/widgets/StyledSwitch.qml | 70 ++ .../modules/common/widgets/StyledText.qml | 91 ++ .../modules/common/widgets/StyledTextArea.qml | 20 + .../common/widgets/StyledTextInput.qml | 19 + .../modules/common/widgets/StyledToolTip.qml | 33 + .../common/widgets/StyledToolTipContent.qml | 53 ++ .../modules/common/widgets/ThumbnailImage.qml | 56 ++ .../config/modules/common/widgets/Toolbar.qml | 48 + .../modules/common/widgets/ToolbarButton.qml | 8 + .../modules/common/widgets/ToolbarTabBar.qml | 102 ++ .../common/widgets/ToolbarTabButton.qml | 40 + .../common/widgets/ToolbarTextField.qml | 33 + .../common/widgets/VerticalButtonGroup.qml | 45 + .../common/widgets/VibrantToolbarButton.qml | 10 + .../modules/common/widgets/WaveVisualizer.qml | 73 ++ .../modules/common/widgets/WavyLine.qml | 34 + .../config/modules/common/widgets/WeekRow.qml | 43 + .../modules/common/widgets/WindowDialog.qml | 91 ++ .../common/widgets/WindowDialogButtonRow.qml | 15 + .../common/widgets/WindowDialogParagraph.qml | 12 + .../widgets/WindowDialogSectionHeader.qml | 14 + .../common/widgets/WindowDialogSeparator.qml | 16 + .../common/widgets/WindowDialogSlider.qml | 43 + .../common/widgets/WindowDialogTitle.qml | 16 + .../modules/common/widgets/shapes/LICENSE | 201 ++++ .../modules/common/widgets/shapes/README.md | 17 + .../common/widgets/shapes/ShapeCanvas.qml | 71 ++ .../modules/common/widgets/shapes/example.qml | 81 ++ .../common/widgets/shapes/geometry/offset.js | 177 ++++ .../common/widgets/shapes/graphics/matrix.js | 198 ++++ .../common/widgets/shapes/material-shapes.js | 712 ++++++++++++++ .../widgets/shapes/shapes/corner-rounding.js | 18 + .../common/widgets/shapes/shapes/cubic.js | 371 ++++++++ .../widgets/shapes/shapes/feature-mapping.js | 166 ++++ .../common/widgets/shapes/shapes/feature.js | 103 +++ .../widgets/shapes/shapes/float-mapping.js | 86 ++ .../common/widgets/shapes/shapes/morph.js | 94 ++ .../common/widgets/shapes/shapes/point.js | 154 ++++ .../widgets/shapes/shapes/polygon-measure.js | 192 ++++ .../widgets/shapes/shapes/rounded-corner.js | 229 +++++ .../widgets/shapes/shapes/rounded-polygon.js | 343 +++++++ .../common/widgets/shapes/shapes/utils.js | 94 ++ .../widgetCanvas/AbstractOverlayWidget.qml | 13 + .../widgets/widgetCanvas/AbstractWidget.qml | 30 + .../widgets/widgetCanvas/WidgetCanvas.qml | 7 + .../modules/ii/background/Background.qml | 298 ++++++ .../widgets/AbstractBackgroundWidget.qml | 101 ++ .../background/widgets/clock/ClockWidget.qml | 202 ++++ .../background/widgets/clock/CookieClock.qml | 216 +++++ .../background/widgets/clock/CookieQuote.qml | 59 ++ .../ii/background/widgets/clock/HourHand.qml | 45 + .../ii/background/widgets/clock/HourMarks.qml | 50 + .../background/widgets/clock/MinuteHand.qml | 47 + .../background/widgets/clock/SecondHand.qml | 71 ++ .../background/widgets/clock/TimeColumn.qml | 41 + .../clock/dateIndicator/BubbleDate.qml | 37 + .../clock/dateIndicator/DateIndicator.qml | 76 ++ .../clock/dateIndicator/RectangleDate.qml | 21 + .../clock/dateIndicator/RotatingDate.qml | 50 + .../clock/minuteMarks/BigHourNumbers.qml | 49 + .../widgets/clock/minuteMarks/Dots.qml | 34 + .../widgets/clock/minuteMarks/Lines.qml | 66 ++ .../widgets/clock/minuteMarks/MinuteMarks.qml | 54 ++ .../widgets/weather/WeatherWidget.qml | 58 ++ .../config/modules/ii/bar/ActiveWindow.qml | 52 ++ .../quickshell/config/modules/ii/bar/Bar.qml | 249 +++++ .../config/modules/ii/bar/BarContent.qml | 341 +++++++ .../config/modules/ii/bar/BarGroup.qml | 41 + .../modules/ii/bar/BatteryIndicator.qml | 59 ++ .../config/modules/ii/bar/BatteryPopup.qml | 72 ++ .../modules/ii/bar/CircleUtilButton.qml | 15 + .../config/modules/ii/bar/ClockWidget.qml | 49 + .../modules/ii/bar/ClockWidgetPopup.qml | 76 ++ .../modules/ii/bar/HyprlandXkbIndicator.qml | 34 + .../modules/ii/bar/LeftSidebarButton.qml | 55 ++ .../config/modules/ii/bar/Media.qml | 89 ++ .../ii/bar/NotificationUnreadCount.qml | 38 + .../config/modules/ii/bar/Resource.qml | 92 ++ .../config/modules/ii/bar/Resources.qml | 53 ++ .../config/modules/ii/bar/ResourcesPopup.qml | 94 ++ .../config/modules/ii/bar/ScrollHint.qml | 60 ++ .../config/modules/ii/bar/StyledPopup.qml | 81 ++ .../modules/ii/bar/StyledPopupHeaderRow.qml | 30 + .../modules/ii/bar/StyledPopupValueRow.qml | 29 + .../config/modules/ii/bar/SysTray.qml | 152 +++ .../config/modules/ii/bar/SysTrayItem.qml | 99 ++ .../config/modules/ii/bar/SysTrayMenu.qml | 217 +++++ .../modules/ii/bar/SysTrayMenuEntry.qml | 126 +++ .../config/modules/ii/bar/UtilButtons.qml | 158 ++++ .../config/modules/ii/bar/Workspaces.qml | 319 +++++++ .../modules/ii/bar/weather/WeatherBar.qml | 56 ++ .../modules/ii/bar/weather/WeatherCard.qml | 44 + .../modules/ii/bar/weather/WeatherPopup.qml | 104 +++ .../config/modules/ii/dock/Dock.qml | 148 +++ .../config/modules/ii/dock/DockAppButton.qml | 132 +++ .../config/modules/ii/dock/DockApps.qml | 229 +++++ .../config/modules/ii/dock/DockButton.qml | 13 + .../config/modules/ii/dock/DockSeparator.qml | 11 + .../config/modules/ii/lock/Lock.qml | 188 ++++ .../config/modules/ii/lock/LockContext.qml | 135 +++ .../config/modules/ii/lock/LockSurface.qml | 362 ++++++++ .../config/modules/ii/lock/PasswordChars.qml | 95 ++ .../config/modules/ii/lock/pam/fprintd.conf | 1 + .../ii/mediaControls/MediaControls.qml | 228 +++++ .../ii/mediaControls/PlayerControl.qml | 315 +++++++ .../notificationPopup/NotificationPopup.qml | 49 + .../ii/onScreenDisplay/OnScreenDisplay.qml | 224 +++++ .../ii/onScreenDisplay/OsdValueIndicator.qml | 104 +++ .../indicators/BrightnessIndicator.qml | 17 + .../indicators/VolumeIndicator.qml | 10 + .../config/modules/ii/overlay/Overlay.qml | 94 ++ .../modules/ii/overlay/OverlayBackground.qml | 8 + .../modules/ii/overlay/OverlayContent.qml | 66 ++ .../modules/ii/overlay/OverlayContext.qml | 42 + .../modules/ii/overlay/OverlayTaskbar.qml | 150 +++ .../overlay/OverlayWidgetDelegateChooser.qml | 28 + .../ii/overlay/StyledOverlayWidget.qml | 327 +++++++ .../ii/overlay/crosshair/Crosshair.qml | 19 + .../ii/overlay/crosshair/CrosshairContent.qml | 197 ++++ .../overlay/floatingImage/FloatingImage.qml | 95 ++ .../ii/overlay/fpsLimiter/FpsLimiter.qml | 14 + .../overlay/fpsLimiter/FpsLimiterContent.qml | 96 ++ .../config/modules/ii/overlay/notes/Notes.qml | 17 + .../modules/ii/overlay/notes/NotesContent.qml | 292 ++++++ .../modules/ii/overlay/recorder/Recorder.qml | 118 +++ .../ii/overlay/resources/Resources.qml | 131 +++ .../ii/overlay/volumeMixer/VolumeMixer.qml | 80 ++ .../config/modules/ii/overview/Overview.qml | 252 +++++ .../modules/ii/overview/OverviewWidget.qml | 329 +++++++ .../modules/ii/overview/OverviewWindow.qml | 144 +++ .../config/modules/ii/overview/SearchBar.qml | 152 +++ .../config/modules/ii/overview/SearchItem.qml | 286 ++++++ .../modules/ii/overview/SearchWidget.qml | 465 ++++++++++ .../config/modules/ii/polkit/Polkit.qml | 42 + .../modules/ii/polkit/PolkitContent.qml | 113 +++ .../regionSelector/CircleSelectionDetails.qml | 49 + .../ii/regionSelector/OptionsToolbar.qml | 80 ++ .../RectCornersSelectionDetails.qml | 90 ++ .../ii/regionSelector/RegionFunctions.qml | 76 ++ .../ii/regionSelector/RegionSelection.qml | 533 +++++++++++ .../ii/regionSelector/RegionSelector.qml | 122 +++ .../ii/regionSelector/TargetRegion.qml | 79 ++ .../ii/screenCorners/ScreenCorners.qml | 175 ++++ .../ii/sessionScreen/SessionActionButton.qml | 58 ++ .../ii/sessionScreen/SessionScreen.qml | 314 +++++++ .../ii/sidebarRight/BottomWidgetGroup.qml | 279 ++++++ .../ii/sidebarRight/CenterWidgetGroup.qml | 19 + .../modules/ii/sidebarRight/QuickSliders.qml | 111 +++ .../modules/ii/sidebarRight/SidebarRight.qml | 109 +++ .../ii/sidebarRight/SidebarRightContent.qml | 301 ++++++ .../bluetoothDevices/BluetoothDeviceItem.qml | 112 +++ .../bluetoothDevices/BluetoothDialog.qml | 77 ++ .../calendar/CalendarDayButton.qml | 34 + .../calendar/CalendarHeaderButton.qml | 36 + .../sidebarRight/calendar/CalendarWidget.qml | 122 +++ .../sidebarRight/calendar/calendar_layout.js | 113 +++ .../nightLight/NightLightDialog.qml | 158 ++++ .../notifications/NotificationList.qml | 71 ++ .../NotificationStatusButton.qml | 46 + .../sidebarRight/pomodoro/PomodoroTimer.qml | 114 +++ .../sidebarRight/pomodoro/PomodoroWidget.qml | 75 ++ .../ii/sidebarRight/pomodoro/Stopwatch.qml | 207 +++++ .../quickToggles/AbstractQuickPanel.qml | 15 + .../quickToggles/AndroidQuickPanel.qml | 163 ++++ .../quickToggles/ClassicQuickPanel.qml | 39 + .../AndroidAntiFlashbangToggle.qml | 13 + .../androidStyle/AndroidAudioToggle.qml | 13 + .../androidStyle/AndroidBluetoothToggle.qml | 14 + .../AndroidCloudflareWarpToggle.qml | 13 + .../androidStyle/AndroidColorPickerToggle.qml | 13 + .../androidStyle/AndroidDarkModeToggle.qml | 10 + .../androidStyle/AndroidEasyEffectsToggle.qml | 11 + .../androidStyle/AndroidGameModeToggle.qml | 11 + .../AndroidIdleInhibitorToggle.qml | 11 + .../androidStyle/AndroidMicToggle.qml | 11 + .../androidStyle/AndroidMusicRecognition.qml | 13 + .../androidStyle/AndroidNetworkToggle.qml | 13 + .../androidStyle/AndroidNightLightToggle.qml | 11 + .../AndroidNotificationToggle.qml | 11 + .../AndroidOnScreenKeyboardToggle.qml | 11 + .../AndroidPowerProfileToggle.qml | 12 + .../androidStyle/AndroidQuickToggleButton.qml | 247 +++++ .../androidStyle/AndroidScreenSnipToggle.qml | 12 + .../AndroidToggleDelegateChooser.qml | 263 ++++++ .../classicStyle/BluetoothToggle.qml | 30 + .../classicStyle/CloudflareWarp.qml | 92 ++ .../classicStyle/EasyEffectsToggle.qml | 31 + .../quickToggles/classicStyle/GameMode.qml | 31 + .../classicStyle/IdleInhibitor.qml | 15 + .../classicStyle/NetworkToggle.qml | 23 + .../quickToggles/classicStyle/NightLight.qml | 26 + .../classicStyle/QuickToggleButton.qml | 29 + .../modules/ii/sidebarRight/todo/TaskList.qml | 141 +++ .../todo/TodoItemActionButton.qml | 32 + .../ii/sidebarRight/todo/TodoWidget.qml | 219 +++++ .../volumeMixer/AudioDeviceSelectorButton.qml | 55 ++ .../sidebarRight/volumeMixer/VolumeDialog.qml | 48 + .../volumeMixer/VolumeDialogContent.qml | 79 ++ .../volumeMixer/VolumeMixerEntry.qml | 64 ++ .../sidebarRight/wifiNetworks/WifiDialog.qml | 67 ++ .../wifiNetworks/WifiNetworkItem.qml | 118 +++ .../ii/verticalBar/BatteryIndicator.qml | 64 ++ .../modules/ii/verticalBar/Resource.qml | 40 + .../modules/ii/verticalBar/Resources.qml | 45 + .../modules/ii/verticalBar/VerticalBar.qml | 245 +++++ .../ii/verticalBar/VerticalBarContent.qml | 298 ++++++ .../ii/verticalBar/VerticalClockWidget.qml | 42 + .../ii/verticalBar/VerticalDateWidget.qml | 64 ++ .../modules/ii/verticalBar/VerticalMedia.qml | 89 ++ .../config/modules/settings/About.qml | 150 +++ .../modules/settings/AdvancedConfig.qml | 95 ++ .../modules/settings/BackgroundConfig.qml | 505 ++++++++++ .../config/modules/settings/BarConfig.qml | 350 +++++++ .../config/modules/settings/GeneralConfig.qml | 257 ++++++ .../modules/settings/InterfaceConfig.qml | 868 ++++++++++++++++++ .../config/modules/settings/QuickConfig.qml | 334 +++++++ .../modules/settings/ServicesConfig.qml | 235 +++++ .../config/scripts/cava/raw_output_config.txt | 17 + .../config/scripts/hyprland/get_keybinds.py | 222 +++++ .../scripts/images/find-regions-venv.sh | 6 + .../config/scripts/images/find_regions.py | 120 +++ .../scripts/images/least-busy-region-venv.sh | 6 + .../scripts/images/least_busy_region.py | 399 ++++++++ .../config/scripts/keyring/is_unlocked.sh | 11 + .../config/scripts/keyring/try_lookup.sh | 15 + .../config/scripts/keyring/unlock.sh | 23 + .../musicRecognition/recognize-music.sh | 62 ++ .../thumbnails/generate-thumbnails-magick.sh | 128 +++ .../scripts/thumbnails/thumbgen-venv.sh | 7 + .../config/scripts/thumbnails/thumbgen.py | 119 +++ .../config/scripts/videos/record.sh | 78 ++ .../quickshell/config/services/AppSearch.qml | 164 ++++ modules/quickshell/config/services/Audio.qml | 138 +++ .../quickshell/config/services/Battery.qml | 108 +++ .../config/services/BluetoothStatus.qml | 37 + .../quickshell/config/services/Brightness.qml | 259 ++++++ .../quickshell/config/services/Cliphist.qml | 157 ++++ .../config/services/ConflictKiller.qml | 48 + .../quickshell/config/services/DateTime.qml | 60 ++ .../config/services/EasyEffects.qml | 61 ++ modules/quickshell/config/services/Emojis.qml | 64 ++ .../config/services/FirstRunExperience.qml | 43 + .../config/services/HyprlandData.qml | 138 +++ .../config/services/HyprlandKeybinds.qml | 72 ++ .../config/services/HyprlandXkb.qml | 119 +++ .../quickshell/config/services/Hyprsunset.qml | 139 +++ modules/quickshell/config/services/Idle.qml | 51 + .../config/services/KeyringStorage.qml | 123 +++ .../config/services/LatexRenderer.qml | 83 ++ .../config/services/MaterialThemeLoader.qml | 74 ++ .../config/services/MprisController.qml | 189 ++++ .../quickshell/config/services/Network.qml | 331 +++++++ .../config/services/Notifications.qml | 305 ++++++ .../config/services/PolkitService.qml | 37 + .../config/services/ResourceUsage.qml | 114 +++ .../quickshell/config/services/SongRec.qml | 103 +++ .../quickshell/config/services/SystemInfo.qml | 117 +++ .../config/services/TaskbarApps.qml | 68 ++ .../config/services/TimerService.qml | 142 +++ modules/quickshell/config/services/Todo.qml | 87 ++ .../config/services/Translation.qml | 17 + .../config/services/TrayService.qml | 45 + .../quickshell/config/services/Updates.qml | 57 ++ .../quickshell/config/services/Wallpapers.qml | 192 ++++ .../quickshell/config/services/Weather.qml | 165 ++++ .../quickshell/config/services/Ydotool.qml | 47 + .../services/network/WifiAccessPoint.qml | 14 + modules/quickshell/config/settings.qml | 302 ++++++ modules/quickshell/config/shell.qml | 101 ++ modules/quickshell/generate-colors.py | 306 ++++++ modules/quickshell/shell-config.nix | 337 +++++++ 579 files changed, 42478 insertions(+), 167 deletions(-) create mode 100644 modules/home-manager/neovim.nix create mode 100644 modules/home-manager/rust.nix create mode 100644 modules/quickshell/config/.qmlformat.ini create mode 100644 modules/quickshell/config/GlobalStates.qml create mode 100644 modules/quickshell/config/ReloadPopup.qml create mode 100644 modules/quickshell/config/assets/icons/cloudflare-dns-symbolic.svg create mode 100644 modules/quickshell/config/assets/icons/crosshair-symbolic.svg create mode 100644 modules/quickshell/config/assets/icons/desktop-symbolic.svg create mode 100644 modules/quickshell/config/assets/icons/flatpak-symbolic.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/README.md create mode 100644 modules/quickshell/config/assets/icons/fluent/add-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/add.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/alert-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/alert-off-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/alert-off.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/alert-snooze-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/alert-snooze.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/alert.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/apps-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/apps.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/arrow-clockwise-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/arrow-clockwise.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/arrow-counterclockwise-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/arrow-counterclockwise.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/arrow-left-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/arrow-left.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/arrow-sync.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/auto-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/auto.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/battery-0.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/battery-1.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/battery-2.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/battery-3.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/battery-4.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/battery-5.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/battery-6.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/battery-7.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/battery-8.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/battery-9.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/battery-charge.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/battery-full.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/battery-saver.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/battery-warning.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/bluetooth-connected-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/bluetooth-connected.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/bluetooth-disabled-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/bluetooth-disabled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/bluetooth-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/bluetooth-searching.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/bluetooth.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/caret-down-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/caret-down.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/caret-up-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/caret-up.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/chevron-down-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/chevron-down.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/chevron-left-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/chevron-left.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/chevron-right-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/chevron-right.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/chevron-up-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/chevron-up.svg create mode 120000 modules/quickshell/config/assets/icons/fluent/cloudflare-filled.svg create mode 120000 modules/quickshell/config/assets/icons/fluent/cloudflare.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/cut-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/cut.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/dark-theme-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/dark-theme.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/desktop-speaker-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/desktop-speaker.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/device-eq-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/device-eq.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/dismiss.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/drink-coffee-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/drink-coffee.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/ethernet.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/eyedropper-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/eyedropper.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/fire-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/fire.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/flash-off-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/flash-off.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/flash-on-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/flash-on.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/games-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/games.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/globe-shield-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/globe-shield.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/headphones-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/headphones.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/keyboard-dock-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/keyboard-dock.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/keyboard-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/keyboard.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/leaf-two-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/leaf-two.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/lock-closed-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/lock-closed.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/lock-open-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/lock-open.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/mic-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/mic-off-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/mic-off.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/mic-on.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/mic.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/more-horizontal-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/more-horizontal.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/music-note-2-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/music-note-2.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/next-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/next.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/options-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/options.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/pause-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/pause.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/phone-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/phone.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/pin-off.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/pin.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/play-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/play.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/previous-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/previous.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/settings-cog-multiple-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/settings-cog-multiple.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/settings.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/speaker-0.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/speaker-1.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/speaker-2-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/speaker-mute-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/speaker-mute.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/speaker-none.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/speaker-off.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/speaker-settings.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/speaker.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/start-here-pressed.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/start-here.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/stop-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/stop.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/subtract-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/subtract.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/system-search-checked-dark.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/system-search-checked-light.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/system-search-dark.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/system-search-light.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/task-view-dark.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/task-view-light.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/task-view-pressed-dark.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/task-view-pressed-light.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/temperature-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/temperature.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/weather-moon-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/weather-moon-off-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/weather-moon-off.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/weather-moon.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/weather-sunny-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/weather-sunny.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/widgets.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/wifi-1-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/wifi-1.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/wifi-2-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/wifi-2.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/wifi-3-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/wifi-3.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/wifi-4-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/wifi-4.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/wifi-lock-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/wifi-lock.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/wifi-off-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/wifi-off.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/wifi-warning-filled.svg create mode 100644 modules/quickshell/config/assets/icons/fluent/wifi-warning.svg create mode 100644 modules/quickshell/config/assets/icons/github-symbolic.svg create mode 100644 modules/quickshell/config/assets/icons/linux-symbolic.svg create mode 100644 modules/quickshell/config/assets/icons/microsoft-symbolic.svg create mode 100644 modules/quickshell/config/assets/icons/nixos-symbolic.svg create mode 100644 modules/quickshell/config/killDialog.qml create mode 100644 modules/quickshell/config/modules/common/Appearance.qml create mode 100644 modules/quickshell/config/modules/common/Config.qml create mode 100644 modules/quickshell/config/modules/common/Directories.qml create mode 100644 modules/quickshell/config/modules/common/Icons.qml create mode 100644 modules/quickshell/config/modules/common/Images.qml create mode 100644 modules/quickshell/config/modules/common/Persistent.qml create mode 100644 modules/quickshell/config/modules/common/functions/ColorUtils.qml create mode 100644 modules/quickshell/config/modules/common/functions/DateUtils.qml create mode 100644 modules/quickshell/config/modules/common/functions/FileUtils.qml create mode 100644 modules/quickshell/config/modules/common/functions/Fuzzy.qml create mode 100644 modules/quickshell/config/modules/common/functions/Levendist.qml create mode 100644 modules/quickshell/config/modules/common/functions/NotificationUtils.qml create mode 100644 modules/quickshell/config/modules/common/functions/ObjectUtils.qml create mode 100644 modules/quickshell/config/modules/common/functions/Session.qml create mode 100644 modules/quickshell/config/modules/common/functions/StringUtils.qml create mode 100644 modules/quickshell/config/modules/common/functions/fuzzysort.js create mode 100644 modules/quickshell/config/modules/common/functions/levendist.js create mode 100644 modules/quickshell/config/modules/common/models/AdaptedMaterialScheme.qml create mode 100644 modules/quickshell/config/modules/common/models/AnimatedTabIndexPair.qml create mode 100644 modules/quickshell/config/modules/common/models/FolderListModelWithHistory.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/AntiFlashbangToggle.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/AudioToggle.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/BluetoothToggle.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/CloudflareWarpToggle.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/ColorPickerToggle.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/DarkModeToggle.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/EasyEffectsToggle.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/GameModeToggle.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/IdleInhibitorToggle.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/MicToggle.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/MusicRecognitionToggle.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/NetworkToggle.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/NightLightToggle.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/NotificationToggle.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/OnScreenKeyboardToggle.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/PowerProfilesToggle.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/QuickToggleModel.qml create mode 100644 modules/quickshell/config/modules/common/models/quickToggles/ScreenSnipToggle.qml create mode 100644 modules/quickshell/config/modules/common/utils/ImageDownloaderProcess.qml create mode 100644 modules/quickshell/config/modules/common/widgets/AddressBar.qml create mode 100644 modules/quickshell/config/modules/common/widgets/AddressBreadcrumb.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ButtonGroup.qml create mode 100644 modules/quickshell/config/modules/common/widgets/CalendarView.qml create mode 100644 modules/quickshell/config/modules/common/widgets/Circle.qml create mode 100644 modules/quickshell/config/modules/common/widgets/CircularProgress.qml create mode 100644 modules/quickshell/config/modules/common/widgets/CliphistImage.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ClippedFilledCircularProgress.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ClippedProgressBar.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ConfigRow.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ConfigSelectionArray.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ConfigSpinBox.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ConfigSwitch.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ContentPage.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ContentSection.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ContentSubsection.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ContentSubsectionLabel.qml create mode 100644 modules/quickshell/config/modules/common/widgets/CustomIcon.qml create mode 100644 modules/quickshell/config/modules/common/widgets/DialogButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/DialogListItem.qml create mode 100644 modules/quickshell/config/modules/common/widgets/DirectoryIcon.qml create mode 100644 modules/quickshell/config/modules/common/widgets/DragManager.qml create mode 100644 modules/quickshell/config/modules/common/widgets/FadeLoader.qml create mode 100644 modules/quickshell/config/modules/common/widgets/Favicon.qml create mode 100644 modules/quickshell/config/modules/common/widgets/FloatingActionButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/FlowButtonGroup.qml create mode 100644 modules/quickshell/config/modules/common/widgets/FocusedScrollMouseArea.qml create mode 100644 modules/quickshell/config/modules/common/widgets/Graph.qml create mode 100644 modules/quickshell/config/modules/common/widgets/GroupButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/IconAndTextToolbarButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/IconToolbarButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/KeyboardKey.qml create mode 100644 modules/quickshell/config/modules/common/widgets/LightDarkPreferenceButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/MaterialCookie.qml create mode 100644 modules/quickshell/config/modules/common/widgets/MaterialLoadingIndicator.qml create mode 100644 modules/quickshell/config/modules/common/widgets/MaterialShape.qml create mode 100644 modules/quickshell/config/modules/common/widgets/MaterialShapeWrappedMaterialSymbol.qml create mode 100644 modules/quickshell/config/modules/common/widgets/MaterialSymbol.qml create mode 100644 modules/quickshell/config/modules/common/widgets/MaterialTextArea.qml create mode 100644 modules/quickshell/config/modules/common/widgets/MaterialTextField.qml create mode 100644 modules/quickshell/config/modules/common/widgets/MenuButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/NavigationRail.qml create mode 100644 modules/quickshell/config/modules/common/widgets/NavigationRailButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/NavigationRailExpandButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/NavigationRailTabArray.qml create mode 100644 modules/quickshell/config/modules/common/widgets/NoticeBox.qml create mode 100644 modules/quickshell/config/modules/common/widgets/NotificationActionButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/NotificationAppIcon.qml create mode 100644 modules/quickshell/config/modules/common/widgets/NotificationGroup.qml create mode 100644 modules/quickshell/config/modules/common/widgets/NotificationGroupExpandButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/NotificationItem.qml create mode 100644 modules/quickshell/config/modules/common/widgets/NotificationListView.qml create mode 100644 modules/quickshell/config/modules/common/widgets/OptionalMaterialSymbol.qml create mode 100644 modules/quickshell/config/modules/common/widgets/PagePlaceholder.qml create mode 100644 modules/quickshell/config/modules/common/widgets/PointingHandInteraction.qml create mode 100644 modules/quickshell/config/modules/common/widgets/PointingHandLinkHover.qml create mode 100644 modules/quickshell/config/modules/common/widgets/PopupToolTip.qml create mode 100644 modules/quickshell/config/modules/common/widgets/Revealer.qml create mode 100644 modules/quickshell/config/modules/common/widgets/RippleButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/RippleButtonWithIcon.qml create mode 100644 modules/quickshell/config/modules/common/widgets/RoundCorner.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ScrollEdgeFade.qml create mode 100644 modules/quickshell/config/modules/common/widgets/SecondaryTabBar.qml create mode 100644 modules/quickshell/config/modules/common/widgets/SecondaryTabButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/SelectionDialog.qml create mode 100644 modules/quickshell/config/modules/common/widgets/SelectionGroupButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/SineCookie.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledBlurEffect.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledComboBox.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledDropShadow.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledFlickable.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledImage.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledIndeterminateProgressBar.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledListView.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledProgressBar.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledRadioButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledRectangularShadow.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledScrollBar.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledSlider.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledSpinBox.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledSwitch.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledText.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledTextArea.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledTextInput.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledToolTip.qml create mode 100644 modules/quickshell/config/modules/common/widgets/StyledToolTipContent.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ThumbnailImage.qml create mode 100644 modules/quickshell/config/modules/common/widgets/Toolbar.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ToolbarButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ToolbarTabBar.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ToolbarTabButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/ToolbarTextField.qml create mode 100644 modules/quickshell/config/modules/common/widgets/VerticalButtonGroup.qml create mode 100644 modules/quickshell/config/modules/common/widgets/VibrantToolbarButton.qml create mode 100644 modules/quickshell/config/modules/common/widgets/WaveVisualizer.qml create mode 100644 modules/quickshell/config/modules/common/widgets/WavyLine.qml create mode 100644 modules/quickshell/config/modules/common/widgets/WeekRow.qml create mode 100644 modules/quickshell/config/modules/common/widgets/WindowDialog.qml create mode 100644 modules/quickshell/config/modules/common/widgets/WindowDialogButtonRow.qml create mode 100644 modules/quickshell/config/modules/common/widgets/WindowDialogParagraph.qml create mode 100644 modules/quickshell/config/modules/common/widgets/WindowDialogSectionHeader.qml create mode 100644 modules/quickshell/config/modules/common/widgets/WindowDialogSeparator.qml create mode 100644 modules/quickshell/config/modules/common/widgets/WindowDialogSlider.qml create mode 100644 modules/quickshell/config/modules/common/widgets/WindowDialogTitle.qml create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/LICENSE create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/README.md create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/ShapeCanvas.qml create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/example.qml create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/geometry/offset.js create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/graphics/matrix.js create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/material-shapes.js create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/shapes/corner-rounding.js create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/shapes/cubic.js create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/shapes/feature-mapping.js create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/shapes/feature.js create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/shapes/float-mapping.js create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/shapes/morph.js create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/shapes/point.js create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/shapes/polygon-measure.js create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/shapes/rounded-corner.js create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/shapes/rounded-polygon.js create mode 100644 modules/quickshell/config/modules/common/widgets/shapes/shapes/utils.js create mode 100644 modules/quickshell/config/modules/common/widgets/widgetCanvas/AbstractOverlayWidget.qml create mode 100644 modules/quickshell/config/modules/common/widgets/widgetCanvas/AbstractWidget.qml create mode 100644 modules/quickshell/config/modules/common/widgets/widgetCanvas/WidgetCanvas.qml create mode 100644 modules/quickshell/config/modules/ii/background/Background.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/AbstractBackgroundWidget.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/clock/ClockWidget.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/clock/CookieClock.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/clock/CookieQuote.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/clock/HourHand.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/clock/HourMarks.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/clock/MinuteHand.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/clock/SecondHand.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/clock/TimeColumn.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/clock/dateIndicator/BubbleDate.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/clock/dateIndicator/DateIndicator.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/clock/dateIndicator/RectangleDate.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/clock/dateIndicator/RotatingDate.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/clock/minuteMarks/BigHourNumbers.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/clock/minuteMarks/Dots.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/clock/minuteMarks/Lines.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/clock/minuteMarks/MinuteMarks.qml create mode 100644 modules/quickshell/config/modules/ii/background/widgets/weather/WeatherWidget.qml create mode 100644 modules/quickshell/config/modules/ii/bar/ActiveWindow.qml create mode 100644 modules/quickshell/config/modules/ii/bar/Bar.qml create mode 100644 modules/quickshell/config/modules/ii/bar/BarContent.qml create mode 100644 modules/quickshell/config/modules/ii/bar/BarGroup.qml create mode 100644 modules/quickshell/config/modules/ii/bar/BatteryIndicator.qml create mode 100644 modules/quickshell/config/modules/ii/bar/BatteryPopup.qml create mode 100644 modules/quickshell/config/modules/ii/bar/CircleUtilButton.qml create mode 100644 modules/quickshell/config/modules/ii/bar/ClockWidget.qml create mode 100644 modules/quickshell/config/modules/ii/bar/ClockWidgetPopup.qml create mode 100644 modules/quickshell/config/modules/ii/bar/HyprlandXkbIndicator.qml create mode 100644 modules/quickshell/config/modules/ii/bar/LeftSidebarButton.qml create mode 100644 modules/quickshell/config/modules/ii/bar/Media.qml create mode 100644 modules/quickshell/config/modules/ii/bar/NotificationUnreadCount.qml create mode 100644 modules/quickshell/config/modules/ii/bar/Resource.qml create mode 100644 modules/quickshell/config/modules/ii/bar/Resources.qml create mode 100644 modules/quickshell/config/modules/ii/bar/ResourcesPopup.qml create mode 100644 modules/quickshell/config/modules/ii/bar/ScrollHint.qml create mode 100644 modules/quickshell/config/modules/ii/bar/StyledPopup.qml create mode 100644 modules/quickshell/config/modules/ii/bar/StyledPopupHeaderRow.qml create mode 100644 modules/quickshell/config/modules/ii/bar/StyledPopupValueRow.qml create mode 100644 modules/quickshell/config/modules/ii/bar/SysTray.qml create mode 100644 modules/quickshell/config/modules/ii/bar/SysTrayItem.qml create mode 100644 modules/quickshell/config/modules/ii/bar/SysTrayMenu.qml create mode 100644 modules/quickshell/config/modules/ii/bar/SysTrayMenuEntry.qml create mode 100644 modules/quickshell/config/modules/ii/bar/UtilButtons.qml create mode 100644 modules/quickshell/config/modules/ii/bar/Workspaces.qml create mode 100644 modules/quickshell/config/modules/ii/bar/weather/WeatherBar.qml create mode 100644 modules/quickshell/config/modules/ii/bar/weather/WeatherCard.qml create mode 100644 modules/quickshell/config/modules/ii/bar/weather/WeatherPopup.qml create mode 100644 modules/quickshell/config/modules/ii/dock/Dock.qml create mode 100644 modules/quickshell/config/modules/ii/dock/DockAppButton.qml create mode 100644 modules/quickshell/config/modules/ii/dock/DockApps.qml create mode 100644 modules/quickshell/config/modules/ii/dock/DockButton.qml create mode 100644 modules/quickshell/config/modules/ii/dock/DockSeparator.qml create mode 100644 modules/quickshell/config/modules/ii/lock/Lock.qml create mode 100644 modules/quickshell/config/modules/ii/lock/LockContext.qml create mode 100644 modules/quickshell/config/modules/ii/lock/LockSurface.qml create mode 100644 modules/quickshell/config/modules/ii/lock/PasswordChars.qml create mode 100644 modules/quickshell/config/modules/ii/lock/pam/fprintd.conf create mode 100644 modules/quickshell/config/modules/ii/mediaControls/MediaControls.qml create mode 100644 modules/quickshell/config/modules/ii/mediaControls/PlayerControl.qml create mode 100644 modules/quickshell/config/modules/ii/notificationPopup/NotificationPopup.qml create mode 100644 modules/quickshell/config/modules/ii/onScreenDisplay/OnScreenDisplay.qml create mode 100644 modules/quickshell/config/modules/ii/onScreenDisplay/OsdValueIndicator.qml create mode 100644 modules/quickshell/config/modules/ii/onScreenDisplay/indicators/BrightnessIndicator.qml create mode 100644 modules/quickshell/config/modules/ii/onScreenDisplay/indicators/VolumeIndicator.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/Overlay.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/OverlayBackground.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/OverlayContent.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/OverlayContext.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/OverlayTaskbar.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/OverlayWidgetDelegateChooser.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/StyledOverlayWidget.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/crosshair/Crosshair.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/crosshair/CrosshairContent.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/floatingImage/FloatingImage.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/fpsLimiter/FpsLimiter.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/fpsLimiter/FpsLimiterContent.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/notes/Notes.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/notes/NotesContent.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/recorder/Recorder.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/resources/Resources.qml create mode 100644 modules/quickshell/config/modules/ii/overlay/volumeMixer/VolumeMixer.qml create mode 100644 modules/quickshell/config/modules/ii/overview/Overview.qml create mode 100644 modules/quickshell/config/modules/ii/overview/OverviewWidget.qml create mode 100644 modules/quickshell/config/modules/ii/overview/OverviewWindow.qml create mode 100644 modules/quickshell/config/modules/ii/overview/SearchBar.qml create mode 100644 modules/quickshell/config/modules/ii/overview/SearchItem.qml create mode 100644 modules/quickshell/config/modules/ii/overview/SearchWidget.qml create mode 100644 modules/quickshell/config/modules/ii/polkit/Polkit.qml create mode 100644 modules/quickshell/config/modules/ii/polkit/PolkitContent.qml create mode 100644 modules/quickshell/config/modules/ii/regionSelector/CircleSelectionDetails.qml create mode 100644 modules/quickshell/config/modules/ii/regionSelector/OptionsToolbar.qml create mode 100644 modules/quickshell/config/modules/ii/regionSelector/RectCornersSelectionDetails.qml create mode 100644 modules/quickshell/config/modules/ii/regionSelector/RegionFunctions.qml create mode 100644 modules/quickshell/config/modules/ii/regionSelector/RegionSelection.qml create mode 100644 modules/quickshell/config/modules/ii/regionSelector/RegionSelector.qml create mode 100644 modules/quickshell/config/modules/ii/regionSelector/TargetRegion.qml create mode 100644 modules/quickshell/config/modules/ii/screenCorners/ScreenCorners.qml create mode 100644 modules/quickshell/config/modules/ii/sessionScreen/SessionActionButton.qml create mode 100644 modules/quickshell/config/modules/ii/sessionScreen/SessionScreen.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/BottomWidgetGroup.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/CenterWidgetGroup.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/QuickSliders.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/SidebarRight.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/SidebarRightContent.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/bluetoothDevices/BluetoothDeviceItem.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/bluetoothDevices/BluetoothDialog.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/calendar/CalendarDayButton.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/calendar/CalendarHeaderButton.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/calendar/CalendarWidget.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/calendar/calendar_layout.js create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/nightLight/NightLightDialog.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/notifications/NotificationList.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/notifications/NotificationStatusButton.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/pomodoro/PomodoroTimer.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/pomodoro/PomodoroWidget.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/pomodoro/Stopwatch.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/AbstractQuickPanel.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/AndroidQuickPanel.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/ClassicQuickPanel.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidAntiFlashbangToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidAudioToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidBluetoothToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidCloudflareWarpToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidColorPickerToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidDarkModeToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidEasyEffectsToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidGameModeToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidIdleInhibitorToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidMicToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidMusicRecognition.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidNetworkToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidNightLightToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidNotificationToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidOnScreenKeyboardToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidPowerProfileToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidQuickToggleButton.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidScreenSnipToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidToggleDelegateChooser.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/BluetoothToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/CloudflareWarp.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/EasyEffectsToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/GameMode.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/IdleInhibitor.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/NetworkToggle.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/NightLight.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/QuickToggleButton.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/todo/TaskList.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/todo/TodoItemActionButton.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/todo/TodoWidget.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/volumeMixer/VolumeDialog.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/volumeMixer/VolumeMixerEntry.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/wifiNetworks/WifiDialog.qml create mode 100644 modules/quickshell/config/modules/ii/sidebarRight/wifiNetworks/WifiNetworkItem.qml create mode 100644 modules/quickshell/config/modules/ii/verticalBar/BatteryIndicator.qml create mode 100644 modules/quickshell/config/modules/ii/verticalBar/Resource.qml create mode 100644 modules/quickshell/config/modules/ii/verticalBar/Resources.qml create mode 100644 modules/quickshell/config/modules/ii/verticalBar/VerticalBar.qml create mode 100644 modules/quickshell/config/modules/ii/verticalBar/VerticalBarContent.qml create mode 100644 modules/quickshell/config/modules/ii/verticalBar/VerticalClockWidget.qml create mode 100644 modules/quickshell/config/modules/ii/verticalBar/VerticalDateWidget.qml create mode 100644 modules/quickshell/config/modules/ii/verticalBar/VerticalMedia.qml create mode 100644 modules/quickshell/config/modules/settings/About.qml create mode 100644 modules/quickshell/config/modules/settings/AdvancedConfig.qml create mode 100644 modules/quickshell/config/modules/settings/BackgroundConfig.qml create mode 100644 modules/quickshell/config/modules/settings/BarConfig.qml create mode 100644 modules/quickshell/config/modules/settings/GeneralConfig.qml create mode 100644 modules/quickshell/config/modules/settings/InterfaceConfig.qml create mode 100644 modules/quickshell/config/modules/settings/QuickConfig.qml create mode 100644 modules/quickshell/config/modules/settings/ServicesConfig.qml create mode 100644 modules/quickshell/config/scripts/cava/raw_output_config.txt create mode 100755 modules/quickshell/config/scripts/hyprland/get_keybinds.py create mode 100755 modules/quickshell/config/scripts/images/find-regions-venv.sh create mode 100755 modules/quickshell/config/scripts/images/find_regions.py create mode 100755 modules/quickshell/config/scripts/images/least-busy-region-venv.sh create mode 100755 modules/quickshell/config/scripts/images/least_busy_region.py create mode 100755 modules/quickshell/config/scripts/keyring/is_unlocked.sh create mode 100755 modules/quickshell/config/scripts/keyring/try_lookup.sh create mode 100755 modules/quickshell/config/scripts/keyring/unlock.sh create mode 100755 modules/quickshell/config/scripts/musicRecognition/recognize-music.sh create mode 100755 modules/quickshell/config/scripts/thumbnails/generate-thumbnails-magick.sh create mode 100755 modules/quickshell/config/scripts/thumbnails/thumbgen-venv.sh create mode 100755 modules/quickshell/config/scripts/thumbnails/thumbgen.py create mode 100755 modules/quickshell/config/scripts/videos/record.sh create mode 100644 modules/quickshell/config/services/AppSearch.qml create mode 100644 modules/quickshell/config/services/Audio.qml create mode 100644 modules/quickshell/config/services/Battery.qml create mode 100644 modules/quickshell/config/services/BluetoothStatus.qml create mode 100644 modules/quickshell/config/services/Brightness.qml create mode 100644 modules/quickshell/config/services/Cliphist.qml create mode 100644 modules/quickshell/config/services/ConflictKiller.qml create mode 100644 modules/quickshell/config/services/DateTime.qml create mode 100644 modules/quickshell/config/services/EasyEffects.qml create mode 100644 modules/quickshell/config/services/Emojis.qml create mode 100644 modules/quickshell/config/services/FirstRunExperience.qml create mode 100644 modules/quickshell/config/services/HyprlandData.qml create mode 100644 modules/quickshell/config/services/HyprlandKeybinds.qml create mode 100644 modules/quickshell/config/services/HyprlandXkb.qml create mode 100644 modules/quickshell/config/services/Hyprsunset.qml create mode 100644 modules/quickshell/config/services/Idle.qml create mode 100644 modules/quickshell/config/services/KeyringStorage.qml create mode 100644 modules/quickshell/config/services/LatexRenderer.qml create mode 100644 modules/quickshell/config/services/MaterialThemeLoader.qml create mode 100644 modules/quickshell/config/services/MprisController.qml create mode 100644 modules/quickshell/config/services/Network.qml create mode 100644 modules/quickshell/config/services/Notifications.qml create mode 100644 modules/quickshell/config/services/PolkitService.qml create mode 100644 modules/quickshell/config/services/ResourceUsage.qml create mode 100644 modules/quickshell/config/services/SongRec.qml create mode 100644 modules/quickshell/config/services/SystemInfo.qml create mode 100644 modules/quickshell/config/services/TaskbarApps.qml create mode 100644 modules/quickshell/config/services/TimerService.qml create mode 100644 modules/quickshell/config/services/Todo.qml create mode 100644 modules/quickshell/config/services/Translation.qml create mode 100644 modules/quickshell/config/services/TrayService.qml create mode 100644 modules/quickshell/config/services/Updates.qml create mode 100644 modules/quickshell/config/services/Wallpapers.qml create mode 100644 modules/quickshell/config/services/Weather.qml create mode 100644 modules/quickshell/config/services/Ydotool.qml create mode 100644 modules/quickshell/config/services/network/WifiAccessPoint.qml create mode 100644 modules/quickshell/config/settings.qml create mode 100644 modules/quickshell/config/shell.qml create mode 100644 modules/quickshell/generate-colors.py create mode 100644 modules/quickshell/shell-config.nix diff --git a/flake.lock b/flake.lock index 2688a29..87ade11 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ ] }, "locked": { - "lastModified": 1752743471, - "narHash": "sha256-4izhj1j7J4mE8LgljCXSIUDculqOsxxhdoC81VhqizM=", + "lastModified": 1763922789, + "narHash": "sha256-XnkWjCpeXfip9tqYdL0b0zzBDjq+dgdISvEdSVGdVyA=", "owner": "hyprwm", "repo": "aquamarine", - "rev": "e31b575d19e7cf8a8f4398e2f9cffe27a1332506", + "rev": "a20a0e67a33b6848378a91b871b89588d3a12573", "type": "github" }, "original": { @@ -38,11 +38,11 @@ "fromYaml": "fromYaml" }, "locked": { - "lastModified": 1746562888, - "narHash": "sha256-YgNJQyB5dQiwavdDFBMNKk1wyS77AtdgDk/VtU6wEaI=", + "lastModified": 1755819240, + "narHash": "sha256-qcMhnL7aGAuFuutH4rq9fvAhCpJWVHLcHVZLtPctPlo=", "owner": "SenchoPens", "repo": "base16.nix", - "rev": "806a1777a5db2a1ef9d5d6f493ef2381047f2b89", + "rev": "75ed5e5e3fce37df22e49125181fa37899c3ccd6", "type": "github" }, "original": { @@ -54,27 +54,28 @@ "base16-fish": { "flake": false, "locked": { - "lastModified": 1622559957, - "narHash": "sha256-PebymhVYbL8trDVVXxCvZgc0S5VxI7I1Hv4RMSquTpA=", + "lastModified": 1754405784, + "narHash": "sha256-l9xHIy+85FN+bEo6yquq2IjD1rSg9fjfjpyGP1W8YXo=", "owner": "tomyun", "repo": "base16-fish", - "rev": "2f6dd973a9075dabccd26f1cded09508180bf5fe", + "rev": "23ae20a0093dca0d7b39d76ba2401af0ccf9c561", "type": "github" }, "original": { "owner": "tomyun", "repo": "base16-fish", + "rev": "23ae20a0093dca0d7b39d76ba2401af0ccf9c561", "type": "github" } }, "base16-helix": { "flake": false, "locked": { - "lastModified": 1748408240, - "narHash": "sha256-9M2b1rMyMzJK0eusea0x3lyh3mu5nMeEDSc4RZkGm+g=", + "lastModified": 1752979451, + "narHash": "sha256-0CQM+FkYy0fOO/sMGhOoNL80ftsAzYCg9VhIrodqusM=", "owner": "tinted-theming", "repo": "base16-helix", - "rev": "6c711ab1a9db6f51e2f6887cc3345530b33e152e", + "rev": "27cf1e66e50abc622fb76a3019012dc07c678fac", "type": "github" }, "original": { @@ -108,11 +109,11 @@ }, "locked": { "dir": "pkgs/firefox-addons", - "lastModified": 1752984247, - "narHash": "sha256-JIsQY8kDY/uicS4UqYmVM2UP7qa8YeQI7XMkX6EBYJM=", + "lastModified": 1764302674, + "narHash": "sha256-8/zVGvLL4+cb+5A3XWiZep8JdjbQhh50gyghAs61hP8=", "owner": "rycee", "repo": "nur-expressions", - "rev": "8633d2c796c8cb8ec3a206728d9a9278af51947f", + "rev": "de69b47670b359630d2be0ad4dbcab05554d8fb7", "type": "gitlab" }, "original": { @@ -125,11 +126,11 @@ "firefox-gnome-theme": { "flake": false, "locked": { - "lastModified": 1748383148, - "narHash": "sha256-pGvD/RGuuPf/4oogsfeRaeMm6ipUIznI2QSILKjKzeA=", + "lastModified": 1758112371, + "narHash": "sha256-lizRM2pj6PHrR25yimjyFn04OS4wcdbc38DCdBVa2rk=", "owner": "rafaelmardojai", "repo": "firefox-gnome-theme", - "rev": "4eb2714fbed2b80e234312611a947d6cb7d70caf", + "rev": "0909cfe4a2af8d358ad13b20246a350e14c2473d", "type": "github" }, "original": { @@ -141,11 +142,11 @@ "flake-compat": { "flake": false, "locked": { - "lastModified": 1696426674, - "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "lastModified": 1761588595, + "narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=", "owner": "edolstra", "repo": "flake-compat", - "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5", "type": "github" }, "original": { @@ -154,7 +155,39 @@ "type": "github" } }, + "flake-compat_2": { + "locked": { + "lastModified": 1733328505, + "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "revCount": 69, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz?rev=ff81ac966bb2cae68946d5ed5fc4994f96d0ffec&revCount=69" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz" + } + }, "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1754091436, + "narHash": "sha256-XKqDMN1/Qj1DKivQvscI4vmHfDfvYR2pfuFOJiCeewM=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "67df8c627c2c39c41dbec76a1f201929929ab0bd", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-parts_2": { "inputs": { "nixpkgs-lib": [ "stylix", @@ -162,11 +195,11 @@ ] }, "locked": { - "lastModified": 1751413152, - "narHash": "sha256-Tyw1RjYEsp5scoigs1384gIg6e0GoBVjms4aXFfRssQ=", + "lastModified": 1756770412, + "narHash": "sha256-+uWLQZccFHwqpGqr2Yt5VsW/PbeJVTn9Dk6SHWhNRPw=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "77826244401ea9de6e3bac47c2db46005e1f30b5", + "rev": "4524271976b625a4a605beefd893f270620fd751", "type": "github" }, "original": { @@ -216,18 +249,20 @@ "gnome-shell": { "flake": false, "locked": { - "lastModified": 1748186689, - "narHash": "sha256-UaD7Y9f8iuLBMGHXeJlRu6U1Ggw5B9JnkFs3enZlap0=", + "host": "gitlab.gnome.org", + "lastModified": 1762869044, + "narHash": "sha256-nwm/GJ2Syigf7VccLAZ66mFC8mZJFqpJmIxSGKl7+Ds=", "owner": "GNOME", "repo": "gnome-shell", - "rev": "8c88f917db0f1f0d80fa55206c863d3746fa18d0", - "type": "github" + "rev": "680e3d195a92203f28d4bf8c6e8bb537cc3ed4ad", + "type": "gitlab" }, "original": { + "host": "gitlab.gnome.org", "owner": "GNOME", - "ref": "48.2", + "ref": "gnome-49", "repo": "gnome-shell", - "type": "github" + "type": "gitlab" } }, "home-manager": { @@ -237,11 +272,11 @@ ] }, "locked": { - "lastModified": 1752814804, - "narHash": "sha256-irfg7lnfEpJY+3Cffkluzp2MTVw1Uq9QGxFp6qadcXI=", + "lastModified": 1764304195, + "narHash": "sha256-bO7FN/bF6gG7TlZpKAZjO3VvfsLaPFkefeUfJJ7F/7w=", "owner": "nix-community", "repo": "home-manager", - "rev": "d0300c8808e41da81d6edfc202f3d3833c157daf", + "rev": "86ff0ef506c209bb397849706e85cc3a913cb577", "type": "github" }, "original": { @@ -266,11 +301,11 @@ ] }, "locked": { - "lastModified": 1749155331, - "narHash": "sha256-XR9fsI0zwLiFWfqi/pdS/VD+YNorKb3XIykgTg4l1nA=", + "lastModified": 1753964049, + "narHash": "sha256-lIqabfBY7z/OANxHoPeIrDJrFyYy9jAM4GQLzZ2feCM=", "owner": "hyprwm", "repo": "hyprcursor", - "rev": "45fcc10b4c282746d93ec406a740c43b48b4ef80", + "rev": "44e91d467bdad8dcf8bbd2ac7cf49972540980a5", "type": "github" }, "original": { @@ -295,11 +330,11 @@ ] }, "locked": { - "lastModified": 1752149140, - "narHash": "sha256-gbh1HL98Fdqu0jJIWN4OJQN7Kkth7+rbkFpSZLm/62A=", + "lastModified": 1763733840, + "narHash": "sha256-JnET78yl5RvpGuDQy3rCycOCkiKoLr5DN1fPhRNNMco=", "owner": "hyprwm", "repo": "hyprgraphics", - "rev": "340494a38b5ec453dfc542c6226481f736cc8a9a", + "rev": "8f1bec691b2d198c60cccabca7a94add2df4ed1a", "type": "github" }, "original": { @@ -313,8 +348,8 @@ "aquamarine": "aquamarine", "hyprcursor": "hyprcursor", "hyprgraphics": "hyprgraphics", + "hyprland-guiutils": "hyprland-guiutils", "hyprland-protocols": "hyprland-protocols", - "hyprland-qtutils": "hyprland-qtutils", "hyprlang": "hyprlang", "hyprutils": "hyprutils", "hyprwayland-scanner": "hyprwayland-scanner", @@ -324,11 +359,11 @@ "xdph": "xdph" }, "locked": { - "lastModified": 1752936500, - "narHash": "sha256-StLLgYbL3U2iDezMbfr/QjUtd2a0Mb+pScDSQxFElTg=", + "lastModified": 1764283894, + "narHash": "sha256-5BWYZDmJKwUGxhY+43obUZItkAL6rm3xkvBYdltUWz4=", "owner": "hyprwm", "repo": "Hyprland", - "rev": "91d8a629ebfffaa46290331a74a54e249dec64fe", + "rev": "7e1e24fea615503a3cc05218c12b06c1b6cabdc7", "type": "github" }, "original": { @@ -337,104 +372,111 @@ "type": "github" } }, - "hyprland-plugins": { + "hyprland-guiutils": { "inputs": { - "hyprland": [ - "hyprland" + "aquamarine": [ + "hyprland", + "aquamarine" + ], + "hyprgraphics": [ + "hyprland", + "hyprgraphics" + ], + "hyprlang": [ + "hyprland", + "hyprlang" + ], + "hyprtoolkit": "hyprtoolkit", + "hyprutils": [ + "hyprland", + "hyprutils" + ], + "hyprwayland-scanner": [ + "hyprland", + "hyprwayland-scanner" ], "nixpkgs": [ - "hyprland-plugins", "hyprland", "nixpkgs" ], "systems": [ - "hyprland-plugins", "hyprland", "systems" ] }, "locked": { - "lastModified": 1752875239, - "narHash": "sha256-fyMI4mWt29sEdTSFJKhIdVQrrewycFWLmrOX/HRf4Ig=", + "lastModified": 1763727565, + "narHash": "sha256-vRff/2R1U1jzPBy4OODqh2kfUzmizW/nfV2ROzTDIKo=", "owner": "hyprwm", - "repo": "Hyprland-Plugins", - "rev": "d4bf99e72243e035b15f34db8572d62d73b9a68c", + "repo": "hyprland-guiutils", + "rev": "7724d3a12a0453e7aae05f2ef39474219f05a4b4", "type": "github" }, "original": { "owner": "hyprwm", - "repo": "Hyprland-Plugins", + "repo": "hyprland-guiutils", "type": "github" } }, - "hyprland-protocols": { + "hyprland-plugins": { "inputs": { + "hyprland": [ + "hyprland" + ], "nixpkgs": [ + "hyprland-plugins", "hyprland", "nixpkgs" ], "systems": [ + "hyprland-plugins", "hyprland", "systems" ] }, "locked": { - "lastModified": 1749046714, - "narHash": "sha256-kymV5FMnddYGI+UjwIw8ceDjdeg7ToDVjbHCvUlhn14=", + "lastModified": 1764195033, + "narHash": "sha256-ALRU1VfTv+Vld0bEq3UHSiM6vYxALWvss7d2eOymqbM=", "owner": "hyprwm", - "repo": "hyprland-protocols", - "rev": "613878cb6f459c5e323aaafe1e6f388ac8a36330", + "repo": "Hyprland-Plugins", + "rev": "84659a2502df6b2fd245441c16a8365f5e1cd16d", "type": "github" }, "original": { "owner": "hyprwm", - "repo": "hyprland-protocols", + "repo": "Hyprland-Plugins", "type": "github" } }, - "hyprland-qt-support": { + "hyprland-protocols": { "inputs": { - "hyprlang": [ - "hyprland", - "hyprland-qtutils", - "hyprlang" - ], "nixpkgs": [ "hyprland", - "hyprland-qtutils", "nixpkgs" ], "systems": [ "hyprland", - "hyprland-qtutils", "systems" ] }, "locked": { - "lastModified": 1749154592, - "narHash": "sha256-DO7z5CeT/ddSGDEnK9mAXm1qlGL47L3VAHLlLXoCjhE=", + "lastModified": 1759610243, + "narHash": "sha256-+KEVnKBe8wz+a6dTLq8YDcF3UrhQElwsYJaVaHXJtoI=", "owner": "hyprwm", - "repo": "hyprland-qt-support", - "rev": "4c8053c3c888138a30c3a6c45c2e45f5484f2074", + "repo": "hyprland-protocols", + "rev": "bd153e76f751f150a09328dbdeb5e4fab9d23622", "type": "github" }, "original": { "owner": "hyprwm", - "repo": "hyprland-qt-support", + "repo": "hyprland-protocols", "type": "github" } }, - "hyprland-qtutils": { + "hyprlang": { "inputs": { - "hyprland-qt-support": "hyprland-qt-support", - "hyprlang": [ - "hyprland", - "hyprlang" - ], "hyprutils": [ "hyprland", - "hyprland-qtutils", - "hyprlang", "hyprutils" ], "nixpkgs": [ @@ -447,45 +489,68 @@ ] }, "locked": { - "lastModified": 1750371812, - "narHash": "sha256-D868K1dVEACw17elVxRgXC6hOxY+54wIEjURztDWLk8=", + "lastModified": 1763819661, + "narHash": "sha256-0jLarTR/BLWdGlboM86bPVP2zKJNI2jvo3JietnDkOM=", "owner": "hyprwm", - "repo": "hyprland-qtutils", - "rev": "b13c7481e37856f322177010bdf75fccacd1adc8", + "repo": "hyprlang", + "rev": "a318deec0c12409ec39c68d2be8096b636dc2a5c", "type": "github" }, "original": { "owner": "hyprwm", - "repo": "hyprland-qtutils", + "repo": "hyprlang", "type": "github" } }, - "hyprlang": { + "hyprtoolkit": { "inputs": { + "aquamarine": [ + "hyprland", + "hyprland-guiutils", + "aquamarine" + ], + "hyprgraphics": [ + "hyprland", + "hyprland-guiutils", + "hyprgraphics" + ], + "hyprlang": [ + "hyprland", + "hyprland-guiutils", + "hyprlang" + ], "hyprutils": [ "hyprland", + "hyprland-guiutils", "hyprutils" ], + "hyprwayland-scanner": [ + "hyprland", + "hyprland-guiutils", + "hyprwayland-scanner" + ], "nixpkgs": [ "hyprland", + "hyprland-guiutils", "nixpkgs" ], "systems": [ "hyprland", + "hyprland-guiutils", "systems" ] }, "locked": { - "lastModified": 1750371198, - "narHash": "sha256-/iuJ1paQOBoSLqHflRNNGyroqfF/yvPNurxzcCT0cAE=", + "lastModified": 1763503177, + "narHash": "sha256-VPoiswJBBmTLVuNncvT/8FpFR+sYcAi/LgP/zTZ+5rA=", "owner": "hyprwm", - "repo": "hyprlang", - "rev": "cee01452bca58d6cadb3224e21e370de8bc20f0b", + "repo": "hyprtoolkit", + "rev": "f4e1e12755567ecf39090203b8f43eace8279630", "type": "github" }, "original": { "owner": "hyprwm", - "repo": "hyprlang", + "repo": "hyprtoolkit", "type": "github" } }, @@ -501,11 +566,11 @@ ] }, "locked": { - "lastModified": 1752252310, - "narHash": "sha256-06i1pIh6wb+sDeDmWlzuPwIdaFMxLlj1J9I5B9XqSeo=", + "lastModified": 1763996058, + "narHash": "sha256-DsqzFZvrEV+aDmavjaD4/bk5qxeZwhGxPWBQdpFyM9Y=", "owner": "hyprwm", "repo": "hyprutils", - "rev": "bcabcbada90ed2aacb435dc09b91001819a6dc82", + "rev": "0168583075baffa083032ed13a8bea8ea12f281a", "type": "github" }, "original": { @@ -526,11 +591,11 @@ ] }, "locked": { - "lastModified": 1751897909, - "narHash": "sha256-FnhBENxihITZldThvbO7883PdXC/2dzW4eiNvtoV5Ao=", + "lastModified": 1763640274, + "narHash": "sha256-Uan1Nl9i4TF/kyFoHnTq1bd/rsWh4GAK/9/jDqLbY5A=", "owner": "hyprwm", "repo": "hyprwayland-scanner", - "rev": "fcca0c61f988a9d092cbb33e906775014c61579d", + "rev": "f6cf414ca0e16a4d30198fd670ec86df3c89f671", "type": "github" }, "original": { @@ -539,13 +604,33 @@ "type": "github" } }, + "nixcord": { + "inputs": { + "flake-compat": "flake-compat_2", + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1764317078, + "narHash": "sha256-U8V+hb1fXUn/NtR5C//whFPo9Uh7JgqbGgZFndXm+Tk=", + "owner": "kaylorben", + "repo": "nixcord", + "rev": "162347700710442a64b3fc972adc979f8581b382", + "type": "github" + }, + "original": { + "owner": "kaylorben", + "repo": "nixcord", + "type": "github" + } + }, "nixpkgs": { "locked": { - "lastModified": 1752687322, - "narHash": "sha256-RKwfXA4OZROjBTQAl9WOZQFm7L8Bo93FQwSJpAiSRvo=", + "lastModified": 1763966396, + "narHash": "sha256-6eeL1YPcY1MV3DDStIDIdy/zZCDKgHdkCmsrLJFiZf0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "6e987485eb2c77e5dcc5af4e3c70843711ef9251", + "rev": "5ae3b07d8d6527c42f17c876e404993199144b6a", "type": "github" }, "original": { @@ -555,13 +640,44 @@ "type": "github" } }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1753579242, + "narHash": "sha256-zvaMGVn14/Zz8hnp4VWT9xVnhc8vuL3TStRqwk22biA=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "0f36c44e01a6129be94e3ade315a5883f0228a6e", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, "nixpkgs_2": { "locked": { - "lastModified": 1752950548, - "narHash": "sha256-NS6BLD0lxOrnCiEOcvQCDVPXafX1/ek1dfJHX1nUIzc=", + "lastModified": 1754028485, + "narHash": "sha256-IiiXB3BDTi6UqzAZcf2S797hWEPCRZOwyNThJIYhUfk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "59e69648d345d6e8fef86158c555730fa12af9de", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1764242076, + "narHash": "sha256-sKoIWfnijJ0+9e4wRvIgm/HgE27bzwQxcEmo2J/gNpI=", "owner": "nixos", "repo": "nixpkgs", - "rev": "c87b95e25065c028d31a94f06a62927d18763fdf", + "rev": "2fad6eac6077f03fe109c4d4eb171cf96791faa4", "type": "github" }, "original": { @@ -571,13 +687,13 @@ "type": "github" } }, - "nixpkgs_3": { + "nixpkgs_4": { "locked": { - "lastModified": 1751792365, - "narHash": "sha256-J1kI6oAj25IG4EdVlg2hQz8NZTBNYvIS0l4wpr9KcUo=", + "lastModified": 1762977756, + "narHash": "sha256-4PqRErxfe+2toFJFgcRKZ0UI9NSIOJa+7RXVtBhy4KE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "1fd8bada0b6117e6c7eb54aad5813023eed37ccb", + "rev": "c5ae371f1a6a7fd27823bc500d9390b38c05fa55", "type": "github" }, "original": { @@ -599,11 +715,11 @@ ] }, "locked": { - "lastModified": 1751906969, - "narHash": "sha256-BSQAOdPnzdpOuCdAGSJmefSDlqmStFNScEnrWzSqKPw=", + "lastModified": 1758998580, + "narHash": "sha256-VLx0z396gDCGSiowLMFz5XRO/XuNV+4EnDYjdJhHvUk=", "owner": "nix-community", "repo": "NUR", - "rev": "ddb679f4131e819efe3bbc6457ba19d7ad116f25", + "rev": "ba8d9c98f5f4630bcb0e815ab456afd90c930728", "type": "github" }, "original": { @@ -622,11 +738,11 @@ ] }, "locked": { - "lastModified": 1750779888, - "narHash": "sha256-wibppH3g/E2lxU43ZQHC5yA/7kIKLGxVEnsnVK1BtRg=", + "lastModified": 1763988335, + "narHash": "sha256-QlcnByMc8KBjpU37rbq5iP7Cp97HvjRP0ucfdh+M4Qc=", "owner": "cachix", "repo": "git-hooks.nix", - "rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d", + "rev": "50b9238891e388c9fdc6a5c49e49c42533a1b5ce", "type": "github" }, "original": { @@ -635,13 +751,35 @@ "type": "github" } }, + "quickshell": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1764045583, + "narHash": "sha256-W24ReyRrhOKTKIsuAMkY5hnVlCufGoONM79sjUoyQkk=", + "owner": "quickshell-mirror", + "repo": "quickshell", + "rev": "e9bad67619ee9937a1bbecfc6ad3b4231d2ecdc3", + "type": "github" + }, + "original": { + "owner": "quickshell-mirror", + "repo": "quickshell", + "type": "github" + } + }, "root": { "inputs": { "firefox-addons": "firefox-addons", "home-manager": "home-manager", "hyprland": "hyprland", "hyprland-plugins": "hyprland-plugins", - "nixpkgs": "nixpkgs_2", + "nixcord": "nixcord", + "nixpkgs": "nixpkgs_3", + "quickshell": "quickshell", "stylix": "stylix", "wallpapers": "wallpapers" } @@ -653,9 +791,9 @@ "base16-helix": "base16-helix", "base16-vim": "base16-vim", "firefox-gnome-theme": "firefox-gnome-theme", - "flake-parts": "flake-parts", + "flake-parts": "flake-parts_2", "gnome-shell": "gnome-shell", - "nixpkgs": "nixpkgs_3", + "nixpkgs": "nixpkgs_4", "nur": "nur", "systems": "systems_2", "tinted-foot": "tinted-foot", @@ -665,11 +803,11 @@ "tinted-zed": "tinted-zed" }, "locked": { - "lastModified": 1752965417, - "narHash": "sha256-FDec+RoFgSrk3YPedcjNiBK+acaHO4Vt0YDTMdKdw1w=", + "lastModified": 1764254063, + "narHash": "sha256-V22JzkaTLF/GAL2LgqvOsJhAr8JbJsKaD8hnHjGwXfE=", "owner": "danth", "repo": "stylix", - "rev": "f826d3214b8a9be3f158d5cc7514c4130674324b", + "rev": "a1451bc40413870f0c7b576b751c1ca92055e323", "type": "github" }, "original": { @@ -744,11 +882,11 @@ "tinted-schemes": { "flake": false, "locked": { - "lastModified": 1750770351, - "narHash": "sha256-LI+BnRoFNRa2ffbe3dcuIRYAUcGklBx0+EcFxlHj0SY=", + "lastModified": 1757716333, + "narHash": "sha256-d4km8W7w2zCUEmPAPUoLk1NlYrGODuVa3P7St+UrqkM=", "owner": "tinted-theming", "repo": "schemes", - "rev": "5a775c6ffd6e6125947b393872cde95867d85a2a", + "rev": "317a5e10c35825a6c905d912e480dfe8e71c7559", "type": "github" }, "original": { @@ -760,11 +898,11 @@ "tinted-tmux": { "flake": false, "locked": { - "lastModified": 1751159871, - "narHash": "sha256-UOHBN1fgHIEzvPmdNMHaDvdRMgLmEJh2hNmDrp3d3LE=", + "lastModified": 1757811970, + "narHash": "sha256-n5ZJgmzGZXOD9pZdAl1OnBu3PIqD+X3vEBUGbTi4JiI=", "owner": "tinted-theming", "repo": "tinted-tmux", - "rev": "bded5e24407cec9d01bd47a317d15b9223a1546c", + "rev": "d217ba31c846006e9e0ae70775b0ee0f00aa6b1e", "type": "github" }, "original": { @@ -776,11 +914,11 @@ "tinted-zed": { "flake": false, "locked": { - "lastModified": 1751158968, - "narHash": "sha256-ksOyv7D3SRRtebpXxgpG4TK8gZSKFc4TIZpR+C98jX8=", + "lastModified": 1757811247, + "narHash": "sha256-4EFOUyLj85NRL3OacHoLGEo0wjiRJzfsXtR4CZWAn6w=", "owner": "tinted-theming", "repo": "base16-zed", - "rev": "86a470d94204f7652b906ab0d378e4231a5b3384", + "rev": "824fe0aacf82b3c26690d14e8d2cedd56e18404e", "type": "github" }, "original": { @@ -792,7 +930,7 @@ "wallpapers": { "flake": false, "locked": { - "lastModified": 1753205958, + "lastModified": 1753285086, "narHash": "sha256-COH3R0ZYkCM0OLj8lG4uo/YUQh7oHz9etmHgJDOPLNU=", "path": "/usr/share/wallpaper", "type": "path" @@ -830,11 +968,11 @@ ] }, "locked": { - "lastModified": 1751300244, - "narHash": "sha256-PFuv1TZVYvQhha0ac53E3YgdtmLShrN0t4T6xqHl0jE=", + "lastModified": 1761431178, + "narHash": "sha256-xzjC1CV3+wpUQKNF+GnadnkeGUCJX+vgaWIZsnz9tzI=", "owner": "hyprwm", "repo": "xdg-desktop-portal-hyprland", - "rev": "6115f3fdcb2c1a57b4a80a69f3c797e47607b90a", + "rev": "4b8801228ff958d028f588f0c2b911dbf32297f9", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 75c146b..e7a858b 100644 --- a/flake.nix +++ b/flake.nix @@ -16,11 +16,17 @@ stylix.url = "github:danth/stylix"; + nixcord.url = "github:kaylorben/nixcord"; + hyprland.url = "github:hyprwm/Hyprland"; hyprland-plugins = { url = "github:hyprwm/Hyprland-Plugins"; inputs.hyprland.follows = "hyprland"; }; + quickshell = { + url = "github:quickshell-mirror/quickshell"; + inputs.nixpkgs.follows = "nixpkgs"; + }; wallpapers = { url = "path:/usr/share/wallpaper"; # Path to wallpapers directory flake = false; @@ -33,7 +39,7 @@ ... } @ inputs: let themes = import ./modules/nixos/themes/defaults.nix; - activeTheme = themes.dracula; # Change this to switch themes + activeTheme = themes.valua; # Change this to switch themes wallpaperPath = "${inputs.wallpapers}/${activeTheme.wallpaper}"; in { theme = builtins.path { @@ -47,6 +53,15 @@ ./hosts/centaur/configuration.nix inputs.home-manager.nixosModules.default inputs.stylix.nixosModules.stylix + { + home-manager.sharedModules = [ + inputs.nixcord.homeModules.nixcord + ]; + # Add quickshell overlay + nixpkgs.overlays = [ + inputs.quickshell.overlays.default + ]; + } ]; }; formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.alejandra; diff --git a/hosts/centaur/configuration.nix b/hosts/centaur/configuration.nix index f1c6f60..b9d9428 100644 --- a/hosts/centaur/configuration.nix +++ b/hosts/centaur/configuration.nix @@ -19,7 +19,20 @@ # BOOT CONFIGURATION # ============================================================================ boot.loader = { - systemd-boot.enable = true; + grub = { + enable = true; + efiSupport = true; + device = "nodev"; + extraEntries = '' + menuentry "Red Hat Enterprise Linux 10" { + insmod part_gpt + insmod xfs + search --no-floppy --fs-uuid --set=root fd3258e6-5af2-4c09-9943-398267b04e5b + linux /vmlinuz-6.12.0-55.22.1.el10_0.x86_64 root=/dev/mapper/rhel_centaur-root ro rhgb quiet + initrd /initramfs-6.12.0-55.22.1.el10_0.x86_64.img + } + ''; + }; efi.canTouchEfiVariables = true; }; @@ -142,6 +155,9 @@ # Bluetooth services.blueman.enable = true; + # UPower (for battery indicator) + services.upower.enable = true; + # ============================================================================ # HARDWARE CONFIGURATION # ============================================================================ @@ -173,6 +189,14 @@ xdg-desktop-portal-gtk ]; + # ============================================================================ + # FONTS + # ============================================================================ + fonts.packages = with pkgs; [ + material-symbols # Material Symbols Rounded icons for quickshell + rubik # Rubik font used by end-4 config + ]; + # ============================================================================ # PROGRAMS # ============================================================================ @@ -194,6 +218,7 @@ # ============================================================================ home-manager = { extraSpecialArgs = {inherit inputs;}; + backupFileExtension = "backup"; users = { "fpl" = import ./home.nix; }; diff --git a/hosts/centaur/home.nix b/hosts/centaur/home.nix index 67fd343..7fa9403 100644 --- a/hosts/centaur/home.nix +++ b/hosts/centaur/home.nix @@ -21,6 +21,8 @@ ../../modules/home-manager/python-packages.nix ../../modules/home-manager/cli-tools.nix ../../modules/home-manager/discord.nix + ../../modules/home-manager/neovim.nix + ../../modules/home-manager/rust.nix ../../modules/hyprland/default.nix ]; @@ -38,7 +40,20 @@ home.packages = [ pkgs.texlive.combined.scheme-full pkgs.protonvpn-gui - pkgs.termius + (pkgs.termius.overrideAttrs (oldAttrs: { + autoPatchelfIgnoreMissingDeps = + (oldAttrs.autoPatchelfIgnoreMissingDeps or []) + ++ [ + "libsqlite3.so.0" + ]; + buildInputs = + (oldAttrs.buildInputs or []) + ++ [ + pkgs.libGL + pkgs.nss + pkgs.sqlite + ]; + })) # # Adds the 'hello' command to your environment. It prints a friendly # # "Hello, world!" when run. # pkgs.hello diff --git a/modules/home-manager/discord.nix b/modules/home-manager/discord.nix index b47d97b..c5c4410 100644 --- a/modules/home-manager/discord.nix +++ b/modules/home-manager/discord.nix @@ -1,7 +1,156 @@ -{pkgs, ...}: { - home.packages = [ - pkgs.vesktop - ]; +{pkgs, ...}: let + # CSS to map Stylix base16 colors to Midnight Discord theme variables + midnightStylixCss = '' + /** + * Midnight Discord theme with Stylix colors + * This maps Stylix base16 variables to Midnight theme variables + */ - stylix.targets.vesktop.enable = true; + body { + /* font options */ + --font: 'figtree'; + --code-font: ""; + + /* sizes */ + --gap: 12px; + --divider-thickness: 4px; + --border-thickness: 1px; + + /* animation options */ + --animations: on; + --list-item-transition: 0.2s ease; + --dms-icon-svg-transition: 0.4s ease; + --border-hover-transition: 0.2s ease; + + /* top bar options */ + --top-bar-height: 32px; + --top-bar-button-position: titlebar; + --top-bar-title-position: off; + --subtle-top-bar-title: off; + + /* chatbar options */ + --custom-chatbar: aligned; + --chatbar-height: 47px; + + /* dms button options */ + --custom-dms-icon: custom; + --dms-icon-svg-url: url('https://refact0r.github.io/midnight-discord/assets/Font_Awesome_5_solid_moon.svg'); + --dms-icon-svg-size: 90%; + --custom-dms-background: off; + + /* window control options */ + --custom-window-controls: on; + --window-control-size: 14px; + + /* other options */ + --small-user-panel: off; + --colors: on; + + /* text colors - using Stylix base16 variables */ + --text-0: var(--base00); + --text-1: #ffffff; + --text-2: #f0f0f0; + --text-3: #e0e0e0; + --text-4: var(--base04); + --text-5: var(--base03); + + /* background colors - using Stylix base16 variables */ + --bg-1: var(--base02); + --bg-2: var(--base01); + --bg-3: var(--base01); + --bg-4: var(--base00); + --hover: hsla(220, 19%, 40%, 0.1); + --active: hsla(220, 19%, 40%, 0.2); + --active-2: hsla(220, 19%, 40%, 0.3); + --message-hover: hsla(230, 0%, 0%, 0.1); + + /* accent colors - using Stylix base16 variables */ + --accent-1: var(--base0D); + --accent-2: var(--base0D); + --accent-3: var(--base0D); + --accent-4: var(--base0C); + --accent-5: var(--base0C); + --accent-new: var(--base0D); + --mention: linear-gradient(to right, color-mix(in hsl, var(--base0D), transparent 90%) 40%, transparent); + --mention-hover: linear-gradient(to right, color-mix(in hsl, var(--base0D), transparent 95%) 40%, transparent); + --reply: linear-gradient(to right, color-mix(in hsl, var(--text-3), transparent 90%) 40%, transparent); + --reply-hover: linear-gradient(to right, color-mix(in hsl, var(--text-3), transparent 95%) 40%, transparent); + + /* status colors */ + --online: var(--base0B); + --dnd: var(--base08); + --idle: var(--base0A); + --streaming: var(--base0E); + --offline: var(--text-4); + + /* border colors */ + --border-light: hsla(230, 20%, 40%, 0.1); + --border: hsla(230, 20%, 40%, 0.2); + --button-border: hsla(0, 0%, 100%, 0.1); + + /* base colors - all mapped from Stylix */ + --red-1: var(--base08); + --red-2: var(--base08); + --red-3: var(--base08); + --red-4: var(--base08); + --red-5: var(--base08); + + --green-1: var(--base0B); + --green-2: var(--base0B); + --green-3: var(--base0B); + --green-4: var(--base0B); + --green-5: var(--base0B); + + --blue-1: var(--base0D); + --blue-2: var(--base0D); + --blue-3: var(--base0D); + --blue-4: var(--base0D); + --blue-5: var(--base0D); + + --yellow-1: var(--base0A); + --yellow-2: var(--base0A); + --yellow-3: var(--base0A); + --yellow-4: var(--base0A); + --yellow-5: var(--base0A); + + --purple-1: var(--base0E); + --purple-2: var(--base0E); + --purple-3: var(--base0E); + --purple-4: var(--base0E); + --purple-5: var(--base0E); + } + ''; +in { + programs.nixcord = { + enable = true; + vesktop.enable = true; + + config = { + useQuickCss = true; + + themeLinks = [ + "https://refact0r.github.io/midnight-discord/build/midnight.css" + ]; + + plugins = { + biggerStreamPreview.enable = true; + messageLogger.enable = true; + callTimer.enable = true; + clearURLs.enable = true; + permissionsViewer.enable = true; + platformIndicators.enable = true; + relationshipNotifier.enable = true; + showHiddenChannels.enable = true; + typingIndicator.enable = true; + }; + }; + + quickCss = ""; + }; + + # Stylix will generate a theme with --base00 through --base0F CSS variables + stylix.targets.nixcord.enable = true; + + # Extra CSS to map Stylix base16 colors to Midnight Discord theme variables + stylix.targets.nixcord.extraCss = midnightStylixCss; } diff --git a/modules/home-manager/firefox.nix b/modules/home-manager/firefox.nix index af9c925..02ceddf 100644 --- a/modules/home-manager/firefox.nix +++ b/modules/home-manager/firefox.nix @@ -6,11 +6,21 @@ }: { home.packages = with pkgs; [firefox]; - stylix.targets.firefox.profileNames = ["fpl"]; + stylix.targets.firefox = { + enable = true; + profileNames = ["fpl"]; + colorTheme.enable = true; + }; programs.firefox = { enable = true; profiles.fpl = { + isDefault = true; + settings = { + "toolkit.legacyUserProfileCustomizations.stylesheets" = true; + "browser.startup.page" = 3; + }; + extensions.force = true; extensions.packages = with inputs.firefox-addons.packages."x86_64-linux"; [ bitwarden ublock-origin diff --git a/modules/home-manager/jetbrains.nix b/modules/home-manager/jetbrains.nix index ab82787..06bfefd 100644 --- a/modules/home-manager/jetbrains.nix +++ b/modules/home-manager/jetbrains.nix @@ -1,11 +1,25 @@ {pkgs, ...}: let - jetbrainsToolboxWithDesktop = pkgs.symlinkJoin { - name = "jetbrains-toolbox-with-desktop"; - paths = [pkgs.jetbrains-toolbox]; - nativeBuildInputs = [pkgs.makeWrapper]; - postBuild = '' - mkdir -p $out/share/applications - cat > $out/share/applications/jetbrains-toolbox.desktop </dev/null || true + chmod -R u+w $out/share/ 2>/dev/null || true + fi + + # Ensure applications directory exists and is writable + mkdir -p $out/share/applications + chmod u+w $out/share/applications + + # Create the desktop file + cat > $out/share/applications/jetbrains-toolbox.desktop <e", ":NvimTreeToggle") + + -- Telescope + keymap("n", "ff", ":Telescope find_files") + keymap("n", "fg", ":Telescope live_grep") + keymap("n", "fb", ":Telescope buffers") + keymap("n", "fh", ":Telescope help_tags") + + -- Window navigation + keymap("n", "", "h") + keymap("n", "", "j") + keymap("n", "", "k") + keymap("n", "", "l") + + -- Buffer management + keymap("n", "bn", ":bnext") + keymap("n", "bp", ":bprevious") + keymap("n", "bd", ":bdelete") + + -- Plugin configurations + + -- Nvim Tree + require("nvim-tree").setup({ + sort_by = "case_sensitive", + view = { + width = 30, + }, + renderer = { + group_empty = true, + }, + filters = { + dotfiles = true, + }, + }) + + -- Telescope + require("telescope").setup({ + defaults = { + mappings = { + i = { + [""] = require("telescope.actions").move_selection_previous, + [""] = require("telescope.actions").move_selection_next, + } + } + } + }) + require("telescope").load_extension("fzf") + + -- Treesitter + require("nvim-treesitter.configs").setup({ + highlight = { + enable = true, + }, + indent = { + enable = true, + }, + }) + + -- LSP Configuration + local lspconfig = require("lspconfig") + local capabilities = require("cmp_nvim_lsp").default_capabilities() + + -- C/C++ LSP + lspconfig.clangd.setup({ + capabilities = capabilities, + cmd = { + "clangd", + "--background-index", + "--clang-tidy", + "--header-insertion=iwyu", + "--completion-style=detailed", + "--function-arg-placeholders", + "--fallback-style=llvm", + }, + init_options = { + usePlaceholders = true, + }, + }) + + -- Python LSP + lspconfig.pyright.setup({ + capabilities = capabilities, + settings = { + python = { + analysis = { + autoSearchPaths = true, + diagnosticMode = "workspace", + useLibraryCodeForTypes = true, + }, + }, + }, + }) + + -- Java LSP + lspconfig.jdtls.setup({ + capabilities = capabilities, + cmd = { "jdtls" }, + settings = { + java = { + eclipse = { + downloadSources = true, + }, + configuration = { + updateBuildConfiguration = "interactive", + }, + maven = { + downloadSources = true, + }, + implementationsCodeLens = { + enabled = true, + }, + referencesCodeLens = { + enabled = true, + }, + references = { + includeDecompiledSources = true, + }, + format = { + enabled = true, + }, + }, + signatureHelp = { enabled = true }, + completion = { + favoriteStaticMembers = { + "org.hamcrest.MatcherAssert.assertThat", + "org.hamcrest.Matchers.*", + "org.hamcrest.CoreMatchers.*", + "org.junit.jupiter.api.Assertions.*", + "java.util.Objects.requireNonNull", + "java.util.Objects.requireNonNullElse", + "org.mockito.Mockito.*", + }, + }, + contentProvider = { preferred = "fernflower" }, + sources = { + organizeImports = { + starThreshold = 9999, + staticStarThreshold = 9999, + }, + }, + codeGeneration = { + toString = { + template = "$(object.className){$(member.name())=$(member.value), $(otherMembers)}", + }, + useBlocks = true, + }, + }, + }) + + -- Kotlin LSP + lspconfig.kotlin_language_server.setup({ + capabilities = capabilities, + }) + + -- Rust LSP + lspconfig.rust_analyzer.setup({ + capabilities = capabilities, + settings = { + ["rust-analyzer"] = { + imports = { + granularity = { + group = "module", + }, + prefix = "self", + }, + cargo = { + buildScripts = { + enable = true, + }, + }, + procMacro = { + enable = true, + }, + checkOnSave = { + command = "clippy", + }, + }, + }, + }) + + -- Nix LSP + lspconfig.nixd.setup({ + capabilities = capabilities, + }) + + -- TypeScript LSP + lspconfig.ts_ls.setup({ + capabilities = capabilities, + }) + + -- LSP key mappings + vim.api.nvim_create_autocmd("LspAttach", { + group = vim.api.nvim_create_augroup("UserLspConfig", {}), + callback = function(ev) + local opts = { buffer = ev.buf } + keymap("n", "gD", vim.lsp.buf.declaration, opts) + keymap("n", "gd", vim.lsp.buf.definition, opts) + keymap("n", "K", vim.lsp.buf.hover, opts) + keymap("n", "gi", vim.lsp.buf.implementation, opts) + keymap("n", "", vim.lsp.buf.signature_help, opts) + keymap("n", "rn", vim.lsp.buf.rename, opts) + keymap("n", "ca", vim.lsp.buf.code_action, opts) + keymap("n", "gr", vim.lsp.buf.references, opts) + keymap("n", "f", function() + vim.lsp.buf.format { async = true } + end, opts) + end, + }) + + -- Completion setup + local cmp = require("cmp") + local luasnip = require("luasnip") + + cmp.setup({ + snippet = { + expand = function(args) + luasnip.lsp_expand(args.body) + end, + }, + window = { + completion = cmp.config.window.bordered(), + documentation = cmp.config.window.bordered(), + }, + mapping = cmp.mapping.preset.insert({ + [""] = cmp.mapping.scroll_docs(-4), + [""] = cmp.mapping.scroll_docs(4), + [""] = cmp.mapping.complete(), + [""] = cmp.mapping.abort(), + [""] = cmp.mapping.confirm({ select = true }), + [""] = cmp.mapping(function(fallback) + if cmp.visible() then + cmp.select_next_item() + elseif luasnip.expand_or_jumpable() then + luasnip.expand_or_jump() + else + fallback() + end + end, { "i", "s" }), + [""] = cmp.mapping(function(fallback) + if cmp.visible() then + cmp.select_prev_item() + elseif luasnip.jumpable(-1) then + luasnip.jump(-1) + else + fallback() + end + end, { "i", "s" }), + }), + sources = cmp.config.sources({ + { name = "nvim_lsp" }, + { name = "luasnip" }, + { name = "buffer" }, + { name = "path" }, + }), + }) + + -- Lualine + require("lualine").setup({ + options = { + theme = "dracula", + component_separators = { left = "", right = ""}, + section_separators = { left = "", right = ""}, + }, + }) + + -- Git signs + require("gitsigns").setup() + + -- Comment plugin + require("Comment").setup() + + -- Autopairs + require("nvim-autopairs").setup() + + -- Which-key + require("which-key").setup() + + -- Indent blankline + require("ibl").setup() + ''; + }; + + # Additional packages that Neovim might need + home.packages = with pkgs; [ + # Language servers + nixd + rust-analyzer + nodePackages.typescript-language-server + pyright + clang-tools + jdt-language-server + kotlin-language-server + + # Additional tools + ripgrep + fd + tree-sitter + ]; +} diff --git a/modules/home-manager/python-packages.nix b/modules/home-manager/python-packages.nix index 2e95239..256a2ab 100644 --- a/modules/home-manager/python-packages.nix +++ b/modules/home-manager/python-packages.nix @@ -11,6 +11,9 @@ plotly sympy notebook + # For quickshell color generation + pillow + materialyoucolor ]); in { home.packages = [ diff --git a/modules/home-manager/rust.nix b/modules/home-manager/rust.nix new file mode 100644 index 0000000..bbfff85 --- /dev/null +++ b/modules/home-manager/rust.nix @@ -0,0 +1,21 @@ +{ + config, + pkgs, + ... +}: { + home.packages = with pkgs; [ + rustc + cargo + rust-analyzer + rustfmt + clippy + + cargo-watch + cargo-edit + cargo-outdated + ]; + + home.sessionVariables = { + RUST_SRC_PATH = "${pkgs.rust.packages.stable.rustPlatform.rustLibSrc}"; + }; +} diff --git a/modules/hyprland/default.nix b/modules/hyprland/default.nix index e59874c..d3a1630 100644 --- a/modules/hyprland/default.nix +++ b/modules/hyprland/default.nix @@ -1,10 +1,11 @@ {...}: { imports = [ ./hyprland-config.nix - ./waybar.nix - ./rofi.nix - ./mako.nix + # ./waybar.nix # Replaced by quickshell + # ./rofi.nix # Replaced by quickshell overview + # ./mako.nix # Replaced by quickshell notifications ./hyprpaper.nix ./cliphist.nix + ../quickshell/shell-config.nix ]; } diff --git a/modules/hyprland/hyprland-config.nix b/modules/hyprland/hyprland-config.nix index 2a0be6d..c98ba99 100644 --- a/modules/hyprland/hyprland-config.nix +++ b/modules/hyprland/hyprland-config.nix @@ -12,9 +12,9 @@ disable_hyprland_logo = true } exec-once = hyprpaper - exec-once = waybar - exec-once = mako + exec-once = quickshell -p ~/.config/quickshell/shell.qml exec-once = wl-paste --watch cliphist store + exec-once = sleep 3 && vesktop --start-minimized monitor = eDP-1,1920x1080@60,0x0,1 @@ -71,21 +71,29 @@ preserve_split = true } - # Window rules - windowrule=opacity 0.75 override 0.70 override, class:^(thunar)$ - windowrule=opacity 0.98 override 0.98 override, class:^(firefox)$ - windowrule=opacity 0.98 override 0.98 override, class:^(gimp|gwenview|ristretto)$ - windowrule=opacity 0.98 override 0.98 override, class:^(evince|okular|zathura)$ - windowrule=opacity 0.98 override 0.98 override, class:^(vlc|mpv)$ + # Window rules (new v0.52 syntax) + windowrule = opacity 0.75 override 0.70 override, match:class thunar + windowrule = opacity 0.98 override 0.98 override, match:class firefox + windowrule = opacity 0.98 override 0.98 override, match:class vesktop + windowrule = opacity 0.98 override 0.98 override, match:class (gimp|gwenview|ristretto) + windowrule = opacity 0.98 override 0.98 override, match:class (evince|okular|zathura) + windowrule = opacity 0.98 override 0.98 override, match:class (vlc|mpv) $mod = SUPER $term = kitty $browser = firefox - # Rofi toggle on Alt+Space - bind = ALT, SPACE, exec, pkill rofi || rofi -show drun -no-config -theme-str '@theme "${config.home.homeDirectory}/.config/rofi/adv.rasi"' - bind = $mod, V, exec, pkill rofi || (cliphist list | rofi -dmenu | cliphist decode | wl-copy) + # Quickshell toggles + bind = , XF86PowerOff, exec, quickshell msg -p ~/.config/quickshell session toggle + bind = $mod, Tab, exec, quickshell msg -p ~/.config/quickshell overview toggle + bind = $mod, N, exec, quickshell msg -p ~/.config/quickshell sidebarRight toggle + + # Overview/App launcher via ALT+SPACE + bind = ALT, SPACE, exec, quickshell msg -p ~/.config/quickshell overview toggle + + # Clipboard via quickshell + bind = $mod, V, exec, quickshell msg -p ~/.config/quickshell overview clipboardToggle # Terminal & browser bind = $mod, SPACE, exec, $term diff --git a/modules/hyprland/waybar.nix b/modules/hyprland/waybar.nix index 75a84db..45c5fb1 100644 --- a/modules/hyprland/waybar.nix +++ b/modules/hyprland/waybar.nix @@ -234,8 +234,9 @@ waves = false; }; clock = { - format = "๏€— {:%a, %d %m, %H:%M}"; - format-alt = "๏ณ {:%d/%m}"; + format = "{:%H:%M}"; + format-alt = "๏€— {:%a, %d %m, %H:%M:%S}"; + interval = 1; tooltip = true; tooltip-format = "{:%Y %m}\n{calendar}"; }; @@ -331,7 +332,7 @@ format-ethernet = "๓ฐˆ€ 100% "; format-linked = "{ifname} (No IP)"; format-wifi = "๏‡ซ {signalStrength}%"; - tooltip-format = "Connected to {essid} {ifname} via {gwaddr}"; + tooltip-format = "Connected to {essid} {ifname} via {gwaddr} | IP: {ipaddr}"; on-click = "nm-connection-editor"; }; position = "top"; diff --git a/modules/quickshell/config/.qmlformat.ini b/modules/quickshell/config/.qmlformat.ini new file mode 100644 index 0000000..52a955c --- /dev/null +++ b/modules/quickshell/config/.qmlformat.ini @@ -0,0 +1,8 @@ +[General] +UseTabs=false +IndentWidth=4 +NewlineType=unix +NormalizeOrder=false +FunctionsSpacing=false +ObjectsSpacing=true +MaxColumnWidth=110 diff --git a/modules/quickshell/config/GlobalStates.qml b/modules/quickshell/config/GlobalStates.qml new file mode 100644 index 0000000..972495c --- /dev/null +++ b/modules/quickshell/config/GlobalStates.qml @@ -0,0 +1,70 @@ +import qs.modules.common +import qs.services +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +pragma Singleton +pragma ComponentBehavior: Bound + +Singleton { + id: root + property bool barOpen: true + property bool crosshairOpen: false + property bool sidebarLeftOpen: false + property bool sidebarRightOpen: false + property bool mediaControlsOpen: false + property bool osdBrightnessOpen: false + property bool osdVolumeOpen: false + property bool oskOpen: false + property bool overlayOpen: false + property bool overviewOpen: false + property bool regionSelectorOpen: false + property bool screenLocked: false + property bool screenLockContainsCharacters: false + property bool screenUnlockFailed: false + property bool sessionOpen: false + property bool superDown: false + property bool superReleaseMightTrigger: true + property bool wallpaperSelectorOpen: false + property bool workspaceShowNumbers: false + + onSidebarRightOpenChanged: { + if (GlobalStates.sidebarRightOpen) { + Notifications.timeoutAll(); + Notifications.markAllRead(); + } + } + + property real screenZoom: 1 + onScreenZoomChanged: { + Quickshell.execDetached(["hyprctl", "keyword", "cursor:zoom_factor", root.screenZoom.toString()]); + } + Behavior on screenZoom { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + GlobalShortcut { + name: "workspaceNumber" + description: "Hold to show workspace numbers, release to show icons" + + onPressed: { + root.superDown = true + } + onReleased: { + root.superDown = false + } + } + + IpcHandler { + target: "zoom" + + function zoomIn() { + screenZoom = Math.min(screenZoom + 0.4, 3.0) + } + + function zoomOut() { + screenZoom = Math.max(screenZoom - 0.4, 1) + } + } +} \ No newline at end of file diff --git a/modules/quickshell/config/ReloadPopup.qml b/modules/quickshell/config/ReloadPopup.qml new file mode 100644 index 0000000..7a4e711 --- /dev/null +++ b/modules/quickshell/config/ReloadPopup.qml @@ -0,0 +1,160 @@ +import QtQuick +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Wayland + +Scope { + id: root + property bool failed; + property string errorString; + + // Connect to the Quickshell global to listen for the reload signals. + Connections { + target: Quickshell + + function onReloadCompleted() { + root.failed = false; + popupLoader.loading = true; + } + + function onReloadFailed(error: string) { + // Close any existing popup before making a new one. + popupLoader.active = false; + + root.failed = true; + root.errorString = error; + popupLoader.loading = true; + } + } + + // Keep the popup in a loader because it isn't needed most of the time + LazyLoader { + id: popupLoader + + PanelWindow { + id: popup + + exclusiveZone: 0 + anchors.top: true + margins.top: 0 + + implicitWidth: rect.width + shadow.radius * 2 + implicitHeight: rect.height + shadow.radius * 2 + + WlrLayershell.namespace: "quickshell:reloadPopup" + + // color blending is a bit odd as detailed in the type reference. + color: "transparent" + + Rectangle { + id: rect + anchors.centerIn: parent + color: failed ? "#ffe99195" : "#ffD1E8D5" + + implicitHeight: layout.implicitHeight + 30 + implicitWidth: layout.implicitWidth + 30 + radius: 12 + + // Fills the whole area of the rectangle, making any clicks go to it, + // which dismiss the popup. + MouseArea { + id: mouseArea + anchors.fill: parent + onPressed: { + popupLoader.active = false + } + + // makes the mouse area track mouse hovering, so the hide animation + // can be paused when hovering. + hoverEnabled: true + } + + ColumnLayout { + id: layout + spacing: 10 + anchors { + top: parent.top + topMargin: 10 + horizontalCenter: parent.horizontalCenter + } + + Text { + renderType: Text.NativeRendering + font.family: "Google Sans Flex" + font.pointSize: 14 + text: root.failed ? "Quickshell: Reload failed" : "Quickshell reloaded" + color: failed ? "#ff93000A" : "#ff0C1F13" + } + + Text { + renderType: Text.NativeRendering + font.family: "JetBrains Mono NF" + font.pointSize: 11 + text: root.errorString + color: failed ? "#ff93000A" : "#ff0C1F13" + // When visible is false, it also takes up no space. + visible: root.errorString != "" + } + } + + // A progress bar on the bottom of the screen, showing how long until the + // popup is removed. + Rectangle { + z: 2 + id: bar + color: failed ? "#ff93000A" : "#ff0C1F13" + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: 10 + height: 5 + radius: 9999 + + PropertyAnimation { + id: anim + target: bar + property: "width" + from: rect.width - bar.anchors.margins * 2 + to: 0 + duration: failed ? 10000 : 1000 + onFinished: popupLoader.active = false + + // Pause the animation when the mouse is hovering over the popup, + // so it stays onscreen while reading. This updates reactively + // when the mouse moves on and off the popup. + paused: mouseArea.containsMouse + } + } + // Its bg + Rectangle { + z: 1 + id: bar_bg + color: failed ? "#30af1b25" : "#4027643e" + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: 10 + height: 5 + radius: 9999 + width: rect.width - bar.anchors.margins * 2 + } + + // We could set `running: true` inside the animation, but the width of the + // rectangle might not be calculated yet, due to the layout. + // In the `Component.onCompleted` event handler, all of the component's + // properties and children have been initialized. + Component.onCompleted: anim.start() + } + + DropShadow { + id: shadow + anchors.fill: rect + horizontalOffset: 0 + verticalOffset: 2 + radius: 6 + samples: radius * 2 + 1 // Ideally should be 2 * radius + 1, see qt docs + color: "#44000000" + source: rect + } + } + } +} diff --git a/modules/quickshell/config/assets/icons/cloudflare-dns-symbolic.svg b/modules/quickshell/config/assets/icons/cloudflare-dns-symbolic.svg new file mode 100644 index 0000000..bd48d3c --- /dev/null +++ b/modules/quickshell/config/assets/icons/cloudflare-dns-symbolic.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/modules/quickshell/config/assets/icons/crosshair-symbolic.svg b/modules/quickshell/config/assets/icons/crosshair-symbolic.svg new file mode 100644 index 0000000..2296749 --- /dev/null +++ b/modules/quickshell/config/assets/icons/crosshair-symbolic.svg @@ -0,0 +1,65 @@ + + + + + + + ionicons-v5_logos + + + + ionicons-v5_logos + + + + + + diff --git a/modules/quickshell/config/assets/icons/desktop-symbolic.svg b/modules/quickshell/config/assets/icons/desktop-symbolic.svg new file mode 100644 index 0000000..04f7a3b --- /dev/null +++ b/modules/quickshell/config/assets/icons/desktop-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/quickshell/config/assets/icons/flatpak-symbolic.svg b/modules/quickshell/config/assets/icons/flatpak-symbolic.svg new file mode 100644 index 0000000..0c2bf62 --- /dev/null +++ b/modules/quickshell/config/assets/icons/flatpak-symbolic.svg @@ -0,0 +1,52 @@ + + + + + Flatpak + + + + + Flatpak + + + + diff --git a/modules/quickshell/config/assets/icons/fluent/README.md b/modules/quickshell/config/assets/icons/fluent/README.md new file mode 100644 index 0000000..c8fbd90 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/README.md @@ -0,0 +1,5 @@ +The "start-here", "search" and "task view" icons are from here, with modifications + +[Windows 11 by Joshua Oghenekaro Okwe - Figma](https://www.figma.com/community/file/1123040825921884189/windows-11) + +License: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/deed.en) diff --git a/modules/quickshell/config/assets/icons/fluent/add-filled.svg b/modules/quickshell/config/assets/icons/fluent/add-filled.svg new file mode 100644 index 0000000..6b1a818 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/add-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/add.svg b/modules/quickshell/config/assets/icons/fluent/add.svg new file mode 100644 index 0000000..c983f35 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/alert-filled.svg b/modules/quickshell/config/assets/icons/fluent/alert-filled.svg new file mode 100644 index 0000000..644cecc --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/alert-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/alert-off-filled.svg b/modules/quickshell/config/assets/icons/fluent/alert-off-filled.svg new file mode 100644 index 0000000..8622bb9 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/alert-off-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/alert-off.svg b/modules/quickshell/config/assets/icons/fluent/alert-off.svg new file mode 100644 index 0000000..f38d132 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/alert-off.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/alert-snooze-filled.svg b/modules/quickshell/config/assets/icons/fluent/alert-snooze-filled.svg new file mode 100644 index 0000000..0ae53ad --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/alert-snooze-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/alert-snooze.svg b/modules/quickshell/config/assets/icons/fluent/alert-snooze.svg new file mode 100644 index 0000000..b7b56ef --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/alert-snooze.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/alert.svg b/modules/quickshell/config/assets/icons/fluent/alert.svg new file mode 100644 index 0000000..c81df33 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/alert.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/apps-filled.svg b/modules/quickshell/config/assets/icons/fluent/apps-filled.svg new file mode 100644 index 0000000..8821452 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/apps-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/apps.svg b/modules/quickshell/config/assets/icons/fluent/apps.svg new file mode 100644 index 0000000..5eb1884 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/apps.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/arrow-clockwise-filled.svg b/modules/quickshell/config/assets/icons/fluent/arrow-clockwise-filled.svg new file mode 100644 index 0000000..104ea89 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/arrow-clockwise-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/arrow-clockwise.svg b/modules/quickshell/config/assets/icons/fluent/arrow-clockwise.svg new file mode 100644 index 0000000..f955432 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/arrow-clockwise.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/arrow-counterclockwise-filled.svg b/modules/quickshell/config/assets/icons/fluent/arrow-counterclockwise-filled.svg new file mode 100644 index 0000000..a18d0d4 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/arrow-counterclockwise-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/arrow-counterclockwise.svg b/modules/quickshell/config/assets/icons/fluent/arrow-counterclockwise.svg new file mode 100644 index 0000000..68436b2 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/arrow-counterclockwise.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/arrow-left-filled.svg b/modules/quickshell/config/assets/icons/fluent/arrow-left-filled.svg new file mode 100644 index 0000000..afb9f5a --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/arrow-left-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/arrow-left.svg b/modules/quickshell/config/assets/icons/fluent/arrow-left.svg new file mode 100644 index 0000000..38709f3 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/arrow-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/arrow-sync.svg b/modules/quickshell/config/assets/icons/fluent/arrow-sync.svg new file mode 100644 index 0000000..73df683 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/arrow-sync.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/modules/quickshell/config/assets/icons/fluent/auto-filled.svg b/modules/quickshell/config/assets/icons/fluent/auto-filled.svg new file mode 100644 index 0000000..5731bee --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/auto-filled.svg @@ -0,0 +1,41 @@ + + + + + + diff --git a/modules/quickshell/config/assets/icons/fluent/auto.svg b/modules/quickshell/config/assets/icons/fluent/auto.svg new file mode 100644 index 0000000..d81b455 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/auto.svg @@ -0,0 +1,41 @@ + + + + + + diff --git a/modules/quickshell/config/assets/icons/fluent/battery-0.svg b/modules/quickshell/config/assets/icons/fluent/battery-0.svg new file mode 100644 index 0000000..7fe9d7a --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/battery-0.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_battery_0_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/battery-1.svg b/modules/quickshell/config/assets/icons/fluent/battery-1.svg new file mode 100644 index 0000000..caa369c --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/battery-1.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_battery_1_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/battery-2.svg b/modules/quickshell/config/assets/icons/fluent/battery-2.svg new file mode 100644 index 0000000..b939043 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/battery-2.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_battery_2_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/battery-3.svg b/modules/quickshell/config/assets/icons/fluent/battery-3.svg new file mode 100644 index 0000000..a504a1e --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/battery-3.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_battery_3_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/battery-4.svg b/modules/quickshell/config/assets/icons/fluent/battery-4.svg new file mode 100644 index 0000000..6567ddf --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/battery-4.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_battery_4_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/battery-5.svg b/modules/quickshell/config/assets/icons/fluent/battery-5.svg new file mode 100644 index 0000000..1bf4d91 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/battery-5.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_battery_5_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/battery-6.svg b/modules/quickshell/config/assets/icons/fluent/battery-6.svg new file mode 100644 index 0000000..145d9a7 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/battery-6.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_battery_6_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/battery-7.svg b/modules/quickshell/config/assets/icons/fluent/battery-7.svg new file mode 100644 index 0000000..a5197ce --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/battery-7.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_battery_7_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/battery-8.svg b/modules/quickshell/config/assets/icons/fluent/battery-8.svg new file mode 100644 index 0000000..c83bc5f --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/battery-8.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_battery_8_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/battery-9.svg b/modules/quickshell/config/assets/icons/fluent/battery-9.svg new file mode 100644 index 0000000..7f4937b --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/battery-9.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_battery_9_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/battery-charge.svg b/modules/quickshell/config/assets/icons/fluent/battery-charge.svg new file mode 100644 index 0000000..512a78b --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/battery-charge.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_battery_charge_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/battery-full.svg b/modules/quickshell/config/assets/icons/fluent/battery-full.svg new file mode 100644 index 0000000..4749fc1 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/battery-full.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_battery_full_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/battery-saver.svg b/modules/quickshell/config/assets/icons/fluent/battery-saver.svg new file mode 100644 index 0000000..397061c --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/battery-saver.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_battery_saver_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/battery-warning.svg b/modules/quickshell/config/assets/icons/fluent/battery-warning.svg new file mode 100644 index 0000000..7ccade2 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/battery-warning.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_battery_warning_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/bluetooth-connected-filled.svg b/modules/quickshell/config/assets/icons/fluent/bluetooth-connected-filled.svg new file mode 100644 index 0000000..051a7d4 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/bluetooth-connected-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/bluetooth-connected.svg b/modules/quickshell/config/assets/icons/fluent/bluetooth-connected.svg new file mode 100644 index 0000000..b491276 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/bluetooth-connected.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_bluetooth_connected_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/bluetooth-disabled-filled.svg b/modules/quickshell/config/assets/icons/fluent/bluetooth-disabled-filled.svg new file mode 100644 index 0000000..ef57973 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/bluetooth-disabled-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/bluetooth-disabled.svg b/modules/quickshell/config/assets/icons/fluent/bluetooth-disabled.svg new file mode 100644 index 0000000..d8d6f81 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/bluetooth-disabled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/bluetooth-filled.svg b/modules/quickshell/config/assets/icons/fluent/bluetooth-filled.svg new file mode 100644 index 0000000..b885061 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/bluetooth-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/bluetooth-searching.svg b/modules/quickshell/config/assets/icons/fluent/bluetooth-searching.svg new file mode 100644 index 0000000..e0d9eab --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/bluetooth-searching.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_bluetooth_searching_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/bluetooth.svg b/modules/quickshell/config/assets/icons/fluent/bluetooth.svg new file mode 100644 index 0000000..ad46359 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/bluetooth.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/caret-down-filled.svg b/modules/quickshell/config/assets/icons/fluent/caret-down-filled.svg new file mode 100644 index 0000000..85cd6d7 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/caret-down-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/caret-down.svg b/modules/quickshell/config/assets/icons/fluent/caret-down.svg new file mode 100644 index 0000000..58c4670 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/caret-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/caret-up-filled.svg b/modules/quickshell/config/assets/icons/fluent/caret-up-filled.svg new file mode 100644 index 0000000..ba67554 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/caret-up-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/caret-up.svg b/modules/quickshell/config/assets/icons/fluent/caret-up.svg new file mode 100644 index 0000000..b1edee9 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/caret-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/chevron-down-filled.svg b/modules/quickshell/config/assets/icons/fluent/chevron-down-filled.svg new file mode 100644 index 0000000..604bf98 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/chevron-down-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/chevron-down.svg b/modules/quickshell/config/assets/icons/fluent/chevron-down.svg new file mode 100644 index 0000000..8c03304 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/chevron-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/chevron-left-filled.svg b/modules/quickshell/config/assets/icons/fluent/chevron-left-filled.svg new file mode 100644 index 0000000..fd13789 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/chevron-left-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/chevron-left.svg b/modules/quickshell/config/assets/icons/fluent/chevron-left.svg new file mode 100644 index 0000000..5919892 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/chevron-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/chevron-right-filled.svg b/modules/quickshell/config/assets/icons/fluent/chevron-right-filled.svg new file mode 100644 index 0000000..f006917 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/chevron-right-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/chevron-right.svg b/modules/quickshell/config/assets/icons/fluent/chevron-right.svg new file mode 100644 index 0000000..0ac6a30 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/chevron-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/chevron-up-filled.svg b/modules/quickshell/config/assets/icons/fluent/chevron-up-filled.svg new file mode 100644 index 0000000..05d9a36 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/chevron-up-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/chevron-up.svg b/modules/quickshell/config/assets/icons/fluent/chevron-up.svg new file mode 100644 index 0000000..b3d90b8 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/chevron-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/cloudflare-filled.svg b/modules/quickshell/config/assets/icons/fluent/cloudflare-filled.svg new file mode 120000 index 0000000..d420e15 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/cloudflare-filled.svg @@ -0,0 +1 @@ +../cloudflare-dns-symbolic.svg \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/cloudflare.svg b/modules/quickshell/config/assets/icons/fluent/cloudflare.svg new file mode 120000 index 0000000..d420e15 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/cloudflare.svg @@ -0,0 +1 @@ +../cloudflare-dns-symbolic.svg \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/cut-filled.svg b/modules/quickshell/config/assets/icons/fluent/cut-filled.svg new file mode 100644 index 0000000..4d236b2 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/cut-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/cut.svg b/modules/quickshell/config/assets/icons/fluent/cut.svg new file mode 100644 index 0000000..658de7b --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/cut.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/dark-theme-filled.svg b/modules/quickshell/config/assets/icons/fluent/dark-theme-filled.svg new file mode 100644 index 0000000..a594d51 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/dark-theme-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/dark-theme.svg b/modules/quickshell/config/assets/icons/fluent/dark-theme.svg new file mode 100644 index 0000000..3dca1bc --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/dark-theme.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/desktop-speaker-filled.svg b/modules/quickshell/config/assets/icons/fluent/desktop-speaker-filled.svg new file mode 100644 index 0000000..509aedb --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/desktop-speaker-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/desktop-speaker.svg b/modules/quickshell/config/assets/icons/fluent/desktop-speaker.svg new file mode 100644 index 0000000..3228773 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/desktop-speaker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/device-eq-filled.svg b/modules/quickshell/config/assets/icons/fluent/device-eq-filled.svg new file mode 100644 index 0000000..63a51f0 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/device-eq-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/device-eq.svg b/modules/quickshell/config/assets/icons/fluent/device-eq.svg new file mode 100644 index 0000000..cb6fa01 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/device-eq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/dismiss.svg b/modules/quickshell/config/assets/icons/fluent/dismiss.svg new file mode 100644 index 0000000..3cb3656 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/dismiss.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/drink-coffee-filled.svg b/modules/quickshell/config/assets/icons/fluent/drink-coffee-filled.svg new file mode 100644 index 0000000..032a49b --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/drink-coffee-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/drink-coffee.svg b/modules/quickshell/config/assets/icons/fluent/drink-coffee.svg new file mode 100644 index 0000000..662c1b6 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/drink-coffee.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/ethernet.svg b/modules/quickshell/config/assets/icons/fluent/ethernet.svg new file mode 100644 index 0000000..904d972 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/ethernet.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_cellular_data_1_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/eyedropper-filled.svg b/modules/quickshell/config/assets/icons/fluent/eyedropper-filled.svg new file mode 100644 index 0000000..b745573 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/eyedropper-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/eyedropper.svg b/modules/quickshell/config/assets/icons/fluent/eyedropper.svg new file mode 100644 index 0000000..8ee937b --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/eyedropper.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/fire-filled.svg b/modules/quickshell/config/assets/icons/fluent/fire-filled.svg new file mode 100644 index 0000000..6731325 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/fire-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/fire.svg b/modules/quickshell/config/assets/icons/fluent/fire.svg new file mode 100644 index 0000000..83ce5a5 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/fire.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/flash-off-filled.svg b/modules/quickshell/config/assets/icons/fluent/flash-off-filled.svg new file mode 100644 index 0000000..dcdd940 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/flash-off-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/flash-off.svg b/modules/quickshell/config/assets/icons/fluent/flash-off.svg new file mode 100644 index 0000000..ce5811d --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/flash-off.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/flash-on-filled.svg b/modules/quickshell/config/assets/icons/fluent/flash-on-filled.svg new file mode 100644 index 0000000..ee7d5eb --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/flash-on-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/flash-on.svg b/modules/quickshell/config/assets/icons/fluent/flash-on.svg new file mode 100644 index 0000000..acfc20d --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/flash-on.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/games-filled.svg b/modules/quickshell/config/assets/icons/fluent/games-filled.svg new file mode 100644 index 0000000..36e4565 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/games-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/games.svg b/modules/quickshell/config/assets/icons/fluent/games.svg new file mode 100644 index 0000000..c0b16d2 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/games.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/globe-shield-filled.svg b/modules/quickshell/config/assets/icons/fluent/globe-shield-filled.svg new file mode 100644 index 0000000..b8d3c17 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/globe-shield-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/globe-shield.svg b/modules/quickshell/config/assets/icons/fluent/globe-shield.svg new file mode 100644 index 0000000..054ab46 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/globe-shield.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/headphones-filled.svg b/modules/quickshell/config/assets/icons/fluent/headphones-filled.svg new file mode 100644 index 0000000..2568234 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/headphones-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/headphones.svg b/modules/quickshell/config/assets/icons/fluent/headphones.svg new file mode 100644 index 0000000..d4ed6c4 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/headphones.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/keyboard-dock-filled.svg b/modules/quickshell/config/assets/icons/fluent/keyboard-dock-filled.svg new file mode 100644 index 0000000..cd0b52a --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/keyboard-dock-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/keyboard-dock.svg b/modules/quickshell/config/assets/icons/fluent/keyboard-dock.svg new file mode 100644 index 0000000..23841da --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/keyboard-dock.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/keyboard-filled.svg b/modules/quickshell/config/assets/icons/fluent/keyboard-filled.svg new file mode 100644 index 0000000..88d3431 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/keyboard-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/keyboard.svg b/modules/quickshell/config/assets/icons/fluent/keyboard.svg new file mode 100644 index 0000000..bef233d --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/keyboard.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/leaf-two-filled.svg b/modules/quickshell/config/assets/icons/fluent/leaf-two-filled.svg new file mode 100644 index 0000000..ac88fdd --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/leaf-two-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/leaf-two.svg b/modules/quickshell/config/assets/icons/fluent/leaf-two.svg new file mode 100644 index 0000000..0555362 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/leaf-two.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/lock-closed-filled.svg b/modules/quickshell/config/assets/icons/fluent/lock-closed-filled.svg new file mode 100644 index 0000000..31b51d0 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/lock-closed-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/lock-closed.svg b/modules/quickshell/config/assets/icons/fluent/lock-closed.svg new file mode 100644 index 0000000..93966ef --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/lock-closed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/lock-open-filled.svg b/modules/quickshell/config/assets/icons/fluent/lock-open-filled.svg new file mode 100644 index 0000000..f7ebddd --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/lock-open-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/lock-open.svg b/modules/quickshell/config/assets/icons/fluent/lock-open.svg new file mode 100644 index 0000000..a1d5a95 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/lock-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/mic-filled.svg b/modules/quickshell/config/assets/icons/fluent/mic-filled.svg new file mode 100644 index 0000000..860a813 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/mic-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/mic-off-filled.svg b/modules/quickshell/config/assets/icons/fluent/mic-off-filled.svg new file mode 100644 index 0000000..6e4156d --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/mic-off-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/mic-off.svg b/modules/quickshell/config/assets/icons/fluent/mic-off.svg new file mode 100644 index 0000000..6a01b5d --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/mic-off.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/mic-on.svg b/modules/quickshell/config/assets/icons/fluent/mic-on.svg new file mode 100644 index 0000000..3f680d2 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/mic-on.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_mic_on_48_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/mic.svg b/modules/quickshell/config/assets/icons/fluent/mic.svg new file mode 100644 index 0000000..c5bbfa4 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/mic.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/more-horizontal-filled.svg b/modules/quickshell/config/assets/icons/fluent/more-horizontal-filled.svg new file mode 100644 index 0000000..5b5a33c --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/more-horizontal-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/more-horizontal.svg b/modules/quickshell/config/assets/icons/fluent/more-horizontal.svg new file mode 100644 index 0000000..57e2ee1 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/more-horizontal.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/music-note-2-filled.svg b/modules/quickshell/config/assets/icons/fluent/music-note-2-filled.svg new file mode 100644 index 0000000..90f009d --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/music-note-2-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/music-note-2.svg b/modules/quickshell/config/assets/icons/fluent/music-note-2.svg new file mode 100644 index 0000000..09367b0 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/music-note-2.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/next-filled.svg b/modules/quickshell/config/assets/icons/fluent/next-filled.svg new file mode 100644 index 0000000..d374196 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/next-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/next.svg b/modules/quickshell/config/assets/icons/fluent/next.svg new file mode 100644 index 0000000..26bdcb0 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/options-filled.svg b/modules/quickshell/config/assets/icons/fluent/options-filled.svg new file mode 100644 index 0000000..7f2b5b1 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/options-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/options.svg b/modules/quickshell/config/assets/icons/fluent/options.svg new file mode 100644 index 0000000..3e11156 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/options.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/pause-filled.svg b/modules/quickshell/config/assets/icons/fluent/pause-filled.svg new file mode 100644 index 0000000..595435f --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/pause-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/pause.svg b/modules/quickshell/config/assets/icons/fluent/pause.svg new file mode 100644 index 0000000..a334eb6 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/pause.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/phone-filled.svg b/modules/quickshell/config/assets/icons/fluent/phone-filled.svg new file mode 100644 index 0000000..819e1c1 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/phone-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/phone.svg b/modules/quickshell/config/assets/icons/fluent/phone.svg new file mode 100644 index 0000000..6bb5ffb --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/phone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/pin-off.svg b/modules/quickshell/config/assets/icons/fluent/pin-off.svg new file mode 100644 index 0000000..046a27c --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/pin-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/pin.svg b/modules/quickshell/config/assets/icons/fluent/pin.svg new file mode 100644 index 0000000..b317903 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/pin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/play-filled.svg b/modules/quickshell/config/assets/icons/fluent/play-filled.svg new file mode 100644 index 0000000..ae2a123 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/play-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/play.svg b/modules/quickshell/config/assets/icons/fluent/play.svg new file mode 100644 index 0000000..777cf89 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/play.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/previous-filled.svg b/modules/quickshell/config/assets/icons/fluent/previous-filled.svg new file mode 100644 index 0000000..b994af7 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/previous-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/previous.svg b/modules/quickshell/config/assets/icons/fluent/previous.svg new file mode 100644 index 0000000..bb61b77 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/previous.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/settings-cog-multiple-filled.svg b/modules/quickshell/config/assets/icons/fluent/settings-cog-multiple-filled.svg new file mode 100644 index 0000000..cfd8a5f --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/settings-cog-multiple-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/settings-cog-multiple.svg b/modules/quickshell/config/assets/icons/fluent/settings-cog-multiple.svg new file mode 100644 index 0000000..52187ee --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/settings-cog-multiple.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/settings.svg b/modules/quickshell/config/assets/icons/fluent/settings.svg new file mode 100644 index 0000000..f1e5688 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/speaker-0.svg b/modules/quickshell/config/assets/icons/fluent/speaker-0.svg new file mode 100644 index 0000000..0ff116a --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/speaker-0.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_speaker_0_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/speaker-1.svg b/modules/quickshell/config/assets/icons/fluent/speaker-1.svg new file mode 100644 index 0000000..eff9b6d --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/speaker-1.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_speaker_1_24_regular + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/speaker-2-filled.svg b/modules/quickshell/config/assets/icons/fluent/speaker-2-filled.svg new file mode 100644 index 0000000..0fc3856 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/speaker-2-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/speaker-mute-filled.svg b/modules/quickshell/config/assets/icons/fluent/speaker-mute-filled.svg new file mode 100644 index 0000000..50c84c3 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/speaker-mute-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/speaker-mute.svg b/modules/quickshell/config/assets/icons/fluent/speaker-mute.svg new file mode 100644 index 0000000..891d21e --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/speaker-mute.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/speaker-none.svg b/modules/quickshell/config/assets/icons/fluent/speaker-none.svg new file mode 100644 index 0000000..364af84 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/speaker-none.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/speaker-off.svg b/modules/quickshell/config/assets/icons/fluent/speaker-off.svg new file mode 100644 index 0000000..5fa19e4 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/speaker-off.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/speaker-settings.svg b/modules/quickshell/config/assets/icons/fluent/speaker-settings.svg new file mode 100644 index 0000000..072abd8 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/speaker-settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/speaker.svg b/modules/quickshell/config/assets/icons/fluent/speaker.svg new file mode 100644 index 0000000..477b047 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/speaker.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/start-here-pressed.svg b/modules/quickshell/config/assets/icons/fluent/start-here-pressed.svg new file mode 100644 index 0000000..e6b950e --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/start-here-pressed.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/quickshell/config/assets/icons/fluent/start-here.svg b/modules/quickshell/config/assets/icons/fluent/start-here.svg new file mode 100644 index 0000000..708d5a7 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/start-here.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/quickshell/config/assets/icons/fluent/stop-filled.svg b/modules/quickshell/config/assets/icons/fluent/stop-filled.svg new file mode 100644 index 0000000..ef8a912 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/stop-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/stop.svg b/modules/quickshell/config/assets/icons/fluent/stop.svg new file mode 100644 index 0000000..26eb3ea --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/stop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/subtract-filled.svg b/modules/quickshell/config/assets/icons/fluent/subtract-filled.svg new file mode 100644 index 0000000..128a7f9 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/subtract-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/subtract.svg b/modules/quickshell/config/assets/icons/fluent/subtract.svg new file mode 100644 index 0000000..3e1e247 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/subtract.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/system-search-checked-dark.svg b/modules/quickshell/config/assets/icons/fluent/system-search-checked-dark.svg new file mode 100644 index 0000000..af58d93 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/system-search-checked-dark.svg @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/quickshell/config/assets/icons/fluent/system-search-checked-light.svg b/modules/quickshell/config/assets/icons/fluent/system-search-checked-light.svg new file mode 100644 index 0000000..8d0e69f --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/system-search-checked-light.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + diff --git a/modules/quickshell/config/assets/icons/fluent/system-search-dark.svg b/modules/quickshell/config/assets/icons/fluent/system-search-dark.svg new file mode 100644 index 0000000..8eb990c --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/system-search-dark.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/quickshell/config/assets/icons/fluent/system-search-light.svg b/modules/quickshell/config/assets/icons/fluent/system-search-light.svg new file mode 100644 index 0000000..b22f9c0 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/system-search-light.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + diff --git a/modules/quickshell/config/assets/icons/fluent/task-view-dark.svg b/modules/quickshell/config/assets/icons/fluent/task-view-dark.svg new file mode 100644 index 0000000..d38c927 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/task-view-dark.svg @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/quickshell/config/assets/icons/fluent/task-view-light.svg b/modules/quickshell/config/assets/icons/fluent/task-view-light.svg new file mode 100644 index 0000000..1f37c4a --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/task-view-light.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/quickshell/config/assets/icons/fluent/task-view-pressed-dark.svg b/modules/quickshell/config/assets/icons/fluent/task-view-pressed-dark.svg new file mode 100644 index 0000000..a7e1400 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/task-view-pressed-dark.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/quickshell/config/assets/icons/fluent/task-view-pressed-light.svg b/modules/quickshell/config/assets/icons/fluent/task-view-pressed-light.svg new file mode 100644 index 0000000..b1785db --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/task-view-pressed-light.svg @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/quickshell/config/assets/icons/fluent/temperature-filled.svg b/modules/quickshell/config/assets/icons/fluent/temperature-filled.svg new file mode 100644 index 0000000..5c63cf5 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/temperature-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/temperature.svg b/modules/quickshell/config/assets/icons/fluent/temperature.svg new file mode 100644 index 0000000..bc24e92 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/temperature.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/weather-moon-filled.svg b/modules/quickshell/config/assets/icons/fluent/weather-moon-filled.svg new file mode 100644 index 0000000..4a98456 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/weather-moon-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/weather-moon-off-filled.svg b/modules/quickshell/config/assets/icons/fluent/weather-moon-off-filled.svg new file mode 100644 index 0000000..e79039b --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/weather-moon-off-filled.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/weather-moon-off.svg b/modules/quickshell/config/assets/icons/fluent/weather-moon-off.svg new file mode 100644 index 0000000..6bda0d9 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/weather-moon-off.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/weather-moon.svg b/modules/quickshell/config/assets/icons/fluent/weather-moon.svg new file mode 100644 index 0000000..bb97295 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/weather-moon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/weather-sunny-filled.svg b/modules/quickshell/config/assets/icons/fluent/weather-sunny-filled.svg new file mode 100644 index 0000000..d4873dc --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/weather-sunny-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/weather-sunny.svg b/modules/quickshell/config/assets/icons/fluent/weather-sunny.svg new file mode 100644 index 0000000..f67748a --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/weather-sunny.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/widgets.svg b/modules/quickshell/config/assets/icons/fluent/widgets.svg new file mode 100644 index 0000000..0989033 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/widgets.svg @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + diff --git a/modules/quickshell/config/assets/icons/fluent/wifi-1-filled.svg b/modules/quickshell/config/assets/icons/fluent/wifi-1-filled.svg new file mode 100644 index 0000000..c82c5c2 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/wifi-1-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/wifi-1.svg b/modules/quickshell/config/assets/icons/fluent/wifi-1.svg new file mode 100644 index 0000000..a1ff296 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/wifi-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/wifi-2-filled.svg b/modules/quickshell/config/assets/icons/fluent/wifi-2-filled.svg new file mode 100644 index 0000000..35f8a35 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/wifi-2-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/wifi-2.svg b/modules/quickshell/config/assets/icons/fluent/wifi-2.svg new file mode 100644 index 0000000..07b1bfc --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/wifi-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/wifi-3-filled.svg b/modules/quickshell/config/assets/icons/fluent/wifi-3-filled.svg new file mode 100644 index 0000000..6d51746 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/wifi-3-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/wifi-3.svg b/modules/quickshell/config/assets/icons/fluent/wifi-3.svg new file mode 100644 index 0000000..1a6f575 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/wifi-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/wifi-4-filled.svg b/modules/quickshell/config/assets/icons/fluent/wifi-4-filled.svg new file mode 100644 index 0000000..82153a7 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/wifi-4-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/wifi-4.svg b/modules/quickshell/config/assets/icons/fluent/wifi-4.svg new file mode 100644 index 0000000..58073a4 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/wifi-4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/wifi-lock-filled.svg b/modules/quickshell/config/assets/icons/fluent/wifi-lock-filled.svg new file mode 100644 index 0000000..c36df04 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/wifi-lock-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/wifi-lock.svg b/modules/quickshell/config/assets/icons/fluent/wifi-lock.svg new file mode 100644 index 0000000..5d9a7e6 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/wifi-lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/wifi-off-filled.svg b/modules/quickshell/config/assets/icons/fluent/wifi-off-filled.svg new file mode 100644 index 0000000..3df4ac1 --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/wifi-off-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/wifi-off.svg b/modules/quickshell/config/assets/icons/fluent/wifi-off.svg new file mode 100644 index 0000000..3c247ab --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/wifi-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/wifi-warning-filled.svg b/modules/quickshell/config/assets/icons/fluent/wifi-warning-filled.svg new file mode 100644 index 0000000..a8f0c9b --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/wifi-warning-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/fluent/wifi-warning.svg b/modules/quickshell/config/assets/icons/fluent/wifi-warning.svg new file mode 100644 index 0000000..d573ddd --- /dev/null +++ b/modules/quickshell/config/assets/icons/fluent/wifi-warning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/quickshell/config/assets/icons/github-symbolic.svg b/modules/quickshell/config/assets/icons/github-symbolic.svg new file mode 100644 index 0000000..c1c9f19 --- /dev/null +++ b/modules/quickshell/config/assets/icons/github-symbolic.svg @@ -0,0 +1,40 @@ + + + + + + diff --git a/modules/quickshell/config/assets/icons/linux-symbolic.svg b/modules/quickshell/config/assets/icons/linux-symbolic.svg new file mode 100644 index 0000000..63f9c7e --- /dev/null +++ b/modules/quickshell/config/assets/icons/linux-symbolic.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/quickshell/config/assets/icons/microsoft-symbolic.svg b/modules/quickshell/config/assets/icons/microsoft-symbolic.svg new file mode 100644 index 0000000..b90cfc6 --- /dev/null +++ b/modules/quickshell/config/assets/icons/microsoft-symbolic.svg @@ -0,0 +1,54 @@ + + + + + + + + + diff --git a/modules/quickshell/config/assets/icons/nixos-symbolic.svg b/modules/quickshell/config/assets/icons/nixos-symbolic.svg new file mode 100644 index 0000000..b697b0d --- /dev/null +++ b/modules/quickshell/config/assets/icons/nixos-symbolic.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + diff --git a/modules/quickshell/config/killDialog.qml b/modules/quickshell/config/killDialog.qml new file mode 100644 index 0000000..796045f --- /dev/null +++ b/modules/quickshell/config/killDialog.qml @@ -0,0 +1,198 @@ +//@ pragma UseQApplication +//@ pragma Env QS_NO_RELOAD_POPUP=1 +//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic +//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 + +// Adjust this to make the app smaller or larger +//@ pragma Env QT_SCALE_FACTOR=1 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +ApplicationWindow { + id: root + property int conflictCount: 0 + onConflictCountChanged: { + if (conflictCount === 0) { + root.close(); + } + } + + property real contentPadding: 8 + visible: true + onClosing: { + Qt.quit() + } + title: Translation.tr("Shell conflicts killer") + + Component.onCompleted: { + Config.readWriteDelay = 0; + Config.blockWrites = true; + MaterialThemeLoader.reapplyTheme(); + } + + minimumWidth: 400 + minimumHeight: 300 + maximumWidth: 400 + maximumHeight: 300 + width: 400 + height: 300 + color: Appearance.m3colors.m3background + + component ConflictingProgramGroup: ColumnLayout { + id: conflictGroup + required property list programs + required property string description + visible: false + onVisibleChanged: { + conflictCount += visible ? 1 : -1 + } + + signal alwaysSelected() + + Process { + running: true + command: ["pidof", ...conflictGroup.programs] + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + conflictGroup.visible = true + } + } + } + + StyledText { + text: conflictGroup.programs.join(", ") + font.pixelSize: Appearance.font.pixelSize.normal + } + StyledText { + font { + pixelSize: Appearance.font.pixelSize.smaller + italic: true + } + text: conflictGroup.description + color: Appearance.colors.colSubtext + } + RowLayout { + Layout.alignment: Qt.AlignRight + + RippleButton { + colBackground: Appearance.colors.colLayer2 + contentItem: StyledText { + text: Translation.tr("Always") + } + onClicked: { + Quickshell.execDetached(["killall", ...conflictGroup.programs]) + conflictGroup.alwaysSelected() + conflictGroup.visible = false + } + } + RippleButton { + colBackground: Appearance.colors.colLayer2 + contentItem: StyledText { + text: Translation.tr("Yes") + } + onClicked: { + Quickshell.execDetached(["killall", ...conflictGroup.programs]) + conflictGroup.visible = false + } + } + RippleButton { + colBackground: Appearance.colors.colLayer2 + contentItem: StyledText { + text: Translation.tr("No") + } + onClicked: conflictGroup.visible = false + } + } + } + + ColumnLayout { + anchors { + fill: parent + margins: contentPadding + } + + Item { + // Titlebar + visible: Config.options?.windows.showTitlebar + Layout.fillWidth: true + implicitHeight: Math.max(welcomeText.implicitHeight, windowControlsRow.implicitHeight) + StyledText { + id: welcomeText + anchors { + left: Config.options.windows.centerTitle ? undefined : parent.left + horizontalCenter: Config.options.windows.centerTitle ? parent.horizontalCenter : undefined + verticalCenter: parent.verticalCenter + leftMargin: 12 + } + color: Appearance.colors.colOnLayer0 + text: Translation.tr("Kill conflicting programs?") + font { + family: Appearance.font.family.title + pixelSize: Appearance.font.pixelSize.title + variableAxes: Appearance.font.variableAxes.title + } + } + RowLayout { // Window controls row + id: windowControlsRow + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + RippleButton { + buttonRadius: Appearance.rounding.full + implicitWidth: 35 + implicitHeight: 35 + onClicked: root.close() + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "close" + iconSize: 20 + } + } + } + } + Rectangle { + // Content container + color: Appearance.m3colors.m3surfaceContainerLow + radius: Appearance.rounding.windowRounding - root.contentPadding + implicitHeight: contentColumn.implicitHeight + implicitWidth: contentColumn.implicitWidth + Layout.fillWidth: true + Layout.fillHeight: true + + ColumnLayout { + id: contentColumn + anchors.fill: parent + spacing: 12 + + ConflictingProgramGroup { + id: kded6Group + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: false + programs: ["kded6"] + description: Translation.tr("Conflicts with the shell's system tray implementation") + onAlwaysSelected: Config.options.conflictKiller.autoKillTrays = true + } + + ConflictingProgramGroup { + id: notificationDaemons + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: false + programs: ["mako", "dunst"] + description: Translation.tr("Conflicts with the shell's notification implementation") + onAlwaysSelected: Config.options.conflictKiller.autoKillNotificationDaemons = true + } + + } + } + } +} diff --git a/modules/quickshell/config/modules/common/Appearance.qml b/modules/quickshell/config/modules/common/Appearance.qml new file mode 100644 index 0000000..9c50450 --- /dev/null +++ b/modules/quickshell/config/modules/common/Appearance.qml @@ -0,0 +1,393 @@ +import QtQuick +import Quickshell +import qs.modules.common.functions +pragma Singleton +pragma ComponentBehavior: Bound + +Singleton { + id: root + property QtObject m3colors + property QtObject animation + property QtObject animationCurves + property QtObject colors + property QtObject rounding + property QtObject font + property QtObject sizes + property string syntaxHighlightingTheme + + // Transparency. The quadratic functions were derived from analysis of hand-picked transparency values. + ColorQuantizer { + id: wallColorQuant + property string wallpaperPath: Config.options.background.wallpaperPath + property bool wallpaperIsVideo: wallpaperPath.endsWith(".mp4") || wallpaperPath.endsWith(".webm") || wallpaperPath.endsWith(".mkv") || wallpaperPath.endsWith(".avi") || wallpaperPath.endsWith(".mov") + source: Qt.resolvedUrl(wallpaperIsVideo ? Config.options.background.thumbnailPath : Config.options.background.wallpaperPath) + depth: 0 // 2^0 = 1 color + rescaleSize: 10 + } + property real wallpaperVibrancy: (wallColorQuant.colors[0]?.hslSaturation + wallColorQuant.colors[0]?.hslLightness) / 2 + property real autoBackgroundTransparency: { // y = 0.5768x^2 - 0.759x + 0.2896 + let x = wallpaperVibrancy + let y = 0.5768 * (x * x) - 0.759 * (x) + 0.2896 + return Math.max(0, Math.min(0.22, y)) + } + property real autoContentTransparency: { // y = -10.1734x^2 + 3.4457x + 0.1872 + let x = autoBackgroundTransparency + let y = -10.1734 * (x * x) + 3.4457 * (x) + 0.1872 + return Math.max(0, Math.min(0.6, y)) + } + property real backgroundTransparency: Config?.options.appearance.transparency.enable ? Config?.options.appearance.transparency.automatic ? autoBackgroundTransparency : Config?.options.appearance.transparency.backgroundTransparency : 0 + property real contentTransparency: Config?.options.appearance.transparency.enable ? Config?.options.appearance.transparency.automatic ? autoContentTransparency : Config?.options.appearance.transparency.contentTransparency : 0 + + m3colors: QtObject { + property bool darkmode: true + property bool transparent: false + property color m3background: "#141313" + property color m3onBackground: "#e6e1e1" + property color m3surface: "#141313" + property color m3surfaceDim: "#141313" + property color m3surfaceBright: "#3a3939" + property color m3surfaceContainerLowest: "#0f0e0e" + property color m3surfaceContainerLow: "#1c1b1c" + property color m3surfaceContainer: "#201f20" + property color m3surfaceContainerHigh: "#2b2a2a" + property color m3surfaceContainerHighest: "#363435" + property color m3onSurface: "#e6e1e1" + property color m3surfaceVariant: "#49464a" + property color m3onSurfaceVariant: "#cbc5ca" + property color m3inverseSurface: "#e6e1e1" + property color m3inverseOnSurface: "#313030" + property color m3outline: "#948f94" + property color m3outlineVariant: "#49464a" + property color m3shadow: "#000000" + property color m3scrim: "#000000" + property color m3surfaceTint: "#cbc4cb" + property color m3primary: "#cbc4cb" + property color m3onPrimary: "#322f34" + property color m3primaryContainer: "#2d2a2f" + property color m3onPrimaryContainer: "#bcb6bc" + property color m3inversePrimary: "#615d63" + property color m3secondary: "#cac5c8" + property color m3onSecondary: "#323032" + property color m3secondaryContainer: "#4d4b4d" + property color m3onSecondaryContainer: "#ece6e9" + property color m3tertiary: "#d1c3c6" + property color m3onTertiary: "#372e30" + property color m3tertiaryContainer: "#31292b" + property color m3onTertiaryContainer: "#c1b4b7" + property color m3error: "#ffb4ab" + property color m3onError: "#690005" + property color m3errorContainer: "#93000a" + property color m3onErrorContainer: "#ffdad6" + property color m3primaryFixed: "#e7e0e7" + property color m3primaryFixedDim: "#cbc4cb" + property color m3onPrimaryFixed: "#1d1b1f" + property color m3onPrimaryFixedVariant: "#49454b" + property color m3secondaryFixed: "#e6e1e4" + property color m3secondaryFixedDim: "#cac5c8" + property color m3onSecondaryFixed: "#1d1b1d" + property color m3onSecondaryFixedVariant: "#484648" + property color m3tertiaryFixed: "#eddfe1" + property color m3tertiaryFixedDim: "#d1c3c6" + property color m3onTertiaryFixed: "#211a1c" + property color m3onTertiaryFixedVariant: "#4e4447" + property color m3success: "#B5CCBA" + property color m3onSuccess: "#213528" + property color m3successContainer: "#374B3E" + property color m3onSuccessContainer: "#D1E9D6" + property color term0: "#EDE4E4" + property color term1: "#B52755" + property color term2: "#A97363" + property color term3: "#AF535D" + property color term4: "#A67F7C" + property color term5: "#B2416B" + property color term6: "#8D76AD" + property color term7: "#272022" + property color term8: "#0E0D0D" + property color term9: "#B52755" + property color term10: "#A97363" + property color term11: "#AF535D" + property color term12: "#A67F7C" + property color term13: "#B2416B" + property color term14: "#8D76AD" + property color term15: "#221A1A" + } + + colors: QtObject { + property color colSubtext: m3colors.m3outline + property color colLayer0: ColorUtils.mix(ColorUtils.transparentize(m3colors.m3background, root.backgroundTransparency), m3colors.m3primary, Config.options.appearance.extraBackgroundTint ? 0.99 : 1) + property color colOnLayer0: m3colors.m3onBackground + property color colLayer0Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer0, colOnLayer0, 0.9, root.contentTransparency)) + property color colLayer0Active: ColorUtils.transparentize(ColorUtils.mix(colLayer0, colOnLayer0, 0.8, root.contentTransparency)) + property color colLayer0Border: ColorUtils.mix(root.m3colors.m3outlineVariant, colLayer0, 0.4) + property color colLayer1: ColorUtils.transparentize(m3colors.m3surfaceContainerLow, root.contentTransparency); + property color colOnLayer1: m3colors.m3onSurfaceVariant; + property color colOnLayer1Inactive: ColorUtils.mix(colOnLayer1, colLayer1, 0.45); + property color colLayer2: ColorUtils.transparentize(m3colors.m3surfaceContainer, root.contentTransparency) + property color colOnLayer2: m3colors.m3onSurface; + property color colOnLayer2Disabled: ColorUtils.mix(colOnLayer2, m3colors.m3background, 0.4); + property color colLayer1Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer1, colOnLayer1, 0.92), root.contentTransparency) + property color colLayer1Active: ColorUtils.transparentize(ColorUtils.mix(colLayer1, colOnLayer1, 0.85), root.contentTransparency); + property color colLayer2Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer2, colOnLayer2, 0.90), root.contentTransparency) + property color colLayer2Active: ColorUtils.transparentize(ColorUtils.mix(colLayer2, colOnLayer2, 0.80), root.contentTransparency); + property color colLayer2Disabled: ColorUtils.transparentize(ColorUtils.mix(colLayer2, m3colors.m3background, 0.8), root.contentTransparency); + property color colLayer3: ColorUtils.transparentize(m3colors.m3surfaceContainerHigh, root.contentTransparency) + property color colOnLayer3: m3colors.m3onSurface; + property color colLayer3Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer3, colOnLayer3, 0.90), root.contentTransparency) + property color colLayer3Active: ColorUtils.transparentize(ColorUtils.mix(colLayer3, colOnLayer3, 0.80), root.contentTransparency); + property color colLayer4: ColorUtils.transparentize(m3colors.m3surfaceContainerHighest, root.contentTransparency) + property color colOnLayer4: m3colors.m3onSurface; + property color colLayer4Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer4, colOnLayer4, 0.90), root.contentTransparency) + property color colLayer4Active: ColorUtils.transparentize(ColorUtils.mix(colLayer4, colOnLayer4, 0.80), root.contentTransparency); + property color colPrimary: m3colors.m3primary + property color colOnPrimary: m3colors.m3onPrimary + property color colPrimaryHover: ColorUtils.mix(colors.colPrimary, colLayer1Hover, 0.87) + property color colPrimaryActive: ColorUtils.mix(colors.colPrimary, colLayer1Active, 0.7) + property color colPrimaryContainer: m3colors.m3primaryContainer + property color colPrimaryContainerHover: ColorUtils.mix(colors.colPrimaryContainer, colors.colOnPrimaryContainer, 0.9) + property color colPrimaryContainerActive: ColorUtils.mix(colors.colPrimaryContainer, colors.colOnPrimaryContainer, 0.8) + property color colOnPrimaryContainer: m3colors.m3onPrimaryContainer + property color colSecondary: m3colors.m3secondary + property color colOnSecondary: m3colors.m3onSecondary + property color colSecondaryHover: ColorUtils.mix(m3colors.m3secondary, colLayer1Hover, 0.85) + property color colSecondaryActive: ColorUtils.mix(m3colors.m3secondary, colLayer1Active, 0.4) + property color colSecondaryContainer: m3colors.m3secondaryContainer + property color colSecondaryContainerHover: ColorUtils.mix(m3colors.m3secondaryContainer, m3colors.m3onSecondaryContainer, 0.90) + property color colSecondaryContainerActive: ColorUtils.mix(m3colors.m3secondaryContainer, m3colors.m3onSecondaryContainer, 0.54) + property color colTertiary: m3colors.m3tertiary + property color colTertiaryHover: ColorUtils.mix(m3colors.m3tertiary, colLayer1Hover, 0.85) + property color colTertiaryActive: ColorUtils.mix(m3colors.m3tertiary, colLayer1Active, 0.4) + property color colTertiaryContainer: m3colors.m3tertiaryContainer + property color colTertiaryContainerHover: ColorUtils.mix(m3colors.m3tertiaryContainer, m3colors.m3onTertiaryContainer, 0.90) + property color colTertiaryContainerActive: ColorUtils.mix(m3colors.m3tertiaryContainer, colLayer1Active, 0.54) + property color colOnTertiary: m3colors.m3onTertiary + property color colOnTertiaryContainer: m3colors.m3onTertiaryContainer + property color colOnSecondaryContainer: m3colors.m3onSecondaryContainer + property color colSurfaceContainerLow: ColorUtils.transparentize(m3colors.m3surfaceContainerLow, root.contentTransparency) + property color colSurfaceContainer: ColorUtils.transparentize(m3colors.m3surfaceContainer, root.contentTransparency) + property color colBackgroundSurfaceContainer: ColorUtils.transparentize(m3colors.m3surfaceContainer, root.backgroundTransparency) + property color colSurfaceContainerHigh: ColorUtils.transparentize(m3colors.m3surfaceContainerHigh, root.contentTransparency) + property color colSurfaceContainerHighest: ColorUtils.transparentize(m3colors.m3surfaceContainerHighest, root.contentTransparency) + property color colSurfaceContainerHighestHover: ColorUtils.mix(m3colors.m3surfaceContainerHighest, m3colors.m3onSurface, 0.95) + property color colSurfaceContainerHighestActive: ColorUtils.mix(m3colors.m3surfaceContainerHighest, m3colors.m3onSurface, 0.85) + property color colOnSurface: m3colors.m3onSurface + property color colOnSurfaceVariant: m3colors.m3onSurfaceVariant + property color colTooltip: m3colors.m3inverseSurface + property color colOnTooltip: m3colors.m3inverseOnSurface + property color colScrim: ColorUtils.transparentize(m3colors.m3scrim, 0.5) + property color colShadow: ColorUtils.transparentize(m3colors.m3shadow, 0.7) + property color colOutline: m3colors.m3outline + property color colOutlineVariant: m3colors.m3outlineVariant + property color colError: m3colors.m3error + property color colErrorHover: ColorUtils.mix(m3colors.m3error, colLayer1Hover, 0.85) + property color colErrorActive: ColorUtils.mix(m3colors.m3error, colLayer1Active, 0.7) + property color colOnError: m3colors.m3onError + property color colErrorContainer: m3colors.m3errorContainer + property color colErrorContainerHover: ColorUtils.mix(m3colors.m3errorContainer, m3colors.m3onErrorContainer, 0.90) + property color colErrorContainerActive: ColorUtils.mix(m3colors.m3errorContainer, m3colors.m3onErrorContainer, 0.70) + property color colOnErrorContainer: m3colors.m3onErrorContainer + } + + rounding: QtObject { + property int unsharpen: 2 + property int unsharpenmore: 6 + property int verysmall: 8 + property int small: 12 + property int normal: 17 + property int large: 23 + property int verylarge: 30 + property int full: 9999 + property int screenRounding: large + property int windowRounding: 18 + } + + font: QtObject { + property QtObject family: QtObject { + property string main: Config.options.appearance.fonts.main + property string numbers: Config.options.appearance.fonts.numbers + property string title: Config.options.appearance.fonts.title + property string iconMaterial: "Material Symbols Rounded" + property string iconNerd: Config.options.appearance.fonts.iconNerd + property string monospace: Config.options.appearance.fonts.monospace + property string reading: Config.options.appearance.fonts.reading + property string expressive: Config.options.appearance.fonts.expressive + } + property QtObject variableAxes: QtObject { + property var main: ({ + "wght": 450, + "wdth": 100, + }) + property var numbers: ({ + "wght": 450, + }) + property var title: ({ // Slightly bold weight for title + "wght": 550, // Weight (Lowered to compensate for increased grade) + }) + } + property QtObject pixelSize: QtObject { + property int smallest: 10 + property int smaller: 12 + property int smallie: 13 + property int small: 15 + property int normal: 16 + property int large: 17 + property int larger: 19 + property int huge: 22 + property int hugeass: 23 + property int title: huge + } + } + + animationCurves: QtObject { + readonly property list expressiveFastSpatial: [0.42, 1.67, 0.21, 0.90, 1, 1] // Default, 350ms + readonly property list expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1.00, 1, 1] // Default, 500ms + readonly property list expressiveSlowSpatial: [0.39, 1.29, 0.35, 0.98, 1, 1] // Default, 650ms + readonly property list expressiveEffects: [0.34, 0.80, 0.34, 1.00, 1, 1] // Default, 200ms + readonly property list emphasized: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1] + readonly property list emphasizedFirstHalf: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82] + readonly property list emphasizedLastHalf: [5 / 24, 0.82, 0.25, 1, 1, 1] + readonly property list emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1] + readonly property list emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1] + readonly property list standard: [0.2, 0, 0, 1, 1, 1] + readonly property list standardAccel: [0.3, 0, 1, 1, 1, 1] + readonly property list standardDecel: [0, 0, 0, 1, 1, 1] + readonly property real expressiveFastSpatialDuration: 350 + readonly property real expressiveDefaultSpatialDuration: 500 + readonly property real expressiveSlowSpatialDuration: 650 + readonly property real expressiveEffectsDuration: 200 + } + + animation: QtObject { + property QtObject elementMove: QtObject { + property int duration: animationCurves.expressiveDefaultSpatialDuration + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.expressiveDefaultSpatial + property int velocity: 650 + property Component numberAnimation: Component { + NumberAnimation { + duration: root.animation.elementMove.duration + easing.type: root.animation.elementMove.type + easing.bezierCurve: root.animation.elementMove.bezierCurve + } + } + } + + property QtObject elementMoveEnter: QtObject { + property int duration: 400 + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.emphasizedDecel + property int velocity: 650 + property Component numberAnimation: Component { + NumberAnimation { + alwaysRunToEnd: true + duration: root.animation.elementMoveEnter.duration + easing.type: root.animation.elementMoveEnter.type + easing.bezierCurve: root.animation.elementMoveEnter.bezierCurve + } + } + } + + property QtObject elementMoveExit: QtObject { + property int duration: 200 + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.emphasizedAccel + property int velocity: 650 + property Component numberAnimation: Component { + NumberAnimation { + alwaysRunToEnd: true + duration: root.animation.elementMoveExit.duration + easing.type: root.animation.elementMoveExit.type + easing.bezierCurve: root.animation.elementMoveExit.bezierCurve + } + } + } + + property QtObject elementMoveFast: QtObject { + property int duration: animationCurves.expressiveEffectsDuration + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.expressiveEffects + property int velocity: 850 + property Component colorAnimation: Component { ColorAnimation { + duration: root.animation.elementMoveFast.duration + easing.type: root.animation.elementMoveFast.type + easing.bezierCurve: root.animation.elementMoveFast.bezierCurve + }} + property Component numberAnimation: Component { NumberAnimation { + alwaysRunToEnd: true + duration: root.animation.elementMoveFast.duration + easing.type: root.animation.elementMoveFast.type + easing.bezierCurve: root.animation.elementMoveFast.bezierCurve + }} + } + + property QtObject elementResize: QtObject { + property int duration: 300 + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.emphasized + property int velocity: 650 + property Component numberAnimation: Component { + NumberAnimation { + alwaysRunToEnd: true + duration: root.animation.elementResize.duration + easing.type: root.animation.elementResize.type + easing.bezierCurve: root.animation.elementResize.bezierCurve + } + } + } + + property QtObject clickBounce: QtObject { + property int duration: 400 + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.expressiveDefaultSpatial + property int velocity: 850 + property Component numberAnimation: Component { NumberAnimation { + alwaysRunToEnd: true + duration: root.animation.clickBounce.duration + easing.type: root.animation.clickBounce.type + easing.bezierCurve: root.animation.clickBounce.bezierCurve + }} + } + + property QtObject scroll: QtObject { + property int duration: 200 + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.standardDecel + } + + property QtObject menuDecel: QtObject { + property int duration: 350 + property int type: Easing.OutExpo + } + } + + sizes: QtObject { + property real baseBarHeight: 40 + property real barHeight: Config.options.bar.cornerStyle === 1 ? + (baseBarHeight + root.sizes.hyprlandGapsOut * 2) : baseBarHeight + property real barCenterSideModuleWidth: Config.options?.bar.verbose ? 360 : 140 + property real barCenterSideModuleWidthShortened: 280 + property real barCenterSideModuleWidthHellaShortened: 190 + property real barShortenScreenWidthThreshold: 1200 // Shorten if screen width is at most this value + property real barHellaShortenScreenWidthThreshold: 1000 // Shorten even more... + property real elevationMargin: 10 + property real fabShadowRadius: 5 + property real fabHoveredShadowRadius: 7 + property real hyprlandGapsOut: 5 + property real mediaControlsWidth: 440 + property real mediaControlsHeight: 160 + property real notificationPopupWidth: 410 + property real osdWidth: 180 + property real searchWidthCollapsed: 210 + property real searchWidth: 360 + property real sidebarWidth: 460 + property real sidebarWidthExtended: 750 + property real baseVerticalBarWidth: 46 + property real verticalBarWidth: Config.options.bar.cornerStyle === 1 ? + (baseVerticalBarWidth + root.sizes.hyprlandGapsOut * 2) : baseVerticalBarWidth + property real wallpaperSelectorWidth: 1200 + property real wallpaperSelectorHeight: 690 + property real wallpaperSelectorItemMargins: 8 + property real wallpaperSelectorItemPadding: 6 + } + + syntaxHighlightingTheme: root.m3colors.darkmode ? "Monokai" : "ayu Light" +} diff --git a/modules/quickshell/config/modules/common/Config.qml b/modules/quickshell/config/modules/common/Config.qml new file mode 100644 index 0000000..ae5b898 --- /dev/null +++ b/modules/quickshell/config/modules/common/Config.qml @@ -0,0 +1,579 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Io +import qs.modules.common.functions + +Singleton { + id: root + property string filePath: Directories.shellConfigPath + property alias options: configOptionsJsonAdapter + property bool ready: false + property int readWriteDelay: 50 // milliseconds + property bool blockWrites: false + + function setNestedValue(nestedKey, value) { + let keys = nestedKey.split("."); + let obj = root.options; + let parents = [obj]; + + // Traverse and collect parent objects + for (let i = 0; i < keys.length - 1; ++i) { + if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") { + obj[keys[i]] = {}; + } + obj = obj[keys[i]]; + parents.push(obj); + } + + // Convert value to correct type using JSON.parse when safe + let convertedValue = value; + if (typeof value === "string") { + let trimmed = value.trim(); + if (trimmed === "true" || trimmed === "false" || !isNaN(Number(trimmed))) { + try { + convertedValue = JSON.parse(trimmed); + } catch (e) { + convertedValue = value; + } + } + } + + obj[keys[keys.length - 1]] = convertedValue; + } + + Timer { + id: fileReloadTimer + interval: root.readWriteDelay + repeat: false + onTriggered: { + configFileView.reload() + } + } + + Timer { + id: fileWriteTimer + interval: root.readWriteDelay + repeat: false + onTriggered: { + configFileView.writeAdapter() + } + } + + FileView { + id: configFileView + path: root.filePath + watchChanges: true + blockWrites: root.blockWrites + onFileChanged: fileReloadTimer.restart() + onAdapterUpdated: fileWriteTimer.restart() + onLoaded: root.ready = true + onLoadFailed: error => { + if (error == FileViewError.FileNotFound) { + writeAdapter(); + } + } + + JsonAdapter { + id: configOptionsJsonAdapter + + property list enabledPanels: [ + "iiBar", "iiBackground", "iiDock", "iiLock", "iiMediaControls", "iiNotificationPopup", "iiOnScreenDisplay", "iiOverlay", "iiOverview", "iiPolkit", "iiRegionSelector", "iiReloadPopup", "iiScreenCorners", "iiSessionScreen", "iiSidebarRight", "iiVerticalBar" + ] + property string panelFamily: "ii" + + property JsonObject policies: JsonObject { + } + + property JsonObject appearance: JsonObject { + property bool extraBackgroundTint: true + property int fakeScreenRounding: 2 // 0: None | 1: Always | 2: When not fullscreen + property JsonObject fonts: JsonObject { + property string main: "Google Sans Flex" + property string numbers: "Google Sans Flex" + property string title: "Google Sans Flex" + property string iconNerd: "JetBrains Mono NF" + property string monospace: "JetBrains Mono NF" + property string reading: "Readex Pro" + property string expressive: "Space Grotesk" + } + property JsonObject transparency: JsonObject { + property bool enable: false + property bool automatic: true + property real backgroundTransparency: 0.11 + property real contentTransparency: 0.57 + } + property JsonObject wallpaperTheming: JsonObject { + property bool enableAppsAndShell: true + property bool enableQtApps: true + property bool enableTerminal: true + property JsonObject terminalGenerationProps: JsonObject { + property real harmony: 0.6 + property real harmonizeThreshold: 100 + property real termFgBoost: 0.35 + property bool forceDarkMode: false + } + } + property JsonObject palette: JsonObject { + property string type: "auto" // Allowed: auto, scheme-content, scheme-expressive, scheme-fidelity, scheme-fruit-salad, scheme-monochrome, scheme-neutral, scheme-rainbow, scheme-tonal-spot + } + } + + property JsonObject audio: JsonObject { + // Values in % + property JsonObject protection: JsonObject { + // Prevent sudden bangs + property bool enable: false + property real maxAllowedIncrease: 10 + property real maxAllowed: 99 + } + } + + property JsonObject apps: JsonObject { + property string bluetooth: "kcmshell6 kcm_bluetooth" + property string network: "kitty -1 fish -c nmtui" + property string networkEthernet: "kcmshell6 kcm_networkmanagement" + property string taskManager: "plasma-systemmonitor --page-name Processes" + property string terminal: "kitty -1" // This is only for shell actions + property string update: "kitty -1 --hold=yes fish -i -c 'pkexec pacman -Syu'" + property string volumeMixer: `~/.config/hypr/hyprland/scripts/launch_first_available.sh "pavucontrol-qt" "pavucontrol"` + } + + property JsonObject background: JsonObject { + property JsonObject widgets: JsonObject { + property JsonObject clock: JsonObject { + property bool enable: true + property bool showOnlyWhenLocked: false + property string placementStrategy: "leastBusy" // "free", "leastBusy", "mostBusy" + property real x: 100 + property real y: 100 + property string style: "cookie" // Options: "cookie", "digital" + property string styleLocked: "cookie" // Options: "cookie", "digital" + property JsonObject cookie: JsonObject { + property bool aiStyling: false + property int sides: 14 + property string dialNumberStyle: "full" // Options: "dots" , "numbers", "full" , "none" + property string hourHandStyle: "fill" // Options: "classic", "fill", "hollow", "hide" + property string minuteHandStyle: "medium" // Options "classic", "thin", "medium", "bold", "hide" + property string secondHandStyle: "dot" // Options: "dot", "line", "classic", "hide" + property string dateStyle: "bubble" // Options: "border", "rect", "bubble" , "hide" + property bool timeIndicators: true + property bool hourMarks: false + property bool dateInClock: true + property bool constantlyRotate: false + property bool useSineCookie: false + } + property JsonObject digital: JsonObject { + property bool animateChange: true + } + property JsonObject quote: JsonObject { + property bool enable: false + property string text: "" + } + } + property JsonObject weather: JsonObject { + property bool enable: false + property string placementStrategy: "free" // "free", "leastBusy", "mostBusy" + property real x: 400 + property real y: 100 + } + } + property string wallpaperPath: "" + property string thumbnailPath: "" + property bool hideWhenFullscreen: true + property JsonObject parallax: JsonObject { + property bool vertical: false + property bool autoVertical: false + property bool enableWorkspace: true + property real workspaceZoom: 1.07 // Relative to your screen, not wallpaper size + property bool enableSidebar: true + property real widgetsFactor: 1.2 + } + } + + property JsonObject bar: JsonObject { + property JsonObject autoHide: JsonObject { + property bool enable: false + property int hoverRegionWidth: 2 + property bool pushWindows: false + property JsonObject showWhenPressingSuper: JsonObject { + property bool enable: true + property int delay: 140 + } + } + property bool bottom: false // Instead of top + property int cornerStyle: 0 // 0: Hug | 1: Float | 2: Plain rectangle + property bool floatStyleShadow: true // Show shadow behind bar when cornerStyle == 1 (Float) + property bool borderless: false // true for no grouping of items + property string topLeftIcon: "spark" // Options: "distro" or any icon name in ~/.config/quickshell/ii/assets/icons + property bool showBackground: true + property bool verbose: true + property bool vertical: false + property JsonObject resources: JsonObject { + property bool alwaysShowSwap: true + property bool alwaysShowCpu: true + property int memoryWarningThreshold: 95 + property int swapWarningThreshold: 85 + property int cpuWarningThreshold: 90 + } + property list screenList: [] // List of names, like "eDP-1", find out with 'hyprctl monitors' command + property JsonObject utilButtons: JsonObject { + property bool showScreenSnip: true + property bool showColorPicker: false + property bool showMicToggle: false + property bool showKeyboardToggle: true + property bool showDarkModeToggle: true + property bool showPerformanceProfileToggle: false + property bool showScreenRecord: false + } + property JsonObject workspaces: JsonObject { + property bool monochromeIcons: true + property int shown: 10 + property bool showAppIcons: true + property bool alwaysShowNumbers: false + property int showNumberDelay: 300 // milliseconds + property list numberMap: ["1", "2"] // Characters to show instead of numbers on workspace indicator + property bool useNerdFont: false + } + property JsonObject weather: JsonObject { + property bool enable: false + property bool enableGPS: true // gps based location + property string city: "" // When 'enableGPS' is false + property bool useUSCS: false // Instead of metric (SI) units + property int fetchInterval: 10 // minutes + } + property JsonObject indicators: JsonObject { + property JsonObject notifications: JsonObject { + property bool showUnreadCount: false + } + } + property JsonObject tooltips: JsonObject { + property bool clickToShow: false + } + } + + property JsonObject battery: JsonObject { + property int low: 20 + property int critical: 5 + property int full: 101 + property bool automaticSuspend: true + property int suspend: 3 + } + + property JsonObject calendar: JsonObject { + property string locale: "en-GB" + } + + property JsonObject cheatsheet: JsonObject { + // Use a nerdfont to see the icons + // 0: ๓ฐ–ณ | 1: ๓ฐŒฝ | 2: ๓ฐ˜ณ | 3: ๎ฃฅ | 4: ๓ฐจก + // 5: ๎ฏ† | 6: ๎œ‘ | 7: ๓ฐฃ‡ | 8: ๎ฝฒ | 9: ๎Ÿ™ + // 10: ๎ฝ | 11: ๎Ÿฆ | 12: ๏Œข | 13: ๏Œ’ | 14: ๓ฑ„› + property string superKey: "๎ฃฅ" + property bool useMacSymbol: false + property bool splitButtons: false + property bool useMouseSymbol: false + property bool useFnSymbol: false + property JsonObject fontSize: JsonObject { + property int key: Appearance.font.pixelSize.smaller + property int comment: Appearance.font.pixelSize.smaller + } + } + + property JsonObject conflictKiller: JsonObject { + property bool autoKillNotificationDaemons: false + property bool autoKillTrays: false + } + + property JsonObject crosshair: JsonObject { + // Valorant crosshair format. Use https://www.vcrdb.net/builder + property string code: "0;P;d;1;0l;10;0o;2;1b;0" + } + + property JsonObject dock: JsonObject { + property bool enable: false + property bool monochromeIcons: true + property real height: 60 + property real hoverRegionHeight: 2 + property bool pinnedOnStartup: false + property bool hoverToReveal: true // When false, only reveals on empty workspace + property list pinnedApps: [ // IDs of pinned entries + "org.kde.dolphin", "kitty",] + property list ignoredAppRegexes: [] + } + + property JsonObject interactions: JsonObject { + property JsonObject scrolling: JsonObject { + property bool fasterTouchpadScroll: false // Enable faster scrolling with touchpad + property int mouseScrollDeltaThreshold: 120 // delta >= this then it gets detected as mouse scroll rather than touchpad + property int mouseScrollFactor: 120 + property int touchpadScrollFactor: 450 + } + property JsonObject deadPixelWorkaround: JsonObject { // Hyprland leaves out 1 pixel on the right for interactions + property bool enable: false + } + } + + property JsonObject language: JsonObject { + property string ui: "auto" // UI language. "auto" for system locale, or specific language code like "zh_CN", "en_US" + property JsonObject translator: JsonObject { + property string engine: "auto" // Run `trans -list-engines` for available engines. auto should use google + property string targetLanguage: "auto" // Run `trans -list-all` for available languages + property string sourceLanguage: "auto" + } + } + + property JsonObject light: JsonObject { + property JsonObject night: JsonObject { + property bool automatic: true + property string from: "19:00" // Format: "HH:mm", 24-hour time + property string to: "06:30" // Format: "HH:mm", 24-hour time + property int colorTemperature: 5000 + } + property JsonObject antiFlashbang: JsonObject { + property bool enable: false + } + } + + property JsonObject lock: JsonObject { + property bool useHyprlock: false + property bool launchOnStartup: false + property JsonObject blur: JsonObject { + property bool enable: true + property real radius: 100 + property real extraZoom: 1.1 + } + property bool centerClock: true + property bool showLockedText: true + property JsonObject security: JsonObject { + property bool unlockKeyring: true + property bool requirePasswordToPower: false + } + property bool materialShapeChars: true + } + + property JsonObject media: JsonObject { + // Attempt to remove dupes (the aggregator playerctl one and browsers' native ones when there's plasma browser integration) + property bool filterDuplicatePlayers: true + } + + property JsonObject networking: JsonObject { + property string userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" + } + + property JsonObject notifications: JsonObject { + property int timeout: 7000 + } + + property JsonObject osd: JsonObject { + property int timeout: 1000 + } + + property JsonObject osk: JsonObject { + property string layout: "qwerty_full" + property bool pinnedOnStartup: false + } + + property JsonObject overlay: JsonObject { + property bool openingZoomAnimation: true + property bool darkenScreen: true + property real clickthroughOpacity: 0.8 + property JsonObject floatingImage: JsonObject { + property string imageSource: "https://media.tenor.com/H5U5bJzj3oAAAAAi/kukuru.gif" + property real scale: 0.5 + } + } + + property JsonObject overview: JsonObject { + property bool enable: true + property real scale: 0.18 // Relative to screen size + property real rows: 2 + property real columns: 5 + property bool centerIcons: true + } + + property JsonObject regionSelector: JsonObject { + property JsonObject targetRegions: JsonObject { + property bool windows: true + property bool layers: false + property bool content: true + property bool showLabel: false + property real opacity: 0.3 + property real contentRegionOpacity: 0.8 + property int selectionPadding: 5 + } + property JsonObject rect: JsonObject { + property bool showAimLines: true + } + property JsonObject circle: JsonObject { + property int strokeWidth: 6 + property int padding: 10 + } + } + + property JsonObject resources: JsonObject { + property int updateInterval: 3000 + property int historyLength: 60 + } + + property JsonObject tray: JsonObject { + property bool monochromeIcons: true + property bool showItemId: false + property bool invertPinnedItems: true // Makes the below a whitelist for the tray and blacklist for the pinned area + property list pinnedItems: [ "Fcitx" ] + property bool filterPassive: true + } + + property JsonObject musicRecognition: JsonObject { + property int timeout: 16 + property int interval: 4 + } + + property JsonObject search: JsonObject { + property int nonAppResultDelay: 30 // This prevents lagging when typing + property string engineBaseUrl: "https://www.google.com/search?q=" + property list excludedSites: ["quora.com", "facebook.com"] + property bool sloppy: false // Uses levenshtein distance based scoring instead of fuzzy sort. Very weird. + property JsonObject prefix: JsonObject { + property bool showDefaultActionsWithoutPrefix: true + property string action: "/" + property string app: ">" + property string clipboard: ";" + property string emojis: ":" + property string math: "=" + property string shellCommand: "$" + property string webSearch: "?" + } + property JsonObject imageSearch: JsonObject { + property string imageSearchEngineBaseUrl: "https://lens.google.com/uploadbyurl?url=" + property bool useCircleSelection: false + } + } + + property JsonObject sidebar: JsonObject { + property bool keepRightSidebarLoaded: true + property JsonObject translator: JsonObject { + property bool enable: false + property int delay: 300 // Delay before sending request. Reduces (potential) rate limits and lag. + } + property JsonObject ai: JsonObject { + property bool textFadeIn: false + } + property JsonObject cornerOpen: JsonObject { + property bool enable: true + property bool bottom: false + property bool valueScroll: true + property bool clickless: false + property int cornerRegionWidth: 250 + property int cornerRegionHeight: 5 + property bool visualize: false + property bool clicklessCornerEnd: true + property int clicklessCornerVerticalOffset: 1 + } + + property JsonObject quickToggles: JsonObject { + property string style: "android" // Options: classic, android + property JsonObject android: JsonObject { + property int columns: 5 + property list toggles: [ + { "size": 2, "type": "network" }, + { "size": 2, "type": "bluetooth" }, + { "size": 1, "type": "idleInhibitor" }, + { "size": 1, "type": "mic" }, + { "size": 2, "type": "audio" }, + { "size": 2, "type": "nightLight" } + ] + } + } + + property JsonObject quickSliders: JsonObject { + property bool enable: false + property bool showMic: false + property bool showVolume: true + property bool showBrightness: true + } + } + + property JsonObject screenRecord: JsonObject { + property string savePath: Directories.videos.replace("file://","") // strip "file://" + } + + property JsonObject screenSnip: JsonObject { + property string savePath: "" // only copy to clipboard when empty + } + + property JsonObject sounds: JsonObject { + property bool battery: false + property bool pomodoro: false + property string theme: "freedesktop" + } + + property JsonObject time: JsonObject { + // https://doc.qt.io/qt-6/qtime.html#toString + property string format: "hh:mm" + property string shortDateFormat: "dd/MM" + property string dateWithYearFormat: "dd/MM/yyyy" + property string dateFormat: "ddd, dd/MM" + property JsonObject pomodoro: JsonObject { + property int breakTime: 300 + property int cyclesBeforeLongBreak: 4 + property int focus: 1500 + property int longBreak: 900 + } + property bool secondPrecision: false + } + + property JsonObject updates: JsonObject { + property int checkInterval: 120 // minutes + property int adviseUpdateThreshold: 75 // packages + property int stronglyAdviseUpdateThreshold: 200 // packages + } + + property JsonObject wallpaperSelector: JsonObject { + property bool useSystemFileDialog: false + } + + property JsonObject windows: JsonObject { + property bool showTitlebar: true // Client-side decoration for shell apps + property bool centerTitle: true + } + + property JsonObject hacks: JsonObject { + property int arbitraryRaceConditionDelay: 20 // milliseconds + } + + property JsonObject workSafety: JsonObject { + property JsonObject enable: JsonObject { + property bool wallpaper: false + property bool clipboard: false + } + property JsonObject triggerCondition: JsonObject { + property list networkNameKeywords: ["airport", "cafe", "college", "company", "eduroam", "free", "guest", "public", "school", "university"] + property list fileKeywords: [] + property list linkKeywords: [] + } + } + + property JsonObject waffles: JsonObject { + // Some spots are kinda janky/awkward. Setting the following to + // false will make (some) stuff also be like that for accuracy. + // Example: the right-click menu of the Start button + property JsonObject tweaks: JsonObject { + property bool smootherMenuAnimations: true + property bool switchHandlePositionFix: true + } + property JsonObject bar: JsonObject { + property bool bottom: true + property bool leftAlignApps: false + } + property JsonObject actionCenter: JsonObject { + property list toggles: [ "network", "bluetooth", "easyEffects", "powerProfile", "idleInhibitor", "nightLight", "darkMode", "antiFlashbang", "cloudflareWarp", "mic", "musicRecognition", "notifications", "onScreenKeyboard", "gameMode", "screenSnip", "colorPicker" ] + } + property JsonObject calendar: JsonObject { + property bool force2CharDayOfWeek: true + } + } + } + } +} diff --git a/modules/quickshell/config/modules/common/Directories.qml b/modules/quickshell/config/modules/common/Directories.qml new file mode 100644 index 0000000..0cad38e --- /dev/null +++ b/modules/quickshell/config/modules/common/Directories.qml @@ -0,0 +1,51 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common.functions +import QtCore +import QtQuick +import Quickshell + +Singleton { + // XDG Dirs, with "file://" + readonly property string home: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0] + readonly property string config: StandardPaths.standardLocations(StandardPaths.ConfigLocation)[0] + readonly property string state: StandardPaths.standardLocations(StandardPaths.StateLocation)[0] + readonly property string cache: StandardPaths.standardLocations(StandardPaths.CacheLocation)[0] + readonly property string genericCache: StandardPaths.standardLocations(StandardPaths.GenericCacheLocation)[0] + readonly property string documents: StandardPaths.standardLocations(StandardPaths.DocumentsLocation)[0] + readonly property string downloads: StandardPaths.standardLocations(StandardPaths.DownloadLocation)[0] + readonly property string pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0] + readonly property string music: StandardPaths.standardLocations(StandardPaths.MusicLocation)[0] + readonly property string videos: StandardPaths.standardLocations(StandardPaths.MoviesLocation)[0] + + // Other dirs used by the shell, without "file://" + property string assetsPath: Quickshell.shellPath("assets") + property string scriptPath: Quickshell.shellPath("scripts") + property string favicons: FileUtils.trimFileProtocol(`${Directories.cache}/media/favicons`) + property string coverArt: FileUtils.trimFileProtocol(`${Directories.cache}/media/coverart`) + property string tempImages: "/tmp/quickshell/media/images" + property string latexOutput: FileUtils.trimFileProtocol(`${Directories.cache}/media/latex`) + property string shellConfig: FileUtils.trimFileProtocol(`${Directories.config}/illogical-impulse`) + property string shellConfigName: "config.json" + property string shellConfigPath: `${Directories.shellConfig}/${Directories.shellConfigName}` + property string todoPath: FileUtils.trimFileProtocol(`${Directories.state}/user/todo.json`) + property string notesPath: FileUtils.trimFileProtocol(`${Directories.state}/user/notes.txt`) + property string conflictCachePath: FileUtils.trimFileProtocol(`${Directories.cache}/conflict-killer`) + property string notificationsPath: FileUtils.trimFileProtocol(`${Directories.cache}/notifications/notifications.json`) + property string generatedMaterialThemePath: FileUtils.trimFileProtocol(`${Directories.state}/user/generated/colors.json`) + property string generatedWallpaperCategoryPath: FileUtils.trimFileProtocol(`${Directories.state}/user/generated/wallpaper/category.txt`) + property string cliphistDecode: FileUtils.trimFileProtocol(`/tmp/quickshell/media/cliphist`) + property string screenshotTemp: "/tmp/quickshell/media/screenshot" + property string wallpaperSwitchScriptPath: FileUtils.trimFileProtocol(`${Directories.scriptPath}/colors/switchwall.sh`) + property string recordScriptPath: FileUtils.trimFileProtocol(`${Directories.scriptPath}/videos/record.sh`) + // Cleanup on init + Component.onCompleted: { + Quickshell.execDetached(["mkdir", "-p", `${shellConfig}`]) + Quickshell.execDetached(["mkdir", "-p", `${favicons}`]) + Quickshell.execDetached(["bash", "-c", `rm -rf '${coverArt}'; mkdir -p '${coverArt}'`]) + Quickshell.execDetached(["bash", "-c", `rm -rf '${latexOutput}'; mkdir -p '${latexOutput}'`]) + Quickshell.execDetached(["bash", "-c", `rm -rf '${cliphistDecode}'; mkdir -p '${cliphistDecode}'`]) + Quickshell.execDetached(["rm", "-rf", `${tempImages}`]) + } +} diff --git a/modules/quickshell/config/modules/common/Icons.qml b/modules/quickshell/config/modules/common/Icons.qml new file mode 100644 index 0000000..9858d29 --- /dev/null +++ b/modules/quickshell/config/modules/common/Icons.qml @@ -0,0 +1,82 @@ +pragma Singleton + +// From https://github.com/caelestia-dots/shell (GPLv3) + +import Quickshell + +Singleton { + id: root + + function getBluetoothDeviceMaterialSymbol(systemIconName: string): string { + if (systemIconName.includes("headset") || systemIconName.includes("headphones")) + return "headphones"; + if (systemIconName.includes("audio")) + return "speaker"; + if (systemIconName.includes("phone")) + return "smartphone"; + if (systemIconName.includes("mouse")) + return "mouse"; + if (systemIconName.includes("keyboard")) + return "keyboard"; + return "bluetooth"; + } + + readonly property var weatherIconMap: ({ + "113": "clear_day", + "116": "partly_cloudy_day", + "119": "cloud", + "122": "cloud", + "143": "foggy", + "176": "rainy", + "179": "rainy", + "182": "rainy", + "185": "rainy", + "200": "thunderstorm", + "227": "cloudy_snowing", + "230": "snowing_heavy", + "248": "foggy", + "260": "foggy", + "263": "rainy", + "266": "rainy", + "281": "rainy", + "284": "rainy", + "293": "rainy", + "296": "rainy", + "299": "rainy", + "302": "weather_hail", + "305": "rainy", + "308": "weather_hail", + "311": "rainy", + "314": "rainy", + "317": "rainy", + "320": "cloudy_snowing", + "323": "cloudy_snowing", + "326": "cloudy_snowing", + "329": "snowing_heavy", + "332": "snowing_heavy", + "335": "snowing", + "338": "snowing_heavy", + "350": "rainy", + "353": "rainy", + "356": "rainy", + "359": "weather_hail", + "362": "rainy", + "365": "rainy", + "368": "cloudy_snowing", + "371": "snowing", + "374": "rainy", + "377": "rainy", + "386": "thunderstorm", + "389": "thunderstorm", + "392": "thunderstorm", + "395": "snowing" + }) + + + function getWeatherIcon(code) { + const key = String(code) + if (weatherIconMap.hasOwnProperty(key)) { + return weatherIconMap[key] + } + } +} diff --git a/modules/quickshell/config/modules/common/Images.qml b/modules/quickshell/config/modules/common/Images.qml new file mode 100644 index 0000000..be3701e --- /dev/null +++ b/modules/quickshell/config/modules/common/Images.qml @@ -0,0 +1,31 @@ +pragma Singleton + +import Quickshell + +Singleton { + // Formats + readonly property list validImageTypes: ["jpeg", "png", "webp", "tiff", "svg"] + readonly property list validImageExtensions: ["jpg", "jpeg", "png", "webp", "tif", "tiff", "svg"] + + function isValidImageByName(name: string): bool { + return validImageExtensions.some(t => name.endsWith(`.${t}`)); + } + + // Thumbnails + // https://specifications.freedesktop.org/thumbnail-spec/latest/directory.html + readonly property var thumbnailSizes: ({ + "normal": 128, + "large": 256, + "x-large": 512, + "xx-large": 1024 + }) + function thumbnailSizeNameForDimensions(width: int, height: int): string { + const sizeNames = Object.keys(thumbnailSizes); + for(let i = 0; i < sizeNames.length; i++) { + const sizeName = sizeNames[i]; + const maxSize = thumbnailSizes[sizeName]; + if (width <= maxSize && height <= maxSize) return sizeName; + } + return "xx-large"; + } +} diff --git a/modules/quickshell/config/modules/common/Persistent.qml b/modules/quickshell/config/modules/common/Persistent.qml new file mode 100644 index 0000000..8d57961 --- /dev/null +++ b/modules/quickshell/config/modules/common/Persistent.qml @@ -0,0 +1,154 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + property alias states: persistentStatesJsonAdapter + property string fileDir: Directories.state + property string fileName: "states.json" + property string filePath: `${root.fileDir}/${root.fileName}` + + property bool ready: false + property string previousHyprlandInstanceSignature: "" + property bool isNewHyprlandInstance: previousHyprlandInstanceSignature !== states.hyprlandInstanceSignature + + onReadyChanged: { + root.previousHyprlandInstanceSignature = root.states.hyprlandInstanceSignature + root.states.hyprlandInstanceSignature = Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE") || "" + } + + Timer { + id: fileReloadTimer + interval: 100 + repeat: false + onTriggered: { + persistentStatesFileView.reload() + } + } + + Timer { + id: fileWriteTimer + interval: 100 + repeat: false + onTriggered: { + persistentStatesFileView.writeAdapter() + } + } + + FileView { + id: persistentStatesFileView + path: root.filePath + + watchChanges: true + onFileChanged: fileReloadTimer.restart() + onAdapterUpdated: fileWriteTimer.restart() + onLoaded: root.ready = true + onLoadFailed: error => { + console.log("Failed to load persistent states file:", error); + if (error == FileViewError.FileNotFound) { + fileWriteTimer.restart(); + } + } + + adapter: JsonAdapter { + id: persistentStatesJsonAdapter + + property string hyprlandInstanceSignature: "" + + property JsonObject ai: JsonObject { + property string model + property real temperature: 0.5 + } + + property JsonObject sidebar: JsonObject { + property JsonObject bottomGroup: JsonObject { + property bool collapsed: false + property int tab: 0 + } + } + + property JsonObject idle: JsonObject { + property bool inhibit: false + } + + property JsonObject overlay: JsonObject { + property list open: ["crosshair", "recorder", "volumeMixer", "resources"] + property JsonObject crosshair: JsonObject { + property bool pinned: false + property bool clickthrough: true + property real x: 827 + property real y: 441 + property real width: 250 + property real height: 100 + } + property JsonObject floatingImage: JsonObject { + property bool pinned: false + property bool clickthrough: false + property real x: 1650 + property real y: 390 + property real width: 0 + property real height: 0 + } + property JsonObject fpsLimiter: JsonObject { + property bool pinned: false + property bool clickthrough: false + property real x: 1570 + property real y: 615 + property real width: 280 + property real height: 80 + } + property JsonObject recorder: JsonObject { + property bool pinned: false + property bool clickthrough: false + property real x: 80 + property real y: 80 + property real width: 350 + property real height: 130 + } + property JsonObject resources: JsonObject { + property bool pinned: false + property bool clickthrough: true + property real x: 1500 + property real y: 770 + property real width: 350 + property real height: 200 + property int tabIndex: 0 + } + property JsonObject volumeMixer: JsonObject { + property bool pinned: false + property bool clickthrough: false + property real x: 80 + property real y: 280 + property real width: 350 + property real height: 600 + property int tabIndex: 0 + } + property JsonObject notes: JsonObject { + property bool pinned: false + property bool clickthrough: true + property real x: 1400 + property real y: 42 + property real width: 460 + property real height: 330 + } + } + + property JsonObject timer: JsonObject { + property JsonObject pomodoro: JsonObject { + property bool running: false + property int start: 0 + property bool isBreak: false + property int cycle: 0 + } + property JsonObject stopwatch: JsonObject { + property bool running: false + property int start: 0 + property list laps: [] + } + } + } + } +} diff --git a/modules/quickshell/config/modules/common/functions/ColorUtils.qml b/modules/quickshell/config/modules/common/functions/ColorUtils.qml new file mode 100644 index 0000000..165f277 --- /dev/null +++ b/modules/quickshell/config/modules/common/functions/ColorUtils.qml @@ -0,0 +1,138 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + /** + * Returns a color with the hue of color2 and the saturation, value, and alpha of color1. + * + * @param {string} color1 - The base color (any Qt.color-compatible string). + * @param {string} color2 - The color to take hue from. + * @returns {Qt.rgba} The resulting color. + */ + function colorWithHueOf(color1, color2) { + var c1 = Qt.color(color1); + var c2 = Qt.color(color2); + + // Qt.color hsvHue/hsvSaturation/hsvValue/alpha return 0-1 + var hue = c2.hsvHue; + var sat = c1.hsvSaturation; + var val = c1.hsvValue; + var alpha = c1.a; + + return Qt.hsva(hue, sat, val, alpha); + } + + /** + * Returns a color with the saturation of color2 and the hue/value/alpha of color1. + * + * @param {string} color1 - The base color (any Qt.color-compatible string). + * @param {string} color2 - The color to take saturation from. + * @returns {Qt.rgba} The resulting color. + */ + function colorWithSaturationOf(color1, color2) { + var c1 = Qt.color(color1); + var c2 = Qt.color(color2); + + var hue = c1.hsvHue; + var sat = c2.hsvSaturation; + var val = c1.hsvValue; + var alpha = c1.a; + + return Qt.hsva(hue, sat, val, alpha); + } + + /** + * Returns a color with the given lightness and the hue, saturation, and alpha of the input color (using HSL). + * + * @param {string} color - The base color (any Qt.color-compatible string). + * @param {number} lightness - The lightness value to use (0-1). + * @returns {Qt.rgba} The resulting color. + */ + function colorWithLightness(color, lightness) { + var c = Qt.color(color); + return Qt.hsla(c.hslHue, c.hslSaturation, lightness, c.a); + } + + /** + * Returns a color with the lightness of color2 and the hue, saturation, and alpha of color1 (using HSL). + * + * @param {string} color1 - The base color (any Qt.color-compatible string). + * @param {string} color2 - The color to take lightness from. + * @returns {Qt.rgba} The resulting color. + */ + function colorWithLightnessOf(color1, color2) { + var c2 = Qt.color(color2); + return colorWithLightness(color1, c2.hslLightness); + } + + /** + * Adapts color1 to the accent (hue and saturation) of color2 using HSL, keeping lightness and alpha from color1. + * + * @param {string} color1 - The base color (any Qt.color-compatible string). + * @param {string} color2 - The accent color. + * @returns {Qt.rgba} The resulting color. + */ + function adaptToAccent(color1, color2) { + var c1 = Qt.color(color1); + var c2 = Qt.color(color2); + + var hue = c2.hslHue; + var sat = c2.hslSaturation; + var light = c1.hslLightness; + var alpha = c1.a; + + return Qt.hsla(hue, sat, light, alpha); + } + + /** + * Mixes two colors by a given percentage. + * + * @param {string} color1 - The first color (any Qt.color-compatible string). + * @param {string} color2 - The second color. + * @param {number} percentage - The mix ratio (0-1). 1 = all color1, 0 = all color2. + * @returns {Qt.rgba} The resulting mixed color. + */ + function mix(color1, color2, percentage = 0.5) { + var c1 = Qt.color(color1); + var c2 = Qt.color(color2); + return Qt.rgba(percentage * c1.r + (1 - percentage) * c2.r, percentage * c1.g + (1 - percentage) * c2.g, percentage * c1.b + (1 - percentage) * c2.b, percentage * c1.a + (1 - percentage) * c2.a); + } + + /** + * Transparentizes a color by a given percentage. + * + * @param {string} color - The color (any Qt.color-compatible string). + * @param {number} percentage - The amount to transparentize (0-1). + * @returns {Qt.rgba} The resulting color. + */ + function transparentize(color, percentage = 1) { + var c = Qt.color(color); + return Qt.rgba(c.r, c.g, c.b, c.a * (1 - percentage)); + } + + /** + * Sets the alpha channel of a color. + * + * @param {string} color - The base color (any Qt.color-compatible string). + * @param {number} alpha - The desired alpha (0-1). + * @returns {Qt.rgba} The resulting color with applied alpha. + */ + function applyAlpha(color, alpha) { + var c = Qt.color(color); + var a = Math.max(0, Math.min(1, alpha)); + return Qt.rgba(c.r, c.g, c.b, a); + } + + /** + * Returns true if the color is considered "dark" (hslLightness < 0.5). + * + * @param {string} color - The color to check (any Qt.color-compatible string). + * @returns {boolean} True if dark, false otherwise. + */ + function isDark(color) { + var c = Qt.color(color); + return c.hslLightness < 0.5; + } +} diff --git a/modules/quickshell/config/modules/common/functions/DateUtils.qml b/modules/quickshell/config/modules/common/functions/DateUtils.qml new file mode 100644 index 0000000..3cbae5b --- /dev/null +++ b/modules/quickshell/config/modules/common/functions/DateUtils.qml @@ -0,0 +1,27 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + function getFirstDayOfWeek(date, firstDay = 1) { + const d = new Date(date); // Copy + const day = d.getDay(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday + + // Calculate difference to firstDay + const diff = (day - firstDay + 7) % 7; + d.setDate(d.getDate() - diff); + return d; + } + + function sameDate(d1, d2) { + return (d1.getFullYear() === d2.getFullYear() && d1.getMonth() === d2.getMonth() && d1.getDate() === d2.getDate()); + } + + function getIthDayDateOfSameWeek(date, i, firstDay = 1) { + const firstDayDate = root.getFirstDayOfWeek(date, firstDay); + const targetDate = new Date(firstDayDate); + targetDate.setDate(firstDayDate.getDate() + i); + return targetDate; + } +} diff --git a/modules/quickshell/config/modules/common/functions/FileUtils.qml b/modules/quickshell/config/modules/common/functions/FileUtils.qml new file mode 100644 index 0000000..e7f5e61 --- /dev/null +++ b/modules/quickshell/config/modules/common/functions/FileUtils.qml @@ -0,0 +1,71 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + /** + * Trims the File protocol off the input string + * @param {string} str + * @returns {string} + */ + function trimFileProtocol(str) { + let s = str; + if (typeof s !== "string") s = str.toString(); // Convert to string if it's an url or whatever + return s.startsWith("file://") ? s.slice(7) : s; + } + + /** + * Extracts the file name from a file path + * @param {string} str + * @returns {string} + */ + function fileNameForPath(str) { + if (typeof str !== "string") return ""; + const trimmed = trimFileProtocol(str); + return trimmed.split(/[\\/]/).pop(); + } + + /** + * Extracts the folder name from a directory path + * @param {string} str + * @returns {string} + */ + function folderNameForPath(str) { + if (typeof str !== "string") return ""; + const trimmed = trimFileProtocol(str); + // Remove trailing slash if present + const noTrailing = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; + if (!noTrailing) return ""; + return noTrailing.split(/[\\/]/).pop(); + } + + /** + * Removes the file extension from a file path or name + * @param {string} str + * @returns {string} + */ + function trimFileExt(str) { + if (typeof str !== "string") return ""; + const trimmed = trimFileProtocol(str); + const lastDot = trimmed.lastIndexOf("."); + if (lastDot > -1 && lastDot > trimmed.lastIndexOf("/")) { + return trimmed.slice(0, lastDot); + } + return trimmed; + } + + /** + * Returns the parent directory of a given file path + * @param {string} str + * @returns {string} + */ + function parentDirectory(str) { + if (typeof str !== "string") return ""; + const trimmed = trimFileProtocol(str); + const parts = trimmed.split(/[\\/]/); + if (parts.length <= 1) return ""; + parts.pop(); + return parts.join("/"); + } +} diff --git a/modules/quickshell/config/modules/common/functions/Fuzzy.qml b/modules/quickshell/config/modules/common/functions/Fuzzy.qml new file mode 100644 index 0000000..00891ed --- /dev/null +++ b/modules/quickshell/config/modules/common/functions/Fuzzy.qml @@ -0,0 +1,18 @@ +pragma Singleton +import Quickshell +import "fuzzysort.js" as FuzzySort + +/** + * Wrapper for FuzzySort to play nicely with Quickshell's imports + */ + +Singleton { + function go(...args) { + return FuzzySort.go(...args) + } + + function prepare(...args) { + return FuzzySort.prepare(...args) + } +} + diff --git a/modules/quickshell/config/modules/common/functions/Levendist.qml b/modules/quickshell/config/modules/common/functions/Levendist.qml new file mode 100644 index 0000000..0d6a374 --- /dev/null +++ b/modules/quickshell/config/modules/common/functions/Levendist.qml @@ -0,0 +1,18 @@ +pragma Singleton +import Quickshell +import "levendist.js" as Levendist + +/** + * Wrapper for levendist.js to play nicely with Quickshell's imports + */ + +Singleton { + function computeScore(...args) { + return Levendist.computeScore(...args) + } + + function computeTextMatchScore(...args) { + return Levendist.computeTextMatchScore(...args) + } +} + diff --git a/modules/quickshell/config/modules/common/functions/NotificationUtils.qml b/modules/quickshell/config/modules/common/functions/NotificationUtils.qml new file mode 100644 index 0000000..8a336e8 --- /dev/null +++ b/modules/quickshell/config/modules/common/functions/NotificationUtils.qml @@ -0,0 +1,87 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + /** + * @param { string } summary + * @returns { string } + */ + function findSuitableMaterialSymbol(summary = "") { + const defaultType = 'chat'; + if (summary.length === 0) return defaultType; + + const keywordsToTypes = { + 'reboot': 'restart_alt', + 'record': 'screen_record', + 'battery': 'power', + 'power': 'power', + 'screenshot': 'screenshot_monitor', + 'welcome': 'waving_hand', + 'time': 'scheduleb', + 'installed': 'download', + 'configuration reloaded': 'reset_wrench', + 'unable': 'question_mark', + "couldn't": 'question_mark', + 'config': 'reset_wrench', + 'update': 'update', + 'ai response': 'neurology', + 'control': 'settings', + 'upsca': 'compare', + 'music': 'queue_music', + 'install': 'deployed_code_update', + 'input': 'keyboard_alt', + 'preedit': 'keyboard_alt', + 'startswith:file': 'folder_copy', // Declarative startsWith check + }; + + const lowerSummary = summary.toLowerCase(); + + for (const [keyword, type] of Object.entries(keywordsToTypes)) { + if (keyword.startsWith('startswith:')) { + const startsWithKeyword = keyword.replace('startswith:', ''); + if (lowerSummary.startsWith(startsWithKeyword)) { + return type; + } + } else if (lowerSummary.includes(keyword)) { + return type; + } + } + + return defaultType; + } + + /** + * @param { number | string | Date } timestamp + * @returns { string } + */ + function getFriendlyNotifTimeString(timestamp) { + if (!timestamp) return ''; + const messageTime = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - messageTime.getTime(); + + // Less than 1 minute + if (diffMs < 60000) + return 'Now'; + + // Same day - show relative time + if (messageTime.toDateString() === now.toDateString()) { + const diffMinutes = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + + if (diffHours > 0) { + return `${diffHours}h`; + } else { + return `${diffMinutes}m`; + } + } + + // Yesterday + if (messageTime.toDateString() === new Date(now.getTime() - 86400000).toDateString()) + return 'Yesterday'; + + // Older dates + return Qt.formatDateTime(messageTime, "MMMM dd"); + } +} diff --git a/modules/quickshell/config/modules/common/functions/ObjectUtils.qml b/modules/quickshell/config/modules/common/functions/ObjectUtils.qml new file mode 100644 index 0000000..d1204cd --- /dev/null +++ b/modules/quickshell/config/modules/common/functions/ObjectUtils.qml @@ -0,0 +1,98 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + function toPlainObject(qtObj) { + if (qtObj === null || typeof qtObj !== "object") return qtObj; + + // Handle true arrays + if (Array.isArray(qtObj)) { + return qtObj.map(item => toPlainObject(item)); + } + + // Handle array-like Qt objects (e.g., have length and numeric keys) + if ( + typeof qtObj.length === "number" && + qtObj.length > 0 && + Object.keys(qtObj).every( + key => !isNaN(key) || key === "length" + ) + ) { + let arr = []; + for (let i = 0; i < qtObj.length; i++) { + arr.push(toPlainObject(qtObj[i])); + } + return arr; + } + + const result = ({}); + for (let key in qtObj) { + if ( + typeof qtObj[key] !== "function" && + !key.startsWith("objectName") && + !key.startsWith("children") && + !key.startsWith("object") && + !key.startsWith("parent") && + !key.startsWith("metaObject") && + !key.startsWith("destroyed") && + !key.startsWith("reloadableId") + ) { + result[key] = toPlainObject(qtObj[key]); + } + } + // console.log(JSON.stringify(result)) + return result; + } + + function applyToQtObject(qtObj, jsonObj) { + // console.log("applyToQtObject", JSON.stringify(qtObj, null, 2), "<<", JSON.stringify(jsonObj, null, 2)); + if (!qtObj || typeof jsonObj !== "object" || jsonObj === null) return; + + // Detect array-like Qt objects + const isQtArrayLike = obj => { + return obj && typeof obj === "object" && + typeof obj.length === "number" && + obj.length > 0 && + Object.keys(obj).every(key => !isNaN(key) || key === "length"); + }; + + // If both are arrays or array-like, update in place or replace + if ((Array.isArray(qtObj) || isQtArrayLike(qtObj)) && Array.isArray(jsonObj)) { + qtObj.length = 0; + for (let i = 0; i < jsonObj.length; i++) { + qtObj.push(jsonObj[i]); + } + return; + } + + // If target is array or array-like but source is not, clear + if ((Array.isArray(qtObj) || isQtArrayLike(qtObj)) && !Array.isArray(jsonObj)) { + qtObj.length = 0; + return; + } + + // If source is array but target is not, assign directly if possible + if (!(Array.isArray(qtObj) || isQtArrayLike(qtObj)) && Array.isArray(jsonObj)) { + return jsonObj; + } + + for (let key in jsonObj) { + if (!qtObj.hasOwnProperty(key)) continue; + const value = qtObj[key]; + const jsonValue = jsonObj[key]; + // console.log("applying to qt obj key:", value, "jsonValue:", jsonValue); + if ((Array.isArray(value) || isQtArrayLike(value)) && Array.isArray(jsonValue)) { + value.length = 0; + for (let i = 0; i < jsonValue.length; i++) { + value.push(jsonValue[i]); + } + } else if (value && typeof value === "object" && !Array.isArray(value) && !isQtArrayLike(value)) { + applyToQtObject(value, jsonValue); + } else { + qtObj[key] = jsonValue; + } + } + } +} diff --git a/modules/quickshell/config/modules/common/functions/Session.qml b/modules/quickshell/config/modules/common/functions/Session.qml new file mode 100644 index 0000000..bbb9932 --- /dev/null +++ b/modules/quickshell/config/modules/common/functions/Session.qml @@ -0,0 +1,50 @@ +pragma Singleton +import Quickshell +import qs.services +import qs.modules.common + +Singleton { + id: root + + function closeAllWindows() { + HyprlandData.windowList.map(w => w.pid).forEach(pid => { + Quickshell.execDetached(["kill", pid]); + }); + } + + function lock() { + Quickshell.execDetached(["loginctl", "lock-session"]); + } + + function suspend() { + Quickshell.execDetached(["bash", "-c", "systemctl suspend || loginctl suspend"]); + } + + function logout() { + closeAllWindows(); + Quickshell.execDetached(["pkill", "-i", "Hyprland"]); + } + + function launchTaskManager() { + Quickshell.execDetached(["bash", "-c", `${Config.options.apps.taskManager}`]); + } + + function hibernate() { + Quickshell.execDetached(["bash", "-c", `systemctl hibernate || loginctl hibernate`]); + } + + function poweroff() { + closeAllWindows(); + Quickshell.execDetached(["bash", "-c", `systemctl poweroff || loginctl poweroff`]); + } + + function reboot() { + closeAllWindows(); + Quickshell.execDetached(["bash", "-c", `reboot || loginctl reboot`]); + } + + function rebootToFirmware() { + closeAllWindows(); + Quickshell.execDetached(["bash", "-c", `systemctl reboot --firmware-setup || loginctl reboot --firmware-setup`]); + } +} diff --git a/modules/quickshell/config/modules/common/functions/StringUtils.qml b/modules/quickshell/config/modules/common/functions/StringUtils.qml new file mode 100644 index 0000000..0839b53 --- /dev/null +++ b/modules/quickshell/config/modules/common/functions/StringUtils.qml @@ -0,0 +1,298 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + /** + * Formats a string according to the args that are passed inc + * @param { string } str + * @param {...any} args + * @returns { string } + */ + function format(str, ...args) { + return str.replace(/{(\d+)}/g, (match, index) => typeof args[index] !== 'undefined' ? args[index] : match); + } + + /** + * Returns the domain of the passed in url or null + * @param { string } url + * @returns { string| null } + */ + function getDomain(url) { + const match = url.match(/^(?:https?:\/\/)?(?:www\.)?([^\/]+)/); + return match ? match[1] : null; + } + + /** + * Returns the base url of the passed in url or null + * @param { string } url + * @returns { string | null } + */ + function getBaseUrl(url) { + const match = url.match(/^(https?:\/\/[^\/]+)(\/.*)?$/); + return match ? match[1] : null; + } + + /** + * Escapes single quotes in shell commands + * @param { string } str + * @returns { string } + */ + function shellSingleQuoteEscape(str) { + return String(str) + // .replace(/\\/g, '\\\\') + .replace(/'/g, "'\\''"); + } + + /** + * Splits markdown blocks into three different types: text, think, and code. + * @param { string } markdown + * @returns {Array<{type: "text" | "think" | "code", content: string, lang?: string, completed?: boolean}>} + */ + function splitMarkdownBlocks(markdown) { + const regex = /```(\w+)?\n([\s\S]*?)```|([\s\S]*?)<\/think>/g; + /** + * @type {{type: "text" | "think" | "code"; content: string; lang: string | undefined; completed: boolean | undefined}[]} + */ + let result = []; + let lastIndex = 0; + let match; + while ((match = regex.exec(markdown)) !== null) { + if (match.index > lastIndex) { + const text = markdown.slice(lastIndex, match.index); + if (text.trim()) { + result.push({ + type: "text", + content: text + }); + } + } + if (match[0].startsWith('```')) { + if (match[2] && match[2].trim()) { + result.push({ + type: "code", + lang: match[1] || "", + content: match[2], + completed: true + }); + } + } else if (match[0].startsWith('')) { + if (match[3] && match[3].trim()) { + result.push({ + type: "think", + content: match[3], + completed: true + }); + } + } + lastIndex = regex.lastIndex; + } + // Handle any remaining text after the last match + if (lastIndex < markdown.length) { + const text = markdown.slice(lastIndex); + // Check for unfinished block + const thinkStart = text.indexOf(''); + const codeStart = text.indexOf('```'); + if (thinkStart !== -1 && (codeStart === -1 || thinkStart < codeStart)) { + const beforeThink = text.slice(0, thinkStart); + if (beforeThink.trim()) { + result.push({ + type: "text", + content: beforeThink + }); + } + const thinkContent = text.slice(thinkStart + 7); + if (thinkContent.trim()) { + result.push({ + type: "think", + content: thinkContent, + completed: false + }); + } + } else if (codeStart !== -1) { + const beforeCode = text.slice(0, codeStart); + if (beforeCode.trim()) { + result.push({ + type: "text", + content: beforeCode + }); + } + // Try to detect language after ``` + const codeLangMatch = text.slice(codeStart + 3).match(/^(\w+)?\n/); + let lang = ""; + let codeContentStart = codeStart + 3; + if (codeLangMatch) { + lang = codeLangMatch[1] || ""; + codeContentStart += codeLangMatch[0].length; + } else if (text[codeStart + 3] === '\n') { + codeContentStart += 1; + } + const codeContent = text.slice(codeContentStart); + if (codeContent.trim()) { + result.push({ + type: "code", + lang, + content: codeContent, + completed: false + }); + } + } else if (text.trim()) { + result.push({ + type: "text", + content: text + }); + } + } + // console.log(JSON.stringify(result, null, 2)); + return result; + } + + /** + * Returns the original string with backslashes escaped + * @param { string } str + * @returns { string } + */ + function escapeBackslashes(str) { + return str.replace(/\\/g, '\\\\'); + } + + /** + * Wraps words to supplied maximum length + * @param { string | null } str + * @param { number } maxLen + * @returns { string } + */ + function wordWrap(str, maxLen) { + if (!str) + return ""; + let words = str.split(" "); + let lines = []; + let current = ""; + for (let i = 0; i < words.length; ++i) { + if ((current + (current.length > 0 ? " " : "") + words[i]).length > maxLen) { + if (current.length > 0) + lines.push(current); + current = words[i]; + } else { + current += (current.length > 0 ? " " : "") + words[i]; + } + } + if (current.length > 0) + lines.push(current); + return lines.join("\n"); + } + + /** + * Cleans up a music title by removing bracketed and special characters. + * @param { string } title + * @returns { string } + */ + function cleanMusicTitle(title) { + if (!title) + return ""; + // Brackets + title = title.replace(/^ *\([^)]*\) */g, " "); // Round brackets + title = title.replace(/^ *\[[^\]]*\] */g, " "); // Square brackets + title = title.replace(/^ *\{[^\}]*\} */g, " "); // Curly brackets + // Japenis brackets + title = title.replace(/^ *ใ€[^ใ€‘]*ใ€‘/, ""); // Touhou + title = title.replace(/^ *ใ€Š[^ใ€‹]*ใ€‹/, ""); // ?? + title = title.replace(/^ *ใ€Œ[^ใ€]*ใ€/, ""); // OP/ED thingie + title = title.replace(/^ *ใ€Ž[^ใ€]*ใ€/, ""); // OP/ED thingie + + return title.trim(); + } + + /** + * Converts seconds to a friendly time string (e.g. 1:23 or 1:02:03). + * @param { number } seconds + * @returns { string } + */ + function friendlyTimeForSeconds(seconds) { + if (isNaN(seconds) || seconds < 0) + return "0:00"; + seconds = Math.floor(seconds); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) { + return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + } else { + return `${m}:${s.toString().padStart(2, '0')}`; + } + } + + /** + * Escapes HTML special characters in a string. + * @param { string } str + * @returns { string } + */ + function escapeHtml(str) { + if (typeof str !== 'string') + return str; + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + /** + * Cleans a cliphist entry by removing leading digits and tab. + * @param { string } str + * @returns { string } + */ + function cleanCliphistEntry(str: string): string { + return str.replace(/^\d+\t/, ""); + } + + /** + * Checks if any substring in the list is contained in the string. + * @param { string } str + * @param { string[] } substrings + * @returns { boolean } + */ + function stringListContainsSubstring(str, substrings) { + for (let i = 0; i < substrings.length; ++i) { + if (str.includes(substrings[i])) { + return true; + } + } + return false; + } + + /** + * Removes the given prefix from the string if present. + * @param { string } str + * @param { string } prefix + * @returns { string } + */ + function cleanPrefix(str, prefix) { + if (str.startsWith(prefix)) { + return str.slice(prefix.length); + } + return str; + } + + /** + * Removes the first matching prefix from the string if present. + * @param { string } str + * @param { string[] } prefixes + * @returns { string } + */ + function cleanOnePrefix(str, prefixes) { + for (let i = 0; i < prefixes.length; ++i) { + if (str.startsWith(prefixes[i])) { + return str.slice(prefixes[i].length); + } + } + return str; + } + + function toTitleCase(str) { + // Replace "-" and "_" with space, then capitalize each word + return str.replace(/[-_]/g, " ").replace( + /\w\S*/g, + function(txt) { + return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); + } + ); + } +} diff --git a/modules/quickshell/config/modules/common/functions/fuzzysort.js b/modules/quickshell/config/modules/common/functions/fuzzysort.js new file mode 100644 index 0000000..1c1d9b9 --- /dev/null +++ b/modules/quickshell/config/modules/common/functions/fuzzysort.js @@ -0,0 +1,682 @@ +.pragma library + +// https://github.com/farzher/fuzzysort +// License: MIT | Copyright (c) 2018 Stephen Kamenar +// A copy of the license is available in the `licenses` folder of this repository + +var single = (search, target) => { + if(!search || !target) return NULL + + var preparedSearch = getPreparedSearch(search) + if(!isPrepared(target)) target = getPrepared(target) + + var searchBitflags = preparedSearch.bitflags + if((searchBitflags & target._bitflags) !== searchBitflags) return NULL + + return algorithm(preparedSearch, target) +} + +var go = (search, targets, options) => { + if(!search) return options?.all ? all(targets, options) : noResults + + var preparedSearch = getPreparedSearch(search) + var searchBitflags = preparedSearch.bitflags + var containsSpace = preparedSearch.containsSpace + + var threshold = denormalizeScore( options?.threshold || 0 ) + var limit = options?.limit || INFINITY + + var resultsLen = 0; var limitedCount = 0 + var targetsLen = targets.length + + function push_result(result) { + if(resultsLen < limit) { q.add(result); ++resultsLen } + else { + ++limitedCount + if(result._score > q.peek()._score) q.replaceTop(result) + } + } + + // This code is copy/pasted 3 times for performance reasons [options.key, options.keys, no keys] + + // options.key + if(options?.key) { + var key = options.key + for(var i = 0; i < targetsLen; ++i) { var obj = targets[i] + var target = getValue(obj, key) + if(!target) continue + if(!isPrepared(target)) target = getPrepared(target) + + if((searchBitflags & target._bitflags) !== searchBitflags) continue + var result = algorithm(preparedSearch, target) + if(result === NULL) continue + if(result._score < threshold) continue + + result.obj = obj + push_result(result) + } + + // options.keys + } else if(options?.keys) { + var keys = options.keys + var keysLen = keys.length + + outer: for(var i = 0; i < targetsLen; ++i) { var obj = targets[i] + + { // early out based on bitflags + var keysBitflags = 0 + for (var keyI = 0; keyI < keysLen; ++keyI) { + var key = keys[keyI] + var target = getValue(obj, key) + if(!target) { tmpTargets[keyI] = noTarget; continue } + if(!isPrepared(target)) target = getPrepared(target) + tmpTargets[keyI] = target + + keysBitflags |= target._bitflags + } + + if((searchBitflags & keysBitflags) !== searchBitflags) continue + } + + if(containsSpace) for(let i=0; i -1000) { + if(keysSpacesBestScores[i] > NEGATIVE_INFINITY) { + var tmp = (keysSpacesBestScores[i] + allowPartialMatchScores[i]) / 4/*bonus score for having multiple matches*/ + if(tmp > keysSpacesBestScores[i]) keysSpacesBestScores[i] = tmp + } + } + if(allowPartialMatchScores[i] > keysSpacesBestScores[i]) keysSpacesBestScores[i] = allowPartialMatchScores[i] + } + } + + if(containsSpace) { + for(let i=0; i -1000) { + if(score > NEGATIVE_INFINITY) { + var tmp = (score + result._score) / 4/*bonus score for having multiple matches*/ + if(tmp > score) score = tmp + } + } + if(result._score > score) score = result._score + } + } + + objResults.obj = obj + objResults._score = score + if(options?.scoreFn) { + score = options.scoreFn(objResults) + if(!score) continue + score = denormalizeScore(score) + objResults._score = score + } + + if(score < threshold) continue + push_result(objResults) + } + + // no keys + } else { + for(var i = 0; i < targetsLen; ++i) { var target = targets[i] + if(!target) continue + if(!isPrepared(target)) target = getPrepared(target) + + if((searchBitflags & target._bitflags) !== searchBitflags) continue + var result = algorithm(preparedSearch, target) + if(result === NULL) continue + if(result._score < threshold) continue + + push_result(result) + } + } + + if(resultsLen === 0) return noResults + var results = new Array(resultsLen) + for(var i = resultsLen - 1; i >= 0; --i) results[i] = q.poll() + results.total = resultsLen + limitedCount + return results +} + + +// this is written as 1 function instead of 2 for minification. perf seems fine ... +// except when minified. the perf is very slow +var highlight = (result, open='', close='') => { + var callback = typeof open === 'function' ? open : undefined + + var target = result.target + var targetLen = target.length + var indexes = result.indexes + var highlighted = '' + var matchI = 0 + var indexesI = 0 + var opened = false + var parts = [] + + for(var i = 0; i < targetLen; ++i) { var char = target[i] + if(indexes[indexesI] === i) { + ++indexesI + if(!opened) { opened = true + if(callback) { + parts.push(highlighted); highlighted = '' + } else { + highlighted += open + } + } + + if(indexesI === indexes.length) { + if(callback) { + highlighted += char + parts.push(callback(highlighted, matchI++)); highlighted = '' + parts.push(target.substr(i+1)) + } else { + highlighted += char + close + target.substr(i+1) + } + break + } + } else { + if(opened) { opened = false + if(callback) { + parts.push(callback(highlighted, matchI++)); highlighted = '' + } else { + highlighted += close + } + } + } + highlighted += char + } + + return callback ? parts : highlighted +} + + +var prepare = (target) => { + if(typeof target === 'number') target = ''+target + else if(typeof target !== 'string') target = '' + var info = prepareLowerInfo(target) + return new_result(target, {_targetLower:info._lower, _targetLowerCodes:info.lowerCodes, _bitflags:info.bitflags}) +} + +var cleanup = () => { preparedCache.clear(); preparedSearchCache.clear() } + + +// Below this point is only internal code +// Below this point is only internal code +// Below this point is only internal code +// Below this point is only internal code + + +class Result { + get ['indexes']() { return this._indexes.slice(0, this._indexes.len).sort((a,b)=>a-b) } + set ['indexes'](indexes) { return this._indexes = indexes } + ['highlight'](open, close) { return highlight(this, open, close) } + get ['score']() { return normalizeScore(this._score) } + set ['score'](score) { this._score = denormalizeScore(score) } +} + +class KeysResult extends Array { + get ['score']() { return normalizeScore(this._score) } + set ['score'](score) { this._score = denormalizeScore(score) } +} + +var new_result = (target, options) => { + const result = new Result() + result['target'] = target + result['obj'] = options.obj ?? NULL + result._score = options._score ?? NEGATIVE_INFINITY + result._indexes = options._indexes ?? [] + result._targetLower = options._targetLower ?? '' + result._targetLowerCodes = options._targetLowerCodes ?? NULL + result._nextBeginningIndexes = options._nextBeginningIndexes ?? NULL + result._bitflags = options._bitflags ?? 0 + return result +} + + +var normalizeScore = score => { + if(score === NEGATIVE_INFINITY) return 0 + if(score > 1) return score + return Math.E ** ( ((-score + 1)**.04307 - 1) * -2) +} +var denormalizeScore = normalizedScore => { + if(normalizedScore === 0) return NEGATIVE_INFINITY + if(normalizedScore > 1) return normalizedScore + return 1 - Math.pow((Math.log(normalizedScore) / -2 + 1), 1 / 0.04307) +} + + +var prepareSearch = (search) => { + if(typeof search === 'number') search = ''+search + else if(typeof search !== 'string') search = '' + search = search.trim() + var info = prepareLowerInfo(search) + + var spaceSearches = [] + if(info.containsSpace) { + var searches = search.split(/\s+/) + searches = [...new Set(searches)] // distinct + for(var i=0; i { + if(target.length > 999) return prepare(target) // don't cache huge targets + var targetPrepared = preparedCache.get(target) + if(targetPrepared !== undefined) return targetPrepared + targetPrepared = prepare(target) + preparedCache.set(target, targetPrepared) + return targetPrepared +} +var getPreparedSearch = (search) => { + if(search.length > 999) return prepareSearch(search) // don't cache huge searches + var searchPrepared = preparedSearchCache.get(search) + if(searchPrepared !== undefined) return searchPrepared + searchPrepared = prepareSearch(search) + preparedSearchCache.set(search, searchPrepared) + return searchPrepared +} + + +var all = (targets, options) => { + var results = []; results.total = targets.length // this total can be wrong if some targets are skipped + + var limit = options?.limit || INFINITY + + if(options?.key) { + for(var i=0;i= limit) return results + } + } else if(options?.keys) { + for(var i=0;i= 0; --keyI) { + var target = getValue(obj, options.keys[keyI]) + if(!target) { objResults[keyI] = noTarget; continue } + if(!isPrepared(target)) target = getPrepared(target) + target._score = NEGATIVE_INFINITY + target._indexes.len = 0 + objResults[keyI] = target + } + objResults.obj = obj + objResults._score = NEGATIVE_INFINITY + results.push(objResults); if(results.length >= limit) return results + } + } else { + for(var i=0;i= limit) return results + } + } + + return results +} + + +var algorithm = (preparedSearch, prepared, allowSpaces=false, allowPartialMatch=false) => { + if(allowSpaces===false && preparedSearch.containsSpace) return algorithmSpaces(preparedSearch, prepared, allowPartialMatch) + + var searchLower = preparedSearch._lower + var searchLowerCodes = preparedSearch.lowerCodes + var searchLowerCode = searchLowerCodes[0] + var targetLowerCodes = prepared._targetLowerCodes + var searchLen = searchLowerCodes.length + var targetLen = targetLowerCodes.length + var searchI = 0 // where we at + var targetI = 0 // where you at + var matchesSimpleLen = 0 + + // very basic fuzzy match; to remove non-matching targets ASAP! + // walk through target. find sequential matches. + // if all chars aren't found then exit + for(;;) { + var isMatch = searchLowerCode === targetLowerCodes[targetI] + if(isMatch) { + matchesSimple[matchesSimpleLen++] = targetI + ++searchI; if(searchI === searchLen) break + searchLowerCode = searchLowerCodes[searchI] + } + ++targetI; if(targetI >= targetLen) return NULL // Failed to find searchI + } + + var searchI = 0 + var successStrict = false + var matchesStrictLen = 0 + + var nextBeginningIndexes = prepared._nextBeginningIndexes + if(nextBeginningIndexes === NULL) nextBeginningIndexes = prepared._nextBeginningIndexes = prepareNextBeginningIndexes(prepared.target) + targetI = matchesSimple[0]===0 ? 0 : nextBeginningIndexes[matchesSimple[0]-1] + + // Our target string successfully matched all characters in sequence! + // Let's try a more advanced and strict test to improve the score + // only count it as a match if it's consecutive or a beginning character! + var backtrackCount = 0 + if(targetI !== targetLen) for(;;) { + if(targetI >= targetLen) { + // We failed to find a good spot for this search char, go back to the previous search char and force it forward + if(searchI <= 0) break // We failed to push chars forward for a better match + + ++backtrackCount; if(backtrackCount > 200) break // exponential backtracking is taking too long, just give up and return a bad match + + --searchI + var lastMatch = matchesStrict[--matchesStrictLen] + targetI = nextBeginningIndexes[lastMatch] + + } else { + var isMatch = searchLowerCodes[searchI] === targetLowerCodes[targetI] + if(isMatch) { + matchesStrict[matchesStrictLen++] = targetI + ++searchI; if(searchI === searchLen) { successStrict = true; break } + ++targetI + } else { + targetI = nextBeginningIndexes[targetI] + } + } + } + + // check if it's a substring match + var substringIndex = searchLen <= 1 ? -1 : prepared._targetLower.indexOf(searchLower, matchesSimple[0]) // perf: this is slow + var isSubstring = !!~substringIndex + var isSubstringBeginning = !isSubstring ? false : substringIndex===0 || prepared._nextBeginningIndexes[substringIndex-1] === substringIndex + + // if it's a substring match but not at a beginning index, let's try to find a substring starting at a beginning index for a better score + if(isSubstring && !isSubstringBeginning) { + for(var i=0; i { + var score = 0 + + var extraMatchGroupCount = 0 + for(var i = 1; i < searchLen; ++i) { + if(matches[i] - matches[i-1] !== 1) {score -= matches[i]; ++extraMatchGroupCount} + } + var unmatchedDistance = matches[searchLen-1] - matches[0] - (searchLen-1) + + score -= (12+unmatchedDistance) * extraMatchGroupCount // penality for more groups + + if(matches[0] !== 0) score -= matches[0]*matches[0]*.2 // penality for not starting near the beginning + + if(!successStrict) { + score *= 1000 + } else { + // successStrict on a target with too many beginning indexes loses points for being a bad target + var uniqueBeginningIndexes = 1 + for(var i = nextBeginningIndexes[0]; i < targetLen; i=nextBeginningIndexes[i]) ++uniqueBeginningIndexes + + if(uniqueBeginningIndexes > 24) score *= (uniqueBeginningIndexes-24)*10 // quite arbitrary numbers here ... + } + + score -= (targetLen - searchLen)/2 // penality for longer targets + + if(isSubstring) score /= 1+searchLen*searchLen*1 // bonus for being a full substring + if(isSubstringBeginning) score /= 1+searchLen*searchLen*1 // bonus for substring starting on a beginningIndex + + score -= (targetLen - searchLen)/2 // penality for longer targets + + return score + } + + if(!successStrict) { + if(isSubstring) for(var i=0; i { + var seen_indexes = new Set() + var score = 0 + var result = NULL + + var first_seen_index_last_search = 0 + var searches = preparedSearch.spaceSearches + var searchesLen = searches.length + var changeslen = 0 + + // Return _nextBeginningIndexes back to its normal state + var resetNextBeginningIndexes = () => { + for(let i=changeslen-1; i>=0; i--) target._nextBeginningIndexes[nextBeginningIndexesChanges[i*2 + 0]] = nextBeginningIndexesChanges[i*2 + 1] + } + + var hasAtLeast1Match = false + for(var i=0; i=0; i--) { + if(toReplace !== target._nextBeginningIndexes[i]) break + target._nextBeginningIndexes[i] = newBeginningIndex + nextBeginningIndexesChanges[changeslen*2 + 0] = i + nextBeginningIndexesChanges[changeslen*2 + 1] = toReplace + changeslen++ + } + } + } + + score += result._score / searchesLen + allowPartialMatchScores[i] = result._score / searchesLen + + // dock points based on order otherwise "c man" returns Manifest.cpp instead of CheatManager.h + if(result._indexes[0] < first_seen_index_last_search) { + score -= (first_seen_index_last_search - result._indexes[0]) * 2 + } + first_seen_index_last_search = result._indexes[0] + + for(var j=0; j score) { + if(allowPartialMatch) { + for(var i=0; i str.replace(/\p{Script=Latin}+/gu, match => match.normalize('NFD')).replace(/[\u0300-\u036f]/g, '') + +var prepareLowerInfo = (str) => { + str = remove_accents(str) + var strLen = str.length + var lower = str.toLowerCase() + var lowerCodes = [] // new Array(strLen) sparse array is too slow + var bitflags = 0 + var containsSpace = false // space isn't stored in bitflags because of how searching with a space works + + for(var i = 0; i < strLen; ++i) { + var lowerCode = lowerCodes[i] = lower.charCodeAt(i) + + if(lowerCode === 32) { + containsSpace = true + continue // it's important that we don't set any bitflags for space + } + + var bit = lowerCode>=97&&lowerCode<=122 ? lowerCode-97 // alphabet + : lowerCode>=48&&lowerCode<=57 ? 26 // numbers + // 3 bits available + : lowerCode<=127 ? 30 // other ascii + : 31 // other utf8 + bitflags |= 1< { + var targetLen = target.length + var beginningIndexes = []; var beginningIndexesLen = 0 + var wasUpper = false + var wasAlphanum = false + for(var i = 0; i < targetLen; ++i) { + var targetCode = target.charCodeAt(i) + var isUpper = targetCode>=65&&targetCode<=90 + var isAlphanum = isUpper || targetCode>=97&&targetCode<=122 || targetCode>=48&&targetCode<=57 + var isBeginning = isUpper && !wasUpper || !wasAlphanum || !isAlphanum + wasUpper = isUpper + wasAlphanum = isAlphanum + if(isBeginning) beginningIndexes[beginningIndexesLen++] = i + } + return beginningIndexes +} +var prepareNextBeginningIndexes = (target) => { + target = remove_accents(target) + var targetLen = target.length + var beginningIndexes = prepareBeginningIndexes(target) + var nextBeginningIndexes = [] // new Array(targetLen) sparse array is too slow + var lastIsBeginning = beginningIndexes[0] + var lastIsBeginningI = 0 + for(var i = 0; i < targetLen; ++i) { + if(lastIsBeginning > i) { + nextBeginningIndexes[i] = lastIsBeginning + } else { + lastIsBeginning = beginningIndexes[++lastIsBeginningI] + nextBeginningIndexes[i] = lastIsBeginning===undefined ? targetLen : lastIsBeginning + } + } + return nextBeginningIndexes +} + +var preparedCache = new Map() +var preparedSearchCache = new Map() + +// the theory behind these being globals is to reduce garbage collection by not making new arrays +var matchesSimple = []; var matchesStrict = [] +var nextBeginningIndexesChanges = [] // allows straw berry to match strawberry well, by modifying the end of a substring to be considered a beginning index for the rest of the search +var keysSpacesBestScores = []; var allowPartialMatchScores = [] +var tmpTargets = []; var tmpResults = [] + +// prop = 'key' 2.5ms optimized for this case, seems to be about as fast as direct obj[prop] +// prop = 'key1.key2' 10ms +// prop = ['key1', 'key2'] 27ms +// prop = obj => obj.tags.join() ??ms +var getValue = (obj, prop) => { + var tmp = obj[prop]; if(tmp !== undefined) return tmp + if(typeof prop === 'function') return prop(obj) // this should run first. but that makes string props slower + var segs = prop + if(!Array.isArray(prop)) segs = prop.split('.') + var len = segs.length + var i = -1 + while (obj && (++i < len)) obj = obj[segs[i]] + return obj +} + +var isPrepared = (x) => { return typeof x === 'object' && typeof x._bitflags === 'number' } +var INFINITY = Infinity; var NEGATIVE_INFINITY = -INFINITY +var noResults = []; noResults.total = 0 +var NULL = null + +var noTarget = prepare('') + +// Hacked version of https://github.com/lemire/FastPriorityQueue.js +var fastpriorityqueue=r=>{var e=[],o=0,a={},v=r=>{for(var a=0,v=e[a],c=1;c>1]=e[a],c=1+(a<<1)}for(var f=a-1>>1;a>0&&v._score>1)e[a]=e[f];e[a]=v};return a.add=(r=>{var a=o;e[o++]=r;for(var v=a-1>>1;a>0&&r._score>1)e[a]=e[v];e[a]=r}),a.poll=(r=>{if(0!==o){var a=e[0];return e[0]=e[--o],v(),a}}),a.peek=(r=>{if(0!==o)return e[0]}),a.replaceTop=(r=>{e[0]=r,v()}),a} +var q = fastpriorityqueue() // reuse this diff --git a/modules/quickshell/config/modules/common/functions/levendist.js b/modules/quickshell/config/modules/common/functions/levendist.js new file mode 100644 index 0000000..90180d2 --- /dev/null +++ b/modules/quickshell/config/modules/common/functions/levendist.js @@ -0,0 +1,141 @@ +// Original code from https://github.com/koeqaife/hyprland-material-you +// Original code license: GPLv3 +// Translated to Js from Cython with an LLM and reviewed + +function min3(a, b, c) { + return a < b && a < c ? a : b < c ? b : c; +} + +function max3(a, b, c) { + return a > b && a > c ? a : b > c ? b : c; +} + +function min2(a, b) { + return a < b ? a : b; +} + +function max2(a, b) { + return a > b ? a : b; +} + +function levenshteinDistance(s1, s2) { + let len1 = s1.length; + let len2 = s2.length; + + if (len1 === 0) return len2; + if (len2 === 0) return len1; + + if (len2 > len1) { + [s1, s2] = [s2, s1]; + [len1, len2] = [len2, len1]; + } + + let prev = new Array(len2 + 1); + let curr = new Array(len2 + 1); + + for (let j = 0; j <= len2; j++) { + prev[j] = j; + } + + for (let i = 1; i <= len1; i++) { + curr[0] = i; + for (let j = 1; j <= len2; j++) { + let cost = s1[i - 1] === s2[j - 1] ? 0 : 1; + curr[j] = min3(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); + } + [prev, curr] = [curr, prev]; + } + + return prev[len2]; +} + +function partialRatio(shortS, longS) { + let lenS = shortS.length; + let lenL = longS.length; + let best = 0.0; + + if (lenS === 0) return 1.0; + + for (let i = 0; i <= lenL - lenS; i++) { + let sub = longS.slice(i, i + lenS); + let dist = levenshteinDistance(shortS, sub); + let score = 1.0 - (dist / lenS); + if (score > best) best = score; + } + + return best; +} + +function computeScore(s1, s2) { + if (s1 === s2) return 1.0; + + let dist = levenshteinDistance(s1, s2); + let maxLen = max2(s1.length, s2.length); + if (maxLen === 0) return 1.0; + + let full = 1.0 - (dist / maxLen); + let part = s1.length < s2.length ? partialRatio(s1, s2) : partialRatio(s2, s1); + + let score = 0.85 * full + 0.15 * part; + + if (s1 && s2 && s1[0] !== s2[0]) { + score -= 0.05; + } + + let lenDiff = Math.abs(s1.length - s2.length); + if (lenDiff >= 3) { + score -= 0.05 * lenDiff / maxLen; + } + + let commonPrefixLen = 0; + let minLen = min2(s1.length, s2.length); + for (let i = 0; i < minLen; i++) { + if (s1[i] === s2[i]) { + commonPrefixLen++; + } else { + break; + } + } + score += 0.02 * commonPrefixLen; + + if (s1.includes(s2) || s2.includes(s1)) { + score += 0.06; + } + + return Math.max(0.0, Math.min(1.0, score)); +} + +function computeTextMatchScore(s1, s2) { + if (s1 === s2) return 1.0; + + let dist = levenshteinDistance(s1, s2); + let maxLen = max2(s1.length, s2.length); + if (maxLen === 0) return 1.0; + + let full = 1.0 - (dist / maxLen); + let part = s1.length < s2.length ? partialRatio(s1, s2) : partialRatio(s2, s1); + + let score = 0.4 * full + 0.6 * part; + + let lenDiff = Math.abs(s1.length - s2.length); + if (lenDiff >= 10) { + score -= 0.02 * lenDiff / maxLen; + } + + let commonPrefixLen = 0; + let minLen = min2(s1.length, s2.length); + for (let i = 0; i < minLen; i++) { + if (s1[i] === s2[i]) { + commonPrefixLen++; + } else { + break; + } + } + score += 0.01 * commonPrefixLen; + + if (s1.includes(s2) || s2.includes(s1)) { + score += 0.2; + } + + return Math.max(0.0, Math.min(1.0, score)); +} diff --git a/modules/quickshell/config/modules/common/models/AdaptedMaterialScheme.qml b/modules/quickshell/config/modules/common/models/AdaptedMaterialScheme.qml new file mode 100644 index 0000000..6cc374c --- /dev/null +++ b/modules/quickshell/config/modules/common/models/AdaptedMaterialScheme.qml @@ -0,0 +1,27 @@ +import QtQuick +import qs.modules.common +import qs.modules.common.functions + +/** + * Material color scheme adapted to a given color. It's incomplete but enough for what we need... + */ +QtObject { + id: root + required property color color + readonly property bool colorIsDark: color.hslLightness < 0.5 + + property color colLayer0: ColorUtils.mix(Appearance.colors.colLayer0, root.color, (colorIsDark && Appearance.m3colors.darkmode) ? 0.6 : 0.5) + property color colLayer1: ColorUtils.mix(Appearance.colors.colLayer1, root.color, 0.5) + property color colOnLayer0: ColorUtils.mix(Appearance.colors.colOnLayer0, root.color, 0.5) + property color colOnLayer1: ColorUtils.mix(Appearance.colors.colOnLayer1, root.color, 0.5) + property color colSubtext: ColorUtils.mix(Appearance.colors.colOnLayer1, root.color, 0.5) + property color colPrimary: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimary, root.color), root.color, 0.5) + property color colPrimaryHover: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimaryHover, root.color), root.color, 0.3) + property color colPrimaryActive: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimaryActive, root.color), root.color, 0.3) + property color colSecondary: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colSecondary, root.color), root.color, 0.5) + property color colSecondaryContainer: ColorUtils.mix(Appearance.m3colors.m3secondaryContainer, root.color, 0.15) + property color colSecondaryContainerHover: ColorUtils.mix(Appearance.colors.colSecondaryContainerHover, root.color, 0.3) + property color colSecondaryContainerActive: ColorUtils.mix(Appearance.colors.colSecondaryContainerActive, root.color, 0.5) + property color colOnPrimary: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.m3colors.m3onPrimary, root.color), root.color, 0.5) + property color colOnSecondaryContainer: ColorUtils.mix(Appearance.m3colors.m3onSecondaryContainer, root.color, 0.5) +} diff --git a/modules/quickshell/config/modules/common/models/AnimatedTabIndexPair.qml b/modules/quickshell/config/modules/common/models/AnimatedTabIndexPair.qml new file mode 100644 index 0000000..c18e9cc --- /dev/null +++ b/modules/quickshell/config/modules/common/models/AnimatedTabIndexPair.qml @@ -0,0 +1,26 @@ +import QtQuick + +// idx1 is the "leading" indicator position, idx2 is the "following" one +// The former animates faster than the latter, see the NumberAnimations below +QtObject { + id: root + required property int index + + property real idx1: index + property real idx2: index + property int idx1Duration: 100 + property int idx2Duration: 300 + + Behavior on idx1 { + NumberAnimation { + duration: root.idx1Duration + easing.type: Easing.OutSine + } + } + Behavior on idx2 { + NumberAnimation { + duration: root.idx2Duration + easing.type: Easing.OutSine + } + } +} diff --git a/modules/quickshell/config/modules/common/models/FolderListModelWithHistory.qml b/modules/quickshell/config/modules/common/models/FolderListModelWithHistory.qml new file mode 100644 index 0000000..2aeb3e7 --- /dev/null +++ b/modules/quickshell/config/modules/common/models/FolderListModelWithHistory.qml @@ -0,0 +1,53 @@ +import QtQuick +import Qt.labs.folderlistmodel + +FolderListModel { + id: root + property list folderHistory: [] + property int currentFolderHistoryIndex: -1 + property bool historyNavigationLock: false + + function lockNextNavigation() { + historyNavigationLock = true; + } + + function pushToHistory(path) { + if (folderHistory[currentFolderHistoryIndex] === path) + return; + folderHistory = folderHistory.slice(0, currentFolderHistoryIndex + 1); + folderHistory.push(path); + currentFolderHistoryIndex = folderHistory.length - 1; + } + + function navigateUp() { + root.folder = root.parentFolder; + } + + function navigateBack() { + if (currentFolderHistoryIndex === 0) + return; + currentFolderHistoryIndex--; + lockNextNavigation(); + root.folder = folderHistory[currentFolderHistoryIndex]; + } + + function navigateForward() { + if (currentFolderHistoryIndex >= folderHistory.length - 1) return; + currentFolderHistoryIndex++; + lockNextNavigation(); + root.folder = folderHistory[currentFolderHistoryIndex]; + } + + onFolderChanged: { + if (historyNavigationLock) { + historyNavigationLock = false; + return; + } + pushToHistory(folder); + } + + Component.onCompleted: { + root.folderHistory = [root.folder] + root.currentFolderHistoryIndex = 0 + } +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/AntiFlashbangToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/AntiFlashbangToggle.qml new file mode 100644 index 0000000..088434a --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/AntiFlashbangToggle.qml @@ -0,0 +1,17 @@ +import QtQuick +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +QuickToggleModel { + name: Translation.tr("Anti-flashbang") + tooltipText: Translation.tr("Anti-flashbang") + icon: "flash_off" + toggled: Config.options.light.antiFlashbang.enable + + mainAction: () => { + Config.options.light.antiFlashbang.enable = !Config.options.light.antiFlashbang.enable; + } + hasMenu: true +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/AudioToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/AudioToggle.qml new file mode 100644 index 0000000..3f79674 --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/AudioToggle.qml @@ -0,0 +1,17 @@ +import QtQuick +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +QuickToggleModel { + name: Translation.tr("Audio output") + statusText: toggled ? Translation.tr("Unmuted") : Translation.tr("Muted") + tooltipText: Translation.tr("Audio output | Right-click for volume mixer & device selector") + toggled: !Audio.sink?.audio?.muted + icon: Audio.sink?.audio?.muted ? "volume_off" : "volume_up" + mainAction: () => { + Audio.toggleMute() + } + hasMenu: true +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/BluetoothToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/BluetoothToggle.qml new file mode 100644 index 0000000..c56b826 --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/BluetoothToggle.qml @@ -0,0 +1,23 @@ +import QtQuick +import Quickshell.Bluetooth +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +QuickToggleModel { + name: Translation.tr("Bluetooth") + statusText: BluetoothStatus.firstActiveDevice?.name ?? Translation.tr("Not connected") + tooltipText: Translation.tr("%1 | Right-click to configure").arg( + (BluetoothStatus.firstActiveDevice?.name ?? Translation.tr("Bluetooth")) + + (BluetoothStatus.activeDeviceCount > 1 ? ` +${BluetoothStatus.activeDeviceCount - 1}` : "") + ) + icon: BluetoothStatus.connected ? "bluetooth_connected" : BluetoothStatus.enabled ? "bluetooth" : "bluetooth_disabled" + + available: BluetoothStatus.available + toggled: BluetoothStatus.enabled + mainAction: () => { + Bluetooth.defaultAdapter.enabled = !Bluetooth.defaultAdapter?.enabled + } + hasMenu: true +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/CloudflareWarpToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/CloudflareWarpToggle.qml new file mode 100644 index 0000000..015010c --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/CloudflareWarpToggle.qml @@ -0,0 +1,78 @@ +import QtQuick +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import Quickshell +import Quickshell.Io + +QuickToggleModel { + id: root + name: Translation.tr("Cloudflare WARP") + + toggled: false + icon: "cloud_lock" + + mainAction: () => { + if (toggled) { + root.toggled = false + Quickshell.execDetached(["warp-cli", "disconnect"]) + } else { + root.toggled = true + Quickshell.execDetached(["warp-cli", "connect"]) + } + } + + Process { + id: connectProc + command: ["warp-cli", "connect"] + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + Quickshell.execDetached(["notify-send", + Translation.tr("Cloudflare WARP"), + Translation.tr("Connection failed. Please inspect manually with the warp-cli command") + , "-a", "Shell" + ]) + } + } + } + + Process { + id: registrationProc + command: ["warp-cli", "registration", "new"] + onExited: (exitCode, exitStatus) => { + console.log("Warp registration exited with code and status:", exitCode, exitStatus) + if (exitCode === 0) { + connectProc.running = true + } else { + Quickshell.execDetached(["notify-send", + Translation.tr("Cloudflare WARP"), + Translation.tr("Registration failed. Please inspect manually with the warp-cli command"), + "-a", "Shell" + ]) + } + } + } + + Process { + id: fetchActiveState + running: true + command: ["bash", "-c", "warp-cli status"] + stdout: StdioCollector { + id: warpStatusCollector + onStreamFinished: { + if (warpStatusCollector.text.length > 0) { + root.available = true + } + if (warpStatusCollector.text.includes("Unable")) { + registrationProc.running = true + } else if (warpStatusCollector.text.includes("Connected")) { + root.toggled = true + } else if (warpStatusCollector.text.includes("Disconnected")) { + root.toggled = false + } + } + } + } + tooltipText: Translation.tr("Cloudflare WARP (1.1.1.1)") +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/ColorPickerToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/ColorPickerToggle.qml new file mode 100644 index 0000000..611e256 --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/ColorPickerToggle.qml @@ -0,0 +1,29 @@ +import QtQuick +import Quickshell +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +QuickToggleModel { + name: Translation.tr("Color picker") + hasStatusText: false + toggled: false + icon: "colorize" + + mainAction: () => { + GlobalStates.sidebarRightOpen = false; + delayedActionTimer.start(); + } + Timer { + id: delayedActionTimer + interval: 300 + repeat: false + onTriggered: { + Quickshell.execDetached(["hyprpicker", "-a"]); + } + } + + tooltipText: Translation.tr("Color picker") +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/DarkModeToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/DarkModeToggle.qml new file mode 100644 index 0000000..8a98f6f --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/DarkModeToggle.qml @@ -0,0 +1,25 @@ +import QtQuick +import Quickshell +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +QuickToggleModel { + name: Translation.tr("Dark Mode") + statusText: Appearance.m3colors.darkmode ? Translation.tr("Dark") : Translation.tr("Light") + + toggled: Appearance.m3colors.darkmode + icon: "contrast" + + mainAction: () => { + if (Appearance.m3colors.darkmode) { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", "light", "--noswitch"]); + } else { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", "dark", "--noswitch"]); + } + } + + tooltipText: Translation.tr("Dark Mode") +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/EasyEffectsToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/EasyEffectsToggle.qml new file mode 100644 index 0000000..4f76c09 --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/EasyEffectsToggle.qml @@ -0,0 +1,30 @@ +import QtQuick +import Quickshell +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +QuickToggleModel { + name: Translation.tr("EasyEffects") + + available: EasyEffects.available + toggled: EasyEffects.active + icon: "graphic_eq" + + Component.onCompleted: { + EasyEffects.fetchActiveState() + } + + mainAction: () => { + EasyEffects.toggle() + } + + altAction: () => { + Quickshell.execDetached(["bash", "-c", "flatpak run com.github.wwmm.easyeffects || easyeffects"]) + GlobalStates.sidebarRightOpen = false + } + + tooltipText: Translation.tr("EasyEffects | Right-click to configure") +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/GameModeToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/GameModeToggle.qml new file mode 100644 index 0000000..d8ba740 --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/GameModeToggle.qml @@ -0,0 +1,33 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +QuickToggleModel { + id: root + name: Translation.tr("Game mode") + toggled: toggled + icon: "gamepad" + + mainAction: () => { + root.toggled = !root.toggled + if (root.toggled) { + Quickshell.execDetached(["bash", "-c", `hyprctl --batch "keyword animations:enabled 0; keyword decoration:shadow:enabled 0; keyword decoration:blur:enabled 0; keyword general:gaps_in 0; keyword general:gaps_out 0; keyword general:border_size 1; keyword decoration:rounding 0; keyword general:allow_tearing 1"`]) + } else { + Quickshell.execDetached(["hyprctl", "reload"]) + } + } + Process { + id: fetchActiveState + running: true + command: ["bash", "-c", `test "$(hyprctl getoption animations:enabled -j | jq ".int")" -ne 0`] + onExited: (exitCode, exitStatus) => { + root.toggled = exitCode !== 0 // Inverted because enabled = nonzero exit + } + } + tooltipText: Translation.tr("Game mode") +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/IdleInhibitorToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/IdleInhibitorToggle.qml new file mode 100644 index 0000000..b6cf252 --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/IdleInhibitorToggle.qml @@ -0,0 +1,18 @@ +import QtQuick +import Quickshell +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +QuickToggleModel { + name: Translation.tr("Keep awake") + + toggled: Idle.inhibit + icon: "coffee" + mainAction: () => { + Idle.toggleInhibit() + } + tooltipText: Translation.tr("Keep system awake") +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/MicToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/MicToggle.qml new file mode 100644 index 0000000..ced0cb0 --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/MicToggle.qml @@ -0,0 +1,20 @@ +import QtQuick +import Quickshell +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +QuickToggleModel { + name: Translation.tr("Audio input") + statusText: toggled ? Translation.tr("Enabled") : Translation.tr("Muted") + toggled: !Audio.source?.audio?.muted + icon: Audio.source?.audio?.muted ? "mic_off" : "mic" + mainAction: () => { + Audio.toggleMicMute() + } + hasMenu: true + + tooltipText: Translation.tr("Audio input | Right-click for volume mixer & device selector") +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/MusicRecognitionToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/MusicRecognitionToggle.qml new file mode 100644 index 0000000..36f161a --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/MusicRecognitionToggle.qml @@ -0,0 +1,25 @@ +import QtQuick +import Quickshell +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +QuickToggleModel { + toggled: SongRec.running + property bool sourceIsMonitor: SongRec.monitorSource === SongRec.MonitorSource.Monitor + + name: Translation.tr("Identify Music") + statusText: toggled ? Translation.tr("Listening...") : sourceIsMonitor ? Translation.tr("System sound") : Translation.tr("Microphone") + icon: toggled ? "music_cast" : (sourceIsMonitor ? "music_note" : "frame_person_mic") + + tooltipText: Translation.tr("Recognize music | Right-click to toggle source") + + mainAction: () => { + SongRec.toggleRunning() + } + altAction: () => { + SongRec.toggleMonitorSource() + } +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/NetworkToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/NetworkToggle.qml new file mode 100644 index 0000000..f6f412d --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/NetworkToggle.qml @@ -0,0 +1,16 @@ +import QtQuick +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +QuickToggleModel { + name: Translation.tr("Internet") + statusText: Network.networkName + tooltipText: Translation.tr("%1 | Right-click to configure").arg(Network.networkName) + icon: Network.materialSymbol + + toggled: Network.wifiStatus !== "disabled" + mainAction: () => Network.toggleWifi() + hasMenu: true +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/NightLightToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/NightLightToggle.qml new file mode 100644 index 0000000..d53af97 --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/NightLightToggle.qml @@ -0,0 +1,28 @@ +import QtQuick +import Quickshell +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +QuickToggleModel { + property bool auto: Config.options.light.night.automatic + + name: Translation.tr("Night Light") + statusText: (auto ? Translation.tr("Auto, ") : "") + (toggled ? Translation.tr("Active") : Translation.tr("Inactive")) + + toggled: Hyprsunset.active + icon: auto ? "night_sight_auto" : "bedtime" + + mainAction: () => { + Hyprsunset.toggle() + } + hasMenu: true + + Component.onCompleted: { + Hyprsunset.fetchState() + } + + tooltipText: Translation.tr("Night Light | Right-click to configure") +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/NotificationToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/NotificationToggle.qml new file mode 100644 index 0000000..3c4c871 --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/NotificationToggle.qml @@ -0,0 +1,20 @@ +import QtQuick +import Quickshell +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +QuickToggleModel { + name: Translation.tr("Notifications") + statusText: toggled ? Translation.tr("Show") : Translation.tr("Silent") + toggled: !Notifications.silent + icon: toggled ? "notifications_active" : "notifications_paused" + + mainAction: () => { + Notifications.silent = !Notifications.silent; + } + + tooltipText: Translation.tr("Show notifications") +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/OnScreenKeyboardToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/OnScreenKeyboardToggle.qml new file mode 100644 index 0000000..f98ee5a --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/OnScreenKeyboardToggle.qml @@ -0,0 +1,19 @@ +import QtQuick +import Quickshell +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +QuickToggleModel { + name: Translation.tr("Virtual Keyboard") + toggled: GlobalStates.oskOpen + icon: toggled ? "keyboard_hide" : "keyboard" + + mainAction: () => { + GlobalStates.oskOpen = !GlobalStates.oskOpen + } + + tooltipText: Translation.tr("On-screen keyboard") +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/PowerProfilesToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/PowerProfilesToggle.qml new file mode 100644 index 0000000..6fa9d38 --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/PowerProfilesToggle.qml @@ -0,0 +1,39 @@ +import QtQuick +import Quickshell +import Quickshell.Services.UPower +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +QuickToggleModel { + name: Translation.tr("Power Profile") + toggled: PowerProfiles.profile !== PowerProfile.Balanced + icon: switch(PowerProfiles.profile) { + case PowerProfile.PowerSaver: return "energy_savings_leaf" + case PowerProfile.Balanced: return "airwave" + case PowerProfile.Performance: return "local_fire_department" + } + statusText: switch(PowerProfiles.profile) { + case PowerProfile.PowerSaver: return "Power Saver" + case PowerProfile.Balanced: return "Balanced" + case PowerProfile.Performance: return "Performance" + } + + mainAction: () => { + if (PowerProfiles.hasPerformanceProfile) { + switch(PowerProfiles.profile) { + case PowerProfile.PowerSaver: PowerProfiles.profile = PowerProfile.Balanced + break; + case PowerProfile.Balanced: PowerProfiles.profile = PowerProfile.Performance + break; + case PowerProfile.Performance: PowerProfiles.profile = PowerProfile.PowerSaver + break; + } + } else { + PowerProfiles.profile = PowerProfiles.profile == PowerProfile.Balanced ? PowerProfile.PowerSaver : PowerProfile.Balanced + } + } + tooltipText: Translation.tr("Click to cycle through power profiles") +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/QuickToggleModel.qml b/modules/quickshell/config/modules/common/models/quickToggles/QuickToggleModel.qml new file mode 100644 index 0000000..176edcb --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/QuickToggleModel.qml @@ -0,0 +1,22 @@ +import QtQuick + +QtObject { + // Textual info + required property string name + property string statusText + property string tooltipText: "" + property string icon: "close" + + // State + property bool hasStatusText: true + property bool available: true + property bool toggled: false + + // Interactions + required property var mainAction + property bool hasMenu: false + property var altAction: null + + // Allow stuff like Processes to be declared freely + default property list data +} diff --git a/modules/quickshell/config/modules/common/models/quickToggles/ScreenSnipToggle.qml b/modules/quickshell/config/modules/common/models/quickToggles/ScreenSnipToggle.qml new file mode 100644 index 0000000..28c5333 --- /dev/null +++ b/modules/quickshell/config/modules/common/models/quickToggles/ScreenSnipToggle.qml @@ -0,0 +1,29 @@ +import QtQuick +import Quickshell +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +QuickToggleModel { + name: Translation.tr("Screen snip") + hasStatusText: false + toggled: false + icon: "screenshot_region" + + mainAction: () => { + GlobalStates.sidebarRightOpen = false; + delayedActionTimer.start(); + } + Timer { + id: delayedActionTimer + interval: 300 + repeat: false + onTriggered: { + Quickshell.execDetached(["qs", "-p", Quickshell.shellPath(""), "ipc", "call", "region", "screenshot"]); + } + } + + tooltipText: Translation.tr("Screen snip") +} diff --git a/modules/quickshell/config/modules/common/utils/ImageDownloaderProcess.qml b/modules/quickshell/config/modules/common/utils/ImageDownloaderProcess.qml new file mode 100644 index 0000000..11ff92a --- /dev/null +++ b/modules/quickshell/config/modules/common/utils/ImageDownloaderProcess.qml @@ -0,0 +1,31 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.modules.common +import qs.modules.common.functions + +Process { + id: root + + signal done(string path, int width, int height); + required property string filePath; + required property string sourceUrl; + property string downloadUserAgent: Config.options?.networking.userAgent ?? "" + + function processFilePath() { + return StringUtils.shellSingleQuoteEscape(FileUtils.trimFileProtocol(filePath)); + } + + running: true + command: ["bash", "-c", + `mkdir -p $(dirname '${processFilePath(filePath)}'); [ -f '${processFilePath(filePath)}' ] || curl -sSL '${sourceUrl}' -o '${processFilePath(filePath)}' && magick identify -format '%w %h' '${processFilePath(filePath)}'[0]` + ] + stdout: StdioCollector { + id: imageSizeOutputCollector + onStreamFinished: { + const output = imageSizeOutputCollector.text.trim(); + const [width, height] = output.split(" ").map(Number); + root.done(root.filePath, width, height); + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/AddressBar.qml b/modules/quickshell/config/modules/common/widgets/AddressBar.qml new file mode 100644 index 0000000..e608e9c --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/AddressBar.qml @@ -0,0 +1,120 @@ +import QtQuick +import QtQuick.Layouts +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +Rectangle { + id: root + required property var directory + property bool showBreadcrumb: true + onShowBreadcrumbChanged: { + addressInput.text = root.directory; + } + + signal navigateToDirectory(string path) + + property real padding: 6 + implicitWidth: mainLayout.implicitWidth + padding * 2 + implicitHeight: mainLayout.implicitHeight + padding * 2 + color: Appearance.colors.colLayer2 + + function focusBreadcrumb() { + root.showBreadcrumb = false; + addressInput.forceActiveFocus(); + } + + RowLayout { + id: mainLayout + anchors { + fill: parent + margins: root.padding + } + spacing: 8 + + RippleButton { + id: parentDirButton + downAction: () => root.navigateToDirectory(FileUtils.parentDirectory(root.directory)) + contentItem: MaterialSymbol { + text: "drive_folder_upload" + iconSize: Appearance.font.pixelSize.larger + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + Rectangle { + id: directoryEntry + visible: !root.showBreadcrumb + anchors.fill: parent + color: Appearance.colors.colLayer1 + radius: Appearance.rounding.full + implicitWidth: addressInput.implicitWidth + implicitHeight: addressInput.implicitHeight + + Keys.onPressed: event => { + if (directoryEntry.visible && event.key === Qt.Key_Escape) { + root.showBreadcrumb = true; + event.accepted = true; + return; + } + event.accepted = false; + } + + StyledTextInput { + id: addressInput + anchors.fill: parent + padding: 10 + text: root.directory + + Keys.onPressed: event => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.navigateToDirectory(text); + root.showBreadcrumb = true; + event.accepted = true; + } + } + + MouseArea { + // I-beam cursor + anchors.fill: parent + acceptedButtons: Qt.NoButton + hoverEnabled: true + cursorShape: Qt.IBeamCursor + } + } + } + + Loader { + id: breadcrumbLoader + active: root.showBreadcrumb + visible: root.showBreadcrumb + anchors.fill: parent + sourceComponent: AddressBreadcrumb { + directory: root.directory + onNavigateToDirectory: dir => { + root.navigateToDirectory(dir); + } + } + } + } + + RippleButton { + id: dirEditButton + toggled: !root.showBreadcrumb + downAction: () => root.showBreadcrumb = !root.showBreadcrumb + contentItem: MaterialSymbol { + text: "edit" + iconSize: Appearance.font.pixelSize.larger + color: dirEditButton.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnLayer2 + } + + StyledToolTip { + text: Translation.tr("Edit directory") + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/AddressBreadcrumb.qml b/modules/quickshell/config/modules/common/widgets/AddressBreadcrumb.qml new file mode 100644 index 0000000..d1d6b52 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/AddressBreadcrumb.qml @@ -0,0 +1,41 @@ +import QtQuick +import QtQuick.Layouts +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +ListView { + id: root + required property var directory + property var breadcrumbDirectory: "" + Component.onCompleted: breadcrumbDirectory = directory; + onDirectoryChanged: { + if (breadcrumbDirectory.startsWith(directory)) return; + breadcrumbDirectory = directory + } + + signal navigateToDirectory(string path) + + orientation: ListView.Horizontal + clip: true + spacing: 2 + + model: breadcrumbDirectory.split("/") + delegate: SelectionGroupButton { + id: folderButton + required property var modelData + required property int index + buttonText: index === 0 ? "/" : modelData + toggled: { + if (directory.trim() === "/") return index === 0; + return index === directory.split("/").length - 1 + } + leftmost: index === 0 + rightmost: index === breadcrumbDirectory.split("/").length - 1 + + onClicked: { + root.navigateToDirectory(breadcrumbDirectory.split("/").slice(0, index + 1).join("/")) + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/ButtonGroup.qml b/modules/quickshell/config/modules/common/widgets/ButtonGroup.qml new file mode 100644 index 0000000..d3c9114 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ButtonGroup.qml @@ -0,0 +1,49 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +/** + * A container that supports GroupButton children for bounciness. + * See https://m3.material.io/components/button-groups/overview + */ +Rectangle { + id: root + default property alias data: rowLayout.data + property alias uniformCellSizes: rowLayout.uniformCellSizes + property real spacing: 5 + property real padding: 0 + property alias clickIndex: rowLayout.clickIndex + property alias childrenCount: rowLayout.childrenCount + + property real contentWidth: { + let total = 0; + for (let i = 0; i < rowLayout.children.length; ++i) { + const child = rowLayout.children[i]; + if (!child.visible) continue; + total += child.baseWidth ?? child.implicitWidth ?? child.width; + } + return total + rowLayout.spacing * (rowLayout.children.length - 1); + } + + topLeftRadius: rowLayout.children.length > 0 ? (rowLayout.children[0].radius + padding) : + Appearance?.rounding?.small + bottomLeftRadius: topLeftRadius + topRightRadius: rowLayout.children.length > 0 ? (rowLayout.children[rowLayout.children.length - 1].radius + padding) : + Appearance?.rounding?.small + bottomRightRadius: topRightRadius + + color: "transparent" + width: root.contentWidth + padding * 2 + implicitHeight: rowLayout.implicitHeight + padding * 2 + implicitWidth: root.contentWidth + padding * 2 + + children: [RowLayout { + id: rowLayout + anchors.fill: parent + anchors.margins: root.padding + spacing: root.spacing + property int clickIndex: -1 + property int childrenCount: children.length + }] +} diff --git a/modules/quickshell/config/modules/common/widgets/CalendarView.qml b/modules/quickshell/config/modules/common/widgets/CalendarView.qml new file mode 100644 index 0000000..2b52324 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/CalendarView.qml @@ -0,0 +1,121 @@ +pragma ComponentBehavior: Bound +import QtQml +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +Item { + id: root + + // Expose delegate + property Component delegate: Text { + required property var model + text: model.day + } + + // Configuration + property int paddingWeeks: 2 // 1 should be sufficient with proper clipping and no padding + property var locale: Qt.locale() // Should be of type Locale but QML is being funny + + // Scrolling + function scrollMonthsAndSnap(x) { // Scroll x months and snap to month + const focusedDate = root.focusedDate; + const focusedMonth = focusedDate.getMonth(); + const focusedYear = focusedDate.getFullYear(); + const targetMonth = focusedMonth + x; + const targetDate = new Date(focusedYear, targetMonth, 1); + const currentFirstShownDate = new Date(root.dateInFirstWeek.getTime() + (root.paddingWeeks * root.millisPerWeek)); + const diffMillis = targetDate.getTime() - currentFirstShownDate.getTime(); + const diffWeeks = Math.round(diffMillis / root.millisPerWeek); + root.targetWeekDiff += diffWeeks; + } + property int weeksPerScroll: 1 + property real targetWeekDiff: 0 + property real weekDiff: targetWeekDiff + property int contentWeekDiff: weekDiff // whole part of weekDiff + property bool scrolling: false + + Behavior on weekDiff { + id: weekScrollBehavior + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Timer { + id: scrollAnimationCheckTimer + interval: 30 // Should be plenty for 60fps + onTriggered: root.scrolling = false; + } + onWeekDiffChanged: { + scrolling = true; + scrollAnimationCheckTimer.restart(); + } + + MouseArea { + anchors.fill: parent + onWheel: wheel => { + root.targetWeekDiff += wheel.angleDelta.y / 120 * -root.weeksPerScroll; // Reverse cuz scrolling down should advance + } + } + + // Date calculations + readonly property int millisPerWeek: 7 * 24 * 60 * 60 * 1000 + readonly property int totalWeeks: 6 + (paddingWeeks * 2) + readonly property int focusedWeekIndex: 2 // The third row, 0-indexed + readonly property int focusDayOfWeekIndex: 6 + property date dateInFirstWeek: { + const currentDate = new Date(); + const currentMonth = currentDate.getMonth(); + const currentYear = currentDate.getFullYear(); + const firstDayThisMonth = new Date(currentYear, currentMonth, 1); + return new Date(firstDayThisMonth.getTime() - (paddingWeeks * millisPerWeek) + contentWeekDiff * millisPerWeek); + } + property date focusedDate: { + // The last day of 3rd week shown is considered the focused month + const addedTime = (root.paddingWeeks + root.focusedWeekIndex) * root.millisPerWeek + const dateInTargetWeek = new Date(root.dateInFirstWeek.getTime() + addedTime); + return DateUtils.getIthDayDateOfSameWeek(dateInTargetWeek, root.focusDayOfWeekIndex - root.locale.firstDayOfWeek, root.locale.firstdayOfWeek); // 4 = Thursday + } + property int focusedMonth: focusedDate.getMonth() + 1 // 0-indexed -> 1-indexed + + // Sizes + property real verticalPadding: 0 + property real buttonSize: 40 + property real buttonSpacing: 2 + implicitHeight: (6 * buttonSize) + (5 * buttonSpacing) + (2 * verticalPadding) + implicitWidth: weeksColumn.implicitWidth + clip: true + + ColumnLayout { + id: weeksColumn + anchors { + left: parent.left + right: parent.right + } + y: { + const spacePerExtraRow = root.buttonSize + root.buttonSpacing; + const origin = -(spacePerExtraRow * root.paddingWeeks); + const diff = root.weekDiff * spacePerExtraRow; + return origin + (-diff % spacePerExtraRow) + root.verticalPadding; + } + + spacing: root.buttonSpacing + + Repeater { + model: root.totalWeeks + + WeekRow { + required property int index + locale: root.locale + date: new Date(root.dateInFirstWeek.getTime() + (index * root.millisPerWeek)) + Layout.fillWidth: true + spacing: root.buttonSpacing + delegate: root.delegate + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/Circle.qml b/modules/quickshell/config/modules/common/widgets/Circle.qml new file mode 100644 index 0000000..ed137a9 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/Circle.qml @@ -0,0 +1,9 @@ +import QtQuick + +Rectangle { + property double diameter + + implicitWidth: diameter + implicitHeight: diameter + radius: diameter / 2 +} diff --git a/modules/quickshell/config/modules/common/widgets/CircularProgress.qml b/modules/quickshell/config/modules/common/widgets/CircularProgress.qml new file mode 100644 index 0000000..e1b2eff --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/CircularProgress.qml @@ -0,0 +1,88 @@ +import QtQuick +import QtQuick.Shapes +import qs.modules.common + +/** + * Material 3 circular progress. See https://m3.material.io/components/progress-indicators/specs + */ +Item { + id: root + + property int implicitSize: 30 + property int lineWidth: 2 + property real value: 0 + property color colPrimary: Appearance.m3colors.m3onSecondaryContainer + property color colSecondary: Appearance.colors.colSecondaryContainer + property real gapAngle: 360 / 18 + property bool fill: false + property int fillOverflow: 2 + property bool enableAnimation: true + property int animationDuration: 800 + property var easingType: Easing.OutCubic + + implicitWidth: implicitSize + implicitHeight: implicitSize + + property real degree: value * 360 + property real centerX: root.width / 2 + property real centerY: root.height / 2 + property real arcRadius: root.implicitSize / 2 - root.lineWidth + property real startAngle: -90 + + Behavior on degree { + enabled: root.enableAnimation + NumberAnimation { + duration: root.animationDuration + easing.type: root.easingType + } + + } + + Loader { + active: root.fill + anchors.fill: parent + + sourceComponent: Rectangle { + radius: 9999 + color: root.colSecondary + } + } + + Shape { + anchors.fill: parent + layer.enabled: true + layer.smooth: true + preferredRendererType: Shape.CurveRenderer + ShapePath { + id: secondaryPath + strokeColor: root.colSecondary + strokeWidth: root.lineWidth + capStyle: ShapePath.RoundCap + fillColor: "transparent" + PathAngleArc { + centerX: root.centerX + centerY: root.centerY + radiusX: root.arcRadius + radiusY: root.arcRadius + startAngle: root.startAngle - root.gapAngle + sweepAngle: -(360 - root.degree - 2 * root.gapAngle) + } + } + ShapePath { + id: primaryPath + strokeColor: root.colPrimary + strokeWidth: root.lineWidth + capStyle: ShapePath.RoundCap + fillColor: "transparent" + PathAngleArc { + centerX: root.centerX + centerY: root.centerY + radiusX: root.arcRadius + radiusY: root.arcRadius + startAngle: root.startAngle + sweepAngle: root.degree + } + } + } + +} diff --git a/modules/quickshell/config/modules/common/widgets/CliphistImage.qml b/modules/quickshell/config/modules/common/widgets/CliphistImage.qml new file mode 100644 index 0000000..7aa1e6f --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/CliphistImage.qml @@ -0,0 +1,131 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import Quickshell +import Quickshell.Io + +Rectangle { + id: root + property string entry + property real maxWidth + property real maxHeight + property bool blur: false + property string blurText: "Image hidden" + + property string imageDecodePath: Directories.cliphistDecode + property string imageDecodeFileName: `${entryNumber}` + property string imageDecodeFilePath: `${imageDecodePath}/${imageDecodeFileName}` + property string source + + property int entryNumber: { + if (!root.entry) + return 0; + const match = root.entry.match(/^(\d+)\t/); + return match ? parseInt(match[1]) : 0; + } + property int imageWidth: { + if (!root.entry) + return 0; + const match = root.entry.match(/(\d+)x(\d+)/); + return match ? parseInt(match[1]) : 0; + } + property int imageHeight: { + if (!root.entry) + return 0; + const match = root.entry.match(/(\d+)x(\d+)/); + return match ? parseInt(match[2]) : 0; + } + property real scale: { + return Math.min(root.maxWidth / imageWidth, root.maxHeight / imageHeight, 1); + } + + color: Appearance.colors.colLayer1 + radius: Appearance.rounding.small + implicitHeight: imageHeight * scale + implicitWidth: imageWidth * scale + + Component.onCompleted: { + decodeImageProcess.running = true; + } + + Process { + id: decodeImageProcess + command: ["bash", "-c", `[ -f ${imageDecodeFilePath} ] || echo '${StringUtils.shellSingleQuoteEscape(root.entry)}' | ${Cliphist.cliphistBinary} decode > '${imageDecodeFilePath}'`] + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + root.source = imageDecodeFilePath; + } else { + console.error("[CliphistImage] Failed to decode image for entry:", root.entry); + root.source = ""; + } + } + } + + Component.onDestruction: { + Quickshell.execDetached(["bash", "-c", `[ -f '${imageDecodeFilePath}' ] && rm -f '${imageDecodeFilePath}'`]); + } + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: image.width + height: image.height + radius: root.radius + } + } + + StyledImage { + id: image + anchors.fill: parent + + source: Qt.resolvedUrl(root.source) + fillMode: Image.PreserveAspectFit + antialiasing: true + asynchronous: true + + width: root.imageWidth * root.scale + height: root.imageHeight * root.scale + sourceSize.width: width + sourceSize.height: height + } + + Loader { + id: blurLoader + active: root.blur + anchors.fill: image + sourceComponent: GaussianBlur { + source: image + radius: 35 + samples: radius * 2 + 1 + + Rectangle { + anchors.fill: parent + color: ColorUtils.transparentize(Appearance.colors.colLayer0, 0.5) + + Column { + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + } + MaterialSymbol { + visible: width <= image.width + anchors.horizontalCenter: parent.horizontalCenter + text: "visibility_off" + font.pixelSize: 28 + } + StyledText { + visible: width <= image.width + anchors.horizontalCenter: parent.horizontalCenter + text: root.blurText + color: Appearance.colors.colOnSurface + font.pixelSize: Appearance.font.pixelSize.smallie + } + } + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/ClippedFilledCircularProgress.qml b/modules/quickshell/config/modules/common/widgets/ClippedFilledCircularProgress.qml new file mode 100644 index 0000000..d6522e6 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ClippedFilledCircularProgress.qml @@ -0,0 +1,98 @@ +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import QtQuick.Shapes +import Qt5Compat.GraphicalEffects + +Item { + id: root + + property int implicitSize: 18 + property int lineWidth: 2 + property real value: 0 + property color colPrimary: Appearance?.colors.colOnSecondaryContainer ?? "#685496" + property color colSecondary: ColorUtils.transparentize(colPrimary, 0.5) ?? "#F1D3F9" + property real gapAngle: 360 / 18 + property bool fill: true + property int fillOverflow: 2 + property bool enableAnimation: true + property int animationDuration: 800 + property var easingType: Easing.OutCubic + property bool accountForLightBleeding: true + default property Item textMask: Item { + width: implicitSize + height: implicitSize + StyledText { + anchors.centerIn: parent + text: Math.round(root.value * 100) + font.pixelSize: 12 + font.weight: Font.Medium + } + } + + implicitWidth: implicitSize + implicitHeight: implicitSize + + property real degree: value * 360 + property real centerX: root.width / 2 + property real centerY: root.height / 2 + property real arcRadius: root.implicitSize / 2 - root.lineWidth / 2 - (0.5 * root.accountForLightBleeding) + property real startAngle: -90 + + Behavior on degree { + enabled: root.enableAnimation + NumberAnimation { + duration: root.animationDuration + easing.type: root.easingType + } + + } + + Rectangle { + id: contentItem + anchors.fill: parent + radius: implicitSize / 2 + color: root.colSecondary + visible: false + layer.enabled: true + layer.smooth: true + + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + id: primaryPath + pathHints: ShapePath.PathSolid & ShapePath.PathNonIntersecting + strokeColor: root.colPrimary + strokeWidth: root.lineWidth + capStyle: ShapePath.RoundCap + fillColor: root.colPrimary + + startX: root.centerX + startY: root.centerY + + PathAngleArc { + moveToStart: false + centerX: root.centerX + centerY: root.centerY + radiusX: root.arcRadius + radiusY: root.arcRadius + startAngle: root.startAngle + sweepAngle: root.degree + } + PathLine { + x: primaryPath.startX + y: primaryPath.startY + } + } + } + } + + OpacityMask { + anchors.fill: parent + source: contentItem + invert: true + maskSource: root.textMask + } +} diff --git a/modules/quickshell/config/modules/common/widgets/ClippedProgressBar.qml b/modules/quickshell/config/modules/common/widgets/ClippedProgressBar.qml new file mode 100644 index 0000000..b47b49b --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ClippedProgressBar.qml @@ -0,0 +1,101 @@ +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import Qt5Compat.GraphicalEffects + +/** + * A progress bar with both ends rounded and text acts as clipping like OneUI 7's battery indicator. + */ +ProgressBar { + id: root + property bool vertical: false + property real valueBarWidth: 30 + property real valueBarHeight: 18 + property color highlightColor: Appearance?.colors.colOnSecondaryContainer ?? "#685496" + property color trackColor: ColorUtils.transparentize(highlightColor, 0.5) ?? "#F1D3F9" + property alias radius: contentItem.radius + property string text + default property Item textMask: Item { + width: valueBarWidth + height: valueBarHeight + StyledText { + anchors.centerIn: parent + font: root.font + text: root.text + } + } + + text: Math.round(value * 100) + font { + pixelSize: 13 + weight: text.length > 2 ? Font.Medium : Font.DemiBold + } + + background: Item { + implicitHeight: valueBarHeight + implicitWidth: valueBarWidth + } + + contentItem: Rectangle { + id: contentItem + anchors.fill: parent + radius: 9999 + color: root.trackColor + visible: false + + Rectangle { + id: progressFill + anchors { + top: parent.top + bottom: parent.bottom + left: parent.left + right: undefined + } + width: parent.width * root.visualPosition + height: parent.height + + states: State { + name: "vertical" + when: root.vertical + AnchorChanges { + target: progressFill + anchors { + top: undefined + bottom: parent.bottom + left: parent.left + right: parent.right + } + } + PropertyChanges { + target: progressFill + width: parent.width + height: parent.height * root.visualPosition + } + } + + radius: Appearance.rounding.unsharpen + color: root.highlightColor + } + } + + OpacityMask { + id: roundingMask + visible: false + anchors.fill: parent + source: contentItem + maskSource: Rectangle { + width: contentItem.width + height: contentItem.height + radius: contentItem.radius + } + } + + OpacityMask { + anchors.fill: parent + source: roundingMask + invert: true + maskSource: root.textMask + } +} diff --git a/modules/quickshell/config/modules/common/widgets/ConfigRow.qml b/modules/quickshell/config/modules/common/widgets/ConfigRow.qml new file mode 100644 index 0000000..ae2adff --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ConfigRow.qml @@ -0,0 +1,8 @@ +import QtQuick +import QtQuick.Layouts + +RowLayout { + property bool uniform: false + spacing: 4 + uniformCellSizes: uniform +} diff --git a/modules/quickshell/config/modules/common/widgets/ConfigSelectionArray.qml b/modules/quickshell/config/modules/common/widgets/ConfigSelectionArray.qml new file mode 100644 index 0000000..dd10207 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ConfigSelectionArray.qml @@ -0,0 +1,54 @@ +import QtQuick +import QtQuick.Layouts +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +Flow { + id: root + Layout.fillWidth: true + spacing: 2 + property list options: [ + { + "displayName": "Option 1", + "icon": "check", + "value": 1 + }, + { + "displayName": "Option 2", + "icon": "close", + "value": 2 + }, + ] + property var currentValue: null + + signal selected(var newValue) + + Repeater { + model: root.options + delegate: SelectionGroupButton { + id: paletteButton + required property var modelData + required property int index + onYChanged: { + if (index === 0) { + paletteButton.leftmost = true + } else { + var prev = root.children[index - 1] + var thisIsOnNewLine = prev && prev.y !== paletteButton.y + paletteButton.leftmost = thisIsOnNewLine + prev.rightmost = thisIsOnNewLine + } + } + leftmost: index === 0 + rightmost: index === root.options.length - 1 + buttonIcon: modelData.icon || "" + buttonText: modelData.displayName + toggled: root.currentValue == modelData.value + onClicked: { + root.selected(modelData.value); + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/ConfigSpinBox.qml b/modules/quickshell/config/modules/common/widgets/ConfigSpinBox.qml new file mode 100644 index 0000000..de70529 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ConfigSpinBox.qml @@ -0,0 +1,38 @@ +import qs.modules.common.widgets +import qs.modules.common +import QtQuick +import QtQuick.Layouts + +RowLayout { + id: root + property string text: "" + property string icon + property alias value: spinBoxWidget.value + property alias stepSize: spinBoxWidget.stepSize + property alias from: spinBoxWidget.from + property alias to: spinBoxWidget.to + spacing: 10 + Layout.leftMargin: 8 + Layout.rightMargin: 8 + + RowLayout { + spacing: 10 + OptionalMaterialSymbol { + icon: root.icon + opacity: root.enabled ? 1 : 0.4 + } + StyledText { + id: labelWidget + Layout.fillWidth: true + text: root.text + color: Appearance.colors.colOnSecondaryContainer + opacity: root.enabled ? 1 : 0.4 + } + } + + StyledSpinBox { + id: spinBoxWidget + Layout.fillWidth: false + value: root.value + } +} diff --git a/modules/quickshell/config/modules/common/widgets/ConfigSwitch.qml b/modules/quickshell/config/modules/common/widgets/ConfigSwitch.qml new file mode 100644 index 0000000..53e6a27 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ConfigSwitch.qml @@ -0,0 +1,43 @@ +import qs.modules.common.widgets +import qs.modules.common +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +RippleButton { + id: root + property string buttonIcon + property alias iconSize: iconWidget.iconSize + + Layout.fillWidth: true + implicitHeight: contentItem.implicitHeight + 8 * 2 + font.pixelSize: Appearance.font.pixelSize.small + + onClicked: checked = !checked + + contentItem: RowLayout { + spacing: 10 + OptionalMaterialSymbol { + id: iconWidget + icon: root.buttonIcon + opacity: root.enabled ? 1 : 0.4 + iconSize: Appearance.font.pixelSize.larger + } + StyledText { + id: labelWidget + Layout.fillWidth: true + text: root.text + font: root.font + color: Appearance.colors.colOnSecondaryContainer + opacity: root.enabled ? 1 : 0.4 + } + StyledSwitch { + id: switchWidget + down: root.down + Layout.fillWidth: false + checked: root.checked + onClicked: root.clicked() + } + } +} + diff --git a/modules/quickshell/config/modules/common/widgets/ContentPage.qml b/modules/quickshell/config/modules/common/widgets/ContentPage.qml new file mode 100644 index 0000000..7ef96e9 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ContentPage.qml @@ -0,0 +1,29 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +StyledFlickable { + id: root + property real baseWidth: 600 + property bool forceWidth: false + property real bottomContentPadding: 100 + + default property alias data: contentColumn.data + + clip: true + contentHeight: contentColumn.implicitHeight + root.bottomContentPadding // Add some padding at the bottom + implicitWidth: contentColumn.implicitWidth + + ColumnLayout { + id: contentColumn + width: root.forceWidth ? root.baseWidth : Math.max(root.baseWidth, implicitWidth) + anchors { + top: parent.top + horizontalCenter: parent.horizontalCenter + margins: 20 + } + spacing: 30 + } + +} diff --git a/modules/quickshell/config/modules/common/widgets/ContentSection.qml b/modules/quickshell/config/modules/common/widgets/ContentSection.qml new file mode 100644 index 0000000..0ff800c --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ContentSection.qml @@ -0,0 +1,36 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +ColumnLayout { + id: root + property string title + property string icon: "" + default property alias data: sectionContent.data + + Layout.fillWidth: true + spacing: 6 + + RowLayout { + spacing: 6 + OptionalMaterialSymbol { + icon: root.icon + iconSize: Appearance.font.pixelSize.hugeass + } + StyledText { + text: root.title + font.pixelSize: Appearance.font.pixelSize.larger + font.weight: Font.Medium + color: Appearance.colors.colOnSecondaryContainer + } + } + + ColumnLayout { + id: sectionContent + Layout.fillWidth: true + spacing: 4 + + } +} diff --git a/modules/quickshell/config/modules/common/widgets/ContentSubsection.qml b/modules/quickshell/config/modules/common/widgets/ContentSubsection.qml new file mode 100644 index 0000000..6c3824e --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ContentSubsection.qml @@ -0,0 +1,46 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +ColumnLayout { + id: root + property string title: "" + property string tooltip: "" + default property alias data: sectionContent.data + + Layout.fillWidth: true + Layout.topMargin: 4 + spacing: 2 + + RowLayout { + ContentSubsectionLabel { + visible: root.title && root.title.length > 0 + text: root.title + } + MaterialSymbol { + visible: root.tooltip && root.tooltip.length > 0 + text: "info" + iconSize: Appearance.font.pixelSize.large + + color: Appearance.colors.colSubtext + MouseArea { + id: infoMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.WhatsThisCursor + StyledToolTip { + extraVisibleCondition: false + alternativeVisibleCondition: infoMouseArea.containsMouse + text: root.tooltip + } + } + } + Item { Layout.fillWidth: true } + } + ColumnLayout { + id: sectionContent + Layout.fillWidth: true + spacing: 2 + } +} diff --git a/modules/quickshell/config/modules/common/widgets/ContentSubsectionLabel.qml b/modules/quickshell/config/modules/common/widgets/ContentSubsectionLabel.qml new file mode 100644 index 0000000..0215aab --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ContentSubsectionLabel.qml @@ -0,0 +1,10 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +StyledText { + text: "Subsection" + color: Appearance.colors.colSubtext + Layout.leftMargin: 2 +} diff --git a/modules/quickshell/config/modules/common/widgets/CustomIcon.qml b/modules/quickshell/config/modules/common/widgets/CustomIcon.qml new file mode 100644 index 0000000..d7a1c63 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/CustomIcon.qml @@ -0,0 +1,37 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets +import Qt5Compat.GraphicalEffects + +Item { + id: root + + property bool colorize: false + property color color + property string source: "" + property string iconFolder: Qt.resolvedUrl(Quickshell.shellPath("assets/icons")) // The folder to check first + width: 30 + height: 30 + + IconImage { + id: iconImage + anchors.fill: parent + source: { + const fullPathWhenSourceIsIconName = iconFolder + "/" + root.source; + if (iconFolder && fullPathWhenSourceIsIconName) { + return fullPathWhenSourceIsIconName + } + return root.source + } + implicitSize: root.height + } + + Loader { + active: root.colorize + anchors.fill: iconImage + sourceComponent: ColorOverlay { + source: iconImage + color: root.color + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/DialogButton.qml b/modules/quickshell/config/modules/common/widgets/DialogButton.qml new file mode 100644 index 0000000..9373a8c --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/DialogButton.qml @@ -0,0 +1,40 @@ +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import QtQuick + +/** + * Material 3 dialog button. See https://m3.material.io/components/dialogs/overview + */ +RippleButton { + id: root + + property string buttonText + padding: 14 + implicitHeight: 36 + implicitWidth: buttonTextWidget.implicitWidth + padding * 2 + buttonRadius: Appearance?.rounding.full ?? 9999 + + property color colEnabled: Appearance?.colors.colPrimary ?? "#65558F" + property color colDisabled: Appearance?.m3colors.m3outline ?? "#8D8C96" + colBackground: ColorUtils.transparentize(Appearance.colors.colLayer3) + colBackgroundHover: Appearance.colors.colLayer3Hover + colRipple: Appearance.colors.colLayer3Active + property alias colText: buttonTextWidget.color + + contentItem: StyledText { + id: buttonTextWidget + anchors.fill: parent + anchors.leftMargin: root.padding + anchors.rightMargin: root.padding + text: buttonText + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance?.font.pixelSize.small ?? 12 + color: root.enabled ? root.colEnabled : root.colDisabled + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + +} diff --git a/modules/quickshell/config/modules/common/widgets/DialogListItem.qml b/modules/quickshell/config/modules/common/widgets/DialogListItem.qml new file mode 100644 index 0000000..67205ce --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/DialogListItem.qml @@ -0,0 +1,25 @@ +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import QtQuick + +RippleButton { + id: root + property bool active: false + + horizontalPadding: Appearance.rounding.large + verticalPadding: 12 + + clip: true + pointingHandCursor: !active + implicitWidth: contentItem.implicitWidth + horizontalPadding * 2 + implicitHeight: contentItem.implicitHeight + verticalPadding * 2 + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + colBackground: ColorUtils.transparentize(Appearance.colors.colLayer3) + colBackgroundHover: active ? colBackground : Appearance.colors.colLayer3Hover + colRipple: Appearance.colors.colLayer3Active + buttonRadius: 0 +} diff --git a/modules/quickshell/config/modules/common/widgets/DirectoryIcon.qml b/modules/quickshell/config/modules/common/widgets/DirectoryIcon.qml new file mode 100644 index 0000000..9df2ee2 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/DirectoryIcon.qml @@ -0,0 +1,41 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.modules.common +import qs.modules.common.functions + +// From https://github.com/caelestia-dots/shell with modifications. +// License: GPLv3 + +Image { + id: root + required property var fileModelData + asynchronous: true + fillMode: Image.PreserveAspectFit + + source: { + if (!fileModelData.fileIsDir) + return Quickshell.iconPath("application-x-zerosize"); + + if ([Directories.documents, Directories.downloads, Directories.music, Directories.pictures, Directories.videos].some(dir => FileUtils.trimFileProtocol(dir) === fileModelData.filePath)) + return Quickshell.iconPath(`folder-${fileModelData.fileName.toLowerCase()}`); + + return Quickshell.iconPath("inode-directory"); + } + + onStatusChanged: { + if (status === Image.Error) + source = Quickshell.iconPath("error"); + } + + Process { + running: !fileModelData.fileIsDir + command: ["file", "--mime", "-b", fileModelData.filePath] + stdout: StdioCollector { + onStreamFinished: { + const mime = text.split(";")[0].replace("/", "-"); + root.source = Images.validImageTypes.some(t => mime === `image-${t}`) ? fileModelData.fileUrl : Quickshell.iconPath(mime, "image-missing"); + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/DragManager.qml b/modules/quickshell/config/modules/common/widgets/DragManager.qml new file mode 100644 index 0000000..9a430d9 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/DragManager.qml @@ -0,0 +1,72 @@ +import qs.modules.common +import qs.services +import QtQuick + +/** + * A convenience MouseArea for handling drag events. + */ +MouseArea { + id: root + hoverEnabled: true + acceptedButtons: Qt.LeftButton + + property bool interactive: true + property bool automaticallyReset: true + readonly property real dragDiffX: _dragDiffX + readonly property real dragDiffY: _dragDiffY + + signal dragPressed(diffX: real, diffY: real) + signal dragReleased(diffX: real, diffY: real) + + property real startX: 0 + property real startY: 0 + property bool dragging: false + property real _dragDiffX: 0 + property real _dragDiffY: 0 + + function resetDrag() { + _dragDiffX = 0 + _dragDiffY = 0 + } + + onPressed: (mouse) => { + if (!root.interactive) { + if (mouse.button === Qt.LeftButton) { + mouse.accepted = false; + } + return; + } + if (mouse.button === Qt.LeftButton) { + startX = mouse.x + startY = mouse.y + } + } + onReleased: (mouse) => { + if (!root.interactive) { + return; + } + dragging = false + root.dragReleased(_dragDiffX, _dragDiffY); + if (root.automaticallyReset) { + root.resetDrag(); + } + } + onPositionChanged: (mouse) => { + if (!root.interactive) { + return; + } + if (mouse.buttons & Qt.LeftButton) { + root._dragDiffX = mouse.x - startX + root._dragDiffY = mouse.y - startY + const dist = Math.sqrt(root._dragDiffX * root._dragDiffX + root._dragDiffY * root._dragDiffY); + root.dragPressed(_dragDiffX, _dragDiffY); + root.dragging = true; + } + } + onCanceled: (mouse) => { + if (!root.interactive) { + return; + } + released(mouse); + } +} diff --git a/modules/quickshell/config/modules/common/widgets/FadeLoader.qml b/modules/quickshell/config/modules/common/widgets/FadeLoader.qml new file mode 100644 index 0000000..f822792 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/FadeLoader.qml @@ -0,0 +1,18 @@ +import QtQuick + +import qs.modules.common + +Loader { + id: root + property bool shown: true + property alias fade: opacityBehavior.enabled + property alias animation: opacityBehavior.animation + opacity: shown ? 1 : 0 + visible: opacity > 0 + active: opacity > 0 + + Behavior on opacity { + id: opacityBehavior + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } +} diff --git a/modules/quickshell/config/modules/common/widgets/Favicon.qml b/modules/quickshell/config/modules/common/widgets/Favicon.qml new file mode 100644 index 0000000..04e9285 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/Favicon.qml @@ -0,0 +1,48 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import Quickshell.Io +import Quickshell.Widgets + +IconImage { + id: root + property string url + property string displayText + + property real size: 32 + property string downloadUserAgent: Config.options?.networking.userAgent ?? "" + property string faviconDownloadPath: Directories.favicons + property string domainName: url.includes("vertexaisearch") ? displayText : StringUtils.getDomain(url) + property string faviconUrl: `https://www.google.com/s2/favicons?domain=${domainName}&sz=32` + property string fileName: `${domainName}.ico` + property string faviconFilePath: `${faviconDownloadPath}/${fileName}` + property string urlToLoad + + Process { + id: faviconDownloadProcess + running: false + command: ["bash", "-c", `[ -f ${faviconFilePath} ] || curl -s '${root.faviconUrl}' -o '${faviconFilePath}' -L -H 'User-Agent: ${downloadUserAgent}'`] + onExited: (exitCode, exitStatus) => { + root.urlToLoad = root.faviconFilePath + } + } + + Component.onCompleted: { + faviconDownloadProcess.running = true + } + + source: Qt.resolvedUrl(root.urlToLoad) + implicitSize: root.size + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: root.implicitSize + height: root.implicitSize + radius: Appearance.rounding.full + } + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/FloatingActionButton.qml b/modules/quickshell/config/modules/common/widgets/FloatingActionButton.qml new file mode 100644 index 0000000..1b26692 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/FloatingActionButton.qml @@ -0,0 +1,64 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +/** + * Material 3 FAB. + */ +RippleButton { + id: root + property string iconText: "add" + property bool expanded: false + property real baseSize: 56 + property real elementSpacing: 5 + implicitWidth: expanded ? (Math.max(contentRowLayout.implicitWidth + 10 * 2, baseSize)) : baseSize + implicitHeight: baseSize + buttonRadius: baseSize / 14 * 4 + colBackground: Appearance.colors.colPrimaryContainer + colBackgroundHover: Appearance.colors.colPrimaryContainerHover + colRipple: Appearance.colors.colPrimaryContainerActive + property color colOnBackground: Appearance.colors.colOnPrimaryContainer + contentItem: Row { + id: contentRowLayout + property real horizontalMargins: (root.baseSize - icon.width) / 2 + anchors { + verticalCenter: parent?.verticalCenter + left: parent?.left + leftMargin: contentRowLayout.horizontalMargins + } + spacing: 0 + + MaterialSymbol { + id: icon + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + iconSize: 26 + color: root.colOnBackground + text: root.iconText + } + Loader { + anchors.verticalCenter: parent.verticalCenter + visible: root.buttonText?.length > 0 + active: true + sourceComponent: Revealer { + visible: root.expanded || implicitWidth > 0 + reveal: root.expanded + implicitWidth: reveal ? (buttonText.implicitWidth + root.elementSpacing + contentRowLayout.horizontalMargins) : 0 + StyledText { + id: buttonText + anchors { + left: parent.left + leftMargin: root.elementSpacing + verticalCenter: parent.verticalCenter + } + text: root.buttonText + color: Appearance.colors.colOnPrimaryContainer + font.pixelSize: 14 + font.weight: 450 + } + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/FlowButtonGroup.qml b/modules/quickshell/config/modules/common/widgets/FlowButtonGroup.qml new file mode 100644 index 0000000..ec9526e --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/FlowButtonGroup.qml @@ -0,0 +1,8 @@ +import QtQuick + +/** + * This is just to make sure `RippleButton`s can be used in a Flow layout. + */ +Flow { + property int clickIndex: -1 +} diff --git a/modules/quickshell/config/modules/common/widgets/FocusedScrollMouseArea.qml b/modules/quickshell/config/modules/common/widgets/FocusedScrollMouseArea.qml new file mode 100644 index 0000000..70829f6 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/FocusedScrollMouseArea.qml @@ -0,0 +1,56 @@ +import QtQuick + +MouseArea { // Right side | scroll to change volume + id: root + + signal scrollUp(delta: int) + signal scrollDown(delta: int) + signal movedAway() + + property bool hovered: false + property real lastScrollX: 0 + property real lastScrollY: 0 + property bool trackingScroll: false + property real moveThreshold: 20 + + acceptedButtons: Qt.LeftButton + hoverEnabled: true + + onEntered: { + root.hovered = true; + } + + onExited: { + root.hovered = false; + root.trackingScroll = false; + } + + onWheel: event => { + if (event.angleDelta.y < 0) + root.scrollDown(event.angleDelta.y); + else if (event.angleDelta.y > 0) + root.scrollUp(event.angleDelta.y); + // Store the mouse position and start tracking + root.lastScrollX = event.x; + root.lastScrollY = event.y; + root.trackingScroll = true; + } + + onPositionChanged: mouse => { + if (root.trackingScroll) { + const dx = mouse.x - root.lastScrollX; + const dy = mouse.y - root.lastScrollY; + if (Math.sqrt(dx * dx + dy * dy) > root.moveThreshold) { + root.movedAway(); + root.trackingScroll = false; + } + } + } + + onContainsMouseChanged: { + if (!root.containsMouse && root.trackingScroll) { + root.movedAway(); + root.trackingScroll = false; + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/Graph.qml b/modules/quickshell/config/modules/common/widgets/Graph.qml new file mode 100644 index 0000000..4747e56 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/Graph.qml @@ -0,0 +1,51 @@ +import QtQuick +import qs.modules.common +import qs.modules.common.functions + +/* + * Simple one value line graph + */ +Canvas { + id: root + + enum Alignment { Left, Right } + + required property list values + property int points: values.length + property color color: Appearance.colors.colPrimary + property real fillOpacity: 0.5 + property var alignment: Graph.Alignment.Left + + onValuesChanged: root.requestPaint() + onPaint: { + var ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + if (!root.values || root.values.length < 2) + return + + var n = root.points + var dx = width / (n - 1) + ctx.strokeStyle = root.color + ctx.fillStyle = ColorUtils.transparentize(root.color, 1 - root.fillOpacity) + ctx.lineWidth = 2 + ctx.beginPath() + for (var i = 0; i < n; ++i) { + var valueIndex = (root.alignment === Graph.Alignment.Right) ? root.values.length - n + i : i + if (valueIndex < 0 || valueIndex >= root.values.length) { + continue; // No data for this point + } + var x = i * dx + var norm = root.values[valueIndex] // already in 0-1 range + var y = height - norm * height + if (valueIndex === 0) { + ctx.moveTo(x, height) + ctx.lineTo(x, y) + } else { + ctx.lineTo(x, y) + } + } + ctx.stroke() + ctx.lineTo(width, height) + ctx.fill() + } +} diff --git a/modules/quickshell/config/modules/common/widgets/GroupButton.qml b/modules/quickshell/config/modules/common/widgets/GroupButton.qml new file mode 100644 index 0000000..0804396 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/GroupButton.qml @@ -0,0 +1,141 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +/** + * Material 3 button with expressive bounciness. + * See https://m3.material.io/components/button-groups/overview + */ +Button { + id: root + property bool toggled + property string buttonText + property real buttonRadius: Appearance?.rounding?.small ?? 8 + property real buttonRadiusPressed: Appearance?.rounding?.small ?? 6 + property var downAction // When left clicking (down) + property var releaseAction // When left clicking (release) + property var altAction // When right clicking + property var middleClickAction // When middle clicking + property bool bounce: true + property real baseWidth: contentItem.implicitWidth + horizontalPadding * 2 + property real baseHeight: contentItem.implicitHeight + verticalPadding * 2 + property bool enableImplicitWidthAnimation: true + property bool enableImplicitHeightAnimation: true + property real clickedWidth: baseWidth + (isAtSide ? 10 : 20) + property real clickedHeight: baseHeight + property var parentGroup: root.parent + property int indexInParent: parentGroup?.children.indexOf(root) ?? -1 + property int clickIndex: parentGroup?.clickIndex ?? -1 + property bool isAtSide: indexInParent === 0 || indexInParent === (parentGroup?.childrenCount - 1) + + Layout.fillWidth: (clickIndex - 1 <= indexInParent && indexInParent <= clickIndex + 1) + Layout.fillHeight: (clickIndex - 1 <= indexInParent && indexInParent <= clickIndex + 1) + implicitWidth: (root.down && bounce) ? clickedWidth : baseWidth + implicitHeight: (root.down && bounce) ? clickedHeight : baseHeight + + property color colBackground: ColorUtils.transparentize(colBackgroundHover, 1) || "transparent" + property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED" + property color colBackgroundActive: Appearance?.colors.colLayer1Active ?? "#D6CEE2" + property color colBackgroundToggled: Appearance?.colors.colPrimary ?? "#65558F" + property color colBackgroundToggledHover: Appearance?.colors.colPrimaryHover ?? "#77699C" + property color colBackgroundToggledActive: Appearance?.colors.colPrimaryActive ?? "#D6CEE2" + + property real radius: root.down ? root.buttonRadiusPressed : root.buttonRadius + property real leftRadius: root.down ? root.buttonRadiusPressed : root.buttonRadius + property real rightRadius: root.down ? root.buttonRadiusPressed : root.buttonRadius + property color color: root.enabled ? (root.toggled ? + (root.down ? colBackgroundToggledActive : + root.hovered ? colBackgroundToggledHover : + colBackgroundToggled) : + (root.down ? colBackgroundActive : + root.hovered ? colBackgroundHover : + colBackground)) : colBackground + + onDownChanged: { + if (root.down) { + if (root.parent.clickIndex !== undefined) { + root.parent.clickIndex = parent.children.indexOf(root) + } + } + } + + Behavior on implicitWidth { + enabled: root.enableImplicitWidthAnimation + animation: Appearance.animation.clickBounce.numberAnimation.createObject(this) + } + + Behavior on implicitHeight { + enabled: root.enableImplicitHeightAnimation + animation: Appearance.animation.clickBounce.numberAnimation.createObject(this) + } + + Behavior on leftRadius { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on rightRadius { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + property alias mouseArea: buttonMouseArea + MouseArea { + id: buttonMouseArea + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onPressed: (event) => { + if(event.button === Qt.RightButton) { + if (root.altAction) root.altAction(); + return; + } + if(event.button === Qt.MiddleButton) { + if (root.middleClickAction) root.middleClickAction(); + return; + } + root.down = true + if (root.downAction) root.downAction(); + } + onReleased: (event) => { + root.down = false + if (event.button != Qt.LeftButton) return; + if (root.releaseAction) root.releaseAction(); + } + onClicked: (event) => { + if (event.button != Qt.LeftButton) return; + root.click() + } + onCanceled: (event) => { + root.down = false + } + + onPressAndHold: () => { + altAction(); + root.down = false; + root.clicked = false; + }; + } + + property bool tabbedTo: root.focus && (focusReason === Qt.TabFocusReason || focusReason === Qt.BacktabFocusReason) + background: Rectangle { + id: buttonBackground + topLeftRadius: root.leftRadius + topRightRadius: root.rightRadius + bottomLeftRadius: root.leftRadius + bottomRightRadius: root.rightRadius + implicitHeight: 50 + + color: root.color + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + border.width: root.tabbedTo ? 2 : 0 + border.color: Appearance.colors.colSecondary + } + + contentItem: StyledText { + text: root.buttonText + } +} diff --git a/modules/quickshell/config/modules/common/widgets/IconAndTextToolbarButton.qml b/modules/quickshell/config/modules/common/widgets/IconAndTextToolbarButton.qml new file mode 100644 index 0000000..875aec4 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/IconAndTextToolbarButton.qml @@ -0,0 +1,33 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common + +ToolbarButton { + id: iconBtn + required property string iconText + + colBackgroundToggled: Appearance.colors.colSecondaryContainer + colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover + colRippleToggled: Appearance.colors.colSecondaryContainerActive + property color colText: toggled ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnSurfaceVariant + + contentItem: Row { + anchors.centerIn: parent + spacing: 4 + + MaterialSymbol { + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + iconSize: 22 + text: iconBtn.iconText + color: iconBtn.colText + } + StyledText { + visible: iconBtn.iconText.length > 0 && iconBtn.text.length > 0 + anchors.verticalCenter: parent.verticalCenter + color: iconBtn.colText + text: iconBtn.text + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/IconToolbarButton.qml b/modules/quickshell/config/modules/common/widgets/IconToolbarButton.qml new file mode 100644 index 0000000..6e2fd41 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/IconToolbarButton.qml @@ -0,0 +1,23 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common + +ToolbarButton { + id: iconBtn + implicitWidth: height + + colBackgroundToggled: Appearance.colors.colSecondaryContainer + colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover + colRippleToggled: Appearance.colors.colSecondaryContainerActive + property color colText: toggled ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnSurfaceVariant + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + iconSize: 22 + text: iconBtn.text + color: iconBtn.colText + animateChange: true + } +} diff --git a/modules/quickshell/config/modules/common/widgets/KeyboardKey.qml b/modules/quickshell/config/modules/common/widgets/KeyboardKey.qml new file mode 100644 index 0000000..cdb287a --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/KeyboardKey.qml @@ -0,0 +1,43 @@ +import qs.modules.common +import QtQuick + +Rectangle { + id: root + property string key + + property real horizontalPadding: 6 + property real verticalPadding: 1 + property real borderWidth: 1 + property real extraBottomBorderWidth: 2 + property color borderColor: Appearance.colors.colOnLayer0 + property real borderRadius: 5 + property real pixelSize: Appearance.font.pixelSize.smaller + property color keyColor: Appearance.m3colors.m3surfaceContainerLow + implicitWidth: keyFace.implicitWidth + borderWidth * 2 + implicitHeight: keyFace.implicitHeight + borderWidth * 2 + extraBottomBorderWidth + radius: borderRadius + color: borderColor + + Rectangle { + id: keyFace + anchors { + fill: parent + topMargin: borderWidth + leftMargin: borderWidth + rightMargin: borderWidth + bottomMargin: extraBottomBorderWidth + borderWidth + } + implicitWidth: keyText.implicitWidth + horizontalPadding * 2 + implicitHeight: keyText.implicitHeight + verticalPadding * 2 + color: keyColor + radius: borderRadius - borderWidth + + StyledText { + id: keyText + anchors.centerIn: parent + font.family: Appearance.font.family.monospace + font.pixelSize: root.pixelSize + text: key + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/LightDarkPreferenceButton.qml b/modules/quickshell/config/modules/common/widgets/LightDarkPreferenceButton.qml new file mode 100644 index 0000000..e886bd9 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/LightDarkPreferenceButton.qml @@ -0,0 +1,122 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell + +RippleButton { + id: lightDarkButtonRoot + required property bool dark + property color previewBg: dark ? ColorUtils.colorWithHueOf("#3f3838", Appearance.m3colors.m3primary) : + ColorUtils.colorWithHueOf("#F7F9FF", Appearance.m3colors.m3primary) + property color previewFg: dark ? Qt.lighter(previewBg, 2.2) : ColorUtils.mix(previewBg, "#292929", 0.85) + padding: 5 + Layout.fillWidth: true + colBackground: Appearance.colors.colLayer2 + toggled: Appearance.m3colors.darkmode === dark + onClicked: { + Quickshell.execDetached(["bash", "-c", `${Directories.wallpaperSwitchScriptPath} --mode ${dark ? "dark" : "light"} --noswitch`]) + } + contentItem: Item { + anchors.centerIn: parent + implicitWidth: buttonContentLayout.implicitWidth + implicitHeight: buttonContentLayout.implicitHeight + ColumnLayout { + id: buttonContentLayout + anchors.centerIn: parent + Rectangle { + Layout.alignment: Qt.AlignHCenter + implicitWidth: 250 + implicitHeight: skeletonColumnLayout.implicitHeight + 10 * 2 + radius: lightDarkButtonRoot.buttonRadius - lightDarkButtonRoot.padding + color: lightDarkButtonRoot.previewBg + border { + width: 1 + color: Appearance.m3colors.m3outlineVariant + } + + // Some skeleton items + ColumnLayout { + id: skeletonColumnLayout + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + RowLayout { + Rectangle { + radius: Appearance.rounding.full + color: lightDarkButtonRoot.previewFg + implicitWidth: 50 + implicitHeight: 50 + } + ColumnLayout { + spacing: 4 + Rectangle { + radius: Appearance.rounding.unsharpenmore + color: lightDarkButtonRoot.previewFg + Layout.fillWidth: true + implicitHeight: 22 + } + Rectangle { + radius: Appearance.rounding.unsharpenmore + color: lightDarkButtonRoot.previewFg + Layout.fillWidth: true + Layout.rightMargin: 45 + implicitHeight: 18 + } + } + } + StyledProgressBar { + Layout.topMargin: 5 + Layout.bottomMargin: 5 + Layout.fillWidth: true + value: 0.7 + wavy: true + animateWave: lightDarkButtonRoot.toggled + highlightColor: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3primary : lightDarkButtonRoot.previewFg + trackColor: ColorUtils.mix(lightDarkButtonRoot.previewBg, lightDarkButtonRoot.previewFg, 0.5) + } + RowLayout { + spacing: 2 + Rectangle { + radius: Appearance.rounding.full + color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3primary : lightDarkButtonRoot.previewFg + Layout.fillWidth: true + implicitHeight: 30 + MaterialSymbol { + visible: lightDarkButtonRoot.toggled + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "check" + iconSize: 20 + color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3onPrimary : lightDarkButtonRoot.previewBg + } + } + Rectangle { + radius: Appearance.rounding.unsharpenmore + color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3secondaryContainer : lightDarkButtonRoot.previewFg + Layout.fillWidth: true + implicitHeight: 30 + } + Rectangle { + topLeftRadius: Appearance.rounding.unsharpenmore + bottomLeftRadius: Appearance.rounding.unsharpenmore + topRightRadius: Appearance.rounding.full + bottomRightRadius: Appearance.rounding.full + color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3secondaryContainer : lightDarkButtonRoot.previewFg + Layout.fillWidth: true + implicitHeight: 30 + } + } + } + } + StyledText { + Layout.fillWidth: true + text: dark ? Translation.tr("Dark") : Translation.tr("Light") + color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2 + horizontalAlignment: Text.AlignHCenter + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/MaterialCookie.qml b/modules/quickshell/config/modules/common/widgets/MaterialCookie.qml new file mode 100644 index 0000000..a1f4be2 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/MaterialCookie.qml @@ -0,0 +1,39 @@ +import QtQuick +import QtQuick.Shapes +import Quickshell +import qs.modules.common +import qs.modules.common.widgets.shapes +import "shapes/geometry/offset.js" as Offset +import "shapes/shapes/corner-rounding.js" as CornerRounding +import "shapes/shapes/rounded-polygon.js" as RoundedPolygon +import "shapes/material-shapes.js" as MaterialShapes + +Item { + id: root + property int sides: 12 + property int implicitSize: 100 + property alias color: shapeCanvas.color + + implicitWidth: implicitSize + implicitHeight: implicitSize + + property var cornerRounding: new CornerRounding.CornerRounding((sides < 17 ? 1.5 : 1.1) / Math.max(sides, 1)) + + ShapeCanvas { + id: shapeCanvas + anchors.fill: parent + roundedPolygon: switch(sides) { + case 0: return MaterialShapes.getCircle(); + case 1: return MaterialShapes.getCircle(); + case 4: return MaterialShapes.getCookie4Sided(); + case 6: return MaterialShapes.getCookie6Sided(); + case 7: return MaterialShapes.getCookie7Sided(); + case 9: return MaterialShapes.getCookie9Sided(); + case 12: return MaterialShapes.getCookie12Sided(); + default: return RoundedPolygon.RoundedPolygon.star(sides, 1, 0.8, root.cornerRounding) + .transformed((x, y) => MaterialShapes.rotate30.map(new Offset.Offset(x, y))) + .normalized(); + } + } +} + diff --git a/modules/quickshell/config/modules/common/widgets/MaterialLoadingIndicator.qml b/modules/quickshell/config/modules/common/widgets/MaterialLoadingIndicator.qml new file mode 100644 index 0000000..502cd1a --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/MaterialLoadingIndicator.qml @@ -0,0 +1,89 @@ +pragma ComponentBehavior: Bound +import QtQuick +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +Rectangle { + id: root + + property bool loading: true + property double pullProgress: 0 + + // Size, color + property double implicitSize: 48 + implicitWidth: implicitSize + implicitHeight: implicitSize + radius: Math.min(width, height) / 2 + color: Appearance.colors.colPrimaryContainer + property double baseShapeSize: root.implicitSize * 0.7 + property double leapZoomSize: root.baseShapeSize * 1.2 + property double leapZoomProgress: 0 + + // Shape + property list shapes: [ + MaterialShape.Shape.SoftBurst, + MaterialShape.Shape.Cookie9Sided, + MaterialShape.Shape.Pentagon, + MaterialShape.Shape.Pill, + MaterialShape.Shape.Sunny, + MaterialShape.Shape.Cookie4Sided, + MaterialShape.Shape.Oval, + ] + property int shapeIndex: 0 + property double pullRotation: root.loading ? 0 : -(root.pullProgress * 360) + property double continuousRotation: 0 + property double leapRotation: 0 + rotation: pullRotation + continuousRotation + leapRotation + + RotationAnimation on continuousRotation { + running: root.loading + duration: 12000 + easing.type: Easing.Linear + loops: Animation.Infinite + from: 0 + to: 360 + } + Timer { + interval: 800 + running: root.loading + repeat: true + onTriggered: leapAnimation.start() + } + ParallelAnimation { + id: leapAnimation + PropertyAction { target: root; property: "shapeIndex"; value: (root.shapeIndex + 1) % root.shapes.length } + RotationAnimation { + target: root + direction: RotationAnimation.Shortest + property: "leapRotation" + to: (root.leapRotation + 90) % 360 + duration: 350 + easing.type: Easing.InOutQuad + } + NumberAnimation { + target: root + property: "leapZoomProgress" + from: 0 + to: 1 + duration: 750 + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animationCurves.standard + } + } + + MaterialShape { + id: shape + anchors.centerIn: parent + shape: root.shapes[root.shapeIndex] + implicitSize: { + const leapZoomDiff = root.leapZoomSize - root.baseShapeSize + const progressFirstHalf = Math.min(root.leapZoomProgress, 0.5) * 2; + const progressSecondHalf = Math.max(root.leapZoomProgress - 0.5, 0) * 2; + return root.baseShapeSize + leapZoomDiff * progressFirstHalf - leapZoomDiff * progressSecondHalf; + } + color: Appearance.colors.colOnPrimaryContainer + + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } +} diff --git a/modules/quickshell/config/modules/common/widgets/MaterialShape.qml b/modules/quickshell/config/modules/common/widgets/MaterialShape.qml new file mode 100644 index 0000000..225fc41 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/MaterialShape.qml @@ -0,0 +1,88 @@ +import qs.modules.common.widgets.shapes +import "shapes/material-shapes.js" as MaterialShapes + +ShapeCanvas { + id: root + enum Shape { + Circle, + Square, + Slanted, + Arch, + Fan, + Arrow, + SemiCircle, + Oval, + Pill, + Triangle, + Diamond, + ClamShell, + Pentagon, + Gem, + Sunny, + VerySunny, + Cookie4Sided, + Cookie6Sided, + Cookie7Sided, + Cookie9Sided, + Cookie12Sided, + Ghostish, + Clover4Leaf, + Clover8Leaf, + Burst, + SoftBurst, + Boom, + SoftBoom, + Flower, + Puffy, + PuffyDiamond, + PixelCircle, + PixelTriangle, + Bun, + Heart + } + required property var shape + property double implicitSize + implicitHeight: implicitSize + implicitWidth: implicitSize + polygonIsNormalized: true + roundedPolygon: { + switch (root.shape) { + case MaterialShape.Shape.Circle: return MaterialShapes.getCircle(); + case MaterialShape.Shape.Square: return MaterialShapes.getSquare(); + case MaterialShape.Shape.Slanted: return MaterialShapes.getSlanted(); + case MaterialShape.Shape.Arch: return MaterialShapes.getArch(); + case MaterialShape.Shape.Fan: return MaterialShapes.getFan(); + case MaterialShape.Shape.Arrow: return MaterialShapes.getArrow(); + case MaterialShape.Shape.SemiCircle: return MaterialShapes.getSemiCircle(); + case MaterialShape.Shape.Oval: return MaterialShapes.getOval(); + case MaterialShape.Shape.Pill: return MaterialShapes.getPill(); + case MaterialShape.Shape.Triangle: return MaterialShapes.getTriangle(); + case MaterialShape.Shape.Diamond: return MaterialShapes.getDiamond(); + case MaterialShape.Shape.ClamShell: return MaterialShapes.getClamShell(); + case MaterialShape.Shape.Pentagon: return MaterialShapes.getPentagon(); + case MaterialShape.Shape.Gem: return MaterialShapes.getGem(); + case MaterialShape.Shape.Sunny: return MaterialShapes.getSunny(); + case MaterialShape.Shape.VerySunny: return MaterialShapes.getVerySunny(); + case MaterialShape.Shape.Cookie4Sided: return MaterialShapes.getCookie4Sided(); + case MaterialShape.Shape.Cookie6Sided: return MaterialShapes.getCookie6Sided(); + case MaterialShape.Shape.Cookie7Sided: return MaterialShapes.getCookie7Sided(); + case MaterialShape.Shape.Cookie9Sided: return MaterialShapes.getCookie9Sided(); + case MaterialShape.Shape.Cookie12Sided: return MaterialShapes.getCookie12Sided(); + case MaterialShape.Shape.Ghostish: return MaterialShapes.getGhostish(); + case MaterialShape.Shape.Clover4Leaf: return MaterialShapes.getClover4Leaf(); + case MaterialShape.Shape.Clover8Leaf: return MaterialShapes.getClover8Leaf(); + case MaterialShape.Shape.Burst: return MaterialShapes.getBurst(); + case MaterialShape.Shape.SoftBurst: return MaterialShapes.getSoftBurst(); + case MaterialShape.Shape.Boom: return MaterialShapes.getBoom(); + case MaterialShape.Shape.SoftBoom: return MaterialShapes.getSoftBoom(); + case MaterialShape.Shape.Flower: return MaterialShapes.getFlower(); + case MaterialShape.Shape.Puffy: return MaterialShapes.getPuffy(); + case MaterialShape.Shape.PuffyDiamond: return MaterialShapes.getPuffyDiamond(); + case MaterialShape.Shape.PixelCircle: return MaterialShapes.getPixelCircle(); + case MaterialShape.Shape.PixelTriangle: return MaterialShapes.getPixelTriangle(); + case MaterialShape.Shape.Bun: return MaterialShapes.getBun(); + case MaterialShape.Shape.Heart: return MaterialShapes.getHeart(); + default: return MaterialShapes.getCircle(); + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/MaterialShapeWrappedMaterialSymbol.qml b/modules/quickshell/config/modules/common/widgets/MaterialShapeWrappedMaterialSymbol.qml new file mode 100644 index 0000000..b6abd1e --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/MaterialShapeWrappedMaterialSymbol.qml @@ -0,0 +1,23 @@ +import QtQuick +import qs.modules.common +import qs.modules.common.widgets + +MaterialShape { + id: root + property alias text: symbol.text + property alias iconSize: symbol.iconSize + property alias font: symbol.font + property alias colSymbol: symbol.color + property real padding: 6 + + color: Appearance.colors.colSecondaryContainer + colSymbol: Appearance.colors.colOnSecondaryContainer + shape: MaterialShape.Shape.Clover4Leaf + implicitSize: Math.max(symbol.implicitWidth, symbol.implicitHeight) + padding * 2 + + MaterialSymbol { + id: symbol + anchors.centerIn: parent + color: root.colSymbol + } +} diff --git a/modules/quickshell/config/modules/common/widgets/MaterialSymbol.qml b/modules/quickshell/config/modules/common/widgets/MaterialSymbol.qml new file mode 100644 index 0000000..d117bb6 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/MaterialSymbol.qml @@ -0,0 +1,30 @@ +import qs.modules.common +import QtQuick + +StyledText { + id: root + property real iconSize: Appearance?.font.pixelSize.small ?? 16 + property real fill: 0 + property real truncatedFill: fill.toFixed(1) // Reduce memory consumption spikes from constant font remapping + renderType: Text.NativeRendering + font { + hintingPreference: Font.PreferNoHinting + family: Appearance?.font.family.iconMaterial ?? "Material Symbols Rounded" + pixelSize: iconSize + weight: Font.Normal + (Font.DemiBold - Font.Normal) * truncatedFill + variableAxes: { + "FILL": truncatedFill, + // "wght": font.weight, + // "GRAD": 0, + "opsz": iconSize, + } + } + + Behavior on fill { // Leaky leaky, no good + NumberAnimation { + duration: Appearance?.animation.elementMoveFast.duration ?? 200 + easing.type: Appearance?.animation.elementMoveFast.type ?? Easing.BezierSpline + easing.bezierCurve: Appearance?.animation.elementMoveFast.bezierCurve ?? [0.34, 0.80, 0.34, 1.00, 1, 1] + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/MaterialTextArea.qml b/modules/quickshell/config/modules/common/widgets/MaterialTextArea.qml new file mode 100644 index 0000000..1390d71 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/MaterialTextArea.qml @@ -0,0 +1,53 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls.Material +import QtQuick.Controls + +/** + * Material 3 styled TextArea (filled style) + * https://m3.material.io/components/text-fields/overview + * Note: We don't use NativeRendering because it makes the small placeholder text look weird + */ +TextArea { + id: root + Material.theme: Material.System + Material.accent: Appearance.m3colors.m3primary + Material.primary: Appearance.m3colors.m3primary + Material.background: Appearance.m3colors.m3surface + Material.foreground: Appearance.m3colors.m3onSurface + Material.containerStyle: Material.Filled + renderType: Text.QtRendering + + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + placeholderTextColor: Appearance.m3colors.m3outline + + background: Rectangle { + implicitHeight: 56 + color: Appearance.m3colors.m3surface + topLeftRadius: 4 + topRightRadius: 4 + Rectangle { + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + } + height: 1 + color: root.focus ? Appearance.m3colors.m3primary : + root.hovered ? Appearance.m3colors.m3outline : Appearance.m3colors.m3outlineVariant + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + + font { + family: Appearance.font.family.main + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + variableAxes: Appearance.font.variableAxes.main + } + wrapMode: TextEdit.Wrap +} diff --git a/modules/quickshell/config/modules/common/widgets/MaterialTextField.qml b/modules/quickshell/config/modules/common/widgets/MaterialTextField.qml new file mode 100644 index 0000000..aa8041b --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/MaterialTextField.qml @@ -0,0 +1,40 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls.Material +import QtQuick.Controls + +/** + * Material 3 styled TextField (filled style) + * https://m3.material.io/components/text-fields/overview + * Note: We don't use NativeRendering because it makes the small placeholder text look weird + */ +TextField { + id: root + Material.theme: Material.System + Material.accent: Appearance.m3colors.m3primary + Material.primary: Appearance.m3colors.m3primary + Material.background: Appearance.m3colors.m3surface + Material.foreground: Appearance.m3colors.m3onSurface + Material.containerStyle: Material.Outlined + renderType: Text.QtRendering + + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + placeholderTextColor: Appearance.m3colors.m3outline + clip: true + + font { + family: Appearance.font.family.main + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + variableAxes: Appearance.font.variableAxes.main + } + wrapMode: TextEdit.Wrap + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + hoverEnabled: true + cursorShape: Qt.IBeamCursor + } +} diff --git a/modules/quickshell/config/modules/common/widgets/MenuButton.qml b/modules/quickshell/config/modules/common/widgets/MenuButton.qml new file mode 100644 index 0000000..9185bc9 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/MenuButton.qml @@ -0,0 +1,26 @@ +import qs.modules.common +import QtQuick + +RippleButton { + id: root + + buttonRadius: 0 + implicitHeight: 36 + implicitWidth: buttonTextWidget.implicitWidth + 14 * 2 + + contentItem: StyledText { + id: buttonTextWidget + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 14 + text: root.buttonText + horizontalAlignment: Text.AlignLeft + font.pixelSize: Appearance.font.pixelSize.small + color: root.enabled ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3outline + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + +} diff --git a/modules/quickshell/config/modules/common/widgets/NavigationRail.qml b/modules/quickshell/config/modules/common/widgets/NavigationRail.qml new file mode 100644 index 0000000..11082a7 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/NavigationRail.qml @@ -0,0 +1,11 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +ColumnLayout { // Window content with navigation rail and content pane + id: root + property bool expanded: true + property int currentIndex: 0 + spacing: 5 +} diff --git a/modules/quickshell/config/modules/common/widgets/NavigationRailButton.qml b/modules/quickshell/config/modules/common/widgets/NavigationRailButton.qml new file mode 100644 index 0000000..9932911 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/NavigationRailButton.qml @@ -0,0 +1,150 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +TabButton { + id: root + + property bool toggled: TabBar.tabBar.currentIndex === TabBar.index + property string buttonIcon + property real buttonIconRotation: 0 + property string buttonText + property bool expanded: false + property bool showToggledHighlight: true + readonly property real visualWidth: root.expanded ? root.baseSize + 20 + itemText.implicitWidth : root.baseSize + + property real baseSize: 56 + property real baseHighlightHeight: 32 + property real highlightCollapsedTopMargin: 8 + padding: 0 + + // The navigation itemโ€™s target area always spans the full width of the + // nav rail, even if the item container hugs its contents. + Layout.fillWidth: true + // implicitWidth: contentItem.implicitWidth + implicitHeight: baseSize + + background: null + PointingHandInteraction {} + + // Real stuff + contentItem: Item { + id: buttonContent + anchors { + top: parent.top + bottom: parent.bottom + left: parent.left + right: undefined + } + + implicitWidth: root.visualWidth + implicitHeight: root.expanded ? itemIconBackground.implicitHeight : itemIconBackground.implicitHeight + itemText.implicitHeight + + Rectangle { + id: itemBackground + anchors.top: itemIconBackground.top + anchors.left: itemIconBackground.left + anchors.bottom: itemIconBackground.bottom + implicitWidth: root.visualWidth + radius: Appearance.rounding.full + color: toggled ? + root.showToggledHighlight ? + (root.down ? Appearance.colors.colSecondaryContainerActive : root.hovered ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colSecondaryContainer) + : ColorUtils.transparentize(Appearance.colors.colSecondaryContainer) : + (root.down ? Appearance.colors.colLayer1Active : root.hovered ? Appearance.colors.colLayer1Hover : ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1)) + + states: State { + name: "expanded" + when: root.expanded + AnchorChanges { + target: itemBackground + anchors.top: buttonContent.top + anchors.left: buttonContent.left + anchors.bottom: buttonContent.bottom + } + PropertyChanges { + target: itemBackground + implicitWidth: root.visualWidth + } + } + transitions: Transition { + AnchorAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + PropertyAnimation { + target: itemBackground + property: "implicitWidth" + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + + Item { + id: itemIconBackground + implicitWidth: root.baseSize + implicitHeight: root.baseHighlightHeight + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + } + MaterialSymbol { + id: navRailButtonIcon + rotation: root.buttonIconRotation + anchors.centerIn: parent + iconSize: 24 + fill: toggled ? 1 : 0 + font.weight: (toggled || root.hovered) ? Font.DemiBold : Font.Normal + text: buttonIcon + color: toggled ? Appearance.m3colors.m3onSecondaryContainer : Appearance.colors.colOnLayer1 + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + + StyledText { + id: itemText + anchors { + top: itemIconBackground.bottom + topMargin: 2 + horizontalCenter: itemIconBackground.horizontalCenter + } + states: State { + name: "expanded" + when: root.expanded + AnchorChanges { + target: itemText + anchors { + top: undefined + horizontalCenter: undefined + left: itemIconBackground.right + verticalCenter: itemIconBackground.verticalCenter + } + } + } + transitions: Transition { + AnchorAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + text: buttonText + font.pixelSize: 14 + color: Appearance.colors.colOnLayer1 + } + } + +} diff --git a/modules/quickshell/config/modules/common/widgets/NavigationRailExpandButton.qml b/modules/quickshell/config/modules/common/widgets/NavigationRailExpandButton.qml new file mode 100644 index 0000000..1e562f6 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/NavigationRailExpandButton.qml @@ -0,0 +1,30 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +RippleButton { + id: root + Layout.alignment: Qt.AlignLeft + implicitWidth: 40 + implicitHeight: 40 + Layout.leftMargin: 8 + downAction: () => { + parent.expanded = !parent.expanded; + } + buttonRadius: Appearance.rounding.full + + rotation: root.parent.expanded ? 0 : -180 + Behavior on rotation { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + contentItem: MaterialSymbol { + id: icon + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: 24 + color: Appearance.colors.colOnLayer1 + text: root.parent.expanded ? "menu_open" : "menu" + } +} diff --git a/modules/quickshell/config/modules/common/widgets/NavigationRailTabArray.qml b/modules/quickshell/config/modules/common/widgets/NavigationRailTabArray.qml new file mode 100644 index 0000000..01d27ba --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/NavigationRailTabArray.qml @@ -0,0 +1,42 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +Item { + id: root + property int currentIndex: 0 + property bool expanded: false + default property alias data: tabBarColumn.data + implicitHeight: tabBarColumn.implicitHeight + implicitWidth: tabBarColumn.implicitWidth + Layout.topMargin: 25 + + Rectangle { + property real itemHeight: tabBarColumn.children[0]?.baseSize ?? 56 + property real baseHighlightHeight: tabBarColumn.children[0]?.baseHighlightHeight ?? 56 + anchors { + top: tabBarColumn.top + left: tabBarColumn.left + topMargin: itemHeight * root.currentIndex + (root.expanded ? 0 : ((itemHeight - baseHighlightHeight) / 2)) + } + radius: Appearance.rounding.full + color: Appearance.colors.colSecondaryContainer + implicitHeight: root.expanded ? itemHeight : baseHighlightHeight + implicitWidth: tabBarColumn?.children[root.currentIndex]?.visualWidth ?? 100 + + Behavior on anchors.topMargin { + NumberAnimation { + duration: Appearance.animationCurves.expressiveFastSpatialDuration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial + } + } + } + + ColumnLayout { + id: tabBarColumn + anchors.fill: parent + spacing: 0 + } +} diff --git a/modules/quickshell/config/modules/common/widgets/NoticeBox.qml b/modules/quickshell/config/modules/common/widgets/NoticeBox.qml new file mode 100644 index 0000000..c6a1d29 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/NoticeBox.qml @@ -0,0 +1,51 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +Rectangle { + id: root + property alias materialIcon: icon.text + property alias text: noticeText.text + default property alias data: buttonRow.data + + radius: Appearance.rounding.normal + color: Appearance.colors.colPrimaryContainer + implicitWidth: mainRowLayout.implicitWidth + mainRowLayout.anchors.margins * 2 + implicitHeight: mainRowLayout.implicitHeight + mainRowLayout.anchors.margins * 2 + + RowLayout { + id: mainRowLayout + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + MaterialSymbol { + id: icon + Layout.fillWidth: false + Layout.alignment: Qt.AlignTop + text: "info" + iconSize: Appearance.font.pixelSize.huge + color: Appearance.colors.colOnPrimaryContainer + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + StyledText { + id: noticeText + Layout.fillWidth: true + text: "Notice message" + color: Appearance.colors.colOnPrimaryContainer + wrapMode: Text.WordWrap + } + + RowLayout { + id: buttonRow + visible: children.length > 0 + Layout.fillWidth: true + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/NotificationActionButton.qml b/modules/quickshell/config/modules/common/widgets/NotificationActionButton.qml new file mode 100644 index 0000000..89b8576 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/NotificationActionButton.qml @@ -0,0 +1,24 @@ +import qs.modules.common +import qs.services +import QtQuick +import Quickshell.Services.Notifications + +RippleButton { + id: button + property string buttonText + property string urgency + + implicitHeight: 34 + leftPadding: 15 + rightPadding: 15 + buttonRadius: Appearance.rounding.small + colBackground: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainer : Appearance.colors.colLayer4 + colBackgroundHover: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colLayer4Hover + colRipple: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainerActive : Appearance.colors.colLayer4Active + + contentItem: StyledText { + horizontalAlignment: Text.AlignHCenter + text: buttonText + color: (urgency == NotificationUrgency.Critical) ? Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/NotificationAppIcon.qml b/modules/quickshell/config/modules/common/widgets/NotificationAppIcon.qml new file mode 100644 index 0000000..ad250d9 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/NotificationAppIcon.qml @@ -0,0 +1,104 @@ +import qs.modules.common +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Notifications + +MaterialShape { // App icon + id: root + property var appIcon: "" + property var summary: "" + property var urgency: NotificationUrgency.Normal + property bool isUrgent: urgency === NotificationUrgency.Critical + property var image: "" + property real materialIconScale: 0.57 + property real appIconScale: 0.8 + property real smallAppIconScale: 0.49 + property real materialIconSize: implicitSize * materialIconScale + property real appIconSize: implicitSize * appIconScale + property real smallAppIconSize: implicitSize * smallAppIconScale + + implicitSize: 38 * scale + property list urgentShapes: [ + MaterialShape.Shape.VerySunny, + MaterialShape.Shape.SoftBurst, + ] + shape: isUrgent ? urgentShapes[Math.floor(Math.random() * urgentShapes.length)] : MaterialShape.Shape.Circle + + color: isUrgent ? Appearance.colors.colPrimaryContainer : Appearance.colors.colSecondaryContainer + Loader { + id: materialSymbolLoader + active: root.appIcon == "" + anchors.fill: parent + sourceComponent: MaterialSymbol { + text: { + const defaultIcon = NotificationUtils.findSuitableMaterialSymbol("") + const guessedIcon = NotificationUtils.findSuitableMaterialSymbol(root.summary) + return (root.urgency == NotificationUrgency.Critical && guessedIcon === defaultIcon) ? + "priority_high" : guessedIcon + } + anchors.fill: parent + color: isUrgent ? Appearance.colors.colOnPrimaryContainer : Appearance.colors.colOnSecondaryContainer + iconSize: root.materialIconSize + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + Loader { + id: appIconLoader + active: root.image == "" && root.appIcon != "" + anchors.centerIn: parent + sourceComponent: IconImage { + id: appIconImage + implicitSize: root.appIconSize + asynchronous: true + source: Quickshell.iconPath(root.appIcon, "image-missing") + } + } + Loader { + id: notifImageLoader + active: root.image != "" + anchors.fill: parent + sourceComponent: Item { + anchors.fill: parent + Image { + id: notifImage + anchors.fill: parent + readonly property int size: parent.width + + source: root.image + fillMode: Image.PreserveAspectCrop + cache: false + antialiasing: true + asynchronous: true + + width: size + height: size + sourceSize.width: size + sourceSize.height: size + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: notifImage.size + height: notifImage.size + radius: Appearance.rounding.full + } + } + } + Loader { + id: notifImageAppIconLoader + active: root.appIcon != "" + anchors.bottom: parent.bottom + anchors.right: parent.right + sourceComponent: IconImage { + implicitSize: root.smallAppIconSize + asynchronous: true + source: Quickshell.iconPath(root.appIcon, "image-missing") + } + } + } + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/NotificationGroup.qml b/modules/quickshell/config/modules/common/widgets/NotificationGroup.qml new file mode 100644 index 0000000..fc612dc --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/NotificationGroup.qml @@ -0,0 +1,259 @@ +import qs.services +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Notifications + +/** + * A group of notifications from the same app. + * Similar to Android's notifications + */ +MouseArea { // Notification group area + id: root + property var notificationGroup + property var notifications: notificationGroup?.notifications ?? [] + property int notificationCount: notifications.length + property bool multipleNotifications: notificationCount > 1 + property bool expanded: false + property bool popup: false + property real padding: 10 + implicitHeight: background.implicitHeight + + property real dragConfirmThreshold: 70 // Drag further to discard notification + property real dismissOvershoot: 20 // Account for gaps and bouncy animations + property var qmlParent: root?.parent?.parent // There's something between this and the parent ListView + property var parentDragIndex: qmlParent?.dragIndex + property var parentDragDistance: qmlParent?.dragDistance + property var dragIndexDiff: Math.abs(parentDragIndex - index) + property real xOffset: dragIndexDiff == 0 ? parentDragDistance : + Math.abs(parentDragDistance) > dragConfirmThreshold ? 0 : + dragIndexDiff == 1 ? (parentDragDistance * 0.3) : + dragIndexDiff == 2 ? (parentDragDistance * 0.1) : 0 + + function destroyWithAnimation(left = false) { + root.qmlParent.resetDrag() + background.anchors.leftMargin = background.anchors.leftMargin; // Break binding + destroyAnimation.left = left; + destroyAnimation.running = true; + } + + hoverEnabled: true + onContainsMouseChanged: { + if (!root.popup) return; + if (root.containsMouse) root.notifications.forEach(notif => { + Notifications.cancelTimeout(notif.notificationId); + }); + else root.notifications.forEach(notif => { + Notifications.timeoutNotification(notif.notificationId); + }); + } + + SequentialAnimation { // Drag finish animation + id: destroyAnimation + property bool left: true + running: false + + NumberAnimation { + target: background.anchors + property: "leftMargin" + to: (root.width + root.dismissOvershoot) * (destroyAnimation.left ? -1 : 1) + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + onFinished: () => { + root.notifications.forEach((notif) => { + Qt.callLater(() => { + Notifications.discardNotification(notif.notificationId); + }); + }); + } + } + + function toggleExpanded() { + if (expanded) implicitHeightAnim.enabled = true; + else implicitHeightAnim.enabled = false; + root.expanded = !root.expanded; + } + + DragManager { // Drag manager + id: dragManager + anchors.fill: parent + interactive: !expanded + automaticallyReset: false + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + + onPressed: { + if (mouse.button === Qt.RightButton) + root.toggleExpanded(); + } + + onClicked: (mouse) => { + if (mouse.button === Qt.MiddleButton) + root.destroyWithAnimation(); + } + + onDraggingChanged: () => { + if (dragging) { + root.qmlParent.dragIndex = root.index ?? root.parent.children.indexOf(root); + } + } + + onDragDiffXChanged: () => { + root.qmlParent.dragDistance = dragDiffX; + } + + onDragReleased: (diffX, diffY) => { + if (Math.abs(diffX) > root.dragConfirmThreshold) + root.destroyWithAnimation(diffX < 0); + else + dragManager.resetDrag(); + } + } + + StyledRectangularShadow { + target: background + visible: popup + } + Rectangle { // Background of the notification + id: background + anchors.left: parent.left + width: parent.width + color: popup ? ColorUtils.applyAlpha(Appearance.colors.colLayer2, 1 - Appearance.backgroundTransparency) : Appearance.colors.colLayer2 + radius: Appearance.rounding.normal + anchors.leftMargin: root.xOffset + + Behavior on anchors.leftMargin { + enabled: !dragManager.dragging + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial + } + } + + clip: true + implicitHeight: root.expanded ? + row.implicitHeight + padding * 2 : + Math.min(80, row.implicitHeight + padding * 2) + + Behavior on implicitHeight { + id: implicitHeightAnim + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + RowLayout { // Left column for icon, right column for content + id: row + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: root.padding + spacing: 10 + + NotificationAppIcon { // Icons + Layout.alignment: Qt.AlignTop + Layout.fillWidth: false + image: root?.multipleNotifications ? "" : notificationGroup?.notifications[0]?.image ?? "" + appIcon: root.notificationGroup?.appIcon + summary: root.notificationGroup?.notifications[root.notificationCount - 1]?.summary + urgency: root.notifications.some(n => n.urgency === NotificationUrgency.Critical.toString()) ? + NotificationUrgency.Critical : NotificationUrgency.Normal + } + + ColumnLayout { // Content + Layout.fillWidth: true + spacing: expanded ? (root.multipleNotifications ? + (notificationGroup?.notifications[root.notificationCount - 1].image != "") ? 35 : + 5 : 0) : 0 + // spacing: 00 + Behavior on spacing { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Item { // App name (or summary when there's only 1 notif) and time + id: topRow + // spacing: 0 + Layout.fillWidth: true + property real fontSize: Appearance.font.pixelSize.smaller + property bool showAppName: root.multipleNotifications + implicitHeight: Math.max(topTextRow.implicitHeight, expandButton.implicitHeight) + + RowLayout { + id: topTextRow + anchors.left: parent.left + anchors.right: expandButton.left + anchors.verticalCenter: parent.verticalCenter + spacing: 5 + StyledText { + id: appName + elide: Text.ElideRight + Layout.fillWidth: true + text: (topRow.showAppName ? + notificationGroup?.appName : + notificationGroup?.notifications[0]?.summary) || "" + font.pixelSize: topRow.showAppName ? + topRow.fontSize : + Appearance.font.pixelSize.small + color: topRow.showAppName ? + Appearance.colors.colSubtext : + Appearance.colors.colOnLayer2 + } + StyledText { + id: timeText + // Layout.fillWidth: true + Layout.rightMargin: 10 + horizontalAlignment: Text.AlignLeft + text: NotificationUtils.getFriendlyNotifTimeString(notificationGroup?.time) + font.pixelSize: topRow.fontSize + color: Appearance.colors.colSubtext + } + } + NotificationGroupExpandButton { + id: expandButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + count: root.notificationCount + expanded: root.expanded + fontSize: topRow.fontSize + onClicked: { root.toggleExpanded() } + altAction: () => { root.toggleExpanded() } + + StyledToolTip { + text: Translation.tr("Tip: right-clicking a group\nalso expands it") + } + } + } + + StyledListView { // Notification body (expanded) + id: notificationsColumn + implicitHeight: contentHeight + Layout.fillWidth: true + spacing: expanded ? 5 : 3 + // clip: true + interactive: false + Behavior on spacing { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + model: ScriptModel { + values: root.expanded ? root.notifications.slice().reverse() : + root.notifications.slice().reverse().slice(0, 2) + } + delegate: NotificationItem { + required property int index + required property var modelData + notificationObject: modelData + expanded: root.expanded + onlyNotification: (root.notificationCount === 1) + opacity: (!root.expanded && index == 1 && root.notificationCount > 2) ? 0.5 : 1 + visible: root.expanded || (index < 2) + anchors.left: parent?.left + anchors.right: parent?.right + } + } + + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/NotificationGroupExpandButton.qml b/modules/quickshell/config/modules/common/widgets/NotificationGroupExpandButton.qml new file mode 100644 index 0000000..aced621 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/NotificationGroupExpandButton.qml @@ -0,0 +1,47 @@ +import qs.services +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts + +RippleButton { // Expand button + id: root + required property int count + required property bool expanded + property real fontSize: Appearance?.font.pixelSize.small ?? 12 + property real iconSize: Appearance?.font.pixelSize.normal ?? 16 + implicitHeight: fontSize + 4 * 2 + implicitWidth: Math.max(contentItem.implicitWidth + 5 * 2, 30) + Layout.alignment: Qt.AlignVCenter + Layout.fillHeight: false + + buttonRadius: Appearance.rounding.full + colBackground: ColorUtils.mix(Appearance?.colors.colLayer2, Appearance?.colors.colLayer2Hover, 0.5) + colBackgroundHover: Appearance?.colors.colLayer2Hover ?? "#E5DFED" + colRipple: Appearance?.colors.colLayer2Active ?? "#D6CEE2" + + contentItem: Item { + anchors.centerIn: parent + implicitWidth: contentRow.implicitWidth + RowLayout { + id: contentRow + anchors.centerIn: parent + spacing: 3 + StyledText { + Layout.leftMargin: 4 + visible: root.count > 1 + text: root.count + font.pixelSize: root.fontSize + } + MaterialSymbol { + text: "keyboard_arrow_down" + iconSize: root.iconSize + color: Appearance.colors.colOnLayer2 + rotation: expanded ? 180 : 0 + Behavior on rotation { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/NotificationItem.qml b/modules/quickshell/config/modules/common/widgets/NotificationItem.qml new file mode 100644 index 0000000..7ae1607 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/NotificationItem.qml @@ -0,0 +1,342 @@ +import qs +import qs.modules.common +import qs.services +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Hyprland +import Quickshell.Services.Notifications + +Item { // Notification item area + id: root + property var notificationObject + property bool expanded: false + property bool onlyNotification: false + property real fontSize: Appearance.font.pixelSize.small + property real padding: onlyNotification ? 0 : 8 + property real summaryElideRatio: 0.85 + + property real dragConfirmThreshold: 70 // Drag further to discard notification + property real dismissOvershoot: notificationIcon.implicitWidth + 20 // Account for gaps and bouncy animations + property var qmlParent: root?.parent?.parent // There's something between this and the parent ListView + property var parentDragIndex: qmlParent?.dragIndex ?? -1 + property var parentDragDistance: qmlParent?.dragDistance ?? 0 + property var dragIndexDiff: Math.abs(parentDragIndex - index) + property real xOffset: dragIndexDiff == 0 ? parentDragDistance : + Math.abs(parentDragDistance) > dragConfirmThreshold ? 0 : + dragIndexDiff == 1 ? (parentDragDistance * 0.3) : + dragIndexDiff == 2 ? (parentDragDistance * 0.1) : 0 + + implicitHeight: background.implicitHeight + + function processNotificationBody(body, appName) { + let processedBody = body + + // Clean Chromium-based browsers notifications - remove first line + if (appName) { + const lowerApp = appName.toLowerCase() + const chromiumBrowsers = [ + "brave", "chrome", "chromium", "vivaldi", "opera", "microsoft edge" + ] + + if (chromiumBrowsers.some(name => lowerApp.includes(name))) { + const lines = body.split('\n\n') + + if (lines.length > 1 && lines[0].startsWith(' { + Notifications.discardNotification(notificationObject.notificationId); + } + } + + DragManager { // Drag manager + id: dragManager + anchors.fill: root + anchors.leftMargin: root.expanded ? -notificationIcon.implicitWidth : 0 + interactive: expanded + automaticallyReset: false + acceptedButtons: Qt.LeftButton | Qt.MiddleButton + + onClicked: (mouse) => { + if (mouse.button === Qt.MiddleButton) { + root.destroyWithAnimation(); + } + } + + onDraggingChanged: () => { + if (dragging) { + root.qmlParent.dragIndex = root.index ?? root.parent.children.indexOf(root); + } + } + + onDragDiffXChanged: () => { + root.qmlParent.dragDistance = dragDiffX; + } + + onDragReleased: (diffX, diffY) => { + if (Math.abs(diffX) > root.dragConfirmThreshold) + root.destroyWithAnimation(diffX < 0); + else + dragManager.resetDrag(); + } + } + + NotificationAppIcon { // App icon + id: notificationIcon + opacity: (!onlyNotification && notificationObject.image != "" && expanded) ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + image: notificationObject.image + anchors.right: background.left + anchors.top: background.top + anchors.rightMargin: 10 + } + + Rectangle { // Background of notification item + id: background + width: parent.width + anchors.left: parent.left + radius: Appearance.rounding.small + anchors.leftMargin: root.xOffset + + Behavior on anchors.leftMargin { + enabled: !dragManager.dragging + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial + } + } + + color: (expanded && !onlyNotification) ? + (notificationObject.urgency == NotificationUrgency.Critical) ? + ColorUtils.mix(Appearance.colors.colSecondaryContainer, Appearance.colors.colLayer2, 0.35) : + (Appearance.colors.colLayer3) : + ColorUtils.transparentize(Appearance.colors.colLayer3) + + implicitHeight: expanded ? (contentColumn.implicitHeight + padding * 2) : summaryRow.implicitHeight + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + ColumnLayout { // Content column + id: contentColumn + anchors.fill: parent + anchors.margins: expanded ? root.padding : 0 + spacing: 3 + + Behavior on anchors.margins { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + RowLayout { // Summary row + id: summaryRow + visible: !root.onlyNotification || !root.expanded + Layout.fillWidth: true + implicitHeight: summaryText.implicitHeight + StyledText { + id: summaryText + Layout.fillWidth: summaryTextMetrics.width >= summaryRow.implicitWidth * root.summaryElideRatio + visible: !root.onlyNotification + font.pixelSize: root.fontSize + color: Appearance.colors.colOnLayer3 + elide: Text.ElideRight + text: root.notificationObject.summary || "" + } + StyledText { + opacity: !root.expanded ? 1 : 0 + visible: opacity > 0 + Layout.fillWidth: true + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + font.pixelSize: root.fontSize + color: Appearance.colors.colSubtext + elide: Text.ElideRight + wrapMode: Text.Wrap // Needed for proper eliding???? + maximumLineCount: 1 + textFormat: Text.StyledText + text: { + return processNotificationBody(notificationObject.body, notificationObject.appName || notificationObject.summary).replace(/\n/g, "
") + } + } + } + + ColumnLayout { // Expanded content + Layout.fillWidth: true + opacity: root.expanded ? 1 : 0 + visible: opacity > 0 + + StyledText { // Notification body (expanded) + id: notificationBodyText + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Layout.fillWidth: true + font.pixelSize: root.fontSize + color: Appearance.colors.colSubtext + wrapMode: Text.Wrap + elide: Text.ElideRight + textFormat: Text.RichText + text: { + return `` + + `${processNotificationBody(notificationObject.body, notificationObject.appName || notificationObject.summary).replace(/\n/g, "
")}` + } + + onLinkActivated: (link) => { + Qt.openUrlExternally(link) + GlobalStates.sidebarRightOpen = false + } + + PointingHandLinkHover {} + } + + Item { + Layout.fillWidth: true + implicitWidth: actionsFlickable.implicitWidth + implicitHeight: actionsFlickable.implicitHeight + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: actionsFlickable.width + height: actionsFlickable.height + radius: Appearance.rounding.small + } + } + + ScrollEdgeFade { + target: actionsFlickable + vertical: false + } + + StyledFlickable { // Notification actions + id: actionsFlickable + anchors.fill: parent + implicitHeight: actionRowLayout.implicitHeight + contentWidth: actionRowLayout.implicitWidth + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + RowLayout { + id: actionRowLayout + Layout.alignment: Qt.AlignBottom + + NotificationActionButton { + Layout.fillWidth: true + buttonText: Translation.tr("Close") + urgency: notificationObject.urgency + implicitWidth: (notificationObject.actions.length == 0) ? ((actionsFlickable.width - actionRowLayout.spacing) / 2) : + (contentItem.implicitWidth + leftPadding + rightPadding) + + onClicked: { + root.destroyWithAnimation() + } + + contentItem: MaterialSymbol { + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: (notificationObject.urgency == NotificationUrgency.Critical) ? + Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface + text: "close" + } + } + + Repeater { + id: actionRepeater + model: notificationObject.actions + NotificationActionButton { + Layout.fillWidth: true + buttonText: modelData.text + urgency: notificationObject.urgency + onClicked: { + Notifications.attemptInvokeAction(notificationObject.notificationId, modelData.identifier); + } + } + } + + NotificationActionButton { + Layout.fillWidth: true + urgency: notificationObject.urgency + implicitWidth: (notificationObject.actions.length == 0) ? ((actionsFlickable.width - actionRowLayout.spacing) / 2) : + (contentItem.implicitWidth + leftPadding + rightPadding) + + onClicked: { + Quickshell.clipboardText = notificationObject.body + copyIcon.text = "inventory" + copyIconTimer.restart() + } + + Timer { + id: copyIconTimer + interval: 1500 + repeat: false + onTriggered: { + copyIcon.text = "content_copy" + } + } + + contentItem: MaterialSymbol { + id: copyIcon + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: (notificationObject.urgency == NotificationUrgency.Critical) ? + Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface + text: "content_copy" + } + } + + } + } + } + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/NotificationListView.qml b/modules/quickshell/config/modules/common/widgets/NotificationListView.qml new file mode 100644 index 0000000..ec76349 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/NotificationListView.qml @@ -0,0 +1,26 @@ +pragma ComponentBehavior: Bound + +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell + +StyledListView { // Scrollable window + id: root + property bool popup: false + + spacing: 3 + + model: ScriptModel { + values: root.popup ? Notifications.popupAppNameList : Notifications.appNameList + } + delegate: NotificationGroup { + required property int index + required property var modelData + popup: root.popup + width: ListView.view.width // https://doc.qt.io/qt-6/qml-qtquick-listview.html + notificationGroup: popup ? + Notifications.popupGroupsByAppName[modelData] : + Notifications.groupsByAppName[modelData] + } +} diff --git a/modules/quickshell/config/modules/common/widgets/OptionalMaterialSymbol.qml b/modules/quickshell/config/modules/common/widgets/OptionalMaterialSymbol.qml new file mode 100644 index 0000000..196a15a --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/OptionalMaterialSymbol.qml @@ -0,0 +1,27 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +Loader { + id: root + required property string icon + property real iconSize: Appearance.font.pixelSize.larger + Layout.alignment: Qt.AlignVCenter + + active: root.icon && root.icon.length > 0 + visible: active + + sourceComponent: Item { + implicitWidth: materialSymbol.implicitWidth + + MaterialSymbol { + id: materialSymbol + anchors.centerIn: parent + + iconSize: root.iconSize + color: root.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer + text: root.icon + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/PagePlaceholder.qml b/modules/quickshell/config/modules/common/widgets/PagePlaceholder.qml new file mode 100644 index 0000000..ba5172c --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/PagePlaceholder.qml @@ -0,0 +1,61 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +Item { + id: root + + property bool shown: true + property alias icon: shapeWidget.text + property alias title: widgetNameText.text + property alias description: widgetDescriptionText.text + property alias shape: shapeWidget.shape + property alias descriptionHorizontalAlignment: widgetDescriptionText.horizontalAlignment + + opacity: shown ? 1 : 0 + visible: opacity > 0 + anchors { + fill: parent + topMargin: -30 * (1 - opacity) + bottomMargin: 30 * (1 - opacity) + } + + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialShapeWrappedMaterialSymbol { + id: shapeWidget + Layout.alignment: Qt.AlignHCenter + padding: 12 + iconSize: 56 + rotation: -30 * (1 - root.opacity) + } + StyledText { + id: widgetNameText + visible: title !== "" + Layout.alignment: Qt.AlignHCenter + font { + family: Appearance.font.family.title + pixelSize: Appearance.font.pixelSize.larger + variableAxes: Appearance.font.variableAxes.title + } + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + } + StyledText { + id: widgetDescriptionText + visible: description !== "" + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignLeft + wrapMode: Text.Wrap + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/PointingHandInteraction.qml b/modules/quickshell/config/modules/common/widgets/PointingHandInteraction.qml new file mode 100644 index 0000000..a626bb9 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/PointingHandInteraction.qml @@ -0,0 +1,7 @@ +import QtQuick + +MouseArea { + anchors.fill: parent + onPressed: (mouse) => mouse.accepted = false + cursorShape: Qt.PointingHandCursor +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/PointingHandLinkHover.qml b/modules/quickshell/config/modules/common/widgets/PointingHandLinkHover.qml new file mode 100644 index 0000000..4d14c81 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/PointingHandLinkHover.qml @@ -0,0 +1,8 @@ +import QtQuick + +MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton // Only for hover + hoverEnabled: true + cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor +} diff --git a/modules/quickshell/config/modules/common/widgets/PopupToolTip.qml b/modules/quickshell/config/modules/common/widgets/PopupToolTip.qml new file mode 100644 index 0000000..437c02a --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/PopupToolTip.qml @@ -0,0 +1,60 @@ +pragma ComponentBehavior: Bound +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +Item { + id: root + property string text: "" + property bool extraVisibleCondition: true + property bool alternativeVisibleCondition: false + property real horizontalPadding: 10 + property real verticalPadding: 5 + property real horizontalMargin: horizontalPadding + property real verticalMargin: verticalPadding + + function updateAnchor() { + tooltipLoader.item?.anchor.updateAnchor(); + } + + readonly property bool internalVisibleCondition: (extraVisibleCondition && (parent.hovered === undefined || parent?.hovered)) || alternativeVisibleCondition + property var anchorEdges: Edges.Top + property var anchorGravity: anchorEdges + + property Item contentItem: StyledToolTipContent { + id: contentItem + anchors.centerIn: parent + text: root.text + shown: false + Component.onCompleted: shown = true + horizontalPadding: root.horizontalPadding + verticalPadding: root.verticalPadding + } + + Loader { + id: tooltipLoader + anchors.fill: parent + active: root.internalVisibleCondition + sourceComponent: PopupWindow { + visible: true + anchor { + window: root.QsWindow.window + item: root.parent + edges: root.anchorEdges + gravity: root.anchorGravity + } + mask: Region { + item: null + } + + color: "transparent" + implicitWidth: root.contentItem.implicitWidth + root.horizontalMargin * 2 + implicitHeight: root.contentItem.implicitHeight + root.verticalMargin * 2 + + data: [root.contentItem] + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/Revealer.qml b/modules/quickshell/config/modules/common/widgets/Revealer.qml new file mode 100644 index 0000000..bdfd15c --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/Revealer.qml @@ -0,0 +1,27 @@ +import qs.modules.common +import QtQuick + +/** + * Recreation of GTK revealer. Expects one single child. + */ +Item { + id: root + property bool reveal + property bool vertical: false + property real contentWidth: children.length > 0 ? children[0].implicitWidth : 0 + property real contentHeight: children.length > 0 ? children[0].implicitHeight : 0 + clip: true + + implicitWidth: (reveal || vertical) ? contentWidth : 0 + implicitHeight: (reveal || !vertical) ? contentHeight : 0 + visible: reveal || (width > 0 && height > 0) + + Behavior on implicitWidth { + enabled: !vertical + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + enabled: vertical + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } +} diff --git a/modules/quickshell/config/modules/common/widgets/RippleButton.qml b/modules/quickshell/config/modules/common/widgets/RippleButton.qml new file mode 100644 index 0000000..498bfc1 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/RippleButton.qml @@ -0,0 +1,186 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls + +/** + * A button with ripple effect similar to in Material Design. + */ +Button { + id: root + property bool toggled + property string buttonText + property bool pointingHandCursor: true + property real buttonRadius: Appearance?.rounding?.small ?? 4 + property real buttonRadiusPressed: buttonRadius + property real buttonEffectiveRadius: root.down ? root.buttonRadiusPressed : root.buttonRadius + property int rippleDuration: 1200 + property bool rippleEnabled: true + property var downAction // When left clicking (down) + property var releaseAction // When left clicking (release) + property var altAction // When right clicking + property var middleClickAction // When middle clicking + + property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 1) || "transparent" + property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED" + property color colBackgroundToggled: Appearance?.colors.colPrimary ?? "#65558F" + property color colBackgroundToggledHover: Appearance?.colors.colPrimaryHover ?? "#77699C" + property color colRipple: Appearance?.colors.colLayer1Active ?? "#D6CEE2" + property color colRippleToggled: Appearance?.colors.colPrimaryActive ?? "#D6CEE2" + + opacity: root.enabled ? 1 : 0.4 + property color buttonColor: ColorUtils.transparentize(root.toggled ? + (root.hovered ? colBackgroundToggledHover : + colBackgroundToggled) : + (root.hovered ? colBackgroundHover : + colBackground), root.enabled ? 0 : 1) + property color rippleColor: root.toggled ? colRippleToggled : colRipple + + function startRipple(x, y) { + const stateY = buttonBackground.y; + rippleAnim.x = x; + rippleAnim.y = y - stateY; + + const dist = (ox,oy) => ox*ox + oy*oy + const stateEndY = stateY + buttonBackground.height + rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY))) + + rippleFadeAnim.complete(); + rippleAnim.restart(); + } + + component RippleAnim: NumberAnimation { + duration: rippleDuration + easing.type: Appearance?.animation.elementMoveEnter.type + easing.bezierCurve: Appearance?.animationCurves.standardDecel + } + + MouseArea { + anchors.fill: parent + cursorShape: root.pointingHandCursor ? Qt.PointingHandCursor : Qt.ArrowCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onPressed: (event) => { + if(event.button === Qt.RightButton) { + if (root.altAction) root.altAction(event); + return; + } + if(event.button === Qt.MiddleButton) { + if (root.middleClickAction) root.middleClickAction(); + return; + } + root.down = true + if (root.downAction) root.downAction(); + if (!root.rippleEnabled) return; + const {x,y} = event + startRipple(x, y) + } + onReleased: (event) => { + root.down = false + if (event.button != Qt.LeftButton) return; + if (root.releaseAction) root.releaseAction(); + root.click() // Because the MouseArea already consumed the event + if (!root.rippleEnabled) return; + rippleFadeAnim.restart(); + } + onCanceled: (event) => { + root.down = false + if (!root.rippleEnabled) return; + rippleFadeAnim.restart(); + } + } + + RippleAnim { + id: rippleFadeAnim + duration: rippleDuration * 2 + target: ripple + property: "opacity" + to: 0 + } + + SequentialAnimation { + id: rippleAnim + + property real x + property real y + property real radius + + PropertyAction { + target: ripple + property: "x" + value: rippleAnim.x + } + PropertyAction { + target: ripple + property: "y" + value: rippleAnim.y + } + PropertyAction { + target: ripple + property: "opacity" + value: 1 + } + ParallelAnimation { + RippleAnim { + target: ripple + properties: "implicitWidth,implicitHeight" + from: 0 + to: rippleAnim.radius * 2 + } + } + } + + background: Rectangle { + id: buttonBackground + radius: root.buttonEffectiveRadius + implicitHeight: 30 + + color: root.buttonColor + Behavior on color { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: buttonBackground.width + height: buttonBackground.height + radius: root.buttonEffectiveRadius + } + } + + Item { + id: ripple + width: ripple.implicitWidth + height: ripple.implicitHeight + opacity: 0 + visible: width > 0 && height > 0 + + property real implicitWidth: 0 + property real implicitHeight: 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + + RadialGradient { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: root.rippleColor } + GradientStop { position: 0.3; color: root.rippleColor } + GradientStop { position: 0.5; color: Qt.rgba(root.rippleColor.r, root.rippleColor.g, root.rippleColor.b, 0) } + } + } + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } + + contentItem: StyledText { + text: root.buttonText + } +} diff --git a/modules/quickshell/config/modules/common/widgets/RippleButtonWithIcon.qml b/modules/quickshell/config/modules/common/widgets/RippleButtonWithIcon.qml new file mode 100644 index 0000000..3bba711 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/RippleButtonWithIcon.qml @@ -0,0 +1,58 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +RippleButton { + id: buttonWithIconRoot + property string nerdIcon + property string materialIcon + property bool materialIconFill: true + property string mainText: "Button text" + property Component mainContentComponent: Component { + StyledText { + visible: text !== "" + text: buttonWithIconRoot.mainText + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSecondaryContainer + } + } + implicitHeight: 35 + horizontalPadding: 10 + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colLayer2 + + contentItem: RowLayout { + Item { + Layout.fillWidth: false + implicitWidth: Math.max(materialIconLoader.implicitWidth, nerdIconLoader.implicitWidth) + Loader { + id: materialIconLoader + anchors.centerIn: parent + active: !nerdIcon + sourceComponent: MaterialSymbol { + text: buttonWithIconRoot.materialIcon + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnSecondaryContainer + fill: buttonWithIconRoot.materialIconFill ? 1 : 0 + } + } + Loader { + id: nerdIconLoader + anchors.centerIn: parent + active: nerdIcon + sourceComponent: StyledText { + text: buttonWithIconRoot.nerdIcon + font.pixelSize: Appearance.font.pixelSize.larger + font.family: Appearance.font.family.iconNerd + color: Appearance.colors.colOnSecondaryContainer + } + } + } + Loader { + Layout.fillWidth: true + sourceComponent: buttonWithIconRoot.mainContentComponent + Layout.alignment: Qt.AlignVCenter + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/RoundCorner.qml b/modules/quickshell/config/modules/common/widgets/RoundCorner.qml new file mode 100644 index 0000000..f833a78 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/RoundCorner.qml @@ -0,0 +1,80 @@ +import QtQuick +import QtQuick.Shapes + +Item { + id: root + + enum CornerEnum { TopLeft, TopRight, BottomLeft, BottomRight } + property var corner: RoundCorner.CornerEnum.TopLeft + property alias leftVisualMargin: shape.anchors.leftMargin + property alias topVisualMargin: shape.anchors.topMargin + property alias rightVisualMargin: shape.anchors.rightMargin + property alias bottomVisualMargin: shape.anchors.bottomMargin + + property int implicitSize: 25 + property color color: "#000000" + + implicitWidth: implicitSize + implicitHeight: implicitSize + + property bool isTopLeft: corner === RoundCorner.CornerEnum.TopLeft + property bool isBottomLeft: corner === RoundCorner.CornerEnum.BottomLeft + property bool isTopRight: corner === RoundCorner.CornerEnum.TopRight + property bool isBottomRight: corner === RoundCorner.CornerEnum.BottomRight + property bool isTop: isTopLeft || isTopRight + property bool isBottom: isBottomLeft || isBottomRight + property bool isLeft: isTopLeft || isBottomLeft + property bool isRight: isTopRight || isBottomRight + + Shape { + id: shape + anchors { + top: root.isTop ? parent.top : undefined + bottom: root.isBottom ? parent.bottom : undefined + left: root.isLeft ? parent.left : undefined + right: root.isRight ? parent.right : undefined + } + layer.enabled: true + layer.smooth: true + preferredRendererType: Shape.CurveRenderer + + ShapePath { + id: shapePath + strokeWidth: 0 + fillColor: root.color + pathHints: ShapePath.PathSolid & ShapePath.PathNonIntersecting + + startX: switch (root.corner) { + case RoundCorner.CornerEnum.TopLeft: + case RoundCorner.CornerEnum.BottomLeft: return 0; + case RoundCorner.CornerEnum.TopRight: + case RoundCorner.CornerEnum.BottomRight: return root.implicitSize; + } + startY: switch (root.corner) { + case RoundCorner.CornerEnum.TopLeft: + case RoundCorner.CornerEnum.TopRight: return 0; + case RoundCorner.CornerEnum.BottomLeft: + case RoundCorner.CornerEnum.BottomRight: return root.implicitSize; + } + PathAngleArc { + moveToStart: false + centerX: root.implicitSize - shapePath.startX + centerY: root.implicitSize - shapePath.startY + radiusX: root.implicitSize + radiusY: root.implicitSize + startAngle: switch (root.corner) { + case RoundCorner.CornerEnum.TopLeft: return 180; + case RoundCorner.CornerEnum.TopRight: return -90; + case RoundCorner.CornerEnum.BottomLeft: return 90; + case RoundCorner.CornerEnum.BottomRight: return 0; + } + sweepAngle: 90 + } + PathLine { + x: shapePath.startX + y: shapePath.startY + } + } + } + +} diff --git a/modules/quickshell/config/modules/common/widgets/ScrollEdgeFade.qml b/modules/quickshell/config/modules/common/widgets/ScrollEdgeFade.qml new file mode 100644 index 0000000..5c19f81 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ScrollEdgeFade.qml @@ -0,0 +1,59 @@ +import QtQuick +import qs.modules.common +import qs.modules.common.functions + +Item { + id: root + z: 99 + required property Item target + property real fadeSize: Appearance.m3colors.darkmode ? 40 : 20 + property color color: ColorUtils.transparentize(Appearance.colors.colShadow, Appearance.m3colors.darkmode ? 0 : 0.7) + property bool vertical: true + + anchors.fill: target + + EndGradient { + anchors { + top: parent.top + left: parent.left + right: vertical ? parent.right : undefined + bottom: vertical ? undefined : parent.bottom + } + shown: !(root.vertical ? root.target.atYBeginning : root.target.atXBeginning) + } + + EndGradient { + anchors { + bottom: parent.bottom + right: parent.right + left: vertical ? parent.left : undefined + top: vertical ? undefined : parent.top + } + shown: !(root.vertical ? root.target.atYEnd : root.target.atXEnd) + rotation: 180 + } + + component EndGradient: Rectangle { + required property bool shown + height: vertical ? root.fadeSize : parent.height + width: vertical ? parent.width : root.fadeSize + + opacity: shown ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + gradient: Gradient { + orientation: root.vertical ? Gradient.Vertical : Gradient.Horizontal + GradientStop { + position: 0.0 + color: root.color + } + GradientStop { + position: 1.0 + color: ColorUtils.transparentize(root.color) + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/SecondaryTabBar.qml b/modules/quickshell/config/modules/common/widgets/SecondaryTabBar.qml new file mode 100644 index 0000000..dcb4900 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/SecondaryTabBar.qml @@ -0,0 +1,53 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.models + +TabBar { + id: root + property real indicatorPadding: 8 + Layout.fillWidth: true + + background: Item { + WheelHandler { + onWheel: (event) => { + if (event.angleDelta.y < 0) root.incrementCurrentIndex(); + else if (event.angleDelta.y > 0) root.decrementCurrentIndex(); + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + + Rectangle { + id: activeIndicator + z: 9999 + anchors.bottom: parent.bottom + topLeftRadius: height + topRightRadius: height + bottomLeftRadius: 0 + bottomRightRadius: 0 + color: Appearance.colors.colPrimary + // Animation + property real baseWidth: root.width / root.count + AnimatedTabIndexPair { + id: idxPair + index: root.currentIndex + } + height: 3 + x: Math.min(idxPair.idx1, idxPair.idx2) * baseWidth + root.indicatorPadding + width: ((Math.max(idxPair.idx1, idxPair.idx2) + 1) * baseWidth - root.indicatorPadding) - x + } + + Rectangle { // Tabbar bottom border + id: tabBarBottomBorder + z: 9998 + anchors.bottom: parent.bottom + height: 1 + anchors { + left: parent.left + right: parent.right + } + color: Appearance.colors.colOutlineVariant + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/SecondaryTabButton.qml b/modules/quickshell/config/modules/common/widgets/SecondaryTabButton.qml new file mode 100644 index 0000000..b0bc9b4 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/SecondaryTabButton.qml @@ -0,0 +1,180 @@ +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +TabButton { + id: root + property string buttonText + property string buttonIcon + property int rippleDuration: 1200 + property int tabContentWidth: buttonBackground.width - buttonBackground.radius*2 + + property color colBackground: ColorUtils.transparentize(Appearance.colors.colSurfaceContainer) + property color colBackgroundHover: ColorUtils.transparentize(Appearance.colors.colOnSurface, root.checked ? 1 : 0.95) + property color colRipple: ColorUtils.transparentize(Appearance.colors.colOnSurface, 0.95) + + PointingHandInteraction {} + + component RippleAnim: NumberAnimation { + duration: rippleDuration + easing.type: Appearance.animation.elementMoveEnter.type + easing.bezierCurve: Appearance.animationCurves.standardDecel + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onPressed: (event) => { + root.click() // Because the MouseArea already consumed the event + const {x,y} = event + const stateY = buttonBackground.y; + rippleAnim.x = x; + rippleAnim.y = y - stateY; + + const dist = (ox,oy) => ox*ox + oy*oy + const stateEndY = stateY + buttonBackground.height + rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY))) + + rippleFadeAnim.complete(); + rippleAnim.restart(); + } + onReleased: (event) => { + rippleFadeAnim.restart(); + } + } + + RippleAnim { + id: rippleFadeAnim + duration: rippleDuration * 2 + target: ripple + property: "opacity" + to: 0 + } + + SequentialAnimation { + id: rippleAnim + + property real x + property real y + property real radius + + PropertyAction { + target: ripple + property: "x" + value: rippleAnim.x + } + PropertyAction { + target: ripple + property: "y" + value: rippleAnim.y + } + PropertyAction { + target: ripple + property: "opacity" + value: 1 + } + ParallelAnimation { + RippleAnim { + target: ripple + properties: "implicitWidth,implicitHeight" + from: 0 + to: rippleAnim.radius * 2 + } + } + } + + background: Rectangle { + id: buttonBackground + anchors { + fill: parent + margins: 3 + } + radius: Appearance?.rounding.normal + implicitHeight: 42 + color: (root.hovered ? root.colBackgroundHover : root.colBackground) + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: buttonBackground.width + height: buttonBackground.height + radius: buttonBackground.radius + } + } + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + Item { + id: ripple + width: ripple.implicitWidth + height: ripple.implicitHeight + opacity: 0 + + property real implicitWidth: 0 + property real implicitHeight: 0 + visible: width > 0 && height > 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + + RadialGradient { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: root.colRipple } + GradientStop { position: 0.3; color: root.colRipple } + GradientStop { position: 0.5 ; color: Qt.rgba(root.colRipple.r, root.colRipple.g, root.colRipple.b, 0) } + } + } + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } + + contentItem: Item { + anchors.centerIn: buttonBackground + RowLayout { + anchors.centerIn: parent + spacing: 0 + + Loader { + id: iconLoader + active: buttonIcon?.length > 0 + sourceComponent: buttonIcon?.length > 0 ? materialSymbolComponent : null + Layout.rightMargin: 5 + } + + Component { + id: materialSymbolComponent + MaterialSymbol { + verticalAlignment: Text.AlignVCenter + text: buttonIcon + iconSize: Appearance.font.pixelSize.huge + fill: root.checked ? 1 : 0 + color: root.checked ? Appearance.colors.colPrimary : Appearance.colors.colOnLayer1 + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + StyledText { + id: buttonTextWidget + verticalAlignment: Text.AlignVCenter + font.pixelSize: Appearance.font.pixelSize.small + color: root.checked ? Appearance.colors.colPrimary : Appearance.colors.colOnLayer1 + text: buttonText + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/SelectionDialog.qml b/modules/quickshell/config/modules/common/widgets/SelectionDialog.qml new file mode 100644 index 0000000..68d67a3 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/SelectionDialog.qml @@ -0,0 +1,128 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts +import Quickshell + +Item { + id: root + property real dialogPadding: 15 + property real dialogMargin: 30 + property string titleText: "Selection Dialog" + property alias items: choiceModel.values + property int selectedId: choiceListView.currentIndex + property var defaultChoice + + signal canceled(); + signal selected(var result); + + Rectangle { // Scrim + id: scrimOverlay + anchors.fill: parent + radius: Appearance.rounding.small + color: Appearance.colors.colScrim + MouseArea { + hoverEnabled: true + anchors.fill: parent + preventStealing: true + propagateComposedEvents: false + } + } + + Rectangle { // The dialog + id: dialog + color: Appearance.colors.colSurfaceContainerHigh + radius: Appearance.rounding.normal + anchors.fill: parent + anchors.margins: dialogMargin + implicitHeight: dialogColumnLayout.implicitHeight + + ColumnLayout { + id: dialogColumnLayout + anchors.fill: parent + spacing: 16 + + StyledText { + id: dialogTitle + Layout.topMargin: dialogPadding + Layout.leftMargin: dialogPadding + Layout.rightMargin: dialogPadding + Layout.alignment: Qt.AlignLeft + color: Appearance.m3colors.m3onSurface + font.pixelSize: Appearance.font.pixelSize.larger + text: root.titleText + } + + Rectangle { + color: Appearance.m3colors.m3outline + implicitHeight: 1 + Layout.fillWidth: true + Layout.leftMargin: dialogPadding + Layout.rightMargin: dialogPadding + } + + StyledListView { + id: choiceListView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + currentIndex: root.defaultChoice !== undefined ? root.items.indexOf(root.defaultChoice) : -1 + spacing: 6 + + model: ScriptModel { + id: choiceModel + } + + delegate: StyledRadioButton { + id: radioButton + required property var modelData + required property int index + anchors { + left: parent?.left + right: parent?.right + leftMargin: root.dialogPadding + rightMargin: root.dialogPadding + } + + description: modelData.toString() + checked: index === choiceListView.currentIndex + + onCheckedChanged: { + if (checked) { + choiceListView.currentIndex = index; + } + } + } + } + + Rectangle { + color: Appearance.m3colors.m3outline + implicitHeight: 1 + Layout.fillWidth: true + Layout.leftMargin: dialogPadding + Layout.rightMargin: dialogPadding + } + + RowLayout { + id: dialogButtonsRowLayout + Layout.bottomMargin: dialogPadding + Layout.leftMargin: dialogPadding + Layout.rightMargin: dialogPadding + Layout.alignment: Qt.AlignRight + + DialogButton { + buttonText: Translation.tr("Cancel") + onClicked: root.canceled() + } + DialogButton { + buttonText: Translation.tr("OK") + onClicked: root.selected( + root.selectedId === -1 ? null : + root.items[root.selectedId] + ) + } + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/SelectionGroupButton.qml b/modules/quickshell/config/modules/common/widgets/SelectionGroupButton.qml new file mode 100644 index 0000000..ab7aa1e --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/SelectionGroupButton.qml @@ -0,0 +1,62 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +GroupButton { + id: root + horizontalPadding: 12 + verticalPadding: 8 + bounce: false + property string buttonIcon + property bool leftmost: false + property bool rightmost: false + leftRadius: (toggled || leftmost) ? (height / 2) : Appearance.rounding.unsharpenmore + rightRadius: (toggled || rightmost) ? (height / 2) : Appearance.rounding.unsharpenmore + colBackground: Appearance.colors.colSecondaryContainer + colBackgroundHover: Appearance.colors.colSecondaryContainerHover + colBackgroundActive: Appearance.colors.colSecondaryContainerActive + + contentItem: RowLayout { + spacing: 4 * (root.buttonText?.length > 0) + + Loader { + Layout.alignment: Qt.AlignVCenter + active: root.buttonIcon && root.buttonIcon.length > 0 + visible: active + sourceComponent: Item { + implicitWidth: materialSymbol.implicitWidth + MaterialSymbol { + id: materialSymbol + anchors.centerIn: parent + text: root.buttonIcon + iconSize: Appearance.font.pixelSize.larger + color: root.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer + } + } + } + + Item { + implicitWidth: root.buttonText?.length > 0 ? textItem.implicitWidth : 0 + implicitHeight: textMetrics.height // Force height to that of regular text + + TextMetrics { + id: textMetrics + font.family: Appearance.font.family.main + text: "Abc" + } + + StyledText { + id: textItem + anchors.centerIn: parent + color: root.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer + text: root.buttonText + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/SineCookie.qml b/modules/quickshell/config/modules/common/widgets/SineCookie.qml new file mode 100644 index 0000000..79048ca --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/SineCookie.qml @@ -0,0 +1,70 @@ +import QtQuick +import QtQuick.Shapes +import Quickshell +import qs.modules.common + +Item { + id: root + + property real sides: 12 + property int implicitSize: 100 + property real amplitude: implicitSize / 50 + property int renderPoints: 360 + property color color: "#605790" + property alias strokeWidth: shapePath.strokeWidth + property bool constantlyRotate: false + + implicitWidth: implicitSize + implicitHeight: implicitSize + + property real shapeRotation: 0 + + Loader { + active: constantlyRotate + sourceComponent: FrameAnimation { + running: true + onTriggered: { + shapeRotation += 0.05 + } + } + } + + Behavior on sides { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Shape { + id: shape + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + id: shapePath + strokeWidth: 0 + fillColor: root.color + pathHints: ShapePath.PathSolid & ShapePath.PathNonIntersecting + + PathPolyline { + property var pointsList: { + var points = [] + var cx = shape.width / 2 // center x + var cy = shape.height / 2 // center y + var steps = root.renderPoints + var radius = root.implicitSize / 2 - root.amplitude + for (var i = 0; i <= steps; i++) { + var angle = (i / steps) * 2 * Math.PI + var rotatedAngle = angle * root.sides + Math.PI/2 + (root.shapeRotation * root.constantlyRotate) + var wave = Math.sin(rotatedAngle) * root.amplitude + var x = Math.cos(angle) * (radius + wave) + cx + var y = Math.sin(angle) * (radius + wave) + cy + points.push(Qt.point(x, y)) + } + return points + } + + path: pointsList + } + + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledBlurEffect.qml b/modules/quickshell/config/modules/common/widgets/StyledBlurEffect.qml new file mode 100644 index 0000000..c271538 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledBlurEffect.qml @@ -0,0 +1,12 @@ +import QtQuick +import QtQuick.Effects + +MultiEffect { + id: root + source: wallpaper + anchors.fill: source + saturation: 0.2 + blurEnabled: true + blurMax: 100 + blur: 1 +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledComboBox.qml b/modules/quickshell/config/modules/common/widgets/StyledComboBox.qml new file mode 100644 index 0000000..18add2c --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledComboBox.qml @@ -0,0 +1,208 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +ComboBox { + id: root + + property string buttonIcon: "" + property real buttonRadius: height / 2 + property color colBackground: Appearance.colors.colSecondaryContainer + property color colBackgroundHover: Appearance.colors.colSecondaryContainerHover + property color colBackgroundActive: Appearance.colors.colSecondaryContainerActive + + implicitHeight: 40 + Layout.fillWidth: true + + background: Rectangle { + radius: root.buttonRadius + color: (root.down && !root.popup.visible) ? root.colBackgroundActive : root.hovered ? root.colBackgroundHover : root.colBackground + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: Qt.PointingHandCursor + } + } + + indicator: MaterialSymbol { + x: root.width - width - 16 + y: root.height / 2 - height / 2 + text: "keyboard_arrow_down" + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnSecondaryContainer + + rotation: root.popup.visible ? 180 : 0 + Behavior on rotation { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + + contentItem: Item { + implicitWidth: buttonLayout.implicitWidth + implicitHeight: buttonLayout.implicitHeight + + RowLayout { + id: buttonLayout + anchors.fill: parent + spacing: 8 + anchors.leftMargin: 16 + anchors.rightMargin: 16 + + Loader { + Layout.alignment: Qt.AlignVCenter + active: root.buttonIcon.length > 0 || (root.currentIndex >= 0 && typeof root.model[root.currentIndex] === 'object' && root.model[root.currentIndex]?.icon) + visible: active + sourceComponent: MaterialSymbol { + text: { + if (root.currentIndex >= 0 && typeof root.model[root.currentIndex] === 'object' && root.model[root.currentIndex]?.icon) { + return root.model[root.currentIndex].icon; + } + return root.buttonIcon; + } + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnSecondaryContainer + } + } + + StyledText { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + color: Appearance.colors.colOnSecondaryContainer + text: root.displayText + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + } + } + + delegate: ItemDelegate { + id: itemDelegate + width: ListView.view ? ListView.view.width : root.width + implicitHeight: 40 + + required property var model + required property int index + property color color: { + if (root.currentIndex === itemDelegate.index) { + if (itemDelegate.down) return Appearance.colors.colSecondaryContainerActive; + if (itemDelegate.hovered) return Appearance.colors.colSecondaryContainerHover; + return Appearance.colors.colSecondaryContainer; + } else { + if (itemDelegate.down) return Appearance.colors.colLayer3Active; + if (itemDelegate.hovered) return Appearance.colors.colLayer3Hover; + return ColorUtils.transparentize(Appearance.colors.colLayer3); + } + } + property color colText: (root.currentIndex === itemDelegate.index) ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer3 + + background: Rectangle { + anchors.fill: parent + radius: Appearance.rounding.small + color: itemDelegate.color + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: Qt.PointingHandCursor + } + } + + contentItem: RowLayout { + spacing: 8 + anchors.leftMargin: 12 + anchors.rightMargin: 12 + + Loader { + Layout.alignment: Qt.AlignVCenter + Layout.preferredHeight: Appearance.font.pixelSize.larger + active: typeof itemDelegate.model === 'object' && itemDelegate.model?.icon?.length > 0 + visible: active + + sourceComponent: Item { + implicitWidth: icon.implicitWidth + implicitHeight: Appearance.font.pixelSize.larger + + MaterialSymbol { + id: icon + anchors.centerIn: parent + text: itemDelegate.model?.icon ?? "" + iconSize: Appearance.font.pixelSize.larger + color: itemDelegate.colText + } + } + } + + StyledText { + Layout.fillWidth: true + Layout.preferredHeight: Appearance.font.pixelSize.larger + color: itemDelegate.colText + text: itemDelegate.model[root.textRole] + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + } + } + + popup: Popup { + y: root.height + 4 + width: root.width + height: Math.min(listView.contentHeight + topPadding + bottomPadding, 300) + padding: 8 + + enter: Transition { + PropertyAnimation { + properties: "opacity" + to: 1 + duration: Appearance.animation.elementMoveFast.duration + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + exit: Transition { + PropertyAnimation { + properties: "opacity" + to: 0 + duration: Appearance.animation.elementMoveFast.duration + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + background: Item { + StyledRectangularShadow { + target: popupBackground + } + + Rectangle { + id: popupBackground + anchors.fill: parent + radius: Appearance.rounding.normal + color: Appearance.colors.colSurfaceContainerHigh + } + } + + contentItem: StyledListView { + id: listView + clip: true + implicitHeight: contentHeight + spacing: 2 + model: root.popup.visible ? root.delegateModel : null + currentIndex: root.highlightedIndex + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledDropShadow.qml b/modules/quickshell/config/modules/common/widgets/StyledDropShadow.qml new file mode 100644 index 0000000..5daca89 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledDropShadow.qml @@ -0,0 +1,13 @@ +import QtQuick +import Qt5Compat.GraphicalEffects +import qs.modules.common + +DropShadow { + required property var target + source: target + anchors.fill: source + radius: 8 + samples: radius * 2 + 1 + color: Appearance.colors.colShadow + transparentBorder: true +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledFlickable.qml b/modules/quickshell/config/modules/common/widgets/StyledFlickable.qml new file mode 100644 index 0000000..10994ee --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledFlickable.qml @@ -0,0 +1,54 @@ +import QtQuick +import QtQuick.Controls +import qs.modules.common + +Flickable { + id: root + maximumFlickVelocity: 3500 + boundsBehavior: Flickable.DragOverBounds + + property real touchpadScrollFactor: Config?.options.interactions.scrolling.touchpadScrollFactor ?? 100 + property real mouseScrollFactor: Config?.options.interactions.scrolling.mouseScrollFactor ?? 50 + property real mouseScrollDeltaThreshold: Config?.options.interactions.scrolling.mouseScrollDeltaThreshold ?? 120 + // Accumulated scroll destination so wheel deltas stack while animating + property real scrollTargetY: 0 + + ScrollBar.vertical: StyledScrollBar {} + + MouseArea { + visible: Config?.options.interactions.scrolling.fasterTouchpadScroll + anchors.fill: parent + acceptedButtons: Qt.NoButton + onWheel: function(wheelEvent) { + const delta = wheelEvent.angleDelta.y / root.mouseScrollDeltaThreshold; + // The angleDelta.y of a touchpad is usually small and continuous, + // while that of a mouse wheel is typically in multiples of ยฑ120. + var scrollFactor = Math.abs(wheelEvent.angleDelta.y) >= root.mouseScrollDeltaThreshold ? root.mouseScrollFactor : root.touchpadScrollFactor; + + const maxY = Math.max(0, root.contentHeight - root.height); + const base = scrollAnim.running ? root.scrollTargetY : root.contentY; + var targetY = Math.max(0, Math.min(base - delta * scrollFactor, maxY)); + + root.scrollTargetY = targetY; + root.contentY = targetY; + wheelEvent.accepted = true; + } + } + + Behavior on contentY { + NumberAnimation { + id: scrollAnim + duration: Appearance.animation.scroll.duration + easing.type: Appearance.animation.scroll.type + easing.bezierCurve: Appearance.animation.scroll.bezierCurve + } + } + + // Keep target synced when not animating (e.g., drag/flick or programmatic changes) + onContentYChanged: { + if (!scrollAnim.running) { + root.scrollTargetY = root.contentY; + } + } + +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledImage.qml b/modules/quickshell/config/modules/common/widgets/StyledImage.qml new file mode 100644 index 0000000..c360b53 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledImage.qml @@ -0,0 +1,15 @@ +import QtQuick +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +Image { + asynchronous: true + retainWhileLoading: true + visible: opacity > 0 + opacity: (status === Image.Ready) ? 1 : 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledIndeterminateProgressBar.qml b/modules/quickshell/config/modules/common/widgets/StyledIndeterminateProgressBar.qml new file mode 100644 index 0000000..030d3fc --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledIndeterminateProgressBar.qml @@ -0,0 +1,9 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls.Material +import QtQuick.Controls + +ProgressBar { + indeterminate: true + Material.accent: Appearance.colors.colPrimary +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledListView.qml b/modules/quickshell/config/modules/common/widgets/StyledListView.qml new file mode 100644 index 0000000..26518cf --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledListView.qml @@ -0,0 +1,153 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls + +/** + * A ListView with animations. + */ +ListView { + id: root + spacing: 5 + property real removeOvershoot: 20 // Account for gaps and bouncy animations + property int dragIndex: -1 + property real dragDistance: 0 + property bool popin: true + property bool animateAppearance: true + property bool animateMovement: false + // Accumulated scroll destination so wheel deltas stack while animating + property real scrollTargetY: 0 + + property real touchpadScrollFactor: Config?.options.interactions.scrolling.touchpadScrollFactor ?? 100 + property real mouseScrollFactor: Config?.options.interactions.scrolling.mouseScrollFactor ?? 50 + property real mouseScrollDeltaThreshold: Config?.options.interactions.scrolling.mouseScrollDeltaThreshold ?? 120 + + function resetDrag() { + root.dragIndex = -1 + root.dragDistance = 0 + } + + maximumFlickVelocity: 3500 + boundsBehavior: Flickable.DragOverBounds + ScrollBar.vertical: StyledScrollBar {} + + MouseArea { + visible: Config?.options.interactions.scrolling.fasterTouchpadScroll + anchors.fill: parent + acceptedButtons: Qt.NoButton + onWheel: function(wheelEvent) { + const delta = wheelEvent.angleDelta.y / root.mouseScrollDeltaThreshold; + // The angleDelta.y of a touchpad is usually small and continuous, + // while that of a mouse wheel is typically in multiples of ยฑ120. + var scrollFactor = Math.abs(wheelEvent.angleDelta.y) >= root.mouseScrollDeltaThreshold ? root.mouseScrollFactor : root.touchpadScrollFactor; + + const maxY = Math.max(0, root.contentHeight - root.height); + const base = scrollAnim.running ? root.scrollTargetY : root.contentY; + var targetY = Math.max(0, Math.min(base - delta * scrollFactor, maxY)); + + root.scrollTargetY = targetY; + root.contentY = targetY; + wheelEvent.accepted = true; + } + } + + Behavior on contentY { + NumberAnimation { + id: scrollAnim + alwaysRunToEnd: true + duration: Appearance.animation.scroll.duration + easing.type: Appearance.animation.scroll.type + easing.bezierCurve: Appearance.animation.scroll.bezierCurve + } + } + + // Keep target synced when not animating (e.g., drag/flick or programmatic changes) + onContentYChanged: { + if (!scrollAnim.running) { + root.scrollTargetY = root.contentY; + } + } + + add: Transition { + animations: animateAppearance ? [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: popin ? "opacity,scale" : "opacity", + from: 0, + to: 1, + }), + ] : [] + } + + addDisplaced: Transition { + animations: animateAppearance ? [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: popin ? "opacity,scale" : "opacity", + to: 1, + }), + ] : [] + } + + displaced: Transition { + animations: root.animateMovement ? [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] : [] + } + + move: Transition { + animations: root.animateMovement ? [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] : [] + } + moveDisplaced: Transition { + animations: root.animateMovement ? [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] : [] + } + + remove: Transition { + animations: animateAppearance ? [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "x", + to: root.width + root.removeOvershoot, + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "opacity", + to: 0, + }) + ] : [] + } + + // This is movement when something is removed, not removing animation! + removeDisplaced: Transition { + animations: animateAppearance ? [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] : [] + } +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledProgressBar.qml b/modules/quickshell/config/modules/common/widgets/StyledProgressBar.qml new file mode 100644 index 0000000..62b8dcf --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledProgressBar.qml @@ -0,0 +1,97 @@ +pragma ComponentBehavior: Bound +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls + + +/** + * Material 3 progress bar. See https://m3.material.io/components/progress-indicators/overview + */ +ProgressBar { + id: root + property real valueBarWidth: 120 + property real valueBarHeight: 4 + property real valueBarGap: 4 + property color highlightColor: Appearance?.colors.colPrimary ?? "#685496" + property color trackColor: Appearance?.m3colors.m3secondaryContainer ?? "#F1D3F9" + property bool wavy: false // If true, the progress bar will have a wavy fill effect + property bool animateWave: true + property real waveAmplitudeMultiplier: wavy ? 0.5 : 0 + property real waveFrequency: 6 + property real waveFps: 60 + + Behavior on waveAmplitudeMultiplier { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Behavior on value { + animation: Appearance?.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + background: Item { + implicitHeight: valueBarHeight + implicitWidth: valueBarWidth + } + + contentItem: Item { + id: contentItem + anchors.fill: parent + + Loader { + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + } + active: root.wavy + sourceComponent: WavyLine { + id: wavyFill + frequency: root.waveFrequency + color: root.highlightColor + amplitudeMultiplier: root.wavy ? 0.5 : 0 + height: contentItem.height * 6 + width: contentItem.width * root.visualPosition + lineWidth: contentItem.height + fullLength: root.width + Connections { + target: root + function onValueChanged() { wavyFill.requestPaint(); } + function onHighlightColorChanged() { wavyFill.requestPaint(); } + } + FrameAnimation { + running: root.animateWave + onTriggered: { + wavyFill.requestPaint() + } + } + } + } + + Loader { + active: !root.wavy + sourceComponent: Rectangle { + anchors.left: parent.left + width: contentItem.width * root.visualPosition + height: contentItem.height + radius: height / 2 + color: root.highlightColor + } + } + + Rectangle { // Right remaining part fill + anchors.right: parent.right + width: (1 - root.visualPosition) * parent.width - valueBarGap + height: parent.height + radius: height / 2 + color: root.trackColor + } + + Rectangle { // Stop point + anchors.right: parent.right + width: valueBarGap + height: valueBarGap + radius: height / 2 + color: root.highlightColor + } + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/StyledRadioButton.qml b/modules/quickshell/config/modules/common/widgets/StyledRadioButton.qml new file mode 100644 index 0000000..ac511ce --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledRadioButton.qml @@ -0,0 +1,88 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Widgets +import Quickshell.Services.Pipewire + +RadioButton { + id: root + padding: 4 + implicitHeight: contentItem.implicitHeight + padding * 2 + property string description + property color activeColor: Appearance?.colors.colPrimary ?? "#685496" + property color inactiveColor: Appearance?.m3colors.m3onSurfaceVariant ?? "#45464F" + + PointingHandInteraction {} + + indicator: Item{} + + contentItem: RowLayout { + id: contentItem + Layout.fillWidth: true + spacing: 12 + Rectangle { + id: radio + Layout.fillWidth: false + Layout.alignment: Qt.AlignVCenter + width: 20 + height: 20 + radius: Appearance?.rounding.full + border.color: checked ? root.activeColor : root.inactiveColor + border.width: 2 + color: "transparent" + + // Checked indicator + Rectangle { + anchors.centerIn: parent + width: checked ? 10 : 4 + height: checked ? 10 : 4 + radius: Appearance?.rounding.full + color: Appearance?.colors.colPrimary + opacity: checked ? 1 : 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on width { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + + } + + // Hover + Rectangle { + anchors.centerIn: parent + width: root.hovered ? 40 : 20 + height: root.hovered ? 40 : 20 + radius: Appearance?.rounding.full + color: Appearance?.m3colors.m3onSurface + opacity: root.hovered ? 0.1 : 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on width { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + } + } + + StyledText { + text: root.description + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + wrapMode: Text.Wrap + color: Appearance?.m3colors.m3onSurface + } + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/StyledRectangularShadow.qml b/modules/quickshell/config/modules/common/widgets/StyledRectangularShadow.qml new file mode 100644 index 0000000..a3c842c --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledRectangularShadow.qml @@ -0,0 +1,14 @@ +import QtQuick +import QtQuick.Effects +import qs.modules.common + +RectangularShadow { + required property var target + anchors.fill: target + radius: target.radius + blur: 0.9 * Appearance.sizes.elevationMargin + offset: Qt.vector2d(0.0, 1.0) + spread: 1 + color: Appearance.colors.colShadow + cached: true +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledScrollBar.qml b/modules/quickshell/config/modules/common/widgets/StyledScrollBar.qml new file mode 100644 index 0000000..7b677c4 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledScrollBar.qml @@ -0,0 +1,28 @@ +import QtQuick +import QtQuick.Controls +import qs.modules.common +import qs.modules.common.functions + +ScrollBar { + id: root + + policy: ScrollBar.AsNeeded + topPadding: Appearance.rounding.normal + bottomPadding: Appearance.rounding.normal + + contentItem: Rectangle { + implicitWidth: 4 + implicitHeight: root.visualSize + radius: width / 2 + color: Appearance.colors.colOnSurfaceVariant + + opacity: root.policy === ScrollBar.AlwaysOn || (root.active && root.size < 1.0) ? 0.5 : 0 + Behavior on opacity { + NumberAnimation { + duration: 350 + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledSlider.qml b/modules/quickshell/config/modules/common/widgets/StyledSlider.qml new file mode 100644 index 0000000..84f2b3d --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledSlider.qml @@ -0,0 +1,199 @@ +pragma ComponentBehavior: Bound +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Widgets + +/** + * Material 3 slider. See https://m3.material.io/components/sliders/overview + * It doesn't exactly match the spec because it does not make sense to have stuff on a computer that fucking huge. + * Should be at 3/4 scale... + */ + +Slider { + id: root + + property list stopIndicatorValues: [1] + enum Configuration { + Wavy = 4, + XS = 12, + S = 18, + M = 30, + L = 42, + XL = 72 + } + + property var configuration: StyledSlider.Configuration.S + + property real handleDefaultWidth: 3 + property real handlePressedWidth: 1.5 + property color highlightColor: Appearance.colors.colPrimary + property color trackColor: Appearance.colors.colSecondaryContainer + property color handleColor: Appearance.colors.colPrimary + property color dotColor: Appearance.m3colors.m3onSecondaryContainer + property color dotColorHighlighted: Appearance.m3colors.m3onPrimary + property real unsharpenRadius: Appearance.rounding.unsharpen + property real trackWidth: configuration + property real trackRadius: trackWidth >= StyledSlider.Configuration.XL ? 21 + : trackWidth >= StyledSlider.Configuration.L ? 12 + : trackWidth >= StyledSlider.Configuration.M ? 9 + : trackWidth >= StyledSlider.Configuration.S ? 6 + : height / 2 + property real handleHeight: (configuration === StyledSlider.Configuration.Wavy) ? 24 : Math.max(33, trackWidth + 9) + property real handleWidth: root.pressed ? handlePressedWidth : handleDefaultWidth + property real handleMargins: 4 + property real trackDotSize: 3 + property string tooltipContent: `${Math.round(value * 100)}%` + property bool wavy: configuration === StyledSlider.Configuration.Wavy // If true, the progress bar will have a wavy fill effect + property bool animateWave: true + property real waveAmplitudeMultiplier: wavy ? 0.5 : 0 + property real waveFrequency: 6 + property real waveFps: 60 + + leftPadding: handleMargins + rightPadding: handleMargins + property real effectiveDraggingWidth: width - leftPadding - rightPadding + + Layout.fillWidth: true + from: 0 + to: 1 + + // Only animate value changes when not being dragged by user + Behavior on value { + enabled: !root.pressed + SmoothedAnimation { + velocity: Appearance.animation.elementMoveFast.velocity + } + } + + Behavior on handleMargins { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + component TrackDot: Rectangle { + required property real value + property real normalizedValue: (value - root.from) / (root.to - root.from) + anchors.verticalCenter: parent.verticalCenter + x: root.handleMargins + (normalizedValue * root.effectiveDraggingWidth) - (root.trackDotSize / 2) + width: root.trackDotSize + height: root.trackDotSize + radius: Appearance.rounding.full + color: normalizedValue > root.visualPosition ? root.dotColor : root.dotColorHighlighted + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + + MouseArea { + anchors.fill: parent + onPressed: (mouse) => mouse.accepted = false + cursorShape: root.pressed ? Qt.ClosedHandCursor : Qt.PointingHandCursor + } + + background: Item { + anchors.verticalCenter: parent.verticalCenter + width: parent.width + implicitHeight: trackWidth + + // Fill left + Loader { + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + } + width: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins) + height: root.trackWidth + active: !root.wavy + sourceComponent: Rectangle { + color: root.highlightColor + topLeftRadius: root.trackRadius + bottomLeftRadius: root.trackRadius + topRightRadius: root.unsharpenRadius + bottomRightRadius: root.unsharpenRadius + } + } + + Loader { + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + } + width: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins) + height: root.height + active: root.wavy + sourceComponent: WavyLine { + id: wavyFill + frequency: root.waveFrequency + fullLength: root.width + color: root.highlightColor + amplitudeMultiplier: root.wavy ? 0.5 : 0 + width: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins) + height: root.trackWidth + Connections { + target: root + function onValueChanged() { wavyFill.requestPaint(); } + function onHighlightColorChanged() { wavyFill.requestPaint(); } + } + FrameAnimation { + running: root.animateWave + onTriggered: { + wavyFill.requestPaint() + } + } + } + } + + // Fill right + Rectangle { + anchors { + verticalCenter: parent.verticalCenter + right: parent.right + } + width: root.handleMargins + ((1 - root.visualPosition) * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins) + height: trackWidth + color: root.trackColor + topRightRadius: root.trackRadius + bottomRightRadius: root.trackRadius + topLeftRadius: root.unsharpenRadius + bottomLeftRadius: root.unsharpenRadius + } + + // Stop indicators + Repeater { + model: root.stopIndicatorValues + TrackDot { + required property real modelData + value: modelData + anchors.verticalCenter: parent?.verticalCenter + } + } + } + + handle: Rectangle { + id: handle + + implicitWidth: root.handleWidth + implicitHeight: root.handleHeight + x: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2) + anchors.verticalCenter: parent.verticalCenter + radius: Appearance.rounding.full + color: root.handleColor + + Behavior on implicitWidth { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + StyledToolTip { + extraVisibleCondition: root.pressed + text: root.tooltipContent + font { + family: Appearance.font.family.numbers + variableAxes: Appearance.font.variableAxes.numbers + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledSpinBox.qml b/modules/quickshell/config/modules/common/widgets/StyledSpinBox.qml new file mode 100644 index 0000000..f516aef --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledSpinBox.qml @@ -0,0 +1,96 @@ +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import QtQuick.Controls + +/** + * Material 3 styled SpinBox component. + */ +SpinBox { + id: root + + property real baseHeight: 35 + property real radius: Appearance.rounding.small + property real innerButtonRadius: Appearance.rounding.unsharpen + editable: true + + opacity: root.enabled ? 1 : 0.4 + + background: Rectangle { + color: Appearance.colors.colLayer2 + radius: root.radius + } + + contentItem: Item { + implicitHeight: root.baseHeight + implicitWidth: Math.max(labelText.implicitWidth, 40) + + StyledTextInput { + id: labelText + anchors.centerIn: parent + text: root.value // displayText would make the numbers weird like 1,000 instead of 1000 + color: Appearance.colors.colOnLayer2 + font.family: Appearance.font.family.numbers + font.variableAxes: Appearance.font.variableAxes.numbers + font.pixelSize: Appearance.font.pixelSize.small + validator: root.validator + onTextChanged: { + root.value = parseFloat(text); + } + } + } + + down.indicator: Rectangle { + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + } + implicitHeight: root.baseHeight + implicitWidth: root.baseHeight + topLeftRadius: root.radius + bottomLeftRadius: root.radius + topRightRadius: root.innerButtonRadius + bottomRightRadius: root.innerButtonRadius + + color: root.down.pressed ? Appearance.colors.colLayer2Active : + root.down.hovered ? Appearance.colors.colLayer2Hover : + ColorUtils.transparentize(Appearance.colors.colLayer2) + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + MaterialSymbol { + anchors.centerIn: parent + text: "remove" + iconSize: 20 + color: Appearance.colors.colOnLayer2 + } + } + + up.indicator: Rectangle { + anchors { + verticalCenter: parent.verticalCenter + right: parent.right + } + implicitHeight: root.baseHeight + implicitWidth: root.baseHeight + topRightRadius: root.radius + bottomRightRadius: root.radius + topLeftRadius: root.innerButtonRadius + bottomLeftRadius: root.innerButtonRadius + + color: root.up.pressed ? Appearance.colors.colLayer2Active : + root.up.hovered ? Appearance.colors.colLayer2Hover : + ColorUtils.transparentize(Appearance.colors.colLayer2) + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + MaterialSymbol { + anchors.centerIn: parent + text: "add" + iconSize: 20 + color: Appearance.colors.colOnLayer2 + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledSwitch.qml b/modules/quickshell/config/modules/common/widgets/StyledSwitch.qml new file mode 100644 index 0000000..372ed9b --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledSwitch.qml @@ -0,0 +1,70 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls + +/** + * Material 3 switch. See https://m3.material.io/components/switch/overview + */ +Switch { + id: root + property real scale: 0.75 // Default in m3 spec is huge af + implicitHeight: 32 * root.scale + implicitWidth: 52 * root.scale + property color activeColor: Appearance?.colors.colPrimary ?? "#685496" + property color inactiveColor: Appearance?.colors.colSurfaceContainerHighest ?? "#45464F" + + PointingHandInteraction {} + + // Custom track styling + background: Rectangle { + width: parent.width + height: parent.height + radius: Appearance?.rounding.full ?? 9999 + color: root.checked ? root.activeColor : root.inactiveColor + border.width: 2 * root.scale + border.color: root.checked ? root.activeColor : Appearance.m3colors.m3outline + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + Behavior on border.color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + + // Custom thumb styling + indicator: Rectangle { + width: (root.pressed || root.down) ? (28 * root.scale) : root.checked ? (24 * root.scale) : (16 * root.scale) + height: (root.pressed || root.down) ? (28 * root.scale) : root.checked ? (24 * root.scale) : (16 * root.scale) + radius: Appearance.rounding.full + color: root.checked ? Appearance.m3colors.m3onPrimary : Appearance.m3colors.m3outline + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: root.checked ? ((root.pressed || root.down) ? (22 * root.scale) : 24 * root.scale) : ((root.pressed || root.down) ? (2 * root.scale) : 8 * root.scale) + + Behavior on anchors.leftMargin { + NumberAnimation { + duration: Appearance.animationCurves.expressiveFastSpatialDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial + } + } + Behavior on width { + NumberAnimation { + duration: Appearance.animationCurves.expressiveFastSpatialDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial + } + } + Behavior on height { + NumberAnimation { + duration: Appearance.animationCurves.expressiveFastSpatialDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial + } + } + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledText.qml b/modules/quickshell/config/modules/common/widgets/StyledText.qml new file mode 100644 index 0000000..14f6a9c --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledText.qml @@ -0,0 +1,91 @@ +import qs.modules.common +import QtQuick + +Text { + id: root + property bool animateChange: false + property real animationDistanceX: 0 + property real animationDistanceY: 6 + + renderType: Text.NativeRendering + verticalAlignment: Text.AlignVCenter + property bool shouldUseNumberFont: /^\d+$/.test(root.text) + property var defaultFont: shouldUseNumberFont ? Appearance.font.family.numbers : Appearance.font.family.main + + font { + hintingPreference: Font.PreferDefaultHinting + family: defaultFont + pixelSize: Appearance?.font.pixelSize.small ?? 15 + variableAxes: shouldUseNumberFont ? ({}) : Appearance.font.variableAxes.main + } + color: Appearance?.m3colors.m3onBackground ?? "black" + linkColor: Appearance?.m3colors.m3primary + + component Anim: NumberAnimation { + target: root + duration: 300 / 2 + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + + Component.onCompleted: { + textAnimationBehavior.originalX = root.x; + textAnimationBehavior.originalY = root.y; + } + + Behavior on text { + id: textAnimationBehavior + property real originalX: root.x + property real originalY: root.y + enabled: root.animateChange + + SequentialAnimation { + alwaysRunToEnd: true + ParallelAnimation { + Anim { + property: "x" + to: textAnimationBehavior.originalX - root.animationDistanceX + easing.type: Easing.InSine + } + Anim { + property: "y" + to: textAnimationBehavior.originalY - root.animationDistanceY + easing.type: Easing.InSine + } + Anim { + property: "opacity" + to: 0 + easing.type: Easing.InSine + } + } + PropertyAction {} // Tie the text update to this point (we don't want it to happen during the first slide+fade) + PropertyAction { + target: root + property: "x" + value: textAnimationBehavior.originalX + root.animationDistanceX + } + PropertyAction { + target: root + property: "y" + value: textAnimationBehavior.originalY + root.animationDistanceY + } + ParallelAnimation { + Anim { + property: "x" + to: textAnimationBehavior.originalX + easing.type: Easing.OutSine + } + Anim { + property: "y" + to: textAnimationBehavior.originalY + easing.type: Easing.OutSine + } + Anim { + property: "opacity" + to: 1 + easing.type: Easing.OutSine + } + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledTextArea.qml b/modules/quickshell/config/modules/common/widgets/StyledTextArea.qml new file mode 100644 index 0000000..13702e1 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledTextArea.qml @@ -0,0 +1,20 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls + +/** + * Does not include visual layout, but includes the easily neglected colors. + */ +TextArea { + renderType: Text.NativeRendering + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + placeholderTextColor: Appearance.m3colors.m3outline + color: Appearance.colors.colOnLayer0 + font { + family: Appearance.font.family.main + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + variableAxes: Appearance.font.variableAxes.main + } +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledTextInput.qml b/modules/quickshell/config/modules/common/widgets/StyledTextInput.qml new file mode 100644 index 0000000..7b31816 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledTextInput.qml @@ -0,0 +1,19 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls + +/** + * Does not include visual layout, but includes the easily neglected colors. + */ +TextInput { + color: Appearance.colors.colOnLayer1 + renderType: Text.NativeRendering + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + font { + family: Appearance.font.family.main + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + variableAxes: Appearance.font.variableAxes.main + } +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledToolTip.qml b/modules/quickshell/config/modules/common/widgets/StyledToolTip.qml new file mode 100644 index 0000000..53797fb --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledToolTip.qml @@ -0,0 +1,33 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ToolTip { + id: root + property bool extraVisibleCondition: true + property bool alternativeVisibleCondition: false + + readonly property bool internalVisibleCondition: (extraVisibleCondition && (parent.hovered === undefined || parent?.hovered)) || alternativeVisibleCondition + verticalPadding: 5 + horizontalPadding: 10 + background: null + font { + family: Appearance.font.family.main + variableAxes: Appearance.font.variableAxes.main + pixelSize: Appearance?.font.pixelSize.smaller ?? 14 + hintingPreference: Font.PreferNoHinting // Prevent shaky text + } + + visible: internalVisibleCondition + + contentItem: StyledToolTipContent { + id: contentItem + font: root.font + text: root.text + shown: root.internalVisibleCondition + horizontalPadding: root.horizontalPadding + verticalPadding: root.verticalPadding + } +} diff --git a/modules/quickshell/config/modules/common/widgets/StyledToolTipContent.qml b/modules/quickshell/config/modules/common/widgets/StyledToolTipContent.qml new file mode 100644 index 0000000..62d0a3d --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/StyledToolTipContent.qml @@ -0,0 +1,53 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + required property string text + property bool shown: false + property real horizontalPadding: 10 + property real verticalPadding: 5 + property alias font: tooltipTextObject.font + implicitWidth: tooltipTextObject.implicitWidth + 2 * root.horizontalPadding + implicitHeight: tooltipTextObject.implicitHeight + 2 * root.verticalPadding + + property bool isVisible: backgroundRectangle.implicitHeight > 0 + + Rectangle { + id: backgroundRectangle + anchors { + bottom: root.bottom + horizontalCenter: root.horizontalCenter + } + color: Appearance?.colors.colTooltip ?? "#3C4043" + radius: Appearance?.rounding.verysmall ?? 7 + opacity: shown ? 1 : 0 + implicitWidth: shown ? (tooltipTextObject.implicitWidth + 2 * root.horizontalPadding) : 0 + implicitHeight: shown ? (tooltipTextObject.implicitHeight + 2 * root.verticalPadding) : 0 + clip: true + + Behavior on implicitWidth { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + StyledText { + id: tooltipTextObject + anchors.centerIn: parent + text: root.text + font.pixelSize: Appearance?.font.pixelSize.smaller ?? 14 + font.hintingPreference: Font.PreferNoHinting // Prevent shaky text + color: Appearance?.colors.colOnTooltip ?? "#FFFFFF" + wrapMode: Text.Wrap + } + } +} + diff --git a/modules/quickshell/config/modules/common/widgets/ThumbnailImage.qml b/modules/quickshell/config/modules/common/widgets/ThumbnailImage.qml new file mode 100644 index 0000000..50315b2 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ThumbnailImage.qml @@ -0,0 +1,56 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +/** + * Thumbnail image. It currently generates to the right place at the right size, but does not handle metadata/maintenance on modification. + * See Freedesktop's spec: https://specifications.freedesktop.org/thumbnail-spec/thumbnail-spec-latest.html + */ +StyledImage { + id: root + + property bool generateThumbnail: true + required property string sourcePath + property string thumbnailSizeName: Images.thumbnailSizeNameForDimensions(sourceSize.width, sourceSize.height) + property string thumbnailPath: { + if (sourcePath.length == 0) return; + const resolvedUrlWithoutFileProtocol = FileUtils.trimFileProtocol(`${Qt.resolvedUrl(sourcePath)}`); + const encodedUrlWithoutFileProtocol = resolvedUrlWithoutFileProtocol.split("/").map(part => encodeURIComponent(part)).join("/"); + const md5Hash = Qt.md5(`file://${encodedUrlWithoutFileProtocol}`); + return `${Directories.genericCache}/thumbnails/${thumbnailSizeName}/${md5Hash}.png`; + } + source: thumbnailPath + + asynchronous: true + smooth: true + mipmap: false + + opacity: status === Image.Ready ? 1 : 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + onSourceSizeChanged: { + if (!root.generateThumbnail) return; + thumbnailGeneration.running = false; + thumbnailGeneration.running = true; + } + Process { + id: thumbnailGeneration + command: { + const maxSize = Images.thumbnailSizes[root.thumbnailSizeName]; + return ["bash", "-c", + `[ -f '${FileUtils.trimFileProtocol(root.thumbnailPath)}' ] && exit 0 || { magick '${root.sourcePath}' -resize ${maxSize}x${maxSize} '${FileUtils.trimFileProtocol(root.thumbnailPath)}' && exit 1; }` + ] + } + onExited: (exitCode, exitStatus) => { + if (exitCode === 1) { // Force reload if thumbnail had to be generated + root.source = ""; + root.source = root.thumbnailPath; // Force reload + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/Toolbar.qml b/modules/quickshell/config/modules/common/widgets/Toolbar.qml new file mode 100644 index 0000000..51348c4 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/Toolbar.qml @@ -0,0 +1,48 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +/** + * Material 3 expressive style toolbar. + * https://m3.material.io/components/toolbars + */ +Item { + id: root + + property bool enableShadow: true + property real padding: 8 + property alias colBackground: background.color + property alias spacing: toolbarLayout.spacing + default property alias data: toolbarLayout.data + implicitWidth: background.implicitWidth + implicitHeight: background.implicitHeight + property alias radius: background.radius + + Loader { + active: root.enableShadow + anchors.fill: background + sourceComponent: StyledRectangularShadow { + target: background + anchors.fill: undefined + } + } + + Rectangle { + id: background + anchors.fill: parent + color: Appearance.m3colors.m3surfaceContainer + implicitHeight: 56 + implicitWidth: toolbarLayout.implicitWidth + root.padding * 2 + radius: height / 2 + + RowLayout { + id: toolbarLayout + spacing: 4 + anchors { + fill: parent + margins: root.padding + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/ToolbarButton.qml b/modules/quickshell/config/modules/common/widgets/ToolbarButton.qml new file mode 100644 index 0000000..04b3f5f --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ToolbarButton.qml @@ -0,0 +1,8 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common + +RippleButton { + Layout.fillHeight: true + buttonRadius: Appearance.rounding.full +} diff --git a/modules/quickshell/config/modules/common/widgets/ToolbarTabBar.qml b/modules/quickshell/config/modules/common/widgets/ToolbarTabBar.qml new file mode 100644 index 0000000..1bca184 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ToolbarTabBar.qml @@ -0,0 +1,102 @@ +pragma ComponentBehavior: Bound +import qs.modules.common +import qs.modules.common.models +import qs.services +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + property alias currentIndex: tabBar.currentIndex + required property var tabButtonList + + function incrementCurrentIndex() { + tabBar.incrementCurrentIndex() + } + function decrementCurrentIndex() { + tabBar.decrementCurrentIndex() + } + function setCurrentIndex(index) { + tabBar.setCurrentIndex(index) + } + + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + implicitWidth: contentItem.implicitWidth + implicitHeight: 40 + + Row { + id: contentItem + z: 1 + anchors.centerIn: parent + spacing: 4 + + Repeater { + model: root.tabButtonList + delegate: ToolbarTabButton { + required property int index + required property var modelData + current: index == root.currentIndex + text: modelData.name + materialSymbol: modelData.icon + onClicked: { + root.setCurrentIndex(index) + } + } + } + } + + Rectangle { + id: activeIndicator + z: 0 + color: Appearance.colors.colSecondaryContainer + implicitWidth: contentItem.children[root.currentIndex]?.implicitWidth ?? 0 + implicitHeight: contentItem.children[root.currentIndex]?.implicitHeight ?? 0 + radius: height / 2 + // Animation + property Item targetItem: contentItem.children[root.currentIndex] + AnimatedTabIndexPair { + id: leftBound + idx1Duration: 50 + idx2Duration: 200 + index: activeIndicator.targetItem.x + } + AnimatedTabIndexPair { + id: rightBound + idx1Duration: 50 + idx2Duration: 200 + index: activeIndicator.targetItem.x + activeIndicator.targetItem.width + } + x: Math.min(leftBound.idx1, leftBound.idx2) + width: Math.max(rightBound.idx1, rightBound.idx2) - x + } + + MouseArea { + anchors.fill: parent + z: 2 + acceptedButtons: Qt.NoButton + cursorShape: Qt.PointingHandCursor + onWheel: (event) => { + if (event.angleDelta.y < 0) { + root.incrementCurrentIndex(); + } + else { + root.decrementCurrentIndex(); + } + } + } + + // TabBar doesn't allow tabs to be of different sizes. Literally unusable. + // We use it only for the logic and draw stuff manually + TabBar { + id: tabBar + z: -1 + background: null + Repeater { // This is to fool the TabBar that it has tabs so it does the indices properly + model: root.tabButtonList.length + delegate: TabButton { + background: null + } + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/ToolbarTabButton.qml b/modules/quickshell/config/modules/common/widgets/ToolbarTabButton.qml new file mode 100644 index 0000000..e535188 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ToolbarTabButton.qml @@ -0,0 +1,40 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +RippleButton { + id: root + required property string materialSymbol + required property bool current + horizontalPadding: 10 + + implicitHeight: 40 + implicitWidth: implicitContentWidth + horizontalPadding * 2 + buttonRadius: height / 2 + + colBackground: ColorUtils.transparentize(Appearance.colors.colSurfaceContainer) + colBackgroundHover: ColorUtils.transparentize(Appearance.colors.colOnSurface, current ? 1 : 0.95) + colRipple: ColorUtils.transparentize(Appearance.colors.colOnSurface, 0.95) + + contentItem: Row { + id: contentRow + anchors.centerIn: parent + spacing: 6 + + MaterialSymbol { + id: icon + anchors.verticalCenter: parent.verticalCenter + iconSize: 22 + text: root.materialSymbol + } + StyledText { + id: label + anchors.verticalCenter: parent.verticalCenter + text: root.text + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/ToolbarTextField.qml b/modules/quickshell/config/modules/common/widgets/ToolbarTextField.qml new file mode 100644 index 0000000..657df77 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/ToolbarTextField.qml @@ -0,0 +1,33 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import qs.modules.common +import qs.modules.common.widgets + +TextField { + id: filterField + + property alias colBackground: background.color + + Layout.fillHeight: true + implicitWidth: 200 + padding: 10 + + placeholderTextColor: Appearance.colors.colSubtext + color: Appearance.colors.colOnLayer1 + font { + family: Appearance.font.family.main + pixelSize: Appearance.font.pixelSize.small + hintingPreference: Font.PreferFullHinting + variableAxes: Appearance.font.variableAxes.main + } + renderType: Text.NativeRendering + selectedTextColor: Appearance.colors.colOnSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + + background: Rectangle { + id: background + color: Appearance.colors.colLayer1 + radius: Appearance.rounding.full + } +} diff --git a/modules/quickshell/config/modules/common/widgets/VerticalButtonGroup.qml b/modules/quickshell/config/modules/common/widgets/VerticalButtonGroup.qml new file mode 100644 index 0000000..b1ca845 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/VerticalButtonGroup.qml @@ -0,0 +1,45 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +/** + * A container that supports GroupButton children for bounciness. + * See https://m3.material.io/components/button-groups/overview + */ +Rectangle { + id: root + default property alias content: columnLayout.data + property real spacing: 5 + property real padding: 0 + property int clickIndex: columnLayout.clickIndex + + property real contentHeight: { + let total = 0; + for (let i = 0; i < columnLayout.children.length; ++i) { + const child = columnLayout.children[i]; + total += child.baseHeight ?? child.implicitHeight ?? child.height; + } + return total + columnLayout.spacing * (columnLayout.children.length - 1); + } + + topLeftRadius: columnLayout.children.length > 0 ? (columnLayout.children[0].radius + padding) : + Appearance?.rounding?.small + topRightRadius: topLeftRadius + bottomLeftRadius: columnLayout.children.length > 0 ? (columnLayout.children[columnLayout.children.length - 1].radius + padding) : + Appearance?.rounding?.small + bottomRightRadius: bottomLeftRadius + + color: "transparent" + height: root.contentHeight + padding * 2 + implicitWidth: columnLayout.implicitWidth + padding * 2 + implicitHeight: root.contentHeight + padding * 2 + + children: [ColumnLayout { + id: columnLayout + anchors.fill: parent + anchors.margins: root.padding + spacing: root.spacing + property int clickIndex: -1 + }] +} diff --git a/modules/quickshell/config/modules/common/widgets/VibrantToolbarButton.qml b/modules/quickshell/config/modules/common/widgets/VibrantToolbarButton.qml new file mode 100644 index 0000000..c6e58f3 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/VibrantToolbarButton.qml @@ -0,0 +1,10 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.functions + +ToolbarButton { + colBackground: ColorUtils.transparentize(Appearance.colors.colPrimaryContainer) + colBackgroundHover: Appearance.colors.colPrimaryContainerHover + colRipple: Appearance.colors.colPrimaryContainerActive +} diff --git a/modules/quickshell/config/modules/common/widgets/WaveVisualizer.qml b/modules/quickshell/config/modules/common/widgets/WaveVisualizer.qml new file mode 100644 index 0000000..64559c1 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/WaveVisualizer.qml @@ -0,0 +1,73 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Effects + +Canvas { // Visualizer + id: root + property list points + property list smoothPoints + property real maxVisualizerValue: 1000 + property int smoothing: 2 + property bool live: true + property color color: Appearance.m3colors.m3primary + + onPointsChanged: () => { + root.requestPaint() + } + + anchors.fill: parent + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + + var points = root.points; + var maxVal = root.maxVisualizerValue || 1; + var h = height; + var w = width; + var n = points.length; + if (n < 2) return; + + // Smoothing: simple moving average (optional) + var smoothWindow = root.smoothing; // adjust for more/less smoothing + root.smoothPoints = []; + for (var i = 0; i < n; ++i) { + var sum = 0, count = 0; + for (var j = -smoothWindow; j <= smoothWindow; ++j) { + var idx = Math.max(0, Math.min(n - 1, i + j)); + sum += points[idx]; + count++; + } + root.smoothPoints.push(sum / count); + } + if (!root.live) root.smoothPoints.fill(0); // If not playing, show no points + + ctx.beginPath(); + ctx.moveTo(0, h); + for (var i = 0; i < n; ++i) { + var x = i * w / (n - 1); + var y = h - (root.smoothPoints[i] / maxVal) * h; + ctx.lineTo(x, y); + } + ctx.lineTo(w, h); + ctx.closePath(); + + ctx.fillStyle = Qt.rgba( + root.color.r, + root.color.g, + root.color.b, + 0.15 + ); + ctx.fill(); + } + + layer.enabled: true + layer.effect: MultiEffect { // Blur a bit to obscure away the points + source: root + saturation: 0.2 + blurEnabled: true + blurMax: 7 + blur: 1 + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/WavyLine.qml b/modules/quickshell/config/modules/common/widgets/WavyLine.qml new file mode 100644 index 0000000..7c3ff52 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/WavyLine.qml @@ -0,0 +1,34 @@ +import qs.modules.common +import QtQuick + +Canvas { + id: root + property real amplitudeMultiplier: 0.5 + property real frequency: 6 + property color color: Appearance?.colors.colPrimary ?? "#685496" + property real lineWidth: 4 + property real fullLength: width + + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + + var amplitude = root.lineWidth * root.amplitudeMultiplier; + var frequency = root.frequency; + var phase = Date.now() / 400.0; + var centerY = height / 2; + + ctx.strokeStyle = root.color; + ctx.lineWidth = root.lineWidth; + ctx.lineCap = "round"; + ctx.beginPath(); + for (var x = ctx.lineWidth / 2; x <= root.width - ctx.lineWidth / 2; x += 1) { + var waveY = centerY + amplitude * Math.sin(frequency * 2 * Math.PI * x / root.fullLength + phase); + if (x === 0) + ctx.moveTo(x, waveY); + else + ctx.lineTo(x, waveY); + } + ctx.stroke(); + } +} diff --git a/modules/quickshell/config/modules/common/widgets/WeekRow.qml b/modules/quickshell/config/modules/common/widgets/WeekRow.qml new file mode 100644 index 0000000..93fc536 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/WeekRow.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.services +import qs.modules.common.functions + +RowLayout { + id: root + + // Pls supply + required property date date // Any date within the week + property var locale + + // Expose model and delegate for flexibility + property list model: { + // Should expose props like here: https://doc.qt.io/qt-6/qml-qtquick-controls-monthgrid.html#delegate-prop + // (except weekNumber because i'm lazy and it's not so important) + const firstDayOfWeek = DateUtils.getFirstDayOfWeek(root.date, root.locale.firstDayOfWeek); + const weekDates = []; + for (let i = 0; i < 7; i++) { + const dayDate = new Date(firstDayOfWeek); + dayDate.setDate(firstDayOfWeek.getDate() + i); + weekDates.push({ + date: dayDate, + day: dayDate.getDate(), + month: dayDate.getMonth() + 1, + year: dayDate.getFullYear(), + today: DateUtils.sameDate(dayDate, DateTime.clock.date) + }); + } + return weekDates; + } + property Component delegate: Text { + required property var model + text: model.day + } + + // Obvious + Repeater { + model: root.model + delegate: root.delegate + } +} diff --git a/modules/quickshell/config/modules/common/widgets/WindowDialog.qml b/modules/quickshell/config/modules/common/widgets/WindowDialog.qml new file mode 100644 index 0000000..16a82c4 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/WindowDialog.qml @@ -0,0 +1,91 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +Rectangle { + id: root + + property bool show: false + default property alias data: contentColumn.data + property real backgroundHeight: dialogBackground.implicitHeight + property real backgroundWidth: 350 + property real backgroundAnimationMovementDistance: 60 + + signal dismiss() + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Escape) { + root.dismiss(); + event.accepted = true; + } + } + + color: root.show ? Appearance.colors.colScrim : ColorUtils.transparentize(Appearance.colors.colScrim) + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + visible: dialogBackground.implicitHeight > 0 + + onShowChanged: { + dialogBackgroundHeightAnimation.easing.bezierCurve = (show ? Appearance.animationCurves.emphasizedDecel : Appearance.animationCurves.emphasizedAccel) + dialogBackground.implicitHeight = show ? backgroundHeight : 0 + } + + radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1 + + MouseArea { // Clicking outside the dialog should dismiss + anchors.fill: parent + acceptedButtons: Qt.AllButtons + hoverEnabled: true + onPressed: root.dismiss() + } + + Rectangle { + id: dialogBackground + anchors.horizontalCenter: parent.horizontalCenter + radius: Appearance.rounding.large + color: Appearance.m3colors.m3surfaceContainerHigh // Use opaque version of layer3 + + property real targetY: root.height / 2 - root.backgroundHeight / 2 + y: root.show ? targetY : (targetY - root.backgroundAnimationMovementDistance) + implicitWidth: root.backgroundWidth + implicitHeight: contentColumn.implicitHeight + dialogBackground.radius * 2 + Behavior on implicitHeight { + NumberAnimation { + id: dialogBackgroundHeightAnimation + duration: Appearance.animation.elementMoveFast.duration + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animationCurves.emphasizedDecel + } + } + Behavior on y { + NumberAnimation { + duration: dialogBackgroundHeightAnimation.duration + easing.type: dialogBackgroundHeightAnimation.easing.type + easing.bezierCurve: dialogBackgroundHeightAnimation.easing.bezierCurve + } + } + + MouseArea { // So clicking inside the dialog won't dismiss + anchors.fill: parent + acceptedButtons: Qt.AllButtons + hoverEnabled: true + } + + ColumnLayout { + id: contentColumn + anchors { + fill: parent + margins: dialogBackground.radius + } + spacing: 16 + opacity: root.show ? 1 : 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + } + } +} diff --git a/modules/quickshell/config/modules/common/widgets/WindowDialogButtonRow.qml b/modules/quickshell/config/modules/common/widgets/WindowDialogButtonRow.qml new file mode 100644 index 0000000..0672e96 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/WindowDialogButtonRow.qml @@ -0,0 +1,15 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +RowLayout { + id: root + spacing: 4 + + // These shouldn't be needed but it would be a terrible waste of space to follow the spec + Layout.margins: -8 + Layout.topMargin: 0 +} diff --git a/modules/quickshell/config/modules/common/widgets/WindowDialogParagraph.qml b/modules/quickshell/config/modules/common/widgets/WindowDialogParagraph.qml new file mode 100644 index 0000000..83b2166 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/WindowDialogParagraph.qml @@ -0,0 +1,12 @@ +import QtQuick +import Quickshell +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +StyledText { + text: "Some body content" + color: Appearance.colors.colOnSurfaceVariant + font.pixelSize: Appearance.font.pixelSize.small + wrapMode: Text.Wrap +} diff --git a/modules/quickshell/config/modules/common/widgets/WindowDialogSectionHeader.qml b/modules/quickshell/config/modules/common/widgets/WindowDialogSectionHeader.qml new file mode 100644 index 0000000..85e34a4 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/WindowDialogSectionHeader.qml @@ -0,0 +1,14 @@ +import QtQuick +import Quickshell +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +StyledText { + text: "Section" + font { + family: Appearance.font.family.title + pixelSize: Appearance.font.pixelSize.large + variableAxes: Appearance.font.variableAxes.title + } +} diff --git a/modules/quickshell/config/modules/common/widgets/WindowDialogSeparator.qml b/modules/quickshell/config/modules/common/widgets/WindowDialogSeparator.qml new file mode 100644 index 0000000..52707e5 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/WindowDialogSeparator.qml @@ -0,0 +1,16 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +Rectangle { + implicitHeight: 1 + color: Appearance.colors.colOutline + Layout.fillWidth: true + Layout.leftMargin: -Appearance.rounding.large + Layout.rightMargin: -Appearance.rounding.large + Layout.topMargin: -8 + Layout.bottomMargin: -8 +} diff --git a/modules/quickshell/config/modules/common/widgets/WindowDialogSlider.qml b/modules/quickshell/config/modules/common/widgets/WindowDialogSlider.qml new file mode 100644 index 0000000..1ae6da1 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/WindowDialogSlider.qml @@ -0,0 +1,43 @@ +pragma ComponentBehavior: Bound +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Widgets + +Column { + id: root + + property alias text: sliderName.text + property alias from: sliderWidget.from + property alias to: sliderWidget.to + property alias value: sliderWidget.value + property alias tooltipContent: sliderWidget.tooltipContent + property alias stopIndicatorValues: sliderWidget.stopIndicatorValues + + signal moved() + + spacing: -2 + ContentSubsectionLabel { + id: sliderName + visible: text?.length > 0 + text: "" + anchors { + left: parent.left + right: parent.right + } + } + StyledSlider { + id: sliderWidget + anchors { + left: parent.left + right: parent.right + leftMargin: 4 + rightMargin: 4 + } + configuration: StyledSlider.Configuration.S + onMoved: root.moved() + } +} diff --git a/modules/quickshell/config/modules/common/widgets/WindowDialogTitle.qml b/modules/quickshell/config/modules/common/widgets/WindowDialogTitle.qml new file mode 100644 index 0000000..439c8df --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/WindowDialogTitle.qml @@ -0,0 +1,16 @@ +import QtQuick +import Quickshell +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +StyledText { + text: "Dialog Title" + color: Appearance.colors.colOnSurface + wrapMode: Text.Wrap + font { + family: Appearance.font.family.title + pixelSize: Appearance.font.pixelSize.title + variableAxes: Appearance.font.variableAxes.title + } +} diff --git a/modules/quickshell/config/modules/common/widgets/shapes/LICENSE b/modules/quickshell/config/modules/common/widgets/shapes/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/modules/quickshell/config/modules/common/widgets/shapes/README.md b/modules/quickshell/config/modules/common/widgets/shapes/README.md new file mode 100644 index 0000000..2559b98 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/README.md @@ -0,0 +1,17 @@ +## Morphing rounded polygons in QML Js + +QML-compatible Javascript port of [Knugel's Typescript port](https://github.com/Knugel/rounded-polygon-ts) of Androidx's shape library. Also includes the 35 [Material Design shapes](https://m3.material.io/styles/shape/overview-principles#579dd4ba-39f3-4e60-bd9b-1d97ed6ef1bf) + +### Example + +You can run the example with Quickshell's runner: + +```sh +qs -p example.qml +``` + +https://github.com/user-attachments/assets/142cdd86-2a30-4c77-a9ce-e234b6d0d77e + +### Usage + +You can add this repo as a submodule to yours. I haven't figured out how to package this (and make it generally available with non-Quickshell QML) yet, and stability is not guaranteed. diff --git a/modules/quickshell/config/modules/common/widgets/shapes/ShapeCanvas.qml b/modules/quickshell/config/modules/common/widgets/shapes/ShapeCanvas.qml new file mode 100644 index 0000000..a88ad64 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/ShapeCanvas.qml @@ -0,0 +1,71 @@ +import QtQuick +import "shapes/morph.js" as Morph + +Canvas { + id: root + property color color: "#685496" + property var roundedPolygon: null + property bool polygonIsNormalized: true + + // Internals: size + property var bounds: roundedPolygon.calculateBounds() + implicitWidth: bounds[2] - bounds[0] + implicitHeight: bounds[3] - bounds[1] + + // Internals: anim + property var prevRoundedPolygon: null + property double progress: 1 + property var morph: new Morph.Morph(roundedPolygon, roundedPolygon) + property Animation animation: NumberAnimation { + duration: 350 + easing.type: Easing.BezierSpline + easing.bezierCurve: [0.42, 1.67, 0.21, 0.90, 1, 1] // Material 3 Expressive fast spatial (https://m3.material.io/styles/motion/overview/specs) + } + + onRoundedPolygonChanged: { + delete root.morph + root.morph = new Morph.Morph(root.prevRoundedPolygon ?? root.roundedPolygon, root.roundedPolygon) + morphBehavior.enabled = false; + root.progress = 0 + morphBehavior.enabled = true; + root.progress = 1 + root.prevRoundedPolygon = root.roundedPolygon + } + + Behavior on progress { + id: morphBehavior + animation: root.animation + } + + onProgressChanged: requestPaint() + onColorChanged: requestPaint() + onPaint: { + var ctx = getContext("2d") + ctx.fillStyle = root.color + ctx.clearRect(0, 0, width, height) + if (!root.morph) return + const cubics = root.morph.asCubics(root.progress) + if (cubics.length === 0) return + + const size = Math.min(root.width, root.height) + const offsetX = root.width / 2 - size / 2 + const offsetY = root.height / 2 - size / 2 + + ctx.save() + ctx.translate(offsetX, offsetY) + if (root.polygonIsNormalized) ctx.scale(size, size) + + ctx.beginPath() + ctx.moveTo(cubics[0].anchor0X, cubics[0].anchor0Y) + for (const cubic of cubics) { + ctx.bezierCurveTo( + cubic.control0X, cubic.control0Y, + cubic.control1X, cubic.control1Y, + cubic.anchor1X, cubic.anchor1Y + ) + } + ctx.closePath() + ctx.fill() + ctx.restore() + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/shapes/example.qml b/modules/quickshell/config/modules/common/widgets/shapes/example.qml new file mode 100644 index 0000000..b6767b2 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/example.qml @@ -0,0 +1,81 @@ +import QtQuick +import QtQuick.Window +import "material-shapes.js" as MaterialShapes + +Window { + id: root + title: "Shape Morph Demo" + color: "#211F21" + width: radius * 2 + padding * 2 + height: radius * 2 + padding * 2 + visible: true + onClosing: Qt.quit() + + property double radius: 50 + property double padding: 50 + property double shapePadding: 12 + + //////////////////////////////// Begin juicy part //////////////////////////////// + // All 35 shapes + property var shapeGetters: [ MaterialShapes.getCircle, MaterialShapes.getSquare, MaterialShapes.getSlanted, MaterialShapes.getArch, MaterialShapes.getFan, MaterialShapes.getArrow, MaterialShapes.getSemiCircle, MaterialShapes.getOval, MaterialShapes.getPill, MaterialShapes.getTriangle, MaterialShapes.getDiamond, MaterialShapes.getClamShell, MaterialShapes.getPentagon, MaterialShapes.getGem, MaterialShapes.getSunny, MaterialShapes.getVerySunny, MaterialShapes.getCookie4Sided, MaterialShapes.getCookie6Sided, MaterialShapes.getCookie7Sided, MaterialShapes.getCookie9Sided, MaterialShapes.getCookie12Sided, MaterialShapes.getGhostish, MaterialShapes.getClover4Leaf, MaterialShapes.getClover8Leaf, MaterialShapes.getBurst, MaterialShapes.getSoftBurst, MaterialShapes.getBoom, MaterialShapes.getSoftBoom, MaterialShapes.getFlower, MaterialShapes.getPuffy, MaterialShapes.getPuffyDiamond, MaterialShapes.getPixelCircle, MaterialShapes.getPixelTriangle, MaterialShapes.getBun, MaterialShapes.getHeart] + property int shapeIndex: 0 + // Automatic morphing + Timer { + id: morphTimer + interval: 700 + running: true + repeat: true + onTriggered: root.shapeIndex = (root.shapeIndex + 1) % root.shapeGetters.length; + } + // The actual shape + ShapeCanvas { + id: shapeCanvas + z: 2 + anchors.centerIn: parent + implicitWidth: root.radius * 2 + implicitHeight: root.radius * 2 + color: "#685496" + roundedPolygon: root.shapeGetters[root.shapeIndex]() + onProgressChanged: requestPaint() + } + //////////////////////////////// End juicy part //////////////////////////////// + + // Background circle + Rectangle { + z: 1 + anchors.fill: parent + anchors.margins: root.padding - root.shapePadding + width: radius * 2 + 50 + height: height + radius: height / 2 + color: "#C7B3FC" + } + + // Text + Text { + z: 3 + anchors { + horizontalCenter: parent.horizontalCenter + top: parent.top + topMargin: 8 + } + color: "#E6E1E3" + text: "Shape %1/%2".arg(root.shapeIndex+1).arg(root.shapeGetters.length) + font.pixelSize: 16 + } + + MouseArea { + anchors.fill: parent + onClicked: { + morphTimer.running = !morphTimer.running + } + onWheel: (wheel) => { + if (wheel.angleDelta.y < 0) { + root.shapeIndex = (root.shapeIndex + 1) % root.shapeGetters.length; + } else { + root.shapeIndex = (root.shapeIndex - 1 + root.shapeGetters.length) % root.shapeGetters.length; + } + } + } +} + diff --git a/modules/quickshell/config/modules/common/widgets/shapes/geometry/offset.js b/modules/quickshell/config/modules/common/widgets/shapes/geometry/offset.js new file mode 100644 index 0000000..6066605 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/geometry/offset.js @@ -0,0 +1,177 @@ +.pragma library + +/** + * @param {number} x + * @param {number} y + * @returns {Offset} + */ +function createOffset(x, y) { + return new Offset(x, y); +} + +class Offset { + /** + * @param {number} x + * @param {number} y + */ + constructor(x, y) { + this.x = x; + this.y = y; + } + + /** + * @param {number} x + * @param {number} y + * @returns {Offset} + */ + copy(x = this.x, y = this.y) { + return new Offset(x, y); + } + + /** + * @returns {number} + */ + getDistance() { + return Math.sqrt(this.x * this.x + this.y * this.y); + } + + /** + * @returns {number} + */ + getDistanceSquared() { + return this.x * this.x + this.y * this.y; + } + + /** + * @returns {boolean} + */ + isValid() { + return isFinite(this.x) && isFinite(this.y); + } + + /** + * @returns {boolean} + */ + get isFinite() { + return isFinite(this.x) && isFinite(this.y); + } + + /** + * @returns {boolean} + */ + get isSpecified() { + return !this.isUnspecified; + } + + /** + * @returns {boolean} + */ + get isUnspecified() { + return Object.is(this.x, NaN) && Object.is(this.y, NaN); + } + + /** + * @returns {Offset} + */ + negate() { + return new Offset(-this.x, -this.y); + } + + /** + * @param {Offset} other + * @returns {Offset} + */ + minus(other) { + return new Offset(this.x - other.x, this.y - other.y); + } + + /** + * @param {Offset} other + * @returns {Offset} + */ + plus(other) { + return new Offset(this.x + other.x, this.y + other.y); + } + + /** + * @param {number} operand + * @returns {Offset} + */ + times(operand) { + return new Offset(this.x * operand, this.y * operand); + } + + /** + * @param {number} operand + * @returns {Offset} + */ + div(operand) { + return new Offset(this.x / operand, this.y / operand); + } + + /** + * @param {number} operand + * @returns {Offset} + */ + rem(operand) { + return new Offset(this.x % operand, this.y % operand); + } + + /** + * @returns {string} + */ + toString() { + if (this.isSpecified) { + return `Offset(${this.x.toFixed(1)}, ${this.y.toFixed(1)})`; + } else { + return 'Offset.Unspecified'; + } + } + + /** + * @param {Offset} start + * @param {Offset} stop + * @param {number} fraction + * @returns {Offset} + */ + static lerp(start, stop, fraction) { + return new Offset( + start.x + (stop.x - start.x) * fraction, + start.y + (stop.y - start.y) * fraction + ); + } + + /** + * @param {function(): Offset} block + * @returns {Offset} + */ + takeOrElse(block) { + return this.isSpecified ? this : block(); + } + + /** + * @returns {number} + */ + angleDegrees() { + return Math.atan2(this.y, this.x) * 180 / Math.PI; + } + + /** + * @param {number} angle + * @param {Offset} center + * @returns {Offset} + */ + rotateDegrees(angle, center = Offset.Zero) { + const a = angle * Math.PI / 180; + const off = this.minus(center); + const cosA = Math.cos(a); + const sinA = Math.sin(a); + const newX = off.x * cosA - off.y * sinA; + const newY = off.x * sinA + off.y * cosA; + return new Offset(newX, newY).plus(center); + } +} + +Offset.Zero = new Offset(0, 0); +Offset.Infinite = new Offset(Infinity, Infinity); +Offset.Unspecified = new Offset(NaN, NaN); diff --git a/modules/quickshell/config/modules/common/widgets/shapes/graphics/matrix.js b/modules/quickshell/config/modules/common/widgets/shapes/graphics/matrix.js new file mode 100644 index 0000000..4c8181c --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/graphics/matrix.js @@ -0,0 +1,198 @@ +.pragma library + +.import "../geometry/offset.js" as Offset + +class Matrix { + constructor(values = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]) { + this.values = values; + } + + get(row, column) { + return this.values[(row * 4) + column]; + } + + set(row, column, v) { + this.values[(row * 4) + column] = v; + } + + /** Does the 3D transform on [point] and returns the `x` and `y` values in an [Offset]. */ + map(point) { + if (this.values.length < 16) return point; + + const v00 = this.get(0, 0); + const v01 = this.get(0, 1); + const v03 = this.get(0, 3); + const v10 = this.get(1, 0); + const v11 = this.get(1, 1); + const v13 = this.get(1, 3); + const v30 = this.get(3, 0); + const v31 = this.get(3, 1); + const v33 = this.get(3, 3); + + const x = point.x; + const y = point.y; + const z = v03 * x + v13 * y + v33; + const inverseZ = 1 / z; + const pZ = isFinite(inverseZ) ? inverseZ : 0; + + return new Offset.Offset(pZ * (v00 * x + v10 * y + v30), pZ * (v01 * x + v11 * y + v31)); + } + + /** Multiply this matrix by [m] and assign the result to this matrix. */ + timesAssign(m) { + const v = this.values; + if (v.length < 16) return; + if (m.values.length < 16) return; + + const v00 = this.dot(0, m, 0); + const v01 = this.dot(0, m, 1); + const v02 = this.dot(0, m, 2); + const v03 = this.dot(0, m, 3); + const v10 = this.dot(1, m, 0); + const v11 = this.dot(1, m, 1); + const v12 = this.dot(1, m, 2); + const v13 = this.dot(1, m, 3); + const v20 = this.dot(2, m, 0); + const v21 = this.dot(2, m, 1); + const v22 = this.dot(2, m, 2); + const v23 = this.dot(2, m, 3); + const v30 = this.dot(3, m, 0); + const v31 = this.dot(3, m, 1); + const v32 = this.dot(3, m, 2); + const v33 = this.dot(3, m, 3); + + v[0] = v00; + v[1] = v01; + v[2] = v02; + v[3] = v03; + v[4] = v10; + v[5] = v11; + v[6] = v12; + v[7] = v13; + v[8] = v20; + v[9] = v21; + v[10] = v22; + v[11] = v23; + v[12] = v30; + v[13] = v31; + v[14] = v32; + v[15] = v33; + } + + dot(row, m, column) { + return this.get(row, 0) * m.get(0, column) + + this.get(row, 1) * m.get(1, column) + + this.get(row, 2) * m.get(2, column) + + this.get(row, 3) * m.get(3, column); + } + + /** Resets the `this` to the identity matrix. */ + reset() { + const v = this.values; + if (v.length < 16) return; + v[0] = 1; + v[1] = 0; + v[2] = 0; + v[3] = 0; + v[4] = 0; + v[5] = 1; + v[6] = 0; + v[7] = 0; + v[8] = 0; + v[9] = 0; + v[10] = 1; + v[11] = 0; + v[12] = 0; + v[13] = 0; + v[14] = 0; + v[15] = 1; + } + + /** Applies a [degrees] rotation around Z to `this`. */ + rotateZ(degrees) { + if (this.values.length < 16) return; + + const r = degrees * (Math.PI / 180.0); + const s = Math.sin(r); + const c = Math.cos(r); + + const a00 = this.get(0, 0); + const a10 = this.get(1, 0); + const v00 = c * a00 + s * a10; + const v10 = -s * a00 + c * a10; + + const a01 = this.get(0, 1); + const a11 = this.get(1, 1); + const v01 = c * a01 + s * a11; + const v11 = -s * a01 + c * a11; + + const a02 = this.get(0, 2); + const a12 = this.get(1, 2); + const v02 = c * a02 + s * a12; + const v12 = -s * a02 + c * a12; + + const a03 = this.get(0, 3); + const a13 = this.get(1, 3); + const v03 = c * a03 + s * a13; + const v13 = -s * a03 + c * a13; + + this.set(0, 0, v00); + this.set(0, 1, v01); + this.set(0, 2, v02); + this.set(0, 3, v03); + this.set(1, 0, v10); + this.set(1, 1, v11); + this.set(1, 2, v12); + this.set(1, 3, v13); + } + + /** Scale this matrix by [x], [y], [z] */ + scale(x = 1, y = 1, z = 1) { + if (this.values.length < 16) return; + this.set(0, 0, this.get(0, 0) * x); + this.set(0, 1, this.get(0, 1) * x); + this.set(0, 2, this.get(0, 2) * x); + this.set(0, 3, this.get(0, 3) * x); + this.set(1, 0, this.get(1, 0) * y); + this.set(1, 1, this.get(1, 1) * y); + this.set(1, 2, this.get(1, 2) * y); + this.set(1, 3, this.get(1, 3) * y); + this.set(2, 0, this.get(2, 0) * z); + this.set(2, 1, this.get(2, 1) * z); + this.set(2, 2, this.get(2, 2) * z); + this.set(2, 3, this.get(2, 3) * z); + } + + /** Translate this matrix by [x], [y], [z] */ + translate(x = 0, y = 0, z = 0) { + if (this.values.length < 16) return; + const t1 = this.get(0, 0) * x + this.get(1, 0) * y + this.get(2, 0) * z + this.get(3, 0); + const t2 = this.get(0, 1) * x + this.get(1, 1) * y + this.get(2, 1) * z + this.get(3, 1); + const t3 = this.get(0, 2) * x + this.get(1, 2) * y + this.get(2, 2) * z + this.get(3, 2); + const t4 = this.get(0, 3) * x + this.get(1, 3) * y + this.get(2, 3) * z + this.get(3, 3); + this.set(3, 0, t1); + this.set(3, 1, t2); + this.set(3, 2, t3); + this.set(3, 3, t4); + } + + toString() { + return `${this.get(0, 0)} ${this.get(0, 1)} ${this.get(0, 2)} ${this.get(0, 3)}\n` + + `${this.get(1, 0)} ${this.get(1, 1)} ${this.get(1, 2)} ${this.get(1, 3)}\n` + + `${this.get(2, 0)} ${this.get(2, 1)} ${this.get(2, 2)} ${this.get(2, 3)}\n` + + `${this.get(3, 0)} ${this.get(3, 1)} ${this.get(3, 2)} ${this.get(3, 3)}`; + } +} + +// Companion object constants +Matrix.ScaleX = 0; +Matrix.SkewY = 1; +Matrix.Perspective0 = 3; +Matrix.SkewX = 4; +Matrix.ScaleY = 5; +Matrix.Perspective1 = 7; +Matrix.ScaleZ = 10; +Matrix.TranslateX = 12; +Matrix.TranslateY = 13; +Matrix.TranslateZ = 14; +Matrix.Perspective2 = 15; diff --git a/modules/quickshell/config/modules/common/widgets/shapes/material-shapes.js b/modules/quickshell/config/modules/common/widgets/shapes/material-shapes.js new file mode 100644 index 0000000..9009092 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/material-shapes.js @@ -0,0 +1,712 @@ +.pragma library + +.import "shapes/point.js" as Point +.import "shapes/rounded-polygon.js" as RoundedPolygon +.import "shapes/corner-rounding.js" as CornerRounding +.import "geometry/offset.js" as Offset +.import "graphics/matrix.js" as Matrix + +var _circle = null +var _square = null +var _slanted = null +var _arch = null +var _fan = null +var _arrow = null +var _semiCircle = null +var _oval = null +var _pill = null +var _triangle = null +var _diamond = null +var _clamShell = null +var _pentagon = null +var _gem = null +var _verySunny = null +var _sunny = null +var _cookie4Sided = null +var _cookie6Sided = null +var _cookie7Sided = null +var _cookie9Sided = null +var _cookie12Sided = null +var _ghostish = null +var _clover4Leaf = null +var _clover8Leaf = null +var _burst = null +var _softBurst = null +var _boom = null +var _softBoom = null +var _flower = null +var _puffy = null +var _puffyDiamond = null +var _pixelCircle = null +var _pixelTriangle = null +var _bun = null +var _heart = null + +var cornerRound15 = new CornerRounding.CornerRounding(0.15) +var cornerRound20 = new CornerRounding.CornerRounding(0.2) +var cornerRound30 = new CornerRounding.CornerRounding(0.3) +var cornerRound50 = new CornerRounding.CornerRounding(0.5) +var cornerRound100 = new CornerRounding.CornerRounding(1.0) + +var rotateNeg30 = new Matrix.Matrix(); +rotateNeg30.rotateZ(-30); +var rotateNeg45 = new Matrix.Matrix(); +rotateNeg45.rotateZ(-45); +var rotateNeg90 = new Matrix.Matrix(); +rotateNeg90.rotateZ(-90); +var rotateNeg135 = new Matrix.Matrix(); +rotateNeg135.rotateZ(-135); +var rotate30 = new Matrix.Matrix(); +rotate30.rotateZ(30); +var rotate45 = new Matrix.Matrix(); +rotate45.rotateZ(45); +var rotate60 = new Matrix.Matrix(); +rotate60.rotateZ(60); +var rotate90 = new Matrix.Matrix(); +rotate90.rotateZ(90); +var rotate120 = new Matrix.Matrix(); +rotate120.rotateZ(120); +var rotate135 = new Matrix.Matrix(); +rotate135.rotateZ(135); +var rotate180 = new Matrix.Matrix(); +rotate180.rotateZ(180); + +var rotate28th = new Matrix.Matrix(); +rotate28th.rotateZ(360/28); +var rotateNeg16th = new Matrix.Matrix(); +rotateNeg16th.rotateZ(-360/16); + +function getCircle() { + if (_circle !== null) return _circle; + _circle = circle(); + return _circle; +} + +function getSquare() { + if (_square !== null) return _square; + _square = square(); + return _square; +} + +function getSlanted() { + if (_slanted !== null) return _slanted; + _slanted = slanted(); + return _slanted; +} + +function getArch() { + if (_arch !== null) return _arch; + _arch = arch(); + return _arch; +} + +function getFan() { + if (_fan !== null) return _fan; + _fan = fan(); + return _fan; +} + +function getArrow() { + if (_arrow !== null) return _arrow; + _arrow = arrow(); + return _arrow; +} + +function getSemiCircle() { + if (_semiCircle !== null) return _semiCircle; + _semiCircle = semiCircle(); + return _semiCircle; +} + +function getOval() { + if (_oval !== null) return _oval; + _oval = oval(); + return _oval; +} + +function getPill() { + if (_pill !== null) return _pill; + _pill = pill(); + return _pill; +} + +function getTriangle() { + if (_triangle !== null) return _triangle; + _triangle = triangle(); + return _triangle; +} + +function getDiamond() { + if (_diamond !== null) return _diamond; + _diamond = diamond(); + return _diamond; +} + +function getClamShell() { + if (_clamShell !== null) return _clamShell; + _clamShell = clamShell(); + return _clamShell; +} + +function getPentagon() { + if (_pentagon !== null) return _pentagon; + _pentagon = pentagon(); + return _pentagon; +} + +function getGem() { + if (_gem !== null) return _gem; + _gem = gem(); + return _gem; +} + +function getSunny() { + if (_sunny !== null) return _sunny; + _sunny = sunny(); + return _sunny; +} + +function getVerySunny() { + if (_verySunny !== null) return _verySunny; + _verySunny = verySunny(); + return _verySunny; +} + +function getCookie4Sided() { + if (_cookie4Sided !== null) return _cookie4Sided; + _cookie4Sided = cookie4(); + return _cookie4Sided; +} + +function getCookie6Sided() { + if (_cookie6Sided !== null) return _cookie6Sided; + _cookie6Sided = cookie6(); + return _cookie6Sided; +} + +function getCookie7Sided() { + if (_cookie7Sided !== null) return _cookie7Sided; + _cookie7Sided = cookie7(); + return _cookie7Sided; +} + +function getCookie9Sided() { + if (_cookie9Sided !== null) return _cookie9Sided; + _cookie9Sided = cookie9(); + return _cookie9Sided; +} + +function getCookie12Sided() { + if (_cookie12Sided !== null) return _cookie12Sided; + _cookie12Sided = cookie12(); + return _cookie12Sided; +} + +function getGhostish() { + if (_ghostish !== null) return _ghostish; + _ghostish = ghostish(); + return _ghostish; +} + +function getClover4Leaf() { + if (_clover4Leaf !== null) return _clover4Leaf; + _clover4Leaf = clover4(); + return _clover4Leaf; +} + +function getClover8Leaf() { + if (_clover8Leaf !== null) return _clover8Leaf; + _clover8Leaf = clover8(); + return _clover8Leaf; +} + +function getBurst() { + if (_burst !== null) return _burst; + _burst = burst(); + return _burst; +} + +function getSoftBurst() { + if (_softBurst !== null) return _softBurst; + _softBurst = softBurst(); + return _softBurst; +} + +function getBoom() { + if (_boom !== null) return _boom; + _boom = boom(); + return _boom; +} + +function getSoftBoom() { + if (_softBoom !== null) return _softBoom; + _softBoom = softBoom(); + return _softBoom; +} + +function getFlower() { + if (_flower !== null) return _flower; + _flower = flower(); + return _flower; +} + +function getPuffy() { + if (_puffy !== null) return _puffy; + _puffy = puffy(); + return _puffy; +} + +function getPuffyDiamond() { + if (_puffyDiamond !== null) return _puffyDiamond; + _puffyDiamond = puffyDiamond(); + return _puffyDiamond; +} + +function getPixelCircle() { + if (_pixelCircle !== null) return _pixelCircle; + _pixelCircle = pixelCircle(); + return _pixelCircle; +} + +function getPixelTriangle() { + if (_pixelTriangle !== null) return _pixelTriangle; + _pixelTriangle = pixelTriangle(); + return _pixelTriangle; +} + +function getBun() { + if (_bun !== null) return _bun; + _bun = bun(); + return _bun; +} + +function getHeart() { + if (_heart !== null) return _heart; + _heart = heart(); + return _heart; +} + +function circle() { + return RoundedPolygon.RoundedPolygon.circle(10) + .transformed((x, y) => rotate45.map(new Offset.Offset(x, y))) + .normalized(); +} + +function square() { + return RoundedPolygon.RoundedPolygon.rectangle(1, 1, cornerRound30).normalized(); +} + +function slanted() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.926, 0.970), new CornerRounding.CornerRounding(0.189, 0.811)), + new PointNRound(new Offset.Offset(-0.021, 0.967), new CornerRounding.CornerRounding(0.187, 0.057)), + ], 2).normalized(); +} + +function arch() { + return RoundedPolygon.RoundedPolygon.rectangle(1, 1, CornerRounding.Unrounded, [cornerRound20, cornerRound20, cornerRound100, cornerRound100]) + .normalized(); +} + +function fan() { + return customPolygon([ + new PointNRound(new Offset.Offset(1.004, 1.000), new CornerRounding.CornerRounding(0.148, 0.417)), + new PointNRound(new Offset.Offset(0.000, 1.000), new CornerRounding.CornerRounding(0.151)), + new PointNRound(new Offset.Offset(0.000, -0.003), new CornerRounding.CornerRounding(0.148)), + new PointNRound(new Offset.Offset(0.978, 0.020), new CornerRounding.CornerRounding(0.803)), + ], 1).normalized(); +} + +function arrow() { + return customPolygon([ + new PointNRound(new Offset.Offset(1.225, 1.060), new CornerRounding.CornerRounding(0.211)), + new PointNRound(new Offset.Offset(0.500, 0.892), new CornerRounding.CornerRounding(0.313)), + new PointNRound(new Offset.Offset(-0.216, 1.050), new CornerRounding.CornerRounding(0.207)), + new PointNRound(new Offset.Offset(0.499, -0.160), new CornerRounding.CornerRounding(0.215, 1.000)), + ], 1).normalized(); +} + +function semiCircle() { + return RoundedPolygon.RoundedPolygon.rectangle(1.6, 1, CornerRounding.Unrounded, [cornerRound20, cornerRound20, cornerRound100, cornerRound100]).normalized(); +} + +function oval() { + const scaleMatrix = new Matrix.Matrix(); + scaleMatrix.scale(1, 0.64); + return RoundedPolygon.RoundedPolygon.circle() + .transformed((x, y) => rotateNeg90.map(new Offset.Offset(x, y))) + .transformed((x, y) => scaleMatrix.map(new Offset.Offset(x, y))) + .transformed((x, y) => rotate135.map(new Offset.Offset(x, y))) + .normalized(); +} + +function pill() { + return customPolygon([ + // new PointNRound(new Offset.Offset(0.609, 0.000), new CornerRounding.CornerRounding(1.000)), + new PointNRound(new Offset.Offset(0.428, -0.001), new CornerRounding.CornerRounding(0.426)), + new PointNRound(new Offset.Offset(0.961, 0.039), new CornerRounding.CornerRounding(0.426)), + new PointNRound(new Offset.Offset(1.001, 0.428)), + new PointNRound(new Offset.Offset(1.000, 0.609), new CornerRounding.CornerRounding(1.000)), + ], 2) + .transformed((x, y) => rotate180.map(new Offset.Offset(x, y))) + .normalized(); +} + +function triangle() { + return RoundedPolygon.RoundedPolygon.fromNumVertices(3, 1, 0.5, 0.5, cornerRound20) + .transformed((x, y) => rotate30.map(new Offset.Offset(x, y))) + .normalized() +} + +function diamond() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.500, 1.096), new CornerRounding.CornerRounding(0.151, 0.524)), + new PointNRound(new Offset.Offset(0.040, 0.500), new CornerRounding.CornerRounding(0.159)), + ], 2).normalized(); +} + +function clamShell() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.829, 0.841), new CornerRounding.CornerRounding(0.159)), + new PointNRound(new Offset.Offset(0.171, 0.841), new CornerRounding.CornerRounding(0.159)), + new PointNRound(new Offset.Offset(-0.020, 0.500), new CornerRounding.CornerRounding(0.140)), + ], 2).normalized(); +} + +function pentagon() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.828, 0.970), new CornerRounding.CornerRounding(0.169)), + new PointNRound(new Offset.Offset(0.172, 0.970), new CornerRounding.CornerRounding(0.169)), + new PointNRound(new Offset.Offset(-0.030, 0.365), new CornerRounding.CornerRounding(0.164)), + new PointNRound(new Offset.Offset(0.500, -0.009), new CornerRounding.CornerRounding(0.172)), + new PointNRound(new Offset.Offset(1.030, 0.365), new CornerRounding.CornerRounding(0.164)), + ], 1).normalized(); +} + +function gem() { + return customPolygon([ + new PointNRound(new Offset.Offset(1.005, 0.792), new CornerRounding.CornerRounding(0.208)), + new PointNRound(new Offset.Offset(0.5, 1.023), new CornerRounding.CornerRounding(0.241, 0.778)), + new PointNRound(new Offset.Offset(-0.005, 0.792), new CornerRounding.CornerRounding(0.208)), + new PointNRound(new Offset.Offset(0.073, 0.258), new CornerRounding.CornerRounding(0.228)), + new PointNRound(new Offset.Offset(0.5, 0.000), new CornerRounding.CornerRounding(0.241, 0.778)), + new PointNRound(new Offset.Offset(0.927, 0.258), new CornerRounding.CornerRounding(0.228)), + ], 1).normalized(); +} + +function sunny() { + return RoundedPolygon.RoundedPolygon.star(8, 1, 0.8, cornerRound15) + .transformed((x, y) => rotate45.map(new Offset.Offset(x, y))) + .normalized(); +} + +function verySunny() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.500, 1.080), new CornerRounding.CornerRounding(0.085)), + new PointNRound(new Offset.Offset(0.358, 0.843), new CornerRounding.CornerRounding(0.085)), + ], 8) + .transformed((x, y) => rotateNeg45.map(new Offset.Offset(x, y))) + .normalized(); +} + +function cookie4() { + return customPolygon([ + new PointNRound(new Offset.Offset(1.237, 1.236), new CornerRounding.CornerRounding(0.258)), + new PointNRound(new Offset.Offset(0.500, 0.918), new CornerRounding.CornerRounding(0.233)), + ], 4).normalized(); +} + +function cookie6() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.723, 0.884), new CornerRounding.CornerRounding(0.394)), + new PointNRound(new Offset.Offset(0.500, 1.099), new CornerRounding.CornerRounding(0.398)), + ], 6).normalized(); +} + +function cookie7() { + return RoundedPolygon.RoundedPolygon.star(7, 1, 0.75, cornerRound50) + .normalized() + .transformed((x, y) => rotate28th.map(new Offset.Offset(x, y))) + .transformed((x, y) => rotate28th.map(new Offset.Offset(x, y))) + .transformed((x, y) => rotate28th.map(new Offset.Offset(x, y))) + .transformed((x, y) => rotate28th.map(new Offset.Offset(x, y))) + .transformed((x, y) => rotate28th.map(new Offset.Offset(x, y))) + .normalized(); +} + +function cookie9() { + return RoundedPolygon.RoundedPolygon.star(9, 1, 0.8, cornerRound50) + .transformed((x, y) => rotate30.map(new Offset.Offset(x, y))) + .normalized(); +} + +function cookie12() { + return RoundedPolygon.RoundedPolygon.star(12, 1, 0.8, cornerRound50) + .transformed((x, y) => rotate30.map(new Offset.Offset(x, y))) + .normalized(); +} + +function ghostish() { + return customPolygon([ + new PointNRound(new Offset.Offset(1.000, 1.140), new CornerRounding.CornerRounding(0.254, 0.106)), + new PointNRound(new Offset.Offset(0.575, 0.906), new CornerRounding.CornerRounding(0.253)), + new PointNRound(new Offset.Offset(0.425, 0.906), new CornerRounding.CornerRounding(0.253)), + new PointNRound(new Offset.Offset(0.000, 1.140), new CornerRounding.CornerRounding(0.254, 0.106)), + new PointNRound(new Offset.Offset(0.000, 0.000), new CornerRounding.CornerRounding(1.0)), + new PointNRound(new Offset.Offset(0.500, 0.000), new CornerRounding.CornerRounding(1.0)), + new PointNRound(new Offset.Offset(1.000, 0.000), new CornerRounding.CornerRounding(1.0)), + ], 1).normalized(); +} + +function clover4() { + return customPolygon([ + new PointNRound(new Offset.Offset(1.099, 0.725), new CornerRounding.CornerRounding(0.476)), + new PointNRound(new Offset.Offset(0.725, 1.099), new CornerRounding.CornerRounding(0.476)), + new PointNRound(new Offset.Offset(0.500, 0.926)), + ], 4).normalized(); +} + +function clover8() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.758, 1.101), new CornerRounding.CornerRounding(0.209)), + new PointNRound(new Offset.Offset(0.500, 0.964)), + ], 8).normalized(); +} + +function burst() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.592, 0.842), new CornerRounding.CornerRounding(0.006)), + new PointNRound(new Offset.Offset(0.500, 1.006), new CornerRounding.CornerRounding(0.006)), + ], 12) + .transformed((x, y) => rotateNeg30.map(new Offset.Offset(x, y))) + .transformed((x, y) => rotateNeg30.map(new Offset.Offset(x, y))) + .normalized(); +} + +function softBurst() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.193, 0.277), new CornerRounding.CornerRounding(0.053)), + new PointNRound(new Offset.Offset(0.176, 0.055), new CornerRounding.CornerRounding(0.053)), + ], 10) + .transformed((x, y) => rotate180.map(new Offset.Offset(x, y))) + .normalized(); +} + +function boom() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.457, 0.296), new CornerRounding.CornerRounding(0.007)), + new PointNRound(new Offset.Offset(0.500, -0.051), new CornerRounding.CornerRounding(0.007)), + ], 15) + .transformed((x, y) => rotate120.map(new Offset.Offset(x, y))) + .normalized(); +} + +function softBoom() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.733, 0.454)), + new PointNRound(new Offset.Offset(0.839, 0.437), new CornerRounding.CornerRounding(0.532)), + new PointNRound(new Offset.Offset(0.949, 0.449), new CornerRounding.CornerRounding(0.439, 1.000)), + new PointNRound(new Offset.Offset(0.998, 0.478), new CornerRounding.CornerRounding(0.174)), + // mirrored points + new PointNRound(new Offset.Offset(0.998, 0.522), new CornerRounding.CornerRounding(0.174)), + new PointNRound(new Offset.Offset(0.949, 0.551), new CornerRounding.CornerRounding(0.439, 1.000)), + new PointNRound(new Offset.Offset(0.839, 0.563), new CornerRounding.CornerRounding(0.532)), + new PointNRound(new Offset.Offset(0.733, 0.546)), + ], 16) + .transformed((x, y) => rotate45.map(new Offset.Offset(x, y))) + .transformed((x, y) => rotateNeg16th.map(new Offset.Offset(x, y))) + .normalized(); +} + +function flower() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.370, 0.187)), + new PointNRound(new Offset.Offset(0.416, 0.049), new CornerRounding.CornerRounding(0.381)), + new PointNRound(new Offset.Offset(0.479, 0.001), new CornerRounding.CornerRounding(0.095)), + // mirrored points + new PointNRound(new Offset.Offset(0.521, 0.001), new CornerRounding.CornerRounding(0.095)), + new PointNRound(new Offset.Offset(0.584, 0.049), new CornerRounding.CornerRounding(0.381)), + new PointNRound(new Offset.Offset(0.630, 0.187)), + ], 8) + .transformed((x, y) => rotate135.map(new Offset.Offset(x, y))) + .normalized(); +} + +function puffy() { + const m = new Matrix.Matrix(); + m.scale(1, 0.742); + const shape = customPolygon([ + // mirrored points + new PointNRound(new Offset.Offset(1.003, 0.563), new CornerRounding.CornerRounding(0.255)), + new PointNRound(new Offset.Offset(0.940, 0.656), new CornerRounding.CornerRounding(0.126)), + new PointNRound(new Offset.Offset(0.881, 0.654)), + new PointNRound(new Offset.Offset(0.926, 0.711), new CornerRounding.CornerRounding(0.660)), + new PointNRound(new Offset.Offset(0.914, 0.851), new CornerRounding.CornerRounding(0.660)), + new PointNRound(new Offset.Offset(0.777, 0.998), new CornerRounding.CornerRounding(0.360)), + new PointNRound(new Offset.Offset(0.722, 0.872)), + new PointNRound(new Offset.Offset(0.717, 0.934), new CornerRounding.CornerRounding(0.574)), + new PointNRound(new Offset.Offset(0.670, 1.035), new CornerRounding.CornerRounding(0.426)), + new PointNRound(new Offset.Offset(0.545, 1.040), new CornerRounding.CornerRounding(0.405)), + new PointNRound(new Offset.Offset(0.500, 0.947)), + // original points + new PointNRound(new Offset.Offset(0.500, 1-0.053)), + new PointNRound(new Offset.Offset(1-0.545, 1+0.040), new CornerRounding.CornerRounding(0.405)), + new PointNRound(new Offset.Offset(1-0.670, 1+0.035), new CornerRounding.CornerRounding(0.426)), + new PointNRound(new Offset.Offset(1-0.717, 1-0.066), new CornerRounding.CornerRounding(0.574)), + new PointNRound(new Offset.Offset(1-0.722, 1-0.128)), + new PointNRound(new Offset.Offset(1-0.777, 1-0.002), new CornerRounding.CornerRounding(0.360)), + new PointNRound(new Offset.Offset(1-0.914, 1-0.149), new CornerRounding.CornerRounding(0.660)), + new PointNRound(new Offset.Offset(1-0.926, 1-0.289), new CornerRounding.CornerRounding(0.660)), + new PointNRound(new Offset.Offset(1-0.881, 1-0.346)), + new PointNRound(new Offset.Offset(1-0.940, 1-0.344), new CornerRounding.CornerRounding(0.126)), + new PointNRound(new Offset.Offset(1-1.003, 1-0.437), new CornerRounding.CornerRounding(0.255)), + ], 2); + return shape.transformed((x, y) => m.map(new Offset.Offset(x, y))).normalized(); +} + +function puffyDiamond() { + return customPolygon([ + // original points + new PointNRound(new Offset.Offset(0.870, 0.130), new CornerRounding.CornerRounding(0.146)), + new PointNRound(new Offset.Offset(0.818, 0.357)), + new PointNRound(new Offset.Offset(1.000, 0.332), new CornerRounding.CornerRounding(0.853)), + // mirrored points + new PointNRound(new Offset.Offset(1.000, 1-0.332), new CornerRounding.CornerRounding(0.853)), + new PointNRound(new Offset.Offset(0.818, 1-0.357)), + ], 4) + .transformed((x, y) => rotate90.map(new Offset.Offset(x, y))) + .normalized(); +} + +function pixelCircle() { + return customPolygon([ + new PointNRound(new Offset.Offset(1.000, 0.704)), + new PointNRound(new Offset.Offset(0.926, 0.704)), + new PointNRound(new Offset.Offset(0.926, 0.852)), + new PointNRound(new Offset.Offset(0.843, 0.852)), + new PointNRound(new Offset.Offset(0.843, 0.935)), + new PointNRound(new Offset.Offset(0.704, 0.935)), + new PointNRound(new Offset.Offset(0.704, 1.000)), + new PointNRound(new Offset.Offset(0.500, 1.000)), + new PointNRound(new Offset.Offset(1-0.704, 1.000)), + new PointNRound(new Offset.Offset(1-0.704, 0.935)), + new PointNRound(new Offset.Offset(1-0.843, 0.935)), + new PointNRound(new Offset.Offset(1-0.843, 0.852)), + new PointNRound(new Offset.Offset(1-0.926, 0.852)), + new PointNRound(new Offset.Offset(1-0.926, 0.704)), + new PointNRound(new Offset.Offset(1-1.000, 0.704)), + ], 2) + .normalized(); +} + +function pixelTriangle() { + return customPolygon([ + // mirrored points + new PointNRound(new Offset.Offset(0.888, 1-0.439)), + new PointNRound(new Offset.Offset(0.789, 1-0.439)), + new PointNRound(new Offset.Offset(0.789, 1-0.344)), + new PointNRound(new Offset.Offset(0.675, 1-0.344)), + new PointNRound(new Offset.Offset(0.674, 1-0.265)), + new PointNRound(new Offset.Offset(0.560, 1-0.265)), + new PointNRound(new Offset.Offset(0.560, 1-0.170)), + new PointNRound(new Offset.Offset(0.421, 1-0.170)), + new PointNRound(new Offset.Offset(0.421, 1-0.087)), + new PointNRound(new Offset.Offset(0.287, 1-0.087)), + new PointNRound(new Offset.Offset(0.287, 1-0.000)), + new PointNRound(new Offset.Offset(0.113, 1-0.000)), + // original points + new PointNRound(new Offset.Offset(0.110, 0.500)), + new PointNRound(new Offset.Offset(0.113, 0.000)), + new PointNRound(new Offset.Offset(0.287, 0.000)), + new PointNRound(new Offset.Offset(0.287, 0.087)), + new PointNRound(new Offset.Offset(0.421, 0.087)), + new PointNRound(new Offset.Offset(0.421, 0.170)), + new PointNRound(new Offset.Offset(0.560, 0.170)), + new PointNRound(new Offset.Offset(0.560, 0.265)), + new PointNRound(new Offset.Offset(0.674, 0.265)), + new PointNRound(new Offset.Offset(0.675, 0.344)), + new PointNRound(new Offset.Offset(0.789, 0.344)), + new PointNRound(new Offset.Offset(0.789, 0.439)), + new PointNRound(new Offset.Offset(0.888, 0.439)), + ], 1).normalized(); +} + +function bun() { + return customPolygon([ + // original points + new PointNRound(new Offset.Offset(0.796, 0.500)), + new PointNRound(new Offset.Offset(0.853, 0.518), cornerRound100), + new PointNRound(new Offset.Offset(0.992, 0.631), cornerRound100), + new PointNRound(new Offset.Offset(0.968, 1.000), cornerRound100), + // mirrored points + new PointNRound(new Offset.Offset(0.032, 1-0.000), cornerRound100), + new PointNRound(new Offset.Offset(0.008, 1-0.369), cornerRound100), + new PointNRound(new Offset.Offset(0.147, 1-0.482), cornerRound100), + new PointNRound(new Offset.Offset(0.204, 1-0.500)), + ], 2).normalized(); +} + +function heart() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.782, 0.611)), + new PointNRound(new Offset.Offset(0.499, 0.946), new CornerRounding.CornerRounding(0.000)), + new PointNRound(new Offset.Offset(0.2175, 0.611)), + new PointNRound(new Offset.Offset(-0.064, 0.276), new CornerRounding.CornerRounding(1.000)), + new PointNRound(new Offset.Offset(0.208, -0.066), new CornerRounding.CornerRounding(0.958)), + new PointNRound(new Offset.Offset(0.500, 0.268), new CornerRounding.CornerRounding(0.016)), + new PointNRound(new Offset.Offset(0.792, -0.066), new CornerRounding.CornerRounding(0.958)), + new PointNRound(new Offset.Offset(1.064, 0.276), new CornerRounding.CornerRounding(1.000)), + ], 1) + .normalized(); +} + +class PointNRound { + constructor(o, r = CornerRounding.Unrounded) { + this.o = o; + this.r = r; + } +} + +function doRepeat(points, reps, center, mirroring) { + if (mirroring) { + const result = []; + const angles = points.map(p => p.o.minus(center).angleDegrees()); + const distances = points.map(p => p.o.minus(center).getDistance()); + const actualReps = reps * 2; + const sectionAngle = 360 / actualReps; + for (let it = 0; it < actualReps; it++) { + for (let index = 0; index < points.length; index++) { + const i = (it % 2 === 0) ? index : points.length - 1 - index; + if (i > 0 || it % 2 === 0) { + const baseAngle = angles[i]; + const angle = it * sectionAngle + (it % 2 === 0 ? baseAngle : (2 * angles[0] - baseAngle)); + const dist = distances[i]; + const rad = angle * Math.PI / 180; + const x = center.x + dist * Math.cos(rad); + const y = center.y + dist * Math.sin(rad); + result.push(new PointNRound(new Offset.Offset(x, y), points[i].r)); + } + } + } + return result; + } else { + const np = points.length; + const result = []; + for (let i = 0; i < np * reps; i++) { + const point = points[i % np].o.rotateDegrees(Math.floor(i / np) * 360 / reps, center); + result.push(new PointNRound(point, points[i % np].r)); + } + return result; + } +} + +function customPolygon(pnr, reps, center = new Offset.Offset(0.5, 0.5), mirroring = false) { + const actualPoints = doRepeat(pnr, reps, center, mirroring); + const vertices = []; + for (const p of actualPoints) { + vertices.push(p.o.x); + vertices.push(p.o.y); + } + const perVertexRounding = actualPoints.map(p => p.r); + return RoundedPolygon.RoundedPolygon.fromVertices(vertices, CornerRounding.Unrounded, perVertexRounding, center.x, center.y); +} diff --git a/modules/quickshell/config/modules/common/widgets/shapes/shapes/corner-rounding.js b/modules/quickshell/config/modules/common/widgets/shapes/shapes/corner-rounding.js new file mode 100644 index 0000000..da456fa --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/shapes/corner-rounding.js @@ -0,0 +1,18 @@ +.pragma library + +/** + * Represents corner rounding configuration + */ +class CornerRounding { + /** + * @param {float} [radius=0] + * @param {float} [smoothing=0] + */ + constructor(radius = 0, smoothing = 0) { + this.radius = radius; + this.smoothing = smoothing; + } +} + +// Static property +CornerRounding.Unrounded = new CornerRounding(); \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/shapes/shapes/cubic.js b/modules/quickshell/config/modules/common/widgets/shapes/shapes/cubic.js new file mode 100644 index 0000000..1e3b0bd --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/shapes/cubic.js @@ -0,0 +1,371 @@ +.pragma library +.import "point.js" as PointModule +.import "utils.js" as UtilsModule + +var Point = PointModule.Point; +var DistanceEpsilon = UtilsModule.DistanceEpsilon; +var interpolate = UtilsModule.interpolate; +var directionVector = UtilsModule.directionVector; +var distance = UtilsModule.distance; + +/** + * Represents a cubic Bรฉzier curve with anchor and control points + */ +class Cubic { + /** + * @param {Array} points Array of 8 numbers [anchor0X, anchor0Y, control0X, control0Y, control1X, control1Y, anchor1X, anchor1Y] + */ + constructor(points) { + this.points = points; + } + + get anchor0X() { return this.points[0]; } + get anchor0Y() { return this.points[1]; } + get control0X() { return this.points[2]; } + get control0Y() { return this.points[3]; } + get control1X() { return this.points[4]; } + get control1Y() { return this.points[5]; } + get anchor1X() { return this.points[6]; } + get anchor1Y() { return this.points[7]; } + + /** + * @param {Point} anchor0 + * @param {Point} control0 + * @param {Point} control1 + * @param {Point} anchor1 + * @returns {Cubic} + */ + static create(anchor0, control0, control1, anchor1) { + return new Cubic([ + anchor0.x, anchor0.y, + control0.x, control0.y, + control1.x, control1.y, + anchor1.x, anchor1.y + ]); + } + + /** + * @param {float} t + * @returns {Point} + */ + pointOnCurve(t) { + const u = 1 - t; + return new Point( + this.anchor0X * (u * u * u) + + this.control0X * (3 * t * u * u) + + this.control1X * (3 * t * t * u) + + this.anchor1X * (t * t * t), + this.anchor0Y * (u * u * u) + + this.control0Y * (3 * t * u * u) + + this.control1Y * (3 * t * t * u) + + this.anchor1Y * (t * t * t) + ); + } + + /** + * @returns {boolean} + */ + zeroLength() { + return Math.abs(this.anchor0X - this.anchor1X) < DistanceEpsilon && + Math.abs(this.anchor0Y - this.anchor1Y) < DistanceEpsilon; + } + + /** + * @param {Cubic} next + * @returns {boolean} + */ + convexTo(next) { + const prevVertex = new Point(this.anchor0X, this.anchor0Y); + const currVertex = new Point(this.anchor1X, this.anchor1Y); + const nextVertex = new Point(next.anchor1X, next.anchor1Y); + return convex(prevVertex, currVertex, nextVertex); + } + + /** + * @param {float} value + * @returns {boolean} + */ + zeroIsh(value) { + return Math.abs(value) < DistanceEpsilon; + } + + /** + * @param {Array} bounds + * @param {boolean} [approximate=false] + */ + calculateBounds(bounds, approximate = false) { + if (this.zeroLength()) { + bounds[0] = this.anchor0X; + bounds[1] = this.anchor0Y; + bounds[2] = this.anchor0X; + bounds[3] = this.anchor0Y; + return; + } + + let minX = Math.min(this.anchor0X, this.anchor1X); + let minY = Math.min(this.anchor0Y, this.anchor1Y); + let maxX = Math.max(this.anchor0X, this.anchor1X); + let maxY = Math.max(this.anchor0Y, this.anchor1Y); + + if (approximate) { + bounds[0] = Math.min(minX, Math.min(this.control0X, this.control1X)); + bounds[1] = Math.min(minY, Math.min(this.control0Y, this.control1Y)); + bounds[2] = Math.max(maxX, Math.max(this.control0X, this.control1X)); + bounds[3] = Math.max(maxY, Math.max(this.control0Y, this.control1Y)); + return; + } + + // Find extrema using derivatives + const xa = -this.anchor0X + 3 * this.control0X - 3 * this.control1X + this.anchor1X; + const xb = 2 * this.anchor0X - 4 * this.control0X + 2 * this.control1X; + const xc = -this.anchor0X + this.control0X; + + if (this.zeroIsh(xa)) { + if (xb != 0) { + const t = 2 * xc / (-2 * xb); + if (t >= 0 && t <= 1) { + const it = this.pointOnCurve(t).x; + if (it < minX) minX = it; + if (it > maxX) maxX = it; + } + } + } else { + const xs = xb * xb - 4 * xa * xc; + if (xs >= 0) { + const t1 = (-xb + Math.sqrt(xs)) / (2 * xa); + if (t1 >= 0 && t1 <= 1) { + const it = this.pointOnCurve(t1).x; + if (it < minX) minX = it; + if (it > maxX) maxX = it; + } + + const t2 = (-xb - Math.sqrt(xs)) / (2 * xa); + if (t2 >= 0 && t2 <= 1) { + const it = this.pointOnCurve(t2).x; + if (it < minX) minX = it; + if (it > maxX) maxX = it; + } + } + } + + // Repeat for y coord + const ya = -this.anchor0Y + 3 * this.control0Y - 3 * this.control1Y + this.anchor1Y; + const yb = 2 * this.anchor0Y - 4 * this.control0Y + 2 * this.control1Y; + const yc = -this.anchor0Y + this.control0Y; + + if (this.zeroIsh(ya)) { + if (yb != 0) { + const t = 2 * yc / (-2 * yb); + if (t >= 0 && t <= 1) { + const it = this.pointOnCurve(t).y; + if (it < minY) minY = it; + if (it > maxY) maxY = it; + } + } + } else { + const ys = yb * yb - 4 * ya * yc; + if (ys >= 0) { + const t1 = (-yb + Math.sqrt(ys)) / (2 * ya); + if (t1 >= 0 && t1 <= 1) { + const it = this.pointOnCurve(t1).y; + if (it < minY) minY = it; + if (it > maxY) maxY = it; + } + + const t2 = (-yb - Math.sqrt(ys)) / (2 * ya); + if (t2 >= 0 && t2 <= 1) { + const it = this.pointOnCurve(t2).y; + if (it < minY) minY = it; + if (it > maxY) maxY = it; + } + } + } + bounds[0] = minX; + bounds[1] = minY; + bounds[2] = maxX; + bounds[3] = maxY; + } + + /** + * @param {float} t + * @returns {{a: Cubic, b: Cubic}} + */ + split(t) { + const u = 1 - t; + const pointOnCurve = this.pointOnCurve(t); + return { + a: new Cubic([ + this.anchor0X, + this.anchor0Y, + this.anchor0X * u + this.control0X * t, + this.anchor0Y * u + this.control0Y * t, + this.anchor0X * (u * u) + this.control0X * (2 * u * t) + this.control1X * (t * t), + this.anchor0Y * (u * u) + this.control0Y * (2 * u * t) + this.control1Y * (t * t), + pointOnCurve.x, + pointOnCurve.y + ]), + b: new Cubic([ + pointOnCurve.x, + pointOnCurve.y, + this.control0X * (u * u) + this.control1X * (2 * u * t) + this.anchor1X * (t * t), + this.control0Y * (u * u) + this.control1Y * (2 * u * t) + this.anchor1Y * (t * t), + this.control1X * u + this.anchor1X * t, + this.control1Y * u + this.anchor1Y * t, + this.anchor1X, + this.anchor1Y + ]) + }; + } + + /** + * @returns {Cubic} + */ + reverse() { + return new Cubic([ + this.anchor1X, this.anchor1Y, + this.control1X, this.control1Y, + this.control0X, this.control0Y, + this.anchor0X, this.anchor0Y + ]); + } + + /** + * @param {Cubic} other + * @returns {Cubic} + */ + plus(other) { + return new Cubic(other.points.map((_, index) => this.points[index] + other.points[index])); + } + + /** + * @param {float} x + * @returns {Cubic} + */ + times(x) { + return new Cubic(this.points.map(v => v * x)); + } + + /** + * @param {float} x + * @returns {Cubic} + */ + div(x) { + return this.times(1 / x); + } + + /** + * @param {Cubic} other + * @returns {boolean} + */ + equals(other) { + return this.points.every((p, i) => other.points[i] === p); + } + + /** + * @param {function(float, float): Point} f + * @returns {Cubic} + */ + transformed(f) { + const newCubic = new MutableCubic([...this.points]); + newCubic.transform(f); + return newCubic; + } + + /** + * @param {float} x0 + * @param {float} y0 + * @param {float} x1 + * @param {float} y1 + * @returns {Cubic} + */ + static straightLine(x0, y0, x1, y1) { + return new Cubic([ + x0, + y0, + interpolate(x0, x1, 1/3), + interpolate(y0, y1, 1/3), + interpolate(x0, x1, 2/3), + interpolate(y0, y1, 2/3), + x1, + y1 + ]); + } + + /** + * @param {float} centerX + * @param {float} centerY + * @param {float} x0 + * @param {float} y0 + * @param {float} x1 + * @param {float} y1 + * @returns {Cubic} + */ + static circularArc(centerX, centerY, x0, y0, x1, y1) { + const p0d = directionVector(x0 - centerX, y0 - centerY); + const p1d = directionVector(x1 - centerX, y1 - centerY); + const rotatedP0 = p0d.rotate90(); + const rotatedP1 = p1d.rotate90(); + const clockwise = rotatedP0.dotProductScalar(x1 - centerX, y1 - centerY) >= 0; + const cosa = p0d.dotProduct(p1d); + + if (cosa > 0.999) { + return Cubic.straightLine(x0, y0, x1, y1); + } + + const k = distance(x0 - centerX, y0 - centerY) * 4/3 * + (Math.sqrt(2 * (1 - cosa)) - Math.sqrt(1 - cosa * cosa)) / + (1 - cosa) * (clockwise ? 1 : -1); + + return new Cubic([ + x0, y0, + x0 + rotatedP0.x * k, + y0 + rotatedP0.y * k, + x1 - rotatedP1.x * k, + y1 - rotatedP1.y * k, + x1, y1 + ]); + } + + /** + * @param {float} x0 + * @param {float} y0 + * @returns {Cubic} + */ + static empty(x0, y0) { + return new Cubic([x0, y0, x0, y0, x0, y0, x0, y0]); + } +} + +class MutableCubic extends Cubic { + /** + * @param {function(float, float): Point} f + */ + transform(f) { + this.transformOnePoint(f, 0); + this.transformOnePoint(f, 2); + this.transformOnePoint(f, 4); + this.transformOnePoint(f, 6); + } + + /** + * @param {Cubic} c1 + * @param {Cubic} c2 + * @param {float} progress + */ + interpolate(c1, c2, progress) { + for (let i = 0; i < 8; i++) { + this.points[i] = interpolate(c1.points[i], c2.points[i], progress); + } + } + + /** + * @private + * @param {function(float, float): Point} f + * @param {number} ix + */ + transformOnePoint(f, ix) { + const result = f(this.points[ix], this.points[ix + 1]); + this.points[ix] = result.x; + this.points[ix + 1] = result.y; + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/shapes/shapes/feature-mapping.js b/modules/quickshell/config/modules/common/widgets/shapes/shapes/feature-mapping.js new file mode 100644 index 0000000..16db92c --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/shapes/feature-mapping.js @@ -0,0 +1,166 @@ +.pragma library +.import "feature.js" as FeatureModule +.import "float-mapping.js" as MappingModule +.import "point.js" as PointModule +.import "utils.js" as UtilsModule + +var Feature = FeatureModule.Feature; +var Corner = FeatureModule.Corner; +var Point = PointModule.Point; +var DoubleMapper = MappingModule.DoubleMapper; +var progressInRange = MappingModule.progressInRange; +var DistanceEpsilon = UtilsModule.DistanceEpsilon; + +var IdentityMapping = [{ a: 0, b: 0 }, { a: 0.5, b: 0.5 }]; + +class ProgressableFeature { + /** + * @param {float} progress + * @param {Feature} feature + */ + constructor(progress, feature) { + this.progress = progress; + this.feature = feature; + } +} + +class DistanceVertex { + /** + * @param {float} distance + * @param {ProgressableFeature} f1 + * @param {ProgressableFeature} f2 + */ + constructor(distance, f1, f2) { + this.distance = distance; + this.f1 = f1; + this.f2 = f2; + } +} + +class MappingHelper { + constructor() { + this.mapping = []; + this.usedF1 = new Set(); + this.usedF2 = new Set(); + } + + /** + * @param {ProgressableFeature} f1 + * @param {ProgressableFeature} f2 + */ + addMapping(f1, f2) { + if (this.usedF1.has(f1) || this.usedF2.has(f2)) { + return; + } + + const index = this.mapping.findIndex(x => x.a === f1.progress); + const insertionIndex = -index - 1; + const n = this.mapping.length; + + if (n >= 1) { + const { a: before1, b: before2 } = this.mapping[(insertionIndex + n - 1) % n]; + const { a: after1, b: after2 } = this.mapping[insertionIndex % n]; + + if ( + progressDistance(f1.progress, before1) < DistanceEpsilon || + progressDistance(f1.progress, after1) < DistanceEpsilon || + progressDistance(f2.progress, before2) < DistanceEpsilon || + progressDistance(f2.progress, after2) < DistanceEpsilon + ) { + return; + } + + if (n > 1 && !progressInRange(f2.progress, before2, after2)) { + return; + } + } + + this.mapping.splice(insertionIndex, 0, { a: f1.progress, b: f2.progress }); + this.usedF1.add(f1); + this.usedF2.add(f2); + } +} + +/** + * @param {Array} features1 + * @param {Array} features2 + * @returns {DoubleMapper} + */ +function featureMapper(features1, features2) { + const filteredFeatures1 = features1.filter(f => f.feature instanceof Corner); + const filteredFeatures2 = features2.filter(f => f.feature instanceof Corner); + + const featureProgressMapping = doMapping(filteredFeatures1, filteredFeatures2); + return new DoubleMapper(...featureProgressMapping); +} + +/** + * @param {Array} features1 + * @param {Array} features2 + * @returns {Array<{a: float, b: float}>} + */ +function doMapping(features1, features2) { + const distanceVertexList = []; + + for (const f1 of features1) { + for (const f2 of features2) { + const d = featureDistSquared(f1.feature, f2.feature); + if (d !== Number.MAX_VALUE) { + distanceVertexList.push(new DistanceVertex(d, f1, f2)); + } + } + } + + distanceVertexList.sort((a, b) => a.distance - b.distance); + + // Special cases + if (distanceVertexList.length === 0) { + return IdentityMapping; + } else if (distanceVertexList.length === 1) { + const { f1, f2 } = distanceVertexList[0]; + const p1 = f1.progress; + const p2 = f2.progress; + return [ + { a: p1, b: p2 }, + { a: (p1 + 0.5) % 1, b: (p2 + 0.5) % 1 } + ]; + } + + const helper = new MappingHelper(); + distanceVertexList.forEach(({ f1, f2 }) => helper.addMapping(f1, f2)); + return helper.mapping; +} + +/** + * @param {Feature} f1 + * @param {Feature} f2 + * @returns {float} + */ +function featureDistSquared(f1, f2) { + if (f1 instanceof Corner && f2 instanceof Corner && f1.convex != f2.convex) { + return Number.MAX_VALUE; + } + return featureRepresentativePoint(f1).minus(featureRepresentativePoint(f2)).getDistanceSquared(); +} + +/** + * @param {Feature} feature + * @returns {Point} + */ +function featureRepresentativePoint(feature) { + const firstCubic = feature.cubics[0]; + const lastCubic = feature.cubics[feature.cubics.length - 1]; + const x = (firstCubic.anchor0X + lastCubic.anchor1X) / 2; + const y = (firstCubic.anchor0Y + lastCubic.anchor1Y) / 2; + return new Point(x, y); +} + +/** + * @param {float} p1 + * @param {float} p2 + * @returns {float} + */ +function progressDistance(p1, p2) { + const it = Math.abs(p1 - p2); + return Math.min(it, 1 - it); +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/shapes/shapes/feature.js b/modules/quickshell/config/modules/common/widgets/shapes/shapes/feature.js new file mode 100644 index 0000000..afd5ee5 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/shapes/feature.js @@ -0,0 +1,103 @@ +.pragma library +.import "cubic.js" as CubicModule + +var Cubic = CubicModule.Cubic; + +/** + * Base class for shape features (edges and corners) + */ +class Feature { + /** + * @param {Array} cubics + */ + constructor(cubics) { + this.cubics = cubics; + } + + /** + * @param {Array} cubics + * @returns {Edge} + */ + buildIgnorableFeature(cubics) { + return new Edge(cubics); + } + + /** + * @param {Cubic} cubic + * @returns {Edge} + */ + buildEdge(cubic) { + return new Edge([cubic]); + } + + /** + * @param {Array} cubics + * @returns {Corner} + */ + buildConvexCorner(cubics) { + return new Corner(cubics, true); + } + + /** + * @param {Array} cubics + * @returns {Corner} + */ + buildConcaveCorner(cubics) { + return new Corner(cubics, false); + } +} + +class Edge extends Feature { + constructor(cubics) { + super(cubics); + this.isIgnorableFeature = true; + this.isEdge = true; + this.isConvexCorner = false; + this.isConcaveCorner = false; + } + + /** + * @param {function(float, float): Point} f + * @returns {Feature} + */ + transformed(f) { + return new Edge(this.cubics.map(c => c.transformed(f))); + } + + /** + * @returns {Feature} + */ + reversed() { + return new Edge(this.cubics.map(c => c.reverse())); + } +} + +class Corner extends Feature { + /** + * @param {Array} cubics + * @param {boolean} convex + */ + constructor(cubics, convex) { + super(cubics); + this.convex = convex; + this.isIgnorableFeature = false; + this.isEdge = false; + this.isConvexCorner = convex; + this.isConcaveCorner = !convex; + } + + /** + * @param {function(float, float): Point} f + * @returns {Feature} + */ + transformed(f) { + return new Corner(this.cubics.map(c => c.transformed(f)), this.convex); + } + + /** + * @returns {Feature} + */ + reversed() { + return new Corner(this.cubics.map(c => c.reverse()), !this.convex); + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/shapes/shapes/float-mapping.js b/modules/quickshell/config/modules/common/widgets/shapes/shapes/float-mapping.js new file mode 100644 index 0000000..dc9c94a --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/shapes/float-mapping.js @@ -0,0 +1,86 @@ +.pragma library +.import "utils.js" as UtilsModule + +var positiveModulo = UtilsModule.positiveModulo; + +/** + * Maps values between two ranges + */ +class DoubleMapper { + constructor(...mappings) { + this.sourceValues = []; + this.targetValues = []; + + for (const mapping of mappings) { + this.sourceValues.push(mapping.a); + this.targetValues.push(mapping.b); + } + } + + /** + * @param {float} x + * @returns {float} + */ + map(x) { + return linearMap(this.sourceValues, this.targetValues, x); + } + + /** + * @param {float} x + * @returns {float} + */ + mapBack(x) { + return linearMap(this.targetValues, this.sourceValues, x); + } +} + +// Static property +DoubleMapper.Identity = new DoubleMapper({ a: 0, b: 0 }, { a: 0.5, b: 0.5 }); + +/** + * @param {Array} xValues + * @param {Array} yValues + * @param {float} x + * @returns {float} + */ +function linearMap(xValues, yValues, x) { + let segmentStartIndex = -1; + for (let i = 0; i < xValues.length; i++) { + const nextIndex = (i + 1) % xValues.length; + if (progressInRange(x, xValues[i], xValues[nextIndex])) { + segmentStartIndex = i; + break; + } + } + + if (segmentStartIndex === -1) { + throw new Error("No valid segment found"); + } + + const segmentEndIndex = (segmentStartIndex + 1) % xValues.length; + const segmentSizeX = positiveModulo(xValues[segmentEndIndex] - xValues[segmentStartIndex], 1); + const segmentSizeY = positiveModulo(yValues[segmentEndIndex] - yValues[segmentStartIndex], 1); + + let positionInSegment; + if (segmentSizeX < 0.001) { + positionInSegment = 0.5; + } else { + positionInSegment = positiveModulo(x - xValues[segmentStartIndex], 1) / segmentSizeX; + } + + return positiveModulo(yValues[segmentStartIndex] + segmentSizeY * positionInSegment, 1); +} + +/** + * @param {float} progress + * @param {float} progressFrom + * @param {float} progressTo + * @returns {boolean} + */ +function progressInRange(progress, progressFrom, progressTo) { + if (progressTo >= progressFrom) { + return progress >= progressFrom && progress <= progressTo; + } else { + return progress >= progressFrom || progress <= progressTo; + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/shapes/shapes/morph.js b/modules/quickshell/config/modules/common/widgets/shapes/shapes/morph.js new file mode 100644 index 0000000..ae149b9 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/shapes/morph.js @@ -0,0 +1,94 @@ +.pragma library + +.import "rounded-polygon.js" as RoundedPolygon +.import "cubic.js" as Cubic +.import "polygon-measure.js" as PolygonMeasure +.import "feature-mapping.js" as FeatureMapping +.import "utils.js" as Utils + +class Morph { + constructor(start, end) { + this.morphMatch = this.match(start, end) + } + + asCubics(progress) { + const ret = [] + + // The first/last mechanism here ensures that the final anchor point in the shape + // exactly matches the first anchor point. There can be rendering artifacts introduced + // by those points being slightly off, even by much less than a pixel + let firstCubic = null + let lastCubic = null + for (let i = 0; i < this.morphMatch.length; i++) { + const cubic = new Cubic.Cubic(Array.from({ length: 8 }).map((_, it) => Utils.interpolate( + this.morphMatch[i].a.points[it], + this.morphMatch[i].b.points[it], + progress, + ))) + if (firstCubic == null) + firstCubic = cubic + if (lastCubic != null) + ret.push(lastCubic) + lastCubic = cubic + } + if (lastCubic != null && firstCubic != null) + ret.push( + new Cubic.Cubic([ + lastCubic.anchor0X, + lastCubic.anchor0Y, + lastCubic.control0X, + lastCubic.control0Y, + lastCubic.control1X, + lastCubic.control1Y, + firstCubic.anchor0X, + firstCubic.anchor0Y, + ]) + ) + return ret + } + + forEachCubic(progress, mutableCubic, callback) { + for (let i = 0; i < this.morphMatch.length; i++) { + mutableCubic.interpolate(this.morphMatch[i].a, this.morphMatch[i].b, progress) + callback(mutableCubic) + } + } + + match(p1, p2) { + const measurer = new PolygonMeasure.LengthMeasurer() + const measuredPolygon1 = PolygonMeasure.MeasuredPolygon.measurePolygon(measurer, p1) + const measuredPolygon2 = PolygonMeasure.MeasuredPolygon.measurePolygon(measurer, p2) + + const features1 = measuredPolygon1.features + const features2 = measuredPolygon2.features + + const doubleMapper = FeatureMapping.featureMapper(features1, features2) + + const polygon2CutPoint = doubleMapper.map(0) + + const bs1 = measuredPolygon1 + const bs2 = measuredPolygon2.cutAndShift(polygon2CutPoint) + + const ret = [] + + let i1 = 0 + let i2 = 0 + + let b1 = bs1.cubics[i1++] + let b2 = bs2.cubics[i2++] + + while (b1 != null && b2 != null) { + const b1a = (i1 == bs1.cubics.length) ? 1 : b1.endOutlineProgress + const b2a = (i2 == bs2.cubics.length) ? 1 : doubleMapper.mapBack(Utils.positiveModulo(b2.endOutlineProgress + polygon2CutPoint, 1)) + const minb = Math.min(b1a, b2a) + const { a: seg1, b: newb1 } = b1a > minb + Utils.AngleEpsilon ? b1.cutAtProgress(minb) : { a: b1, b: bs1.cubics[i1++] } + const { a: seg2, b: newb2 } = b2a > minb + Utils.AngleEpsilon ? b2.cutAtProgress(Utils.positiveModulo(doubleMapper.map(minb) - polygon2CutPoint, 1)) : { a: b2, b: bs2.cubics[i2++] } + + ret.push({ a: seg1.cubic, b: seg2.cubic }) + b1 = newb1 + b2 = newb2 + } + + return ret + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/shapes/shapes/point.js b/modules/quickshell/config/modules/common/widgets/shapes/shapes/point.js new file mode 100644 index 0000000..d301974 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/shapes/point.js @@ -0,0 +1,154 @@ +.pragma library + +/** + * @param {number} x + * @param {number} y + * @returns {Point} + */ +function createPoint(x, y) { + return new Point(x, y); +} + +class Point { + /** + * @param {float} x + * @param {float} y + */ + constructor(x, y) { + this.x = x; + this.y = y; + } + + /** + * @param {float} x + * @param {float} y + * @returns {Point} + */ + copy(x = this.x, y = this.y) { + return new Point(x, y); + } + + /** + * @returns {float} + */ + getDistance() { + return Math.sqrt(this.x * this.x + this.y * this.y); + } + + /** + * @returns {float} + */ + getDistanceSquared() { + return this.x * this.x + this.y * this.y; + } + + /** + * @param {Point} other + * @returns {float} + */ + dotProduct(other) { + return this.x * other.x + this.y * other.y; + } + + /** + * @param {float} otherX + * @param {float} otherY + * @returns {float} + */ + dotProductScalar(otherX, otherY) { + return this.x * otherX + this.y * otherY; + } + + /** + * @param {Point} other + * @returns {boolean} + */ + clockwise(other) { + return this.x * other.y - this.y * other.x > 0; + } + + /** + * @returns {Point} + */ + getDirection() { + const d = this.getDistance(); + return this.div(d); + } + + /** + * @returns {Point} + */ + negate() { + return new Point(-this.x, -this.y); + } + + /** + * @param {Point} other + * @returns {Point} + */ + minus(other) { + return new Point(this.x - other.x, this.y - other.y); + } + + /** + * @param {Point} other + * @returns {Point} + */ + plus(other) { + return new Point(this.x + other.x, this.y + other.y); + } + + /** + * @param {float} operand + * @returns {Point} + */ + times(operand) { + return new Point(this.x * operand, this.y * operand); + } + + /** + * @param {float} operand + * @returns {Point} + */ + div(operand) { + return new Point(this.x / operand, this.y / operand); + } + + /** + * @param {float} operand + * @returns {Point} + */ + rem(operand) { + return new Point(this.x % operand, this.y % operand); + } + + /** + * @param {Point} start + * @param {Point} stop + * @param {float} fraction + * @returns {Point} + */ + static interpolate(start, stop, fraction) { + return new Point( + start.x + (stop.x - start.x) * fraction, + start.y + (stop.y - start.y) * fraction + ); + } + + /** + * @param {function(float, float): Point} f + * @returns {Point} + */ + transformed(f) { + const result = f(this.x, this.y); + return new Point(result.x, result.y); + } + + /** + * @returns {Point} + */ + rotate90() { + return new Point(-this.y, this.x); + } +} + diff --git a/modules/quickshell/config/modules/common/widgets/shapes/shapes/polygon-measure.js b/modules/quickshell/config/modules/common/widgets/shapes/shapes/polygon-measure.js new file mode 100644 index 0000000..bbcd7b4 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/shapes/polygon-measure.js @@ -0,0 +1,192 @@ +.pragma library + +.import "cubic.js" as Cubic +.import "point.js" as Point +.import "feature-mapping.js" as FeatureMapping +.import "utils.js" as Utils +.import "feature.js" as Feature + +class MeasuredPolygon { + constructor(measurer, features, cubics, outlineProgress) { + this.measurer = measurer + this.features = features + this.outlineProgress = outlineProgress + this.cubics = [] + + const measuredCubics = [] + let startOutlineProgress = 0 + for(let i = 0; i < cubics.length; i++) { + if ((outlineProgress[i + 1] - outlineProgress[i]) > Utils.DistanceEpsilon) { + measuredCubics.push( + new MeasuredCubic(this, cubics[i], startOutlineProgress, outlineProgress[i + 1]) + ) + // The next measured cubic will start exactly where this one ends. + startOutlineProgress = outlineProgress[i + 1] + } + } + + measuredCubics[measuredCubics.length - 1].updateProgressRange(measuredCubics[measuredCubics.length - 1].startOutlineProgress, 1) + this.cubics = measuredCubics + } + + cutAndShift(cuttingPoint) { + if (cuttingPoint < Utils.DistanceEpsilon) return this + + // Find the index of cubic we want to cut + const targetIndex = this.cubics.findIndex(it => cuttingPoint >= it.startOutlineProgress && cuttingPoint <= it.endOutlineProgress) + const target = this.cubics[targetIndex] + // Cut the target cubic. + // b1, b2 are two resulting cubics after cut + const { a: b1, b: b2 } = target.cutAtProgress(cuttingPoint) + + // Construct the list of the cubics we need: + // * The second part of the target cubic (after the cut) + // * All cubics after the target, until the end + All cubics from the start, before the + // target cubic + // * The first part of the target cubic (before the cut) + const retCubics = [b2.cubic] + for(let i = 1; i < this.cubics.length; i++) { + retCubics.push(this.cubics[(i + targetIndex) % this.cubics.length].cubic) + } + retCubics.push(b1.cubic) + + // Construct the array of outline progress. + // For example, if we have 3 cubics with outline progress [0 .. 0.3], [0.3 .. 0.8] & + // [0.8 .. 1.0], and we cut + shift at 0.6: + // 0. 0123456789 + // |--|--/-|-| + // The outline progresses will start at 0 (the cutting point, that shifs to 0.0), + // then 0.8 - 0.6 = 0.2, then 1 - 0.6 = 0.4, then 0.3 - 0.6 + 1 = 0.7, + // then 1 (the cutting point again), + // all together: (0.0, 0.2, 0.4, 0.7, 1.0) + const retOutlineProgress = [] + for (let i = 0; i < this.cubics.length + 2; i++) { + if (i === 0) { + retOutlineProgress.push(0) + } else if(i === this.cubics.length + 1) { + retOutlineProgress.push(1) + } else { + const cubicIndex = (targetIndex + i - 1) % this.cubics.length + retOutlineProgress.push(Utils.positiveModulo(this.cubics[cubicIndex].endOutlineProgress - cuttingPoint, 1)) + } + } + + // Shift the feature's outline progress too. + const newFeatures = [] + for(let i = 0; i < this.features.length; i++) { + newFeatures.push(new FeatureMapping.ProgressableFeature(Utils.positiveModulo(this.features[i].progress - cuttingPoint, 1), this.features[i].feature)) + } + + // Filter out all empty cubics (i.e. start and end anchor are (almost) the same point.) + return new MeasuredPolygon(this.measurer, newFeatures, retCubics, retOutlineProgress) + } + + static measurePolygon(measurer, polygon) { + const cubics = [] + const featureToCubic = [] + + for (let featureIndex = 0; featureIndex < polygon.features.length; featureIndex++) { + const feature = polygon.features[featureIndex] + for (let cubicIndex = 0; cubicIndex < feature.cubics.length; cubicIndex++) { + if (feature instanceof Feature.Corner && cubicIndex == feature.cubics.length / 2) { + featureToCubic.push({ a: feature, b: cubics.length }) + } + cubics.push(feature.cubics[cubicIndex]) + } + } + + const measures = [0] // Initialize with 0 like in Kotlin's scan + for (const cubic of cubics) { + const measurement = measurer.measureCubic(cubic) + if (measurement < 0) { + throw new Error("Measured cubic is expected to be greater or equal to zero") + } + const lastMeasure = measures[measures.length - 1] + measures.push(lastMeasure + measurement) + } + const totalMeasure = measures[measures.length - 1] + + const outlineProgress = [] + for (let i = 0; i < measures.length; i++) { + outlineProgress.push(measures[i] / totalMeasure) + } + + const features = [] + for (let i = 0; i < featureToCubic.length; i++) { + const ix = featureToCubic[i].b + features.push( + new FeatureMapping.ProgressableFeature(Utils.positiveModulo((outlineProgress[ix] + outlineProgress[ix + 1]) / 2, 1), featureToCubic[i].a)) + } + + return new MeasuredPolygon(measurer, features, cubics, outlineProgress) + } +} + +class MeasuredCubic { + constructor(polygon, cubic, startOutlineProgress, endOutlineProgress) { + this.polygon = polygon + this.cubic = cubic + this.startOutlineProgress = startOutlineProgress + this.endOutlineProgress = endOutlineProgress + this.measuredSize = this.polygon.measurer.measureCubic(cubic) + } + + updateProgressRange( + startOutlineProgress = this.startOutlineProgress, + endOutlineProgress = this.endOutlineProgress, + ) { + this.startOutlineProgress = startOutlineProgress + this.endOutlineProgress = endOutlineProgress + } + + cutAtProgress(cutOutlineProgress) { + const boundedCutOutlineProgress = Utils.coerceIn(cutOutlineProgress, this.startOutlineProgress, this.endOutlineProgress) + const outlineProgressSize = this.endOutlineProgress - this.startOutlineProgress + const progressFromStart = boundedCutOutlineProgress - this.startOutlineProgress + + const relativeProgress = progressFromStart / outlineProgressSize + const t = this.polygon.measurer.findCubicCutPoint(this.cubic, relativeProgress * this.measuredSize) + + const {a: c1, b: c2} = this.cubic.split(t) + return { + a: new MeasuredCubic(this.polygon, c1, this.startOutlineProgress, boundedCutOutlineProgress), + b: new MeasuredCubic(this.polygon, c2, boundedCutOutlineProgress, this.endOutlineProgress) + } + } +} + +class LengthMeasurer { + constructor() { + this.segments = 3 + } + + measureCubic(c) { + return this.closestProgressTo(c, Number.POSITIVE_INFINITY).y + } + + findCubicCutPoint(c, m) { + return this.closestProgressTo(c, m).x + } + + closestProgressTo(cubic, threshold) { + let total = 0 + let remainder = threshold + let prev = new Point.Point(cubic.anchor0X, cubic.anchor0Y) + + for (let i = 1; i < this.segments; i++) { + const progress = i / this.segments + const point = cubic.pointOnCurve(progress) + const segment = point.minus(prev).getDistance() + + if (segment >= remainder) { + return new Point.Point(progress - (1.0 - remainder / segment) / this.segments, threshold) + } + + remainder -= segment + total += segment + prev = point + } + + return new Point.Point(1.0, total) + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/shapes/shapes/rounded-corner.js b/modules/quickshell/config/modules/common/widgets/shapes/shapes/rounded-corner.js new file mode 100644 index 0000000..f2d7b3c --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/shapes/rounded-corner.js @@ -0,0 +1,229 @@ +.pragma library +.import "point.js" as PointModule +.import "corner-rounding.js" as RoundingModule +.import "utils.js" as UtilsModule +.import "cubic.js" as CubicModule + +var Point = PointModule.Point; +var CornerRounding = RoundingModule.CornerRounding; +var DistanceEpsilon = UtilsModule.DistanceEpsilon; +var directionVector = UtilsModule.directionVector; +var Cubic = CubicModule.Cubic; + +class RoundedCorner { + /** + * @param {Point} p0 + * @param {Point} p1 + * @param {Point} p2 + * @param {CornerRounding} [rounding=null] + */ + constructor(p0, p1, p2, rounding = null) { + this.p0 = p0; + this.p1 = p1; + this.p2 = p2; + this.rounding = rounding; + this.center = new Point(0, 0); + + const v01 = p0.minus(p1); + const v21 = p2.minus(p1); + const d01 = v01.getDistance(); + const d21 = v21.getDistance(); + + if (d01 > 0 && d21 > 0) { + this.d1 = v01.div(d01); + this.d2 = v21.div(d21); + this.cornerRadius = rounding?.radius ?? 0; + this.smoothing = rounding?.smoothing ?? 0; + + // cosine of angle at p1 is dot product of unit vectors to the other two vertices + this.cosAngle = this.d1.dotProduct(this.d2); + + // identity: sin^2 + cos^2 = 1 + // sinAngle gives us the intersection + this.sinAngle = Math.sqrt(1 - Math.pow(this.cosAngle, 2)); + + // How much we need to cut, as measured on a side, to get the required radius + // calculating where the rounding circle hits the edge + // This uses the identity of tan(A/2) = sinA/(1 + cosA), where tan(A/2) = radius/cut + this.expectedRoundCut = this.sinAngle > 1e-3 ? this.cornerRadius * (this.cosAngle + 1) / this.sinAngle : 0; + } else { + // One (or both) of the sides is empty, not much we can do. + this.d1 = new Point(0, 0); + this.d2 = new Point(0, 0); + this.cornerRadius = 0; + this.smoothing = 0; + this.cosAngle = 0; + this.sinAngle = 0; + this.expectedRoundCut = 0; + } + } + + get expectedCut() { + return ((1 + this.smoothing) * this.expectedRoundCut); + } + + /** + * @param {float} allowedCut0 + * @param {float} [allowedCut1] + * @returns {Array} + */ + getCubics(allowedCut0, allowedCut1 = allowedCut0) { + // We use the minimum of both cuts to determine the radius, but if there is more space + // in one side we can use it for smoothing. + const allowedCut = Math.min(allowedCut0, allowedCut1); + + // Nothing to do, just use lines, or a point + if ( + this.expectedRoundCut < DistanceEpsilon || + allowedCut < DistanceEpsilon || + this.cornerRadius < DistanceEpsilon + ) { + this.center = this.p1; + return [Cubic.straightLine(this.p1.x, this.p1.y, this.p1.x, this.p1.y)]; + } + + // How much of the cut is required for the rounding part. + const actualRoundCut = Math.min(allowedCut, this.expectedRoundCut); + + // We have two smoothing values, one for each side of the vertex + // Space is used for rounding values first. If there is space left over, then we + // apply smoothing, if it was requested + const actualSmoothing0 = this.calculateActualSmoothingValue(allowedCut0); + const actualSmoothing1 = this.calculateActualSmoothingValue(allowedCut1); + + // Scale the radius if needed + const actualR = this.cornerRadius * actualRoundCut / this.expectedRoundCut; + + // Distance from the corner (p1) to the center + const centerDistance = Math.sqrt(Math.pow(actualR, 2) + Math.pow(actualRoundCut, 2)); + + // Center of the arc we will use for rounding + this.center = this.p1.plus(this.d1.plus(this.d2).div(2).getDirection().times(centerDistance)); + + const circleIntersection0 = this.p1.plus(this.d1.times(actualRoundCut)); + const circleIntersection2 = this.p1.plus(this.d2.times(actualRoundCut)); + + const flanking0 = this.computeFlankingCurve( + actualRoundCut, + actualSmoothing0, + this.p1, + this.p0, + circleIntersection0, + circleIntersection2, + this.center, + actualR + ); + + const flanking2 = this.computeFlankingCurve( + actualRoundCut, + actualSmoothing1, + this.p1, + this.p2, + circleIntersection2, + circleIntersection0, + this.center, + actualR + ).reverse(); + + return [ + flanking0, + Cubic.circularArc( + this.center.x, + this.center.y, + flanking0.anchor1X, + flanking0.anchor1Y, + flanking2.anchor0X, + flanking2.anchor0Y + ), + flanking2 + ]; + } + + /** + * @private + * @param {float} allowedCut + * @returns {float} + */ + calculateActualSmoothingValue(allowedCut) { + if (allowedCut > this.expectedCut) { + return this.smoothing; + } else if (allowedCut > this.expectedRoundCut) { + return this.smoothing * (allowedCut - this.expectedRoundCut) / (this.expectedCut - this.expectedRoundCut); + } else { + return 0; + } + } + + /** + * @private + * @param {float} actualRoundCut + * @param {float} actualSmoothingValues + * @param {Point} corner + * @param {Point} sideStart + * @param {Point} circleSegmentIntersection + * @param {Point} otherCircleSegmentIntersection + * @param {Point} circleCenter + * @param {float} actualR + * @returns {Cubic} + */ + computeFlankingCurve( + actualRoundCut, + actualSmoothingValues, + corner, + sideStart, + circleSegmentIntersection, + otherCircleSegmentIntersection, + circleCenter, + actualR + ) { + // sideStart is the anchor, 'anchor' is actual control point + const sideDirection = (sideStart.minus(corner)).getDirection(); + const curveStart = corner.plus(sideDirection.times(actualRoundCut * (1 + actualSmoothingValues))); + + // We use an approximation to cut a part of the circle section proportional to 1 - smooth, + // When smooth = 0, we take the full section, when smooth = 1, we take nothing. + const p = Point.interpolate( + circleSegmentIntersection, + (circleSegmentIntersection.plus(otherCircleSegmentIntersection)).div(2), + actualSmoothingValues + ); + + // The flanking curve ends on the circle + const curveEnd = circleCenter.plus( + directionVector(p.x - circleCenter.x, p.y - circleCenter.y).times(actualR) + ); + + // The anchor on the circle segment side is in the intersection between the tangent to the + // circle in the circle/flanking curve boundary and the linear segment. + const circleTangent = (curveEnd.minus(circleCenter)).rotate90(); + const anchorEnd = this.lineIntersection(sideStart, sideDirection, curveEnd, circleTangent) ?? circleSegmentIntersection; + + // From what remains, we pick a point for the start anchor. + // 2/3 seems to come from design tools? + const anchorStart = (curveStart.plus(anchorEnd.times(2))).div(3); + + return Cubic.create(curveStart, anchorStart, anchorEnd, curveEnd); + } + + /** + * @private + * @param {Point} p0 + * @param {Point} d0 + * @param {Point} p1 + * @param {Point} d1 + * @returns {Point|null} + */ + lineIntersection(p0, d0, p1, d1) { + const rotatedD1 = d1.rotate90(); + const den = d0.dotProduct(rotatedD1); + if (Math.abs(den) < DistanceEpsilon) return null; + + const num = (p1.minus(p0)).dotProduct(rotatedD1); + // Also check the relative value. This is equivalent to abs(den/num) < DistanceEpsilon, + // but avoid doing a division + if (Math.abs(den) < DistanceEpsilon * Math.abs(num)) return null; + + const k = num / den; + return p0.plus(d0.times(k)); + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/shapes/shapes/rounded-polygon.js b/modules/quickshell/config/modules/common/widgets/shapes/shapes/rounded-polygon.js new file mode 100644 index 0000000..9815875 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/shapes/rounded-polygon.js @@ -0,0 +1,343 @@ +.pragma library + +.import "feature.js" as Feature +.import "point.js" as Point +.import "cubic.js" as Cubic +.import "utils.js" as Utils +.import "corner-rounding.js" as CornerRounding +.import "rounded-corner.js" as RoundedCorner + +class RoundedPolygon { + constructor(features, center) { + this.features = features + this.center = center + this.cubics = this.buildCubicList() + } + + get centerX() { + return this.center.x + } + + get centerY() { + return this.center.y + } + + transformed(f) { + const center = this.center.transformed(f) + return new RoundedPolygon(this.features.map(x => x.transformed(f)), center) + } + + normalized() { + const bounds = this.calculateBounds() + const width = bounds[2] - bounds[0] + const height = bounds[3] - bounds[1] + const side = Math.max(width, height) + // Center the shape if bounds are not a square + const offsetX = (side - width) / 2 - bounds[0] /* left */ + const offsetY = (side - height) / 2 - bounds[1] /* top */ + return this.transformed((x, y) => { + return new Point.Point((x + offsetX) / side, (y + offsetY) / side) + }) + } + + calculateMaxBounds(bounds = []) { + let maxDistSquared = 0 + for (let i = 0; i < this.cubics.length; i++) { + const cubic = this.cubics[i] + const anchorDistance = Utils.distanceSquared(cubic.anchor0X - this.centerX, cubic.anchor0Y - this.centerY) + const middlePoint = cubic.pointOnCurve(.5) + const middleDistance = Utils.distanceSquared(middlePoint.x - this.centerX, middlePoint.y - this.centerY) + maxDistSquared = Math.max(maxDistSquared, Math.max(anchorDistance, middleDistance)) + } + const distance = Math.sqrt(maxDistSquared) + bounds[0] = this.centerX - distance + bounds[1] = this.centerY - distance + bounds[2] = this.centerX + distance + bounds[3] = this.centerY + distance + return bounds + } + + calculateBounds(bounds = [], approximate = true) { + let minX = Number.MAX_SAFE_INTEGER + let minY = Number.MAX_SAFE_INTEGER + let maxX = Number.MIN_SAFE_INTEGER + let maxY = Number.MIN_SAFE_INTEGER + for (let i = 0; i < this.cubics.length; i++) { + const cubic = this.cubics[i] + cubic.calculateBounds(bounds, approximate) + minX = Math.min(minX, bounds[0]) + minY = Math.min(minY, bounds[1]) + maxX = Math.max(maxX, bounds[2]) + maxY = Math.max(maxY, bounds[3]) + } + bounds[0] = minX + bounds[1] = minY + bounds[2] = maxX + bounds[3] = maxY + return bounds + } + + buildCubicList() { + const result = [] + + // The first/last mechanism here ensures that the final anchor point in the shape + // exactly matches the first anchor point. There can be rendering artifacts introduced + // by those points being slightly off, even by much less than a pixel + let firstCubic = null + let lastCubic = null + let firstFeatureSplitStart = null + let firstFeatureSplitEnd = null + + if (this.features.length > 0 && this.features[0].cubics.length == 3) { + const centerCubic = this.features[0].cubics[1] + const { a: start, b: end } = centerCubic.split(.5) + firstFeatureSplitStart = [this.features[0].cubics[0], start] + firstFeatureSplitEnd = [end, this.features[0].cubics[2]] + } + + // iterating one past the features list size allows us to insert the initial split + // cubic if it exists + for (let i = 0; i <= this.features.length; i++) { + let featureCubics + if (i == 0 && firstFeatureSplitEnd != null) { + featureCubics = firstFeatureSplitEnd + } else if (i == this.features.length) { + if (firstFeatureSplitStart != null) { + featureCubics = firstFeatureSplitStart + } else { + break + } + } else { + featureCubics = this.features[i].cubics + } + + for (let j = 0; j < featureCubics.length; j++) { + // Skip zero-length curves; they add nothing and can trigger rendering artifacts + const cubic = featureCubics[j] + if (!cubic.zeroLength()) { + if (lastCubic != null) + result.push(lastCubic) + lastCubic = cubic + if (firstCubic == null) + firstCubic = cubic + } else { + if (lastCubic != null) { + // Dropping several zero-ish length curves in a row can lead to + // enough discontinuity to throw an exception later, even though the + // distances are quite small. Account for that by making the last + // cubic use the latest anchor point, always. + lastCubic = new Cubic.Cubic([...lastCubic.points]) // Make a copy before mutating + lastCubic.points[6] = cubic.anchor1X + lastCubic.points[7] = cubic.anchor1Y + } + } + } + } + if (lastCubic != null && firstCubic != null) { + result.push( + new Cubic.Cubic([ + lastCubic.anchor0X, + lastCubic.anchor0Y, + lastCubic.control0X, + lastCubic.control0Y, + lastCubic.control1X, + lastCubic.control1Y, + firstCubic.anchor0X, + firstCubic.anchor0Y, + ]) + ) + } else { + // Empty / 0-sized polygon. + result.push(new Cubic.Cubic([this.centerX, this.centerY, this.centerX, this.centerY, this.centerX, this.centerY, this.centerX, this.centerY])) + } + + return result + } + + static calculateCenter(vertices) { + let cumulativeX = 0 + let cumulativeY = 0 + let index = 0 + while (index < vertices.length) { + cumulativeX += vertices[index++] + cumulativeY += vertices[index++] + } + return new Point.Point(cumulativeX / (vertices.length / 2), cumulativeY / (vertices.length / 2)) + } + + static verticesFromNumVerts(numVertices, radius, centerX, centerY) { + const result = [] + let arrayIndex = 0 + for (let i = 0; i < numVertices; i++) { + const vertex = Utils.radialToCartesian(radius, (Math.PI / numVertices * 2 * i)).plus(new Point.Point(centerX, centerY)) + result[arrayIndex++] = vertex.x + result[arrayIndex++] = vertex.y + } + return result + } + + static fromNumVertices(numVertices, radius = 1, centerX = 0, centerY = 0, rounding = CornerRounding.Unrounded, perVertexRounding = null) { + return RoundedPolygon.fromVertices(this.verticesFromNumVerts(numVertices, radius, centerX, centerY), rounding, perVertexRounding, centerX, centerY) + } + + static fromVertices(vertices, rounding = CornerRounding.Unrounded, perVertexRounding = null, centerX = Number.MIN_SAFE_INTEGER, centerY = Number.MAX_SAFE_INTEGER) { + const corners = [] + const n = vertices.length / 2 + const roundedCorners = [] + for (let i = 0; i < n; i++) { + const vtxRounding = perVertexRounding?.[i] ?? rounding + const prevIndex = ((i + n - 1) % n) * 2 + const nextIndex = ((i + 1) % n) * 2 + roundedCorners.push( + new RoundedCorner.RoundedCorner( + new Point.Point(vertices[prevIndex], vertices[prevIndex + 1]), + new Point.Point(vertices[i * 2], vertices[i * 2 + 1]), + new Point.Point(vertices[nextIndex], vertices[nextIndex + 1]), + vtxRounding + ) + ) + } + + // For each side, check if we have enough space to do the cuts needed, and if not split + // the available space, first for round cuts, then for smoothing if there is space left. + // Each element in this list is a pair, that represent how much we can do of the cut for + // the given side (side i goes from corner i to corner i+1), the elements of the pair are: + // first is how much we can use of expectedRoundCut, second how much of expectedCut + const cutAdjusts = Array.from({ length: n }).map((_, ix) => { + const expectedRoundCut = roundedCorners[ix].expectedRoundCut + roundedCorners[(ix + 1) % n].expectedRoundCut + const expectedCut = roundedCorners[ix].expectedCut + roundedCorners[(ix + 1) % n].expectedCut + const vtxX = vertices[ix * 2] + const vtxY = vertices[ix * 2 + 1] + const nextVtxX = vertices[((ix + 1) % n) * 2] + const nextVtxY = vertices[((ix + 1) % n) * 2 + 1] + const sideSize = Utils.distance(vtxX - nextVtxX, vtxY - nextVtxY) + + // Check expectedRoundCut first, and ensure we fulfill rounding needs first for + // both corners before using space for smoothing + if (expectedRoundCut > sideSize) { + // Not enough room for fully rounding, see how much we can actually do. + return { a: sideSize / expectedRoundCut, b: 0 } + } else if (expectedCut > sideSize) { + // We can do full rounding, but not full smoothing. + return { a: 1, b: (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut) } + } else { + // There is enough room for rounding & smoothing. + return { a: 1, b: 1 } + } + }) + + // Create and store list of beziers for each [potentially] rounded corner + for (let i = 0; i < n; i++) { + // allowedCuts[0] is for the side from the previous corner to this one, + // allowedCuts[1] is for the side from this corner to the next one. + const allowedCuts = [] + for(const delta of [0, 1]) { + const { a: roundCutRatio, b: cutRatio } = cutAdjusts[(i + n - 1 + delta) % n] + allowedCuts.push( + roundedCorners[i].expectedRoundCut * roundCutRatio + + (roundedCorners[i].expectedCut - roundedCorners[i].expectedRoundCut) * cutRatio + ) + } + corners.push( + roundedCorners[i].getCubics(allowedCuts[0], allowedCuts[1]) + ) + } + + const tempFeatures = [] + for (let i = 0; i < n; i++) { + // Note that these indices are for pairs of values (points), they need to be + // doubled to access the xy values in the vertices float array + const prevVtxIndex = (i + n - 1) % n + const nextVtxIndex = (i + 1) % n + const currVertex = new Point.Point(vertices[i * 2], vertices[i * 2 + 1]) + const prevVertex = new Point.Point(vertices[prevVtxIndex * 2], vertices[prevVtxIndex * 2 + 1]) + const nextVertex = new Point.Point(vertices[nextVtxIndex * 2], vertices[nextVtxIndex * 2 + 1]) + const cnvx = Utils.convex(prevVertex, currVertex, nextVertex) + tempFeatures.push(new Feature.Corner(corners[i], cnvx)) + tempFeatures.push( + new Feature.Edge([Cubic.Cubic.straightLine( + corners[i][corners[i].length - 1].anchor1X, + corners[i][corners[i].length - 1].anchor1Y, + corners[(i + 1) % n][0].anchor0X, + corners[(i + 1) % n][0].anchor0Y, + )]) + ) + } + + let center + if (centerX == Number.MIN_SAFE_INTEGER || centerY == Number.MIN_SAFE_INTEGER) { + center = RoundedPolygon.calculateCenter(vertices) + } else { + center = new Point.Point(centerX, centerY) + } + + return RoundedPolygon.fromFeatures(tempFeatures, center.x, center.y) + } + + static fromFeatures(features, centerX, centerY) { + const vertices = [] + for (const feature of features) { + for (const cubic of feature.cubics) { + vertices.push(cubic.anchor0X) + vertices.push(cubic.anchor0Y) + } + } + + if (Number.isNaN(centerX)) { + centerX = this.calculateCenter(vertices).x + } + if (Number.isNaN(centerY)) { + centerY = this.calculateCenter(vertices).y + } + + return new RoundedPolygon(features, new Point.Point(centerX, centerY)) + } + + static circle(numVertices = 8, radius = 1, centerX = 0, centerY = 0) { + // Half of the angle between two adjacent vertices on the polygon + const theta = Math.PI / numVertices + // Radius of the underlying RoundedPolygon object given the desired radius of the circle + const polygonRadius = radius / Math.cos(theta) + return RoundedPolygon.fromNumVertices( + numVertices, + polygonRadius, + centerX, + centerY, + new CornerRounding.CornerRounding(radius) + ) + } + + static rectangle(width, height, rounding = CornerRounding.Unrounded, perVertexRounding = null, centerX = 0, centerY = 0) { + const left = centerX - width / 2 + const top = centerY - height / 2 + const right = centerX + width / 2 + const bottom = centerY + height / 2 + + return RoundedPolygon.fromVertices([right, bottom, left, bottom, left, top, right, top], rounding, perVertexRounding, centerX, centerY) + } + + static star(numVerticesPerRadius, radius = 1, innerRadius = .5, rounding = CornerRounding.Unrounded, innerRounding = null, perVertexRounding = null, centerX = 0, centerY = 0) { + let pvRounding = perVertexRounding + // If no per-vertex rounding supplied and caller asked for inner rounding, + // create per-vertex rounding list based on supplied outer/inner rounding parameters + if (pvRounding == null && innerRounding != null) { + pvRounding = Array.from({ length: numVerticesPerRadius * 2 }).flatMap(() => [rounding, innerRounding]) + } + + return RoundedPolygon.fromVertices(RoundedPolygon.starVerticesFromNumVerts(numVerticesPerRadius, radius, innerRadius, centerX, centerY), rounding, perVertexRounding, centerX, centerY) + } + + static starVerticesFromNumVerts(numVerticesPerRadius, radius, innerRadius, centerX, centerY) { + const result = [] + let arrayIndex = 0 + for (let i = 0; i < numVerticesPerRadius; i++) { + let vertex = Utils.radialToCartesian(radius, (Math.PI / numVerticesPerRadius * 2 * i)) + result[arrayIndex++] = vertex.x + centerX + result[arrayIndex++] = vertex.y + centerY + vertex = Utils.radialToCartesian(innerRadius, (Math.PI / numVerticesPerRadius * (2 * i + 1))) + result[arrayIndex++] = vertex.x + centerX + result[arrayIndex++] = vertex.y + centerY + } + return result + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/common/widgets/shapes/shapes/utils.js b/modules/quickshell/config/modules/common/widgets/shapes/shapes/utils.js new file mode 100644 index 0000000..17b56f6 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/shapes/shapes/utils.js @@ -0,0 +1,94 @@ +.pragma library +.import "point.js" as PointModule + +var Point = PointModule.Point; +var DistanceEpsilon = 1e-4; +var AngleEpsilon = 1e-6; + +/** + * @param {Point} previous + * @param {Point} current + * @param {Point} next + * @returns {boolean} + */ +function convex(previous, current, next) { + return (current.minus(previous)).clockwise(next.minus(current)); +} + +/** + * @param {float} start + * @param {float} stop + * @param {float} fraction + * @returns {float} + */ +function interpolate(start, stop, fraction) { + return (1 - fraction) * start + fraction * stop; +} + +/** + * @param {float} x + * @param {float} y + * @returns {Point} + */ +function directionVector(x, y) { + const d = distance(x, y); + return new Point(x / d, y / d); +} + +/** + * @param {float} x + * @param {float} y + * @returns {float} + */ +function distance(x, y) { + return Math.sqrt(x * x + y * y); +} + +/** + * @param {float} x + * @param {float} y + * @returns {float} + */ +function distanceSquared(x, y) { + return x * x + y * y; +} + +/** + * @param {float} radius + * @param {float} angleRadians + * @param {Point} [center] + * @returns {Point} + */ +function radialToCartesian(radius, angleRadians, center = new Point(0, 0)) { + return new Point(Math.cos(angleRadians), Math.sin(angleRadians)) + .times(radius) + .plus(center); +} + +/** + * @param {float} value + * @param {float|object} min + * @param {float} [max] + * @returns {float} + */ +function coerceIn(value, min, max) { + if (max === undefined) { + if (typeof min === 'object' && 'start' in min && 'endInclusive' in min) { + return Math.max(min.start, Math.min(min.endInclusive, value)); + } + throw new Error("Invalid arguments for coerceIn"); + } + + const [actualMin, actualMax] = min <= max ? [min, max] : [max, min]; + return Math.max(actualMin, Math.min(actualMax, value)); +} + +/** + * @param {float} value + * @param {float} mod + * @returns {float} + */ +function positiveModulo(value, mod) { + return ((value % mod) + mod) % mod; +} + diff --git a/modules/quickshell/config/modules/common/widgets/widgetCanvas/AbstractOverlayWidget.qml b/modules/quickshell/config/modules/common/widgets/widgetCanvas/AbstractOverlayWidget.qml new file mode 100644 index 0000000..10ce0f5 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/widgetCanvas/AbstractOverlayWidget.qml @@ -0,0 +1,13 @@ +import QtQuick +import Quickshell +import qs.modules.common + +/* + * Abstract widgets for an overlay. Doesn't contain any visuals. + */ +AbstractWidget { + id: root + + property bool pinned: false // Whether to stay visible when the overlay is dismissed + property bool clickthrough: true // When pinned, whether to allow clicks go through +} diff --git a/modules/quickshell/config/modules/common/widgets/widgetCanvas/AbstractWidget.qml b/modules/quickshell/config/modules/common/widgets/widgetCanvas/AbstractWidget.qml new file mode 100644 index 0000000..ee97c71 --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/widgetCanvas/AbstractWidget.qml @@ -0,0 +1,30 @@ +import QtQuick +import Quickshell +import qs.modules.common + +/* + * Widget to be placed on a WidgetCanvas + */ +MouseArea { + id: root + + property alias animateXPos: xBehavior.enabled + property alias animateYPos: yBehavior.enabled + property bool draggable: true + drag.target: draggable ? root : undefined + cursorShape: (draggable && containsPress) ? Qt.ClosedHandCursor : draggable ? Qt.OpenHandCursor : Qt.ArrowCursor + + function center() { + root.x = (root.parent.width - root.width) / 2 + root.y = (root.parent.height - root.height) / 2 + } + + Behavior on x { + id: xBehavior + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on y { + id: yBehavior + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } +} diff --git a/modules/quickshell/config/modules/common/widgets/widgetCanvas/WidgetCanvas.qml b/modules/quickshell/config/modules/common/widgets/widgetCanvas/WidgetCanvas.qml new file mode 100644 index 0000000..d348ffa --- /dev/null +++ b/modules/quickshell/config/modules/common/widgets/widgetCanvas/WidgetCanvas.qml @@ -0,0 +1,7 @@ +import QtQuick + +MouseArea { + id: root + + // uh this is stupid turns out we don't need anything here +} diff --git a/modules/quickshell/config/modules/ii/background/Background.qml b/modules/quickshell/config/modules/ii/background/Background.qml new file mode 100644 index 0000000..68d8b16 --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/Background.qml @@ -0,0 +1,298 @@ +pragma ComponentBehavior: Bound + +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.widgets.widgetCanvas +import qs.modules.common.functions as CF +import QtQuick +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +import qs.modules.ii.background.widgets +import qs.modules.ii.background.widgets.clock +import qs.modules.ii.background.widgets.weather + +Variants { + id: root + model: Quickshell.screens + + PanelWindow { + id: bgRoot + + required property var modelData + + // Hide when fullscreen + property list workspacesForMonitor: Hyprland.workspaces.values.filter(workspace => workspace.monitor && workspace.monitor.name == monitor.name) + property var activeWorkspaceWithFullscreen: workspacesForMonitor.filter(workspace => ((workspace.toplevels.values.filter(window => window.wayland?.fullscreen)[0] != undefined) && workspace.active))[0] + visible: GlobalStates.screenLocked || (!(activeWorkspaceWithFullscreen != undefined)) || !Config?.options.background.hideWhenFullscreen + + // Workspaces + property HyprlandMonitor monitor: Hyprland.monitorFor(modelData) + property list relevantWindows: HyprlandData.windowList.filter(win => win.monitor == monitor?.id && win.workspace.id >= 0).sort((a, b) => a.workspace.id - b.workspace.id) + property int firstWorkspaceId: relevantWindows[0]?.workspace.id || 1 + property int lastWorkspaceId: relevantWindows[relevantWindows.length - 1]?.workspace.id || 10 + // Wallpaper + property bool wallpaperIsVideo: Config.options.background.wallpaperPath.endsWith(".mp4") || Config.options.background.wallpaperPath.endsWith(".webm") || Config.options.background.wallpaperPath.endsWith(".mkv") || Config.options.background.wallpaperPath.endsWith(".avi") || Config.options.background.wallpaperPath.endsWith(".mov") + property string wallpaperPath: wallpaperIsVideo ? Config.options.background.thumbnailPath : Config.options.background.wallpaperPath + property bool wallpaperSafetyTriggered: { + const enabled = Config.options.workSafety.enable.wallpaper; + const sensitiveWallpaper = (CF.StringUtils.stringListContainsSubstring(wallpaperPath.toLowerCase(), Config.options.workSafety.triggerCondition.fileKeywords)); + const sensitiveNetwork = (CF.StringUtils.stringListContainsSubstring(Network.networkName.toLowerCase(), Config.options.workSafety.triggerCondition.networkNameKeywords)); + return enabled && sensitiveWallpaper && sensitiveNetwork; + } + property real wallpaperToScreenRatio: Math.min(wallpaperWidth / screen.width, wallpaperHeight / screen.height) + property real preferredWallpaperScale: Config.options.background.parallax.workspaceZoom + property real effectiveWallpaperScale: 1 // Some reasonable init value, to be updated + property int wallpaperWidth: modelData.width // Some reasonable init value, to be updated + property int wallpaperHeight: modelData.height // Some reasonable init value, to be updated + property real movableXSpace: ((wallpaperWidth / wallpaperToScreenRatio * effectiveWallpaperScale) - screen.width) / 2 + property real movableYSpace: ((wallpaperHeight / wallpaperToScreenRatio * effectiveWallpaperScale) - screen.height) / 2 + readonly property bool verticalParallax: (Config.options.background.parallax.autoVertical && wallpaperHeight > wallpaperWidth) || Config.options.background.parallax.vertical + // Colors + property bool shouldBlur: (GlobalStates.screenLocked && Config.options.lock.blur.enable) + property color dominantColor: Appearance.colors.colPrimary // Default, to be changed + property bool dominantColorIsDark: dominantColor.hslLightness < 0.5 + property color colText: { + if (wallpaperSafetyTriggered) + return CF.ColorUtils.mix(Appearance.colors.colOnLayer0, Appearance.colors.colPrimary, 0.75); + return (GlobalStates.screenLocked && shouldBlur) ? Appearance.colors.colOnLayer0 : CF.ColorUtils.colorWithLightness(Appearance.colors.colPrimary, (dominantColorIsDark ? 0.8 : 0.12)); + } + Behavior on colText { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + // Layer props + screen: modelData + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: (GlobalStates.screenLocked && !scaleAnim.running) ? WlrLayer.Overlay : WlrLayer.Bottom + // WlrLayershell.layer: WlrLayer.Bottom + WlrLayershell.namespace: "quickshell:background" + anchors { + top: true + bottom: true + left: true + right: true + } + color: { + if (!bgRoot.wallpaperSafetyTriggered || bgRoot.wallpaperIsVideo) + return "transparent"; + return CF.ColorUtils.mix(Appearance.colors.colLayer0, Appearance.colors.colPrimary, 0.75); + } + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + onWallpaperPathChanged: { + bgRoot.updateZoomScale(); + // Clock position gets updated after zoom scale is updated + } + + // Wallpaper zoom scale + function updateZoomScale() { + getWallpaperSizeProc.path = bgRoot.wallpaperPath; + getWallpaperSizeProc.running = true; + } + Process { + id: getWallpaperSizeProc + property string path: bgRoot.wallpaperPath + command: ["magick", "identify", "-format", "%w %h", path] + stdout: StdioCollector { + id: wallpaperSizeOutputCollector + onStreamFinished: { + const output = wallpaperSizeOutputCollector.text; + const [width, height] = output.split(" ").map(Number); + const [screenWidth, screenHeight] = [bgRoot.screen.width, bgRoot.screen.height]; + bgRoot.wallpaperWidth = width; + bgRoot.wallpaperHeight = height; + + if (width <= screenWidth || height <= screenHeight) { + // Undersized/perfectly sized wallpapers + bgRoot.effectiveWallpaperScale = Math.max(screenWidth / width, screenHeight / height); + } else { + // Oversized = can be zoomed for parallax, yay + bgRoot.effectiveWallpaperScale = Math.min(bgRoot.preferredWallpaperScale, width / screenWidth, height / screenHeight); + } + } + } + } + + Item { + anchors.fill: parent + clip: true + + // Wallpaper + StyledImage { + id: wallpaper + visible: opacity > 0 && !blurLoader.active + opacity: (status === Image.Ready && !bgRoot.wallpaperIsVideo) ? 1 : 0 + cache: false + smooth: false + // Range = groups that workspaces span on + property int chunkSize: Config?.options.bar.workspaces.shown ?? 10 + property int lower: Math.floor(bgRoot.firstWorkspaceId / chunkSize) * chunkSize + property int upper: Math.ceil(bgRoot.lastWorkspaceId / chunkSize) * chunkSize + property int range: upper - lower + property real valueX: { + let result = 0.5; + if (Config.options.background.parallax.enableWorkspace && !bgRoot.verticalParallax) { + result = ((bgRoot.monitor.activeWorkspace?.id - lower) / range); + } + if (Config.options.background.parallax.enableSidebar) { + result += (0.15 * GlobalStates.sidebarRightOpen - 0.15 * GlobalStates.sidebarLeftOpen); + } + return result; + } + property real valueY: { + let result = 0.5; + if (Config.options.background.parallax.enableWorkspace && bgRoot.verticalParallax) { + result = ((bgRoot.monitor.activeWorkspace?.id - lower) / range); + } + return result; + } + property real effectiveValueX: Math.max(0, Math.min(1, valueX)) + property real effectiveValueY: Math.max(0, Math.min(1, valueY)) + x: -(bgRoot.movableXSpace) - (effectiveValueX - 0.5) * 2 * bgRoot.movableXSpace + y: -(bgRoot.movableYSpace) - (effectiveValueY - 0.5) * 2 * bgRoot.movableYSpace + source: bgRoot.wallpaperSafetyTriggered ? "" : bgRoot.wallpaperPath + fillMode: Image.PreserveAspectCrop + Behavior on x { + NumberAnimation { + duration: 600 + easing.type: Easing.OutCubic + } + } + Behavior on y { + NumberAnimation { + duration: 600 + easing.type: Easing.OutCubic + } + } + sourceSize { + width: bgRoot.screen.width * bgRoot.effectiveWallpaperScale * bgRoot.monitor.scale + height: bgRoot.screen.height * bgRoot.effectiveWallpaperScale * bgRoot.monitor.scale + } + width: bgRoot.wallpaperWidth / bgRoot.wallpaperToScreenRatio * bgRoot.effectiveWallpaperScale + height: bgRoot.wallpaperHeight / bgRoot.wallpaperToScreenRatio * bgRoot.effectiveWallpaperScale + } + + Loader { + id: blurLoader + active: Config.options.lock.blur.enable && (GlobalStates.screenLocked || scaleAnim.running) + anchors.fill: wallpaper + scale: GlobalStates.screenLocked ? Config.options.lock.blur.extraZoom : 1 + Behavior on scale { + NumberAnimation { + id: scaleAnim + duration: 400 + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animationCurves.expressiveDefaultSpatial + } + } + sourceComponent: GaussianBlur { + source: wallpaper + radius: GlobalStates.screenLocked ? Config.options.lock.blur.radius : 0 + samples: radius * 2 + 1 + + Rectangle { + opacity: GlobalStates.screenLocked ? 1 : 0 + anchors.fill: parent + color: CF.ColorUtils.transparentize(Appearance.colors.colLayer0, 0.7) + } + } + } + + WidgetCanvas { + id: widgetCanvas + anchors { + left: wallpaper.left + right: wallpaper.right + top: wallpaper.top + bottom: wallpaper.bottom + horizontalCenter: undefined + verticalCenter: undefined + readonly property real parallaxFactor: Config.options.background.parallax.widgetsFactor + leftMargin: { + const xOnWallpaper = bgRoot.movableXSpace; + const extraMove = (wallpaper.effectiveValueX * 2 * bgRoot.movableXSpace) * (parallaxFactor - 1); + return xOnWallpaper - extraMove; + } + topMargin: { + const yOnWallpaper = bgRoot.movableYSpace; + const extraMove = (wallpaper.effectiveValueY * 2 * bgRoot.movableYSpace) * (parallaxFactor - 1); + return yOnWallpaper - extraMove; + } + Behavior on leftMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on topMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + width: wallpaper.width + height: wallpaper.height + states: State { + name: "centered" + when: GlobalStates.screenLocked || bgRoot.wallpaperSafetyTriggered + PropertyChanges { + target: widgetCanvas + width: parent.width + height: parent.height + } + AnchorChanges { + target: widgetCanvas + anchors { + left: undefined + right: undefined + top: undefined + bottom: undefined + horizontalCenter: parent.horizontalCenter + verticalCenter: parent.verticalCenter + } + } + } + transitions: Transition { + PropertyAnimation { + properties: "width,height" + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + AnchorAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + FadeLoader { + shown: Config.options.background.widgets.weather.enable + sourceComponent: WeatherWidget { + screenWidth: bgRoot.screen.width + screenHeight: bgRoot.screen.height + scaledScreenWidth: bgRoot.screen.width / bgRoot.effectiveWallpaperScale + scaledScreenHeight: bgRoot.screen.height / bgRoot.effectiveWallpaperScale + wallpaperScale: bgRoot.effectiveWallpaperScale + } + } + + FadeLoader { + shown: Config.options.background.widgets.clock.enable + sourceComponent: ClockWidget { + screenWidth: bgRoot.screen.width + screenHeight: bgRoot.screen.height + scaledScreenWidth: bgRoot.screen.width / bgRoot.effectiveWallpaperScale + scaledScreenHeight: bgRoot.screen.height / bgRoot.effectiveWallpaperScale + wallpaperScale: bgRoot.effectiveWallpaperScale + wallpaperSafetyTriggered: bgRoot.wallpaperSafetyTriggered + } + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/background/widgets/AbstractBackgroundWidget.qml b/modules/quickshell/config/modules/ii/background/widgets/AbstractBackgroundWidget.qml new file mode 100644 index 0000000..b24c33a --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/AbstractBackgroundWidget.qml @@ -0,0 +1,101 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets.widgetCanvas + +AbstractWidget { + id: root + + required property string configEntryName + required property int screenWidth + required property int screenHeight + required property int scaledScreenWidth + required property int scaledScreenHeight + required property real wallpaperScale + property bool visibleWhenLocked: false + property var configEntry: Config.options.background.widgets[configEntryName] + property string placementStrategy: configEntry.placementStrategy + property real targetX: Math.max(0, Math.min(configEntry.x, scaledScreenWidth - width)) + property real targetY : Math.max(0, Math.min(configEntry.y, scaledScreenHeight - height)) + x: targetX + y: targetY + visible: opacity > 0 + opacity: (GlobalStates.screenLocked && !visibleWhenLocked) ? 0 : 1 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + scale: (draggable && containsPress) ? 1.05 : 1 + Behavior on scale { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + + draggable: placementStrategy === "free" + onReleased: { + root.targetX = root.x; + root.targetY = root.y; + configEntry.x = root.targetX; + configEntry.y = root.targetY; + } + + property bool needsColText: false + property color dominantColor: Appearance.colors.colPrimary + property bool dominantColorIsDark: dominantColor.hslLightness < 0.5 + property color colText: { + const onNormalBackground = (GlobalStates.screenLocked && Config.options.lock.blur.enable) + const adaptiveColor = ColorUtils.colorWithLightness(Appearance.colors.colPrimary, (dominantColorIsDark ? 0.8 : 0.12)) + return onNormalBackground ? Appearance.colors.colOnLayer0 : adaptiveColor; + } + + property bool wallpaperIsVideo: Config.options.background.wallpaperPath.endsWith(".mp4") || Config.options.background.wallpaperPath.endsWith(".webm") || Config.options.background.wallpaperPath.endsWith(".mkv") || Config.options.background.wallpaperPath.endsWith(".avi") || Config.options.background.wallpaperPath.endsWith(".mov") + property string wallpaperPath: wallpaperIsVideo ? Config.options.background.thumbnailPath : Config.options.background.wallpaperPath + + onWallpaperPathChanged: refreshPlacementIfNeeded() + onPlacementStrategyChanged: refreshPlacementIfNeeded() + Connections { + target: Config + function onReadyChanged() { refreshPlacementIfNeeded() } + } + function refreshPlacementIfNeeded() { + if (!Config.ready || (root.placementStrategy === "free" && root.needsColText)) return; + leastBusyRegionProc.wallpaperPath = root.wallpaperPath; + leastBusyRegionProc.running = false; + leastBusyRegionProc.running = true; + } + Process { + id: leastBusyRegionProc + property string wallpaperPath: root.wallpaperPath + // TODO: make these less arbitrary + property int contentWidth: 300 + property int contentHeight: 300 + property int horizontalPadding: 200 + property int verticalPadding: 200 + command: [Quickshell.shellPath("scripts/images/least-busy-region-venv.sh") // Comments to force the formatter to break lines + , "--screen-width", Math.round(root.scaledScreenWidth) // + , "--screen-height", Math.round(root.scaledScreenHeight) // + , "--width", contentWidth // + , "--height", contentHeight // + , "--horizontal-padding", horizontalPadding // + , "--vertical-padding", verticalPadding // + , wallpaperPath // + , ...(root.placementStrategy === "mostBusy" ? ["--busiest"] : []) + // "--visual-output", + ] + stdout: StdioCollector { + id: leastBusyRegionOutputCollector + onStreamFinished: { + const output = leastBusyRegionOutputCollector.text; + // console.log("[Background] Least busy region output:", output) + if (output.length === 0) return; + const parsedContent = JSON.parse(output); + root.dominantColor = parsedContent.dominant_color || Appearance.colors.colPrimary; + if (root.placementStrategy === "free") return; + root.targetX = parsedContent.center_x * root.wallpaperScale - root.width / 2; + root.targetY = parsedContent.center_y * root.wallpaperScale - root.height / 2; + } + } + } +} + diff --git a/modules/quickshell/config/modules/ii/background/widgets/clock/ClockWidget.qml b/modules/quickshell/config/modules/ii/background/widgets/clock/ClockWidget.qml new file mode 100644 index 0000000..97e1e46 --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/clock/ClockWidget.qml @@ -0,0 +1,202 @@ +import QtQuick +import QtQuick.Layouts +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import qs.modules.common.widgets.widgetCanvas +import qs.modules.ii.background.widgets + +AbstractBackgroundWidget { + id: root + + configEntryName: "clock" + + implicitHeight: contentColumn.implicitHeight + implicitWidth: contentColumn.implicitWidth + + readonly property string clockStyle: GlobalStates.screenLocked ? Config.options.background.widgets.clock.styleLocked : Config.options.background.widgets.clock.style + readonly property bool forceCenter: (GlobalStates.screenLocked && Config.options.lock.centerClock) + readonly property bool shouldShow: (!Config.options.background.widgets.clock.showOnlyWhenLocked || GlobalStates.screenLocked) + property bool wallpaperSafetyTriggered: false + needsColText: clockStyle === "digital" + x: forceCenter ? ((root.screenWidth - root.width) / 2) : targetX + y: forceCenter ? ((root.screenHeight - root.height) / 2) : targetY + visibleWhenLocked: true + + property var textHorizontalAlignment: { + if (root.forceCenter) + return Text.AlignHCenter; + if (root.x < root.scaledScreenWidth / 3) + return Text.AlignLeft; + if (root.x > root.scaledScreenWidth * 2 / 3) + return Text.AlignRight; + return Text.AlignHCenter; + } + + Column { + id: contentColumn + anchors.centerIn: parent + spacing: 10 + + FadeLoader { + id: cookieClockLoader + anchors.horizontalCenter: parent.horizontalCenter + shown: root.clockStyle === "cookie" && (root.shouldShow) + fade: false + sourceComponent: Column { + spacing: 10 + CookieClock { + anchors.horizontalCenter: parent.horizontalCenter + } + FadeLoader { + anchors.horizontalCenter: parent.horizontalCenter + shown: Config.options.background.widgets.clock.quote.enable && Config.options.background.widgets.clock.quote.text !== "" + sourceComponent: CookieQuote {} + } + } + } + + FadeLoader { + id: digitalClockLoader + anchors.horizontalCenter: parent.horizontalCenter + shown: root.clockStyle === "digital" && (root.shouldShow) + fade: false + sourceComponent: ColumnLayout { + id: clockColumn + spacing: 6 + + ClockText { + font.pixelSize: 90 + text: DateTime.time + } + ClockText { + Layout.topMargin: -5 + text: DateTime.longDate + } + StyledText { + // Somehow gets fucked up if made a ClockText??? + visible: Config.options.background.widgets.clock.quote.enable && Config.options.background.widgets.clock.quote.text.length > 0 + Layout.fillWidth: true + horizontalAlignment: root.textHorizontalAlignment + font { + pixelSize: Appearance.font.pixelSize.normal + weight: 350 + } + color: root.colText + style: Text.Raised + styleColor: Appearance.colors.colShadow + text: Config.options.background.widgets.clock.quote.text + } + } + } + StatusRow { + anchors.horizontalCenter: parent.horizontalCenter + } + } + + component StatusRow: Item { + id: statusText + implicitHeight: statusTextBg.implicitHeight + implicitWidth: statusTextBg.implicitWidth + StyledRectangularShadow { + target: statusTextBg + visible: statusTextBg.visible && root.clockStyle === "cookie" + opacity: statusTextBg.opacity + } + Rectangle { + id: statusTextBg + anchors.centerIn: parent + clip: true + opacity: (safetyStatusText.shown || lockStatusText.shown) ? 1 : 0 + visible: opacity > 0 + implicitHeight: statusTextRow.implicitHeight + 5 * 2 + implicitWidth: statusTextRow.implicitWidth + 5 * 2 + radius: Appearance.rounding.small + color: ColorUtils.transparentize(Appearance.colors.colSecondaryContainer, root.clockStyle === "cookie" ? 0 : 1) + + Behavior on implicitWidth { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + RowLayout { + id: statusTextRow + anchors.centerIn: parent + spacing: 14 + Item { + Layout.fillWidth: root.textHorizontalAlignment !== Text.AlignLeft + implicitWidth: 1 + } + ClockStatusText { + id: safetyStatusText + shown: root.wallpaperSafetyTriggered + statusIcon: "hide_image" + statusText: Translation.tr("Wallpaper safety enforced") + } + ClockStatusText { + id: lockStatusText + shown: GlobalStates.screenLocked && Config.options.lock.showLockedText + statusIcon: "lock" + statusText: Translation.tr("Locked") + } + Item { + Layout.fillWidth: root.textHorizontalAlignment !== Text.AlignRight + implicitWidth: 1 + } + } + } + } + + component ClockText: StyledText { + Layout.fillWidth: true + horizontalAlignment: root.textHorizontalAlignment + font { + family: Appearance.font.family.expressive + pixelSize: 20 + weight: Font.DemiBold + } + color: root.colText + style: Text.Raised + styleColor: Appearance.colors.colShadow + animateChange: Config.options.background.widgets.clock.digital.animateChange + } + component ClockStatusText: Row { + id: statusTextRow + property alias statusIcon: statusIconWidget.text + property alias statusText: statusTextWidget.text + property bool shown: true + property color textColor: root.clockStyle === "cookie" ? Appearance.colors.colOnSecondaryContainer : root.colText + opacity: shown ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + spacing: 4 + MaterialSymbol { + id: statusIconWidget + anchors.verticalCenter: statusTextRow.verticalCenter + iconSize: Appearance.font.pixelSize.huge + color: statusTextRow.textColor + style: Text.Raised + styleColor: Appearance.colors.colShadow + } + ClockText { + id: statusTextWidget + color: statusTextRow.textColor + anchors.verticalCenter: statusTextRow.verticalCenter + font { + pixelSize: Appearance.font.pixelSize.large + weight: Font.Normal + } + style: Text.Raised + styleColor: Appearance.colors.colShadow + } + } +} diff --git a/modules/quickshell/config/modules/ii/background/widgets/clock/CookieClock.qml b/modules/quickshell/config/modules/ii/background/widgets/clock/CookieClock.qml new file mode 100644 index 0000000..7e43280 --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/clock/CookieClock.qml @@ -0,0 +1,216 @@ +pragma ComponentBehavior: Bound + +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell.Io + +import qs.modules.ii.background.widgets.clock.dateIndicator +import qs.modules.ii.background.widgets.clock.minuteMarks + +Item { + id: root + + readonly property string clockStyle: Config.options.background.widgets.clock.style + + property real implicitSize: 230 + + property color colShadow: Appearance.colors.colShadow + property color colBackground: Appearance.colors.colPrimaryContainer + property color colOnBackground: ColorUtils.mix(Appearance.colors.colSecondary, Appearance.colors.colPrimaryContainer, 0.15) + property color colBackgroundInfo: ColorUtils.mix(Appearance.colors.colPrimary, Appearance.colors.colPrimaryContainer, 0.55) + property color colHourHand: Appearance.colors.colPrimary + property color colMinuteHand: Appearance.colors.colTertiary + property color colSecondHand: Appearance.colors.colPrimary + + readonly property list clockNumbers: DateTime.time.split(/[: ]/) + readonly property int clockHour: parseInt(clockNumbers[0]) % 12 + readonly property int clockMinute: DateTime.clock.minutes + readonly property int clockSecond: DateTime.clock.seconds + + implicitWidth: implicitSize + implicitHeight: implicitSize + + function applyStyle(sides, dialStyle, hourHandStyle, minuteHandStyle, secondHandStyle, dateStyle) { + Config.options.background.widgets.clock.cookie.sides = sides + Config.options.background.widgets.clock.cookie.dialNumberStyle = dialStyle + Config.options.background.widgets.clock.cookie.hourHandStyle = hourHandStyle + Config.options.background.widgets.clock.cookie.minuteHandStyle = minuteHandStyle + Config.options.background.widgets.clock.cookie.secondHandStyle = secondHandStyle + Config.options.background.widgets.clock.cookie.dateStyle = dateStyle + } + + function setClockPreset(category) { + if (!Config.options.background.widgets.clock.cookie.aiStyling) return; + if (category === "") return; + print("[Cookie clock] Setting clock preset for category: " + category) + // "abstract", "city", "minimalist", "landscape", "plants", "person", "space" + if (category == "abstract") { + applyStyle(9, "none", "fill", "medium", "dot", "bubble") + } else if (category == "city" || category == "space") { + applyStyle(23, "full", "hollow", "thin", "classic", "bubble") + } else if (category == "minimalist") { + applyStyle(6, "none", "fill", "bold", "dot", "hide") + } else if (category == "landscape") { + applyStyle(14, "full", "hollow", "medium", "classic", "bubble") + } else if (category == "plants") { + applyStyle(9, "dots", "fill", "bold", "dot", "border") + } else if (category == "person") { + applyStyle(14, "full", "classic", "classic", "classic", "rect") + } + } + + Connections { + target: Config + function onReadyChanged() { + categoryFileView.path = Directories.generatedWallpaperCategoryPath + } + } + + FileView { + id: categoryFileView + path: "" + watchChanges: true + onFileChanged: reload() + onLoaded: { + root.setClockPreset(categoryFileView.text().trim()) + } + } + + property bool useSineCookie: Config.options.background.widgets.clock.cookie.useSineCookie + StyledDropShadow { + target: useSineCookie ? sineCookieLoader : roundedPolygonCookieLoader + + RotationAnimation on rotation { + running: Config.options.background.widgets.clock.cookie.constantlyRotate + duration: 30000 + easing.type: Easing.Linear + loops: Animation.Infinite + from: 360 + to: 0 + } + } + Loader { + id: sineCookieLoader + z: 0 + visible: false // The DropShadow already draws it + active: useSineCookie + sourceComponent: SineCookie { + implicitSize: root.implicitSize + sides: Config.options.background.widgets.clock.cookie.sides + color: root.colBackground + } + } + Loader { + id: roundedPolygonCookieLoader + z: 0 + visible: false // The DropShadow already draws it + active: !useSineCookie + sourceComponent: MaterialCookie { + implicitSize: root.implicitSize + sides: Config.options.background.widgets.clock.cookie.sides + color: root.colBackground + } + } + + // Hour/minutes numbers/dots/lines + MinuteMarks { + anchors.fill: parent + color: root.colOnBackground + } + + // Stupid extra hour marks in the middle + FadeLoader { + id: hourMarksLoader + anchors.centerIn: parent + shown: Config.options.background.widgets.clock.cookie.hourMarks + sourceComponent: HourMarks { + implicitSize: 135 * (1.75 - 0.75 * hourMarksLoader.opacity) + color: root.colOnBackground + colOnBackground: ColorUtils.mix(root.colBackgroundInfo, root.colOnBackground, 0.5) + } + } + + // Number column in the middle + FadeLoader { + id: timeColumnLoader + anchors.centerIn: parent + shown: Config.options.background.widgets.clock.cookie.timeIndicators + scale: 1.4 - 0.4 * timeColumnLoader.shown + Behavior on scale { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + + sourceComponent: TimeColumn { + color: root.colBackgroundInfo + } + } + + // Minute hand + FadeLoader { + anchors.fill: parent + z: 1 + shown: Config.options.background.widgets.clock.cookie.minuteHandStyle !== "hide" + sourceComponent: MinuteHand { + anchors.fill: parent + clockMinute: root.clockMinute + style: Config.options.background.widgets.clock.cookie.minuteHandStyle + color: root.colMinuteHand + } + } + + // Hour hand + FadeLoader { + anchors.fill: parent + z: item?.style === "hollow" ? 0 : 2 + shown: Config.options.background.widgets.clock.cookie.hourHandStyle !== "hide" + sourceComponent: HourHand { + clockHour: root.clockHour + clockMinute: root.clockMinute + style: Config.options.background.widgets.clock.cookie.hourHandStyle + color: root.colHourHand + } + } + + // Second hand + FadeLoader { + id: secondHandLoader + z: (Config.options.background.widgets.clock.cookie.secondHandStyle === "line") ? 2 : 3 + shown: Config.options.time.secondPrecision && Config.options.background.widgets.clock.cookie.secondHandStyle !== "hide" + anchors.fill: parent + sourceComponent: SecondHand { + id: secondHand + clockSecond: root.clockSecond + style: Config.options.background.widgets.clock.cookie.secondHandStyle + color: root.colSecondHand + } + } + + // Center dot + FadeLoader { + z: 4 + anchors.centerIn: parent + shown: Config.options.background.widgets.clock.cookie.minuteHandStyle !== "bold" + sourceComponent: Rectangle { + color: Config.options.background.widgets.clock.cookie.minuteHandStyle === "medium" ? root.colBackground : root.colMinuteHand + implicitWidth: 6 + implicitHeight: implicitWidth + radius: width / 2 + } + } + + // Date + FadeLoader { + anchors.fill: parent + shown: Config.options.background.widgets.clock.cookie.dateStyle !== "hide" + + sourceComponent: DateIndicator { + color: root.colBackgroundInfo + style: Config.options.background.widgets.clock.cookie.dateStyle + } + } +} diff --git a/modules/quickshell/config/modules/ii/background/widgets/clock/CookieQuote.qml b/modules/quickshell/config/modules/ii/background/widgets/clock/CookieQuote.qml new file mode 100644 index 0000000..8959264 --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/clock/CookieQuote.qml @@ -0,0 +1,59 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import Qt5Compat.GraphicalEffects + + +Item { + id: root + + readonly property string quoteText: Config.options.background.widgets.clock.quote.text + + implicitWidth: quoteBox.implicitWidth + implicitHeight: quoteBox.implicitHeight + + DropShadow { + source: quoteBox + anchors.fill: quoteBox + horizontalOffset: 0 + verticalOffset: 2 + radius: 12 + samples: radius * 2 + 1 + color: Appearance.colors.colShadow + transparentBorder: true + } + + Rectangle { + id: quoteBox + + implicitWidth: quoteRow.implicitWidth + 8 * 2 + implicitHeight: quoteRow.implicitHeight + 4 * 2 + radius: Appearance.rounding.small + color: Appearance.colors.colSecondaryContainer + + Row { + id: quoteRow + anchors.centerIn: parent + spacing: 4 + + MaterialSymbol { + id: quoteIcon + anchors.top: parent.top + iconSize: Appearance.font.pixelSize.huge + text: "format_quote" + color: Appearance.colors.colOnSecondaryContainer + } + StyledText { + id: quoteStyledText + horizontalAlignment: Text.AlignLeft + text: Config.options.background.widgets.clock.quote.text + color: Appearance.colors.colOnSecondaryContainer + font { + family: Appearance.font.family.reading + pixelSize: Appearance.font.pixelSize.large + weight: Font.Normal + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/background/widgets/clock/HourHand.qml b/modules/quickshell/config/modules/ii/background/widgets/clock/HourHand.qml new file mode 100644 index 0000000..5c355d3 --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/clock/HourHand.qml @@ -0,0 +1,45 @@ +pragma ComponentBehavior: Bound + +import qs.modules.common +import QtQuick + +Item { + id: root + + required property int clockHour + required property int clockMinute + property real handLength: 72 + property real handWidth: 20 + property string style: "fill" + property color color: Appearance.colors.colPrimary + + property real fillColorAlpha: root.style === "hollow" ? 0 : 1 + Behavior on fillColorAlpha { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + + rotation: -90 + (360 / 12) * (root.clockHour + root.clockMinute / 60) + Behavior on rotation { + animation: RotationAnimation { + direction: RotationAnimation.Clockwise + duration: 300 + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animationCurves.emphasized + } + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + x: (parent.width - root.handWidth) / 2 - 15 * (root.style === "classic") + width: root.handLength + height: root.style === "classic" ? 8 : root.handWidth + radius: root.style === "classic" ? 2 : root.handWidth / 2 + color : Qt.rgba(root.color.r, root.color.g, root.color.b, root.fillColorAlpha) + border.color: root.color + border.width: 4 + + Behavior on x { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + } +} diff --git a/modules/quickshell/config/modules/ii/background/widgets/clock/HourMarks.qml b/modules/quickshell/config/modules/ii/background/widgets/clock/HourMarks.qml new file mode 100644 index 0000000..ec1bfab --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/clock/HourMarks.qml @@ -0,0 +1,50 @@ +pragma ComponentBehavior: Bound + +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick + +Item { + id: root + property real implicitSize: 135 + property real markLength: 12 + property real markWidth: 4 + property color color: Appearance.colors.colOnSecondaryContainer + property color colOnBackground: Appearance.colors.colSecondaryContainer + property real padding: 8 + + Rectangle { + color: root.color + anchors.centerIn: parent + implicitWidth: root.implicitSize + implicitHeight: root.implicitSize + radius: width / 2 + + // Hour mark lines + Repeater { + model: 12 + + Item { + required property int index + anchors.fill: parent + rotation: 360 / 12 * index + + Rectangle { + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + leftMargin: root.padding + } + implicitWidth: root.markLength + implicitHeight: root.markWidth + + radius: width / 2 + color: root.colOnBackground + } + } + } + } + +} diff --git a/modules/quickshell/config/modules/ii/background/widgets/clock/MinuteHand.qml b/modules/quickshell/config/modules/ii/background/widgets/clock/MinuteHand.qml new file mode 100644 index 0000000..5ca0f2e --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/clock/MinuteHand.qml @@ -0,0 +1,47 @@ +pragma ComponentBehavior: Bound + +import qs.modules.common +import QtQuick + +Item { + id: root + anchors.fill: parent + + required property int clockMinute + property string style: "medium" + property real handLength: 95 + property real handWidth: style === "bold" ? 20 : style === "medium" ? 12 : 5 + property color color: Appearance.colors.colTertiary + + rotation: -90 + (360 / 60) * root.clockMinute + Behavior on rotation { + animation: RotationAnimation { + direction: RotationAnimation.Clockwise + duration: 300 + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animationCurves.emphasized + } + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + x: { + let position = parent.width / 2 - root.handWidth / 2; + if (root.style === "classic") position -= 15; + return position; + } + width: root.handLength + height: root.handWidth + + radius: root.style === "classic" ? 2 : root.handWidth / 2 + color: root.color + + Behavior on height { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + + Behavior on x { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + } +} diff --git a/modules/quickshell/config/modules/ii/background/widgets/clock/SecondHand.qml b/modules/quickshell/config/modules/ii/background/widgets/clock/SecondHand.qml new file mode 100644 index 0000000..2c436a2 --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/clock/SecondHand.qml @@ -0,0 +1,71 @@ +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +Item { + id: root + anchors.fill: parent + + required property int clockSecond + property real handWidth: 2 + property real handLength: 95 + property real dotSize: 20 + property string style: "hide" + property color color: Appearance.colors.colSecondary + + rotation: (360 / 60 * clockSecond) + 90 + + Behavior on rotation { + enabled: Config.options.background.widgets.clock.cookie.constantlyRotate // Animating every second is expensive... + animation: RotationAnimation { + direction: RotationAnimation.Clockwise + duration: 1000 // 1 second + easing.type: Easing.InOutQuad + } + } + + Rectangle { + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + leftMargin: 10 + (root.style === "dot" ? root.dotSize : 0) + } + implicitWidth: root.style === "dot" ? root.dotSize : root.handLength + implicitHeight: root.style === "dot" ? root.dotSize : root.handWidth + radius: Math.min(width, height) / 2 + color: root.color + Behavior on implicitHeight { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + Behavior on implicitWidth { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + } + + // Classic style dot in the middle of the hand + FadeLoader { + id: classicDotLoader + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + } + shown: root.style === "classic" + Rectangle { + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + leftMargin: 40 + } + implicitWidth: root.style === "classic" ? 14 : 0 + implicitHeight: implicitWidth + color: root.color + radius: Appearance.rounding.small + + Behavior on implicitWidth { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/background/widgets/clock/TimeColumn.qml b/modules/quickshell/config/modules/ii/background/widgets/clock/TimeColumn.qml new file mode 100644 index 0000000..6b73c85 --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/clock/TimeColumn.qml @@ -0,0 +1,41 @@ +pragma ComponentBehavior: Bound + +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +Column { + id: root + property list clockNumbers: DateTime.time.split(/[: ]/) + property bool isEnabled: Config.options.background.widgets.clock.cookie.timeIndicators + property color color: Appearance.colors.colOnSecondaryContainer + + property bool hourMarksEnabled: Config.options.background.widgets.clock.cookie.hourMarks + spacing: -16 + + Repeater { + model: root.clockNumbers + + delegate: StyledText { + required property string modelData + text: modelData.padStart(2, "0") + property bool isAmPm: !text.match(/\d{2}/i) + property real numberSizeWithoutGlow: isAmPm ? 26 : 68 + property real numberSizeWithGlow: isAmPm ? 20 : 40 + property real numberSize: root.hourMarksEnabled ? numberSizeWithGlow : numberSizeWithoutGlow + + anchors.horizontalCenter: root.horizontalCenter + color: root.color + font { + family: Appearance.font.family.expressive + weight: Font.Bold + pixelSize: numberSize + } + + Behavior on numberSize { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/background/widgets/clock/dateIndicator/BubbleDate.qml b/modules/quickshell/config/modules/ii/background/widgets/clock/dateIndicator/BubbleDate.qml new file mode 100644 index 0000000..1c75c35 --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/clock/dateIndicator/BubbleDate.qml @@ -0,0 +1,37 @@ +pragma ComponentBehavior: Bound + +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +Item { + id: root + property bool isMonth: false + property real targetSize: 0 + property alias text: bubbleText.text + + text: Qt.locale().toString(DateTime.clock.date, root.isMonth ? "MM" : "d") + + MaterialShape { + id: bubble + z: 5 + // sides: root.isMonth ? 1 : 4 + shape: root.isMonth ? MaterialShape.Shape.Pill : MaterialShape.Shape.Pentagon + anchors.centerIn: parent + color: root.isMonth ? Appearance.colors.colSecondaryContainer : Appearance.colors.colTertiaryContainer + implicitSize: targetSize + } + + StyledText { + id: bubbleText + z: 6 + anchors.centerIn: parent + color: root.isMonth ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnTertiaryContainer + font { + family: Appearance.font.family.expressive + pixelSize: 30 + weight: Font.Black + } + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/ii/background/widgets/clock/dateIndicator/DateIndicator.qml b/modules/quickshell/config/modules/ii/background/widgets/clock/dateIndicator/DateIndicator.qml new file mode 100644 index 0000000..820a96d --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/clock/dateIndicator/DateIndicator.qml @@ -0,0 +1,76 @@ +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick + +Item { + id: root + property string style: "bubble" + property color color: Appearance.colors.colOnSecondaryContainer + property real dateSquareSize: 64 + + // Rotating date + FadeLoader { + anchors.fill: parent + shown: Config.options.background.widgets.clock.cookie.dateStyle === "border" + sourceComponent: RotatingDate { + color: root.color + } + } + + // Rectangle date (only today's number) in right side of the clock + FadeLoader { + id: rectLoader + shown: root.style === "rect" + anchors { + verticalCenter: parent.verticalCenter + right: parent.right + rightMargin: 40 - rectLoader.opacity * 30 + } + + sourceComponent: RectangleDate { + color: ColorUtils.mix(root.color, Appearance.colors.colSecondaryContainerHover, 0.5) + radius: Appearance.rounding.small + implicitWidth: 45 * rectLoader.opacity + implicitHeight: 30 * rectLoader.opacity + } + } + + // Bubble style: day of month + FadeLoader { + id: dayBubbleLoader + shown: root.style === "bubble" + property real targetSize: root.dateSquareSize * opacity + anchors { + left: parent.left + top: parent.top + } + + sourceComponent: BubbleDate { + implicitWidth: dayBubbleLoader.targetSize + implicitHeight: dayBubbleLoader.targetSize + isMonth: false + targetSize: dayBubbleLoader.targetSize + } + } + + // Bubble style: month + FadeLoader { + id: monthBubbleLoader + shown: root.style === "bubble" + property real targetSize: root.dateSquareSize * opacity + anchors { + right: parent.right + bottom: parent.bottom + } + + sourceComponent: BubbleDate { + implicitWidth: monthBubbleLoader.targetSize + implicitHeight: monthBubbleLoader.targetSize + isMonth: true + targetSize: monthBubbleLoader.targetSize + } + } +} diff --git a/modules/quickshell/config/modules/ii/background/widgets/clock/dateIndicator/RectangleDate.qml b/modules/quickshell/config/modules/ii/background/widgets/clock/dateIndicator/RectangleDate.qml new file mode 100644 index 0000000..0c09db1 --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/clock/dateIndicator/RectangleDate.qml @@ -0,0 +1,21 @@ + +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +Rectangle { + id: rect + readonly property string dialStyle: Config.options.background.widgets.clock.cookie.dialNumberStyle + + StyledText { + anchors.centerIn: parent + color: Appearance.colors.colSecondaryHover + text: Qt.locale().toString(DateTime.clock.date, "dd") + font { + family: Appearance.font.family.expressive + pixelSize: 20 + weight: 1000 + } + } +} diff --git a/modules/quickshell/config/modules/ii/background/widgets/clock/dateIndicator/RotatingDate.qml b/modules/quickshell/config/modules/ii/background/widgets/clock/dateIndicator/RotatingDate.qml new file mode 100644 index 0000000..3c8354d --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/clock/dateIndicator/RotatingDate.qml @@ -0,0 +1,50 @@ +pragma ComponentBehavior: Bound + +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +Item { + id: root + + property string style: Config.options.background.widgets.clock.cookie.dateStyle + property color color: Appearance.colors.colOnSecondaryContainer + property real angleStep: 12 * Math.PI / 180 + property string dateText: Qt.locale().toString(DateTime.clock.date, "ddd dd") + + readonly property int clockSecond: DateTime.clock.seconds + readonly property string dialStyle: Config.options.background.widgets.clock.cookie.dialNumberStyle + readonly property bool timeIndicators: Config.options.background.widgets.clock.cookie.timeIndicators + + property real radius: style === "border" ? 90 : 0 + Behavior on radius { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + + rotation: { + if (!Config.options.time.secondPrecision) return 0 + else return (360 / 60 * clockSecond) + 180 - (angleStep / Math.PI * 180 * dateText.length) / 2 + } + + Repeater { + model: root.dateText.length + + delegate: Text { + required property int index + property real angle: index * root.angleStep - Math.PI / 2 + x: root.width / 2 + root.radius * Math.cos(angle) - width / 2 + y: root.height / 2 + root.radius * Math.sin(angle) - height / 2 + rotation: angle * 180 / Math.PI + 90 + + color: root.color + font { + family: Appearance.font.family.title + pixelSize: 30 + variableAxes: Appearance.font.variableAxes.title + } + + text: root.dateText.charAt(index) + } + } +} diff --git a/modules/quickshell/config/modules/ii/background/widgets/clock/minuteMarks/BigHourNumbers.qml b/modules/quickshell/config/modules/ii/background/widgets/clock/minuteMarks/BigHourNumbers.qml new file mode 100644 index 0000000..d0794d1 --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/clock/minuteMarks/BigHourNumbers.qml @@ -0,0 +1,49 @@ +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +Item { + id: root + property real numberSize: 80 + property real margins: 10 + property color color: Appearance.colors.colOnSecondaryContainer + + property int hours: 12 + property int numbers: 4 + property int fontSize: 80 + + Repeater { + model: root.numbers + + Item { + id: numberItem + required property int index + rotation: 360 / root.numbers * (index + 1) + anchors.fill: parent + + Item { + implicitWidth: root.numberSize + implicitHeight: implicitWidth + anchors { + top: parent.top + horizontalCenter: parent.horizontalCenter + topMargin: root.margins + } + StyledText { + color: root.color + anchors.centerIn: parent + text: root.hours / root.numbers * (numberItem.index + 1) + rotation: -numberItem.rotation + + font { + family: Appearance.font.family.reading + pixelSize: root.fontSize + weight: Font.Black + } + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/background/widgets/clock/minuteMarks/Dots.qml b/modules/quickshell/config/modules/ii/background/widgets/clock/minuteMarks/Dots.qml new file mode 100644 index 0000000..60bf594 --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/clock/minuteMarks/Dots.qml @@ -0,0 +1,34 @@ +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +Item { + id: root + property real implicitSize: 12 + property real margins: 10 + property color color: Appearance.colors.colOnSecondaryContainer + + Repeater { + model: 12 + + Item { + required property int index + anchors.fill: parent // Ensures rotation works properly + rotation: 360 / 12 * index + + Rectangle { + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + leftMargin: root.margins + } + implicitWidth: root.implicitSize + implicitHeight: implicitWidth + radius: implicitWidth / 2 + color: root.color + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/background/widgets/clock/minuteMarks/Lines.qml b/modules/quickshell/config/modules/ii/background/widgets/clock/minuteMarks/Lines.qml new file mode 100644 index 0000000..c4b8d72 --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/clock/minuteMarks/Lines.qml @@ -0,0 +1,66 @@ +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +Item { + id: root + property real numberSize: 80 + property real margins: 10 + property color color: Appearance.colors.colOnSecondaryContainer + + property real hourLineSize: 4 + property real minuteLineSize: 2 + property real hourLineLength: 18 + property real minuteLineLength: 7 + + property int hours: 12 + property int minutes: 60 + + // Full dial style hour lines + Repeater { + model: root.hours + + Item { + required property int index + rotation: 360 / root.hours * index + anchors.fill: parent + + Rectangle { + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + leftMargin: root.margins + } + implicitWidth: root.hourLineLength + implicitHeight: root.hourLineSize + radius: implicitWidth / 2 + color: root.color + } + } + } + + // Minute lines + Repeater { + model: root.minutes + + Item { + required property int index + rotation: 360 / root.minutes * index + anchors.fill: parent + + Rectangle { + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + leftMargin: root.margins + } + implicitWidth: root.minuteLineLength + implicitHeight: root.minuteLineSize + radius: implicitWidth / 2 + color: root.color + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/background/widgets/clock/minuteMarks/MinuteMarks.qml b/modules/quickshell/config/modules/ii/background/widgets/clock/minuteMarks/MinuteMarks.qml new file mode 100644 index 0000000..02d8f25 --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/clock/minuteMarks/MinuteMarks.qml @@ -0,0 +1,54 @@ +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +Item { + id: root + + property color color: Appearance.colors.colOnSecondaryContainer + property string style: Config.options.background.widgets.clock.cookie.dialNumberStyle // "dots", "numbers", "full", "hide" + property string dateStyle : Config.options.background.widgets.clock.cookie.dateStyle + + // 12 Dots + FadeLoader { + id: dotsLoader + anchors { + fill: parent + margins: 10 + } + shown: root.style === "dots" + sourceComponent: Dots { + color: root.color + margins: 46 - dotsLoader.opacity * 34 + } + } + + // 3-6-9-12 hour numbers (pls don't realize you can have more than 4 numbers) + FadeLoader { + id: bigHourNumbersLoader + anchors.fill: parent + shown: root.style === "numbers" + sourceComponent: BigHourNumbers { + numberSize: 80 + color: root.color + margins: 20 - 10 * bigHourNumbersLoader.opacity + } + } + + // Lines + FadeLoader { + id: linesLoader + anchors { + fill: parent + margins: 10 + } + shown: root.style === "full" + sourceComponent: Lines { + color: root.color + margins: 46 - linesLoader.opacity * 34 + } + } + +} diff --git a/modules/quickshell/config/modules/ii/background/widgets/weather/WeatherWidget.qml b/modules/quickshell/config/modules/ii/background/widgets/weather/WeatherWidget.qml new file mode 100644 index 0000000..ef62670 --- /dev/null +++ b/modules/quickshell/config/modules/ii/background/widgets/weather/WeatherWidget.qml @@ -0,0 +1,58 @@ +import QtQuick +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import qs.modules.common.widgets.widgetCanvas +import qs.modules.ii.background.widgets + +AbstractBackgroundWidget { + id: root + + configEntryName: "weather" + + implicitHeight: backgroundShape.implicitHeight + implicitWidth: backgroundShape.implicitWidth + + StyledDropShadow { + target: backgroundShape + } + + MaterialShape { + id: backgroundShape + anchors.fill: parent + shape: MaterialShape.Shape.Pill + color: Appearance.colors.colPrimaryContainer + implicitSize: 200 + + StyledText { + font { + pixelSize: 80 + family: Appearance.font.family.expressive + weight: Font.Medium + } + color: Appearance.colors.colPrimary + text: Weather.data?.temp.substring(0,Weather.data?.temp.length - 1) ?? "--ยฐ" + anchors { + right: parent.right + top: parent.top + rightMargin: 16 + topMargin: 20 + } + } + + MaterialSymbol { + iconSize: 80 + color: Appearance.colors.colOnPrimaryContainer + text: Icons.getWeatherIcon(Weather.data.wCode) ?? "cloud" + anchors { + left: parent.left + bottom: parent.bottom + + leftMargin: 16 + bottomMargin: 20 + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/bar/ActiveWindow.qml b/modules/quickshell/config/modules/ii/bar/ActiveWindow.qml new file mode 100644 index 0000000..b4f1c69 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/ActiveWindow.qml @@ -0,0 +1,52 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Item { + id: root + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.QsWindow.window?.screen) + readonly property Toplevel activeWindow: ToplevelManager.activeToplevel + + property string activeWindowAddress: `0x${activeWindow?.HyprlandToplevel?.address}` + property bool focusingThisMonitor: HyprlandData.activeWorkspace?.monitor == monitor?.name + property var biggestWindow: HyprlandData.biggestWindowForWorkspace(HyprlandData.monitors[root.monitor?.id]?.activeWorkspace.id) + + implicitWidth: colLayout.implicitWidth + + ColumnLayout { + id: colLayout + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + spacing: -4 + + StyledText { + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colSubtext + elide: Text.ElideRight + text: root.focusingThisMonitor && root.activeWindow?.activated && root.biggestWindow ? + root.activeWindow?.appId : + (root.biggestWindow?.class) ?? Translation.tr("Desktop") + + } + + StyledText { + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnLayer0 + elide: Text.ElideRight + text: root.focusingThisMonitor && root.activeWindow?.activated && root.biggestWindow ? + root.activeWindow?.title : + (root.biggestWindow?.title) ?? `${Translation.tr("Workspace")} ${monitor?.activeWorkspace?.id ?? 1}` + } + + } + +} diff --git a/modules/quickshell/config/modules/ii/bar/Bar.qml b/modules/quickshell/config/modules/ii/bar/Bar.qml new file mode 100644 index 0000000..0bf1ad9 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/Bar.qml @@ -0,0 +1,249 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +Scope { + id: bar + property bool showBarBackground: Config.options.bar.showBackground + + Variants { + // For each monitor + model: { + const screens = Quickshell.screens; + const list = Config.options.bar.screenList; + if (!list || list.length === 0) + return screens; + return screens.filter(screen => list.includes(screen.name)); + } + LazyLoader { + id: barLoader + active: GlobalStates.barOpen && !GlobalStates.screenLocked + required property ShellScreen modelData + component: PanelWindow { // Bar window + id: barRoot + screen: barLoader.modelData + + Timer { + id: showBarTimer + interval: (Config?.options.bar.autoHide.showWhenPressingSuper.delay ?? 100) + repeat: false + onTriggered: { + barRoot.superShow = true + } + } + Connections { + target: GlobalStates + function onSuperDownChanged() { + if (!Config?.options.bar.autoHide.showWhenPressingSuper.enable) return; + if (GlobalStates.superDown) showBarTimer.restart(); + else { + showBarTimer.stop(); + barRoot.superShow = false; + } + } + } + property bool superShow: false + property bool mustShow: hoverRegion.containsMouse || superShow + exclusionMode: ExclusionMode.Ignore + exclusiveZone: (Config?.options.bar.autoHide.enable && (!mustShow || !Config?.options.bar.autoHide.pushWindows)) ? 0 : + Appearance.sizes.baseBarHeight + (Config.options.bar.cornerStyle === 1 ? Appearance.sizes.hyprlandGapsOut : 0) + WlrLayershell.namespace: "quickshell:bar" + implicitHeight: Appearance.sizes.barHeight + Appearance.rounding.screenRounding + mask: Region { + item: hoverMaskRegion + } + color: "transparent" + + anchors { + top: !Config.options.bar.bottom + bottom: Config.options.bar.bottom + left: true + right: true + } + + margins { + right: (Config.options.interactions.deadPixelWorkaround.enable && barRoot.anchors.right) * -1 + bottom: (Config.options.interactions.deadPixelWorkaround.enable && barRoot.anchors.bottom) * -1 + } + + MouseArea { + id: hoverRegion + hoverEnabled: true + anchors { + fill: parent + rightMargin: (Config.options.interactions.deadPixelWorkaround.enable && barRoot.anchors.right) * 1 + bottomMargin: (Config.options.interactions.deadPixelWorkaround.enable && barRoot.anchors.bottom) * 1 + } + + Item { + id: hoverMaskRegion + anchors { + fill: barContent + topMargin: -Config.options.bar.autoHide.hoverRegionWidth + bottomMargin: -Config.options.bar.autoHide.hoverRegionWidth + } + } + + BarContent { + id: barContent + + implicitHeight: Appearance.sizes.barHeight + anchors { + right: parent.right + left: parent.left + top: parent.top + bottom: undefined + topMargin: (Config?.options.bar.autoHide.enable && !mustShow) ? -Appearance.sizes.barHeight : 0 + bottomMargin: (Config.options.interactions.deadPixelWorkaround.enable && barRoot.anchors.bottom) * -1 + rightMargin: (Config.options.interactions.deadPixelWorkaround.enable && barRoot.anchors.right) * -1 + } + Behavior on anchors.topMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on anchors.bottomMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + states: State { + name: "bottom" + when: Config.options.bar.bottom + AnchorChanges { + target: barContent + anchors { + right: parent.right + left: parent.left + top: undefined + bottom: parent.bottom + } + } + PropertyChanges { + target: barContent + anchors.topMargin: 0 + anchors.bottomMargin: (Config?.options.bar.autoHide.enable && !mustShow) ? -Appearance.sizes.barHeight : 0 + } + } + } + + // Round decorators + Loader { + id: roundDecorators + anchors { + left: parent.left + right: parent.right + top: barContent.bottom + bottom: undefined + } + height: Appearance.rounding.screenRounding + active: showBarBackground && Config.options.bar.cornerStyle === 0 // Hug + + states: State { + name: "bottom" + when: Config.options.bar.bottom + AnchorChanges { + target: roundDecorators + anchors { + right: parent.right + left: parent.left + top: undefined + bottom: barContent.top + } + } + } + + sourceComponent: Item { + implicitHeight: Appearance.rounding.screenRounding + RoundCorner { + id: leftCorner + anchors { + top: parent.top + bottom: parent.bottom + left: parent.left + } + + implicitSize: Appearance.rounding.screenRounding + color: showBarBackground ? Appearance.colors.colLayer0 : "transparent" + + corner: RoundCorner.CornerEnum.TopLeft + states: State { + name: "bottom" + when: Config.options.bar.bottom + PropertyChanges { + leftCorner.corner: RoundCorner.CornerEnum.BottomLeft + } + } + } + RoundCorner { + id: rightCorner + anchors { + right: parent.right + top: !Config.options.bar.bottom ? parent.top : undefined + bottom: Config.options.bar.bottom ? parent.bottom : undefined + } + implicitSize: Appearance.rounding.screenRounding + color: showBarBackground ? Appearance.colors.colLayer0 : "transparent" + + corner: RoundCorner.CornerEnum.TopRight + states: State { + name: "bottom" + when: Config.options.bar.bottom + PropertyChanges { + rightCorner.corner: RoundCorner.CornerEnum.BottomRight + } + } + } + } + } + } + } + } + } + + IpcHandler { + target: "bar" + + function toggle(): void { + GlobalStates.barOpen = !GlobalStates.barOpen + } + + function close(): void { + GlobalStates.barOpen = false + } + + function open(): void { + GlobalStates.barOpen = true + } + } + + GlobalShortcut { + name: "barToggle" + description: "Toggles bar on press" + + onPressed: { + GlobalStates.barOpen = !GlobalStates.barOpen; + } + } + + GlobalShortcut { + name: "barOpen" + description: "Opens bar on press" + + onPressed: { + GlobalStates.barOpen = true; + } + } + + GlobalShortcut { + name: "barClose" + description: "Closes bar on press" + + onPressed: { + GlobalStates.barOpen = false; + } + } +} diff --git a/modules/quickshell/config/modules/ii/bar/BarContent.qml b/modules/quickshell/config/modules/ii/bar/BarContent.qml new file mode 100644 index 0000000..fefcc0a --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/BarContent.qml @@ -0,0 +1,341 @@ +import qs.modules.ii.bar.weather +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.UPower +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +Item { // Bar content region + id: root + + property var screen: root.QsWindow.window?.screen + property var brightnessMonitor: Brightness.getMonitorForScreen(screen) + property real useShortenedForm: (Appearance.sizes.barHellaShortenScreenWidthThreshold >= screen?.width) ? 2 : (Appearance.sizes.barShortenScreenWidthThreshold >= screen?.width) ? 1 : 0 + readonly property int centerSideModuleWidth: (useShortenedForm == 2) ? Appearance.sizes.barCenterSideModuleWidthHellaShortened : (useShortenedForm == 1) ? Appearance.sizes.barCenterSideModuleWidthShortened : Appearance.sizes.barCenterSideModuleWidth + + component VerticalBarSeparator: Rectangle { + Layout.topMargin: Appearance.sizes.baseBarHeight / 3 + Layout.bottomMargin: Appearance.sizes.baseBarHeight / 3 + Layout.fillHeight: true + implicitWidth: 1 + color: Appearance.colors.colOutlineVariant + } + + // Background shadow + Loader { + active: Config.options.bar.showBackground && Config.options.bar.cornerStyle === 1 && Config.options.bar.floatStyleShadow + anchors.fill: barBackground + sourceComponent: StyledRectangularShadow { + anchors.fill: undefined // The loader's anchors act on this, and this should not have any anchor + target: barBackground + } + } + // Background + Rectangle { + id: barBackground + anchors { + fill: parent + margins: Config.options.bar.cornerStyle === 1 ? (Appearance.sizes.hyprlandGapsOut) : 0 // idk why but +1 is needed + } + color: Config.options.bar.showBackground ? Appearance.colors.colLayer0 : "transparent" + radius: Config.options.bar.cornerStyle === 1 ? Appearance.rounding.windowRounding : 0 + border.width: Config.options.bar.cornerStyle === 1 ? 1 : 0 + border.color: Appearance.colors.colLayer0Border + } + + FocusedScrollMouseArea { // Left side | scroll to change brightness + id: barLeftSideMouseArea + + anchors { + top: parent.top + bottom: parent.bottom + left: parent.left + right: middleSection.left + } + implicitWidth: leftSectionRowLayout.implicitWidth + implicitHeight: Appearance.sizes.baseBarHeight + + onScrollDown: root.brightnessMonitor.setBrightness(root.brightnessMonitor.brightness - 0.05) + onScrollUp: root.brightnessMonitor.setBrightness(root.brightnessMonitor.brightness + 0.05) + onMovedAway: GlobalStates.osdBrightnessOpen = false + onPressed: event => { + if (event.button === Qt.LeftButton) + GlobalStates.overviewOpen = !GlobalStates.overviewOpen; + } + + // Visual content + ScrollHint { + reveal: barLeftSideMouseArea.hovered + icon: "light_mode" + tooltipText: Translation.tr("Scroll to change brightness") + side: "left" + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + RowLayout { + id: leftSectionRowLayout + anchors.fill: parent + spacing: 10 + + LeftSidebarButton { // Left sidebar button + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: Appearance.rounding.screenRounding + colBackground: barLeftSideMouseArea.hovered ? Appearance.colors.colLayer1Hover : ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1) + } + + ActiveWindow { + visible: root.useShortenedForm === 0 + Layout.rightMargin: Appearance.rounding.screenRounding + Layout.fillWidth: true + Layout.fillHeight: true + } + } + } + + Row { // Middle section + id: middleSection + anchors { + top: parent.top + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + } + spacing: 4 + + BarGroup { + id: leftCenterGroup + anchors.verticalCenter: parent.verticalCenter + implicitWidth: root.centerSideModuleWidth + + Resources { + alwaysShowAllResources: root.useShortenedForm === 2 + Layout.fillWidth: root.useShortenedForm === 2 + } + + Media { + visible: root.useShortenedForm < 2 + Layout.fillWidth: true + } + } + + VerticalBarSeparator { + visible: Config.options?.bar.borderless + } + + BarGroup { + id: middleCenterGroup + anchors.verticalCenter: parent.verticalCenter + padding: workspacesWidget.widgetPadding + + Workspaces { + id: workspacesWidget + Layout.fillHeight: true + MouseArea { + // Right-click to toggle overview + anchors.fill: parent + acceptedButtons: Qt.RightButton + + onPressed: event => { + if (event.button === Qt.RightButton) { + GlobalStates.overviewOpen = !GlobalStates.overviewOpen; + } + } + } + } + } + + VerticalBarSeparator { + visible: Config.options?.bar.borderless + } + + MouseArea { + id: rightCenterGroup + anchors.verticalCenter: parent.verticalCenter + implicitWidth: root.centerSideModuleWidth + implicitHeight: rightCenterGroupContent.implicitHeight + + onPressed: { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + } + + BarGroup { + id: rightCenterGroupContent + anchors.fill: parent + + ClockWidget { + showDate: (Config.options.bar.verbose && root.useShortenedForm < 2) + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + } + + UtilButtons { + visible: (Config.options.bar.verbose && root.useShortenedForm === 0) + Layout.alignment: Qt.AlignVCenter + } + + BatteryIndicator { + visible: (root.useShortenedForm < 2 && Battery.available) + Layout.alignment: Qt.AlignVCenter + } + } + } + } + + FocusedScrollMouseArea { // Right side | scroll to change volume + id: barRightSideMouseArea + + anchors { + top: parent.top + bottom: parent.bottom + left: middleSection.right + right: parent.right + } + implicitWidth: rightSectionRowLayout.implicitWidth + implicitHeight: Appearance.sizes.baseBarHeight + + onScrollDown: Audio.decrementVolume(); + onScrollUp: Audio.incrementVolume(); + onMovedAway: GlobalStates.osdVolumeOpen = false; + onPressed: event => { + if (event.button === Qt.LeftButton) { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + } + } + + // Visual content + ScrollHint { + reveal: barRightSideMouseArea.hovered + icon: "volume_up" + tooltipText: Translation.tr("Scroll to change volume") + side: "right" + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + } + + RowLayout { + id: rightSectionRowLayout + anchors.fill: parent + spacing: 5 + layoutDirection: Qt.RightToLeft + + RippleButton { // Right sidebar button + id: rightSidebarButton + + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + Layout.rightMargin: Appearance.rounding.screenRounding + Layout.fillWidth: false + + implicitWidth: indicatorsRowLayout.implicitWidth + 10 * 2 + implicitHeight: indicatorsRowLayout.implicitHeight + 5 * 2 + + buttonRadius: Appearance.rounding.full + colBackground: barRightSideMouseArea.hovered ? Appearance.colors.colLayer1Hover : ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1) + colBackgroundHover: Appearance.colors.colLayer1Hover + colRipple: Appearance.colors.colLayer1Active + colBackgroundToggled: Appearance.colors.colSecondaryContainer + colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover + colRippleToggled: Appearance.colors.colSecondaryContainerActive + toggled: GlobalStates.sidebarRightOpen + property color colText: toggled ? Appearance.m3colors.m3onSecondaryContainer : Appearance.colors.colOnLayer0 + + Behavior on colText { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + onPressed: { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + } + + RowLayout { + id: indicatorsRowLayout + anchors.centerIn: parent + property real realSpacing: 15 + spacing: 0 + + Revealer { + reveal: Audio.sink?.audio?.muted ?? false + Layout.fillHeight: true + Layout.rightMargin: reveal ? indicatorsRowLayout.realSpacing : 0 + Behavior on Layout.rightMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + MaterialSymbol { + text: "volume_off" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + } + Revealer { + reveal: Audio.source?.audio?.muted ?? false + Layout.fillHeight: true + Layout.rightMargin: reveal ? indicatorsRowLayout.realSpacing : 0 + Behavior on Layout.rightMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + MaterialSymbol { + text: "mic_off" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + } + HyprlandXkbIndicator { + Layout.alignment: Qt.AlignVCenter + Layout.rightMargin: indicatorsRowLayout.realSpacing + color: rightSidebarButton.colText + } + Revealer { + reveal: Notifications.silent || Notifications.unread > 0 + Layout.fillHeight: true + Layout.rightMargin: reveal ? indicatorsRowLayout.realSpacing : 0 + implicitHeight: reveal ? notificationUnreadCount.implicitHeight : 0 + implicitWidth: reveal ? notificationUnreadCount.implicitWidth : 0 + Behavior on Layout.rightMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + NotificationUnreadCount { + id: notificationUnreadCount + } + } + MaterialSymbol { + text: Network.materialSymbol + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + MaterialSymbol { + Layout.leftMargin: indicatorsRowLayout.realSpacing + visible: BluetoothStatus.available + text: BluetoothStatus.connected ? "bluetooth_connected" : BluetoothStatus.enabled ? "bluetooth" : "bluetooth_disabled" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + } + } + + SysTray { + visible: root.useShortenedForm === 0 + Layout.fillWidth: false + Layout.fillHeight: true + invertSide: Config?.options.bar.bottom + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + } + + // Weather + Loader { + Layout.leftMargin: 4 + active: Config.options.bar.weather.enable + + sourceComponent: BarGroup { + WeatherBar {} + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/bar/BarGroup.qml b/modules/quickshell/config/modules/ii/bar/BarGroup.qml new file mode 100644 index 0000000..35769e4 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/BarGroup.qml @@ -0,0 +1,41 @@ +import qs.modules.common +import QtQuick +import QtQuick.Layouts + +Item { + id: root + property bool vertical: false + property real padding: 5 + implicitWidth: vertical ? Appearance.sizes.baseVerticalBarWidth : (gridLayout.implicitWidth + padding * 2) + implicitHeight: vertical ? (gridLayout.implicitHeight + padding * 2) : Appearance.sizes.baseBarHeight + default property alias items: gridLayout.children + + Rectangle { + id: background + anchors { + fill: parent + topMargin: root.vertical ? 0 : 4 + bottomMargin: root.vertical ? 0 : 4 + leftMargin: root.vertical ? 4 : 0 + rightMargin: root.vertical ? 4 : 0 + } + color: Config.options?.bar.borderless ? "transparent" : Appearance.colors.colLayer1 + radius: Appearance.rounding.small + } + + GridLayout { + id: gridLayout + columns: root.vertical ? 1 : -1 + anchors { + verticalCenter: root.vertical ? undefined : parent.verticalCenter + horizontalCenter: root.vertical ? parent.horizontalCenter : undefined + left: root.vertical ? undefined : parent.left + right: root.vertical ? undefined : parent.right + top: root.vertical ? parent.top : undefined + bottom: root.vertical ? parent.bottom : undefined + margins: root.padding + } + columnSpacing: 4 + rowSpacing: 12 + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/ii/bar/BatteryIndicator.qml b/modules/quickshell/config/modules/ii/bar/BatteryIndicator.qml new file mode 100644 index 0000000..36799fe --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/BatteryIndicator.qml @@ -0,0 +1,59 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts + +MouseArea { + id: root + property bool borderless: Config.options.bar.borderless + readonly property var chargeState: Battery.chargeState + readonly property bool isCharging: Battery.isCharging + readonly property bool isPluggedIn: Battery.isPluggedIn + readonly property real percentage: Battery.percentage + readonly property bool isLow: percentage <= Config.options.battery.low / 100 + + implicitWidth: batteryProgress.implicitWidth + implicitHeight: Appearance.sizes.barHeight + + hoverEnabled: !Config.options.bar.tooltips.clickToShow + + ClippedProgressBar { + id: batteryProgress + anchors.centerIn: parent + value: percentage + highlightColor: (isLow && !isCharging) ? Appearance.m3colors.m3error : Appearance.colors.colOnSecondaryContainer + + Item { + anchors.centerIn: parent + width: batteryProgress.valueBarWidth + height: batteryProgress.valueBarHeight + + RowLayout { + anchors.centerIn: parent + spacing: 0 + + MaterialSymbol { + id: boltIcon + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: -2 + Layout.rightMargin: -2 + fill: 1 + text: "bolt" + iconSize: Appearance.font.pixelSize.smaller + visible: isCharging && percentage < 1 // TODO: animation + } + StyledText { + Layout.alignment: Qt.AlignVCenter + font: batteryProgress.font + text: batteryProgress.text + } + } + } + } + + BatteryPopup { + id: batteryPopup + hoverTarget: root + } +} diff --git a/modules/quickshell/config/modules/ii/bar/BatteryPopup.qml b/modules/quickshell/config/modules/ii/bar/BatteryPopup.qml new file mode 100644 index 0000000..26eda56 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/BatteryPopup.qml @@ -0,0 +1,72 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts + +StyledPopup { + id: root + + ColumnLayout { + id: columnLayout + anchors.centerIn: parent + spacing: 4 + + // Header + StyledPopupHeaderRow { + icon: "battery_android_full" + label: Translation.tr("Battery") + } + + StyledPopupValueRow { + visible: { + let timeValue = Battery.isCharging ? Battery.timeToFull : Battery.timeToEmpty; + let power = Battery.energyRate; + return !(Battery.chargeState == 4 || timeValue <= 0 || power <= 0.01); + } + icon: "schedule" + label: Battery.isCharging ? Translation.tr("Time to full:") : Translation.tr("Time to empty:") + value: { + function formatTime(seconds) { + var h = Math.floor(seconds / 3600); + var m = Math.floor((seconds % 3600) / 60); + if (h > 0) + return `${h}h, ${m}m`; + else + return `${m}m`; + } + if (Battery.isCharging) + return formatTime(Battery.timeToFull); + else + return formatTime(Battery.timeToEmpty); + } + } + + StyledPopupValueRow { + visible: !(Battery.chargeState != 4 && Battery.energyRate == 0) + icon: "bolt" + label: { + if (Battery.chargeState == 4) { + return Translation.tr("Fully charged"); + } else if (Battery.chargeState == 1) { + return Translation.tr("Charging:"); + } else { + return Translation.tr("Discharging:"); + } + } + value: { + if (Battery.chargeState == 4) { + return ""; + } else { + return `${Battery.energyRate.toFixed(2)}W`; + } + } + } + + StyledPopupValueRow { + icon: "heart_check" + label: Translation.tr("Health:") + value: `${(Battery.health).toFixed(1)}%` + } + } +} diff --git a/modules/quickshell/config/modules/ii/bar/CircleUtilButton.qml b/modules/quickshell/config/modules/ii/bar/CircleUtilButton.qml new file mode 100644 index 0000000..bd80a6c --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/CircleUtilButton.qml @@ -0,0 +1,15 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +RippleButton { + id: button + + required default property Item content + property bool extraActiveCondition: false + + implicitHeight: Math.max(content.implicitHeight, 26, content.implicitHeight) + implicitWidth: implicitHeight + contentItem: content + +} diff --git a/modules/quickshell/config/modules/ii/bar/ClockWidget.qml b/modules/quickshell/config/modules/ii/bar/ClockWidget.qml new file mode 100644 index 0000000..1d4b9f8 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/ClockWidget.qml @@ -0,0 +1,49 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts + +Item { + id: root + property bool borderless: Config.options.bar.borderless + property bool showDate: Config.options.bar.verbose + implicitWidth: rowLayout.implicitWidth + implicitHeight: Appearance.sizes.barHeight + + RowLayout { + id: rowLayout + anchors.centerIn: parent + spacing: 4 + + StyledText { + font.pixelSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer1 + text: DateTime.time + } + + StyledText { + visible: root.showDate + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnLayer1 + text: "โ€ข" + } + + StyledText { + visible: root.showDate + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnLayer1 + text: DateTime.longDate + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: !Config.options.bar.tooltips.clickToShow + + ClockWidgetPopup { + hoverTarget: mouseArea + } + } +} diff --git a/modules/quickshell/config/modules/ii/bar/ClockWidgetPopup.qml b/modules/quickshell/config/modules/ii/bar/ClockWidgetPopup.qml new file mode 100644 index 0000000..294ccf0 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/ClockWidgetPopup.qml @@ -0,0 +1,76 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts + +StyledPopup { + id: root + property string formattedDate: Qt.locale().toString(DateTime.clock.date, "dddd, MMMM dd, yyyy") + property string formattedTime: Qt.locale().toString(DateTime.clock.date, "HH:mm:ss") + property string formattedUptime: DateTime.uptime + property string todosSection: getUpcomingTodos() + + function getUpcomingTodos() { + const unfinishedTodos = Todo.list.filter(function (item) { + return !item.done; + }); + if (unfinishedTodos.length === 0) { + return Translation.tr("No pending tasks"); + } + + // Limit to first 5 todos to keep popup manageable + const limitedTodos = unfinishedTodos.slice(0, 5); + let todoText = limitedTodos.map(function (item, index) { + return ` ${index + 1}. ${item.content}`; + }).join('\n'); + + if (unfinishedTodos.length > 5) { + todoText += `\n ${Translation.tr("... and %1 more").arg(unfinishedTodos.length - 5)}`; + } + + return todoText; + } + + ColumnLayout { + id: columnLayout + anchors.centerIn: parent + spacing: 4 + + StyledPopupHeaderRow { + icon: "calendar_month" + label: root.formattedDate + } + + StyledPopupValueRow { + icon: "schedule" + label: Translation.tr("Current time:") + value: root.formattedTime + } + + StyledPopupValueRow { + icon: "timelapse" + label: Translation.tr("System uptime:") + value: root.formattedUptime + } + + // Tasks + Column { + spacing: 0 + Layout.fillWidth: true + + StyledPopupValueRow { + icon: "checklist" + label: Translation.tr("To Do:") + value: "" + } + + StyledText { + horizontalAlignment: Text.AlignLeft + wrapMode: Text.Wrap + color: Appearance.colors.colOnSurfaceVariant + text: root.todosSection + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/bar/HyprlandXkbIndicator.qml b/modules/quickshell/config/modules/ii/bar/HyprlandXkbIndicator.qml new file mode 100644 index 0000000..a6ca214 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/HyprlandXkbIndicator.qml @@ -0,0 +1,34 @@ +import QtQuick +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +Loader { + id: root + property bool vertical: false + property color color: Appearance.colors.colOnSurfaceVariant + active: HyprlandXkb.layoutCodes.length > 1 + visible: active + + function abbreviateLayoutCode(fullCode) { + return fullCode.split(':').map(layout => { + const baseLayout = layout.split('-')[0]; + return baseLayout.slice(0, 4); + }).join('\n'); + } + + sourceComponent: Item { + implicitWidth: root.vertical ? null : layoutCodeText.implicitWidth + implicitHeight: root.vertical ? layoutCodeText.implicitHeight : null + + StyledText { + id: layoutCodeText + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: abbreviateLayoutCode(HyprlandXkb.currentLayoutCode) + font.pixelSize: text.includes("\n") ? Appearance.font.pixelSize.smallie : Appearance.font.pixelSize.small + color: root.color + animateChange: true + } + } +} diff --git a/modules/quickshell/config/modules/ii/bar/LeftSidebarButton.qml b/modules/quickshell/config/modules/ii/bar/LeftSidebarButton.qml new file mode 100644 index 0000000..1e2f2f4 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/LeftSidebarButton.qml @@ -0,0 +1,55 @@ +import QtQuick +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +RippleButton { + id: root + + property bool showPing: false + + property real buttonPadding: 5 + implicitWidth: distroIcon.width + buttonPadding * 2 + implicitHeight: distroIcon.height + buttonPadding * 2 + buttonRadius: Appearance.rounding.full + colBackgroundHover: Appearance.colors.colLayer1Hover + colRipple: Appearance.colors.colLayer1Active + colBackgroundToggled: Appearance.colors.colSecondaryContainer + colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover + colRippleToggled: Appearance.colors.colSecondaryContainerActive + toggled: GlobalStates.overviewOpen + + onPressed: { + GlobalStates.overviewOpen = !GlobalStates.overviewOpen; + } + + CustomIcon { + id: distroIcon + anchors.centerIn: parent + width: 19.5 + height: 19.5 + source: Config.options.bar.topLeftIcon == 'distro' ? SystemInfo.distroIcon : `${Config.options.bar.topLeftIcon}-symbolic` + colorize: true + color: Appearance.colors.colOnLayer0 + + Rectangle { + opacity: root.showPing ? 1 : 0 + visible: opacity > 0 + anchors { + bottom: parent.bottom + right: parent.right + bottomMargin: -2 + rightMargin: -2 + } + implicitWidth: 8 + implicitHeight: 8 + radius: Appearance.rounding.full + color: Appearance.colors.colTertiary + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/bar/Media.qml b/modules/quickshell/config/modules/ii/bar/Media.qml new file mode 100644 index 0000000..63295d8 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/Media.qml @@ -0,0 +1,89 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +import qs.modules.common.functions + +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Mpris +import Quickshell.Hyprland + +Item { + id: root + property bool borderless: Config.options.bar.borderless + readonly property MprisPlayer activePlayer: MprisController.activePlayer + readonly property string cleanedTitle: StringUtils.cleanMusicTitle(activePlayer?.trackTitle) || Translation.tr("No media") + + Layout.fillHeight: true + implicitWidth: rowLayout.implicitWidth + rowLayout.spacing * 2 + implicitHeight: Appearance.sizes.barHeight + + Timer { + running: activePlayer?.playbackState == MprisPlaybackState.Playing + interval: Config.options.resources.updateInterval + repeat: true + onTriggered: activePlayer.positionChanged() + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.MiddleButton | Qt.BackButton | Qt.ForwardButton | Qt.RightButton | Qt.LeftButton + onPressed: (event) => { + if (event.button === Qt.MiddleButton) { + activePlayer.togglePlaying(); + } else if (event.button === Qt.BackButton) { + activePlayer.previous(); + } else if (event.button === Qt.ForwardButton || event.button === Qt.RightButton) { + activePlayer.next(); + } else if (event.button === Qt.LeftButton) { + GlobalStates.mediaControlsOpen = !GlobalStates.mediaControlsOpen + } + } + } + + RowLayout { // Real content + id: rowLayout + + spacing: 4 + anchors.fill: parent + + ClippedFilledCircularProgress { + id: mediaCircProg + Layout.alignment: Qt.AlignVCenter + lineWidth: Appearance.rounding.unsharpen + value: activePlayer?.position / activePlayer?.length + implicitSize: 20 + colPrimary: Appearance.colors.colOnSecondaryContainer + enableAnimation: false + + Item { + anchors.centerIn: parent + width: mediaCircProg.implicitSize + height: mediaCircProg.implicitSize + + MaterialSymbol { + anchors.centerIn: parent + fill: 1 + text: activePlayer?.isPlaying ? "pause" : "music_note" + iconSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onSecondaryContainer + } + } + } + + StyledText { + visible: Config.options.bar.verbose + width: rowLayout.width - (CircularProgress.size + rowLayout.spacing * 2) + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true // Ensures the text takes up available space + Layout.rightMargin: rowLayout.spacing + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight // Truncates the text on the right + color: Appearance.colors.colOnLayer1 + text: `${cleanedTitle}${activePlayer?.trackArtist ? ' โ€ข ' + activePlayer.trackArtist : ''}` + } + + } + +} diff --git a/modules/quickshell/config/modules/ii/bar/NotificationUnreadCount.qml b/modules/quickshell/config/modules/ii/bar/NotificationUnreadCount.qml new file mode 100644 index 0000000..baf7351 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/NotificationUnreadCount.qml @@ -0,0 +1,38 @@ +import QtQuick +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +MaterialSymbol { + id: root + readonly property bool showUnreadCount: Config.options.bar.indicators.notifications.showUnreadCount + text: Notifications.silent ? "notifications_paused" : "notifications" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + + Rectangle { + id: notifPing + visible: !Notifications.silent && Notifications.unread > 0 + anchors { + right: parent.right + top: parent.top + rightMargin: root.showUnreadCount ? 0 : 1 + topMargin: root.showUnreadCount ? 0 : 3 + } + radius: Appearance.rounding.full + color: Appearance.colors.colOnLayer0 + z: 1 + + implicitHeight: root.showUnreadCount ? Math.max(notificationCounterText.implicitWidth, notificationCounterText.implicitHeight) : 8 + implicitWidth: implicitHeight + + StyledText { + id: notificationCounterText + visible: root.showUnreadCount + anchors.centerIn: parent + font.pixelSize: Appearance.font.pixelSize.smallest + color: Appearance.colors.colLayer0 + text: Notifications.unread + } + } +} diff --git a/modules/quickshell/config/modules/ii/bar/Resource.qml b/modules/quickshell/config/modules/ii/bar/Resource.qml new file mode 100644 index 0000000..7c626a5 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/Resource.qml @@ -0,0 +1,92 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +Item { + id: root + required property string iconName + required property double percentage + property int warningThreshold: 100 + property bool shown: true + clip: true + visible: width > 0 && height > 0 + implicitWidth: resourceRowLayout.x < 0 ? 0 : resourceRowLayout.implicitWidth + implicitHeight: Appearance.sizes.barHeight + property bool warning: percentage * 100 >= warningThreshold + + RowLayout { + id: resourceRowLayout + spacing: 2 + x: shown ? 0 : -resourceRowLayout.width + anchors { + verticalCenter: parent.verticalCenter + } + + ClippedFilledCircularProgress { + id: resourceCircProg + Layout.alignment: Qt.AlignVCenter + lineWidth: Appearance.rounding.unsharpen + value: percentage + implicitSize: 20 + colPrimary: root.warning ? Appearance.colors.colError : Appearance.colors.colOnSecondaryContainer + accountForLightBleeding: !root.warning + enableAnimation: false + + Item { + anchors.centerIn: parent + width: resourceCircProg.implicitSize + height: resourceCircProg.implicitSize + + MaterialSymbol { + anchors.centerIn: parent + font.weight: Font.DemiBold + fill: 1 + text: iconName + iconSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onSecondaryContainer + } + } + } + + Item { + Layout.alignment: Qt.AlignVCenter + implicitWidth: fullPercentageTextMetrics.width + implicitHeight: percentageText.implicitHeight + + TextMetrics { + id: fullPercentageTextMetrics + text: "100" + font.pixelSize: Appearance.font.pixelSize.small + } + + StyledText { + id: percentageText + anchors.centerIn: parent + color: Appearance.colors.colOnLayer1 + font.pixelSize: Appearance.font.pixelSize.small + text: `${Math.round(percentage * 100).toString()}` + } + } + + Behavior on x { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + enabled: resourceRowLayout.x >= 0 && root.width > 0 && root.visible + } + + Behavior on implicitWidth { + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } +} diff --git a/modules/quickshell/config/modules/ii/bar/Resources.qml b/modules/quickshell/config/modules/ii/bar/Resources.qml new file mode 100644 index 0000000..2d9e936 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/Resources.qml @@ -0,0 +1,53 @@ +import qs.modules.common +import qs.services +import QtQuick +import QtQuick.Layouts + +MouseArea { + id: root + property bool borderless: Config.options.bar.borderless + property bool alwaysShowAllResources: false + implicitWidth: rowLayout.implicitWidth + rowLayout.anchors.leftMargin + rowLayout.anchors.rightMargin + implicitHeight: Appearance.sizes.barHeight + hoverEnabled: !Config.options.bar.tooltips.clickToShow + + RowLayout { + id: rowLayout + + spacing: 0 + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + + Resource { + iconName: "memory" + percentage: ResourceUsage.memoryUsedPercentage + warningThreshold: Config.options.bar.resources.memoryWarningThreshold + } + + Resource { + iconName: "swap_horiz" + percentage: ResourceUsage.swapUsedPercentage + shown: (Config.options.bar.resources.alwaysShowSwap && percentage > 0) || + (MprisController.activePlayer?.trackTitle == null) || + root.alwaysShowAllResources + Layout.leftMargin: shown ? 6 : 0 + warningThreshold: Config.options.bar.resources.swapWarningThreshold + } + + Resource { + iconName: "planner_review" + percentage: ResourceUsage.cpuUsage + shown: Config.options.bar.resources.alwaysShowCpu || + !(MprisController.activePlayer?.trackTitle?.length > 0) || + root.alwaysShowAllResources + Layout.leftMargin: shown ? 6 : 0 + warningThreshold: Config.options.bar.resources.cpuWarningThreshold + } + + } + + ResourcesPopup { + hoverTarget: root + } +} diff --git a/modules/quickshell/config/modules/ii/bar/ResourcesPopup.qml b/modules/quickshell/config/modules/ii/bar/ResourcesPopup.qml new file mode 100644 index 0000000..40ed727 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/ResourcesPopup.qml @@ -0,0 +1,94 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts + +StyledPopup { + id: root + + // Helper function to format KB to GB + function formatKB(kb) { + return (kb / (1024 * 1024)).toFixed(1) + " GB"; + } + + Row { + anchors.centerIn: parent + spacing: 12 + + Column { + anchors.top: parent.top + spacing: 8 + + StyledPopupHeaderRow { + icon: "memory" + label: "RAM" + } + Column { + spacing: 4 + StyledPopupValueRow { + icon: "clock_loader_60" + label: Translation.tr("Used:") + value: root.formatKB(ResourceUsage.memoryUsed) + } + StyledPopupValueRow { + icon: "check_circle" + label: Translation.tr("Free:") + value: root.formatKB(ResourceUsage.memoryFree) + } + StyledPopupValueRow { + icon: "empty_dashboard" + label: Translation.tr("Total:") + value: root.formatKB(ResourceUsage.memoryTotal) + } + } + } + + Column { + visible: ResourceUsage.swapTotal > 0 + anchors.top: parent.top + spacing: 8 + + StyledPopupHeaderRow { + icon: "swap_horiz" + label: "Swap" + } + Column { + spacing: 4 + StyledPopupValueRow { + icon: "clock_loader_60" + label: Translation.tr("Used:") + value: root.formatKB(ResourceUsage.swapUsed) + } + StyledPopupValueRow { + icon: "check_circle" + label: Translation.tr("Free:") + value: root.formatKB(ResourceUsage.swapFree) + } + StyledPopupValueRow { + icon: "empty_dashboard" + label: Translation.tr("Total:") + value: root.formatKB(ResourceUsage.swapTotal) + } + } + } + + Column { + anchors.top: parent.top + spacing: 8 + + StyledPopupHeaderRow { + icon: "planner_review" + label: "CPU" + } + Column { + spacing: 4 + StyledPopupValueRow { + icon: "bolt" + label: Translation.tr("Load:") + value: `${Math.round(ResourceUsage.cpuUsage * 100)}%` + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/bar/ScrollHint.qml b/modules/quickshell/config/modules/ii/bar/ScrollHint.qml new file mode 100644 index 0000000..e63d64d --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/ScrollHint.qml @@ -0,0 +1,60 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +Revealer { // Scroll hint + id: root + property string icon + property string side: "left" + property string tooltipText: "" + + MouseArea { + id: mouseArea + anchors.right: root.side === "left" ? parent.right : undefined + anchors.left: root.side === "right" ? parent.left : undefined + implicitWidth: contentColumn.implicitWidth + implicitHeight: contentColumn.implicitHeight + property bool hovered: false + + hoverEnabled: true + onEntered: hovered = true + onExited: hovered = false + acceptedButtons: Qt.NoButton + + property bool showHintTimedOut: false + onHoveredChanged: showHintTimedOut = false + Timer { + running: mouseArea.hovered + interval: 500 + onTriggered: mouseArea.showHintTimedOut = true + } + + PopupToolTip { + extraVisibleCondition: (tooltipText.length > 0 && mouseArea.showHintTimedOut) + text: tooltipText + } + + Column { + id: contentColumn + anchors { + fill: parent + } + spacing: -5 + MaterialSymbol { + text: "keyboard_arrow_up" + iconSize: 14 + color: Appearance.colors.colSubtext + } + MaterialSymbol { + text: root.icon + iconSize: 14 + color: Appearance.colors.colSubtext + } + MaterialSymbol { + text: "keyboard_arrow_down" + iconSize: 14 + color: Appearance.colors.colSubtext + } + } + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/ii/bar/StyledPopup.qml b/modules/quickshell/config/modules/ii/bar/StyledPopup.qml new file mode 100644 index 0000000..650d770 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/StyledPopup.qml @@ -0,0 +1,81 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Wayland + +LazyLoader { + id: root + + property Item hoverTarget + default property Item contentItem + property real popupBackgroundMargin: 0 + + active: hoverTarget && hoverTarget.containsMouse + + component: PanelWindow { + id: popupWindow + color: "transparent" + + anchors.left: !Config.options.bar.vertical || (Config.options.bar.vertical && !Config.options.bar.bottom) + anchors.right: Config.options.bar.vertical && Config.options.bar.bottom + anchors.top: Config.options.bar.vertical || (!Config.options.bar.vertical && !Config.options.bar.bottom) + anchors.bottom: !Config.options.bar.vertical && Config.options.bar.bottom + + implicitWidth: popupBackground.implicitWidth + Appearance.sizes.elevationMargin * 2 + root.popupBackgroundMargin + implicitHeight: popupBackground.implicitHeight + Appearance.sizes.elevationMargin * 2 + root.popupBackgroundMargin + + mask: Region { + item: popupBackground + } + + exclusionMode: ExclusionMode.Ignore + exclusiveZone: 0 + margins { + left: { + if (!Config.options.bar.vertical) return root.QsWindow?.mapFromItem( + root.hoverTarget, + (root.hoverTarget.width - popupBackground.implicitWidth) / 2, 0 + ).x; + return Appearance.sizes.verticalBarWidth + } + top: { + if (!Config.options.bar.vertical) return Appearance.sizes.barHeight; + return root.QsWindow?.mapFromItem( + root.hoverTarget, + (root.hoverTarget.height - popupBackground.implicitHeight) / 2, 0 + ).y; + } + right: Appearance.sizes.verticalBarWidth + bottom: Appearance.sizes.barHeight + } + WlrLayershell.namespace: "quickshell:popup" + WlrLayershell.layer: WlrLayer.Overlay + + StyledRectangularShadow { + target: popupBackground + } + + Rectangle { + id: popupBackground + readonly property real margin: 10 + anchors { + fill: parent + leftMargin: Appearance.sizes.elevationMargin + root.popupBackgroundMargin * (!popupWindow.anchors.left) + rightMargin: Appearance.sizes.elevationMargin + root.popupBackgroundMargin * (!popupWindow.anchors.right) + topMargin: Appearance.sizes.elevationMargin + root.popupBackgroundMargin * (!popupWindow.anchors.top) + bottomMargin: Appearance.sizes.elevationMargin + root.popupBackgroundMargin * (!popupWindow.anchors.bottom) + } + implicitWidth: root.contentItem.implicitWidth + margin * 2 + implicitHeight: root.contentItem.implicitHeight + margin * 2 + color: Appearance.m3colors.m3surfaceContainer + radius: Appearance.rounding.small + children: [root.contentItem] + + border.width: 1 + border.color: Appearance.colors.colLayer0Border + } + } +} diff --git a/modules/quickshell/config/modules/ii/bar/StyledPopupHeaderRow.qml b/modules/quickshell/config/modules/ii/bar/StyledPopupHeaderRow.qml new file mode 100644 index 0000000..f5c7ba4 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/StyledPopupHeaderRow.qml @@ -0,0 +1,30 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +Row { + id: root + required property var icon + required property var label + spacing: 5 + + MaterialSymbol { + anchors.verticalCenter: parent.verticalCenter + fill: 0 + font.weight: Font.DemiBold + text: root.icon + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnSurfaceVariant + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: root.label + font { + weight: Font.DemiBold + pixelSize: Appearance.font.pixelSize.normal + } + color: Appearance.colors.colOnSurfaceVariant + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/ii/bar/StyledPopupValueRow.qml b/modules/quickshell/config/modules/ii/bar/StyledPopupValueRow.qml new file mode 100644 index 0000000..de8ac57 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/StyledPopupValueRow.qml @@ -0,0 +1,29 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +RowLayout { + id: root + required property string icon + required property string label + required property string value + spacing: 4 + + MaterialSymbol { + text: root.icon + color: Appearance.colors.colOnSurfaceVariant + iconSize: Appearance.font.pixelSize.large + } + StyledText { + text: root.label + color: Appearance.colors.colOnSurfaceVariant + } + StyledText { + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + visible: root.value !== "" + color: Appearance.colors.colOnSurfaceVariant + text: root.value + } +} diff --git a/modules/quickshell/config/modules/ii/bar/SysTray.qml b/modules/quickshell/config/modules/ii/bar/SysTray.qml new file mode 100644 index 0000000..56d33e0 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/SysTray.qml @@ -0,0 +1,152 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Services.SystemTray +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +Item { + id: root + implicitWidth: gridLayout.implicitWidth + implicitHeight: gridLayout.implicitHeight + property bool vertical: false + property bool invertSide: false + property bool trayOverflowOpen: false + property bool showSeparator: true + property bool showOverflowMenu: true + property var activeMenu: null + + property list pinnedItems: TrayService.pinnedItems + property list unpinnedItems: TrayService.unpinnedItems + onUnpinnedItemsChanged: { + if (unpinnedItems.length == 0) root.closeOverflowMenu(); + } + + function grabFocus() { + focusGrab.active = true; + } + + function setExtraWindowAndGrabFocus(window) { + root.activeMenu = window; + root.grabFocus(); + } + + function releaseFocus() { + focusGrab.active = false; + } + + function closeOverflowMenu() { + focusGrab.active = false; + } + + onTrayOverflowOpenChanged: { + if (root.trayOverflowOpen) { + root.grabFocus(); + } + } + + HyprlandFocusGrab { + id: focusGrab + active: false + windows: [trayOverflowLayout.QsWindow?.window, root.activeMenu] + onCleared: { + root.trayOverflowOpen = false; + if (root.activeMenu) { + root.activeMenu.close(); + root.activeMenu = null; + } + } + } + + GridLayout { + id: gridLayout + columns: root.vertical ? 1 : -1 + anchors.fill: parent + rowSpacing: 8 + columnSpacing: 15 + + RippleButton { + id: trayOverflowButton + visible: root.showOverflowMenu && root.unpinnedItems.length > 0 + toggled: root.trayOverflowOpen + property bool containsMouse: hovered + + downAction: () => root.trayOverflowOpen = !root.trayOverflowOpen + + Layout.fillHeight: !root.vertical + Layout.fillWidth: root.vertical + background.implicitWidth: 24 + background.implicitHeight: 24 + background.anchors.centerIn: this + colBackgroundToggled: Appearance.colors.colSecondaryContainer + colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover + colRippleToggled: Appearance.colors.colSecondaryContainerActive + + contentItem: MaterialSymbol { + anchors.centerIn: parent + iconSize: Appearance.font.pixelSize.larger + text: "expand_more" + horizontalAlignment: Text.AlignHCenter + color: root.trayOverflowOpen ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer2 + rotation: (root.trayOverflowOpen ? 180 : 0) - (90 * root.vertical) + (180 * root.invertSide) + Behavior on rotation { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + + StyledPopup { + id: overflowPopup + hoverTarget: trayOverflowButton + active: root.trayOverflowOpen && root.unpinnedItems.length > 0 + + GridLayout { + id: trayOverflowLayout + anchors.centerIn: parent + columns: Math.ceil(Math.sqrt(root.unpinnedItems.length)) + columnSpacing: 10 + rowSpacing: 10 + + Repeater { + model: root.unpinnedItems + + delegate: SysTrayItem { + required property SystemTrayItem modelData + item: modelData + Layout.fillHeight: !root.vertical + Layout.fillWidth: root.vertical + onMenuClosed: root.releaseFocus(); + onMenuOpened: (qsWindow) => root.setExtraWindowAndGrabFocus(qsWindow); + } + } + } + } + } + + Repeater { + model: ScriptModel { + values: root.pinnedItems + } + + delegate: SysTrayItem { + required property SystemTrayItem modelData + item: modelData + Layout.fillHeight: !root.vertical + Layout.fillWidth: root.vertical + onMenuClosed: root.releaseFocus(); + onMenuOpened: (qsWindow) => { + root.setExtraWindowAndGrabFocus(qsWindow); + } + } + } + + StyledText { + Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colSubtext + text: "โ€ข" + visible: root.showSeparator && SystemTray.items.values.length > 0 + } + } +} diff --git a/modules/quickshell/config/modules/ii/bar/SysTrayItem.qml b/modules/quickshell/config/modules/ii/bar/SysTrayItem.qml new file mode 100644 index 0000000..6230de0 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/SysTrayItem.qml @@ -0,0 +1,99 @@ +import QtQuick +import Quickshell +import Quickshell.Services.SystemTray +import Quickshell.Widgets +import Qt5Compat.GraphicalEffects +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +MouseArea { + id: root + required property SystemTrayItem item + property bool targetMenuOpen: false + + signal menuOpened(qsWindow: var) + signal menuClosed() + + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + implicitWidth: 20 + implicitHeight: 20 + onPressed: (event) => { + switch (event.button) { + case Qt.LeftButton: + item.activate(); + break; + case Qt.RightButton: + if (item.hasMenu) menu.open(); + break; + } + event.accepted = true; + } + onEntered: { + tooltip.text = TrayService.getTooltipForItem(root.item); + } + + Loader { + id: menu + function open() { + menu.active = true; + } + active: false + sourceComponent: SysTrayMenu { + Component.onCompleted: this.open(); + trayItemMenuHandle: root.item.menu + anchor { + window: root.QsWindow.window + rect.x: root.x + (Config.options.bar.vertical ? 0 : QsWindow.window?.width) + rect.y: root.y + (Config.options.bar.vertical ? QsWindow.window?.height : 0) + rect.height: root.height + rect.width: root.width + edges: Config.options.bar.bottom ? (Edges.Top | Edges.Left) : (Edges.Bottom | Edges.Right) + gravity: Config.options.bar.bottom ? (Edges.Top | Edges.Left) : (Edges.Bottom | Edges.Right) + } + onMenuOpened: (window) => root.menuOpened(window); + onMenuClosed: { + root.menuClosed(); + menu.active = false; + } + } + } + + IconImage { + id: trayIcon + visible: !Config.options.tray.monochromeIcons + source: root.item.icon + anchors.centerIn: parent + width: parent.width + height: parent.height + } + + Loader { + active: Config.options.tray.monochromeIcons + anchors.fill: trayIcon + sourceComponent: Item { + Desaturate { + id: desaturatedIcon + visible: false // There's already color overlay + anchors.fill: parent + source: trayIcon + desaturation: 0.8 // 1.0 means fully grayscale + } + ColorOverlay { + anchors.fill: desaturatedIcon + source: desaturatedIcon + color: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.9) + } + } + } + + PopupToolTip { + id: tooltip + extraVisibleCondition: root.containsMouse + alternativeVisibleCondition: extraVisibleCondition + anchorEdges: (!Config.options.bar.bottom && !Config.options.bar.vertical) ? Edges.Bottom : Edges.Top + } + +} diff --git a/modules/quickshell/config/modules/ii/bar/SysTrayMenu.qml b/modules/quickshell/config/modules/ii/bar/SysTrayMenu.qml new file mode 100644 index 0000000..8dc6dc6 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/SysTrayMenu.qml @@ -0,0 +1,217 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +PopupWindow { + id: root + required property QsMenuHandle trayItemMenuHandle + property real popupBackgroundMargin: 0 + + signal menuClosed + signal menuOpened(qsWindow: var) // Correct type is QsWindow, but QML does not like that + + color: "transparent" + property real padding: Appearance.sizes.elevationMargin + + implicitHeight: { + let result = 0; + for (let child of stackView.children) { + result = Math.max(child.implicitHeight, result); + } + return result + popupBackground.padding * 2 + root.padding * 2; + } + implicitWidth: { + let result = 0; + for (let child of stackView.children) { + result = Math.max(child.implicitWidth, result); + } + return result + popupBackground.padding * 2 + root.padding * 2; + } + + function open() { + root.visible = true; + root.menuOpened(root); + } + + function close() { + root.visible = false; + while (stackView.depth > 1) + stackView.pop(); + root.menuClosed(); + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.BackButton | Qt.RightButton + onPressed: event => { + if ((event.button === Qt.BackButton || event.button === Qt.RightButton) && stackView.depth > 1) + stackView.pop(); + } + + StyledRectangularShadow { + target: popupBackground + opacity: popupBackground.opacity + } + + Rectangle { + id: popupBackground + readonly property real padding: 4 + anchors { + left: parent.left + right: parent.right + verticalCenter: Config.options.bar.vertical ? parent.verticalCenter : undefined + top: Config.options.bar.vertical ? undefined : Config.options.bar.bottom ? undefined : parent.top + bottom: Config.options.bar.vertical ? undefined : Config.options.bar.bottom ? parent.bottom : undefined + margins: root.padding + } + + color: Appearance.colors.colLayer0 + radius: Appearance.rounding.windowRounding + border.width: 1 + border.color: Appearance.colors.colLayer0Border + clip: true + + opacity: 0 + Component.onCompleted: opacity = 1 + implicitWidth: stackView.implicitWidth + popupBackground.padding * 2 + implicitHeight: stackView.implicitHeight + popupBackground.padding * 2 + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + Behavior on implicitWidth { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + + StackView { + id: stackView + anchors { + fill: parent + margins: popupBackground.padding + } + pushEnter: NoAnim {} + pushExit: NoAnim {} + popEnter: NoAnim {} + popExit: NoAnim {} + + implicitWidth: currentItem.implicitWidth + implicitHeight: currentItem.implicitHeight + + initialItem: SubMenu { + handle: root.trayItemMenuHandle + } + } + } + } + + component NoAnim: Transition { + NumberAnimation { + duration: 0 + } + } + + component SubMenu: ColumnLayout { + id: submenu + required property QsMenuHandle handle + property bool isSubMenu: false + property bool shown: false + opacity: shown ? 1 : 0 + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Component.onCompleted: shown = true + StackView.onActivating: shown = true + StackView.onDeactivating: shown = false + StackView.onRemoved: destroy() + + QsMenuOpener { + id: menuOpener + menu: submenu.handle + } + + spacing: 0 + + Loader { + Layout.fillWidth: true + visible: submenu.isSubMenu + active: visible + sourceComponent: RippleButton { + id: backButton + buttonRadius: popupBackground.radius - popupBackground.padding + horizontalPadding: 12 + implicitWidth: contentItem.implicitWidth + horizontalPadding * 2 + implicitHeight: 36 + + downAction: () => stackView.pop() + + contentItem: RowLayout { + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + right: parent.right + leftMargin: backButton.horizontalPadding + rightMargin: backButton.horizontalPadding + } + spacing: 8 + MaterialSymbol { + iconSize: 20 + text: "chevron_left" + } + StyledText { + Layout.fillWidth: true + text: Translation.tr("Back") + } + } + } + } + + Repeater { + id: menuEntriesRepeater + property bool iconColumnNeeded: { + for (let i = 0; i < menuOpener.children.values.length; i++) { + if (menuOpener.children.values[i].icon.length > 0) + return true; + } + return false; + } + property bool specialInteractionColumnNeeded: { + for (let i = 0; i < menuOpener.children.values.length; i++) { + if (menuOpener.children.values[i].buttonType !== QsMenuButtonType.None) + return true; + } + return false; + } + model: menuOpener.children + delegate: SysTrayMenuEntry { + required property QsMenuEntry modelData + forceIconColumn: menuEntriesRepeater.iconColumnNeeded + forceSpecialInteractionColumn: menuEntriesRepeater.specialInteractionColumnNeeded + menuEntry: modelData + + buttonRadius: popupBackground.radius - popupBackground.padding + + onDismiss: root.close() + onOpenSubmenu: handle => { + stackView.push(subMenuComponent.createObject(null, { + handle: handle, + isSubMenu: true + })); + } + } + } + } + + Component { + id: subMenuComponent + SubMenu {} + } +} diff --git a/modules/quickshell/config/modules/ii/bar/SysTrayMenuEntry.qml b/modules/quickshell/config/modules/ii/bar/SysTrayMenuEntry.qml new file mode 100644 index 0000000..59f3325 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/SysTrayMenuEntry.qml @@ -0,0 +1,126 @@ +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets + +RippleButton { + id: root + required property QsMenuEntry menuEntry + property bool forceIconColumn: false + property bool forceSpecialInteractionColumn: false + readonly property bool hasIcon: menuEntry.icon.length > 0 + readonly property bool hasSpecialInteraction: menuEntry.buttonType !== QsMenuButtonType.None + + signal dismiss() + signal openSubmenu(handle: QsMenuHandle) + + colBackground: menuEntry.isSeparator ? Appearance.m3colors.m3outlineVariant : ColorUtils.transparentize(Appearance.colors.colLayer0) + enabled: !menuEntry.isSeparator + opacity: 1 + + horizontalPadding: 12 + implicitWidth: contentItem.implicitWidth + horizontalPadding * 2 + implicitHeight: menuEntry.isSeparator ? 1 : 36 + Layout.topMargin: menuEntry.isSeparator ? 4 : 0 + Layout.bottomMargin: menuEntry.isSeparator ? 4 : 0 + Layout.fillWidth: true + + Component.onCompleted: { + if (menuEntry.isSeparator) { + root.buttonColor = root.colBackground; + } + } + + releaseAction: () => { + if (menuEntry.hasChildren) { + root.openSubmenu(root.menuEntry); + return; + } + menuEntry.triggered(); + root.dismiss(); + } + altAction: (event) => { // Not hog right-click + event.accepted = false; + } + + contentItem: RowLayout { + id: contentItem + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + right: parent.right + leftMargin: root.horizontalPadding + rightMargin: root.horizontalPadding + } + spacing: 8 + visible: !root.menuEntry.isSeparator + + // Interaction: checkbox or radio button + Item { + visible: root.hasSpecialInteraction || root.forceSpecialInteractionColumn + implicitWidth: 20 + implicitHeight: 20 + + Loader { + anchors.fill: parent + active: root.menuEntry.buttonType === QsMenuButtonType.RadioButton + + sourceComponent: StyledRadioButton { + enabled: false + padding: 0 + checked: root.menuEntry.checkState === Qt.Checked + } + } + + Loader { + anchors.fill: parent + active: root.menuEntry.buttonType === QsMenuButtonType.CheckBox && root.menuEntry.checkState !== Qt.Unchecked + + sourceComponent: MaterialSymbol { + text: root.menuEntry.checkState === Qt.PartiallyChecked ? "check_indeterminate_small" : "check" + iconSize: 20 + } + } + } + + // Button icon + Item { + visible: root.hasIcon || root.forceIconColumn + implicitWidth: 20 + implicitHeight: 20 + + Loader { + anchors.centerIn: parent + active: root.menuEntry.icon.length > 0 + sourceComponent: IconImage { + asynchronous: true + source: root.menuEntry.icon + implicitSize: 20 + mipmap: true + } + } + } + + StyledText { + id: label + text: root.menuEntry.text + font.pixelSize: Appearance.font.pixelSize.smallie + Layout.fillWidth: true + } + + Loader { + active: root.menuEntry.hasChildren + + sourceComponent: MaterialSymbol { + text: "chevron_right" + iconSize: 20 + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/bar/UtilButtons.qml b/modules/quickshell/config/modules/ii/bar/UtilButtons.qml new file mode 100644 index 0000000..abda8a6 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/UtilButtons.qml @@ -0,0 +1,158 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Services.Pipewire +import Quickshell.Services.UPower + +Item { + id: root + property bool borderless: Config.options.bar.borderless + implicitWidth: rowLayout.implicitWidth + rowLayout.spacing * 2 + implicitHeight: rowLayout.implicitHeight + + RowLayout { + id: rowLayout + + spacing: 4 + anchors.centerIn: parent + + Loader { + active: Config.options.bar.utilButtons.showScreenSnip + visible: Config.options.bar.utilButtons.showScreenSnip + sourceComponent: CircleUtilButton { + Layout.alignment: Qt.AlignVCenter + onClicked: Quickshell.execDetached(["qs", "-p", Quickshell.shellPath(""), "ipc", "call", "region", "screenshot"]); + MaterialSymbol { + horizontalAlignment: Qt.AlignHCenter + fill: 1 + text: "screenshot_region" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer2 + } + } + } + + Loader { + active: Config.options.bar.utilButtons.showScreenRecord + visible: Config.options.bar.utilButtons.showScreenRecord + sourceComponent: CircleUtilButton { + Layout.alignment: Qt.AlignVCenter + onClicked: Quickshell.execDetached([Directories.recordScriptPath]) + MaterialSymbol { + horizontalAlignment: Qt.AlignHCenter + fill: 1 + text: "videocam" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer2 + } + } + } + + Loader { + active: Config.options.bar.utilButtons.showColorPicker + visible: Config.options.bar.utilButtons.showColorPicker + sourceComponent: CircleUtilButton { + Layout.alignment: Qt.AlignVCenter + onClicked: Quickshell.execDetached(["hyprpicker", "-a"]) + MaterialSymbol { + horizontalAlignment: Qt.AlignHCenter + fill: 1 + text: "colorize" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer2 + } + } + } + + Loader { + active: Config.options.bar.utilButtons.showKeyboardToggle + visible: Config.options.bar.utilButtons.showKeyboardToggle + sourceComponent: CircleUtilButton { + Layout.alignment: Qt.AlignVCenter + onClicked: GlobalStates.oskOpen = !GlobalStates.oskOpen + MaterialSymbol { + horizontalAlignment: Qt.AlignHCenter + fill: 0 + text: "keyboard" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer2 + } + } + } + + Loader { + active: Config.options.bar.utilButtons.showMicToggle + visible: Config.options.bar.utilButtons.showMicToggle + sourceComponent: CircleUtilButton { + Layout.alignment: Qt.AlignVCenter + onClicked: Quickshell.execDetached(["wpctl", "set-mute", "@DEFAULT_SOURCE@", "toggle"]) + MaterialSymbol { + horizontalAlignment: Qt.AlignHCenter + fill: 0 + text: Pipewire.defaultAudioSource?.audio?.muted ? "mic_off" : "mic" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer2 + } + } + } + + Loader { + active: Config.options.bar.utilButtons.showDarkModeToggle + visible: Config.options.bar.utilButtons.showDarkModeToggle + sourceComponent: CircleUtilButton { + Layout.alignment: Qt.AlignVCenter + onClicked: event => { + if (Appearance.m3colors.darkmode) { + Hyprland.dispatch(`exec ${Directories.wallpaperSwitchScriptPath} --mode light --noswitch`); + } else { + Hyprland.dispatch(`exec ${Directories.wallpaperSwitchScriptPath} --mode dark --noswitch`); + } + } + MaterialSymbol { + horizontalAlignment: Qt.AlignHCenter + fill: 0 + text: Appearance.m3colors.darkmode ? "light_mode" : "dark_mode" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer2 + } + } + } + + Loader { + active: Config.options.bar.utilButtons.showPerformanceProfileToggle + visible: Config.options.bar.utilButtons.showPerformanceProfileToggle + sourceComponent: CircleUtilButton { + Layout.alignment: Qt.AlignVCenter + onClicked: event => { + if (PowerProfiles.hasPerformanceProfile) { + switch(PowerProfiles.profile) { + case PowerProfile.PowerSaver: PowerProfiles.profile = PowerProfile.Balanced + break; + case PowerProfile.Balanced: PowerProfiles.profile = PowerProfile.Performance + break; + case PowerProfile.Performance: PowerProfiles.profile = PowerProfile.PowerSaver + break; + } + } else { + PowerProfiles.profile = PowerProfiles.profile == PowerProfile.Balanced ? PowerProfile.PowerSaver : PowerProfile.Balanced + } + } + MaterialSymbol { + horizontalAlignment: Qt.AlignHCenter + fill: 0 + text: switch(PowerProfiles.profile) { + case PowerProfile.PowerSaver: return "energy_savings_leaf" + case PowerProfile.Balanced: return "airwave" + case PowerProfile.Performance: return "local_fire_department" + } + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer2 + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/bar/Workspaces.qml b/modules/quickshell/config/modules/ii/bar/Workspaces.qml new file mode 100644 index 0000000..f0a61ef --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/Workspaces.qml @@ -0,0 +1,319 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.models +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import Quickshell.Widgets +import Qt5Compat.GraphicalEffects + +Item { + id: root + property bool vertical: false + property bool borderless: Config.options.bar.borderless + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.QsWindow.window?.screen) + readonly property Toplevel activeWindow: ToplevelManager.activeToplevel + + readonly property int workspacesShown: Config.options.bar.workspaces.shown + readonly property int workspaceGroup: Math.floor((monitor?.activeWorkspace?.id - 1) / root.workspacesShown) + property list workspaceOccupied: [] + property int widgetPadding: 4 + property int workspaceButtonWidth: 26 + property real activeWorkspaceMargin: 2 + property real workspaceIconSize: workspaceButtonWidth * 0.69 + property real workspaceIconSizeShrinked: workspaceButtonWidth * 0.55 + property real workspaceIconOpacityShrinked: 1 + property real workspaceIconMarginShrinked: -4 + property int workspaceIndexInGroup: (monitor?.activeWorkspace?.id - 1) % root.workspacesShown + + property bool showNumbers: false + Timer { + id: showNumbersTimer + interval: (Config?.options.bar.autoHide.showWhenPressingSuper.delay ?? 100) + repeat: false + onTriggered: { + root.showNumbers = true + } + } + Connections { + target: GlobalStates + function onSuperDownChanged() { + if (!Config?.options.bar.autoHide.showWhenPressingSuper.enable) return; + if (GlobalStates.superDown) showNumbersTimer.restart(); + else { + showNumbersTimer.stop(); + root.showNumbers = false; + } + } + function onSuperReleaseMightTriggerChanged() { + showNumbersTimer.stop() + } + } + + // Function to update workspaceOccupied + function updateWorkspaceOccupied() { + workspaceOccupied = Array.from({ length: root.workspacesShown }, (_, i) => { + return Hyprland.workspaces.values.some(ws => ws.id === workspaceGroup * root.workspacesShown + i + 1); + }) + } + + // Occupied workspace updates + Component.onCompleted: updateWorkspaceOccupied() + Connections { + target: Hyprland.workspaces + function onValuesChanged() { + updateWorkspaceOccupied(); + } + } + Connections { + target: Hyprland + function onFocusedWorkspaceChanged() { + updateWorkspaceOccupied(); + } + } + onWorkspaceGroupChanged: { + updateWorkspaceOccupied(); + } + + implicitWidth: root.vertical ? Appearance.sizes.verticalBarWidth : (root.workspaceButtonWidth * root.workspacesShown) + implicitHeight: root.vertical ? (root.workspaceButtonWidth * root.workspacesShown) : Appearance.sizes.barHeight + + // Scroll to switch workspaces + WheelHandler { + onWheel: (event) => { + if (event.angleDelta.y < 0) + Hyprland.dispatch(`workspace r+1`); + else if (event.angleDelta.y > 0) + Hyprland.dispatch(`workspace r-1`); + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.BackButton + onPressed: (event) => { + if (event.button === Qt.BackButton) { + Hyprland.dispatch(`togglespecialworkspace`); + } + } + } + + // Workspaces - background + Grid { + z: 1 + anchors.centerIn: parent + + rowSpacing: 0 + columnSpacing: 0 + columns: root.vertical ? 1 : root.workspacesShown + rows: root.vertical ? root.workspacesShown : 1 + + Repeater { + model: root.workspacesShown + + Rectangle { + z: 1 + implicitWidth: workspaceButtonWidth + implicitHeight: workspaceButtonWidth + radius: (width / 2) + property var previousOccupied: (workspaceOccupied[index-1] && !(!activeWindow?.activated && monitor?.activeWorkspace?.id === index)) + property var rightOccupied: (workspaceOccupied[index+1] && !(!activeWindow?.activated && monitor?.activeWorkspace?.id === index+2)) + property var radiusPrev: previousOccupied ? 0 : (width / 2) + property var radiusNext: rightOccupied ? 0 : (width / 2) + + topLeftRadius: radiusPrev + bottomLeftRadius: root.vertical ? radiusNext : radiusPrev + topRightRadius: root.vertical ? radiusPrev : radiusNext + bottomRightRadius: radiusNext + + color: ColorUtils.transparentize(Appearance.m3colors.m3secondaryContainer, 0.4) + opacity: (workspaceOccupied[index] && !(!activeWindow?.activated && monitor?.activeWorkspace?.id === index+1)) ? 1 : 0 + + Behavior on opacity { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on radiusPrev { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on radiusNext { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + } + + } + + } + + // Active workspace + Rectangle { + z: 2 + // Make active ws indicator, which has a brighter color, smaller to look like it is of the same size as ws occupied highlight + radius: Appearance.rounding.full + color: Appearance.colors.colPrimary + + anchors { + verticalCenter: vertical ? undefined : parent.verticalCenter + horizontalCenter: vertical ? parent.horizontalCenter : undefined + } + + AnimatedTabIndexPair { + id: idxPair + index: root.workspaceIndexInGroup + } + property real indicatorPosition: Math.min(idxPair.idx1, idxPair.idx2) * workspaceButtonWidth + root.activeWorkspaceMargin + property real indicatorLength: Math.abs(idxPair.idx1 - idxPair.idx2) * workspaceButtonWidth + workspaceButtonWidth - root.activeWorkspaceMargin * 2 + property real indicatorThickness: workspaceButtonWidth - root.activeWorkspaceMargin * 2 + + x: root.vertical ? null : indicatorPosition + implicitWidth: root.vertical ? indicatorThickness : indicatorLength + y: root.vertical ? indicatorPosition : null + implicitHeight: root.vertical ? indicatorLength : indicatorThickness + + } + + // Workspaces - numbers + Grid { + z: 3 + + columns: root.vertical ? 1 : root.workspacesShown + rows: root.vertical ? root.workspacesShown : 1 + columnSpacing: 0 + rowSpacing: 0 + + anchors.fill: parent + + Repeater { + model: root.workspacesShown + + Button { + id: button + property int workspaceValue: workspaceGroup * root.workspacesShown + index + 1 + implicitHeight: vertical ? Appearance.sizes.verticalBarWidth : Appearance.sizes.barHeight + implicitWidth: vertical ? Appearance.sizes.verticalBarWidth : Appearance.sizes.verticalBarWidth + onPressed: Hyprland.dispatch(`workspace ${workspaceValue}`) + width: vertical ? undefined : workspaceButtonWidth + height: vertical ? workspaceButtonWidth : undefined + + background: Item { + id: workspaceButtonBackground + implicitWidth: workspaceButtonWidth + implicitHeight: workspaceButtonWidth + property var biggestWindow: HyprlandData.biggestWindowForWorkspace(button.workspaceValue) + property var mainAppIconSource: Quickshell.iconPath(AppSearch.guessIcon(biggestWindow?.class), "image-missing") + + StyledText { // Workspace number text + opacity: root.showNumbers + || ((Config.options?.bar.workspaces.alwaysShowNumbers && (!Config.options?.bar.workspaces.showAppIcons || !workspaceButtonBackground.biggestWindow || root.showNumbers)) + || (root.showNumbers && !Config.options?.bar.workspaces.showAppIcons) + ) ? 1 : 0 + z: 3 + + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font { + pixelSize: Appearance.font.pixelSize.small - ((text.length - 1) * (text !== "10") * 2) + family: Config.options?.bar.workspaces.useNerdFont ? Appearance.font.family.iconNerd : defaultFont + } + text: Config.options?.bar.workspaces.numberMap[button.workspaceValue - 1] || button.workspaceValue + elide: Text.ElideRight + color: (monitor?.activeWorkspace?.id == button.workspaceValue) ? + Appearance.m3colors.m3onPrimary : + (workspaceOccupied[index] ? Appearance.m3colors.m3onSecondaryContainer : + Appearance.colors.colOnLayer1Inactive) + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + Rectangle { // Dot instead of ws number + id: wsDot + opacity: (Config.options?.bar.workspaces.alwaysShowNumbers + || root.showNumbers + || (Config.options?.bar.workspaces.showAppIcons && workspaceButtonBackground.biggestWindow) + ) ? 0 : 1 + visible: opacity > 0 + anchors.centerIn: parent + width: workspaceButtonWidth * 0.18 + height: width + radius: width / 2 + color: (monitor?.activeWorkspace?.id == button.workspaceValue) ? + Appearance.m3colors.m3onPrimary : + (workspaceOccupied[index] ? Appearance.m3colors.m3onSecondaryContainer : + Appearance.colors.colOnLayer1Inactive) + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + Item { // Main app icon + anchors.centerIn: parent + width: workspaceButtonWidth + height: workspaceButtonWidth + opacity: !Config.options?.bar.workspaces.showAppIcons ? 0 : + (workspaceButtonBackground.biggestWindow && !root.showNumbers && Config.options?.bar.workspaces.showAppIcons) ? + 1 : workspaceButtonBackground.biggestWindow ? workspaceIconOpacityShrinked : 0 + visible: opacity > 0 + IconImage { + id: mainAppIcon + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.bottomMargin: (!root.showNumbers && Config.options?.bar.workspaces.showAppIcons) ? + (workspaceButtonWidth - workspaceIconSize) / 2 : workspaceIconMarginShrinked + anchors.rightMargin: (!root.showNumbers && Config.options?.bar.workspaces.showAppIcons) ? + (workspaceButtonWidth - workspaceIconSize) / 2 : workspaceIconMarginShrinked + + source: workspaceButtonBackground.mainAppIconSource + implicitSize: (!root.showNumbers && Config.options?.bar.workspaces.showAppIcons) ? workspaceIconSize : workspaceIconSizeShrinked + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on anchors.bottomMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on anchors.rightMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on implicitSize { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + + Loader { + active: Config.options.bar.workspaces.monochromeIcons + anchors.fill: mainAppIcon + sourceComponent: Item { + Desaturate { + id: desaturatedIcon + visible: false // There's already color overlay + anchors.fill: parent + source: mainAppIcon + desaturation: 0.8 + } + ColorOverlay { + anchors.fill: desaturatedIcon + source: desaturatedIcon + color: ColorUtils.transparentize(wsDot.color, 0.9) + } + } + } + } + } + + + } + + } + + } + +} diff --git a/modules/quickshell/config/modules/ii/bar/weather/WeatherBar.qml b/modules/quickshell/config/modules/ii/bar/weather/WeatherBar.qml new file mode 100644 index 0000000..e82f41d --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/weather/WeatherBar.qml @@ -0,0 +1,56 @@ +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import Quickshell +import QtQuick +import QtQuick.Layouts + +MouseArea { + id: root + property bool hovered: false + implicitWidth: rowLayout.implicitWidth + 10 * 2 + implicitHeight: Appearance.sizes.barHeight + + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: !Config.options.bar.tooltips.clickToShow + + onPressed: { + if (mouse.button === Qt.RightButton) { + Weather.getData(); + Quickshell.execDetached(["notify-send", + Translation.tr("Weather"), + Translation.tr("Refreshing (manually triggered)") + , "-a", "Shell" + ]) + mouse.accepted = false + } + } + + RowLayout { + id: rowLayout + anchors.centerIn: parent + + MaterialSymbol { + fill: 0 + text: Icons.getWeatherIcon(Weather.data.wCode) ?? "cloud" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer1 + Layout.alignment: Qt.AlignVCenter + } + + StyledText { + visible: true + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnLayer1 + text: Weather.data?.temp ?? "--ยฐ" + Layout.alignment: Qt.AlignVCenter + } + } + + WeatherPopup { + id: weatherPopup + hoverTarget: root + } +} diff --git a/modules/quickshell/config/modules/ii/bar/weather/WeatherCard.qml b/modules/quickshell/config/modules/ii/bar/weather/WeatherCard.qml new file mode 100644 index 0000000..e75dda3 --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/weather/WeatherCard.qml @@ -0,0 +1,44 @@ +import QtQuick +import QtQuick.Layouts + +import qs.modules.common +import qs.modules.common.widgets + +Rectangle { + id: root + radius: Appearance.rounding.small + color: Appearance.colors.colSurfaceContainerHigh + implicitWidth: columnLayout.implicitWidth + 14 * 2 + implicitHeight: columnLayout.implicitHeight + 14 * 2 + Layout.fillWidth: parent + + property alias title: title.text + property alias value: value.text + property alias symbol: symbol.text + + ColumnLayout { + id: columnLayout + anchors.fill: parent + spacing: -10 + RowLayout { + Layout.alignment: Qt.AlignHCenter + MaterialSymbol { + id: symbol + fill: 0 + iconSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colOnSurfaceVariant + } + StyledText { + id: title + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colOnSurfaceVariant + } + } + StyledText { + id: value + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSurfaceVariant + } + } +} diff --git a/modules/quickshell/config/modules/ii/bar/weather/WeatherPopup.qml b/modules/quickshell/config/modules/ii/bar/weather/WeatherPopup.qml new file mode 100644 index 0000000..85d9c1b --- /dev/null +++ b/modules/quickshell/config/modules/ii/bar/weather/WeatherPopup.qml @@ -0,0 +1,104 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +import QtQuick +import QtQuick.Layouts +import qs.modules.ii.bar + +StyledPopup { + id: root + + ColumnLayout { + id: columnLayout + anchors.centerIn: parent + implicitWidth: Math.max(header.implicitWidth, gridLayout.implicitWidth) + implicitHeight: gridLayout.implicitHeight + spacing: 5 + + // Header + ColumnLayout { + id: header + Layout.alignment: Qt.AlignHCenter + spacing: 2 + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 6 + + MaterialSymbol { + fill: 0 + font.weight: Font.Medium + text: "location_on" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnSurfaceVariant + } + + StyledText { + text: Weather.data.city + font { + weight: Font.Medium + pixelSize: Appearance.font.pixelSize.normal + } + color: Appearance.colors.colOnSurfaceVariant + } + } + StyledText { + id: temp + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colOnSurfaceVariant + text: Weather.data.temp + " โ€ข " + Translation.tr("Feels like %1").arg(Weather.data.tempFeelsLike) + } + } + + // Metrics grid + GridLayout { + id: gridLayout + columns: 2 + rowSpacing: 5 + columnSpacing: 5 + uniformCellWidths: true + + WeatherCard { + title: Translation.tr("UV Index") + symbol: "wb_sunny" + value: Weather.data.uv + } + WeatherCard { + title: Translation.tr("Wind") + symbol: "air" + value: `(${Weather.data.windDir}) ${Weather.data.wind}` + } + WeatherCard { + title: Translation.tr("Precipitation") + symbol: "rainy_light" + value: Weather.data.precip + } + WeatherCard { + title: Translation.tr("Humidity") + symbol: "humidity_low" + value: Weather.data.humidity + } + WeatherCard { + title: Translation.tr("Visibility") + symbol: "visibility" + value: Weather.data.visib + } + WeatherCard { + title: Translation.tr("Pressure") + symbol: "readiness_score" + value: Weather.data.press + } + WeatherCard { + title: Translation.tr("Sunrise") + symbol: "wb_twilight" + value: Weather.data.sunrise + } + WeatherCard { + title: Translation.tr("Sunset") + symbol: "bedtime" + value: Weather.data.sunset + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/dock/Dock.qml b/modules/quickshell/config/modules/ii/dock/Dock.qml new file mode 100644 index 0000000..c611155 --- /dev/null +++ b/modules/quickshell/config/modules/ii/dock/Dock.qml @@ -0,0 +1,148 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { // Scope + id: root + property bool pinned: Config.options?.dock.pinnedOnStartup ?? false + + Variants { + // For each monitor + model: Quickshell.screens + + PanelWindow { + id: dockRoot + // Window + required property var modelData + screen: modelData + visible: !GlobalStates.screenLocked + + property bool reveal: root.pinned || (Config.options?.dock.hoverToReveal && dockMouseArea.containsMouse) || dockApps.requestDockShow || (!ToplevelManager.activeToplevel?.activated) + + anchors { + bottom: true + left: true + right: true + } + + exclusiveZone: root.pinned ? implicitHeight - (Appearance.sizes.hyprlandGapsOut) - (Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut) : 0 + + implicitWidth: dockBackground.implicitWidth + WlrLayershell.namespace: "quickshell:dock" + color: "transparent" + + implicitHeight: (Config.options?.dock.height ?? 70) + Appearance.sizes.elevationMargin + Appearance.sizes.hyprlandGapsOut + + mask: Region { + item: dockMouseArea + } + + MouseArea { + id: dockMouseArea + height: parent.height + anchors { + top: parent.top + topMargin: dockRoot.reveal ? 0 : Config.options?.dock.hoverToReveal ? (dockRoot.implicitHeight - Config.options.dock.hoverRegionHeight) : (dockRoot.implicitHeight + 1) + horizontalCenter: parent.horizontalCenter + } + implicitWidth: dockHoverRegion.implicitWidth + Appearance.sizes.elevationMargin * 2 + hoverEnabled: true + + Behavior on anchors.topMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Item { + id: dockHoverRegion + anchors.fill: parent + implicitWidth: dockBackground.implicitWidth + + Item { // Wrapper for the dock background + id: dockBackground + anchors { + top: parent.top + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + } + + implicitWidth: dockRow.implicitWidth + 5 * 2 + height: parent.height - Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut + + StyledRectangularShadow { + target: dockVisualBackground + } + Rectangle { // The real rectangle that is visible + id: dockVisualBackground + property real margin: Appearance.sizes.elevationMargin + anchors.fill: parent + anchors.topMargin: Appearance.sizes.elevationMargin + anchors.bottomMargin: Appearance.sizes.hyprlandGapsOut + color: Appearance.colors.colLayer0 + border.width: 1 + border.color: Appearance.colors.colLayer0Border + radius: Appearance.rounding.large + } + + RowLayout { + id: dockRow + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + spacing: 3 + property real padding: 5 + + VerticalButtonGroup { + Layout.topMargin: Appearance.sizes.hyprlandGapsOut // why does this work + GroupButton { + // Pin button + baseWidth: 35 + baseHeight: 35 + clickedWidth: baseWidth + clickedHeight: baseHeight + 20 + buttonRadius: Appearance.rounding.normal + toggled: root.pinned + onClicked: root.pinned = !root.pinned + contentItem: MaterialSymbol { + text: "keep" + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + color: root.pinned ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer0 + } + } + } + DockSeparator {} + DockApps { + id: dockApps + buttonPadding: dockRow.padding + } + DockSeparator {} + DockButton { + Layout.fillHeight: true + onClicked: GlobalStates.overviewOpen = !GlobalStates.overviewOpen + topInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding + bottomInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding + contentItem: MaterialSymbol { + anchors.fill: parent + horizontalAlignment: Text.AlignHCenter + font.pixelSize: parent.width / 2 + text: "apps" + color: Appearance.colors.colOnLayer0 + } + } + } + } + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/dock/DockAppButton.qml b/modules/quickshell/config/modules/ii/dock/DockAppButton.qml new file mode 100644 index 0000000..8d2d270 --- /dev/null +++ b/modules/quickshell/config/modules/ii/dock/DockAppButton.qml @@ -0,0 +1,132 @@ +import qs.services +import qs.modules.common +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets + +DockButton { + id: root + property var appToplevel + property var appListRoot + property int lastFocused: -1 + property real iconSize: 35 + property real countDotWidth: 10 + property real countDotHeight: 4 + property bool appIsActive: appToplevel.toplevels.find(t => (t.activated == true)) !== undefined + + readonly property bool isSeparator: appToplevel.appId === "SEPARATOR" + readonly property var desktopEntry: DesktopEntries.heuristicLookup(appToplevel.appId) + enabled: !isSeparator + implicitWidth: isSeparator ? 1 : implicitHeight - topInset - bottomInset + + Loader { + active: isSeparator + anchors { + fill: parent + topMargin: dockVisualBackground.margin + dockRow.padding + Appearance.rounding.normal + bottomMargin: dockVisualBackground.margin + dockRow.padding + Appearance.rounding.normal + } + sourceComponent: DockSeparator {} + } + + Loader { + anchors.fill: parent + active: appToplevel.toplevels.length > 0 + sourceComponent: MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + onEntered: { + appListRoot.lastHoveredButton = root + appListRoot.buttonHovered = true + lastFocused = appToplevel.toplevels.length - 1 + } + onExited: { + if (appListRoot.lastHoveredButton === root) { + appListRoot.buttonHovered = false + } + } + } + } + + onClicked: { + if (appToplevel.toplevels.length === 0) { + root.desktopEntry?.execute(); + return; + } + lastFocused = (lastFocused + 1) % appToplevel.toplevels.length + appToplevel.toplevels[lastFocused].activate() + } + + middleClickAction: () => { + root.desktopEntry?.execute(); + } + + altAction: () => { + TaskbarApps.togglePin(appToplevel.appId); + } + + contentItem: Loader { + active: !isSeparator + sourceComponent: Item { + anchors.centerIn: parent + + Loader { + id: iconImageLoader + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + } + active: !root.isSeparator + sourceComponent: IconImage { + source: Quickshell.iconPath(AppSearch.guessIcon(appToplevel.appId), "image-missing") + implicitSize: root.iconSize + } + } + + Loader { + active: Config.options.dock.monochromeIcons + anchors.fill: iconImageLoader + sourceComponent: Item { + Desaturate { + id: desaturatedIcon + visible: false // There's already color overlay + anchors.fill: parent + source: iconImageLoader + desaturation: 0.8 + } + ColorOverlay { + anchors.fill: desaturatedIcon + source: desaturatedIcon + color: ColorUtils.transparentize(Appearance.colors.colPrimary, 0.9) + } + } + } + + RowLayout { + spacing: 3 + anchors { + top: iconImageLoader.bottom + topMargin: 2 + horizontalCenter: parent.horizontalCenter + } + Repeater { + model: Math.min(appToplevel.toplevels.length, 3) + delegate: Rectangle { + required property int index + radius: Appearance.rounding.full + implicitWidth: (appToplevel.toplevels.length <= 3) ? + root.countDotWidth : root.countDotHeight // Circles when too many + implicitHeight: root.countDotHeight + color: appIsActive ? Appearance.colors.colPrimary : ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.4) + } + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/dock/DockApps.qml b/modules/quickshell/config/modules/ii/dock/DockApps.qml new file mode 100644 index 0000000..d575b7d --- /dev/null +++ b/modules/quickshell/config/modules/ii/dock/DockApps.qml @@ -0,0 +1,229 @@ +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +Item { + id: root + property real maxWindowPreviewHeight: 200 + property real maxWindowPreviewWidth: 300 + property real windowControlsHeight: 30 + property real buttonPadding: 5 + + property Item lastHoveredButton + property bool buttonHovered: false + property bool requestDockShow: previewPopup.show + + Layout.fillHeight: true + Layout.topMargin: Appearance.sizes.hyprlandGapsOut // why does this work + implicitWidth: listView.implicitWidth + + StyledListView { + id: listView + spacing: 2 + orientation: ListView.Horizontal + anchors { + top: parent.top + bottom: parent.bottom + } + implicitWidth: contentWidth + + Behavior on implicitWidth { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + model: ScriptModel { + objectProp: "appId" + values: TaskbarApps.apps + } + delegate: DockAppButton { + required property var modelData + appToplevel: modelData + appListRoot: root + + topInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding + bottomInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding + } + } + + PopupWindow { + id: previewPopup + property var appTopLevel: root.lastHoveredButton?.appToplevel + property bool allPreviewsReady: false + Connections { + target: root + function onLastHoveredButtonChanged() { + previewPopup.allPreviewsReady = false; // Reset readiness when the hovered button changes + } + } + function updatePreviewReadiness() { + for(var i = 0; i < previewRowLayout.children.length; i++) { + const view = previewRowLayout.children[i]; + if (view.hasContent === false) { + allPreviewsReady = false; + return; + } + } + allPreviewsReady = true; + } + property bool shouldShow: { + const hoverConditions = (popupMouseArea.containsMouse || root.buttonHovered) + return hoverConditions && allPreviewsReady; + } + property bool show: false + + onShouldShowChanged: { + if (shouldShow) { + // show = true; + updateTimer.restart(); + } else { + updateTimer.restart(); + } + } + Timer { + id: updateTimer + interval: 100 + onTriggered: { + previewPopup.show = previewPopup.shouldShow + } + } + anchor { + window: root.QsWindow.window + adjustment: PopupAdjustment.None + gravity: Edges.Top | Edges.Right + edges: Edges.Top | Edges.Left + + } + visible: popupBackground.visible + color: "transparent" + implicitWidth: root.QsWindow.window?.width ?? 1 + implicitHeight: popupMouseArea.implicitHeight + root.windowControlsHeight + Appearance.sizes.elevationMargin * 2 + + MouseArea { + id: popupMouseArea + anchors.bottom: parent.bottom + implicitWidth: popupBackground.implicitWidth + Appearance.sizes.elevationMargin * 2 + implicitHeight: root.maxWindowPreviewHeight + root.windowControlsHeight + Appearance.sizes.elevationMargin * 2 + hoverEnabled: true + x: { + const itemCenter = root.QsWindow?.mapFromItem(root.lastHoveredButton, root.lastHoveredButton?.width / 2, 0); + return itemCenter.x - width / 2 + } + StyledRectangularShadow { + target: popupBackground + opacity: previewPopup.show ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + Rectangle { + id: popupBackground + property real padding: 5 + opacity: previewPopup.show ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + clip: true + color: Appearance.colors.colSurfaceContainer + radius: Appearance.rounding.normal + anchors.bottom: parent.bottom + anchors.bottomMargin: Appearance.sizes.elevationMargin + anchors.horizontalCenter: parent.horizontalCenter + implicitHeight: previewRowLayout.implicitHeight + padding * 2 + implicitWidth: previewRowLayout.implicitWidth + padding * 2 + Behavior on implicitWidth { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + RowLayout { + id: previewRowLayout + anchors.centerIn: parent + Repeater { + model: ScriptModel { + values: previewPopup.appTopLevel?.toplevels ?? [] + } + RippleButton { + id: windowButton + required property var modelData + padding: 0 + middleClickAction: () => { + windowButton.modelData?.close(); + } + onClicked: { + windowButton.modelData?.activate(); + } + contentItem: ColumnLayout { + implicitWidth: screencopyView.implicitWidth + implicitHeight: screencopyView.implicitHeight + + ButtonGroup { + contentWidth: parent.width - anchors.margins * 2 + WrapperRectangle { + Layout.fillWidth: true + color: ColorUtils.transparentize(Appearance.colors.colSurfaceContainer) + radius: Appearance.rounding.small + margin: 5 + StyledText { + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.small + text: windowButton.modelData?.title + elide: Text.ElideRight + color: Appearance.m3colors.m3onSurface + } + } + GroupButton { + id: closeButton + colBackground: ColorUtils.transparentize(Appearance.colors.colSurfaceContainer) + baseWidth: windowControlsHeight + baseHeight: windowControlsHeight + buttonRadius: Appearance.rounding.full + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "close" + iconSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onSurface + } + onClicked: { + windowButton.modelData?.close(); + } + } + } + ScreencopyView { + id: screencopyView + captureSource: previewPopup ? windowButton.modelData : null + live: true + paintCursor: true + constraintSize: Qt.size(root.maxWindowPreviewWidth, root.maxWindowPreviewHeight) + onHasContentChanged: { + previewPopup.updatePreviewReadiness(); + } + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: screencopyView.width + height: screencopyView.height + radius: Appearance.rounding.small + } + } + } + } + } + } + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/dock/DockButton.qml b/modules/quickshell/config/modules/ii/dock/DockButton.qml new file mode 100644 index 0000000..4decdae --- /dev/null +++ b/modules/quickshell/config/modules/ii/dock/DockButton.qml @@ -0,0 +1,13 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +RippleButton { + Layout.fillHeight: true + Layout.topMargin: Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut + implicitWidth: implicitHeight - topInset - bottomInset + buttonRadius: Appearance.rounding.normal + + background.implicitHeight: 50 +} diff --git a/modules/quickshell/config/modules/ii/dock/DockSeparator.qml b/modules/quickshell/config/modules/ii/dock/DockSeparator.qml new file mode 100644 index 0000000..f8a1645 --- /dev/null +++ b/modules/quickshell/config/modules/ii/dock/DockSeparator.qml @@ -0,0 +1,11 @@ +import qs.modules.common +import QtQuick +import QtQuick.Layouts + +Rectangle { + Layout.topMargin: Appearance.sizes.elevationMargin + dockRow.padding + Appearance.rounding.normal + Layout.bottomMargin: Appearance.sizes.hyprlandGapsOut + dockRow.padding + Appearance.rounding.normal + Layout.fillHeight: true + implicitWidth: 1 + color: Appearance.colors.colOutlineVariant +} diff --git a/modules/quickshell/config/modules/ii/lock/Lock.qml b/modules/quickshell/config/modules/ii/lock/Lock.qml new file mode 100644 index 0000000..e942f4f --- /dev/null +++ b/modules/quickshell/config/modules/ii/lock/Lock.qml @@ -0,0 +1,188 @@ +pragma ComponentBehavior: Bound +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + + Process { + id: unlockKeyringProc + onExited: (exitCode, exitStatus) => { + KeyringStorage.fetchKeyringData(); + } + } + function unlockKeyring() { + unlockKeyringProc.exec({ + environment: ({ + "UNLOCK_PASSWORD": lockContext.currentText + }), + command: ["bash", "-c", Quickshell.shellPath("scripts/keyring/unlock.sh")] + }) + } + + property var windowData: [] + function saveWindowPositionAndTile() { + Quickshell.execDetached(["hyprctl", "keyword", "dwindle:pseudotile", "true"]) + root.windowData = HyprlandData.windowList.filter(w => (w.floating && w.workspace.id === HyprlandData.activeWorkspace.id)) + root.windowData.forEach(w => { + Hyprland.dispatch(`pseudo address:${w.address}`) + Hyprland.dispatch(`settiled address:${w.address}`) + Hyprland.dispatch(`movetoworkspacesilent ${w.workspace.id},address:${w.address}`) + }) + } + function restoreWindowPositionAndTile() { + root.windowData.forEach(w => { + Hyprland.dispatch(`setfloating address:${w.address}`) + Hyprland.dispatch(`movewindowpixel exact ${w.at[0]} ${w.at[1]}, address:${w.address}`) + Hyprland.dispatch(`pseudo address:${w.address}`) + }) + Quickshell.execDetached(["hyprctl", "keyword", "dwindle:pseudotile", "false"]) + } + + // This stores all the information shared between the lock surfaces on each screen. + // https://github.com/quickshell-mirror/quickshell-examples/tree/master/lockscreen + LockContext { + id: lockContext + + Connections { + target: GlobalStates + function onScreenLockedChanged() { + if (GlobalStates.screenLocked) { + lockContext.reset(); + lockContext.tryFingerUnlock(); + } + } + } + + onUnlocked: (targetAction) => { + // Perform the target action if it's not just unlocking + if (targetAction == LockContext.ActionEnum.Poweroff) { + Session.poweroff(); + return; + } else if (targetAction == LockContext.ActionEnum.Reboot) { + Session.reboot(); + return; + } + + // Unlock the keyring if configured to do so + if (Config.options.lock.security.unlockKeyring) root.unlockKeyring(); + + // Unlock the screen before exiting, or the compositor will display a + // fallback lock you can't interact with. + GlobalStates.screenLocked = false; + + // Refocus last focused window on unlock (hack) + Quickshell.execDetached(["bash", "-c", `sleep 0.2; hyprctl --batch "dispatch togglespecialworkspace; dispatch togglespecialworkspace"`]) + + // Reset + lockContext.reset(); + } + } + + WlSessionLock { + id: lock + locked: GlobalStates.screenLocked + + WlSessionLockSurface { + color: "transparent" + Loader { + active: GlobalStates.screenLocked + anchors.fill: parent + opacity: active ? 1 : 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + sourceComponent: LockSurface { + context: lockContext + } + } + } + } + + // Blur layer hack + Variants { + model: Quickshell.screens + delegate: Scope { + required property ShellScreen modelData + property bool shouldPush: GlobalStates.screenLocked + property string targetMonitorName: modelData.name + property int verticalMovementDistance: modelData.height + property int horizontalSqueeze: modelData.width * 0.2 + onShouldPushChanged: { + if (shouldPush) { + root.saveWindowPositionAndTile(); + Quickshell.execDetached(["bash", "-c", `hyprctl keyword monitor ${targetMonitorName}, addreserved, ${verticalMovementDistance}, ${-verticalMovementDistance}, ${horizontalSqueeze}, ${horizontalSqueeze}`]) + } else { + Quickshell.execDetached(["bash", "-c", `hyprctl keyword monitor ${targetMonitorName}, addreserved, 0, 0, 0, 0`]) + root.restoreWindowPositionAndTile(); + } + } + } + } + + function lock() { + if (Config.options.lock.useHyprlock) { + Quickshell.execDetached(["bash", "-c", "pidof hyprlock || hyprlock"]); + return; + } + GlobalStates.screenLocked = true; + } + + IpcHandler { + target: "lock" + + function activate(): void { + root.lock(); + } + function focus(): void { + lockContext.shouldReFocus(); + } + } + + GlobalShortcut { + name: "lock" + description: "Locks the screen" + + onPressed: { + root.lock() + } + } + + GlobalShortcut { + name: "lockFocus" + description: "Re-focuses the lock screen. This is because Hyprland after waking up for whatever reason" + + "decides to keyboard-unfocus the lock screen" + + onPressed: { + lockContext.shouldReFocus(); + } + } + + function initIfReady() { + if (!Config.ready || !Persistent.ready) return; + if (Config.options.lock.launchOnStartup && Persistent.isNewHyprlandInstance) { + root.lock(); + } else { + KeyringStorage.fetchKeyringData(); + } + } + Connections { + target: Config + function onReadyChanged() { + root.initIfReady(); + } + } + Connections { + target: Persistent + function onReadyChanged() { + root.initIfReady(); + } + } +} diff --git a/modules/quickshell/config/modules/ii/lock/LockContext.qml b/modules/quickshell/config/modules/ii/lock/LockContext.qml new file mode 100644 index 0000000..b242cdc --- /dev/null +++ b/modules/quickshell/config/modules/ii/lock/LockContext.qml @@ -0,0 +1,135 @@ +import qs +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Pam + +Scope { + id: root + + enum ActionEnum { Unlock, Poweroff, Reboot } + + signal shouldReFocus() + signal unlocked(targetAction: var) + signal failed() + + // These properties are in the context and not individual lock surfaces + // so all surfaces can share the same state. + property string currentText: "" + property bool unlockInProgress: false + property bool showFailure: false + property bool fingerprintsConfigured: false + property var targetAction: LockContext.ActionEnum.Unlock + + function resetTargetAction() { + root.targetAction = LockContext.ActionEnum.Unlock; + } + + function clearText() { + root.currentText = ""; + } + + function resetClearTimer() { + passwordClearTimer.restart(); + } + + function reset() { + root.resetTargetAction(); + root.clearText(); + root.unlockInProgress = false; + stopFingerPam(); + } + + Timer { + id: passwordClearTimer + interval: 10000 + onTriggered: { + root.reset(); + } + } + + onCurrentTextChanged: { + if (currentText.length > 0) { + showFailure = false; + GlobalStates.screenUnlockFailed = false; + } + GlobalStates.screenLockContainsCharacters = currentText.length > 0; + passwordClearTimer.restart(); + } + + function tryUnlock() { + root.unlockInProgress = true; + pam.start(); + } + + function tryFingerUnlock() { + if (root.fingerprintsConfigured) { + fingerPam.start(); + } + } + + function stopFingerPam() { + if (fingerPam.running) { + fingerPam.abort(); + } + } + + Process { + id: fingerprintCheckProc + running: true + command: ["bash", "-c", "fprintd-list $(whoami)"] + stdout: StdioCollector { + id: fingerprintOutputCollector + onStreamFinished: { + root.fingerprintsConfigured = fingerprintOutputCollector.text.includes("Fingerprints for user"); + } + } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + // console.warn("[LockContext] fprintd-list command exited with error:", exitCode, exitStatus); + root.fingerprintsConfigured = false; + } + } + } + + PamContext { + id: pam + + // pam_unix will ask for a response for the password prompt + onPamMessage: { + if (this.responseRequired) { + this.respond(root.currentText); + } + } + + // pam_unix won't send any important messages so all we need is the completion status. + onCompleted: result => { + if (result == PamResult.Success) { + root.unlocked(root.targetAction); + stopFingerPam(); + } else { + root.clearText(); + root.unlockInProgress = false; + GlobalStates.screenUnlockFailed = true; + root.showFailure = true; + } + } + } + + PamContext { + id: fingerPam + + configDirectory: "pam" + config: "fprintd.conf" + + onCompleted: result => { + if (result == PamResult.Success) { + root.unlocked(root.targetAction); + stopFingerPam(); + } else if (result == PamResult.Error) { // if timeout or etc.. + tryFingerUnlock() + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/lock/LockSurface.qml b/modules/quickshell/config/modules/ii/lock/LockSurface.qml new file mode 100644 index 0000000..da3e176 --- /dev/null +++ b/modules/quickshell/config/modules/ii/lock/LockSurface.qml @@ -0,0 +1,362 @@ +import QtQuick +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell.Services.UPower +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import qs.modules.ii.bar as Bar +import Quickshell +import Quickshell.Services.SystemTray + +MouseArea { + id: root + required property LockContext context + property bool active: false + property bool showInputField: active || context.currentText.length > 0 + readonly property bool requirePasswordToPower: Config.options.lock.security.requirePasswordToPower + + // Force focus on entry + function forceFieldFocus() { + passwordBox.forceActiveFocus(); + } + Connections { + target: context + function onShouldReFocus() { + forceFieldFocus(); + } + } + hoverEnabled: true + acceptedButtons: Qt.LeftButton + onPressed: mouse => { + forceFieldFocus(); + } + onPositionChanged: mouse => { + forceFieldFocus(); + } + + // Toolbar appearing animation + property real toolbarScale: 0.9 + property real toolbarOpacity: 0 + Behavior on toolbarScale { + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial + } + } + Behavior on toolbarOpacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + // Init + Component.onCompleted: { + forceFieldFocus(); + toolbarScale = 1; + toolbarOpacity = 1; + } + + // Key presses + Keys.onPressed: event => { + root.context.resetClearTimer(); + if (event.key === Qt.Key_Escape) { // Esc to clear + root.context.currentText = ""; + } + forceFieldFocus(); + } + + // RippleButton { + // anchors { + // top: parent.top + // left: parent.left + // leftMargin: 10 + // topMargin: 10 + // } + // implicitHeight: 40 + // colBackground: Appearance.colors.colLayer2 + // onClicked: { + // context.unlocked(LockContext.ActionEnum.Unlock); + // GlobalStates.screenLocked = false; + // } + // contentItem: StyledText { + // text: "[[ DEBUG BYPASS ]]" + // } + // } + + // Main toolbar: password box + Toolbar { + id: mainIsland + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: 20 + } + Behavior on anchors.bottomMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + scale: root.toolbarScale + opacity: root.toolbarOpacity + + // Fingerprint + Loader { + Layout.leftMargin: 10 + Layout.rightMargin: 6 + Layout.alignment: Qt.AlignVCenter + active: root.context.fingerprintsConfigured + visible: active + + sourceComponent: MaterialSymbol { + id: fingerprintIcon + fill: 1 + text: "fingerprint" + iconSize: Appearance.font.pixelSize.hugeass + color: Appearance.colors.colOnSurfaceVariant + } + } + + ToolbarTextField { + id: passwordBox + Layout.rightMargin: -Layout.leftMargin + placeholderText: GlobalStates.screenUnlockFailed ? Translation.tr("Incorrect password") : Translation.tr("Enter password") + + // Style + clip: true + font.pixelSize: Appearance.font.pixelSize.small + + // Password + enabled: !root.context.unlockInProgress + echoMode: TextInput.Password + inputMethodHints: Qt.ImhSensitiveData + + // Synchronizing (across monitors) and unlocking + onTextChanged: root.context.currentText = this.text + onAccepted: root.context.tryUnlock() + Connections { + target: root.context + function onCurrentTextChanged() { + passwordBox.text = root.context.currentText; + } + } + + Keys.onPressed: event => { + root.context.resetClearTimer(); + } + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: passwordBox.width - 8 + height: passwordBox.height + radius: height / 2 + } + } + + // Shake when wrong password + SequentialAnimation { + id: wrongPasswordShakeAnim + NumberAnimation { target: passwordBox; property: "Layout.leftMargin"; to: -30; duration: 50 } + NumberAnimation { target: passwordBox; property: "Layout.leftMargin"; to: 30; duration: 50 } + NumberAnimation { target: passwordBox; property: "Layout.leftMargin"; to: -15; duration: 40 } + NumberAnimation { target: passwordBox; property: "Layout.leftMargin"; to: 15; duration: 40 } + NumberAnimation { target: passwordBox; property: "Layout.leftMargin"; to: 0; duration: 30 } + } + Connections { + target: GlobalStates + function onScreenUnlockFailedChanged() { + if (GlobalStates.screenUnlockFailed) wrongPasswordShakeAnim.restart(); + } + } + + // We're drawing dots manually + property bool materialShapeChars: Config.options.lock.materialShapeChars + color: ColorUtils.transparentize(Appearance.colors.colOnLayer1, materialShapeChars ? 1 : 0) + Loader { + active: passwordBox.materialShapeChars + anchors { + fill: parent + leftMargin: passwordBox.padding + rightMargin: passwordBox.padding + } + sourceComponent: PasswordChars { + length: root.context.currentText.length + } + } + } + + ToolbarButton { + id: confirmButton + implicitWidth: height + toggled: true + enabled: !root.context.unlockInProgress + colBackgroundToggled: Appearance.colors.colPrimary + + onClicked: root.context.tryUnlock() + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + iconSize: 24 + text: { + if (root.context.targetAction === LockContext.ActionEnum.Unlock) { + return "arrow_right_alt"; + } else if (root.context.targetAction === LockContext.ActionEnum.Poweroff) { + return "power_settings_new"; + } else if (root.context.targetAction === LockContext.ActionEnum.Reboot) { + return "restart_alt"; + } + } + color: confirmButton.enabled ? Appearance.colors.colOnPrimary : Appearance.colors.colSubtext + } + } + } + + // Left toolbar + Toolbar { + id: leftIsland + anchors { + right: mainIsland.left + top: mainIsland.top + bottom: mainIsland.bottom + rightMargin: 10 + } + scale: root.toolbarScale + opacity: root.toolbarOpacity + + // Username + IconAndTextPair { + Layout.leftMargin: 8 + icon: "account_circle" + text: SystemInfo.username + } + + // Keyboard layout (Xkb) + Loader { + Layout.rightMargin: 8 + Layout.fillHeight: true + + active: true + visible: active + + sourceComponent: Row { + spacing: 8 + + MaterialSymbol { + id: keyboardIcon + anchors.verticalCenter: parent.verticalCenter + fill: 1 + text: "keyboard_alt" + iconSize: Appearance.font.pixelSize.huge + color: Appearance.colors.colOnSurfaceVariant + } + Loader { + anchors.verticalCenter: parent.verticalCenter + sourceComponent: StyledText { + text: HyprlandXkb.currentLayoutCode + color: Appearance.colors.colOnSurfaceVariant + animateChange: true + } + } + } + } + + // Keyboard layout (Fcitx) + Bar.SysTray { + Layout.rightMargin: 10 + Layout.alignment: Qt.AlignVCenter + showSeparator: false + showOverflowMenu: false + pinnedItems: SystemTray.items.values.filter(i => i.id == "Fcitx") + visible: pinnedItems.length > 0 + } + } + + // Right toolbar + Toolbar { + id: rightIsland + anchors { + left: mainIsland.right + top: mainIsland.top + bottom: mainIsland.bottom + leftMargin: 10 + } + + scale: root.toolbarScale + opacity: root.toolbarOpacity + + IconAndTextPair { + visible: Battery.available + icon: Battery.isCharging ? "bolt" : "battery_android_full" + text: Math.round(Battery.percentage * 100) + color: (Battery.isLow && !Battery.isCharging) ? Appearance.colors.colError : Appearance.colors.colOnSurfaceVariant + } + + IconToolbarButton { + id: sleepButton + onClicked: Session.suspend() + text: "dark_mode" + } + + PasswordGuardedIconToolbarButton { + id: powerButton + text: "power_settings_new" + targetAction: LockContext.ActionEnum.Poweroff + } + + PasswordGuardedIconToolbarButton { + id: rebootButton + text: "restart_alt" + targetAction: LockContext.ActionEnum.Reboot + } + } + + component PasswordGuardedIconToolbarButton: IconToolbarButton { + id: guardedBtn + required property var targetAction + + toggled: root.context.targetAction === guardedBtn.targetAction + + onClicked: { + if (!root.requirePasswordToPower) { + root.context.unlocked(guardedBtn.targetAction); + return; + } + if (root.context.targetAction === guardedBtn.targetAction) { + root.context.resetTargetAction(); + } else { + root.context.targetAction = guardedBtn.targetAction; + root.context.shouldReFocus(); + } + } + } + + component IconAndTextPair: Row { + id: pair + required property string icon + required property string text + property color color: Appearance.colors.colOnSurfaceVariant + + spacing: 4 + Layout.fillHeight: true + Layout.leftMargin: 10 + Layout.rightMargin: 10 + + + MaterialSymbol { + anchors.verticalCenter: parent.verticalCenter + fill: 1 + text: pair.icon + iconSize: Appearance.font.pixelSize.huge + animateChange: true + color: pair.color + } + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: pair.text + color: pair.color + } + } +} diff --git a/modules/quickshell/config/modules/ii/lock/PasswordChars.qml b/modules/quickshell/config/modules/ii/lock/PasswordChars.qml new file mode 100644 index 0000000..400c249 --- /dev/null +++ b/modules/quickshell/config/modules/ii/lock/PasswordChars.qml @@ -0,0 +1,95 @@ +pragma ComponentBehavior: Bound +import QtQuick +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import Quickshell + +StyledFlickable { + id: root + required property int length + contentWidth: dotsRow.implicitWidth + contentX: (Math.max(contentWidth - width, 0)) + Behavior on contentX { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Row { + id: dotsRow + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + leftMargin: 4 + } + spacing: 10 + Repeater { + model: ScriptModel { + values: Array(root.length) + } + delegate: Item { + id: charItem + required property int index + implicitWidth: 10 + implicitHeight: 10 + MaterialShape { + id: materialShape + anchors.centerIn: parent + property list charShapes: [ + MaterialShape.Shape.Clover4Leaf, + MaterialShape.Shape.Arrow, + MaterialShape.Shape.Pill, + MaterialShape.Shape.SoftBurst, + MaterialShape.Shape.Diamond, + MaterialShape.Shape.ClamShell, + MaterialShape.Shape.Pentagon, + ] + shape: charShapes[charItem.index % charShapes.length] + // Animate on appearance + color: Appearance.colors.colPrimary + implicitSize: 0 + opacity: 0 + scale: 0.5 + Component.onCompleted: { + appearAnim.start(); + } + ParallelAnimation { + id: appearAnim + NumberAnimation { + target: materialShape + properties: "opacity" + to: 1 + duration: 50 + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + NumberAnimation { + target: materialShape + properties: "scale" + to: 1 + duration: 200 + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial + } + NumberAnimation { + target: materialShape + properties: "implicitSize" + to: 18 + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial + } + ColorAnimation { + target: materialShape + properties: "color" + from: Appearance.colors.colPrimary + to: Appearance.colors.colOnLayer1 + duration: 1000 + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/lock/pam/fprintd.conf b/modules/quickshell/config/modules/ii/lock/pam/fprintd.conf new file mode 100644 index 0000000..73d9cc7 --- /dev/null +++ b/modules/quickshell/config/modules/ii/lock/pam/fprintd.conf @@ -0,0 +1 @@ +auth sufficient pam_fprintd.so \ No newline at end of file diff --git a/modules/quickshell/config/modules/ii/mediaControls/MediaControls.qml b/modules/quickshell/config/modules/ii/mediaControls/MediaControls.qml new file mode 100644 index 0000000..3cd6208 --- /dev/null +++ b/modules/quickshell/config/modules/ii/mediaControls/MediaControls.qml @@ -0,0 +1,228 @@ +pragma ComponentBehavior: Bound +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + property bool visible: false + readonly property MprisPlayer activePlayer: MprisController.activePlayer + readonly property var realPlayers: MprisController.players + readonly property var meaningfulPlayers: filterDuplicatePlayers(realPlayers) + readonly property real osdWidth: Appearance.sizes.osdWidth + readonly property real widgetWidth: Appearance.sizes.mediaControlsWidth + readonly property real widgetHeight: Appearance.sizes.mediaControlsHeight + property real popupRounding: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1 + property list visualizerPoints: [] + + function filterDuplicatePlayers(players) { + let filtered = []; + let used = new Set(); + + for (let i = 0; i < players.length; ++i) { + if (used.has(i)) + continue; + let p1 = players[i]; + let group = [i]; + + // Find duplicates by trackTitle prefix + for (let j = i + 1; j < players.length; ++j) { + let p2 = players[j]; + if (p1.trackTitle && p2.trackTitle && (p1.trackTitle.includes(p2.trackTitle) || p2.trackTitle.includes(p1.trackTitle)) || (p1.position - p2.position <= 2 && p1.length - p2.length <= 2)) { + group.push(j); + } + } + + // Pick the one with non-empty trackArtUrl, or fallback to the first + let chosenIdx = group.find(idx => players[idx].trackArtUrl && players[idx].trackArtUrl.length > 0); + if (chosenIdx === undefined) + chosenIdx = group[0]; + + filtered.push(players[chosenIdx]); + group.forEach(idx => used.add(idx)); + } + return filtered; + } + + Process { + id: cavaProc + running: mediaControlsLoader.active + onRunningChanged: { + if (!cavaProc.running) { + root.visualizerPoints = []; + } + } + command: ["cava", "-p", `${FileUtils.trimFileProtocol(Directories.scriptPath)}/cava/raw_output_config.txt`] + stdout: SplitParser { + onRead: data => { + // Parse `;`-separated values into the visualizerPoints array + let points = data.split(";").map(p => parseFloat(p.trim())).filter(p => !isNaN(p)); + root.visualizerPoints = points; + } + } + } + + Loader { + id: mediaControlsLoader + active: GlobalStates.mediaControlsOpen + onActiveChanged: { + if (!mediaControlsLoader.active && root.realPlayers.length === 0) { + GlobalStates.mediaControlsOpen = false; + } + } + + sourceComponent: PanelWindow { + id: mediaControlsRoot + visible: true + + exclusionMode: ExclusionMode.Ignore + exclusiveZone: 0 + implicitWidth: root.widgetWidth + implicitHeight: playerColumnLayout.implicitHeight + color: "transparent" + WlrLayershell.namespace: "quickshell:mediaControls" + + anchors { + top: !Config.options.bar.bottom || Config.options.bar.vertical + bottom: Config.options.bar.bottom && !Config.options.bar.vertical + left: !(Config.options.bar.vertical && Config.options.bar.bottom) + right: Config.options.bar.vertical && Config.options.bar.bottom + } + margins { + top: Config.options.bar.vertical ? ((mediaControlsRoot.screen.height / 2) - widgetHeight * 1.5) : Appearance.sizes.barHeight + bottom: Appearance.sizes.barHeight + left: Config.options.bar.vertical ? Appearance.sizes.barHeight : ((mediaControlsRoot.screen.width / 2) - (osdWidth / 2) - widgetWidth) + right: Appearance.sizes.barHeight + } + + mask: Region { + item: playerColumnLayout + } + + HyprlandFocusGrab { + windows: [mediaControlsRoot] + active: mediaControlsLoader.active + onCleared: () => { + if (!active) { + GlobalStates.mediaControlsOpen = false; + } + } + } + + ColumnLayout { + id: playerColumnLayout + anchors.fill: parent + spacing: -Appearance.sizes.elevationMargin // Shadow overlap okay + + Repeater { + model: ScriptModel { + values: root.meaningfulPlayers + } + delegate: PlayerControl { + required property MprisPlayer modelData + player: modelData + visualizerPoints: root.visualizerPoints + implicitWidth: root.widgetWidth + implicitHeight: root.widgetHeight + radius: root.popupRounding + } + } + + Item { // No player placeholder + Layout.alignment: { + if (mediaControlsRoot.anchors.left) return Qt.AlignLeft; + if (mediaControlsRoot.anchors.right) return Qt.AlignRight; + return Qt.AlignHCenter; + } + Layout.leftMargin: Appearance.sizes.hyprlandGapsOut + Layout.rightMargin: Appearance.sizes.hyprlandGapsOut + visible: root.meaningfulPlayers.length === 0 + implicitWidth: placeholderBackground.implicitWidth + Appearance.sizes.elevationMargin + implicitHeight: placeholderBackground.implicitHeight + Appearance.sizes.elevationMargin + + StyledRectangularShadow { + target: placeholderBackground + } + + Rectangle { + id: placeholderBackground + anchors.centerIn: parent + color: Appearance.colors.colLayer0 + radius: root.popupRounding + property real padding: 20 + implicitWidth: placeholderLayout.implicitWidth + padding * 2 + implicitHeight: placeholderLayout.implicitHeight + padding * 2 + + ColumnLayout { + id: placeholderLayout + anchors.centerIn: parent + + StyledText { + text: Translation.tr("No active player") + font.pixelSize: Appearance.font.pixelSize.large + } + StyledText { + color: Appearance.colors.colSubtext + text: Translation.tr("Make sure your player has MPRIS support\nor try turning off duplicate player filtering") + font.pixelSize: Appearance.font.pixelSize.small + } + } + } + } + } + } + } + + IpcHandler { + target: "mediaControls" + + function toggle(): void { + mediaControlsLoader.active = !mediaControlsLoader.active; + if (mediaControlsLoader.active) + Notifications.timeoutAll(); + } + + function close(): void { + mediaControlsLoader.active = false; + } + + function open(): void { + mediaControlsLoader.active = true; + Notifications.timeoutAll(); + } + } + + GlobalShortcut { + name: "mediaControlsToggle" + description: "Toggles media controls on press" + + onPressed: { + GlobalStates.mediaControlsOpen = !GlobalStates.mediaControlsOpen; + } + } + GlobalShortcut { + name: "mediaControlsOpen" + description: "Opens media controls on press" + + onPressed: { + GlobalStates.mediaControlsOpen = true; + } + } + GlobalShortcut { + name: "mediaControlsClose" + description: "Closes media controls on press" + + onPressed: { + GlobalStates.mediaControlsOpen = false; + } + } +} diff --git a/modules/quickshell/config/modules/ii/mediaControls/PlayerControl.qml b/modules/quickshell/config/modules/ii/mediaControls/PlayerControl.qml new file mode 100644 index 0000000..f50769a --- /dev/null +++ b/modules/quickshell/config/modules/ii/mediaControls/PlayerControl.qml @@ -0,0 +1,315 @@ +pragma ComponentBehavior: Bound +import qs.modules.common +import qs.modules.common.models +import qs.modules.common.widgets +import qs.services +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris + +Item { // Player instance + id: root + required property MprisPlayer player + property var artUrl: player?.trackArtUrl + property string artDownloadLocation: Directories.coverArt + property string artFileName: Qt.md5(artUrl) + property string artFilePath: `${artDownloadLocation}/${artFileName}` + property color artDominantColor: ColorUtils.mix((colorQuantizer?.colors[0] ?? Appearance.colors.colPrimary), Appearance.colors.colPrimaryContainer, 0.8) || Appearance.m3colors.m3secondaryContainer + property bool downloaded: false + property list visualizerPoints: [] + property real maxVisualizerValue: 1000 // Max value in the data points + property int visualizerSmoothing: 2 // Number of points to average for smoothing + property real radius + + property string displayedArtFilePath: root.downloaded ? Qt.resolvedUrl(artFilePath) : "" + + component TrackChangeButton: RippleButton { + implicitWidth: 24 + implicitHeight: 24 + + property var iconName + colBackground: ColorUtils.transparentize(blendedColors.colSecondaryContainer, 1) + colBackgroundHover: blendedColors.colSecondaryContainerHover + colRipple: blendedColors.colSecondaryContainerActive + + contentItem: MaterialSymbol { + iconSize: Appearance.font.pixelSize.huge + fill: 1 + horizontalAlignment: Text.AlignHCenter + color: blendedColors.colOnSecondaryContainer + text: iconName + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + + Timer { // Force update for revision + running: root.player?.playbackState == MprisPlaybackState.Playing + interval: Config.options.resources.updateInterval + repeat: true + onTriggered: { + root.player.positionChanged() + } + } + + onArtFilePathChanged: { + if (root.artUrl.length == 0) { + root.artDominantColor = Appearance.m3colors.m3secondaryContainer + return; + } + + // Binding does not work in Process + coverArtDownloader.targetFile = root.artUrl + coverArtDownloader.artFilePath = root.artFilePath + // Download + root.downloaded = false + coverArtDownloader.running = true + } + + Process { // Cover art downloader + id: coverArtDownloader + property string targetFile: root.artUrl + property string artFilePath: root.artFilePath + command: [ "bash", "-c", `[ -f ${artFilePath} ] || curl -sSL '${targetFile}' -o '${artFilePath}'` ] + onExited: (exitCode, exitStatus) => { + root.downloaded = true + } + } + + ColorQuantizer { + id: colorQuantizer + source: root.displayedArtFilePath + depth: 0 // 2^0 = 1 color + rescaleSize: 1 // Rescale to 1x1 pixel for faster processing + } + + property QtObject blendedColors: AdaptedMaterialScheme { + color: artDominantColor + } + + StyledRectangularShadow { + target: background + } + Rectangle { // Background + id: background + anchors.fill: parent + anchors.margins: Appearance.sizes.elevationMargin + color: ColorUtils.applyAlpha(blendedColors.colLayer0, 1) + radius: root.radius + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: background.width + height: background.height + radius: background.radius + } + } + + Image { + id: blurredArt + anchors.fill: parent + source: root.displayedArtFilePath + sourceSize.width: background.width + sourceSize.height: background.height + fillMode: Image.PreserveAspectCrop + cache: false + antialiasing: true + asynchronous: true + + layer.enabled: true + layer.effect: StyledBlurEffect { + source: blurredArt + } + + Rectangle { + anchors.fill: parent + color: ColorUtils.transparentize(blendedColors.colLayer0, 0.3) + radius: root.radius + } + } + + WaveVisualizer { + id: visualizerCanvas + anchors.fill: parent + live: root.player?.isPlaying + points: root.visualizerPoints + maxVisualizerValue: root.maxVisualizerValue + smoothing: root.visualizerSmoothing + color: blendedColors.colPrimary + } + + RowLayout { + anchors.fill: parent + anchors.margins: 13 + spacing: 15 + + Rectangle { // Art background + id: artBackground + Layout.fillHeight: true + implicitWidth: height + radius: Appearance.rounding.verysmall + color: ColorUtils.transparentize(blendedColors.colLayer1, 0.5) + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: artBackground.width + height: artBackground.height + radius: artBackground.radius + } + } + + StyledImage { // Art image + id: mediaArt + property int size: parent.height + anchors.fill: parent + + source: root.displayedArtFilePath + fillMode: Image.PreserveAspectCrop + cache: false + antialiasing: true + + width: size + height: size + sourceSize.width: size + sourceSize.height: size + } + } + + ColumnLayout { // Info & controls + Layout.fillHeight: true + spacing: 2 + + StyledText { + id: trackTitle + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.large + color: blendedColors.colOnLayer0 + elide: Text.ElideRight + text: StringUtils.cleanMusicTitle(root.player?.trackTitle) || "Untitled" + animateChange: true + animationDistanceX: 6 + animationDistanceY: 0 + } + StyledText { + id: trackArtist + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.smaller + color: blendedColors.colSubtext + elide: Text.ElideRight + text: root.player?.trackArtist + animateChange: true + animationDistanceX: 6 + animationDistanceY: 0 + } + Item { Layout.fillHeight: true } + Item { + Layout.fillWidth: true + implicitHeight: trackTime.implicitHeight + sliderRow.implicitHeight + + StyledText { + id: trackTime + anchors.bottom: sliderRow.top + anchors.bottomMargin: 5 + anchors.left: parent.left + font.pixelSize: Appearance.font.pixelSize.small + color: blendedColors.colSubtext + elide: Text.ElideRight + text: `${StringUtils.friendlyTimeForSeconds(root.player?.position)} / ${StringUtils.friendlyTimeForSeconds(root.player?.length)}` + } + RowLayout { + id: sliderRow + anchors { + bottom: parent.bottom + left: parent.left + right: parent.right + } + TrackChangeButton { + iconName: "skip_previous" + downAction: () => root.player?.previous() + } + Item { + id: progressBarContainer + Layout.fillWidth: true + implicitHeight: Math.max(sliderLoader.implicitHeight, progressBarLoader.implicitHeight) + + Loader { + id: sliderLoader + anchors.fill: parent + active: root.player?.canSeek ?? false + sourceComponent: StyledSlider { + configuration: StyledSlider.Configuration.Wavy + highlightColor: blendedColors.colPrimary + trackColor: blendedColors.colSecondaryContainer + handleColor: blendedColors.colPrimary + value: root.player?.position / root.player?.length + onMoved: { + root.player.position = value * root.player.length; + } + } + } + + Loader { + id: progressBarLoader + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + right: parent.right + } + active: !(root.player?.canSeek ?? false) + sourceComponent: StyledProgressBar { + wavy: root.player?.isPlaying + highlightColor: blendedColors.colPrimary + trackColor: blendedColors.colSecondaryContainer + value: root.player?.position / root.player?.length + } + } + + + } + TrackChangeButton { + iconName: "skip_next" + downAction: () => root.player?.next() + } + } + + RippleButton { + id: playPauseButton + anchors.right: parent.right + anchors.bottom: sliderRow.top + anchors.bottomMargin: 5 + property real size: 44 + implicitWidth: size + implicitHeight: size + downAction: () => root.player.togglePlaying(); + + buttonRadius: root.player?.isPlaying ? Appearance?.rounding.normal : size / 2 + colBackground: root.player?.isPlaying ? blendedColors.colPrimary : blendedColors.colSecondaryContainer + colBackgroundHover: root.player?.isPlaying ? blendedColors.colPrimaryHover : blendedColors.colSecondaryContainerHover + colRipple: root.player?.isPlaying ? blendedColors.colPrimaryActive : blendedColors.colSecondaryContainerActive + + contentItem: MaterialSymbol { + iconSize: Appearance.font.pixelSize.huge + fill: 1 + horizontalAlignment: Text.AlignHCenter + color: root.player?.isPlaying ? blendedColors.colOnPrimary : blendedColors.colOnSecondaryContainer + text: root.player?.isPlaying ? "pause" : "play_arrow" + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/ii/notificationPopup/NotificationPopup.qml b/modules/quickshell/config/modules/ii/notificationPopup/NotificationPopup.qml new file mode 100644 index 0000000..6ce9981 --- /dev/null +++ b/modules/quickshell/config/modules/ii/notificationPopup/NotificationPopup.qml @@ -0,0 +1,49 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: notificationPopup + + PanelWindow { + id: root + visible: (Notifications.popupList.length > 0) && !GlobalStates.screenLocked + screen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) ?? null + + WlrLayershell.namespace: "quickshell:notificationPopup" + WlrLayershell.layer: WlrLayer.Overlay + exclusiveZone: 0 + + anchors { + top: true + right: true + bottom: true + } + + mask: Region { + item: listview.contentItem + } + + color: "transparent" + implicitWidth: Appearance.sizes.notificationPopupWidth + + NotificationListView { + id: listview + anchors { + top: parent.top + bottom: parent.bottom + right: parent.right + rightMargin: 4 + topMargin: 4 + } + implicitWidth: parent.width - Appearance.sizes.elevationMargin * 2 + popup: true + } + } +} diff --git a/modules/quickshell/config/modules/ii/onScreenDisplay/OnScreenDisplay.qml b/modules/quickshell/config/modules/ii/onScreenDisplay/OnScreenDisplay.qml new file mode 100644 index 0000000..21b9c43 --- /dev/null +++ b/modules/quickshell/config/modules/ii/onScreenDisplay/OnScreenDisplay.qml @@ -0,0 +1,224 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + property string protectionMessage: "" + property var focusedScreen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) + + property string currentIndicator: "volume" + property var indicators: [ + { + id: "volume", + sourceUrl: "indicators/VolumeIndicator.qml" + }, + { + id: "brightness", + sourceUrl: "indicators/BrightnessIndicator.qml" + }, + ] + + function triggerOsd() { + GlobalStates.osdVolumeOpen = true; + osdTimeout.restart(); + } + + Timer { + id: osdTimeout + interval: Config.options.osd.timeout + repeat: false + running: false + onTriggered: { + GlobalStates.osdVolumeOpen = false; + root.protectionMessage = ""; + } + } + + Connections { + target: Brightness + function onBrightnessChanged() { + root.protectionMessage = ""; + root.currentIndicator = "brightness"; + root.triggerOsd(); + } + } + + Connections { + // Listen to volume changes + target: Audio.sink?.audio ?? null + function onVolumeChanged() { + if (!Audio.ready) + return; + root.currentIndicator = "volume"; + root.triggerOsd(); + } + function onMutedChanged() { + if (!Audio.ready) + return; + root.currentIndicator = "volume"; + root.triggerOsd(); + } + } + + Connections { + // Listen to protection triggers + target: Audio + function onSinkProtectionTriggered(reason) { + root.protectionMessage = reason; + root.currentIndicator = "volume"; + root.triggerOsd(); + } + } + + Loader { + id: osdLoader + active: GlobalStates.osdVolumeOpen + + sourceComponent: PanelWindow { + id: osdRoot + color: "transparent" + + Connections { + target: root + function onFocusedScreenChanged() { + osdRoot.screen = root.focusedScreen; + } + } + + WlrLayershell.namespace: "quickshell:onScreenDisplay" + WlrLayershell.layer: WlrLayer.Overlay + anchors { + top: !Config.options.bar.bottom + bottom: Config.options.bar.bottom + } + mask: Region { + item: osdValuesWrapper + } + + exclusionMode: ExclusionMode.Ignore + exclusiveZone: 0 + margins { + top: Appearance.sizes.barHeight + bottom: Appearance.sizes.barHeight + } + + implicitWidth: columnLayout.implicitWidth + implicitHeight: columnLayout.implicitHeight + visible: osdLoader.active + + ColumnLayout { + id: columnLayout + anchors.horizontalCenter: parent.horizontalCenter + + Item { + id: osdValuesWrapper + // Extra space for shadow + implicitHeight: contentColumnLayout.implicitHeight + implicitWidth: contentColumnLayout.implicitWidth + clip: true + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: GlobalStates.osdVolumeOpen = false + } + + Column { + id: contentColumnLayout + anchors { + top: parent.top + left: parent.left + right: parent.right + } + spacing: 0 + + Loader { + id: osdIndicatorLoader + source: root.indicators.find(i => i.id === root.currentIndicator)?.sourceUrl + } + + Item { + id: protectionMessageWrapper + anchors.horizontalCenter: parent.horizontalCenter + implicitHeight: protectionMessageBackground.implicitHeight + implicitWidth: protectionMessageBackground.implicitWidth + opacity: root.protectionMessage !== "" ? 1 : 0 + + StyledRectangularShadow { + target: protectionMessageBackground + } + Rectangle { + id: protectionMessageBackground + anchors.centerIn: parent + color: Appearance.m3colors.m3error + property real padding: 10 + implicitHeight: protectionMessageRowLayout.implicitHeight + padding * 2 + implicitWidth: protectionMessageRowLayout.implicitWidth + padding * 2 + radius: Appearance.rounding.normal + + RowLayout { + id: protectionMessageRowLayout + anchors.centerIn: parent + MaterialSymbol { + id: protectionMessageIcon + text: "dangerous" + iconSize: Appearance.font.pixelSize.hugeass + color: Appearance.m3colors.m3onError + } + StyledText { + id: protectionMessageTextWidget + horizontalAlignment: Text.AlignHCenter + color: Appearance.m3colors.m3onError + wrapMode: Text.Wrap + text: root.protectionMessage + } + } + } + } + } + } + } + } + } + + IpcHandler { + target: "osdVolume" + + function trigger() { + root.triggerOsd(); + } + + function hide() { + GlobalStates.osdVolumeOpen = false; + } + + function toggle() { + GlobalStates.osdVolumeOpen = !GlobalStates.osdVolumeOpen; + } + } + GlobalShortcut { + name: "osdVolumeTrigger" + description: "Triggers volume OSD on press" + + onPressed: { + root.triggerOsd(); + } + } + GlobalShortcut { + name: "osdVolumeHide" + description: "Hides volume OSD on press" + + onPressed: { + GlobalStates.osdVolumeOpen = false; + } + } +} diff --git a/modules/quickshell/config/modules/ii/onScreenDisplay/OsdValueIndicator.qml b/modules/quickshell/config/modules/ii/onScreenDisplay/OsdValueIndicator.qml new file mode 100644 index 0000000..fc10c8a --- /dev/null +++ b/modules/quickshell/config/modules/ii/onScreenDisplay/OsdValueIndicator.qml @@ -0,0 +1,104 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell.Widgets + +Item { + id: root + required property real value + required property string icon + required property string name + property bool rotateIcon: false + property bool scaleIcon: false + + property real valueIndicatorVerticalPadding: 9 + property real valueIndicatorLeftPadding: 10 + property real valueIndicatorRightPadding: 20 // An icon is circle ish, a column isn't, hence the extra padding + + implicitWidth: Appearance.sizes.osdWidth + 2 * Appearance.sizes.elevationMargin + implicitHeight: valueIndicator.implicitHeight + 2 * Appearance.sizes.elevationMargin + + StyledRectangularShadow { + target: valueIndicator + } + Rectangle { + id: valueIndicator + anchors { + fill: parent + margins: Appearance.sizes.elevationMargin + } + radius: Appearance.rounding.full + color: Appearance.colors.colLayer0 + + implicitWidth: valueRow.implicitWidth + implicitHeight: valueRow.implicitHeight + + RowLayout { // Icon on the left, stuff on the right + id: valueRow + Layout.margins: 10 + anchors.fill: parent + spacing: 10 + + Item { + implicitWidth: 30 + implicitHeight: 30 + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: valueIndicatorLeftPadding + Layout.topMargin: valueIndicatorVerticalPadding + Layout.bottomMargin: valueIndicatorVerticalPadding + + MaterialSymbol { // Icon + anchors { + centerIn: parent + alignWhenCentered: !root.rotateIcon + } + color: Appearance.colors.colOnLayer0 + renderType: Text.QtRendering + + text: root.icon + iconSize: 20 + 10 * (root.scaleIcon ? value : 1) + rotation: 180 * (root.rotateIcon ? value : 0) + + Behavior on iconSize { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on rotation { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + } + } + ColumnLayout { // Stuff + Layout.alignment: Qt.AlignVCenter + Layout.rightMargin: valueIndicatorRightPadding + spacing: 5 + + RowLayout { // Name fill left, value on the right end + Layout.leftMargin: valueProgressBar.height / 2 // Align text with progressbar radius curve's left end + Layout.rightMargin: valueProgressBar.height / 2 // Align text with progressbar radius curve's left end + + StyledText { + color: Appearance.colors.colOnLayer0 + font.pixelSize: Appearance.font.pixelSize.small + Layout.fillWidth: true + text: root.name + } + + StyledText { + color: Appearance.colors.colOnLayer0 + font.pixelSize: Appearance.font.pixelSize.small + Layout.fillWidth: false + text: Math.round(root.value * 100) + } + } + + StyledProgressBar { + id: valueProgressBar + Layout.fillWidth: true + value: root.value + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/onScreenDisplay/indicators/BrightnessIndicator.qml b/modules/quickshell/config/modules/ii/onScreenDisplay/indicators/BrightnessIndicator.qml new file mode 100644 index 0000000..fbdbd71 --- /dev/null +++ b/modules/quickshell/config/modules/ii/onScreenDisplay/indicators/BrightnessIndicator.qml @@ -0,0 +1,17 @@ +import qs.services +import QtQuick +import Quickshell +import Quickshell.Hyprland +import qs.modules.ii.onScreenDisplay + +OsdValueIndicator { + id: root + property var focusedScreen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) + property var brightnessMonitor: Brightness.getMonitorForScreen(focusedScreen) + + icon: Hyprsunset.active ? "routine" : "light_mode" + rotateIcon: true + scaleIcon: true + name: Translation.tr("Brightness") + value: root.brightnessMonitor?.brightness ?? 50 +} diff --git a/modules/quickshell/config/modules/ii/onScreenDisplay/indicators/VolumeIndicator.qml b/modules/quickshell/config/modules/ii/onScreenDisplay/indicators/VolumeIndicator.qml new file mode 100644 index 0000000..da8a690 --- /dev/null +++ b/modules/quickshell/config/modules/ii/onScreenDisplay/indicators/VolumeIndicator.qml @@ -0,0 +1,10 @@ +import qs.services +import QtQuick +import qs.modules.ii.onScreenDisplay + +OsdValueIndicator { + id: osdValues + value: Audio.sink?.audio.volume ?? 0 + icon: Audio.sink?.audio.muted ? "volume_off" : "volume_up" + name: Translation.tr("Volume") +} diff --git a/modules/quickshell/config/modules/ii/overlay/Overlay.qml b/modules/quickshell/config/modules/ii/overlay/Overlay.qml new file mode 100644 index 0000000..65901ea --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/Overlay.qml @@ -0,0 +1,94 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + + property Component regionComponent: Component { + Region {} + } + + Loader { + id: overlayLoader + active: GlobalStates.overlayOpen || OverlayContext.hasPinnedWidgets + sourceComponent: PanelWindow { + id: overlayWindow + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "quickshell:overlay" + WlrLayershell.layer: WlrLayer.Overlay + // Use OnDemand for pinned widgets to allow focus switching with mouse clicks + WlrLayershell.keyboardFocus: GlobalStates.overlayOpen ? WlrKeyboardFocus.Exclusive : (OverlayContext.clickableWidgets.length > 0 ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None) + visible: true + color: "transparent" + + mask: Region { + item: GlobalStates.overlayOpen ? overlayContent : null + regions: OverlayContext.clickableWidgets.map((widget) => regionComponent.createObject(this, { + item: widget + })); + } + + anchors { + top: true + bottom: true + left: true + right: true + } + + HyprlandFocusGrab { + id: grab + windows: [overlayWindow] + active: false + onCleared: () => { + if (!active) GlobalStates.overlayOpen = false; + } + } + + Connections { + target: GlobalStates + function onOverlayOpenChanged() { + delayedGrabTimer.restart(); + } + } + + Timer { + id: delayedGrabTimer + interval: Appearance.animation.elementMoveFast.duration + onTriggered: { + grab.active = GlobalStates.overlayOpen; + } + } + + OverlayContent { + id: overlayContent + anchors.fill: parent + } + } + } + + IpcHandler { + target: "overlay" + + function toggle(): void { + GlobalStates.overlayOpen = !GlobalStates.overlayOpen; + } + } + + GlobalShortcut { + name: "overlayToggle" + description: "Toggles overlay on press" + + onPressed: { + GlobalStates.overlayOpen = !GlobalStates.overlayOpen; + } + } +} diff --git a/modules/quickshell/config/modules/ii/overlay/OverlayBackground.qml b/modules/quickshell/config/modules/ii/overlay/OverlayBackground.qml new file mode 100644 index 0000000..d91e4cb --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/OverlayBackground.qml @@ -0,0 +1,8 @@ +import QtQuick +import qs.modules.common + +Rectangle { + id: contentItem + anchors.fill: parent + color: Appearance.colors.colSurfaceContainer +} diff --git a/modules/quickshell/config/modules/ii/overlay/OverlayContent.qml b/modules/quickshell/config/modules/ii/overlay/OverlayContent.qml new file mode 100644 index 0000000..9c8340c --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/OverlayContent.qml @@ -0,0 +1,66 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.widgets.widgetCanvas + +Item { + id: root + focus: true + readonly property bool usePasswordChars: !PolkitService.flow?.responseVisible ?? true + + Keys.onPressed: (event) => { // Esc to close + if (event.key === Qt.Key_Escape) { + GlobalStates.overlayOpen = false; + } + } + + property real initScale: Config.options.overlay.openingZoomAnimation ? 1.08 : 1.000001 + scale: initScale + Component.onCompleted: { + scale = 1 + } + Behavior on scale { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + Rectangle { + id: bg + anchors.fill: parent + color: Appearance.colors.colScrim + visible: Config.options.overlay.darkenScreen && opacity > 0 + opacity: (GlobalStates.overlayOpen && root.scale !== initScale) ? 1 : 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + + WidgetCanvas { + anchors.fill: parent + onClicked: GlobalStates.overlayOpen = false + + OverlayTaskbar { + anchors { + horizontalCenter: parent.horizontalCenter + top: parent.top + topMargin: 50 + } + } + + Repeater { + model: ScriptModel { + values: Persistent.states.overlay.open.map(identifier => { + return OverlayContext.availableWidgets.find(w => w.identifier === identifier); + }) + objectProp: "identifier" + } + delegate: OverlayWidgetDelegateChooser { + + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/overlay/OverlayContext.qml b/modules/quickshell/config/modules/ii/overlay/OverlayContext.qml new file mode 100644 index 0000000..bb68cef --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/OverlayContext.qml @@ -0,0 +1,42 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import Quickshell + +Singleton { + id: root + + readonly property list availableWidgets: [ + { identifier: "crosshair", materialSymbol: "point_scan" }, + { identifier: "fpsLimiter", materialSymbol: "animation" }, + { identifier: "floatingImage", materialSymbol: "imagesmode" }, + { identifier: "recorder", materialSymbol: "screen_record" }, + { identifier: "resources", materialSymbol: "browse_activity" }, + { identifier: "notes", materialSymbol: "note_stack" }, + { identifier: "volumeMixer", materialSymbol: "volume_up" }, + ] + + readonly property bool hasPinnedWidgets: root.pinnedWidgetIdentifiers.length > 0 + + property list pinnedWidgetIdentifiers: [] + property list clickableWidgets: [] + + function pin(identifier: string, pin = true) { + if (pin) { + if (!root.pinnedWidgetIdentifiers.includes(identifier)) { + root.pinnedWidgetIdentifiers.push(identifier) + } + } else { + root.pinnedWidgetIdentifiers = root.pinnedWidgetIdentifiers.filter(id => id !== identifier) + } + } + + function registerClickableWidget(widget: var, clickable = true) { + if (clickable) { + if (!root.clickableWidgets.includes(widget)) { + root.clickableWidgets.push(widget) + } + } else { + root.clickableWidgets = root.clickableWidgets.filter(w => w !== widget) + } + } +} diff --git a/modules/quickshell/config/modules/ii/overlay/OverlayTaskbar.qml b/modules/quickshell/config/modules/ii/overlay/OverlayTaskbar.qml new file mode 100644 index 0000000..2329034 --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/OverlayTaskbar.qml @@ -0,0 +1,150 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import qs.modules.common.widgets.widgetCanvas + +Rectangle { + id: root + + property real padding: 8 + + opacity: GlobalStates.overlayOpen ? 1 : 0 + implicitWidth: contentRow.implicitWidth + (padding * 2) + implicitHeight: contentRow.implicitHeight + (padding * 2) + color: Appearance.m3colors.m3surfaceContainer + radius: Appearance.rounding.large + border.color: Appearance.colors.colOutlineVariant + border.width: 1 + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + RowLayout { + id: contentRow + anchors { + fill: parent + margins: root.padding + } + spacing: 6 + + Row { + spacing: 4 + Repeater { + model: ScriptModel { + values: OverlayContext.availableWidgets + } + delegate: WidgetButton { + required property var modelData + identifier: modelData.identifier + materialSymbol: modelData.materialSymbol + } + } + } + + Separator {} + TimeWidget {} + Separator { + visible: Battery.available + } + BatteryWidget { + visible: Battery.available + } + } + + component Separator: Rectangle { + implicitWidth: 1 + color: Appearance.colors.colOutlineVariant + Layout.fillHeight: true + Layout.topMargin: 10 + Layout.bottomMargin: 10 + } + + component TimeWidget: StyledText { + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: 8 + Layout.rightMargin: 6 + + text: DateTime.time + color: Appearance.colors.colOnSurface + font { + family: Appearance.font.family.numbers + variableAxes: Appearance.font.variableAxes.numbers + pixelSize: 22 + } + } + + component BatteryWidget: Row { + id: batteryWidget + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: 6 + Layout.rightMargin: 6 + spacing: 2 + property color colText: Battery.isLowAndNotCharging ? Appearance.colors.colError : Appearance.colors.colOnSurface + + MaterialSymbol { + id: boltIcon + anchors.verticalCenter: parent.verticalCenter + fill: 1 + text: Battery.isCharging ? "bolt" : "battery_android_full" + color: batteryWidget.colText + iconSize: 24 + animateChange: true + } + + StyledText { + id: batteryText + anchors.verticalCenter: parent.verticalCenter + text: Math.round(Battery.percentage * 100) + "%" + color: batteryWidget.colText + font { + family: Appearance.font.family.numbers + variableAxes: Appearance.font.variableAxes.numbers + pixelSize: 18 + } + } + } + + component WidgetButton: RippleButton { + id: widgetButton + required property string identifier + required property string materialSymbol + + Layout.alignment: Qt.AlignVCenter + + toggled: Persistent.states.overlay.open.includes(identifier) + onClicked: { + if (widgetButton.toggled) { + Persistent.states.overlay.open = Persistent.states.overlay.open.filter(type => type !== identifier); + } else { + Persistent.states.overlay.open.push(identifier); + } + } + implicitWidth: implicitHeight + + colBackgroundToggled: Appearance.colors.colSecondaryContainer + colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover + colRippleToggled: Appearance.colors.colSecondaryContainerActive + + buttonRadius: root.radius - (root.height - height) / 2 + + contentItem: Item { + anchors.centerIn: parent + implicitWidth: 32 + implicitHeight: 32 + MaterialSymbol { + id: iconWidget + anchors.centerIn: parent + iconSize: 24 + text: widgetButton.materialSymbol + color: widgetButton.toggled ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnSurfaceVariant + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/overlay/OverlayWidgetDelegateChooser.qml b/modules/quickshell/config/modules/ii/overlay/OverlayWidgetDelegateChooser.qml new file mode 100644 index 0000000..e004be7 --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/OverlayWidgetDelegateChooser.qml @@ -0,0 +1,28 @@ +pragma ComponentBehavior: Bound +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Bluetooth +import qs.modules.ii.overlay.crosshair +import qs.modules.ii.overlay.volumeMixer +import qs.modules.ii.overlay.floatingImage +import qs.modules.ii.overlay.fpsLimiter +import qs.modules.ii.overlay.recorder +import qs.modules.ii.overlay.resources +import qs.modules.ii.overlay.notes + +DelegateChooser { + id: root + role: "identifier" + + DelegateChoice { roleValue: "crosshair"; Crosshair {} } + DelegateChoice { roleValue: "floatingImage"; FloatingImage {} } + DelegateChoice { roleValue: "fpsLimiter"; FpsLimiter {} } + DelegateChoice { roleValue: "recorder"; Recorder {} } + DelegateChoice { roleValue: "resources"; Resources {} } + DelegateChoice { roleValue: "notes"; Notes {} } + DelegateChoice { roleValue: "volumeMixer"; VolumeMixer {} } +} diff --git a/modules/quickshell/config/modules/ii/overlay/StyledOverlayWidget.qml b/modules/quickshell/config/modules/ii/overlay/StyledOverlayWidget.qml new file mode 100644 index 0000000..949cd23 --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/StyledOverlayWidget.qml @@ -0,0 +1,327 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Qt5Compat.GraphicalEffects +import qs +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import qs.modules.common.widgets.widgetCanvas + +/* + * To make an overlay widget: + * 1. Create a modules/overlay//.qml, using this as the base class and declare your widget content as contentItem + * 2. Add an entry to OverlayContext.availableWidgets with identifier= + * 3. Add an entry in Persistent.states.overlay. with x, y, width, height, pinned, clickthrough properties set to reasonable defaults + * 4. Add an entry in OverlayWidgetDelegateChooser with roleValue= and Declare your widget in there + * Use existing entries as reference. + */ +AbstractOverlayWidget { + id: root + + // To be defined by subclasses + required property Item contentItem + property bool fancyBorders: true + property bool showCenterButton: false + property bool showClickabilityButton: true + + // Defaults n stuff + required property var modelData + readonly property string identifier: modelData.identifier + readonly property string materialSymbol: modelData.materialSymbol ?? "widgets" + property string title: identifier.replace(/([A-Z])/g, " $1").replace(/^./, function(str){ return str.toUpperCase(); }) + property var persistentStateEntry: Persistent.states.overlay[identifier] + property real radius: Appearance.rounding.windowRounding + property real minimumWidth: contentItem.implicitWidth + property real minimumHeight: contentItem.implicitHeight + property real resizeMargin: 8 + property real padding: 6 + property real contentRadius: radius - padding + + // Resizing + function getXResizeDirection(x) { + return (x < root.resizeMargin) ? -1 : (x > root.width - root.resizeMargin) ? 1 : 0 + } + function getYResizeDirection(y) { + return (y < root.resizeMargin) ? -1 : (y > root.height - root.resizeMargin) ? 1 : 0 + } + hoverEnabled: true + property bool resizable: true + property bool resizing: false + property int resizeXDirection: getXResizeDirection(mouseX) + property int resizeYDirection: getYResizeDirection(mouseY) + draggable: GlobalStates.overlayOpen + drag.target: undefined + animateXPos: !dragHandler.active + animateYPos: !dragHandler.active + z: dragHandler.active ? 2 : 1 + cursorShape: { + if (dragHandler.active) return root.resizing ? cursorShape : Qt.ArrowCursor; + if (resizeMargin < mouseX && mouseX < width - resizeMargin && + resizeMargin < mouseY && mouseY < height - resizeMargin) { + return Qt.ArrowCursor; + } else { + if (!root.resizable) return Qt.ArrowCursor; + const dragIsLeft = mouseX < width / 2 + const dragIsTop = mouseY < height / 2 + if ((dragIsLeft && dragIsTop) || (!dragIsLeft && !dragIsTop)) { + return Qt.SizeFDiagCursor + } else { + return Qt.SizeBDiagCursor + } + } + } + + // Positioning & sizing + x: Math.round(persistentStateEntry.x) // Round or it'll be blurry + y: Math.round(persistentStateEntry.y) // Round or it'll be blurry + pinned: persistentStateEntry.pinned + clickthrough: persistentStateEntry.clickthrough + drag { + minimumX: 0 + minimumY: 0 + maximumX: root.parent?.width - root.width + maximumY: root.parent?.height - root.height + } + opacity: (GlobalStates.overlayOpen || !clickthrough) ? 1.0 : Config.options.overlay.clickthroughOpacity + + // Guarded states & registration funcs + property bool open: Persistent.states.overlay.open + property bool actuallyPinned: pinned && open + property bool actuallyClickable: !clickthrough && actuallyPinned && open + onActuallyPinnedChanged: reportPinnedState(); + onActuallyClickableChanged: reportClickableState(); + function reportPinnedState() { + OverlayContext.pin(identifier, actuallyPinned); + } + function reportClickableState() { + OverlayContext.registerClickableWidget(contentItem, actuallyClickable); + } + + // Self-registeration with OverlayContext + Component.onCompleted: { + reportPinnedState(); + reportClickableState(); + } + + // Hooks + onPressed: (event) => { + // We're only interested in handling resize here + // Early returns + if (!root.resizable) return; + if (root.resizeMargin < event.x && event.x < root.width - root.resizeMargin && + root.resizeMargin < event.y && event.y < root.height - root.resizeMargin) { + return; + } + // Resizing setup + root.resizing = true; + root.resizeXDirection = getXResizeDirection(event.x); + root.resizeYDirection = getYResizeDirection(event.y); + if (root.resizeYDirection !== 0 && root.resizeXDirection === 0) { + root.resizeXDirection = event.x < root.width / 2 ? -1 : 1; + } else if (root.resizeXDirection !== 0 && root.resizeYDirection === 0) { + root.resizeYDirection = event.y < root.height / 2 ? -1 : 1; + } + } + onPositionChanged: (event) => { + if (!resizing) return; + contentContainer.implicitWidth = Math.max(root.persistentStateEntry.width + dragHandler.xAxis.activeValue * root.resizeXDirection, root.minimumWidth); + contentContainer.implicitHeight = Math.max(root.persistentStateEntry.height + dragHandler.yAxis.activeValue * root.resizeYDirection, root.minimumHeight); + const negativeXDrag = root.resizeXDirection === -1; + const negativeYDrag = root.resizeYDirection === -1; + const wantedX = root.persistentStateEntry.x + (negativeXDrag ? dragHandler.xAxis.activeValue : 0) + const wantedY = root.persistentStateEntry.y + (negativeYDrag ? dragHandler.yAxis.activeValue : 0) + const negativeXDragLimit = root.persistentStateEntry.x + root.persistentStateEntry.width - contentContainer.implicitWidth; + const negativeYDragLimit = root.persistentStateEntry.y + root.persistentStateEntry.height - contentContainer.implicitHeight; + root.x = negativeXDrag ? Math.min(wantedX, negativeXDragLimit) : wantedX; + root.y = negativeYDrag ? Math.min(wantedY, negativeYDragLimit) : wantedY; + } + DragHandler { + id: dragHandler + acceptedButtons: Qt.LeftButton | Qt.RightButton + target: (root.draggable && !root.resizing) ? root : null + onActiveChanged: { // Handle drag release + if (!active) { + root.resizing = false; + root.savePosition(); + } + } + xAxis.minimum: 0 + xAxis.maximum: root.parent?.width - root.width + yAxis.minimum: 0 + yAxis.maximum: root.parent?.height - root.height + } + + function close() { + Persistent.states.overlay.open = Persistent.states.overlay.open.filter(type => type !== root.identifier); + } + + function togglePinned() { + persistentStateEntry.pinned = !persistentStateEntry.pinned; + } + + function toggleClickthrough() { + persistentStateEntry.clickthrough = !persistentStateEntry.clickthrough; + } + + function savePosition(xPos = root.x, yPos = root.y, width = contentContainer.implicitWidth, height = contentContainer.implicitHeight) { + persistentStateEntry.x = Math.round(xPos); + persistentStateEntry.y = Math.round(yPos); + persistentStateEntry.width = Math.round(width); + persistentStateEntry.height = Math.round(height); + } + + function center() { + const targetX = (root.parent.width - contentColumn.width) / 2 - root.resizeMargin + const targetY = (root.parent.height - contentContainer.height) / 2 - titleBar.implicitHeight + border.border.width - root.resizeMargin + root.x = targetX + root.y = targetY + root.savePosition(targetX, targetY) + } + + visible: GlobalStates.overlayOpen || actuallyPinned + implicitWidth: contentColumn.implicitWidth + resizeMargin * 2 + implicitHeight: contentColumn.implicitHeight + resizeMargin * 2 + + Rectangle { + id: border + anchors { + fill: parent + margins: root.resizeMargin + } + color: ColorUtils.transparentize(Appearance.colors.colLayer1, (root.fancyBorders && GlobalStates.overlayOpen) ? 0 : 1) + radius: root.radius + border.color: ColorUtils.transparentize(Appearance.colors.colOutlineVariant, GlobalStates.overlayOpen ? 0 : 1) + border.width: 1 + + layer.enabled: GlobalStates.overlayOpen + layer.effect: OpacityMask { + maskSource: Rectangle { + width: border.width + height: border.height + radius: root.radius + } + } + + ColumnLayout { + id: contentColumn + z: root.fancyBorders ? 0 : -1 + anchors.fill: parent + spacing: 0 + + // Title bar + Rectangle { + id: titleBar + opacity: GlobalStates.overlayOpen ? 1 : 0 + Layout.fillWidth: true + implicitWidth: titleBarRow.implicitWidth + root.padding * 2 + implicitHeight: titleBarRow.implicitHeight + root.padding * 2 + color: root.fancyBorders ? "transparent" : Appearance.colors.colLayer1 + // border.color: Appearance.colors.colOutlineVariant + // border.width: 1 + + RowLayout { + id: titleBarRow + anchors { + fill: parent + margins: root.padding + } + spacing: 2 + + MaterialSymbol { + text: root.materialSymbol + Layout.leftMargin: 6 + iconSize: 20 + Layout.alignment: Qt.AlignVCenter + Layout.rightMargin: 4 + } + + StyledText { + Layout.fillWidth: true + text: root.title + elide: Text.ElideRight + } + + TitlebarButton { + visible: root.showCenterButton + materialSymbol: "recenter" + onClicked: root.center() + StyledToolTip { + text: "Center" + } + } + + TitlebarButton { + visible: (root.pinned && root.showClickabilityButton) + materialSymbol: "mouse" + toggled: !root.clickthrough + onClicked: root.toggleClickthrough() + StyledToolTip { + text: "Clickable when pinned" + } + } + + TitlebarButton { + materialSymbol: "keep" + toggled: root.pinned + onClicked: root.togglePinned() + StyledToolTip { + text: "Pin" + } + } + + TitlebarButton { + materialSymbol: "close" + onClicked: root.close() + StyledToolTip { + text: "Close" + } + } + } + } + + // Content + Item { + id: contentContainer + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: root.fancyBorders ? root.padding : 0 + Layout.topMargin: -border.border.width // Border of a rectangle is drawn inside its bounds, so we do this to make the gap not too big + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + implicitWidth: Math.max(root.persistentStateEntry.width, root.minimumWidth) + implicitHeight: Math.max(root.persistentStateEntry.height, root.minimumHeight) + children: [root.contentItem] + } + } + } + + + component TitlebarButton: RippleButton { + id: titlebarButton + required property string materialSymbol + buttonRadius: height / 2 + implicitHeight: contentItem.implicitHeight + implicitWidth: implicitHeight + padding: 0 + + colBackgroundToggled: Appearance.colors.colSecondaryContainer + colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover + colRippleToggled: Appearance.colors.colSecondaryContainerActive + + contentItem: Item { + anchors.centerIn: parent + implicitWidth: 30 + implicitHeight: 30 + + MaterialSymbol { + id: iconWidget + anchors.centerIn: parent + iconSize: 20 + text: titlebarButton.materialSymbol + fill: titlebarButton.toggled + color: titlebarButton.toggled ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnSurface + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/overlay/crosshair/Crosshair.qml b/modules/quickshell/config/modules/ii/overlay/crosshair/Crosshair.qml new file mode 100644 index 0000000..b688844 --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/crosshair/Crosshair.qml @@ -0,0 +1,19 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.modules.common +import qs.modules.ii.overlay + +StyledOverlayWidget { + id: root + fancyBorders: false // Crosshair should be see-through + showCenterButton: true + opacity: 1 // The crosshair itself already has transparency if configured + showClickabilityButton: false + clickthrough: true + resizable: false + + contentItem: CrosshairContent { + anchors.centerIn: parent + } +} diff --git a/modules/quickshell/config/modules/ii/overlay/crosshair/CrosshairContent.qml b/modules/quickshell/config/modules/ii/overlay/crosshair/CrosshairContent.qml new file mode 100644 index 0000000..668a908 --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/crosshair/CrosshairContent.qml @@ -0,0 +1,197 @@ +pragma ComponentBehavior: Bound +import QtQuick +import qs.modules.common +import qs.modules.common.functions + +Item { + id: root + + // Keys to props + // f, 0f, 1f, m are irrelevant as they're firing error stuff + // 0 is irrelevant because it's some profile stuff + property var propertyMap: ({ + "c": "color", + "u": "colorCode", + "h": "outline", + "o": "outlineOpacity", + "t": "outlineThickness", + "d": "centerDot", + "a": "centerDotOpacity", + "z": "centerDotSize", + "0a": "innerLineOpacity", + "0l": "innerLineLength", + "0v": "innerLineVerticalLength", + "0g": "innerLineUnbindAxesLengths", + "0t": "innerLineThickness", + "0o": "innerLineOffset", + "1b": "outerLines", + "1a": "outerLineOpacity", + "1l": "outerLineLength", + "1v": "outerLineVerticalLength", + "1g": "outerLineUnbindAxesLengths", + "1t": "outerLineThickness", + "1o": "outerLineOffset", + }) + property var colorMap: ({ + 0: "#FFFFFF", + 1: "#00FF00", + 2: "#7FFF00", + 3: "#DFFF00", + 4: "#FFFF00", + 5: "#00FFFF", + 6: "#FF00FF", + 7: "#FF0000" + }) + + // Raw props + property int color: 0 + property string colorCode: "#FFFFFF" + property bool outline: true + property real outlineOpacity: 0.5 + property int outlineThickness: 1 + property bool centerDot: false + property real centerDotOpacity: 1 + property int centerDotSize: 2 + property bool innerLines: true + property real innerLineOpacity: 0.8 + property int innerLineLength: 6 + property int innerLineVerticalLength: innerLineLength + property bool innerLineUnbindAxesLengths: false + property int innerLineThickness: 2 + property int innerLineOffset: 3 + property bool outerLines: true + property real outerLineOpacity: 0.35 + property int outerLineLength: 2 + property int outerLineVerticalLength: outerLineLength + property bool outerLineUnbindAxesLengths: false + property int outerLineThickness: 2 + property int outerLineOffset: 10 + property string defaultCode: "c;0;u;FFFFFF;h;1;o;0.5;t;1;d;0;a;1;z;2;0a;0.8;0l;6;0v;6;0g;0;0t;2;0o;3;1b;1;1a;0.35;1l;2;1v;2;1g;0;1t;2;1o;10" + + function loadFromCode(code: string): void { + let args = code.split(";"); + for (let i = 0; i < args.length; i+= 2) { + let key = args[i]; + let value = args[i+1]; + let targetKey = root.propertyMap[key]; + let targetType = typeof root[targetKey]; + + if (targetKey === undefined) continue; + + if (targetType === "number") { + value = parseFloat(value); + } else if (targetType === "boolean") { + value = (value === "1"); + } + if (targetKey === "colorCode") { + value = "#" + value.slice(0, 6); + } + root[targetKey] = value; + } + + if (!root.innerLineUnbindAxesLengths) { + root.innerLineVerticalLength = root.innerLineLength; + } + if (!root.outerLineUnbindAxesLengths) { + root.outerLineVerticalLength = root.outerLineLength; + } + + } + + // Update values from code + property var code: Config.options.crosshair.code + Component.onCompleted: reloadFromCode(); + onCodeChanged: reloadFromCode(); + function reloadFromCode() { + root.loadFromCode(root.defaultCode); + root.loadFromCode(root.code); + } + + // Aggregated props + property color crosshairColor: { + if (colorMap[color] !== undefined) return root.colorMap[color]; + if (color === 8) return colorCode; + return "#FFFFFF"; + } + property int borderWidth: outline ? outlineThickness : 0 + property color borderColor: ColorUtils.transparentize("black", 1 - root.outlineOpacity) + property color innerLineColor: ColorUtils.transparentize(root.crosshairColor, 1 - root.innerLineOpacity) + property color outerLineColor: ColorUtils.transparentize(root.crosshairColor, 1 - root.outerLineOpacity) + property int innerLineTotalOffset: root.centerDotSize / 2 + 1 + root.innerLineOffset + property int outerLineTotalOffset: root.centerDotSize / 2 + 1 + root.outerLineOffset + property real centerDotTotalSize: root.centerDotSize + root.borderWidth * 2 + property real innerLineTotalSize: (innerLineTotalOffset + root.innerLineLength + root.borderWidth) * 2 + property real outerLineTotalSize: (outerLineTotalOffset + root.outerLineLength + root.borderWidth) * 2 + implicitWidth: Math.max(centerDotTotalSize, innerLineTotalSize, outerLineTotalSize) + 2 // 2 for pixel correction + implicitHeight: implicitWidth + // width: implicitWidth + // height: implicitHeight + + Rectangle { + id: centerDot + visible: root.centerDot + anchors.centerIn: parent + + color: root.crosshairColor + opacity: root.centerDotOpacity + width: centerDotTotalSize + height: width + + border.width: root.borderWidth + border.color: root.borderColor + } + + Repeater { + id: innerLines + model: 4 + Item { + id: innerHair + z: index % 2 // Vertical lines above horizontal lines + required property int index + property int pixelCorrection: (root.innerLineThickness % 2 === 1 && index > 1) ? 1 : 0 + property int hairLength: (innerHair.index % 2 === 0 ? root.innerLineLength : root.innerLineVerticalLength) + visible: root.innerLines && hairLength > 0 + anchors.fill: parent + rotation: index * 90 + Rectangle { + x: parent.width / 2 + root.innerLineTotalOffset - root.borderWidth + innerHair.pixelCorrection + y: parent.height / 2 - height / 2 + + color: root.innerLineColor + width: innerHair.hairLength + root.borderWidth * 2 + height: root.innerLineThickness + root.borderWidth * 2 + + border.width: root.borderWidth + border.color: root.borderColor + } + } + } + + Repeater { + id: outerLines + model: 4 + Item { + id: outerHair + z: index % 2 + 2 // Vertical lines above horizontal lines, above inner lines + required property int index + property int pixelCorrection: (root.outerLineThickness % 2 === 1 && index > 1) ? 1 : 0 + property int hairLength: (outerHair.index % 2 === 0 ? root.outerLineLength : root.outerLineVerticalLength) + visible: root.outerLines && hairLength > 0 + anchors.fill: parent + rotation: index * 90 + Rectangle { + x: parent.width / 2 + root.outerLineTotalOffset - root.borderWidth + outerHair.pixelCorrection + y: parent.height / 2 - height / 2 + + color: root.outerLineColor + width: hairLength + root.borderWidth * 2 + height: root.outerLineThickness + root.borderWidth * 2 + + border.width: root.borderWidth + border.color: root.borderColor + } + } + } + + +} diff --git a/modules/quickshell/config/modules/ii/overlay/floatingImage/FloatingImage.qml b/modules/quickshell/config/modules/ii/overlay/floatingImage/FloatingImage.qml new file mode 100644 index 0000000..081f9ea --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/floatingImage/FloatingImage.qml @@ -0,0 +1,95 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Qt5Compat.GraphicalEffects +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.utils +import qs.modules.ii.overlay + +StyledOverlayWidget { + id: root + showClickabilityButton: false + resizable: false + clickthrough: true + + property string imageSource: Config.options.overlay.floatingImage.imageSource + property real scaleFactor: Config.options.overlay.floatingImage.scale + property int imageWidth: 0 + property int imageHeight: 0 + + // Override to always save 0 size + function savePosition(xPos = root.x, yPos = root.y, width = 0, height = 0) { + root.persistentStateEntry.x = Math.round(xPos); + root.persistentStateEntry.y = Math.round(yPos); + root.persistentStateEntry.width = 0 + root.persistentStateEntry.height = 0 + } + + onImageSourceChanged: { + imageDownloader.running = false; + imageDownloader.sourceUrl = root.imageSource; + imageDownloader.filePath = Qt.resolvedUrl(Directories.tempImages + "/" + Qt.md5(root.imageSource)) + imageDownloader.running = true; + } + onScaleFactorChanged: { + setSize(); + } + + function setSize() { + bg.implicitWidth = root.imageWidth * root.scaleFactor; + bg.implicitHeight = root.imageHeight * root.scaleFactor; + } + + contentItem: OverlayBackground { + id: bg + color: ColorUtils.transparentize(Appearance.m3colors.m3surfaceContainer, root.actuallyPinned ? 1 : 0) + radius: root.contentRadius + + WheelHandler { + onWheel: (event) => { + if (event.angleDelta.y < 0) { + Config.options.overlay.floatingImage.scale = Math.max(0.1, Config.options.overlay.floatingImage.scale - 0.1); + } + else if (event.angleDelta.y > 0) { + Config.options.overlay.floatingImage.scale = Math.min(5.0, Config.options.overlay.floatingImage.scale + 0.1); + } + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: bg.width + height: bg.height + radius: bg.radius + } + } + + AnimatedImage { + id: animatedImage + anchors.centerIn: parent + width: root.imageWidth * root.scaleFactor + height: root.imageHeight * root.scaleFactor + sourceSize.width: width + sourceSize.height: height + + playing: visible + asynchronous: true + source: "" + + ImageDownloaderProcess { + id: imageDownloader + filePath: Qt.resolvedUrl(Directories.tempImages + "/" + Qt.md5(root.imageSource)) + sourceUrl: root.imageSource + + onDone: (path, width, height) => { + root.imageWidth = width; + root.imageHeight = height; + root.setSize(); + animatedImage.source = path; + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/overlay/fpsLimiter/FpsLimiter.qml b/modules/quickshell/config/modules/ii/overlay/fpsLimiter/FpsLimiter.qml new file mode 100644 index 0000000..857a423 --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/fpsLimiter/FpsLimiter.qml @@ -0,0 +1,14 @@ +import QtQuick +import Quickshell +import qs.modules.common +import qs.modules.ii.overlay + +StyledOverlayWidget { + id: root + title: "MangoHud FPS" + minimumWidth: 275 + minimumHeight: 100 + contentItem: FpsLimiterContent { + radius: root.contentRadius + } +} diff --git a/modules/quickshell/config/modules/ii/overlay/fpsLimiter/FpsLimiterContent.qml b/modules/quickshell/config/modules/ii/overlay/fpsLimiter/FpsLimiterContent.qml new file mode 100644 index 0000000..def4a6c --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/fpsLimiter/FpsLimiterContent.qml @@ -0,0 +1,96 @@ +import qs.services +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Io +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.ii.overlay + +OverlayBackground { + id: root + + enum State { Normal, Success, Error } + + property real padding: 16 + property var currentState: FpsLimiterContent.State.Normal + implicitWidth: content.implicitWidth + (padding * 2) + implicitHeight: content.implicitHeight + (padding * 2) + + Timer { + id: iconResetTimer + interval: 1000 + onTriggered: { + root.currentState = FpsLimiterContent.State.Normal; + } + } + + function applyLimit() { + var fpsValue = parseInt(fpsField.text); + if (isNaN(fpsValue) || fpsValue < 0) { + root.currentState = FpsLimiterContent.State.Error; + iconResetTimer.restart(); + fpsField.text = ""; + return; + } + + var cfgPaths = [ + "~/.config/MangoHud/MangoHud.conf", + ]; // MangoHud config files + + var updateCommands = cfgPaths.map(path => { + return "if grep -q '^fps_limit=' " + path + "; " + + "then sed -i 's/^fps_limit=.*/fps_limit=" + fpsValue + "/' " + path + "; " + + "else echo 'fps_limit=" + fpsValue + "' >> " + path + "; fi"; + }).join("; "); + + var cmd = updateCommands + "; pkill -SIGUSR2 mangohud"; + + fpsSetter.command = ["bash", "-c", cmd]; + fpsSetter.startDetached(); + + root.currentState = FpsLimiterContent.State.Success; + iconResetTimer.restart(); + + // Clear the field after applying + fpsField.text = ""; + } + + Process { + id: fpsSetter + } + + RowLayout { + id: content + anchors.centerIn: parent + spacing: 4 + + ToolbarTextField { + id: fpsField + Layout.fillWidth: true + Layout.preferredWidth: 200 + placeholderText: root.currentState === FpsLimiterContent.State.Error ? Translation.tr("Enter a valid number") : Translation.tr("Set FPS limit") + inputMethodHints: Qt.ImhDigitsOnly + focus: true + + onAccepted: { + root.applyLimit(); + } + } + + IconToolbarButton { + id: applyButton + text: switch (root.currentState) { + case FpsLimiterContent.State.Error: return "close"; + case FpsLimiterContent.State.Success: return "check"; + case FpsLimiterContent.State.Normal: + default: return "save"; + } + enabled: root.currentState === FpsLimiterContent.State.Normal && fpsField.text.length > 0 + onClicked: { + root.applyLimit(); + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/overlay/notes/Notes.qml b/modules/quickshell/config/modules/ii/overlay/notes/Notes.qml new file mode 100644 index 0000000..64aa11d --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/notes/Notes.qml @@ -0,0 +1,17 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.services +import qs.modules.common +import qs.modules.ii.overlay + +StyledOverlayWidget { + id: root + title: Translation.tr("Notes") + showCenterButton: true + + contentItem: NotesContent { + radius: root.contentRadius + isClickthrough: root.clickthrough + } +} diff --git a/modules/quickshell/config/modules/ii/overlay/notes/NotesContent.qml b/modules/quickshell/config/modules/ii/overlay/notes/NotesContent.qml new file mode 100644 index 0000000..e52c226 --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/notes/NotesContent.qml @@ -0,0 +1,292 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.ii.overlay + +OverlayBackground { + id: root + + property alias content: textInput.text + property bool pendingReload: false + property var copyListEntries: [] + property string lastParsedCopylistText: "" + property var parsedCopylistLines: [] + property bool isClickthrough: false + property real maxCopyButtonSize: 20 + + Component.onCompleted: { + noteFile.reload(); + updateCopyListEntries(); + } + + function saveContent() { + if (!textInput) + return; + noteFile.setText(root.content); + } + + function focusAtEnd() { + if (!textInput) + return; + textInput.forceActiveFocus(); + const endPos = root.content.length; + applySelection(endPos, endPos); + } + + function applySelection(cursorPos, anchorPos) { + if (!textInput) + return; + const textLength = root.content.length; + const cursor = Math.max(0, Math.min(cursorPos, textLength)); + const anchor = Math.max(0, Math.min(anchorPos, textLength)); + textInput.select(anchor, cursor); + if (cursor === anchor) + textInput.deselect(); + } + + function scheduleCopylistUpdate(immediate = false) { + if (!textInput) + return; + if (immediate) { + copyListDebounce?.stop(); + updateCopyListEntries(); + } else { + copyListDebounce.restart(); + } + } + + function updateCopyListEntries() { + if (!textInput) + return; + const textValue = root.content; + if (!textValue || textValue.length === 0) { + lastParsedCopylistText = ""; + parsedCopylistLines = []; + root.copyListEntries = []; + return; + } + + if (textValue !== lastParsedCopylistText) { + const lineRegex = /(.*?)(\r?\n|$)/g; + let match = null; + const parsed = []; + while ((match = lineRegex.exec(textValue)) !== null) { + const lineText = match[1]; + const newlineText = match[2]; + const lineStart = match.index; + const lineEnd = lineStart + lineText.length; + const bulletMatch = lineText.match(/^\s*-\s+(.*\S)\s*$/); + if (bulletMatch) { + parsed.push({ + content: bulletMatch[1].trim(), + start: lineStart, + end: lineEnd + }); + } + if (newlineText === "") + break; + } + lastParsedCopylistText = textValue; + parsedCopylistLines = parsed; + if (parsed.length === 0) { + root.copyListEntries = []; + return; + } + } + + updateCopylistPositions(); + } + + function updateCopylistPositions() { + if (!textInput || parsedCopylistLines.length === 0) + return; + const rawSelectionStart = textInput.selectionStart; + const rawSelectionEnd = textInput.selectionEnd; + const selectionStart = rawSelectionStart === -1 ? textInput.cursorPosition : rawSelectionStart; + const selectionEnd = rawSelectionEnd === -1 ? textInput.cursorPosition : rawSelectionEnd; + const rangeStart = Math.min(selectionStart, selectionEnd); + const rangeEnd = Math.max(selectionStart, selectionEnd); + + const entries = parsedCopylistLines.map(line => { + // Don't show copy button if line is (partially) selected + const caretIntersects = rangeEnd > line.start && rangeStart <= line.end; + if (caretIntersects) + return null; + const startRect = textInput.positionToRectangle(line.start); + let endRect = textInput.positionToRectangle(line.end); + if (!isFinite(startRect.y)) + return null; + if (!isFinite(endRect.y)) + endRect = startRect; + const lineBottom = endRect.y + endRect.height; + const rectHeight = Math.max(lineBottom - startRect.y, textInput.font.pixelSize + 8); + return { + content: line.content, + y: startRect.y, + height: rectHeight + }; + }).filter(entry => entry !== null); + + root.copyListEntries = entries; + } + + implicitWidth: 300 + implicitHeight: 200 + + ColumnLayout { + id: contentItem + anchors.fill: parent + spacing: -16 + + ScrollView { + id: editorScrollView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + ScrollBar.vertical.policy: ScrollBar.AsNeeded + onWidthChanged: root.scheduleCopylistUpdate(true) + + StyledTextArea { // This has to be a direct child of ScrollView for proper scrolling + id: textInput + anchors { + left: parent.left + right: parent.right + } + wrapMode: TextEdit.Wrap + placeholderText: Translation.tr("Write something here...\nUse '-' to create copyable bullet points, like this:\n\nSheep fricker\n- 4x Slab\n- 1x Boat\n- 4x Redstone Dust\n- 1x Sticky Piston\n- 1x End Rod\n- 4x Redstone Repeater\n- 1x Redstone Torch\n- 1x Sheep") + selectByMouse: true + persistentSelection: true + textFormat: TextEdit.PlainText + background: null + padding: 24 + + onTextChanged: { + if (textInput.activeFocus) { + saveDebounce.restart(); + } + root.scheduleCopylistUpdate(true); + } + + onHeightChanged: root.scheduleCopylistUpdate(true) + onContentHeightChanged: root.scheduleCopylistUpdate(true) + onCursorPositionChanged: root.scheduleCopylistUpdate() + onSelectionStartChanged: root.scheduleCopylistUpdate() + onSelectionEndChanged: root.scheduleCopylistUpdate() + } + + Item { + anchors.fill: parent + visible: root.copyListEntries.length > 0 + clip: true + + Repeater { + model: ScriptModel { + values: root.copyListEntries + } + delegate: RippleButton { + id: copyButton + required property var modelData + readonly property real lineHeight: Math.min(Math.max(modelData.height, Appearance.font.pixelSize.normal + 6), root.maxCopyButtonSize) + readonly property real iconSizeLocal: Appearance.font.pixelSize.normal + readonly property real hitPadding: 6 + property bool justCopied: false + + implicitHeight: lineHeight + implicitWidth: lineHeight + buttonRadius: height / 2 + y: modelData.y + anchors.right: parent.right + anchors.rightMargin: 10 + z: 5 + + Timer { + id: resetState + interval: 700 + onTriggered: { + copyButton.justCopied = false; + } + } + + onClicked: { + Quickshell.clipboardText = copyButton.modelData.content; + justCopied = true; + resetState.start(); + } + + contentItem: Item { + anchors.centerIn: parent + MaterialSymbol { + id: iconItem + anchors.centerIn: parent + text: copyButton.justCopied ? "check" : "content_copy" + iconSize: copyButton.iconSizeLocal + color: Appearance.colors.colOnLayer1 + } + } + } + } + } + } + + StyledText { + id: statusLabel + Layout.fillWidth: true + Layout.margins: 16 + horizontalAlignment: Text.AlignRight + text: saveDebounce.running ? Translation.tr("Saving...") : Translation.tr("Saved ") + color: Appearance.colors.colSubtext + } + } + + Timer { + id: saveDebounce + interval: 500 + repeat: false + onTriggered: saveContent() + } + + Timer { + id: copyListDebounce + interval: 100 + repeat: false + onTriggered: updateCopylistPositions() + } + + FileView { + id: noteFile + path: Qt.resolvedUrl(Directories.notesPath) + onLoaded: { + root.content = noteFile.text(); + if (root.content !== root.content) { + const previousCursor = textInput.cursorPosition; + const previousAnchor = textInput.selectionStart; + root.content = root.content; + applySelection(previousCursor, previousAnchor); + } + if (pendingReload) { + pendingReload = false; + Qt.callLater(root.focusAtEnd); + } + Qt.callLater(root.updateCopyListEntries); + } + onLoadFailed: error => { + if (error === FileViewError.FileNotFound) { + root.content = ""; + noteFile.setText(root.content); + if (pendingReload) { + pendingReload = false; + Qt.callLater(root.focusAtEnd); + } + Qt.callLater(root.updateCopyListEntries); + } else { + console.log("[Overlay Notes] Error loading file: " + error); + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/overlay/recorder/Recorder.qml b/modules/quickshell/config/modules/ii/overlay/recorder/Recorder.qml new file mode 100644 index 0000000..8128841 --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/recorder/Recorder.qml @@ -0,0 +1,118 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.ii.overlay + +StyledOverlayWidget { + id: root + minimumWidth: 310 + minimumHeight: 130 + + contentItem: OverlayBackground { + id: contentItem + radius: root.contentRadius + property real padding: 8 + ColumnLayout { + id: contentColumn + anchors.centerIn: parent + spacing: 10 + + Row { + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + spacing: 10 + + BigRecorderButton { + materialSymbol: "screenshot_region" + name: "Screenshot region" + onClicked: { + GlobalStates.overlayOpen = false; + Quickshell.execDetached(["qs", "-p", Quickshell.shellPath(""), "ipc", "call", "region", "screenshot"]); + } + } + + BigRecorderButton { + materialSymbol: "photo_camera" + name: "Screenshot" + onClicked: { + GlobalStates.overlayOpen = false; + Quickshell.execDetached(["bash", "-c", "grim - | wl-copy"]); + } + } + + BigRecorderButton { + materialSymbol: "screen_record" + name: "Record region" + onClicked: { + GlobalStates.overlayOpen = false; + Quickshell.execDetached(["qs", "-p", Quickshell.shellPath(""), "ipc", "call", "region", "recordWithSound"]); + } + } + + BigRecorderButton { + materialSymbol: "capture" + name: "Record screen" + onClicked: { + GlobalStates.overlayOpen = false; + Quickshell.execDetached([Directories.recordScriptPath, "--fullscreen", "--sound"]); + } + } + } + + RippleButton { + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + Layout.fillWidth: false + buttonRadius: height / 2 + colBackground: Appearance.colors.colLayer3 + colBackgroundHover: Appearance.colors.colLayer3Hover + colRipple: Appearance.colors.colLayer3Active + onClicked: { + GlobalStates.overlayOpen = false; + Qt.openUrlExternally(`file://${Config.options.screenRecord.savePath}`); + } + contentItem: Row { + anchors.centerIn: parent + spacing: 6 + MaterialSymbol { + anchors.verticalCenter: parent.verticalCenter + text: "animated_images" + iconSize: 20 + } + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: Translation.tr("Open recordings folder") + } + } + } + } + } + + component BigRecorderButton: RippleButton { + id: bigButton + required property string materialSymbol + required property string name + implicitHeight: 66 + implicitWidth: 66 + buttonRadius: height / 2 + + colBackground: Appearance.colors.colLayer3 + colBackgroundHover: Appearance.colors.colLayer3Hover + colRipple: Appearance.colors.colLayer3Active + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: bigButton.materialSymbol + iconSize: 28 + } + + StyledToolTip { + text: bigButton.name + } + } +} diff --git a/modules/quickshell/config/modules/ii/overlay/resources/Resources.qml b/modules/quickshell/config/modules/ii/overlay/resources/Resources.qml new file mode 100644 index 0000000..512307f --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/resources/Resources.qml @@ -0,0 +1,131 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Qt5Compat.GraphicalEffects +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.ii.overlay + +StyledOverlayWidget { + id: root + minimumWidth: 300 + minimumHeight: 200 + property list resources: [ + { + "icon": "planner_review", + "name": Translation.tr("CPU"), + "history": ResourceUsage.cpuUsageHistory, + "maxAvailableString": ResourceUsage.maxAvailableCpuString + }, + { + "icon": "memory", + "name": Translation.tr("RAM"), + "history": ResourceUsage.memoryUsageHistory, + "maxAvailableString": ResourceUsage.maxAvailableMemoryString + }, + { + "icon": "swap_horiz", + "name": Translation.tr("Swap"), + "history": ResourceUsage.swapUsageHistory, + "maxAvailableString": ResourceUsage.maxAvailableSwapString + }, + ] + + contentItem: OverlayBackground { + id: contentItem + radius: root.contentRadius + property real padding: 4 + ColumnLayout { + id: contentColumn + anchors { + fill: parent + margins: parent.padding + } + spacing: 8 + + SecondaryTabBar { + id: tabBar + + currentIndex: Persistent.states.overlay.resources.tabIndex + onCurrentIndexChanged: { + Persistent.states.overlay.resources.tabIndex = tabBar.currentIndex; + } + + Repeater { + model: root.resources.length + delegate: SecondaryTabButton { + required property int index + property var modelData: root.resources[index] + buttonIcon: modelData.icon + buttonText: modelData.name + } + } + } + + ResourceSummary { + Layout.margins: 8 + history: root.resources[tabBar.currentIndex]?.history ?? [] + maxAvailableString: root.resources[tabBar.currentIndex]?.maxAvailableString ?? "--" + } + } + } + + component ResourceSummary: RowLayout { + id: resourceSummary + required property list history + required property string maxAvailableString + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 12 + + ColumnLayout { + spacing: 2 + StyledText { + text: (resourceSummary.history[resourceSummary.history.length - 1] * 100).toFixed(1) + "%" + font { + family: Appearance.font.family.numbers + variableAxes: Appearance.font.variableAxes.numbers + pixelSize: Appearance.font.pixelSize.huge + } + } + StyledText { + text: Translation.tr("of %1").arg(resourceSummary.maxAvailableString) + font { + // family: Appearance.font.family.numbers + // variableAxes: Appearance.font.variableAxes.numbers + pixelSize: Appearance.font.pixelSize.smallie + } + color: Appearance.colors.colSubtext + } + Item { + Layout.fillHeight: true + } + } + Rectangle { + id: graphBg + Layout.fillWidth: true + Layout.fillHeight: true + radius: Appearance.rounding.small + color: Appearance.colors.colSecondaryContainer + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: graphBg.width + height: graphBg.height + radius: graphBg.radius + } + } + Graph { + anchors.fill: parent + values: root.resources[tabBar.currentIndex]?.history ?? [] + points: ResourceUsage.historyLength + alignment: Graph.Alignment.Right + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/overlay/volumeMixer/VolumeMixer.qml b/modules/quickshell/config/modules/ii/overlay/volumeMixer/VolumeMixer.qml new file mode 100644 index 0000000..9a93e80 --- /dev/null +++ b/modules/quickshell/config/modules/ii/overlay/volumeMixer/VolumeMixer.qml @@ -0,0 +1,80 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.ii.overlay +import qs.modules.ii.sidebarRight.volumeMixer + +StyledOverlayWidget { + id: root + minimumWidth: 300 + minimumHeight: 380 + + contentItem: OverlayBackground { + radius: root.contentRadius + property real padding: 6 + + ColumnLayout { + id: contentColumn + anchors { + fill: parent + margins: parent.padding + } + spacing: 8 + + SecondaryTabBar { + id: tabBar + + currentIndex: Persistent.states.overlay.volumeMixer.tabIndex + onCurrentIndexChanged: { + Persistent.states.overlay.volumeMixer.tabIndex = tabBar.currentIndex; + } + + SecondaryTabButton { + buttonIcon: "media_output" + buttonText: Translation.tr("Output") + } + SecondaryTabButton { + buttonIcon: "mic" + buttonText: Translation.tr("Input") + } + } + SwipeView { + id: swipeView + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: Persistent.states.overlay.volumeMixer.tabIndex + onCurrentIndexChanged: { + Persistent.states.overlay.volumeMixer.tabIndex = swipeView.currentIndex; + } + clip: true + + PaddedVolumeDialogContent { + isSink: true + } + PaddedVolumeDialogContent { + isSink: false + } + } + } + } + + component PaddedVolumeDialogContent: Item { + id: paddedVolumeDialogContent + property alias isSink: volDialogContent.isSink + property real padding: 12 + implicitWidth: volDialogContent.implicitWidth + padding * 2 + implicitHeight: volDialogContent.implicitHeight + padding * 2 + + VolumeDialogContent { + id: volDialogContent + anchors { + fill: parent + margins: paddedVolumeDialogContent.padding + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/overview/Overview.qml b/modules/quickshell/config/modules/ii/overview/Overview.qml new file mode 100644 index 0000000..248b46b --- /dev/null +++ b/modules/quickshell/config/modules/ii/overview/Overview.qml @@ -0,0 +1,252 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import Qt.labs.synchronizer +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: overviewScope + property bool dontAutoCancelSearch: false + Variants { + id: overviewVariants + model: Quickshell.screens + PanelWindow { + id: root + required property var modelData + property string searchingText: "" + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen) + property bool monitorIsFocused: (Hyprland.focusedMonitor?.id == monitor?.id) + screen: modelData + visible: GlobalStates.overviewOpen + + WlrLayershell.namespace: "quickshell:overview" + WlrLayershell.layer: WlrLayer.Overlay + // WlrLayershell.keyboardFocus: GlobalStates.overviewOpen ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + color: "transparent" + + mask: Region { + item: GlobalStates.overviewOpen ? columnLayout : null + } + + anchors { + top: true + bottom: true + left: true + right: true + } + + HyprlandFocusGrab { + id: grab + windows: [root] + property bool canBeActive: root.monitorIsFocused + active: false + onCleared: () => { + if (!active) + GlobalStates.overviewOpen = false; + } + } + + Connections { + target: GlobalStates + function onOverviewOpenChanged() { + if (!GlobalStates.overviewOpen) { + searchWidget.disableExpandAnimation(); + overviewScope.dontAutoCancelSearch = false; + } else { + if (!overviewScope.dontAutoCancelSearch) { + searchWidget.cancelSearch(); + } + delayedGrabTimer.start(); + } + } + } + + Timer { + id: delayedGrabTimer + interval: Config.options.hacks.arbitraryRaceConditionDelay + repeat: false + onTriggered: { + if (!grab.canBeActive) + return; + grab.active = GlobalStates.overviewOpen; + } + } + + implicitWidth: columnLayout.implicitWidth + implicitHeight: columnLayout.implicitHeight + + function setSearchingText(text) { + searchWidget.setSearchingText(text); + searchWidget.focusFirstItem(); + } + + Column { + id: columnLayout + visible: GlobalStates.overviewOpen + anchors { + horizontalCenter: parent.horizontalCenter + top: parent.top + } + spacing: -8 + + Keys.onPressed: event => { + if (event.key === Qt.Key_Escape) { + GlobalStates.overviewOpen = false; + } else if (event.key === Qt.Key_Left) { + if (!root.searchingText) + Hyprland.dispatch("workspace r-1"); + } else if (event.key === Qt.Key_Right) { + if (!root.searchingText) + Hyprland.dispatch("workspace r+1"); + } + } + + SearchWidget { + id: searchWidget + anchors.horizontalCenter: parent.horizontalCenter + Synchronizer on searchingText { + property alias source: root.searchingText + } + } + + Loader { + id: overviewLoader + anchors.horizontalCenter: parent.horizontalCenter + active: GlobalStates.overviewOpen && (Config?.options.overview.enable ?? true) + sourceComponent: OverviewWidget { + panelWindow: root + visible: (root.searchingText == "") + } + } + } + } + } + + function toggleClipboard() { + if (GlobalStates.overviewOpen && overviewScope.dontAutoCancelSearch) { + GlobalStates.overviewOpen = false; + return; + } + for (let i = 0; i < overviewVariants.instances.length; i++) { + let panelWindow = overviewVariants.instances[i]; + if (panelWindow.modelData.name == Hyprland.focusedMonitor.name) { + overviewScope.dontAutoCancelSearch = true; + panelWindow.setSearchingText(Config.options.search.prefix.clipboard); + GlobalStates.overviewOpen = true; + return; + } + } + } + + function toggleEmojis() { + if (GlobalStates.overviewOpen && overviewScope.dontAutoCancelSearch) { + GlobalStates.overviewOpen = false; + return; + } + for (let i = 0; i < overviewVariants.instances.length; i++) { + let panelWindow = overviewVariants.instances[i]; + if (panelWindow.modelData.name == Hyprland.focusedMonitor.name) { + overviewScope.dontAutoCancelSearch = true; + panelWindow.setSearchingText(Config.options.search.prefix.emojis); + GlobalStates.overviewOpen = true; + return; + } + } + } + + IpcHandler { + target: "overview" + + function toggle() { + GlobalStates.overviewOpen = !GlobalStates.overviewOpen; + } + function workspacesToggle() { + GlobalStates.overviewOpen = !GlobalStates.overviewOpen; + } + function close() { + GlobalStates.overviewOpen = false; + } + function open() { + GlobalStates.overviewOpen = true; + } + function toggleReleaseInterrupt() { + GlobalStates.superReleaseMightTrigger = false; + } + function clipboardToggle() { + overviewScope.toggleClipboard(); + } + } + + GlobalShortcut { + name: "overviewToggle" + description: "Toggles overview on press" + + onPressed: { + GlobalStates.overviewOpen = !GlobalStates.overviewOpen; + } + } + GlobalShortcut { + name: "overviewWorkspacesToggle" + description: "Toggles overview on press" + + onPressed: { + GlobalStates.overviewOpen = !GlobalStates.overviewOpen; + } + } + GlobalShortcut { + name: "overviewClose" + description: "Closes overview" + + onPressed: { + GlobalStates.overviewOpen = false; + } + } + GlobalShortcut { + name: "overviewToggleRelease" + description: "Toggles overview on release" + + onPressed: { + GlobalStates.superReleaseMightTrigger = true; + } + + onReleased: { + if (!GlobalStates.superReleaseMightTrigger) { + GlobalStates.superReleaseMightTrigger = true; + return; + } + GlobalStates.overviewOpen = !GlobalStates.overviewOpen; + } + } + GlobalShortcut { + name: "overviewToggleReleaseInterrupt" + description: "Interrupts possibility of overview being toggled on release. " + "This is necessary because GlobalShortcut.onReleased in quickshell triggers whether or not you press something else while holding the key. " + "To make sure this works consistently, use binditn = MODKEYS, catchall in an automatically triggered submap that includes everything." + + onPressed: { + GlobalStates.superReleaseMightTrigger = false; + } + } + GlobalShortcut { + name: "overviewClipboardToggle" + description: "Toggle clipboard query on overview widget" + + onPressed: { + overviewScope.toggleClipboard(); + } + } + + GlobalShortcut { + name: "overviewEmojiToggle" + description: "Toggle emoji query on overview widget" + + onPressed: { + overviewScope.toggleEmojis(); + } + } +} diff --git a/modules/quickshell/config/modules/ii/overview/OverviewWidget.qml b/modules/quickshell/config/modules/ii/overview/OverviewWidget.qml new file mode 100644 index 0000000..18501b0 --- /dev/null +++ b/modules/quickshell/config/modules/ii/overview/OverviewWidget.qml @@ -0,0 +1,329 @@ +pragma ComponentBehavior: Bound +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Item { + id: root + required property var panelWindow + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(panelWindow.screen) + readonly property var toplevels: ToplevelManager.toplevels + readonly property int workspacesShown: Config.options.overview.rows * Config.options.overview.columns + readonly property int workspaceGroup: Math.floor((monitor.activeWorkspace?.id - 1) / workspacesShown) + property bool monitorIsFocused: (Hyprland.focusedMonitor?.name == monitor.name) + property var windows: HyprlandData.windowList + property var windowByAddress: HyprlandData.windowByAddress + property var windowAddresses: HyprlandData.addresses + property var monitorData: HyprlandData.monitors.find(m => m.id === root.monitor?.id) + property real scale: Config.options.overview.scale + property color activeBorderColor: Appearance.colors.colSecondary + + property real workspaceImplicitWidth: (monitorData?.transform % 2 === 1) ? + ((monitor.height - monitorData?.reserved[0] - monitorData?.reserved[2]) * root.scale / monitor.scale) : + ((monitor.width - monitorData?.reserved[0] - monitorData?.reserved[2]) * root.scale / monitor.scale) + property real workspaceImplicitHeight: (monitorData?.transform % 2 === 1) ? + ((monitor.width - monitorData?.reserved[1] - monitorData?.reserved[3]) * root.scale / monitor.scale) : + ((monitor.height - monitorData?.reserved[1] - monitorData?.reserved[3]) * root.scale / monitor.scale) + property real largeWorkspaceRadius: Appearance.rounding.large + property real smallWorkspaceRadius: Appearance.rounding.verysmall + + property real workspaceNumberMargin: 80 + property real workspaceNumberSize: 250 * monitor.scale + property int workspaceZ: 0 + property int windowZ: 1 + property int windowDraggingZ: 99999 + property real workspaceSpacing: 5 + + property int draggingFromWorkspace: -1 + property int draggingTargetWorkspace: -1 + + implicitWidth: overviewBackground.implicitWidth + Appearance.sizes.elevationMargin * 2 + implicitHeight: overviewBackground.implicitHeight + Appearance.sizes.elevationMargin * 2 + + property Component windowComponent: OverviewWindow {} + property list windowWidgets: [] + + StyledRectangularShadow { + target: overviewBackground + } + Rectangle { // Background + id: overviewBackground + property real padding: 10 + anchors.fill: parent + anchors.margins: Appearance.sizes.elevationMargin + + implicitWidth: workspaceColumnLayout.implicitWidth + padding * 2 + implicitHeight: workspaceColumnLayout.implicitHeight + padding * 2 + radius: root.largeWorkspaceRadius + padding + color: Appearance.colors.colBackgroundSurfaceContainer + + Column { // Workspaces + id: workspaceColumnLayout + + z: root.workspaceZ + anchors.centerIn: parent + spacing: workspaceSpacing + + Repeater { + model: Config.options.overview.rows + delegate: Row { + id: row + required property int index + spacing: workspaceSpacing + + Repeater { // Workspace repeater + model: Config.options.overview.columns + Rectangle { // Workspace + id: workspace + required property int index + property int colIndex: index + property int workspaceValue: root.workspaceGroup * root.workspacesShown + row.index * Config.options.overview.columns + colIndex + 1 + property color defaultWorkspaceColor: ColorUtils.mix(Appearance.colors.colBackgroundSurfaceContainer, Appearance.colors.colSurfaceContainerHigh, 0.8) + property color hoveredWorkspaceColor: ColorUtils.mix(defaultWorkspaceColor, Appearance.colors.colLayer1Hover, 0.1) + property color hoveredBorderColor: Appearance.colors.colLayer2Hover + property bool hoveredWhileDragging: false + + implicitWidth: root.workspaceImplicitWidth + implicitHeight: root.workspaceImplicitHeight + color: hoveredWhileDragging ? hoveredWorkspaceColor : defaultWorkspaceColor + property bool workspaceAtLeft: colIndex === 0 + property bool workspaceAtRight: colIndex === Config.options.overview.columns - 1 + property bool workspaceAtTop: row.index === 0 + property bool workspaceAtBottom: row.index === Config.options.overview.rows - 1 + topLeftRadius: (workspaceAtLeft && workspaceAtTop) ? root.largeWorkspaceRadius : root.smallWorkspaceRadius + topRightRadius: (workspaceAtRight && workspaceAtTop) ? root.largeWorkspaceRadius : root.smallWorkspaceRadius + bottomLeftRadius: (workspaceAtLeft && workspaceAtBottom) ? root.largeWorkspaceRadius : root.smallWorkspaceRadius + bottomRightRadius: (workspaceAtRight && workspaceAtBottom) ? root.largeWorkspaceRadius : root.smallWorkspaceRadius + border.width: 2 + border.color: hoveredWhileDragging ? hoveredBorderColor : "transparent" + + StyledText { + anchors.centerIn: parent + text: workspace.workspaceValue + font { + pixelSize: root.workspaceNumberSize * root.scale + weight: Font.DemiBold + family: Appearance.font.family.expressive + } + color: ColorUtils.transparentize(Appearance.colors.colOnLayer1, 0.8) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + MouseArea { + id: workspaceArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onPressed: { + if (root.draggingTargetWorkspace === -1) { + GlobalStates.overviewOpen = false + Hyprland.dispatch(`workspace ${workspace.workspaceValue}`) + } + } + } + + DropArea { + anchors.fill: parent + onEntered: { + root.draggingTargetWorkspace = workspace.workspaceValue + if (root.draggingFromWorkspace == root.draggingTargetWorkspace) return; + hoveredWhileDragging = true + } + onExited: { + hoveredWhileDragging = false + if (root.draggingTargetWorkspace == workspace.workspaceValue) root.draggingTargetWorkspace = -1 + } + } + + } + } + } + } + } + + Item { // Windows & focused workspace indicator + id: windowSpace + anchors.centerIn: parent + implicitWidth: workspaceColumnLayout.implicitWidth + implicitHeight: workspaceColumnLayout.implicitHeight + + Repeater { // Window repeater + model: ScriptModel { + values: { + // console.log(JSON.stringify(ToplevelManager.toplevels.values.map(t => t), null, 2)) + return [...ToplevelManager.toplevels.values.filter((toplevel) => { + const address = `0x${toplevel.HyprlandToplevel?.address}` + var win = windowByAddress[address] + const inWorkspaceGroup = (root.workspaceGroup * root.workspacesShown < win?.workspace?.id && win?.workspace?.id <= (root.workspaceGroup + 1) * root.workspacesShown) + return inWorkspaceGroup; + })].reverse() + } + } + delegate: OverviewWindow { + id: window + required property var modelData + property int monitorId: windowData?.monitor + property var monitor: HyprlandData.monitors.find(m => m.id == monitorId) + property var address: `0x${modelData.HyprlandToplevel.address}` + toplevel: modelData + monitorData: this.monitor + scale: root.scale + widgetMonitor: HyprlandData.monitors.find(m => m.id == root.monitor.id) + windowData: windowByAddress[address] + + property bool atInitPosition: (initX == x && initY == y) + + // Offset on the canvas + property int workspaceColIndex: (windowData?.workspace.id - 1) % Config.options.overview.columns + property int workspaceRowIndex: Math.floor((windowData?.workspace.id - 1) % root.workspacesShown / Config.options.overview.columns) + xOffset: (root.workspaceImplicitWidth + workspaceSpacing) * workspaceColIndex + yOffset: (root.workspaceImplicitHeight + workspaceSpacing) * workspaceRowIndex + property real xWithinWorkspaceWidget: Math.max((windowData?.at[0] - (monitor?.x ?? 0) - monitorData?.reserved[0]) * root.scale, 0) + property real yWithinWorkspaceWidget: Math.max((windowData?.at[1] - (monitor?.y ?? 0) - monitorData?.reserved[1]) * root.scale, 0) + + // Radius + property real minRadius: Appearance.rounding.small + property bool workspaceAtLeft: workspaceColIndex === 0 + property bool workspaceAtRight: workspaceColIndex === Config.options.overview.columns - 1 + property bool workspaceAtTop: workspaceRowIndex === 0 + property bool workspaceAtBottom: workspaceRowIndex === Config.options.overview.rows - 1 + property bool workspaceAtTopLeft: (workspaceAtLeft && workspaceAtTop) + property bool workspaceAtTopRight: (workspaceAtRight && workspaceAtTop) + property bool workspaceAtBottomLeft: (workspaceAtLeft && workspaceAtBottom) + property bool workspaceAtBottomRight: (workspaceAtRight && workspaceAtBottom) + property real distanceFromLeftEdge: xWithinWorkspaceWidget + property real distanceFromRightEdge: root.workspaceImplicitWidth - (xWithinWorkspaceWidget + targetWindowWidth) + property real distanceFromTopEdge: yWithinWorkspaceWidget + property real distanceFromBottomEdge: root.workspaceImplicitHeight - (yWithinWorkspaceWidget + targetWindowHeight) + property real distanceFromTopLeftCorner: Math.max(distanceFromLeftEdge, distanceFromTopEdge) + property real distanceFromTopRightCorner: Math.max(distanceFromRightEdge, distanceFromTopEdge) + property real distanceFromBottomLeftCorner: Math.max(distanceFromLeftEdge, distanceFromBottomEdge) + property real distanceFromBottomRightCorner: Math.max(distanceFromRightEdge, distanceFromBottomEdge) + topLeftRadius: Math.max((workspaceAtTopLeft ? root.largeWorkspaceRadius : root.smallWorkspaceRadius) - distanceFromTopLeftCorner, minRadius) + topRightRadius: Math.max((workspaceAtTopRight ? root.largeWorkspaceRadius : root.smallWorkspaceRadius) - distanceFromTopRightCorner, minRadius) + bottomLeftRadius: Math.max((workspaceAtBottomLeft ? root.largeWorkspaceRadius : root.smallWorkspaceRadius) - distanceFromBottomLeftCorner, minRadius) + bottomRightRadius: Math.max((workspaceAtBottomRight ? root.largeWorkspaceRadius : root.smallWorkspaceRadius) - distanceFromBottomRightCorner, minRadius) + + Timer { + id: updateWindowPosition + interval: Config.options.hacks.arbitraryRaceConditionDelay + repeat: false + running: false + onTriggered: { + window.x = Math.round(xWithinWorkspaceWidget + xOffset) + window.y = Math.round(yWithinWorkspaceWidget + yOffset) + } + } + + z: Drag.active ? root.windowDraggingZ : (root.windowZ + windowData?.floating) + Drag.hotSpot.x: width / 2 + Drag.hotSpot.y: height / 2 + MouseArea { + id: dragArea + anchors.fill: parent + hoverEnabled: true + onEntered: hovered = true // For hover color change + onExited: hovered = false // For hover color change + acceptedButtons: Qt.LeftButton | Qt.MiddleButton + drag.target: parent + onPressed: (mouse) => { + root.draggingFromWorkspace = windowData?.workspace.id + window.pressed = true + window.Drag.active = true + window.Drag.source = window + window.Drag.hotSpot.x = mouse.x + window.Drag.hotSpot.y = mouse.y + // console.log(`[OverviewWindow] Dragging window ${windowData?.address} from position (${window.x}, ${window.y})`) + } + onReleased: { + const targetWorkspace = root.draggingTargetWorkspace + window.pressed = false + window.Drag.active = false + root.draggingFromWorkspace = -1 + if (targetWorkspace !== -1 && targetWorkspace !== windowData?.workspace.id) { + Hyprland.dispatch(`movetoworkspacesilent ${targetWorkspace}, address:${window.windowData?.address}`) + updateWindowPosition.restart() + } + else { + if (!window.windowData.floating) { + updateWindowPosition.restart() + return + } + const percentageX = Math.round((window.x - xOffset) / root.workspaceImplicitWidth * 100) + const percentageY = Math.round((window.y - yOffset) / root.workspaceImplicitHeight * 100) + Hyprland.dispatch(`movewindowpixel exact ${percentageX}% ${percentageY}%, address:${window.windowData?.address}`) + } + } + onClicked: (event) => { + if (!windowData) return; + + if (event.button === Qt.LeftButton) { + GlobalStates.overviewOpen = false + Hyprland.dispatch(`focuswindow address:${windowData.address}`) + event.accepted = true + } else if (event.button === Qt.MiddleButton) { + Hyprland.dispatch(`closewindow address:${windowData.address}`) + event.accepted = true + } + } + + StyledToolTip { + extraVisibleCondition: false + alternativeVisibleCondition: dragArea.containsMouse && !window.Drag.active + text: `${windowData.title}\n[${windowData.class}] ${windowData.xwayland ? "[XWayland] " : ""}` + } + } + } + } + + Rectangle { // Focused workspace indicator + id: focusedWorkspaceIndicator + property int activeWorkspaceInGroup: monitor.activeWorkspace?.id - (root.workspaceGroup * root.workspacesShown) + property int rowIndex: Math.floor((activeWorkspaceInGroup - 1) / Config.options.overview.columns) + property int colIndex: (activeWorkspaceInGroup - 1) % Config.options.overview.columns + x: (root.workspaceImplicitWidth + workspaceSpacing) * colIndex + y: (root.workspaceImplicitHeight + workspaceSpacing) * rowIndex + z: root.windowZ + width: root.workspaceImplicitWidth + height: root.workspaceImplicitHeight + color: "transparent" + property bool workspaceAtLeft: colIndex === 0 + property bool workspaceAtRight: colIndex === Config.options.overview.columns - 1 + property bool workspaceAtTop: rowIndex === 0 + property bool workspaceAtBottom: rowIndex === Config.options.overview.rows - 1 + topLeftRadius: (workspaceAtLeft && workspaceAtTop) ? root.largeWorkspaceRadius : root.smallWorkspaceRadius + topRightRadius: (workspaceAtRight && workspaceAtTop) ? root.largeWorkspaceRadius : root.smallWorkspaceRadius + bottomLeftRadius: (workspaceAtLeft && workspaceAtBottom) ? root.largeWorkspaceRadius : root.smallWorkspaceRadius + bottomRightRadius: (workspaceAtRight && workspaceAtBottom) ? root.largeWorkspaceRadius : root.smallWorkspaceRadius + border.width: 2 + border.color: root.activeBorderColor + Behavior on x { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on y { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on topLeftRadius { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on topRightRadius { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on bottomLeftRadius { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on bottomRightRadius { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/overview/OverviewWindow.qml b/modules/quickshell/config/modules/ii/overview/OverviewWindow.qml new file mode 100644 index 0000000..a05fde0 --- /dev/null +++ b/modules/quickshell/config/modules/ii/overview/OverviewWindow.qml @@ -0,0 +1,144 @@ +pragma ComponentBehavior: Bound +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland + +Item { // Window + id: root + property var toplevel + property var windowData + property var monitorData + property var scale + property bool restrictToWorkspace: true + property real widthRatio: { + const widgetWidth = widgetMonitor.transform & 1 ? widgetMonitor.height : widgetMonitor.width; + const monitorWidth = monitorData.transform & 1 ? monitorData.height : monitorData.width; + return (widgetWidth * monitorData.scale) / (monitorWidth * widgetMonitor.scale); + } + property real heightRatio: { + const widgetHeight = widgetMonitor.transform & 1 ? widgetMonitor.width : widgetMonitor.height; + const monitorHeight = monitorData.transform & 1 ? monitorData.width : monitorData.height; + return (widgetHeight * monitorData.scale) / (monitorHeight * widgetMonitor.scale); + } + property real initX: { + return Math.max((windowData?.at[0] - (monitorData?.x ?? 0) - monitorData?.reserved[0]) * widthRatio * root.scale, 0) + xOffset; + } + + property real initY: { + return Math.max((windowData?.at[1] - (monitorData?.y ?? 0) - monitorData?.reserved[1]) * heightRatio * root.scale, 0) + yOffset; + } + property real xOffset: 0 + property real yOffset: 0 + property var widgetMonitor + property int widgetMonitorId: widgetMonitor.id + + property var targetWindowWidth: windowData?.size[0] * scale * widthRatio + property var targetWindowHeight: windowData?.size[1] * scale * heightRatio + property bool hovered: false + property bool pressed: false + + property bool centerIcons: Config.options.overview.centerIcons + property real iconGapRatio: 0.06 + property real iconToWindowRatio: centerIcons ? 0.35 : 0.15 + property real xwaylandIndicatorToIconRatio: 0.35 + property real iconToWindowRatioCompact: 0.6 + property string iconPath: Quickshell.iconPath(AppSearch.guessIcon(windowData?.class), "image-missing") + property bool compactMode: Appearance.font.pixelSize.smaller * 4 > targetWindowHeight || Appearance.font.pixelSize.smaller * 4 > targetWindowWidth + + property bool indicateXWayland: windowData?.xwayland ?? false + + x: initX + y: initY + width: targetWindowWidth + height: targetWindowHeight + opacity: windowData.monitor == widgetMonitorId ? 1 : 0.4 + + property real topLeftRadius + property real topRightRadius + property real bottomLeftRadius + property real bottomRightRadius + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: root.width + height: root.height + topLeftRadius: root.topLeftRadius + topRightRadius: root.topRightRadius + bottomRightRadius: root.bottomRightRadius + bottomLeftRadius: root.bottomLeftRadius + } + } + + Behavior on x { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on y { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on width { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + ScreencopyView { + id: windowPreview + anchors.fill: parent + captureSource: GlobalStates.overviewOpen ? root.toplevel : null + live: true + + // Color overlay for interactions + Rectangle { + anchors.fill: parent + topLeftRadius: root.topLeftRadius + topRightRadius: root.topRightRadius + bottomRightRadius: root.bottomRightRadius + bottomLeftRadius: root.bottomLeftRadius + color: pressed ? ColorUtils.transparentize(Appearance.colors.colLayer2Active, 0.5) : + hovered ? ColorUtils.transparentize(Appearance.colors.colLayer2Hover, 0.7) : + ColorUtils.transparentize(Appearance.colors.colLayer2) + border.color : ColorUtils.transparentize(Appearance.m3colors.m3outline, 0.88) + border.width : 1 + } + + Image { + id: windowIcon + property real baseSize: Math.min(root.targetWindowWidth, root.targetWindowHeight) + anchors { + top: root.centerIcons ? undefined : parent.top + left: root.centerIcons ? undefined : parent.left + centerIn: root.centerIcons ? parent : undefined + margins: baseSize * root.iconGapRatio + } + property var iconSize: { + // console.log("-=-=-", root.toplevel.title, "-=-=-") + // console.log("Target window size:", targetWindowWidth, targetWindowHeight) + // console.log("Icon ratio:", root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) + // console.log("Scale:", root.monitorData.scale) + // console.log("Final:", Math.min(targetWindowWidth, targetWindowHeight) * (root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) / root.monitorData.scale) + return baseSize * (root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio); + } + // mipmap: true + Layout.alignment: Qt.AlignHCenter + source: root.iconPath + width: iconSize + height: iconSize + sourceSize: Qt.size(iconSize, iconSize) + + Behavior on width { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/overview/SearchBar.qml b/modules/quickshell/config/modules/ii/overview/SearchBar.qml new file mode 100644 index 0000000..5cd7b9a --- /dev/null +++ b/modules/quickshell/config/modules/ii/overview/SearchBar.qml @@ -0,0 +1,152 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +RowLayout { + id: root + spacing: 6 + property bool animateWidth: false + property alias searchInput: searchInput + property string searchingText + + function forceFocus() { + searchInput.forceActiveFocus(); + } + + enum SearchPrefixType { Action, App, Clipboard, Emojis, Math, ShellCommand, WebSearch, DefaultSearch } + + property var searchPrefixType: { + if (root.searchingText.startsWith(Config.options.search.prefix.action)) return SearchBar.SearchPrefixType.Action; + if (root.searchingText.startsWith(Config.options.search.prefix.app)) return SearchBar.SearchPrefixType.App; + if (root.searchingText.startsWith(Config.options.search.prefix.clipboard)) return SearchBar.SearchPrefixType.Clipboard; + if (root.searchingText.startsWith(Config.options.search.prefix.emojis)) return SearchBar.SearchPrefixType.Emojis; + if (root.searchingText.startsWith(Config.options.search.prefix.math)) return SearchBar.SearchPrefixType.Math; + if (root.searchingText.startsWith(Config.options.search.prefix.shellCommand)) return SearchBar.SearchPrefixType.ShellCommand; + if (root.searchingText.startsWith(Config.options.search.prefix.webSearch)) return SearchBar.SearchPrefixType.WebSearch; + return SearchBar.SearchPrefixType.DefaultSearch; + } + + MaterialShapeWrappedMaterialSymbol { + id: searchIcon + Layout.alignment: Qt.AlignVCenter + iconSize: Appearance.font.pixelSize.huge + shape: switch(root.searchPrefixType) { + case SearchBar.SearchPrefixType.Action: return MaterialShape.Shape.Pill; + case SearchBar.SearchPrefixType.App: return MaterialShape.Shape.Clover4Leaf; + case SearchBar.SearchPrefixType.Clipboard: return MaterialShape.Shape.Gem; + case SearchBar.SearchPrefixType.Emojis: return MaterialShape.Shape.Sunny; + case SearchBar.SearchPrefixType.Math: return MaterialShape.Shape.PuffyDiamond; + case SearchBar.SearchPrefixType.ShellCommand: return MaterialShape.Shape.PixelCircle; + case SearchBar.SearchPrefixType.WebSearch: return MaterialShape.Shape.SoftBurst; + default: return MaterialShape.Shape.Cookie7Sided; + } + text: switch (root.searchPrefixType) { + case SearchBar.SearchPrefixType.Action: return "settings_suggest"; + case SearchBar.SearchPrefixType.App: return "apps"; + case SearchBar.SearchPrefixType.Clipboard: return "content_paste_search"; + case SearchBar.SearchPrefixType.Emojis: return "add_reaction"; + case SearchBar.SearchPrefixType.Math: return "calculate"; + case SearchBar.SearchPrefixType.ShellCommand: return "terminal"; + case SearchBar.SearchPrefixType.WebSearch: return "travel_explore"; + case SearchBar.SearchPrefixType.DefaultSearch: return "search"; + default: return "search"; + } + } + ToolbarTextField { // Search box + id: searchInput + Layout.topMargin: 4 + Layout.bottomMargin: 4 + implicitHeight: 40 + focus: GlobalStates.overviewOpen + font.pixelSize: Appearance.font.pixelSize.small + placeholderText: Translation.tr("Search, calculate or run") + implicitWidth: root.searchingText == "" ? Appearance.sizes.searchWidthCollapsed : Appearance.sizes.searchWidth + + Behavior on implicitWidth { + id: searchWidthBehavior + enabled: root.animateWidth + NumberAnimation { + duration: 300 + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + onTextChanged: root.searchingText = text + + onAccepted: { + if (appResults.count > 0) { + // Get the first visible delegate and trigger its click + let firstItem = appResults.itemAtIndex(0); + if (firstItem && firstItem.clicked) { + firstItem.clicked(); + } + } + } + } + + IconToolbarButton { + Layout.topMargin: 4 + Layout.bottomMargin: 4 + onClicked: { + GlobalStates.overviewOpen = false; + Quickshell.execDetached(["qs", "-p", Quickshell.shellPath(""), "ipc", "call", "region", "search"]); + } + text: "image_search" + StyledToolTip { + text: Translation.tr("Google Lens") + } + } + + IconToolbarButton { + id: songRecButton + Layout.topMargin: 4 + Layout.bottomMargin: 4 + Layout.rightMargin: 4 + toggled: SongRec.running + onClicked: SongRec.toggleRunning() + text: "music_cast" + + StyledToolTip { + text: Translation.tr("Recognize music") + } + + colText: toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSurfaceVariant + background: MaterialShape { + RotationAnimation on rotation { + running: songRecButton.toggled + duration: 12000 + easing.type: Easing.Linear + loops: Animation.Infinite + from: 0 + to: 360 + } + shape: { + if (songRecButton.down) { + return songRecButton.toggled ? MaterialShape.Shape.Circle : MaterialShape.Shape.Square + } else { + return songRecButton.toggled ? MaterialShape.Shape.SoftBurst : MaterialShape.Shape.Circle + } + } + color: { + if (songRecButton.toggled) { + return songRecButton.hovered ? Appearance.colors.colPrimaryHover : Appearance.colors.colPrimary + } else { + return songRecButton.hovered ? Appearance.colors.colSurfaceContainerHigh : ColorUtils.transparentize(Appearance.colors.colSurfaceContainerHigh) + } + } + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/overview/SearchItem.qml b/modules/quickshell/config/modules/ii/overview/SearchItem.qml new file mode 100644 index 0000000..8f72db5 --- /dev/null +++ b/modules/quickshell/config/modules/ii/overview/SearchItem.qml @@ -0,0 +1,286 @@ +// pragma NativeMethodBehavior: AcceptThisObject +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Hyprland + +RippleButton { + id: root + property var entry + property string query + property bool entryShown: entry?.shown ?? true + property string itemType: entry?.type ?? Translation.tr("App") + property string itemName: entry?.name ?? "" + property string itemIcon: entry?.icon ?? "" + property var itemExecute: entry?.execute + property string fontType: entry?.fontType ?? "main" + property string itemClickActionName: entry?.clickActionName ?? "Open" + property string bigText: entry?.bigText ?? "" + property string materialSymbol: entry?.materialSymbol ?? "" + property string cliphistRawString: entry?.cliphistRawString ?? "" + property bool blurImage: entry?.blurImage ?? false + property string blurImageText: entry?.blurImageText ?? "Image hidden" + + visible: root.entryShown + property int horizontalMargin: 10 + property int buttonHorizontalPadding: 10 + property int buttonVerticalPadding: 6 + property bool keyboardDown: false + + implicitHeight: rowLayout.implicitHeight + root.buttonVerticalPadding * 2 + implicitWidth: rowLayout.implicitWidth + root.buttonHorizontalPadding * 2 + buttonRadius: Appearance.rounding.normal + colBackground: (root.down || root.keyboardDown) ? Appearance.colors.colPrimaryContainerActive : + ((root.hovered || root.focus) ? Appearance.colors.colPrimaryContainer : + ColorUtils.transparentize(Appearance.colors.colPrimaryContainer, 1)) + colBackgroundHover: Appearance.colors.colPrimaryContainer + colRipple: Appearance.colors.colPrimaryContainerActive + + property string highlightPrefix: `` + property string highlightSuffix: `` + function highlightContent(content, query) { + if (!query || query.length === 0 || content == query || fontType === "monospace") + return StringUtils.escapeHtml(content); + + let contentLower = content.toLowerCase(); + let queryLower = query.toLowerCase(); + + let result = ""; + let lastIndex = 0; + let qIndex = 0; + + for (let i = 0; i < content.length && qIndex < query.length; i++) { + if (contentLower[i] === queryLower[qIndex]) { + // Add non-highlighted part (escaped) + if (i > lastIndex) + result += StringUtils.escapeHtml(content.slice(lastIndex, i)); + // Add highlighted character (escaped) + result += root.highlightPrefix + StringUtils.escapeHtml(content[i]) + root.highlightSuffix; + lastIndex = i + 1; + qIndex++; + } + } + // Add the rest of the string (escaped) + if (lastIndex < content.length) + result += StringUtils.escapeHtml(content.slice(lastIndex)); + + return result; + } + property string displayContent: highlightContent(root.itemName, root.query) + + property list urls: { + if (!root.itemName) return []; + // Regular expression to match URLs + const urlRegex = /https?:\/\/[^\s<>"{}|\\^`[\]]+/gi; + const matches = root.itemName?.match(urlRegex) + ?.filter(url => !url.includes("โ€ฆ")) // Elided = invalid + return matches ? matches : []; + } + + PointingHandInteraction {} + + background { + anchors.fill: root + anchors.leftMargin: root.horizontalMargin + anchors.rightMargin: root.horizontalMargin + } + + onClicked: { + GlobalStates.overviewOpen = false + root.itemExecute() + } + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Delete && event.modifiers === Qt.ShiftModifier) { + const deleteAction = root.entry.actions.find(action => action.name == "Delete"); + + if (deleteAction) { + deleteAction.execute() + } + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.keyboardDown = true + root.clicked() + event.accepted = true; + } + } + Keys.onReleased: (event) => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.keyboardDown = false + event.accepted = true; + } + } + + RowLayout { + id: rowLayout + spacing: iconLoader.sourceComponent === null ? 0 : 10 + anchors.fill: parent + anchors.leftMargin: root.horizontalMargin + root.buttonHorizontalPadding + anchors.rightMargin: root.horizontalMargin + root.buttonHorizontalPadding + + // Icon + Loader { + id: iconLoader + active: true + sourceComponent: root.materialSymbol !== "" ? materialSymbolComponent : + root.bigText ? bigTextComponent : + root.itemIcon !== "" ? iconImageComponent : + null + } + + Component { + id: iconImageComponent + IconImage { + source: Quickshell.iconPath(root.itemIcon, "image-missing") + width: 35 + height: 35 + } + } + + Component { + id: materialSymbolComponent + MaterialSymbol { + text: root.materialSymbol + iconSize: 30 + color: Appearance.m3colors.m3onSurface + } + } + + Component { + id: bigTextComponent + StyledText { + text: root.bigText + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.m3colors.m3onSurface + } + } + + // Main text + ColumnLayout { + id: contentColumn + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 0 + StyledText { + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colSubtext + visible: root.itemType && root.itemType != Translation.tr("App") + text: root.itemType + } + RowLayout { + Loader { // Checkmark for copied clipboard entry + visible: itemName == Quickshell.clipboardText && root.cliphistRawString + active: itemName == Quickshell.clipboardText && root.cliphistRawString + sourceComponent: Rectangle { + implicitWidth: activeText.implicitHeight + implicitHeight: activeText.implicitHeight + radius: Appearance.rounding.full + color: Appearance.colors.colPrimary + MaterialSymbol { + id: activeText + anchors.centerIn: parent + text: "check" + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onPrimary + } + } + } + Repeater { // Favicons for links + model: root.query == root.itemName ? [] : root.urls + Favicon { + required property var modelData + size: parent.height + url: modelData + } + } + StyledText { // Item name/content + Layout.fillWidth: true + id: nameText + textFormat: Text.StyledText // RichText also works, but StyledText ensures elide work + font.pixelSize: Appearance.font.pixelSize.small + font.family: Appearance.font.family[root.fontType] + color: Appearance.m3colors.m3onSurface + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + text: `${root.displayContent}` + } + } + Loader { // Clipboard image preview + active: root.cliphistRawString && Cliphist.entryIsImage(root.cliphistRawString) + sourceComponent: CliphistImage { + Layout.fillWidth: true + entry: root.cliphistRawString + maxWidth: contentColumn.width + maxHeight: 140 + blur: root.blurImage + blurText: root.blurImageText + } + } + } + + // Action text + StyledText { + Layout.fillWidth: false + visible: (root.hovered || root.focus) + id: clickAction + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colOnPrimaryContainer + horizontalAlignment: Text.AlignRight + text: root.itemClickActionName + } + + RowLayout { + Layout.alignment: Qt.AlignTop + Layout.topMargin: root.buttonVerticalPadding + Layout.bottomMargin: -root.buttonVerticalPadding // Why is this necessary? Good question. + spacing: 4 + Repeater { + model: (root.entry.actions ?? []).slice(0, 4) + delegate: RippleButton { + id: actionButton + required property var modelData + property string iconName: modelData.icon ?? "" + property string materialIconName: modelData.materialIcon ?? "" + implicitHeight: 34 + implicitWidth: 34 + + colBackgroundHover: Appearance.colors.colSecondaryContainerHover + colRipple: Appearance.colors.colSecondaryContainerActive + + contentItem: Item { + id: actionContentItem + anchors.centerIn: parent + Loader { + anchors.centerIn: parent + active: !(actionButton.iconName !== "") || actionButton.materialIconName + sourceComponent: MaterialSymbol { + text: actionButton.materialIconName || "video_settings" + font.pixelSize: Appearance.font.pixelSize.hugeass + color: Appearance.m3colors.m3onSurface + } + } + Loader { + anchors.centerIn: parent + active: actionButton.materialIconName.length == 0 && actionButton.iconName && actionButton.iconName !== "" + sourceComponent: IconImage { + source: Quickshell.iconPath(actionButton.iconName) + implicitSize: 20 + } + } + } + + onClicked: modelData.execute() + + StyledToolTip { + text: modelData.name + } + } + } + } + + } +} diff --git a/modules/quickshell/config/modules/ii/overview/SearchWidget.qml b/modules/quickshell/config/modules/ii/overview/SearchWidget.qml new file mode 100644 index 0000000..9198512 --- /dev/null +++ b/modules/quickshell/config/modules/ii/overview/SearchWidget.qml @@ -0,0 +1,465 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import Qt.labs.synchronizer +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io + +Item { // Wrapper + id: root + readonly property string xdgConfigHome: Directories.config + property string searchingText: "" + property bool showResults: searchingText != "" + implicitWidth: searchWidgetContent.implicitWidth + Appearance.sizes.elevationMargin * 2 + implicitHeight: searchBar.implicitHeight + searchBar.verticalPadding * 2 + Appearance.sizes.elevationMargin * 2 + + property string mathResult: "" + property bool clipboardWorkSafetyActive: { + const enabled = Config.options.workSafety.enable.clipboard; + const sensitiveNetwork = (StringUtils.stringListContainsSubstring(Network.networkName.toLowerCase(), Config.options.workSafety.triggerCondition.networkNameKeywords)) + return enabled && sensitiveNetwork; + } + + property var searchActions: [ + { + action: "accentcolor", + execute: args => { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--noswitch", "--color", ...(args != '' ? [`${args}`] : [])]); + } + }, + { + action: "dark", + execute: () => { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", "dark", "--noswitch"]); + } + }, + { + action: "light", + execute: () => { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", "light", "--noswitch"]); + } + }, + { + action: "superpaste", + execute: args => { + if (!/^(\d+)/.test(args.trim())) { // Invalid if doesn't start with numbers + Quickshell.execDetached([ + "notify-send", + Translation.tr("Superpaste"), + Translation.tr("Usage: %1superpaste NUM_OF_ENTRIES[i]\nSupply i when you want images\nExamples:\n%1superpaste 4i for the last 4 images\n%1superpaste 7 for the last 7 entries").arg(Config.options.search.prefix.action), + "-a", "Shell" + ]); + return; + } + const syntaxMatch = /^(?:(\d+)(i)?)/.exec(args.trim()); + const count = syntaxMatch[1] ? parseInt(syntaxMatch[1]) : 1; + const isImage = !!syntaxMatch[2]; + Cliphist.superpaste(count, isImage); + } + }, + { + action: "todo", + execute: args => { + Todo.addTask(args); + } + }, + { + action: "wallpaper", + execute: () => { + GlobalStates.wallpaperSelectorOpen = true; + } + }, + { + action: "wipeclipboard", + execute: () => { + Cliphist.wipe(); + } + }, + ] + + function focusFirstItem() { + appResults.currentIndex = 0; + } + + function focusSearchInput() { + searchBar.forceFocus(); + } + + function disableExpandAnimation() { + searchBar.animateWidth = false; + } + + function cancelSearch() { + searchBar.searchInput.selectAll(); + root.searchingText = ""; + searchBar.animateWidth = true; + } + + function setSearchingText(text) { + searchBar.searchInput.text = text; + root.searchingText = text; + } + + function containsUnsafeLink(entry) { + if (entry == undefined) return false; + const unsafeKeywords = Config.options.workSafety.triggerCondition.linkKeywords; + return StringUtils.stringListContainsSubstring(entry.toLowerCase(), unsafeKeywords); + } + + Timer { + id: nonAppResultsTimer + interval: Config.options.search.nonAppResultDelay + onTriggered: { + let expr = root.searchingText; + if (expr.startsWith(Config.options.search.prefix.math)) { + expr = expr.slice(Config.options.search.prefix.math.length); + } + mathProcess.calculateExpression(expr); + } + } + + Process { + id: mathProcess + property list baseCommand: ["qalc", "-t"] + function calculateExpression(expression) { + mathProcess.running = false; + mathProcess.command = baseCommand.concat(expression); + mathProcess.running = true; + } + stdout: SplitParser { + onRead: data => { + root.mathResult = data; + root.focusFirstItem(); + } + } + } + + Keys.onPressed: event => { + // Prevent Esc and Backspace from registering + if (event.key === Qt.Key_Escape) + return; + + // Handle Backspace: focus and delete character if not focused + if (event.key === Qt.Key_Backspace) { + if (!searchBar.searchInput.activeFocus) { + root.focusSearchInput(); + if (event.modifiers & Qt.ControlModifier) { + // Delete word before cursor + let text = searchBar.searchInput.text; + let pos = searchBar.searchInput.cursorPosition; + if (pos > 0) { + // Find the start of the previous word + let left = text.slice(0, pos); + let match = left.match(/(\s*\S+)\s*$/); + let deleteLen = match ? match[0].length : 1; + searchBar.searchInput.text = text.slice(0, pos - deleteLen) + text.slice(pos); + searchBar.searchInput.cursorPosition = pos - deleteLen; + } + } else { + // Delete character before cursor if any + if (searchBar.searchInput.cursorPosition > 0) { + searchBar.searchInput.text = searchBar.searchInput.text.slice(0, searchBar.searchInput.cursorPosition - 1) + searchBar.searchInput.text.slice(searchBar.searchInput.cursorPosition); + searchBar.searchInput.cursorPosition -= 1; + } + } + // Always move cursor to end after programmatic edit + searchBar.searchInput.cursorPosition = searchBar.searchInput.text.length; + event.accepted = true; + } + // If already focused, let TextField handle it + return; + } + + // Only handle visible printable characters (ignore control chars, arrows, etc.) + if (event.text && event.text.length === 1 && event.key !== Qt.Key_Enter && event.key !== Qt.Key_Return && event.key !== Qt.Key_Delete && event.text.charCodeAt(0) >= 0x20) // ignore control chars like Backspace, Tab, etc. + { + if (!searchBar.searchInput.activeFocus) { + root.focusSearchInput(); + // Insert the character at the cursor position + searchBar.searchInput.text = searchBar.searchInput.text.slice(0, searchBar.searchInput.cursorPosition) + event.text + searchBar.searchInput.text.slice(searchBar.searchInput.cursorPosition); + searchBar.searchInput.cursorPosition += 1; + event.accepted = true; + root.focusFirstItem(); + } + } + } + + StyledRectangularShadow { + target: searchWidgetContent + } + Rectangle { // Background + id: searchWidgetContent + anchors { + top: parent.top + horizontalCenter: parent.horizontalCenter + topMargin: Appearance.sizes.elevationMargin + } + clip: true + implicitWidth: columnLayout.implicitWidth + implicitHeight: columnLayout.implicitHeight + radius: searchBar.height / 2 + searchBar.verticalPadding + color: Appearance.colors.colBackgroundSurfaceContainer + + Behavior on implicitHeight { + id: searchHeightBehavior + enabled: GlobalStates.overviewOpen && root.showResults + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + ColumnLayout { + id: columnLayout + anchors { + top: parent.top + horizontalCenter: parent.horizontalCenter + } + spacing: 0 + + // clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: searchWidgetContent.width + height: searchWidgetContent.width + radius: searchWidgetContent.radius + } + } + + SearchBar { + id: searchBar + property real verticalPadding: 4 + Layout.fillWidth: true + Layout.leftMargin: 10 + Layout.rightMargin: 4 + Layout.topMargin: verticalPadding + Layout.bottomMargin: verticalPadding + Synchronizer on searchingText { + property alias source: root.searchingText + } + } + + Rectangle { + // Separator + visible: root.showResults + Layout.fillWidth: true + height: 1 + color: Appearance.colors.colOutlineVariant + } + + ListView { // App results + id: appResults + visible: root.showResults + Layout.fillWidth: true + implicitHeight: Math.min(600, appResults.contentHeight + topMargin + bottomMargin) + clip: true + topMargin: 10 + bottomMargin: 10 + spacing: 2 + KeyNavigation.up: searchBar + highlightMoveDuration: 100 + + onFocusChanged: { + if (focus) + appResults.currentIndex = 1; + } + + Connections { + target: root + function onSearchingTextChanged() { + if (appResults.count > 0) + appResults.currentIndex = 0; + } + } + + model: ScriptModel { + id: model + objectProp: "key" + values: { + // Search results are handled here + ////////////////// Skip? ////////////////// + if (root.searchingText == "") + return []; + + ///////////// Special cases /////////////// + if (root.searchingText.startsWith(Config.options.search.prefix.clipboard)) { + // Clipboard + const searchString = StringUtils.cleanPrefix(root.searchingText, Config.options.search.prefix.clipboard); + return Cliphist.fuzzyQuery(searchString).map((entry, index, array) => { + const mightBlurImage = Cliphist.entryIsImage(entry) && root.clipboardWorkSafetyActive; + let shouldBlurImage = mightBlurImage; + if (mightBlurImage) { + shouldBlurImage = shouldBlurImage && (containsUnsafeLink(array[index - 1]) || containsUnsafeLink(array[index + 1])); + } + const type = `#${entry.match(/^\s*(\S+)/)?.[1] || ""}` + return { + key: type, + cliphistRawString: entry, + name: StringUtils.cleanCliphistEntry(entry), + clickActionName: "", + type: type, + execute: () => { + Cliphist.copy(entry) + }, + actions: [ + { + name: "Copy", + materialIcon: "content_copy", + execute: () => { + Cliphist.copy(entry); + } + }, + { + name: "Delete", + materialIcon: "delete", + execute: () => { + Cliphist.deleteEntry(entry); + } + } + ], + blurImage: shouldBlurImage, + blurImageText: Translation.tr("Work safety") + }; + }).filter(Boolean); + } + else if (root.searchingText.startsWith(Config.options.search.prefix.emojis)) { + // Clipboard + const searchString = StringUtils.cleanPrefix(root.searchingText, Config.options.search.prefix.emojis); + return Emojis.fuzzyQuery(searchString).map(entry => { + const emoji = entry.match(/^\s*(\S+)/)?.[1] || "" + return { + key: emoji, + cliphistRawString: entry, + bigText: emoji, + name: entry.replace(/^\s*\S+\s+/, ""), + clickActionName: "", + type: "Emoji", + execute: () => { + Quickshell.clipboardText = entry.match(/^\s*(\S+)/)?.[1]; + } + }; + }).filter(Boolean); + } + + ////////////////// Init /////////////////// + nonAppResultsTimer.restart(); + const mathResultObject = { + key: `Math result: ${root.mathResult}`, + name: root.mathResult, + clickActionName: Translation.tr("Copy"), + type: Translation.tr("Math result"), + fontType: "monospace", + materialSymbol: 'calculate', + execute: () => { + Quickshell.clipboardText = root.mathResult; + } + }; + const appResultObjects = AppSearch.fuzzyQuery(StringUtils.cleanPrefix(root.searchingText, Config.options.search.prefix.app)).map(entry => { + entry.clickActionName = Translation.tr("Launch"); + entry.type = Translation.tr("App"); + entry.key = entry.execute + return entry; + }) + const commandResultObject = { + key: `cmd ${root.searchingText}`, + name: StringUtils.cleanPrefix(root.searchingText, Config.options.search.prefix.shellCommand).replace("file://", ""), + clickActionName: Translation.tr("Run"), + type: Translation.tr("Run command"), + fontType: "monospace", + materialSymbol: 'terminal', + execute: () => { + let cleanedCommand = root.searchingText.replace("file://", ""); + cleanedCommand = StringUtils.cleanPrefix(cleanedCommand, Config.options.search.prefix.shellCommand); + if (cleanedCommand.startsWith(Config.options.search.prefix.shellCommand)) { + cleanedCommand = cleanedCommand.slice(Config.options.search.prefix.shellCommand.length); + } + Quickshell.execDetached(["bash", "-c", searchingText.startsWith('sudo') ? `${Config.options.apps.terminal} fish -C '${cleanedCommand}'` : cleanedCommand]); + } + }; + const webSearchResultObject = { + key: `website ${root.searchingText}`, + name: StringUtils.cleanPrefix(root.searchingText, Config.options.search.prefix.webSearch), + clickActionName: Translation.tr("Search"), + type: Translation.tr("Search the web"), + materialSymbol: 'travel_explore', + execute: () => { + let query = StringUtils.cleanPrefix(root.searchingText, Config.options.search.prefix.webSearch); + let url = Config.options.search.engineBaseUrl + query; + for (let site of Config.options.search.excludedSites) { + url += ` -site:${site}`; + } + Qt.openUrlExternally(url); + } + } + const launcherActionObjects = root.searchActions.map(action => { + const actionString = `${Config.options.search.prefix.action}${action.action}`; + if (actionString.startsWith(root.searchingText) || root.searchingText.startsWith(actionString)) { + return { + key: `Action ${actionString}`, + name: root.searchingText.startsWith(actionString) ? root.searchingText : actionString, + clickActionName: Translation.tr("Run"), + type: Translation.tr("Action"), + materialSymbol: 'settings_suggest', + execute: () => { + action.execute(root.searchingText.split(" ").slice(1).join(" ")); + } + }; + } + return null; + }).filter(Boolean); + + //////// Prioritized by prefix ///////// + let result = []; + const startsWithNumber = /^\d/.test(root.searchingText); + const startsWithMathPrefix = root.searchingText.startsWith(Config.options.search.prefix.math); + const startsWithShellCommandPrefix = root.searchingText.startsWith(Config.options.search.prefix.shellCommand); + const startsWithWebSearchPrefix = root.searchingText.startsWith(Config.options.search.prefix.webSearch); + if (startsWithNumber || startsWithMathPrefix) { + result.push(mathResultObject); + } else if (startsWithShellCommandPrefix) { + result.push(commandResultObject); + } else if (startsWithWebSearchPrefix) { + result.push(webSearchResultObject); + } + + //////////////// Apps ////////////////// + result = result.concat(appResultObjects); + + ////////// Launcher actions //////////// + result = result.concat(launcherActionObjects); + + /// Math result, command, web search /// + if (Config.options.search.prefix.showDefaultActionsWithoutPrefix) { + if (!startsWithShellCommandPrefix) result.push(commandResultObject); + if (!startsWithNumber && !startsWithMathPrefix) result.push(mathResultObject); + if (!startsWithWebSearchPrefix) result.push(webSearchResultObject); + } + + return result; + } + } + + delegate: SearchItem { + // The selectable item for each search result + required property var modelData + anchors.left: parent?.left + anchors.right: parent?.right + entry: modelData + query: StringUtils.cleanOnePrefix(root.searchingText, [ + Config.options.search.prefix.action, + Config.options.search.prefix.app, + Config.options.search.prefix.clipboard, + Config.options.search.prefix.emojis, + Config.options.search.prefix.math, + Config.options.search.prefix.shellCommand, + Config.options.search.prefix.webSearch + ]) + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/polkit/Polkit.qml b/modules/quickshell/config/modules/ii/polkit/Polkit.qml new file mode 100644 index 0000000..e1c54a4 --- /dev/null +++ b/modules/quickshell/config/modules/ii/polkit/Polkit.qml @@ -0,0 +1,42 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + + Loader { + active: PolkitService.active + sourceComponent: Variants { + model: Quickshell.screens + delegate: PanelWindow { + id: panelWindow + required property var modelData + screen: modelData + + anchors { + top: true + left: true + right: true + bottom: true + } + + color: "transparent" + WlrLayershell.namespace: "quickshell:polkit" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + WlrLayershell.layer: WlrLayer.Overlay + exclusionMode: ExclusionMode.Ignore + + PolkitContent { + anchors.fill: parent + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/polkit/PolkitContent.qml b/modules/quickshell/config/modules/ii/polkit/PolkitContent.qml new file mode 100644 index 0000000..baef7f0 --- /dev/null +++ b/modules/quickshell/config/modules/ii/polkit/PolkitContent.qml @@ -0,0 +1,113 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +Item { + id: root + readonly property bool usePasswordChars: !PolkitService.flow?.responseVisible ?? true + + Keys.onPressed: event => { // Esc to close + if (event.key === Qt.Key_Escape) { + PolkitService.cancel(); + } + } + + function submit() { + PolkitService.submit(inputField.text); + } + Connections { + target: PolkitService + function onInteractionAvailableChanged() { + if (!PolkitService.interactionAvailable) return; + inputField.text = ""; + inputField.forceActiveFocus(); + } + } + + Rectangle { + id: bg + anchors.fill: parent + color: Appearance.colors.colScrim + opacity: 0 + Component.onCompleted: { + opacity = 1 + } + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + + WindowDialog { + anchors.centerIn: parent + backgroundWidth: 450 + show: false + Component.onCompleted: { + show = true + } + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 26 + text: "security" + color: Appearance.colors.colSecondary + } + + WindowDialogTitle { + id: titleText + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + text: Translation.tr("Authentication") + } + + WindowDialogParagraph { + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + text: { + if (!PolkitService.flow) return; + return PolkitService.flow.message.endsWith(".") + ? PolkitService.flow.message.slice(0, -1) + : PolkitService.flow.message + } + } + + MaterialTextField { + id: inputField + Layout.fillWidth: true + focus: true + enabled: PolkitService.interactionAvailable + placeholderText: { + const inputPrompt = PolkitService.flow?.inputPrompt.trim() ?? ""; + const cleanedInputPrompt = inputPrompt.endsWith(":") ? inputPrompt.slice(0, -1) : inputPrompt; + return cleanedInputPrompt || (root.usePasswordChars ? Translation.tr("Password") : Translation.tr("Input")) + } + echoMode: root.usePasswordChars ? TextInput.Password : TextInput.Normal + onAccepted: root.submit(); + + Keys.onPressed: event => { // Esc to close + if (event.key === Qt.Key_Escape) { + PolkitService.cancel(); + } + } + } + + WindowDialogButtonRow { + + Item { + Layout.fillWidth: true + } + DialogButton { + buttonText: Translation.tr("Cancel") + onClicked: PolkitService.cancel(); + } + DialogButton { + enabled: PolkitService.interactionAvailable + buttonText: Translation.tr("OK") + onClicked: root.submit(); + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/regionSelector/CircleSelectionDetails.qml b/modules/quickshell/config/modules/ii/regionSelector/CircleSelectionDetails.qml new file mode 100644 index 0000000..c0f823f --- /dev/null +++ b/modules/quickshell/config/modules/ii/regionSelector/CircleSelectionDetails.qml @@ -0,0 +1,49 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Shapes +import Quickshell + +Item { + id: root + required property color color + required property color overlayColor + required property list points + property int strokeWidth: Config.options.regionSelector.circle.strokeWidth + + function updatePoints() { + if (!root.dragging) return; + root.points.push({ x: root.mouseX, y: root.mouseY }); + } + + Rectangle { + id: darkenOverlay + z: 1 + anchors.fill: parent + color: root.overlayColor + } + + Shape { + id: shape + z: 2 + anchors.fill: parent + layer.enabled: true + layer.smooth: true + preferredRendererType: Shape.CurveRenderer + + ShapePath { + id: shapePath + strokeWidth: root.strokeWidth + pathHints: ShapePath.PathLinear + fillColor: "transparent" + strokeColor: root.color + capStyle: ShapePath.RoundCap + joinStyle: ShapePath.RoundJoin + + PathPolyline { + path: root.points + } + } + } + +} diff --git a/modules/quickshell/config/modules/ii/regionSelector/OptionsToolbar.qml b/modules/quickshell/config/modules/ii/regionSelector/OptionsToolbar.qml new file mode 100644 index 0000000..63a3e8c --- /dev/null +++ b/modules/quickshell/config/modules/ii/regionSelector/OptionsToolbar.qml @@ -0,0 +1,80 @@ +pragma ComponentBehavior: Bound +import qs +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +// Options toolbar +Toolbar { + id: root + + // Use a synchronizer on these + property var action + property var selectionMode + // Signals + signal dismiss() + + MaterialShape { + Layout.fillHeight: true + Layout.leftMargin: 2 + Layout.rightMargin: 2 + implicitSize: 36 // Intentionally smaller because this one is brighter than others + shape: switch (root.action) { + case RegionSelection.SnipAction.Copy: + case RegionSelection.SnipAction.Edit: + return MaterialShape.Shape.Cookie4Sided; + case RegionSelection.SnipAction.Search: + return MaterialShape.Shape.Pentagon; + case RegionSelection.SnipAction.CharRecognition: + return MaterialShape.Shape.Sunny; + case RegionSelection.SnipAction.Record: + case RegionSelection.SnipAction.RecordWithSound: + return MaterialShape.Shape.Gem; + default: + return MaterialShape.Shape.Cookie12Sided; + } + color: Appearance.colors.colPrimary + MaterialSymbol { + anchors.centerIn: parent + iconSize: 22 + color: Appearance.colors.colOnPrimary + animateChange: true + text: switch (root.action) { + case RegionSelection.SnipAction.Copy: + case RegionSelection.SnipAction.Edit: + return "content_cut"; + case RegionSelection.SnipAction.Search: + return "image_search"; + case RegionSelection.SnipAction.CharRecognition: + return "document_scanner"; + case RegionSelection.SnipAction.Record: + case RegionSelection.SnipAction.RecordWithSound: + return "videocam"; + default: + return ""; + } + } + } + + ToolbarTabBar { + id: tabBar + tabButtonList: [ + {"icon": "activity_zone", "name": Translation.tr("Rect")}, + {"icon": "gesture", "name": Translation.tr("Circle")} + ] + currentIndex: root.selectionMode === RegionSelection.SelectionMode.RectCorners ? 0 : 1 + onCurrentIndexChanged: { + root.selectionMode = currentIndex === 0 ? RegionSelection.SelectionMode.RectCorners : RegionSelection.SelectionMode.Circle; + } + } + +} diff --git a/modules/quickshell/config/modules/ii/regionSelector/RectCornersSelectionDetails.qml b/modules/quickshell/config/modules/ii/regionSelector/RectCornersSelectionDetails.qml new file mode 100644 index 0000000..4006913 --- /dev/null +++ b/modules/quickshell/config/modules/ii/regionSelector/RectCornersSelectionDetails.qml @@ -0,0 +1,90 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +Item { + id: root + required property real regionX + required property real regionY + required property real regionWidth + required property real regionHeight + required property real mouseX + required property real mouseY + required property color color + required property color overlayColor + property bool showAimLines: Config.options.regionSelector.rect.showAimLines + + // Overlay to darken screen + // Base dark overlay around region + Rectangle { + id: darkenOverlay + z: 1 + anchors { + left: parent.left + top: parent.top + leftMargin: root.regionX - darkenOverlay.border.width + topMargin: root.regionY - darkenOverlay.border.width + } + width: root.regionWidth + darkenOverlay.border.width * 2 + height: root.regionHeight + darkenOverlay.border.width * 2 + color: "transparent" + border.color: root.overlayColor + border.width: Math.max(root.width, root.height) + } + + // Selection border + Rectangle { + id: selectionBorder + z: 1 + anchors { + left: parent.left + top: parent.top + leftMargin: root.regionX + topMargin: root.regionY + } + width: root.regionWidth + height: root.regionHeight + color: "transparent" + border.color: root.color + border.width: 2 + // radius: root.standardRounding + radius: 0 // TODO: figure out how to make the overlay thing work with rounding + } + + StyledText { + z: 2 + anchors { + top: selectionBorder.bottom + right: selectionBorder.right + margins: 8 + } + color: root.color + text: `${Math.round(root.regionWidth)} x ${Math.round(root.regionHeight)}` + } + + // Coord lines + Rectangle { // Vertical + visible: root.showAimLines + opacity: 0.2 + z: 2 + x: root.mouseX + anchors { + top: parent.top + bottom: parent.bottom + } + width: 1 + color: root.color + } + Rectangle { // Horizontal + visible: root.showAimLines + opacity: 0.2 + z: 2 + y: root.mouseY + anchors { + left: parent.left + right: parent.right + } + height: 1 + color: root.color + } +} diff --git a/modules/quickshell/config/modules/ii/regionSelector/RegionFunctions.qml b/modules/quickshell/config/modules/ii/regionSelector/RegionFunctions.qml new file mode 100644 index 0000000..ee1805d --- /dev/null +++ b/modules/quickshell/config/modules/ii/regionSelector/RegionFunctions.qml @@ -0,0 +1,76 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + function intersectionOverUnion(regionA, regionB) { + // region: { at: [x, y], size: [w, h] } + const ax1 = regionA.at[0], ay1 = regionA.at[1]; + const ax2 = ax1 + regionA.size[0], ay2 = ay1 + regionA.size[1]; + const bx1 = regionB.at[0], by1 = regionB.at[1]; + const bx2 = bx1 + regionB.size[0], by2 = by1 + regionB.size[1]; + + const interX1 = Math.max(ax1, bx1); + const interY1 = Math.max(ay1, by1); + const interX2 = Math.min(ax2, bx2); + const interY2 = Math.min(ay2, by2); + + const interArea = Math.max(0, interX2 - interX1) * Math.max(0, interY2 - interY1); + const areaA = (ax2 - ax1) * (ay2 - ay1); + const areaB = (bx2 - bx1) * (by2 - by1); + const unionArea = areaA + areaB - interArea; + + return unionArea > 0 ? interArea / unionArea : 0; + } + + function filterOverlappingImageRegions(regions) { + let keep = []; + let removed = new Set(); + for (let i = 0; i < regions.length; ++i) { + if (removed.has(i)) continue; + let regionA = regions[i]; + for (let j = i + 1; j < regions.length; ++j) { + if (removed.has(j)) continue; + let regionB = regions[j]; + if (intersectionOverUnion(regionA, regionB) > 0) { + // Compare areas + let areaA = regionA.size[0] * regionA.size[1]; + let areaB = regionB.size[0] * regionB.size[1]; + if (areaA <= areaB) { + removed.add(j); + } else { + removed.add(i); + } + } + } + } + for (let i = 0; i < regions.length; ++i) { + if (!removed.has(i)) keep.push(regions[i]); + } + return keep; + } + + function filterWindowRegionsByLayers(windowRegions, layerRegions) { + return windowRegions.filter(windowRegion => { + for (let i = 0; i < layerRegions.length; ++i) { + if (intersectionOverUnion(windowRegion, layerRegions[i]) > 0) + return false; + } + return true; + }); + } + + function filterImageRegions(regions, windowRegions, threshold = 0.1) { + // Remove image regions that overlap too much with any window region + let filtered = regions.filter(region => { + for (let i = 0; i < windowRegions.length; ++i) { + if (intersectionOverUnion(region, windowRegions[i]) > threshold) + return false; + } + return true; + }); + // Remove overlapping image regions, keep only the smaller one + return filterOverlappingImageRegions(filtered); + } +} diff --git a/modules/quickshell/config/modules/ii/regionSelector/RegionSelection.qml b/modules/quickshell/config/modules/ii/regionSelector/RegionSelection.qml new file mode 100644 index 0000000..a52471b --- /dev/null +++ b/modules/quickshell/config/modules/ii/regionSelector/RegionSelection.qml @@ -0,0 +1,533 @@ +pragma ComponentBehavior: Bound +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Controls +import Qt.labs.synchronizer +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +PanelWindow { + id: root + visible: false + color: "transparent" + WlrLayershell.namespace: "quickshell:regionSelector" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + exclusionMode: ExclusionMode.Ignore + anchors { + left: true + right: true + top: true + bottom: true + } + + // TODO: Ask: sidebar AI; Ocr: tesseract + enum SnipAction { Copy, Edit, Search, CharRecognition, Record, RecordWithSound } + enum SelectionMode { RectCorners, Circle } + property var action: RegionSelection.SnipAction.Copy + property var selectionMode: RegionSelection.SelectionMode.RectCorners + signal dismiss() + + property string saveScreenshotDir: Config.options.screenSnip.savePath !== "" + ? Config.options.screenSnip.savePath + : "" + + property string screenshotDir: Directories.screenshotTemp + property string imageSearchEngineBaseUrl: Config.options.search.imageSearch.imageSearchEngineBaseUrl + property string fileUploadApiEndpoint: "https://uguu.se/upload" + property color overlayColor: "#88111111" + property color brightText: Appearance.m3colors.darkmode ? Appearance.colors.colOnLayer0 : Appearance.colors.colLayer0 + property color brightSecondary: Appearance.m3colors.darkmode ? Appearance.colors.colSecondary : Appearance.colors.colOnSecondary + property color brightTertiary: Appearance.m3colors.darkmode ? Appearance.colors.colTertiary : Qt.lighter(Appearance.colors.colPrimary) + property color selectionBorderColor: ColorUtils.mix(brightText, brightSecondary, 0.5) + property color selectionFillColor: "#33ffffff" + property color windowBorderColor: brightSecondary + property color windowFillColor: ColorUtils.transparentize(windowBorderColor, 0.85) + property color imageBorderColor: brightTertiary + property color imageFillColor: ColorUtils.transparentize(imageBorderColor, 0.85) + property color onBorderColor: "#ff000000" + readonly property var windows: [...HyprlandData.windowList].sort((a, b) => { + // Sort floating=true windows before others + if (a.floating === b.floating) return 0; + return a.floating ? -1 : 1; + }) + readonly property var layers: HyprlandData.layers + readonly property real falsePositivePreventionRatio: 0.5 + + readonly property HyprlandMonitor hyprlandMonitor: Hyprland.monitorFor(screen) + readonly property real monitorScale: hyprlandMonitor.scale + readonly property real monitorOffsetX: hyprlandMonitor.x + readonly property real monitorOffsetY: hyprlandMonitor.y + property int activeWorkspaceId: hyprlandMonitor.activeWorkspace?.id ?? 0 + property string screenshotPath: `${root.screenshotDir}/image-${screen.name}` + property real dragStartX: 0 + property real dragStartY: 0 + property real draggingX: 0 + property real draggingY: 0 + property real dragDiffX: 0 + property real dragDiffY: 0 + property bool draggedAway: (dragDiffX !== 0 || dragDiffY !== 0) + property bool dragging: false + property list points: [] + property var mouseButton: null + property var imageRegions: [] + readonly property list windowRegions: RegionFunctions.filterWindowRegionsByLayers( + root.windows.filter(w => w.workspace.id === root.activeWorkspaceId), + root.layerRegions + ).map(window => { + return { + at: [window.at[0] - root.monitorOffsetX, window.at[1] - root.monitorOffsetY], + size: [window.size[0], window.size[1]], + class: window.class, + title: window.title, + } + }) + readonly property list layerRegions: { + const layersOfThisMonitor = root.layers[root.hyprlandMonitor.name] + const topLayers = layersOfThisMonitor?.levels["2"] + if (!topLayers) return []; + const nonBarTopLayers = topLayers + .filter(layer => !(layer.namespace.includes(":bar") || layer.namespace.includes(":verticalBar") || layer.namespace.includes(":dock"))) + .map(layer => { + return { + at: [layer.x, layer.y], + size: [layer.w, layer.h], + namespace: layer.namespace, + } + }) + const offsetAdjustedLayers = nonBarTopLayers.map(layer => { + return { + at: [layer.at[0] - root.monitorOffsetX, layer.at[1] - root.monitorOffsetY], + size: layer.size, + namespace: layer.namespace, + } + }); + return offsetAdjustedLayers; + } + + property bool isCircleSelection: (root.selectionMode === RegionSelection.SelectionMode.Circle) + property bool enableWindowRegions: Config.options.regionSelector.targetRegions.windows && !isCircleSelection + property bool enableLayerRegions: Config.options.regionSelector.targetRegions.layers && !isCircleSelection + property bool enableContentRegions: Config.options.regionSelector.targetRegions.content + property real targetRegionOpacity: Config.options.regionSelector.targetRegions.opacity + property bool contentRegionOpacity: Config.options.regionSelector.targetRegions.contentRegionOpacity + + property real targetedRegionX: -1 + property real targetedRegionY: -1 + property real targetedRegionWidth: 0 + property real targetedRegionHeight: 0 + function targetedRegionValid() { + return (root.targetedRegionX >= 0 && root.targetedRegionY >= 0) + } + function setRegionToTargeted() { + const padding = Config.options.regionSelector.targetRegions.selectionPadding; // Make borders not cut off n stuff + root.regionX = root.targetedRegionX - padding; + root.regionY = root.targetedRegionY - padding; + root.regionWidth = root.targetedRegionWidth + padding * 2; + root.regionHeight = root.targetedRegionHeight + padding * 2; + } + + function updateTargetedRegion(x, y) { + // Image regions + const clickedRegion = root.imageRegions.find(region => { + return region.at[0] <= x && x <= region.at[0] + region.size[0] && region.at[1] <= y && y <= region.at[1] + region.size[1]; + }); + if (clickedRegion) { + root.targetedRegionX = clickedRegion.at[0]; + root.targetedRegionY = clickedRegion.at[1]; + root.targetedRegionWidth = clickedRegion.size[0]; + root.targetedRegionHeight = clickedRegion.size[1]; + return; + } + + // Layer regions + const clickedLayer = root.layerRegions.find(region => { + return region.at[0] <= x && x <= region.at[0] + region.size[0] && region.at[1] <= y && y <= region.at[1] + region.size[1]; + }); + if (clickedLayer) { + root.targetedRegionX = clickedLayer.at[0]; + root.targetedRegionY = clickedLayer.at[1]; + root.targetedRegionWidth = clickedLayer.size[0]; + root.targetedRegionHeight = clickedLayer.size[1]; + return; + } + + // Window regions + const clickedWindow = root.windowRegions.find(region => { + return region.at[0] <= x && x <= region.at[0] + region.size[0] && region.at[1] <= y && y <= region.at[1] + region.size[1]; + }); + if (clickedWindow) { + root.targetedRegionX = clickedWindow.at[0]; + root.targetedRegionY = clickedWindow.at[1]; + root.targetedRegionWidth = clickedWindow.size[0]; + root.targetedRegionHeight = clickedWindow.size[1]; + return; + } + + root.targetedRegionX = -1; + root.targetedRegionY = -1; + root.targetedRegionWidth = 0; + root.targetedRegionHeight = 0; + } + + property real regionWidth: Math.abs(draggingX - dragStartX) + property real regionHeight: Math.abs(draggingY - dragStartY) + property real regionX: Math.min(dragStartX, draggingX) + property real regionY: Math.min(dragStartY, draggingY) + + Process { + id: screenshotProc + running: true + command: ["bash", "-c", `mkdir -p '${StringUtils.shellSingleQuoteEscape(root.screenshotDir)}' && grim -o '${StringUtils.shellSingleQuoteEscape(root.screen.name)}' '${StringUtils.shellSingleQuoteEscape(root.screenshotPath)}'`] + onExited: (exitCode, exitStatus) => { + if (root.enableContentRegions) imageDetectionProcess.running = true; + root.preparationDone = !checkRecordingProc.running; + } + } + property bool isRecording: root.action === RegionSelection.SnipAction.Record || root.action === RegionSelection.SnipAction.RecordWithSound + property bool recordingShouldStop: false + Process { + id: checkRecordingProc + running: isRecording + command: ["pidof", "wf-recorder"] + onExited: (exitCode, exitStatus) => { + root.preparationDone = !screenshotProc.running + root.recordingShouldStop = (exitCode === 0); + } + } + property bool preparationDone: false + onPreparationDoneChanged: { + if (!preparationDone) return; + if (root.isRecording && root.recordingShouldStop) { + Quickshell.execDetached([Directories.recordScriptPath]); + root.dismiss(); + return; + } + root.visible = true; + } + + Process { + id: imageDetectionProcess + command: ["bash", "-c", `${Directories.scriptPath}/images/find-regions-venv.sh ` + + `--hyprctl ` + + `--image '${StringUtils.shellSingleQuoteEscape(root.screenshotPath)}' ` + + `--max-width ${Math.round(root.screen.width * root.falsePositivePreventionRatio)} ` + + `--max-height ${Math.round(root.screen.height * root.falsePositivePreventionRatio)} `] + stdout: StdioCollector { + id: imageDimensionCollector + onStreamFinished: { + imageRegions = RegionFunctions.filterImageRegions( + JSON.parse(imageDimensionCollector.text), + root.windowRegions + ); + } + } + } + + function snip() { + // Validity check + if (root.regionWidth <= 0 || root.regionHeight <= 0) { + console.warn("[Region Selector] Invalid region size, skipping snip."); + root.dismiss(); + } + + // Clamp region to screen bounds + root.regionX = Math.max(0, Math.min(root.regionX, root.screen.width - root.regionWidth)); + root.regionY = Math.max(0, Math.min(root.regionY, root.screen.height - root.regionHeight)); + root.regionWidth = Math.max(0, Math.min(root.regionWidth, root.screen.width - root.regionX)); + root.regionHeight = Math.max(0, Math.min(root.regionHeight, root.screen.height - root.regionY)); + + // Adjust action + if (root.action === RegionSelection.SnipAction.Copy || root.action === RegionSelection.SnipAction.Edit) { + root.action = root.mouseButton === Qt.RightButton ? RegionSelection.SnipAction.Edit : RegionSelection.SnipAction.Copy; + } + + // Set command for action + const rx = Math.round(root.regionX * root.monitorScale); + const ry = Math.round(root.regionY * root.monitorScale); + const rw = Math.round(root.regionWidth * root.monitorScale); + const rh = Math.round(root.regionHeight * root.monitorScale); + const cropBase = `magick ${StringUtils.shellSingleQuoteEscape(root.screenshotPath)} ` + + `-crop ${rw}x${rh}+${rx}+${ry}` + const cropToStdout = `${cropBase} -` + const cropInPlace = `${cropBase} '${StringUtils.shellSingleQuoteEscape(root.screenshotPath)}'` + const cleanup = `rm '${StringUtils.shellSingleQuoteEscape(root.screenshotPath)}'` + const slurpRegion = `${rx},${ry} ${rw}x${rh}` + const uploadAndGetUrl = (filePath) => { + return `curl -sF files[]=@'${StringUtils.shellSingleQuoteEscape(filePath)}' ${root.fileUploadApiEndpoint} | jq -r '.files[0].url'` + } + switch (root.action) { + case RegionSelection.SnipAction.Copy: + if (saveScreenshotDir === "") { + // not saving the screenshot, just copy to clipboard + snipProc.command = ["bash", "-c", `${cropToStdout} | wl-copy && ${cleanup}`] + break; + } + + const savePathBase = root.saveScreenshotDir + + snipProc.command = [ + "bash", "-c", + `mkdir -p '${StringUtils.shellSingleQuoteEscape(savePathBase)}' && \ + saveFileName="screenshot-$(date '+%Y-%m-%d_%H.%M.%S').png" && \ + savePath="${savePathBase}/$saveFileName" && \ + ${cropToStdout} | tee >(wl-copy) > "$savePath" && \ + ${cleanup}` + ] + + break; + case RegionSelection.SnipAction.Edit: + snipProc.command = ["bash", "-c", `${cropToStdout} | swappy -f - && ${cleanup}`] + break; + case RegionSelection.SnipAction.Search: + snipProc.command = ["bash", "-c", `${cropInPlace} && xdg-open "${root.imageSearchEngineBaseUrl}$(${uploadAndGetUrl(root.screenshotPath)})" && ${cleanup}`] + break; + case RegionSelection.SnipAction.CharRecognition: + snipProc.command = ["bash", "-c", `${cropInPlace} && tesseract '${StringUtils.shellSingleQuoteEscape(root.screenshotPath)}' stdout -l $(tesseract --list-langs | awk 'NR>1{print $1}' | tr '\\n' '+' | sed 's/\\+$/\\n/') | wl-copy && ${cleanup}`] + break; + case RegionSelection.SnipAction.Record: + snipProc.command = ["bash", "-c", `${Directories.recordScriptPath} --region '${slurpRegion}'`] + break; + case RegionSelection.SnipAction.RecordWithSound: + snipProc.command = ["bash", "-c", `${Directories.recordScriptPath} --region '${slurpRegion}' --sound`] + break; + default: + console.warn("[Region Selector] Unknown snip action, skipping snip."); + root.dismiss(); + return; + } + + // Image post-processing + snipProc.startDetached(); + root.dismiss(); + } + + Process { + id: snipProc + } + + ScreencopyView { + anchors.fill: parent + live: false + captureSource: root.screen + + focus: root.visible + Keys.onPressed: (event) => { // Esc to close + if (event.key === Qt.Key_Escape) { + root.dismiss(); + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + cursorShape: Qt.CrossCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: true + + // Controls + onPressed: (mouse) => { + root.dragStartX = mouse.x; + root.dragStartY = mouse.y; + root.draggingX = mouse.x; + root.draggingY = mouse.y; + root.dragging = true; + root.mouseButton = mouse.button; + } + onReleased: (mouse) => { + // Detect if it was a click -> Try to select targeted region + if (root.draggingX === root.dragStartX && root.draggingY === root.dragStartY) { + if (root.targetedRegionValid()) { + root.setRegionToTargeted(); + } + } + // Circle dragging? + else if (root.selectionMode === RegionSelection.SelectionMode.Circle) { + const padding = Config.options.regionSelector.circle.padding + Config.options.regionSelector.circle.strokeWidth / 2; + const dragPoints = (root.points.length > 0) ? root.points : [{ x: mouseArea.mouseX, y: mouseArea.mouseY }]; + const maxX = Math.max(...dragPoints.map(p => p.x)); + const minX = Math.min(...dragPoints.map(p => p.x)); + const maxY = Math.max(...dragPoints.map(p => p.y)); + const minY = Math.min(...dragPoints.map(p => p.y)); + root.regionX = minX - padding; + root.regionY = minY - padding; + root.regionWidth = maxX - minX + padding * 2; + root.regionHeight = maxY - minY + padding * 2; + } + root.snip(); + } + onPositionChanged: (mouse) => { + root.updateTargetedRegion(mouse.x, mouse.y); + if (!root.dragging) return; + root.draggingX = mouse.x; + root.draggingY = mouse.y; + root.dragDiffX = mouse.x - root.dragStartX; + root.dragDiffY = mouse.y - root.dragStartY; + root.points.push({ x: mouse.x, y: mouse.y }); + } + + Loader { + z: 2 + anchors.fill: parent + active: root.selectionMode === RegionSelection.SelectionMode.RectCorners + sourceComponent: RectCornersSelectionDetails { + regionX: root.regionX + regionY: root.regionY + regionWidth: root.regionWidth + regionHeight: root.regionHeight + mouseX: mouseArea.mouseX + mouseY: mouseArea.mouseY + color: root.selectionBorderColor + overlayColor: root.overlayColor + } + } + + Loader { + z: 2 + anchors.fill: parent + active: root.selectionMode === RegionSelection.SelectionMode.Circle + sourceComponent: CircleSelectionDetails { + color: root.selectionBorderColor + overlayColor: root.overlayColor + points: root.points + } + } + + // Window regions + Repeater { + model: ScriptModel { + values: root.enableWindowRegions ? root.windowRegions : [] + } + delegate: TargetRegion { + z: 2 + required property var modelData + clientDimensions: modelData + showIcon: true + targeted: !root.draggedAway && + (root.targetedRegionX === modelData.at[0] + && root.targetedRegionY === modelData.at[1] + && root.targetedRegionWidth === modelData.size[0] + && root.targetedRegionHeight === modelData.size[1]) + + opacity: root.draggedAway ? 0 : root.targetRegionOpacity + borderColor: root.windowBorderColor + fillColor: targeted ? root.windowFillColor : "transparent" + text: `${modelData.class}` + radius: Appearance.rounding.windowRounding + } + } + + // Layer regions + Repeater { + model: ScriptModel { + values: root.enableLayerRegions ? root.layerRegions : [] + } + delegate: TargetRegion { + z: 3 + required property var modelData + clientDimensions: modelData + targeted: !root.draggedAway && + (root.targetedRegionX === modelData.at[0] + && root.targetedRegionY === modelData.at[1] + && root.targetedRegionWidth === modelData.size[0] + && root.targetedRegionHeight === modelData.size[1]) + + opacity: root.draggedAway ? 0 : root.targetRegionOpacity + borderColor: root.windowBorderColor + fillColor: targeted ? root.windowFillColor : "transparent" + text: `${modelData.namespace}` + radius: Appearance.rounding.windowRounding + } + } + + // Content regions + Repeater { + model: ScriptModel { + values: root.enableContentRegions ? root.imageRegions : [] + } + delegate: TargetRegion { + z: 4 + required property var modelData + clientDimensions: modelData + targeted: !root.draggedAway && + (root.targetedRegionX === modelData.at[0] + && root.targetedRegionY === modelData.at[1] + && root.targetedRegionWidth === modelData.size[0] + && root.targetedRegionHeight === modelData.size[1]) + + opacity: root.draggedAway ? 0 : root.contentRegionOpacity + borderColor: root.imageBorderColor + fillColor: targeted ? root.imageFillColor : "transparent" + text: Translation.tr("Content region") + } + } + + // Controls + Row { + id: regionSelectionControls + z: 9999 + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: -height + } + opacity: 0 + Connections { + target: root + function onVisibleChanged() { + if (!visible) return; + regionSelectionControls.anchors.bottomMargin = 8; + regionSelectionControls.opacity = 1; + } + } + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on anchors.bottomMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + spacing: 6 + + OptionsToolbar { + Synchronizer on action { + property alias source: root.action + } + Synchronizer on selectionMode { + property alias source: root.selectionMode + } + onDismiss: root.dismiss(); + } + Item { + anchors { + verticalCenter: parent.verticalCenter + } + implicitWidth: closeFab.implicitWidth + implicitHeight: closeFab.implicitHeight + StyledRectangularShadow { + target: closeFab + radius: closeFab.buttonRadius + } + FloatingActionButton { + id: closeFab + baseSize: 48 + iconText: "close" + onClicked: root.dismiss(); + StyledToolTip { + text: Translation.tr("Close") + } + colBackground: Appearance.colors.colTertiaryContainer + colBackgroundHover: Appearance.colors.colTertiaryContainerHover + colRipple: Appearance.colors.colTertiaryContainerActive + colOnBackground: Appearance.colors.colOnTertiaryContainer + } + } + } + + } + } +} diff --git a/modules/quickshell/config/modules/ii/regionSelector/RegionSelector.qml b/modules/quickshell/config/modules/ii/regionSelector/RegionSelector.qml new file mode 100644 index 0000000..4044036 --- /dev/null +++ b/modules/quickshell/config/modules/ii/regionSelector/RegionSelector.qml @@ -0,0 +1,122 @@ +pragma ComponentBehavior: Bound +import qs +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Widgets +import Quickshell.Hyprland + +Scope { + id: root + + function dismiss() { + GlobalStates.regionSelectorOpen = false + } + + property var action: RegionSelection.SnipAction.Copy + property var selectionMode: RegionSelection.SelectionMode.RectCorners + + Variants { + model: Quickshell.screens + delegate: Loader { + id: regionSelectorLoader + required property var modelData + active: GlobalStates.regionSelectorOpen + + sourceComponent: RegionSelection { + screen: regionSelectorLoader.modelData + onDismiss: root.dismiss() + action: root.action + selectionMode: root.selectionMode + } + } + } + + function screenshot() { + root.action = RegionSelection.SnipAction.Copy + root.selectionMode = RegionSelection.SelectionMode.RectCorners + GlobalStates.regionSelectorOpen = true + } + + function search() { + root.action = RegionSelection.SnipAction.Search + if (Config.options.search.imageSearch.useCircleSelection) { + root.selectionMode = RegionSelection.SelectionMode.Circle + } else { + root.selectionMode = RegionSelection.SelectionMode.RectCorners + } + GlobalStates.regionSelectorOpen = true + } + + function ocr() { + root.action = RegionSelection.SnipAction.CharRecognition + root.selectionMode = RegionSelection.SelectionMode.RectCorners + GlobalStates.regionSelectorOpen = true + } + + function record() { + root.action = RegionSelection.SnipAction.Record + root.selectionMode = RegionSelection.SelectionMode.RectCorners + GlobalStates.regionSelectorOpen = true + } + + function recordWithSound() { + root.action = RegionSelection.SnipAction.RecordWithSound + root.selectionMode = RegionSelection.SelectionMode.RectCorners + GlobalStates.regionSelectorOpen = true + } + + IpcHandler { + target: "region" + + function screenshot() { + root.screenshot() + } + function search() { + root.search() + } + function ocr() { + root.ocr() + } + function record() { + root.record() + } + function recordWithSound() { + root.recordWithSound() + } + } + + GlobalShortcut { + name: "regionScreenshot" + description: "Takes a screenshot of the selected region" + onPressed: root.screenshot() + } + GlobalShortcut { + name: "regionSearch" + description: "Searches the selected region" + onPressed: root.search() + } + GlobalShortcut { + name: "regionOcr" + description: "Recognizes text in the selected region" + onPressed: root.ocr() + } + GlobalShortcut { + name: "regionRecord" + description: "Records the selected region" + onPressed: root.record() + } + GlobalShortcut { + name: "regionRecordWithSound" + description: "Records the selected region with sound" + onPressed: root.recordWithSound() + } +} diff --git a/modules/quickshell/config/modules/ii/regionSelector/TargetRegion.qml b/modules/quickshell/config/modules/ii/regionSelector/TargetRegion.qml new file mode 100644 index 0000000..a1ecbcd --- /dev/null +++ b/modules/quickshell/config/modules/ii/regionSelector/TargetRegion.qml @@ -0,0 +1,79 @@ +pragma ComponentBehavior: Bound +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell +import Quickshell.Widgets + +Rectangle { + id: root + required property var clientDimensions + + property color colBackground: Qt.alpha("#88111111", 0.9) + property color colForeground: "#ddffffff" + property bool showLabel: Config.options.regionSelector.targetRegions.showLabel + property bool showIcon: false + property bool targeted: false + property color borderColor + property color fillColor: "transparent" + property string text: "" + property real textPadding: 10 + z: 2 + color: fillColor + border.color: borderColor + border.width: targeted ? 4 : 2 + radius: 4 + + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + x: clientDimensions.at[0] + y: clientDimensions.at[1] + width: clientDimensions.size[0] + height: clientDimensions.size[1] + + Loader { + anchors { + top: parent.top + left: parent.left + topMargin: root.textPadding + leftMargin: root.textPadding + } + + active: root.showLabel + sourceComponent: Rectangle { + property real verticalPadding: 5 + property real horizontalPadding: 10 + radius: 10 + color: root.colBackground + border.width: 1 + border.color: Appearance.m3colors.m3outlineVariant + implicitWidth: regionInfoRow.implicitWidth + horizontalPadding * 2 + implicitHeight: regionInfoRow.implicitHeight + verticalPadding * 2 + + Row { + id: regionInfoRow + anchors.centerIn: parent + spacing: 4 + + Loader { + id: regionIconLoader + active: root.showIcon + visible: active + sourceComponent: IconImage { + implicitSize: Appearance.font.pixelSize.larger + source: Quickshell.iconPath(AppSearch.guessIcon(root.text), "image-missing") + } + } + + StyledText { + id: regionText + text: root.text + color: root.colForeground + } + } + } + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/ii/screenCorners/ScreenCorners.qml b/modules/quickshell/config/modules/ii/screenCorners/ScreenCorners.qml new file mode 100644 index 0000000..cbb8087 --- /dev/null +++ b/modules/quickshell/config/modules/ii/screenCorners/ScreenCorners.qml @@ -0,0 +1,175 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: screenCorners + readonly property Toplevel activeWindow: ToplevelManager.activeToplevel + property var actionForCorner: ({ + [RoundCorner.CornerEnum.TopLeft]: () => GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen, + [RoundCorner.CornerEnum.BottomLeft]: () => GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen, + [RoundCorner.CornerEnum.TopRight]: () => GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen, + [RoundCorner.CornerEnum.BottomRight]: () => GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen + }) + + component CornerPanelWindow: PanelWindow { + id: cornerPanelWindow + property var screen: QsWindow.window?.screen + property var brightnessMonitor: Brightness.getMonitorForScreen(screen) + property bool fullscreen + visible: (Config.options.appearance.fakeScreenRounding === 1 || (Config.options.appearance.fakeScreenRounding === 2 && !fullscreen)) + property var corner + + exclusionMode: ExclusionMode.Ignore + mask: Region { + item: sidebarCornerOpenInteractionLoader.active ? sidebarCornerOpenInteractionLoader : null + } + WlrLayershell.namespace: "quickshell:screenCorners" + WlrLayershell.layer: WlrLayer.Overlay + color: "transparent" + + anchors { + top: cornerWidget.isTopLeft || cornerWidget.isTopRight + left: cornerWidget.isBottomLeft || cornerWidget.isTopLeft + bottom: cornerWidget.isBottomLeft || cornerWidget.isBottomRight + right: cornerWidget.isTopRight || cornerWidget.isBottomRight + } + margins { + right: (Config.options.interactions.deadPixelWorkaround.enable && cornerPanelWindow.anchors.right) * -1 + bottom: (Config.options.interactions.deadPixelWorkaround.enable && cornerPanelWindow.anchors.bottom) * -1 + } + + implicitWidth: cornerWidget.implicitWidth + implicitHeight: cornerWidget.implicitHeight + + RoundCorner { + id: cornerWidget + anchors.fill: parent + corner: cornerPanelWindow.corner + rightVisualMargin: (Config.options.interactions.deadPixelWorkaround.enable && cornerPanelWindow.anchors.right) * 1 + bottomVisualMargin: (Config.options.interactions.deadPixelWorkaround.enable && cornerPanelWindow.anchors.bottom) * 1 + + implicitSize: Appearance.rounding.screenRounding + implicitHeight: Math.max(implicitSize, sidebarCornerOpenInteractionLoader.implicitHeight) + implicitWidth: Math.max(implicitSize, sidebarCornerOpenInteractionLoader.implicitWidth) + + Loader { + id: sidebarCornerOpenInteractionLoader + active: { + if (!Config.options.sidebar.cornerOpen.enable) return false; + if (cornerPanelWindow.fullscreen) return false; + return (Config.options.sidebar.cornerOpen.bottom == cornerWidget.isBottom); + } + anchors { + top: (cornerWidget.isTopLeft || cornerWidget.isTopRight) ? parent.top : undefined + bottom: (cornerWidget.isBottomLeft || cornerWidget.isBottomRight) ? parent.bottom : undefined + left: (cornerWidget.isLeft) ? parent.left : undefined + right: (cornerWidget.isTopRight || cornerWidget.isBottomRight) ? parent.right : undefined + } + + sourceComponent: FocusedScrollMouseArea { + id: mouseArea + implicitWidth: Config.options.sidebar.cornerOpen.cornerRegionWidth + implicitHeight: Config.options.sidebar.cornerOpen.cornerRegionHeight + hoverEnabled: true + onPositionChanged: { + if (!Config.options.sidebar.cornerOpen.clicklessCornerEnd) return; + const verticalOffset = Config.options.sidebar.cornerOpen.clicklessCornerVerticalOffset; + const correctX = (cornerWidget.isRight && mouseArea.mouseX >= mouseArea.width - 2) || (cornerWidget.isLeft && mouseArea.mouseX <= 2); + const correctY = (cornerWidget.isTop && mouseArea.mouseY > verticalOffset || cornerWidget.isBottom && mouseArea.mouseY < mouseArea.height - verticalOffset); + if (correctX && correctY) + screenCorners.actionForCorner[cornerPanelWindow.corner](); + } + onEntered: { + if (Config.options.sidebar.cornerOpen.clickless) + screenCorners.actionForCorner[cornerPanelWindow.corner](); + } + onPressed: { + screenCorners.actionForCorner[cornerPanelWindow.corner](); + } + onScrollDown: { + if (!Config.options.sidebar.cornerOpen.valueScroll) + return; + if (cornerWidget.isLeft) + cornerPanelWindow.brightnessMonitor.setBrightness(cornerPanelWindow.brightnessMonitor.brightness - 0.05); + else { + const currentVolume = Audio.value; + const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2; + Audio.sink.audio.volume -= step; + } + } + onScrollUp: { + if (!Config.options.sidebar.cornerOpen.valueScroll) + return; + if (cornerWidget.isLeft) + cornerPanelWindow.brightnessMonitor.setBrightness(cornerPanelWindow.brightnessMonitor.brightness + 0.05); + else { + const currentVolume = Audio.value; + const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2; + Audio.sink.audio.volume = Math.min(1, Audio.sink.audio.volume + step); + } + } + onMovedAway: { + if (!Config.options.sidebar.cornerOpen.valueScroll) + return; + if (cornerWidget.isLeft) + GlobalStates.osdBrightnessOpen = false; + else + GlobalStates.osdVolumeOpen = false; + } + + Loader { + active: Config.options.sidebar.cornerOpen.visualize + anchors.fill: parent + sourceComponent: Rectangle { + color: Appearance.colors.colPrimary + } + } + } + } + } + } + + Variants { + model: Quickshell.screens + + Scope { + id: monitorScope + required property var modelData + property HyprlandMonitor monitor: Hyprland.monitorFor(modelData) + + // Hide when fullscreen + property list workspacesForMonitor: Hyprland.workspaces.values.filter(workspace => workspace.monitor && workspace.monitor.name == monitor.name) + property var activeWorkspaceWithFullscreen: workspacesForMonitor.filter(workspace => ((workspace.toplevels.values.filter(window => window.wayland?.fullscreen)[0] != undefined) && workspace.active))[0] + property bool fullscreen: activeWorkspaceWithFullscreen != undefined + + CornerPanelWindow { + screen: modelData + corner: RoundCorner.CornerEnum.TopLeft + fullscreen: monitorScope.fullscreen + } + CornerPanelWindow { + screen: modelData + corner: RoundCorner.CornerEnum.TopRight + fullscreen: monitorScope.fullscreen + } + CornerPanelWindow { + screen: modelData + corner: RoundCorner.CornerEnum.BottomLeft + fullscreen: monitorScope.fullscreen + } + CornerPanelWindow { + screen: modelData + corner: RoundCorner.CornerEnum.BottomRight + fullscreen: monitorScope.fullscreen + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/sessionScreen/SessionActionButton.qml b/modules/quickshell/config/modules/ii/sessionScreen/SessionActionButton.qml new file mode 100644 index 0000000..000c4e2 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sessionScreen/SessionActionButton.qml @@ -0,0 +1,58 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +RippleButton { + id: button + + property string buttonIcon + property string buttonText + property bool keyboardDown: false + property real size: 120 + + buttonRadius: (button.focus || button.down) ? size / 2 : Appearance.rounding.verylarge + colBackground: button.keyboardDown ? Appearance.colors.colSecondaryContainerActive : + button.focus ? Appearance.colors.colPrimary : + Appearance.colors.colSecondaryContainer + colBackgroundHover: Appearance.colors.colPrimary + colRipple: Appearance.colors.colPrimaryActive + property color colText: (button.down || button.keyboardDown || button.focus || button.hovered) ? + Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer0 + + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + background.implicitHeight: size + background.implicitWidth: size + + Behavior on buttonRadius { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + keyboardDown = true + button.clicked() + event.accepted = true; + } + } + Keys.onReleased: (event) => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + keyboardDown = false + event.accepted = true; + } + } + + contentItem: MaterialSymbol { + id: icon + anchors.fill: parent + color: button.colText + horizontalAlignment: Text.AlignHCenter + iconSize: 45 + text: buttonIcon + } + + StyledToolTip { + text: buttonText + } + +} diff --git a/modules/quickshell/config/modules/ii/sessionScreen/SessionScreen.qml b/modules/quickshell/config/modules/ii/sessionScreen/SessionScreen.qml new file mode 100644 index 0000000..8899c3e --- /dev/null +++ b/modules/quickshell/config/modules/ii/sessionScreen/SessionScreen.qml @@ -0,0 +1,314 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + property var focusedScreen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) + property bool packageManagerRunning: false + property bool downloadRunning: false + + component DescriptionLabel: Rectangle { + id: descriptionLabel + property string text + property color textColor: Appearance.colors.colOnTooltip + color: Appearance.colors.colTooltip + clip: true + radius: Appearance.rounding.normal + implicitHeight: descriptionLabelText.implicitHeight + 10 * 2 + implicitWidth: descriptionLabelText.implicitWidth + 15 * 2 + + Behavior on implicitWidth { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + StyledText { + id: descriptionLabelText + anchors.centerIn: parent + color: descriptionLabel.textColor + text: descriptionLabel.text + } + } + + function detectRunningStuff() { + packageManagerRunning = false; + downloadRunning = false; + detectPackageManagerProc.running = false; + detectPackageManagerProc.running = true; + detectDownloadProc.running = false; + detectDownloadProc.running = true; + } + + Process { + id: detectPackageManagerProc + command: ["bash", "-c", "pidof pacman yay paru dnf zypper apt apx xbps flatpak snap apk yum epsi pikman"] + onExited: (exitCode, exitStatus) => { + root.packageManagerRunning = (exitCode === 0); + } + } + + Process { + id: detectDownloadProc + command: ["bash", "-c", "pidof curl wget aria2c yt-dlp || ls ~/Downloads | grep -E '\.crdownload$|\.part$'"] + onExited: (exitCode, exitStatus) => { + root.downloadRunning = (exitCode === 0); + } + } + + Loader { + id: sessionLoader + active: GlobalStates.sessionOpen + onActiveChanged: { + if (sessionLoader.active) root.detectRunningStuff(); + } + + Connections { + target: GlobalStates + function onScreenLockedChanged() { + if (GlobalStates.screenLocked) { + GlobalStates.sessionOpen = false; + } + } + } + + sourceComponent: PanelWindow { // Session menu + id: sessionRoot + visible: sessionLoader.active + property string subtitle + + function hide() { + GlobalStates.sessionOpen = false; + } + + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "quickshell:session" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + // This is a big surface so we needa carefully choose the transparency, + // or we'll get a large scary rgb blob + color: ColorUtils.transparentize(Appearance.m3colors.m3background, Appearance.m3colors.darkmode ? 0.04 : 0.12) + + anchors { + top: true + left: true + right: true + } + + implicitWidth: root.focusedScreen?.width ?? 0 + implicitHeight: root.focusedScreen?.height ?? 0 + + MouseArea { + id: sessionMouseArea + anchors.fill: parent + onClicked: { + sessionRoot.hide() + } + } + + ColumnLayout { // Content column + id: contentColumn + anchors.centerIn: parent + spacing: 15 + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Escape) { + sessionRoot.hide(); + } + } + + ColumnLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 0 + StyledText { // Title + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + font { + family: Appearance.font.family.title + pixelSize: Appearance.font.pixelSize.title + variableAxes: Appearance.font.variableAxes.title + } + text: Translation.tr("Session") + } + + StyledText { // Small instruction + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + text: Translation.tr("Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel") + } + } + + GridLayout { + columns: 4 + columnSpacing: 15 + rowSpacing: 15 + + SessionActionButton { + id: sessionLock + focus: sessionRoot.visible + buttonIcon: "lock" + buttonText: Translation.tr("Lock") + onClicked: { Session.lock(); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.right: sessionSleep + KeyNavigation.down: sessionHibernate + } + SessionActionButton { + id: sessionSleep + buttonIcon: "dark_mode" + buttonText: Translation.tr("Sleep") + onClicked: { Session.suspend(); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionLock + KeyNavigation.right: sessionLogout + KeyNavigation.down: sessionShutdown + } + SessionActionButton { + id: sessionLogout + buttonIcon: "logout" + buttonText: Translation.tr("Logout") + onClicked: { Session.logout(); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionSleep + KeyNavigation.right: sessionTaskManager + KeyNavigation.down: sessionReboot + } + SessionActionButton { + id: sessionTaskManager + buttonIcon: "browse_activity" + buttonText: Translation.tr("Task Manager") + onClicked: { Session.launchTaskManager(); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionLogout + KeyNavigation.down: sessionFirmwareReboot + } + + SessionActionButton { + id: sessionHibernate + buttonIcon: "downloading" + buttonText: Translation.tr("Hibernate") + onClicked: { Session.hibernate(); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.up: sessionLock + KeyNavigation.right: sessionShutdown + } + SessionActionButton { + id: sessionShutdown + buttonIcon: "power_settings_new" + buttonText: Translation.tr("Shutdown") + onClicked: { Session.poweroff(); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionHibernate + KeyNavigation.right: sessionReboot + KeyNavigation.up: sessionSleep + } + SessionActionButton { + id: sessionReboot + buttonIcon: "restart_alt" + buttonText: Translation.tr("Reboot") + onClicked: { Session.reboot(); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionShutdown + KeyNavigation.right: sessionFirmwareReboot + KeyNavigation.up: sessionLogout + } + SessionActionButton { + id: sessionFirmwareReboot + buttonIcon: "settings_applications" + buttonText: Translation.tr("Reboot to firmware settings") + onClicked: { Session.rebootToFirmware(); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.up: sessionTaskManager + KeyNavigation.left: sessionReboot + } + } + + DescriptionLabel { + Layout.alignment: Qt.AlignHCenter + text: sessionRoot.subtitle + } + } + + RowLayout { + anchors { + top: contentColumn.bottom + topMargin: 10 + horizontalCenter: contentColumn.horizontalCenter + } + spacing: 10 + + Loader { + active: root.packageManagerRunning + visible: active + sourceComponent: DescriptionLabel { + text: Translation.tr("Your package manager is running") + textColor: Appearance.m3colors.m3onErrorContainer + color: Appearance.m3colors.m3errorContainer + } + } + Loader { + active: root.downloadRunning + visible: active + sourceComponent: DescriptionLabel { + text: Translation.tr("There might be a download in progress") + textColor: Appearance.m3colors.m3onErrorContainer + color: Appearance.m3colors.m3errorContainer + } + } + } + } + } + + IpcHandler { + target: "session" + + function toggle(): void { + GlobalStates.sessionOpen = !GlobalStates.sessionOpen; + } + + function close(): void { + GlobalStates.sessionOpen = false + } + + function open(): void { + GlobalStates.sessionOpen = true + } + } + + GlobalShortcut { + name: "sessionToggle" + description: "Toggles session screen on press" + + onPressed: { + GlobalStates.sessionOpen = !GlobalStates.sessionOpen; + } + } + + GlobalShortcut { + name: "sessionOpen" + description: "Opens session screen on press" + + onPressed: { + GlobalStates.sessionOpen = true + } + } + + GlobalShortcut { + name: "sessionClose" + description: "Closes session screen on press" + + onPressed: { + GlobalStates.sessionOpen = false + } + } + +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/BottomWidgetGroup.qml b/modules/quickshell/config/modules/ii/sidebarRight/BottomWidgetGroup.qml new file mode 100644 index 0000000..4c6ea6d --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/BottomWidgetGroup.qml @@ -0,0 +1,279 @@ +pragma ComponentBehavior: Bound +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs.modules.ii.sidebarRight.calendar +import qs.modules.ii.sidebarRight.todo +import qs.modules.ii.sidebarRight.pomodoro +import QtQuick +import QtQuick.Layouts + +Rectangle { + id: root + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + clip: true + implicitHeight: collapsed ? collapsedBottomWidgetGroupRow.implicitHeight : 350 + property int selectedTab: Persistent.states.sidebar.bottomGroup.tab + property int previousIndex: -1 + property bool collapsed: Persistent.states.sidebar.bottomGroup.collapsed + property var tabs: [ + { + "type": "calendar", + "name": Translation.tr("Calendar"), + "icon": "calendar_month", + "widget": "calendar/CalendarWidget.qml" + }, + { + "type": "todo", + "name": Translation.tr("To Do"), + "icon": "done_outline", + "widget": "todo/TodoWidget.qml" + }, + { + "type": "timer", + "name": Translation.tr("Timer"), + "icon": "schedule", + "widget": "pomodoro/PomodoroWidget.qml" + }, + ] + + Behavior on implicitHeight { + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + function setCollapsed(state) { + Persistent.states.sidebar.bottomGroup.collapsed = state; + if (collapsed) { + bottomWidgetGroupRow.opacity = 0; + } else { + collapsedBottomWidgetGroupRow.opacity = 0; + } + collapseCleanFadeTimer.start(); + } + + Timer { + id: collapseCleanFadeTimer + interval: Appearance.animation.elementMove.duration / 2 + repeat: false + onTriggered: { + if (collapsed) + collapsedBottomWidgetGroupRow.opacity = 1; + else + bottomWidgetGroupRow.opacity = 1; + } + } + + Keys.onPressed: event => { + if ((event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) && event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_PageDown) { + root.selectedTab = Math.min(root.selectedTab + 1, root.tabs.length - 1); + } else if (event.key === Qt.Key_PageUp) { + root.selectedTab = Math.max(root.selectedTab - 1, 0); + } + event.accepted = true; + } + } + + // The thing when collapsed + RowLayout { + id: collapsedBottomWidgetGroupRow + opacity: collapsed ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + NumberAnimation { + id: collapsedBottomWidgetGroupRowFade + duration: Appearance.animation.elementMove.duration / 2 + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + spacing: 15 + + CalendarHeaderButton { + Layout.margins: 10 + Layout.rightMargin: 0 + forceCircle: true + downAction: () => { + root.setCollapsed(false); + } + contentItem: MaterialSymbol { + text: "keyboard_arrow_up" + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: Appearance.colors.colOnLayer1 + } + } + + StyledText { + property int remainingTasks: Todo.list.filter(task => !task.done).length + Layout.margins: 10 + Layout.leftMargin: 0 + // text: `${DateTime.collapsedCalendarFormat} โ€ข ${remainingTasks} task${remainingTasks > 1 ? "s" : ""}` + text: Translation.tr("%1 โ€ข %2 tasks").arg(DateTime.collapsedCalendarFormat).arg(remainingTasks) + font.pixelSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer1 + } + } + + // The thing when expanded + RowLayout { + id: bottomWidgetGroupRow + + opacity: collapsed ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { + NumberAnimation { + id: bottomWidgetGroupRowFade + duration: Appearance.animation.elementMove.duration / 2 + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + anchors.fill: parent + // implicitHeight: tabStack.implicitHeight + spacing: 10 + + // Navigation rail + Item { + Layout.fillHeight: true + Layout.fillWidth: false + Layout.leftMargin: 10 + Layout.topMargin: 10 + width: tabBar.width + // Navigation rail buttons + NavigationRailTabArray { + id: tabBar + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 5 + currentIndex: root.selectedTab + expanded: false + Repeater { + model: root.tabs + NavigationRailButton { + required property int index + required property var modelData + showToggledHighlight: false + toggled: root.selectedTab == index + buttonText: modelData.name + buttonIcon: modelData.icon + onPressed: { + root.selectedTab = index; + Persistent.states.sidebar.bottomGroup.tab = index; + } + } + } + } + // Collapse button + CalendarHeaderButton { + anchors.left: parent.left + anchors.top: parent.top + forceCircle: true + downAction: () => { + root.setCollapsed(true); + } + contentItem: MaterialSymbol { + text: "keyboard_arrow_down" + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: Appearance.colors.colOnLayer1 + } + } + } + + // Content area + Item { + Layout.fillWidth: true + Layout.fillHeight: true + // implicitHeight: tabStack.implicitHeight + + Loader { + id: tabStack + anchors.fill: parent + anchors.bottomMargin: -anchors.topMargin + + Component.onCompleted: { + tabStack.source = root.tabs[root.selectedTab].widget; + } + + Connections { + target: root + function onSelectedTabChanged() { + if (root.currentTab > root.previousIndex) + tabSwitchBehavior.animation.down = true; + else if (root.currentTab < root.previousIndex) + tabSwitchBehavior.animation.down = false; + tabStack.source = root.tabs[root.selectedTab].widget; + } + } + + Behavior on source { + id: tabSwitchBehavior + animation: TabSwitchAnim { + id: upAnim + down: true + } + } + } + } + } + + component TabSwitchAnim: SequentialAnimation { + id: switchAnim + property bool down: false + ParallelAnimation { + PropertyAnimation { + target: tabStack + properties: "opacity" + to: 0 + duration: Appearance.animation.elementMoveFast.duration + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + PropertyAnimation { + target: tabStack.anchors + properties: "topMargin" + to: 10 * (switchAnim.down ? -1 : 1) + duration: Appearance.animation.elementMoveFast.duration + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + PropertyAction { + target: tabStack + property: "source" + value: root.tabs[root.selectedTab].widget + } // The source change happens here + ParallelAnimation { + PropertyAnimation { + target: tabStack.anchors + properties: "topMargin" + from: 10 * -(switchAnim.down ? -1 : 1) + to: 0 + duration: Appearance.animation.elementMoveFast.duration + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.elementMoveEnter.bezierCurve + } + PropertyAnimation { + target: tabStack + properties: "opacity" + to: 1 + duration: Appearance.animation.elementMoveFast.duration + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.elementMoveEnter.bezierCurve + } + } + ScriptAction { + script: { + root.previousIndex = root.selectedTab; + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/CenterWidgetGroup.qml b/modules/quickshell/config/modules/ii/sidebarRight/CenterWidgetGroup.qml new file mode 100644 index 0000000..4e7747e --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/CenterWidgetGroup.qml @@ -0,0 +1,19 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs.modules.ii.sidebarRight.notifications +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: root + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + + NotificationList { + anchors.fill: parent + anchors.margins: 5 + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/QuickSliders.qml b/modules/quickshell/config/modules/ii/sidebarRight/QuickSliders.qml new file mode 100644 index 0000000..2692500 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/QuickSliders.qml @@ -0,0 +1,111 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Services.UPower + +Rectangle { + id: root + + property var screen: root.QsWindow.window?.screen + property var brightnessMonitor: Brightness.getMonitorForScreen(screen) + + implicitWidth: contentItem.implicitWidth + root.horizontalPadding * 2 + implicitHeight: contentItem.implicitHeight + root.verticalPadding * 2 + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + property real verticalPadding: 4 + property real horizontalPadding: 12 + + Column { + id: contentItem + anchors { + fill: parent + leftMargin: root.horizontalPadding + rightMargin: root.horizontalPadding + topMargin: root.verticalPadding + bottomMargin: root.verticalPadding + } + + Loader { + anchors { + left: parent.left + right: parent.right + } + visible: active + active: Config.options.sidebar.quickSliders.showBrightness + sourceComponent: QuickSlider { + materialSymbol: "brightness_6" + value: root.brightnessMonitor.brightness + onMoved: { + root.brightnessMonitor.setBrightness(value) + } + } + } + + Loader { + anchors { + left: parent.left + right: parent.right + } + visible: active + active: Config.options.sidebar.quickSliders.showVolume + sourceComponent: QuickSlider { + materialSymbol: "volume_up" + value: Audio.sink.audio.volume + onMoved: { + Audio.sink.audio.volume = value + } + } + } + + Loader { + anchors { + left: parent.left + right: parent.right + } + visible: active + active: Config.options.sidebar.quickSliders.showMic + sourceComponent: QuickSlider { + materialSymbol: "mic" + value: Audio.source.audio.volume + onMoved: { + Audio.source.audio.volume = value + } + } + } + } + + component QuickSlider: StyledSlider { + id: quickSlider + required property string materialSymbol + configuration: StyledSlider.Configuration.M + stopIndicatorValues: [] + + MaterialSymbol { + id: icon + property bool nearFull: quickSlider.value >= 0.9 + anchors { + verticalCenter: parent.verticalCenter + right: nearFull ? quickSlider.handle.right : parent.right + rightMargin: quickSlider.nearFull ? 14 : 8 + } + iconSize: 20 + color: nearFull ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer + text: quickSlider.materialSymbol + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + Behavior on anchors.rightMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + } + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/SidebarRight.qml b/modules/quickshell/config/modules/ii/sidebarRight/SidebarRight.qml new file mode 100644 index 0000000..fcba767 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/SidebarRight.qml @@ -0,0 +1,109 @@ +import qs +import qs.services +import qs.modules.common +import QtQuick +import Quickshell.Io +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + property int sidebarWidth: Appearance.sizes.sidebarWidth + + PanelWindow { + id: sidebarRoot + visible: GlobalStates.sidebarRightOpen + + function hide() { + GlobalStates.sidebarRightOpen = false + } + + exclusiveZone: 0 + implicitWidth: sidebarWidth + WlrLayershell.namespace: "quickshell:sidebarRight" + // Hyprland 0.49: Focus is always exclusive and setting this breaks mouse focus grab + // WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + color: "transparent" + + anchors { + top: true + right: true + bottom: true + } + + HyprlandFocusGrab { + id: grab + windows: [ sidebarRoot ] + active: GlobalStates.sidebarRightOpen + onCleared: () => { + if (!active) sidebarRoot.hide() + } + } + + Loader { + id: sidebarContentLoader + active: GlobalStates.sidebarRightOpen || Config?.options.sidebar.keepRightSidebarLoaded + anchors { + fill: parent + margins: Appearance.sizes.hyprlandGapsOut + leftMargin: Appearance.sizes.elevationMargin + } + width: sidebarWidth - Appearance.sizes.hyprlandGapsOut - Appearance.sizes.elevationMargin + height: parent.height - Appearance.sizes.hyprlandGapsOut * 2 + + focus: GlobalStates.sidebarRightOpen + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Escape) { + sidebarRoot.hide(); + } + } + + sourceComponent: SidebarRightContent {} + } + + + } + + IpcHandler { + target: "sidebarRight" + + function toggle(): void { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + } + + function close(): void { + GlobalStates.sidebarRightOpen = false; + } + + function open(): void { + GlobalStates.sidebarRightOpen = true; + } + } + + GlobalShortcut { + name: "sidebarRightToggle" + description: "Toggles right sidebar on press" + + onPressed: { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + } + } + GlobalShortcut { + name: "sidebarRightOpen" + description: "Opens right sidebar on press" + + onPressed: { + GlobalStates.sidebarRightOpen = true; + } + } + GlobalShortcut { + name: "sidebarRightClose" + description: "Closes right sidebar on press" + + onPressed: { + GlobalStates.sidebarRightOpen = false; + } + } + +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/SidebarRightContent.qml b/modules/quickshell/config/modules/ii/sidebarRight/SidebarRightContent.qml new file mode 100644 index 0000000..0f733ed --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/SidebarRightContent.qml @@ -0,0 +1,301 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Hyprland + +import qs.modules.ii.sidebarRight.quickToggles +import qs.modules.ii.sidebarRight.quickToggles.classicStyle + +import qs.modules.ii.sidebarRight.bluetoothDevices +import qs.modules.ii.sidebarRight.nightLight +import qs.modules.ii.sidebarRight.volumeMixer +import qs.modules.ii.sidebarRight.wifiNetworks + +Item { + id: root + property int sidebarWidth: Appearance.sizes.sidebarWidth + property int sidebarPadding: 10 + property string settingsQmlPath: Quickshell.shellPath("settings.qml") + property bool showAudioOutputDialog: false + property bool showAudioInputDialog: false + property bool showBluetoothDialog: false + property bool showNightLightDialog: false + property bool showWifiDialog: false + property bool editMode: false + + Connections { + target: GlobalStates + function onSidebarRightOpenChanged() { + if (!GlobalStates.sidebarRightOpen) { + root.showWifiDialog = false; + root.showBluetoothDialog = false; + root.showAudioOutputDialog = false; + root.showAudioInputDialog = false; + } + } + } + + implicitHeight: sidebarRightBackground.implicitHeight + implicitWidth: sidebarRightBackground.implicitWidth + + StyledRectangularShadow { + target: sidebarRightBackground + } + Rectangle { + id: sidebarRightBackground + + anchors.fill: parent + implicitHeight: parent.height - Appearance.sizes.hyprlandGapsOut * 2 + implicitWidth: sidebarWidth - Appearance.sizes.hyprlandGapsOut * 2 + color: Appearance.colors.colLayer0 + border.width: 1 + border.color: Appearance.colors.colLayer0Border + radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1 + + ColumnLayout { + anchors.fill: parent + anchors.margins: sidebarPadding + spacing: sidebarPadding + + SystemButtonRow { + Layout.fillHeight: false + Layout.fillWidth: true + // Layout.margins: 10 + Layout.topMargin: 5 + Layout.bottomMargin: 0 + } + + Loader { + id: slidersLoader + Layout.fillWidth: true + visible: active + active: { + const configQuickSliders = Config.options.sidebar.quickSliders + if (!configQuickSliders.enable) return false + if (!configQuickSliders.showMic && !configQuickSliders.showVolume && !configQuickSliders.showBrightness) return false; + return true; + } + sourceComponent: QuickSliders {} + } + + LoaderedQuickPanelImplementation { + styleName: "classic" + sourceComponent: ClassicQuickPanel {} + } + + LoaderedQuickPanelImplementation { + styleName: "android" + sourceComponent: AndroidQuickPanel { + editMode: root.editMode + } + } + + CenterWidgetGroup { + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: true + Layout.fillWidth: true + } + + BottomWidgetGroup { + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: false + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + } + } + } + + ToggleDialog { + shownPropertyString: "showAudioOutputDialog" + dialog: VolumeDialog { + isSink: true + } + } + + ToggleDialog { + shownPropertyString: "showAudioInputDialog" + dialog: VolumeDialog { + isSink: false + } + } + + ToggleDialog { + shownPropertyString: "showBluetoothDialog" + dialog: BluetoothDialog {} + onShownChanged: { + if (!shown) { + Bluetooth.defaultAdapter.discovering = false; + } else { + Bluetooth.defaultAdapter.enabled = true; + Bluetooth.defaultAdapter.discovering = true; + } + } + } + + ToggleDialog { + shownPropertyString: "showNightLightDialog" + dialog: NightLightDialog {} + } + + ToggleDialog { + shownPropertyString: "showWifiDialog" + dialog: WifiDialog {} + onShownChanged: { + if (!shown) return; + Network.enableWifi(); + Network.rescanWifi(); + } + } + + component ToggleDialog: Loader { + id: toggleDialogLoader + required property string shownPropertyString + property alias dialog: toggleDialogLoader.sourceComponent + readonly property bool shown: root[shownPropertyString] + anchors.fill: parent + + onShownChanged: if (shown) toggleDialogLoader.active = true; + active: shown + onActiveChanged: { + if (active) { + item.show = true; + item.forceActiveFocus(); + } + } + Connections { + target: toggleDialogLoader.item + function onDismiss() { + toggleDialogLoader.item.show = false + root[toggleDialogLoader.shownPropertyString] = false; + } + function onVisibleChanged() { + if (!toggleDialogLoader.item.visible && !root[toggleDialogLoader.shownPropertyString]) toggleDialogLoader.active = false; + } + } + } + + component LoaderedQuickPanelImplementation: Loader { + id: quickPanelImplLoader + required property string styleName + Layout.alignment: item?.Layout.alignment ?? Qt.AlignHCenter + Layout.fillWidth: item?.Layout.fillWidth ?? false + visible: active + active: Config.options.sidebar.quickToggles.style === styleName + Connections { + target: quickPanelImplLoader.item + function onOpenAudioOutputDialog() { + root.showAudioOutputDialog = true; + } + function onOpenAudioInputDialog() { + root.showAudioInputDialog = true; + } + function onOpenBluetoothDialog() { + root.showBluetoothDialog = true; + } + function onOpenNightLightDialog() { + root.showNightLightDialog = true; + } + function onOpenWifiDialog() { + root.showWifiDialog = true; + } + } + } + + component SystemButtonRow: Item { + implicitHeight: Math.max(uptimeContainer.implicitHeight, systemButtonsRow.implicitHeight) + + Rectangle { + id: uptimeContainer + anchors { + top: parent.top + bottom: parent.bottom + left: parent.left + } + color: Appearance.colors.colLayer1 + radius: height / 2 + implicitWidth: uptimeRow.implicitWidth + 24 + implicitHeight: uptimeRow.implicitHeight + 8 + + Row { + id: uptimeRow + anchors.centerIn: parent + spacing: 8 + CustomIcon { + id: distroIcon + anchors.verticalCenter: parent.verticalCenter + width: 25 + height: 25 + source: SystemInfo.distroIcon + colorize: true + color: Appearance.colors.colOnLayer0 + } + StyledText { + anchors.verticalCenter: parent.verticalCenter + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colOnLayer0 + text: Translation.tr("Up %1").arg(DateTime.uptime) + textFormat: Text.MarkdownText + } + } + } + + ButtonGroup { + id: systemButtonsRow + anchors { + top: parent.top + bottom: parent.bottom + right: parent.right + } + color: Appearance.colors.colLayer1 + padding: 4 + + QuickToggleButton { + toggled: root.editMode + visible: Config.options.sidebar.quickToggles.style === "android" + buttonIcon: "edit" + onClicked: root.editMode = !root.editMode + StyledToolTip { + text: Translation.tr("Edit quick toggles") + (root.editMode ? Translation.tr("\nLMB to enable/disable\nRMB to toggle size\nScroll to swap position") : "") + } + } + QuickToggleButton { + toggled: false + buttonIcon: "restart_alt" + onClicked: { + Hyprland.dispatch("reload"); + Quickshell.reload(true); + } + StyledToolTip { + text: Translation.tr("Reload Hyprland & Quickshell") + } + } + QuickToggleButton { + toggled: false + buttonIcon: "settings" + onClicked: { + GlobalStates.sidebarRightOpen = false; + Quickshell.execDetached(["qs", "-p", root.settingsQmlPath]); + } + StyledToolTip { + text: Translation.tr("Settings") + } + } + QuickToggleButton { + toggled: false + buttonIcon: "power_settings_new" + onClicked: { + GlobalStates.sessionOpen = true; + } + StyledToolTip { + text: Translation.tr("Session") + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/bluetoothDevices/BluetoothDeviceItem.qml b/modules/quickshell/config/modules/ii/sidebarRight/bluetoothDevices/BluetoothDeviceItem.qml new file mode 100644 index 0000000..8c713d3 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/bluetoothDevices/BluetoothDeviceItem.qml @@ -0,0 +1,112 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts + +DialogListItem { + id: root + required property var device + property bool expanded: false + pointingHandCursor: !expanded + + onClicked: expanded = !expanded + altAction: () => expanded = !expanded + + component ActionButton: DialogButton { + colBackground: Appearance.colors.colPrimary + colBackgroundHover: Appearance.colors.colPrimaryHover + colRipple: Appearance.colors.colPrimaryActive + colText: Appearance.colors.colOnPrimary + } + + contentItem: ColumnLayout { + anchors { + fill: parent + topMargin: root.verticalPadding + leftMargin: root.horizontalPadding + rightMargin: root.horizontalPadding + } + spacing: 0 + + RowLayout { + // Name + spacing: 10 + + MaterialSymbol { + iconSize: Appearance.font.pixelSize.larger + text: Icons.getBluetoothDeviceMaterialSymbol(root.device?.icon || "") + color: Appearance.colors.colOnSurfaceVariant + } + + ColumnLayout { + spacing: 2 + Layout.fillWidth: true + StyledText { + Layout.fillWidth: true + color: Appearance.colors.colOnSurfaceVariant + elide: Text.ElideRight + text: root.device?.name || Translation.tr("Unknown device") + } + StyledText { + visible: (root.device?.connected || root.device?.paired) ?? false + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colSubtext + elide: Text.ElideRight + text: { + if (!root.device?.paired) return ""; + let statusText = root.device?.connected ? Translation.tr("Connected") : Translation.tr("Paired"); + if (!root.device?.batteryAvailable) return statusText; + statusText += ` โ€ข ${Math.round(root.device?.battery * 100)}%`; + return statusText; + } + } + } + + MaterialSymbol { + text: "keyboard_arrow_down" + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer3 + rotation: root.expanded ? 180 : 0 + Behavior on rotation { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + } + + RowLayout { + visible: root.expanded + Layout.topMargin: 8 + Item { + Layout.fillWidth: true + } + ActionButton { + buttonText: root.device?.connected ? Translation.tr("Disconnect") : Translation.tr("Connect") + + onClicked: { + if (root.device?.connected) { + root.device.disconnect(); + } else { + root.device.connect(); + } + } + } + ActionButton { + visible: root.device?.paired ?? false + colBackground: Appearance.colors.colError + colBackgroundHover: Appearance.colors.colErrorHover + colRipple: Appearance.colors.colErrorActive + colText: Appearance.colors.colOnError + + buttonText: Translation.tr("Forget") + onClicked: { + root.device?.forget(); + } + } + } + Item { + Layout.fillHeight: true + } + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/bluetoothDevices/BluetoothDialog.qml b/modules/quickshell/config/modules/ii/sidebarRight/bluetoothDevices/BluetoothDialog.qml new file mode 100644 index 0000000..8c56a8f --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/bluetoothDevices/BluetoothDialog.qml @@ -0,0 +1,77 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell.Io +import Quickshell.Bluetooth +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +WindowDialog { + id: root + backgroundHeight: 600 + + WindowDialogTitle { + text: Translation.tr("Bluetooth devices") + } + WindowDialogSeparator { + visible: !(Bluetooth.defaultAdapter?.discovering ?? false) + } + StyledIndeterminateProgressBar { + visible: Bluetooth.defaultAdapter?.discovering ?? false + Layout.fillWidth: true + Layout.topMargin: -8 + Layout.bottomMargin: -8 + Layout.leftMargin: -Appearance.rounding.large + Layout.rightMargin: -Appearance.rounding.large + } + StyledListView { + Layout.fillHeight: true + Layout.fillWidth: true + Layout.topMargin: -15 + Layout.bottomMargin: -16 + Layout.leftMargin: -Appearance.rounding.large + Layout.rightMargin: -Appearance.rounding.large + + clip: true + spacing: 0 + animateAppearance: false + + model: ScriptModel { + values: BluetoothStatus.friendlyDeviceList + } + delegate: BluetoothDeviceItem { + required property BluetoothDevice modelData + device: modelData + anchors { + left: parent?.left + right: parent?.right + } + } + } + WindowDialogSeparator {} + WindowDialogButtonRow { + DialogButton { + buttonText: Translation.tr("Details") + onClicked: { + Quickshell.execDetached(["bash", "-c", `${Config.options.apps.bluetooth}`]); + GlobalStates.sidebarRightOpen = false; + } + } + + Item { + Layout.fillWidth: true + } + + DialogButton { + buttonText: Translation.tr("Done") + onClicked: root.dismiss() + } + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/calendar/CalendarDayButton.qml b/modules/quickshell/config/modules/ii/sidebarRight/calendar/CalendarDayButton.qml new file mode 100644 index 0000000..ab1aca5 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/calendar/CalendarDayButton.qml @@ -0,0 +1,34 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +RippleButton { + id: button + property string day + property int isToday + property bool bold + + Layout.fillWidth: false + Layout.fillHeight: false + implicitWidth: 38; + implicitHeight: 38; + + toggled: (isToday == 1) + buttonRadius: Appearance.rounding.small + + contentItem: StyledText { + anchors.fill: parent + text: day + horizontalAlignment: Text.AlignHCenter + font.weight: bold ? Font.DemiBold : Font.Normal + color: (isToday == 1) ? Appearance.m3colors.m3onPrimary : + (isToday == 0) ? Appearance.colors.colOnLayer1 : + Appearance.colors.colOutlineVariant + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } +} + diff --git a/modules/quickshell/config/modules/ii/sidebarRight/calendar/CalendarHeaderButton.qml b/modules/quickshell/config/modules/ii/sidebarRight/calendar/CalendarHeaderButton.qml new file mode 100644 index 0000000..37a45db --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/calendar/CalendarHeaderButton.qml @@ -0,0 +1,36 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +RippleButton { + id: button + property string buttonText: "" + property string tooltipText: "" + property bool forceCircle: false + + implicitHeight: 30 + implicitWidth: forceCircle ? implicitHeight : (contentItem.implicitWidth + 10 * 2) + Behavior on implicitWidth { + SmoothedAnimation { + velocity: Appearance.animation.elementMove.velocity + } + } + + background.anchors.fill: button + buttonRadius: Appearance.rounding.full + colBackground: Appearance.colors.colLayer2 + colBackgroundHover: Appearance.colors.colLayer2Hover + colRipple: Appearance.colors.colLayer2Active + + contentItem: StyledText { + text: buttonText + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer1 + } + + StyledToolTip { + text: tooltipText + extraVisibleCondition: tooltipText.length > 0 + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/ii/sidebarRight/calendar/CalendarWidget.qml b/modules/quickshell/config/modules/ii/sidebarRight/calendar/CalendarWidget.qml new file mode 100644 index 0000000..002a9e3 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/calendar/CalendarWidget.qml @@ -0,0 +1,122 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import "calendar_layout.js" as CalendarLayout +import QtQuick +import QtQuick.Layouts + +Item { + // Layout.topMargin: 10 + anchors.topMargin: 10 + property int monthShift: 0 + property var viewingDate: CalendarLayout.getDateInXMonthsTime(monthShift) + property var calendarLayout: CalendarLayout.getCalendarLayout(viewingDate, monthShift === 0) + width: calendarColumn.width + implicitHeight: calendarColumn.height + 10 * 2 + + Keys.onPressed: (event) => { + if ((event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) + && event.modifiers === Qt.NoModifier) { + if (event.key === Qt.Key_PageDown) { + monthShift++; + } else if (event.key === Qt.Key_PageUp) { + monthShift--; + } + event.accepted = true; + } + } + MouseArea { + anchors.fill: parent + onWheel: (event) => { + if (event.angleDelta.y > 0) { + monthShift--; + } else if (event.angleDelta.y < 0) { + monthShift++; + } + } + } + + ColumnLayout { + id: calendarColumn + anchors.centerIn: parent + spacing: 5 + + // Calendar header + RowLayout { + Layout.fillWidth: true + spacing: 5 + CalendarHeaderButton { + clip: true + buttonText: `${monthShift != 0 ? "โ€ข " : ""}${viewingDate.toLocaleDateString(Qt.locale(), "MMMM yyyy")}` + tooltipText: (monthShift === 0) ? "" : Translation.tr("Jump to current month") + downAction: () => { + monthShift = 0; + } + } + Item { + Layout.fillWidth: true + Layout.fillHeight: false + } + CalendarHeaderButton { + forceCircle: true + downAction: () => { + monthShift--; + } + contentItem: MaterialSymbol { + text: "chevron_left" + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: Appearance.colors.colOnLayer1 + } + } + CalendarHeaderButton { + forceCircle: true + downAction: () => { + monthShift++; + } + contentItem: MaterialSymbol { + text: "chevron_right" + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: Appearance.colors.colOnLayer1 + } + } + } + + // Week days row + RowLayout { + id: weekDaysRow + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: false + spacing: 5 + Repeater { + model: CalendarLayout.weekDays + delegate: CalendarDayButton { + day: Translation.tr(modelData.day) + isToday: modelData.today + bold: true + enabled: false + } + } + } + + // Real week rows + Repeater { + id: calendarRows + // model: calendarLayout + model: 6 + delegate: RowLayout { + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: false + spacing: 5 + Repeater { + model: Array(7).fill(modelData) + delegate: CalendarDayButton { + day: calendarLayout[modelData][index].day + isToday: calendarLayout[modelData][index].today + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/ii/sidebarRight/calendar/calendar_layout.js b/modules/quickshell/config/modules/ii/sidebarRight/calendar/calendar_layout.js new file mode 100644 index 0000000..e2042bf --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/calendar/calendar_layout.js @@ -0,0 +1,113 @@ +const weekDays = [ // MONDAY IS THE FIRST DAY OF THE WEEK :HESRIGHTYOUKNOW: + { day: 'Mo', today: 0 }, + { day: 'Tu', today: 0 }, + { day: 'We', today: 0 }, + { day: 'Th', today: 0 }, + { day: 'Fr', today: 0 }, + { day: 'Sa', today: 0 }, + { day: 'Su', today: 0 }, +] + +function checkLeapYear(year) { + return ( + year % 400 == 0 || + (year % 4 == 0 && year % 100 != 0)); +} + +function getMonthDays(month, year) { + const leapYear = checkLeapYear(year); + if ((month <= 7 && month % 2 == 1) || (month >= 8 && month % 2 == 0)) return 31; + if (month == 2 && leapYear) return 29; + if (month == 2 && !leapYear) return 28; + return 30; +} + +function getNextMonthDays(month, year) { + const leapYear = checkLeapYear(year); + if (month == 1 && leapYear) return 29; + if (month == 1 && !leapYear) return 28; + if (month == 12) return 31; + if ((month <= 7 && month % 2 == 1) || (month >= 8 && month % 2 == 0)) return 30; + return 31; +} + +function getPrevMonthDays(month, year) { + const leapYear = checkLeapYear(year); + if (month == 3 && leapYear) return 29; + if (month == 3 && !leapYear) return 28; + if (month == 1) return 31; + if ((month <= 7 && month % 2 == 1) || (month >= 8 && month % 2 == 0)) return 30; + return 31; +} + +function getDateInXMonthsTime(x) { + var currentDate = new Date(); // Get the current date + if (x == 0) return currentDate; // If x is 0, return the current date + + var targetMonth = currentDate.getMonth() + x; // Calculate the target month + var targetYear = currentDate.getFullYear(); // Get the current year + + // Adjust the year and month if necessary + targetYear += Math.floor(targetMonth / 12); + targetMonth = (targetMonth % 12 + 12) % 12; + + // Create a new date object with the target year and month + var targetDate = new Date(targetYear, targetMonth, 1); + + // Set the day to the last day of the month to get the desired date + // targetDate.setDate(0); + + return targetDate; +} + +function getCalendarLayout(dateObject, highlight) { + if (!dateObject) dateObject = new Date(); + const weekday = (dateObject.getDay() + 6) % 7; // MONDAY IS THE FIRST DAY OF THE WEEK + const day = dateObject.getDate(); + const month = dateObject.getMonth() + 1; + const year = dateObject.getFullYear(); + const weekdayOfMonthFirst = (weekday + 35 - (day - 1)) % 7; + const daysInMonth = getMonthDays(month, year); + const daysInNextMonth = getNextMonthDays(month, year); + const daysInPrevMonth = getPrevMonthDays(month, year); + + // Fill + var monthDiff = (weekdayOfMonthFirst == 0 ? 0 : -1); + var toFill, dim; + if (weekdayOfMonthFirst == 0) { + toFill = 1; + dim = daysInMonth; + } + else { + toFill = (daysInPrevMonth - (weekdayOfMonthFirst - 1)); + dim = daysInPrevMonth; + } + var calendar = [...Array(6)].map(() => Array(7)); + var i = 0, j = 0; + while (i < 6 && j < 7) { + calendar[i][j] = { + "day": toFill, + "today": ((toFill == day && monthDiff == 0 && highlight) ? 1 : ( + monthDiff == 0 ? 0 : -1 + )) + }; + // Increment + toFill++; + if (toFill > dim) { // Next month? + monthDiff++; + if (monthDiff == 0) + dim = daysInMonth; + else if (monthDiff == 1) + dim = daysInNextMonth; + toFill = 1; + } + // Next tile + j++; + if (j == 7) { + j = 0; + i++; + } + + } + return calendar; +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/nightLight/NightLightDialog.qml b/modules/quickshell/config/modules/ii/sidebarRight/nightLight/NightLightDialog.qml new file mode 100644 index 0000000..f0286fc --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/nightLight/NightLightDialog.qml @@ -0,0 +1,158 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +WindowDialog { + id: root + property var screen: root.QsWindow.window?.screen + property var brightnessMonitor: Brightness.getMonitorForScreen(screen) + backgroundHeight: 600 + + WindowDialogTitle { + text: Translation.tr("Eye protection") + } + + WindowDialogSectionHeader { + text: Translation.tr("Night Light") + } + + WindowDialogSeparator { + Layout.topMargin: -22 + Layout.leftMargin: 0 + Layout.rightMargin: 0 + } + + Column { + id: nightLightColumn + Layout.topMargin: -16 + Layout.fillWidth: true + + ConfigSwitch { + anchors { + left: parent.left + right: parent.right + } + iconSize: Appearance.font.pixelSize.larger + buttonIcon: "lightbulb" + text: Translation.tr("Enable now") + checked: Hyprsunset.active + onCheckedChanged: { + Hyprsunset.toggle(checked) + } + } + + ConfigSwitch { + anchors { + left: parent.left + right: parent.right + } + iconSize: Appearance.font.pixelSize.larger + buttonIcon: "night_sight_auto" + text: Translation.tr("Automatic") + checked: Config.options.light.night.automatic + onCheckedChanged: { + Config.options.light.night.automatic = checked; + } + } + + WindowDialogSlider { + anchors { + left: parent.left + right: parent.right + leftMargin: 4 + rightMargin: 4 + } + text: Translation.tr("Intensity") + from: 6500 + to: 1200 + stopIndicatorValues: [5000, to] + value: Config.options.light.night.colorTemperature + onMoved: Config.options.light.night.colorTemperature = value + tooltipContent: `${Math.round(value)}K` + } + } + + WindowDialogSectionHeader { + text: Translation.tr("Anti-flashbang (experimental)") + } + + WindowDialogSeparator { + Layout.topMargin: -22 + Layout.leftMargin: 0 + Layout.rightMargin: 0 + } + + Column { + id: antiFlashbangColumn + Layout.topMargin: -16 + Layout.fillWidth: true + + ConfigSwitch { + anchors { + left: parent.left + right: parent.right + } + iconSize: Appearance.font.pixelSize.larger + buttonIcon: "flash_off" + text: Translation.tr("Enable") + checked: Config.options.light.antiFlashbang.enable + onCheckedChanged: { + Config.options.light.antiFlashbang.enable = checked; + } + StyledToolTip { + text: Translation.tr("Example use case: eroge on one workspace, dark Discord window on another") + } + } + } + + WindowDialogSectionHeader { + text: Translation.tr("Brightness") + } + + WindowDialogSeparator { + Layout.topMargin: -22 + Layout.leftMargin: 0 + Layout.rightMargin: 0 + } + + Column { + id: brightnessColumn + Layout.topMargin: -16 + Layout.fillWidth: true + Layout.fillHeight: true + + WindowDialogSlider { + anchors { + left: parent.left + right: parent.right + leftMargin: 4 + rightMargin: 4 + } + // text: Translation.tr("Brightness") + value: root.brightnessMonitor.brightness + onMoved: root.brightnessMonitor.setBrightness(value) + } + } + + WindowDialogButtonRow { + Layout.fillWidth: true + + Item { + Layout.fillWidth: true + } + + DialogButton { + buttonText: Translation.tr("Done") + onClicked: root.dismiss() + } + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/notifications/NotificationList.qml b/modules/quickshell/config/modules/ii/sidebarRight/notifications/NotificationList.qml new file mode 100644 index 0000000..99c1fb9 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/notifications/NotificationList.qml @@ -0,0 +1,71 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + + NotificationListView { // Scrollable window + id: listview + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: statusRow.top + anchors.bottomMargin: 5 + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: listview.width + height: listview.height + radius: Appearance.rounding.normal + } + } + + popup: false + } + + // Placeholder when list is empty + PagePlaceholder { + shown: Notifications.list.length === 0 + icon: "notifications_active" + description: Translation.tr("Nothing") + shape: MaterialShape.Shape.Ghostish + descriptionHorizontalAlignment: Text.AlignHCenter + } + + ButtonGroup { + id: statusRow + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + } + + NotificationStatusButton { + Layout.fillWidth: false + buttonIcon: "notifications_paused" + toggled: Notifications.silent + onClicked: () => { + Notifications.silent = !Notifications.silent; + } + } + NotificationStatusButton { + enabled: false + Layout.fillWidth: true + buttonText: Translation.tr("%1 notifications").arg(Notifications.list.length) + } + NotificationStatusButton { + Layout.fillWidth: false + buttonIcon: "delete_sweep" + onClicked: () => { + Notifications.discardAllNotifications() + } + } + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/ii/sidebarRight/notifications/NotificationStatusButton.qml b/modules/quickshell/config/modules/ii/sidebarRight/notifications/NotificationStatusButton.qml new file mode 100644 index 0000000..2609b5f --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/notifications/NotificationStatusButton.qml @@ -0,0 +1,46 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +GroupButton { + id: button + property string buttonIcon: "" + property string buttonText: "" + + baseHeight: 36 + baseWidth: content.implicitWidth + 46 + clickedWidth: baseWidth + 6 + + buttonRadius: baseHeight / 2 + buttonRadiusPressed: Appearance.rounding.small + colBackground: Appearance.colors.colLayer2 + colBackgroundHover: Appearance.colors.colLayer2Hover + colBackgroundActive: Appearance.colors.colLayer2Active + property color colText: toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer1 + + contentItem: Item { + id: content + anchors.fill: parent + implicitWidth: contentRowLayout.implicitWidth + implicitHeight: contentRowLayout.implicitHeight + RowLayout { + id: contentRowLayout + anchors.centerIn: parent + spacing: 5 + MaterialSymbol { + visible: buttonIcon !== "" + text: buttonIcon + iconSize: Appearance.font.pixelSize.huge + color: button.colText + } + StyledText { + visible: buttonText !== "" + text: buttonText + font.pixelSize: Appearance.font.pixelSize.small + color: button.colText + } + } + } + +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/ii/sidebarRight/pomodoro/PomodoroTimer.qml b/modules/quickshell/config/modules/ii/sidebarRight/pomodoro/PomodoroTimer.qml new file mode 100644 index 0000000..b8dce71 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/pomodoro/PomodoroTimer.qml @@ -0,0 +1,114 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +Item { + id: root + + implicitHeight: contentColumn.implicitHeight + implicitWidth: contentColumn.implicitWidth + + ColumnLayout { + id: contentColumn + anchors.fill: parent + spacing: 0 + + // The Pomodoro timer circle + CircularProgress { + Layout.alignment: Qt.AlignHCenter + lineWidth: 8 + value: { + return TimerService.pomodoroSecondsLeft / TimerService.pomodoroLapDuration; + } + implicitSize: 200 + enableAnimation: true + + ColumnLayout { + anchors.centerIn: parent + spacing: 0 + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: { + let minutes = Math.floor(TimerService.pomodoroSecondsLeft / 60).toString().padStart(2, '0'); + let seconds = Math.floor(TimerService.pomodoroSecondsLeft % 60).toString().padStart(2, '0'); + return `${minutes}:${seconds}`; + } + font.pixelSize: 40 + color: Appearance.m3colors.m3onSurface + } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: TimerService.pomodoroLongBreak ? Translation.tr("Long break") : TimerService.pomodoroBreak ? Translation.tr("Break") : Translation.tr("Focus") + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colSubtext + } + } + + Rectangle { + radius: Appearance.rounding.full + color: Appearance.colors.colLayer2 + + anchors { + right: parent.right + bottom: parent.bottom + } + implicitWidth: 36 + implicitHeight: implicitWidth + + StyledText { + id: cycleText + anchors.centerIn: parent + color: Appearance.colors.colOnLayer2 + text: TimerService.pomodoroCycle + 1 + } + } + } + + // The Start/Stop and Reset buttons + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 10 + + RippleButton { + contentItem: StyledText { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: TimerService.pomodoroRunning ? Translation.tr("Pause") : (TimerService.pomodoroSecondsLeft === TimerService.focusTime) ? Translation.tr("Start") : Translation.tr("Resume") + color: TimerService.pomodoroRunning ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnPrimary + } + implicitHeight: 35 + implicitWidth: 90 + font.pixelSize: Appearance.font.pixelSize.larger + onClicked: TimerService.togglePomodoro() + colBackground: TimerService.pomodoroRunning ? Appearance.colors.colSecondaryContainer : Appearance.colors.colPrimary + colBackgroundHover: TimerService.pomodoroRunning ? Appearance.colors.colSecondaryContainer : Appearance.colors.colPrimary + } + + RippleButton { + implicitHeight: 35 + implicitWidth: 90 + + onClicked: TimerService.resetPomodoro() + enabled: (TimerService.pomodoroSecondsLeft < TimerService.pomodoroLapDuration) || TimerService.pomodoroCycle > 0 || TimerService.pomodoroBreak + + font.pixelSize: Appearance.font.pixelSize.larger + colBackground: Appearance.colors.colErrorContainer + colBackgroundHover: Appearance.colors.colErrorContainerHover + colRipple: Appearance.colors.colErrorContainerActive + + contentItem: StyledText { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: Translation.tr("Reset") + color: Appearance.colors.colOnErrorContainer + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/pomodoro/PomodoroWidget.qml b/modules/quickshell/config/modules/ii/sidebarRight/pomodoro/PomodoroWidget.qml new file mode 100644 index 0000000..a62a961 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/pomodoro/PomodoroWidget.qml @@ -0,0 +1,75 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + property var tabButtonList: [ + {"name": Translation.tr("Pomodoro"), "icon": "search_activity"}, + {"name": Translation.tr("Stopwatch"), "icon": "timer"} + ] + + // These are keybinds for stopwatch and pomodoro + Keys.onPressed: (event) => { + if ((event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) && event.modifiers === Qt.NoModifier) { // Switch tabs + if (event.key === Qt.Key_PageDown) { + tabBar.incrementCurrentIndex(); + } else if (event.key === Qt.Key_PageUp) { + tabBar.decrementCurrentIndex(); + } + event.accepted = true + } else if (event.key === Qt.Key_Space || event.key === Qt.Key_S) { // Pause/resume with Space or S + if (tabBar.currentIndex === 0) { + TimerService.togglePomodoro() + } else { + TimerService.toggleStopwatch() + } + event.accepted = true + } else if (event.key === Qt.Key_R) { // Reset with R + if (tabBar.currentIndex === 0) { + TimerService.resetPomodoro() + } else { + TimerService.stopwatchReset() + } + event.accepted = true + } else if (event.key === Qt.Key_L) { // Record lap with L + TimerService.stopwatchRecordLap() + event.accepted = true + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + SecondaryTabBar { + id: tabBar + currentIndex: swipeView.currentIndex + + Repeater { + model: root.tabButtonList + delegate: SecondaryTabButton { + buttonText: modelData.name + buttonIcon: modelData.icon + } + } + } + + SwipeView { + id: swipeView + Layout.topMargin: 10 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + clip: true + currentIndex: tabBar.currentIndex + + // Tabs + PomodoroTimer {} + Stopwatch {} + } + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/pomodoro/Stopwatch.qml b/modules/quickshell/config/modules/ii/sidebarRight/pomodoro/Stopwatch.qml new file mode 100644 index 0000000..8b1dbba --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/pomodoro/Stopwatch.qml @@ -0,0 +1,207 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +Item { + id: stopwatchTab + Layout.fillWidth: true + Layout.fillHeight: true + + Item { + anchors { + fill: parent + topMargin: 8 + leftMargin: 16 + rightMargin: 16 + } + + RowLayout { // Elapsed + id: elapsedIndicator + + anchors { + top: undefined + verticalCenter: parent.verticalCenter + left: controlButtons.left + leftMargin: 6 + } + + states: State { + name: "hasLaps" + when: TimerService.stopwatchLaps.length > 0 + AnchorChanges { + target: elapsedIndicator + anchors.top: parent.top + anchors.verticalCenter: undefined + anchors.left: controlButtons.left + } + } + + transitions: Transition { + AnchorAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + spacing: 0 + StyledText { + // Layout.preferredWidth: elapsedIndicator.width * 0.6 // Prevent shakiness + font.pixelSize: 40 + color: Appearance.m3colors.m3onSurface + text: { + let totalSeconds = Math.floor(TimerService.stopwatchTime) / 100 + let minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0') + let seconds = Math.floor(totalSeconds % 60).toString().padStart(2, '0') + return `${minutes}:${seconds}` + } + } + StyledText { + Layout.fillWidth: true + font.pixelSize: 40 + color: Appearance.colors.colSubtext + text: { + return `:${(Math.floor(TimerService.stopwatchTime) % 100).toString().padStart(2, '0')}` + } + } + } + + // Laps + StyledListView { + id: lapsList + anchors { + top: elapsedIndicator.bottom + bottom: controlButtons.top + left: parent.left + right: parent.right + topMargin: 16 + bottomMargin: 16 + } + spacing: 4 + clip: true + popin: true + + model: ScriptModel { + values: TimerService.stopwatchLaps.map((v, i, arr) => arr[arr.length - 1 - i]) + } + + delegate: Rectangle { + id: lapItem + required property int index + required property var modelData + property var horizontalPadding: 10 + property var verticalPadding: 6 + width: lapsList.width + implicitHeight: lapRow.implicitHeight + verticalPadding * 2 + implicitWidth: lapRow.implicitWidth + horizontalPadding * 2 + color: Appearance.colors.colLayer2 + radius: Appearance.rounding.small + + RowLayout { + id: lapRow + anchors { + fill: parent + leftMargin: lapItem.horizontalPadding + rightMargin: lapItem.horizontalPadding + topMargin: lapItem.verticalPadding + bottomMargin: lapItem.verticalPadding + } + + StyledText { + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + text: `${TimerService.stopwatchLaps.length - lapItem.index}.` + } + + StyledText { + font.pixelSize: Appearance.font.pixelSize.small + text: { + const lapTime = lapItem.modelData + const _10ms = (Math.floor(lapTime) % 100).toString().padStart(2, '0') + const totalSeconds = Math.floor(lapTime) / 100 + const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0') + const seconds = Math.floor(totalSeconds % 60).toString().padStart(2, '0') + return `${minutes}:${seconds}.${_10ms}` + } + } + + Item { Layout.fillWidth: true } + + StyledText { + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colPrimary + text: { + const originalIndex = TimerService.stopwatchLaps.length - lapItem.index - 1 + const lastTime = originalIndex > 0 ? TimerService.stopwatchLaps[originalIndex - 1] : 0 + const lapTime = lapItem.modelData - lastTime + const _10ms = (Math.floor(lapTime) % 100).toString().padStart(2, '0') + const totalSeconds = Math.floor(lapTime) / 100 + const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0') + const seconds = Math.floor(totalSeconds % 60).toString().padStart(2, '0') + return `+${minutes == "00" ? "" : minutes + ":"}${seconds}.${_10ms}` + } + } + } + } + } + + RowLayout { + id: controlButtons + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: 6 + } + spacing: 4 + + RippleButton { + Layout.preferredHeight: 35 + Layout.preferredWidth: 90 + font.pixelSize: Appearance.font.pixelSize.larger + + onClicked: { + TimerService.toggleStopwatch() + } + + colBackground: TimerService.stopwatchRunning ? Appearance.colors.colSecondaryContainer : Appearance.colors.colPrimary + colBackgroundHover: TimerService.stopwatchRunning ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colPrimaryHover + colRipple: TimerService.stopwatchRunning ? Appearance.colors.colSecondaryContainerActive : Appearance.colors.colPrimaryActive + + contentItem: StyledText { + horizontalAlignment: Text.AlignHCenter + color: TimerService.stopwatchRunning ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnPrimary + text: TimerService.stopwatchRunning ? Translation.tr("Pause") : TimerService.stopwatchTime === 0 ? Translation.tr("Start") : Translation.tr("Resume") + } + } + + RippleButton { + implicitHeight: 35 + implicitWidth: 90 + font.pixelSize: Appearance.font.pixelSize.larger + + onClicked: { + if (TimerService.stopwatchRunning) + TimerService.stopwatchRecordLap() + else + TimerService.stopwatchReset() + } + enabled: TimerService.stopwatchTime > 0 || Persistent.states.timer.stopwatch.laps.length > 0 + + colBackground: TimerService.stopwatchRunning ? Appearance.colors.colLayer2 : Appearance.colors.colErrorContainer + colBackgroundHover: TimerService.stopwatchRunning ? Appearance.colors.colLayer2Hover : Appearance.colors.colErrorContainerHover + colRipple: TimerService.stopwatchRunning ? Appearance.colors.colLayer2Active : Appearance.colors.colErrorContainerActive + + contentItem: StyledText { + horizontalAlignment: Text.AlignHCenter + text: TimerService.stopwatchRunning ? Translation.tr("Lap") : Translation.tr("Reset") + color: TimerService.stopwatchRunning ? Appearance.colors.colOnLayer2 : Appearance.colors.colOnErrorContainer + } + } + } + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/AbstractQuickPanel.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/AbstractQuickPanel.qml new file mode 100644 index 0000000..c6ef4b9 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/AbstractQuickPanel.qml @@ -0,0 +1,15 @@ +import QtQuick +import qs.modules.common + +Rectangle { + id: root + + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + + signal openAudioOutputDialog() + signal openAudioInputDialog() + signal openBluetoothDialog() + signal openNightLightDialog() + signal openWifiDialog() +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/AndroidQuickPanel.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/AndroidQuickPanel.qml new file mode 100644 index 0000000..8457b3c --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/AndroidQuickPanel.qml @@ -0,0 +1,163 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Bluetooth + +import qs.modules.ii.sidebarRight.quickToggles.androidStyle + +AbstractQuickPanel { + id: root + property bool editMode: false + Layout.fillWidth: true + + // Sizes + implicitHeight: (editMode ? contentItem.implicitHeight : usedRows.implicitHeight) + root.padding * 2 + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + property real spacing: 6 + property real padding: 6 + readonly property real baseCellWidth: { + // This is the wrong calculation, but it looks correct in reality??? + // (theoretically spacing should be multiplied by 1 column less) + const availableWidth = root.width - (root.padding * 2) - (root.spacing * (root.columns)) + return availableWidth / root.columns + } + readonly property real baseCellHeight: 56 + + // Toggles + readonly property list availableToggleTypes: ["network", "bluetooth", "idleInhibitor", "easyEffects", "nightLight", "darkMode", "cloudflareWarp", "gameMode", "screenSnip", "colorPicker", "onScreenKeyboard", "mic", "audio", "notifications", "powerProfile","musicRecognition", "antiFlashbang"] + readonly property int columns: Config.options.sidebar.quickToggles.android.columns + readonly property list toggles: Config.ready ? Config.options.sidebar.quickToggles.android.toggles : [] + readonly property list toggleRows: toggleRowsForList(toggles) + readonly property list unusedToggles: { + const types = availableToggleTypes.filter(type => !toggles.some(toggle => (toggle && toggle.type === type))) + return types.map(type => { return { type: type, size: 1 } }) + } + readonly property list unusedToggleRows: toggleRowsForList(unusedToggles) + + function toggleRowsForList(togglesList) { + var rows = []; + var row = []; + var totalSize = 0; // Total cols taken in current row + for (var i = 0; i < togglesList.length; i++) { + if (!togglesList[i]) continue; + if (totalSize + togglesList[i].size > columns) { + rows.push(row); + row = []; + totalSize = 0; + } + row.push(togglesList[i]); + totalSize += togglesList[i].size; + } + if (row.length > 0) { + rows.push(row); + } + return rows; + } + + Column { + id: contentItem + anchors { + fill: parent + margins: root.padding + } + spacing: 12 + + Column { + id: usedRows + spacing: root.spacing + + Repeater { + id: usedRowsRepeater + model: ScriptModel { + values: Array(root.toggleRows.length) + } + delegate: ButtonGroup { + id: toggleRow + required property int index + property var modelData: root.toggleRows[index] + property int startingIndex: { + const rows = root.toggleRows; + let sum = 0; + for (let i = 0; i < index; i++) { + sum += rows[i].length; + } + return sum; + } + spacing: root.spacing + + Repeater { + model: ScriptModel { + values: toggleRow?.modelData ?? [] + objectProp: "type" + } + delegate: AndroidToggleDelegateChooser { + startingIndex: toggleRow.startingIndex + editMode: root.editMode + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + spacing: root.spacing + onOpenAudioOutputDialog: root.openAudioOutputDialog() + onOpenAudioInputDialog: root.openAudioInputDialog() + onOpenBluetoothDialog: root.openBluetoothDialog() + onOpenNightLightDialog: root.openNightLightDialog() + onOpenWifiDialog: root.openWifiDialog() + } + } + } + } + } + + FadeLoader { + shown: root.editMode + anchors { + left: parent.left + right: parent.right + leftMargin: root.baseCellHeight / 2 + rightMargin: root.baseCellHeight / 2 + } + sourceComponent: Rectangle { + implicitHeight: 1 + color: Appearance.colors.colOutlineVariant + } + } + + FadeLoader { + shown: root.editMode + sourceComponent: Column { + id: unusedRows + spacing: root.spacing + + Repeater { + model: ScriptModel { + values: Array(root.unusedToggleRows.length) + } + delegate: ButtonGroup { + id: unusedToggleRow + required property int index + property var modelData: root.unusedToggleRows[index] + spacing: root.spacing + + Repeater { + model: ScriptModel { + values: unusedToggleRow?.modelData ?? [] + objectProp: "type" + } + delegate: AndroidToggleDelegateChooser { + startingIndex: -1 + editMode: root.editMode + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + spacing: root.spacing + } + } + } + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/ClassicQuickPanel.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/ClassicQuickPanel.qml new file mode 100644 index 0000000..3aff842 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/ClassicQuickPanel.qml @@ -0,0 +1,39 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell.Bluetooth + +import qs.modules.ii.sidebarRight.quickToggles.classicStyle + +AbstractQuickPanel { + id: root + Layout.alignment: Qt.AlignHCenter + implicitWidth: buttonGroup.implicitWidth + implicitHeight: buttonGroup.implicitHeight + color: "transparent" + + ButtonGroup { + id: buttonGroup + spacing: 5 + padding: 5 + color: Appearance.colors.colLayer1 + + NetworkToggle { + altAction: () => { + root.openWifiDialog(); + } + } + BluetoothToggle { + altAction: () => { + root.openBluetoothDialog(); + } + } + NightLight {} + GameMode {} + IdleInhibitor {} + EasyEffectsToggle {} + CloudflareWarp {} + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidAntiFlashbangToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidAntiFlashbangToggle.qml new file mode 100644 index 0000000..f07d6c2 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidAntiFlashbangToggle.qml @@ -0,0 +1,13 @@ +import qs.modules.common.models.quickToggles +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell + +AndroidQuickToggleButton { + id: root + + toggleModel: AntiFlashbangToggle {} +} + diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidAudioToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidAudioToggle.qml new file mode 100644 index 0000000..4a0ee5b --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidAudioToggle.qml @@ -0,0 +1,13 @@ +import qs +import qs.modules.common +import qs.modules.common.models.quickToggles +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell + +AndroidQuickToggleButton { + id: root + + toggleModel: AudioToggle {} +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidBluetoothToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidBluetoothToggle.qml new file mode 100644 index 0000000..c0ab7d5 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidBluetoothToggle.qml @@ -0,0 +1,14 @@ +import qs.services +import qs.modules.common +import qs.modules.common.models.quickToggles +import qs.modules.common.functions +import qs.modules.common.widgets +import QtQuick +import Quickshell +import Quickshell.Bluetooth + +AndroidQuickToggleButton { + id: root + + toggleModel: BluetoothToggle {} +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidCloudflareWarpToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidCloudflareWarpToggle.qml new file mode 100644 index 0000000..9385133 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidCloudflareWarpToggle.qml @@ -0,0 +1,13 @@ +import qs.modules.common +import qs.modules.common.models.quickToggles +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell +import Quickshell.Io + +AndroidQuickToggleButton { + id: root + + toggleModel: CloudflareWarpToggle {} +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidColorPickerToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidColorPickerToggle.qml new file mode 100644 index 0000000..9319398 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidColorPickerToggle.qml @@ -0,0 +1,13 @@ +import qs +import qs.modules.common +import qs.modules.common.models.quickToggles +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell + +AndroidQuickToggleButton { + id: root + + toggleModel: ColorPickerToggle {} +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidDarkModeToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidDarkModeToggle.qml new file mode 100644 index 0000000..4defe6b --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidDarkModeToggle.qml @@ -0,0 +1,10 @@ +import qs.modules.common +import qs.modules.common.models.quickToggles +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell + +AndroidQuickToggleButton { + toggleModel: DarkModeToggle {} +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidEasyEffectsToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidEasyEffectsToggle.qml new file mode 100644 index 0000000..157d923 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidEasyEffectsToggle.qml @@ -0,0 +1,11 @@ +import qs +import qs.modules.common.models.quickToggles +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell + +AndroidQuickToggleButton { + toggleModel: EasyEffectsToggle {} +} + diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidGameModeToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidGameModeToggle.qml new file mode 100644 index 0000000..ec3225e --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidGameModeToggle.qml @@ -0,0 +1,11 @@ +import qs.modules.common +import qs.modules.common.models.quickToggles +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell +import Quickshell.Io + +AndroidQuickToggleButton { + toggleModel: GameModeToggle {} +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidIdleInhibitorToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidIdleInhibitorToggle.qml new file mode 100644 index 0000000..a42d680 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidIdleInhibitorToggle.qml @@ -0,0 +1,11 @@ +import qs.services +import qs.modules.common +import qs.modules.common.models.quickToggles +import qs.modules.common.functions +import qs.modules.common.widgets +import QtQuick + +AndroidQuickToggleButton { + toggleModel: IdleInhibitorToggle {} +} + diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidMicToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidMicToggle.qml new file mode 100644 index 0000000..55f36a3 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidMicToggle.qml @@ -0,0 +1,11 @@ +import qs +import qs.modules.common +import qs.modules.common.models.quickToggles +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell + +AndroidQuickToggleButton { + toggleModel: MicToggle {} +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidMusicRecognition.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidMusicRecognition.qml new file mode 100644 index 0000000..41139b0 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidMusicRecognition.qml @@ -0,0 +1,13 @@ +import qs +import qs.modules.common +import qs.modules.common.models.quickToggles +import qs.modules.common.widgets +import QtQuick +import Quickshell +import Quickshell.Io +import qs.services + + +AndroidQuickToggleButton { + toggleModel: MusicRecognitionToggle {} +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidNetworkToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidNetworkToggle.qml new file mode 100644 index 0000000..d5b594e --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidNetworkToggle.qml @@ -0,0 +1,13 @@ +import qs.services +import qs.modules.common +import qs.modules.common.models.quickToggles +import qs.modules.common.functions +import qs.modules.common.widgets +import QtQuick + +AndroidQuickToggleButton { + id: root + + toggleModel: NetworkToggle {} +} + diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidNightLightToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidNightLightToggle.qml new file mode 100644 index 0000000..5681734 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidNightLightToggle.qml @@ -0,0 +1,11 @@ +import qs.modules.common +import qs.modules.common.models.quickToggles +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell + +AndroidQuickToggleButton { + toggleModel: NightLightToggle {} +} + diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidNotificationToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidNotificationToggle.qml new file mode 100644 index 0000000..544baa4 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidNotificationToggle.qml @@ -0,0 +1,11 @@ +import qs +import qs.modules.common +import qs.modules.common.models.quickToggles +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell + +AndroidQuickToggleButton { + toggleModel: NotificationToggle {} +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidOnScreenKeyboardToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidOnScreenKeyboardToggle.qml new file mode 100644 index 0000000..3c97977 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidOnScreenKeyboardToggle.qml @@ -0,0 +1,11 @@ +import qs +import qs.modules.common +import qs.modules.common.models.quickToggles +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell + +AndroidQuickToggleButton { + toggleModel: OnScreenKeyboardToggle {} +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidPowerProfileToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidPowerProfileToggle.qml new file mode 100644 index 0000000..0ac826c --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidPowerProfileToggle.qml @@ -0,0 +1,12 @@ +import qs +import qs.modules.common +import qs.modules.common.models.quickToggles +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell +import Quickshell.Services.UPower + +AndroidQuickToggleButton { + toggleModel: PowerProfilesToggle {} +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidQuickToggleButton.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidQuickToggleButton.qml new file mode 100644 index 0000000..017d02a --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidQuickToggleButton.qml @@ -0,0 +1,247 @@ +import QtQuick +import QtQuick.Layouts +import qs.services +import qs.modules.common +import qs.modules.common.models.quickToggles +import qs.modules.common.functions +import qs.modules.common.widgets + +GroupButton { + id: root + + // Info to be passed to by repeater + required property int buttonIndex + required property var buttonData + required property bool expandedSize + required property real baseCellWidth + required property real baseCellHeight + required property real cellSpacing + required property int cellSize + + // Signals + signal openMenu() + + // Declared in specific toggles + property QuickToggleModel toggleModel + property string name: toggleModel?.name ?? "" + property string statusText: (toggleModel?.hasStatusText) ? (toggleModel?.statusText || (toggled ? Translation.tr("Active") : Translation.tr("Inactive"))) : "" + property string tooltipText: toggleModel?.tooltipText ?? "" + property string buttonIcon: toggleModel?.icon ?? "close" + property bool available: toggleModel?.available ?? true + toggled: toggleModel?.toggled ?? false + property var mainAction: toggleModel?.mainAction ?? null + altAction: toggleModel?.hasMenu ? (() => root.openMenu()) : (toggleModel?.altAction ?? null) + + // Edit mode state + property bool editMode: false + + // Sizing shenanigans + baseWidth: root.baseCellWidth * cellSize + cellSpacing * (cellSize - 1) + baseHeight: root.baseCellHeight + enableImplicitWidthAnimation: !editMode && root.mouseArea.containsMouse + enableImplicitHeightAnimation: !editMode && root.mouseArea.containsMouse + Behavior on baseWidth { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on baseHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + opacity: 0 + Component.onCompleted: { + opacity = 1 + } + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + enabled: available || editMode + padding: 6 + horizontalPadding: padding + verticalPadding: padding + + colBackground: Appearance.colors.colLayer2 + colBackgroundToggled: (altAction && expandedSize) ? Appearance.colors.colLayer2 : Appearance.colors.colPrimary + colBackgroundToggledHover: (altAction && expandedSize) ? Appearance.colors.colLayer2Hover : Appearance.colors.colPrimaryHover + colBackgroundToggledActive: (altAction && expandedSize) ? Appearance.colors.colLayer2Active : Appearance.colors.colPrimaryActive + buttonRadius: toggled ? Appearance.rounding.large : height / 2 + buttonRadiusPressed: Appearance.rounding.normal + property color colText: (toggled && !(altAction && expandedSize) && enabled) ? Appearance.colors.colOnPrimary : ColorUtils.transparentize(Appearance.colors.colOnLayer2, enabled ? 0 : 0.7) + property color colIcon: expandedSize ? ((root.toggled) ? Appearance.colors.colOnPrimary : Appearance.colors.colOnLayer3) : colText + + onClicked: { + if (root.expandedSize && root.altAction) root.altAction(); + else root.mainAction(); + } + + contentItem: RowLayout { + id: contentItem + spacing: 4 + anchors { + centerIn: root.expandedSize ? undefined : parent + fill: root.expandedSize ? parent : undefined + leftMargin: root.horizontalPadding + rightMargin: root.horizontalPadding + } + + // Icon + MouseArea { + id: iconMouseArea + hoverEnabled: true + acceptedButtons: (root.expandedSize && root.altAction) ? Qt.LeftButton : Qt.NoButton + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: true + Layout.topMargin: root.verticalPadding + Layout.bottomMargin: root.verticalPadding + implicitHeight: iconBackground.implicitHeight + implicitWidth: iconBackground.implicitWidth + cursorShape: Qt.PointingHandCursor + + onClicked: root.mainAction() + + Rectangle { + id: iconBackground + anchors.fill: parent + implicitWidth: height + radius: root.radius - root.verticalPadding + color: { + const baseColor = root.toggled ? Appearance.colors.colPrimary : Appearance.colors.colLayer3 + const transparentizeAmount = (root.altAction && root.expandedSize) ? 0 : 1 + return ColorUtils.transparentize(baseColor, transparentizeAmount) + } + + Behavior on radius { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + MaterialSymbol { + anchors.centerIn: parent + fill: root.toggled ? 1 : 0 + iconSize: root.expandedSize ? 22 : 24 + color: root.colIcon + text: root.buttonIcon + } + + // State layer + Loader { + anchors.fill: parent + active: (root.expandedSize && root.altAction) + sourceComponent: Rectangle { + radius: iconBackground.radius + color: ColorUtils.transparentize(root.colIcon, iconMouseArea.containsPress ? 0.88 : iconMouseArea.containsMouse ? 0.95 : 1) + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + } + } + + // Text column for expanded size + Loader { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + visible: root.expandedSize + active: visible + sourceComponent: Column { + spacing: -2 + + StyledText { + anchors { + left: parent.left + right: parent.right + } + font.pixelSize: Appearance.font.pixelSize.smallie + font.weight: 600 + color: root.colText + elide: Text.ElideRight + text: root.name + } + + StyledText { + visible: root.statusText + anchors { + left: parent.left + right: parent.right + } + font { + pixelSize: Appearance.font.pixelSize.smaller + weight: 100 + } + color: root.colText + elide: Text.ElideRight + text: root.statusText + } + } + } + } + + MouseArea { // Blocking MouseArea for edit interactions + id: editModeInteraction + visible: root.editMode + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + acceptedButtons: Qt.AllButtons + + function toggleEnabled() { + const index = root.buttonIndex; + const toggleList = Config.options.sidebar.quickToggles.android.toggles; + const buttonType = root.buttonData.type; + if (!toggleList.find(toggle => toggle.type === buttonType)) { + toggleList.push({ type: buttonType, size: 1 }); + } else { + toggleList.splice(index, 1); + } + } + + function toggleSize() { + const index = root.buttonIndex; + const toggleList = Config.options.sidebar.quickToggles.android.toggles; + const buttonType = root.buttonData.type; + if (!toggleList.find(toggle => toggle.type === buttonType)) return; + toggleList[index].size = 3 - toggleList[index].size; // Alternate between 1 and 2 + } + + function movePositionBy(offset) { + const index = root.buttonIndex; + const toggleList = Config.options.sidebar.quickToggles.android.toggles; + const buttonType = root.buttonData.type; + const targetIndex = index + offset; + if (!toggleList.find(toggle => toggle.type === buttonType)) return; + if (targetIndex < 0 || targetIndex >= toggleList.length) return; + const temp = toggleList[index]; + toggleList[index] = toggleList[targetIndex]; + toggleList[targetIndex] = temp; + } + + onReleased: (event) => { + if (event.button === Qt.LeftButton) + toggleEnabled(); + } + onPressed: (event) => { + if (event.button === Qt.RightButton) toggleSize(); + } + onPressAndHold: (event) => { // Also toggle size + toggleSize(); + } + onWheel: (event) => { + const index = root.buttonIndex; + const toggleList = Config.options.sidebar.quickToggles.android.toggles; + const buttonType = root.buttonData.type; + if (event.angleDelta.y < 0) { // Move to right + movePositionBy(1); + } else if (event.angleDelta.y > 0) { // Move to left + movePositionBy(-1); + } + event.accepted = true; + } + } + + StyledToolTip { + extraVisibleCondition: root.tooltipText !== "" + text: root.tooltipText + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidScreenSnipToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidScreenSnipToggle.qml new file mode 100644 index 0000000..703a6ef --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidScreenSnipToggle.qml @@ -0,0 +1,12 @@ +import qs +import qs.modules.common +import qs.modules.common.models.quickToggles +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell +import Quickshell.Hyprland + +AndroidQuickToggleButton { + toggleModel: ScreenSnipToggle {} +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidToggleDelegateChooser.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidToggleDelegateChooser.qml new file mode 100644 index 0000000..0106249 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/androidStyle/AndroidToggleDelegateChooser.qml @@ -0,0 +1,263 @@ +pragma ComponentBehavior: Bound +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Bluetooth + +DelegateChooser { + id: root + property bool editMode: false + required property real baseCellWidth + required property real baseCellHeight + required property real spacing + required property int startingIndex + signal openAudioOutputDialog() + signal openAudioInputDialog() + signal openBluetoothDialog() + signal openNightLightDialog() + signal openWifiDialog() + + role: "type" + + DelegateChoice { roleValue: "antiFlashbang"; AndroidAntiFlashbangToggle { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + onOpenMenu: { + root.openNightLightDialog() + } + } } + + DelegateChoice { roleValue: "audio"; AndroidAudioToggle { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + onOpenMenu: { + root.openAudioOutputDialog() + } + } } + + DelegateChoice { roleValue: "bluetooth"; AndroidBluetoothToggle { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + onOpenMenu: { + root.openBluetoothDialog() + } + } } + + DelegateChoice { roleValue: "cloudflareWarp"; AndroidCloudflareWarpToggle { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + } } + + DelegateChoice { roleValue: "colorPicker"; AndroidColorPickerToggle { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + } } + + DelegateChoice { roleValue: "darkMode"; AndroidDarkModeToggle { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + } } + + DelegateChoice { roleValue: "easyEffects"; AndroidEasyEffectsToggle { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + } } + + DelegateChoice { roleValue: "gameMode"; AndroidGameModeToggle { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + } } + + DelegateChoice { roleValue: "idleInhibitor"; AndroidIdleInhibitorToggle { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + } } + + DelegateChoice { roleValue: "mic"; AndroidMicToggle { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + onOpenMenu: { + root.openAudioInputDialog() + } + } } + + DelegateChoice { roleValue: "musicRecognition"; AndroidMusicRecognition { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + } } + + DelegateChoice { roleValue: "network"; AndroidNetworkToggle { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + onOpenMenu: { + root.openWifiDialog() + } + } } + + DelegateChoice { roleValue: "nightLight"; AndroidNightLightToggle { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + onOpenMenu: { + root.openNightLightDialog() + } + } } + + DelegateChoice { roleValue: "notifications"; AndroidNotificationToggle { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + } } + + DelegateChoice { roleValue: "onScreenKeyboard"; AndroidOnScreenKeyboardToggle { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + } } + + DelegateChoice { roleValue: "powerProfile"; AndroidPowerProfileToggle { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + } } + + DelegateChoice { roleValue: "screenSnip"; AndroidScreenSnipToggle { + required property int index + required property var modelData + buttonIndex: root.startingIndex + index + buttonData: modelData + editMode: root.editMode + expandedSize: modelData.size > 1 + baseCellWidth: root.baseCellWidth + baseCellHeight: root.baseCellHeight + cellSpacing: root.spacing + cellSize: modelData.size + } } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/BluetoothToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/BluetoothToggle.qml new file mode 100644 index 0000000..8dc558e --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/BluetoothToggle.qml @@ -0,0 +1,30 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Io +import Quickshell.Hyprland + +QuickToggleButton { + id: root + visible: BluetoothStatus.available + toggled: BluetoothStatus.enabled + buttonIcon: BluetoothStatus.connected ? "bluetooth_connected" : BluetoothStatus.enabled ? "bluetooth" : "bluetooth_disabled" + onClicked: { + Bluetooth.defaultAdapter.enabled = !Bluetooth.defaultAdapter?.enabled + } + altAction: () => { + Quickshell.execDetached(["bash", "-c", `${Config.options.apps.bluetooth}`]) + GlobalStates.sidebarRightOpen = false + } + StyledToolTip { + text: Translation.tr("%1 | Right-click to configure").arg( + (BluetoothStatus.firstActiveDevice?.name ?? Translation.tr("Bluetooth")) + + (BluetoothStatus.activeDeviceCount > 1 ? ` +${BluetoothStatus.activeDeviceCount - 1}` : "") + ) + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/CloudflareWarp.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/CloudflareWarp.qml new file mode 100644 index 0000000..d12394a --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/CloudflareWarp.qml @@ -0,0 +1,92 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell.Io +import Quickshell + +QuickToggleButton { + id: root + toggled: false + visible: false + + contentItem: CustomIcon { + id: distroIcon + source: 'cloudflare-dns-symbolic' + + anchors.centerIn: parent + width: 16 + height: 16 + colorize: true + color: root.toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer1 + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + + onClicked: { + if (toggled) { + root.toggled = false + Quickshell.execDetached(["warp-cli", "disconnect"]) + } else { + root.toggled = true + Quickshell.execDetached(["warp-cli", "connect"]) + } + } + + Process { + id: connectProc + command: ["warp-cli", "connect"] + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + Quickshell.execDetached(["notify-send", + Translation.tr("Cloudflare WARP"), + Translation.tr("Connection failed. Please inspect manually with the warp-cli command") + , "-a", "Shell" + ]) + } + } + } + + Process { + id: registrationProc + command: ["warp-cli", "registration", "new"] + onExited: (exitCode, exitStatus) => { + console.log("Warp registration exited with code and status:", exitCode, exitStatus) + if (exitCode === 0) { + connectProc.running = true + } else { + Quickshell.execDetached(["notify-send", + Translation.tr("Cloudflare WARP"), + Translation.tr("Registration failed. Please inspect manually with the warp-cli command"), + "-a", "Shell" + ]) + } + } + } + + Process { + id: fetchActiveState + running: true + command: ["bash", "-c", "warp-cli status"] + stdout: StdioCollector { + id: warpStatusCollector + onStreamFinished: { + if (warpStatusCollector.text.length > 0) { + root.visible = true + } + if (warpStatusCollector.text.includes("Unable")) { + registrationProc.running = true + } else if (warpStatusCollector.text.includes("Connected")) { + root.toggled = true + } else if (warpStatusCollector.text.includes("Disconnected")) { + root.toggled = false + } + } + } + } + StyledToolTip { + text: Translation.tr("Cloudflare WARP (1.1.1.1)") + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/EasyEffectsToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/EasyEffectsToggle.qml new file mode 100644 index 0000000..0bbe22d --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/EasyEffectsToggle.qml @@ -0,0 +1,31 @@ +import qs.modules.common.widgets +import qs +import qs.services +import QtQuick +import Quickshell.Io +import Quickshell +import Quickshell.Hyprland + +QuickToggleButton { + id: root + visible: EasyEffects.available + toggled: EasyEffects.active + buttonIcon: "instant_mix" + + Component.onCompleted: { + EasyEffects.fetchActiveState() + } + + onClicked: { + EasyEffects.toggle() + } + + altAction: () => { + Quickshell.execDetached(["bash", "-c", "flatpak run com.github.wwmm.easyeffects || easyeffects"]) + GlobalStates.sidebarRightOpen = false + } + + StyledToolTip { + text: Translation.tr("EasyEffects | Right-click to configure") + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/GameMode.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/GameMode.qml new file mode 100644 index 0000000..a64c336 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/GameMode.qml @@ -0,0 +1,31 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import Quickshell +import Quickshell.Io + +QuickToggleButton { + id: root + buttonIcon: "gamepad" + toggled: toggled + + onClicked: { + root.toggled = !root.toggled + if (root.toggled) { + Quickshell.execDetached(["bash", "-c", `hyprctl --batch "keyword animations:enabled 0; keyword decoration:shadow:enabled 0; keyword decoration:blur:enabled 0; keyword general:gaps_in 0; keyword general:gaps_out 0; keyword general:border_size 1; keyword decoration:rounding 0; keyword general:allow_tearing 1"`]) + } else { + Quickshell.execDetached(["hyprctl", "reload"]) + } + } + Process { + id: fetchActiveState + running: true + command: ["bash", "-c", `test "$(hyprctl getoption animations:enabled -j | jq ".int")" -ne 0`] + onExited: (exitCode, exitStatus) => { + root.toggled = exitCode !== 0 // Inverted because enabled = nonzero exit + } + } + StyledToolTip { + text: Translation.tr("Game mode") + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/IdleInhibitor.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/IdleInhibitor.qml new file mode 100644 index 0000000..99bd5be --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/IdleInhibitor.qml @@ -0,0 +1,15 @@ +import qs.modules.common.widgets +import qs.services + +QuickToggleButton { + id: root + toggled: Idle.inhibit + buttonIcon: "coffee" + onClicked: { + Idle.toggleInhibit() + } + StyledToolTip { + text: Translation.tr("Keep system awake") + } + +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/NetworkToggle.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/NetworkToggle.qml new file mode 100644 index 0000000..63fdb85 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/NetworkToggle.qml @@ -0,0 +1,23 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import qs.modules.ii.sidebarRight.quickToggles +import qs +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +QuickToggleButton { + toggled: Network.wifiStatus !== "disabled" + buttonIcon: Network.materialSymbol + onClicked: Network.toggleWifi() + altAction: () => { + Quickshell.execDetached(["bash", "-c", `${Network.ethernet ? Config.options.apps.networkEthernet : Config.options.apps.network}`]) + GlobalStates.sidebarRightOpen = false + } + StyledToolTip { + text: Translation.tr("%1 | Right-click to configure").arg(Network.networkName) + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/NightLight.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/NightLight.qml new file mode 100644 index 0000000..8b7e69a --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/NightLight.qml @@ -0,0 +1,26 @@ +import QtQuick +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import Quickshell.Io + +QuickToggleButton { + id: nightLightButton + toggled: Hyprsunset.active + buttonIcon: Config.options.light.night.automatic ? "night_sight_auto" : "bedtime" + onClicked: { + Hyprsunset.toggle() + } + + altAction: () => { + Config.options.light.night.automatic = !Config.options.light.night.automatic + } + + Component.onCompleted: { + Hyprsunset.fetchState() + } + + StyledToolTip { + text: Translation.tr("Night Light | Right-click to toggle Auto mode") + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/QuickToggleButton.qml b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/QuickToggleButton.qml new file mode 100644 index 0000000..11ca7cb --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/quickToggles/classicStyle/QuickToggleButton.qml @@ -0,0 +1,29 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +GroupButton { + id: button + property string buttonIcon + baseWidth: 40 + baseHeight: 40 + clickedWidth: baseWidth + 20 + toggled: false + buttonRadius: (altAction && toggled) ? Appearance?.rounding.normal : Math.min(baseHeight, baseWidth) / 2 + buttonRadiusPressed: Appearance?.rounding?.small + + contentItem: MaterialSymbol { + anchors.centerIn: parent + iconSize: 22 + fill: toggled ? 1 : 0 + color: toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer1 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: buttonIcon + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/todo/TaskList.qml b/modules/quickshell/config/modules/ii/sidebarRight/todo/TaskList.qml new file mode 100644 index 0000000..b48352f --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/todo/TaskList.qml @@ -0,0 +1,141 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +Item { + id: root + required property var taskList + property string emptyPlaceholderIcon + property string emptyPlaceholderText + property int todoListItemSpacing: 5 + property int todoListItemPadding: 8 + property int listBottomPadding: 80 + + StyledListView { + id: listView + anchors.fill: parent + spacing: root.todoListItemSpacing + animateAppearance: false + model: ScriptModel { + values: root.taskList + } + delegate: Item { + id: todoItem + required property var modelData + property bool pendingDoneToggle: false + property bool pendingDelete: false + property bool enableHeightAnimation: false + + implicitHeight: todoItemRectangle.implicitHeight + width: ListView.view.width + clip: true + + Behavior on implicitHeight { + enabled: enableHeightAnimation + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + Rectangle { + id: todoItemRectangle + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + implicitHeight: todoContentRowLayout.implicitHeight + color: Appearance.colors.colLayer2 + radius: Appearance.rounding.small + + ColumnLayout { + id: todoContentRowLayout + anchors.left: parent.left + anchors.right: parent.right + + StyledText { + id: todoContentText + Layout.fillWidth: true // Needed for wrapping + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.topMargin: todoListItemPadding + text: todoItem.modelData.content + wrapMode: Text.Wrap + } + RowLayout { + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.bottomMargin: todoListItemPadding + Item { + Layout.fillWidth: true + } + TodoItemActionButton { + Layout.fillWidth: false + onClicked: { + if (!todoItem.modelData.done) + Todo.markDone(todoItem.modelData.originalIndex); + else + Todo.markUnfinished(todoItem.modelData.originalIndex); + } + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: todoItem.modelData.done ? "remove_done" : "check" + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer1 + } + } + TodoItemActionButton { + Layout.fillWidth: false + onClicked: { + Todo.deleteItem(todoItem.modelData.originalIndex); + } + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "delete_forever" + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer1 + } + } + } + } + } + } + } + + Item { + // Placeholder when list is empty + visible: opacity > 0 + opacity: taskList.length === 0 ? 1 : 0 + anchors.fill: parent + + Behavior on opacity { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 55 + color: Appearance.m3colors.m3outline + text: emptyPlaceholderIcon + } + StyledText { + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: emptyPlaceholderText + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/todo/TodoItemActionButton.qml b/modules/quickshell/config/modules/ii/sidebarRight/todo/TodoItemActionButton.qml new file mode 100644 index 0000000..d2fe0ab --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/todo/TodoItemActionButton.qml @@ -0,0 +1,32 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +RippleButton { + id: button + property string buttonText: "" + property string tooltipText: "" + + implicitHeight: 30 + implicitWidth: implicitHeight + + Behavior on implicitWidth { + SmoothedAnimation { + velocity: Appearance.animation.elementMove.velocity + } + } + + buttonRadius: Appearance.rounding.small + + contentItem: StyledText { + text: buttonText + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer1 + } + + StyledToolTip { + text: tooltipText + extraVisibleCondition: tooltipText.length > 0 + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/ii/sidebarRight/todo/TodoWidget.qml b/modules/quickshell/config/modules/ii/sidebarRight/todo/TodoWidget.qml new file mode 100644 index 0000000..36c8855 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/todo/TodoWidget.qml @@ -0,0 +1,219 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + property var tabButtonList: [{"icon": "checklist", "name": Translation.tr("Unfinished")}, {"name": Translation.tr("Done"), "icon": "check_circle"}] + property bool showAddDialog: false + property int dialogMargins: 20 + property int fabSize: 48 + property int fabMargins: 14 + + Keys.onPressed: (event) => { + if ((event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) && event.modifiers === Qt.NoModifier) { + if (event.key === Qt.Key_PageDown) { + tabBar.incrementCurrentIndex(); + } else if (event.key === Qt.Key_PageUp) { + tabBar.decrementCurrentIndex(); + } + event.accepted = true; + } + // Open add dialog on "N" (any modifiers) + else if (event.key === Qt.Key_N) { + root.showAddDialog = true + event.accepted = true; + } + // Close dialog on Esc if open + else if (event.key === Qt.Key_Escape && root.showAddDialog) { + root.showAddDialog = false + event.accepted = true; + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + SecondaryTabBar { + id: tabBar + currentIndex: swipeView.currentIndex + + Repeater { + model: root.tabButtonList + delegate: SecondaryTabButton { + buttonText: modelData.name + buttonIcon: modelData.icon + } + } + } + + SwipeView { + id: swipeView + Layout.topMargin: 10 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + clip: true + currentIndex: tabBar.currentIndex + + // To Do tab + TaskList { + listBottomPadding: root.fabSize + root.fabMargins * 2 + emptyPlaceholderIcon: "check_circle" + emptyPlaceholderText: Translation.tr("Nothing here!") + taskList: Todo.list + .map(function(item, i) { return Object.assign({}, item, {originalIndex: i}); }) + .filter(function(item) { return !item.done; }) + } + TaskList { + listBottomPadding: root.fabSize + root.fabMargins * 2 + emptyPlaceholderIcon: "checklist" + emptyPlaceholderText: Translation.tr("Finished tasks will go here") + taskList: Todo.list + .map(function(item, i) { return Object.assign({}, item, {originalIndex: i}); }) + .filter(function(item) { return item.done; }) + } + + } + } + + // + FAB + StyledRectangularShadow { + target: fabButton + radius: fabButton.buttonRadius + blur: 0.6 * Appearance.sizes.elevationMargin + } + FloatingActionButton { + id: fabButton + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: root.fabMargins + anchors.bottomMargin: root.fabMargins + + onClicked: root.showAddDialog = true + iconText: "add" + } + + Item { + anchors.fill: parent + z: 9999 + + visible: opacity > 0 + opacity: root.showAddDialog ? 1 : 0 + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + onVisibleChanged: { + if (!visible) { + todoInput.text = "" + fabButton.focus = true + } + } + + Rectangle { // Scrim + anchors.fill: parent + radius: Appearance.rounding.small + color: Appearance.colors.colScrim + MouseArea { + hoverEnabled: true + anchors.fill: parent + preventStealing: true + propagateComposedEvents: false + } + } + + Rectangle { // The dialog + id: dialog + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: root.dialogMargins + implicitHeight: dialogColumnLayout.implicitHeight + + color: Appearance.colors.colSurfaceContainerHigh + radius: Appearance.rounding.normal + + function addTask() { + if (todoInput.text.length > 0) { + Todo.addTask(todoInput.text) + todoInput.text = "" + root.showAddDialog = false + tabBar.setCurrentIndex(0) // Show unfinished tasks + } + } + + ColumnLayout { + id: dialogColumnLayout + anchors.fill: parent + spacing: 16 + + StyledText { + Layout.topMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.alignment: Qt.AlignLeft + color: Appearance.m3colors.m3onSurface + font.pixelSize: Appearance.font.pixelSize.larger + text: Translation.tr("Add task") + } + + TextField { + id: todoInput + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + padding: 10 + color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant + renderType: Text.NativeRendering + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + placeholderText: Translation.tr("Task description") + placeholderTextColor: Appearance.m3colors.m3outline + focus: root.showAddDialog + onAccepted: dialog.addTask() + + background: Rectangle { + anchors.fill: parent + radius: Appearance.rounding.verysmall + border.width: 2 + border.color: todoInput.activeFocus ? Appearance.colors.colPrimary : Appearance.m3colors.m3outline + color: "transparent" + } + + cursorDelegate: Rectangle { + width: 1 + color: todoInput.activeFocus ? Appearance.colors.colPrimary : "transparent" + radius: 1 + } + } + + RowLayout { + Layout.bottomMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.alignment: Qt.AlignRight + spacing: 5 + + DialogButton { + buttonText: Translation.tr("Cancel") + onClicked: root.showAddDialog = false + } + DialogButton { + buttonText: Translation.tr("Add") + enabled: todoInput.text.length > 0 + onClicked: dialog.addTask() + } + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml b/modules/quickshell/config/modules/ii/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml new file mode 100644 index 0000000..7857f29 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml @@ -0,0 +1,55 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Pipewire + +RippleButton { + id: button + required property bool input + + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colLayer2 + colBackgroundHover: Appearance.colors.colLayer2Hover + colRipple: Appearance.colors.colLayer2Active + + implicitHeight: contentItem.implicitHeight + 6 * 2 + implicitWidth: contentItem.implicitWidth + 6 * 2 + + contentItem: RowLayout { + anchors.fill: parent + anchors.margins: 5 + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: false + Layout.leftMargin: 5 + color: Appearance.colors.colOnLayer2 + iconSize: Appearance.font.pixelSize.hugeass + text: input ? "mic_external_on" : "media_output" + } + + ColumnLayout { + Layout.fillWidth: true + Layout.rightMargin: 5 + spacing: 0 + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pixelSize: Appearance.font.pixelSize.normal + text: input ? Translation.tr("Input") : Translation.tr("Output") + color: Appearance.colors.colOnLayer2 + } + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pixelSize: Appearance.font.pixelSize.smaller + text: (input ? Pipewire.defaultAudioSource?.description : Pipewire.defaultAudioSink?.description) ?? Translation.tr("Unknown") + color: Appearance.m3colors.m3outline + animateChange: true + } + } + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/ii/sidebarRight/volumeMixer/VolumeDialog.qml b/modules/quickshell/config/modules/ii/sidebarRight/volumeMixer/VolumeDialog.qml new file mode 100644 index 0000000..bb802b7 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/volumeMixer/VolumeDialog.qml @@ -0,0 +1,48 @@ +pragma ComponentBehavior: Bound +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire + +WindowDialog { + id: root + property bool isSink: true + backgroundHeight: 600 + + WindowDialogTitle { + text: root.isSink ? Translation.tr("Audio output") : Translation.tr("Audio input") + } + + WindowDialogSeparator { + Layout.topMargin: -22 + Layout.leftMargin: 0 + Layout.rightMargin: 0 + } + + VolumeDialogContent { + isSink: root.isSink + } + + WindowDialogButtonRow { + DialogButton { + buttonText: Translation.tr("Details") + onClicked: { + Quickshell.execDetached(["bash", "-c", `${Config.options.apps.volumeMixer}`]); + GlobalStates.sidebarRightOpen = false; + } + } + + Item { + Layout.fillWidth: true + } + + DialogButton { + buttonText: Translation.tr("Done") + onClicked: root.dismiss() + } + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml b/modules/quickshell/config/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml new file mode 100644 index 0000000..f7c2dc9 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml @@ -0,0 +1,79 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire + +ColumnLayout { + id: root + required property bool isSink + readonly property list appPwNodes: isSink ? Audio.outputAppNodes : Audio.inputAppNodes + readonly property list devices: isSink ? Audio.outputDevices : Audio.inputDevices + readonly property bool hasApps: appPwNodes.length > 0 + spacing: 16 + + DialogSectionListView { + Layout.fillHeight: true + topMargin: 14 + + model: ScriptModel { + values: root.appPwNodes + } + delegate: VolumeMixerEntry { + anchors { + left: parent?.left + right: parent?.right + } + required property var modelData + node: modelData + } + } + + StyledComboBox { + id: deviceSelector + Layout.fillHeight: false + Layout.fillWidth: true + Layout.bottomMargin: 6 + model: root.devices.map(node => Audio.friendlyDeviceName(node)) + currentIndex: root.devices.findIndex(item => { + if (root.isSink) { + return item.id === Pipewire.defaultAudioSink?.id + } else { + return item.id === Pipewire.defaultAudioSource?.id + } + }) + onActivated: (index) => { + print(index) + const item = root.devices[index] + if (root.isSink) { + Audio.setDefaultSink(item) + } else { + Audio.setDefaultSource(item) + } + } + } + + component DialogSectionListView: StyledListView { + Layout.fillWidth: true + Layout.topMargin: -22 + Layout.bottomMargin: -16 + Layout.leftMargin: -Appearance.rounding.large + Layout.rightMargin: -Appearance.rounding.large + topMargin: 12 + bottomMargin: 12 + leftMargin: 20 + rightMargin: 20 + + clip: true + spacing: 4 + animateAppearance: false + } + + Component { + id: listElementComp + ListElement {} + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/volumeMixer/VolumeMixerEntry.qml b/modules/quickshell/config/modules/ii/sidebarRight/volumeMixer/VolumeMixerEntry.qml new file mode 100644 index 0000000..a871c05 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/volumeMixer/VolumeMixerEntry.qml @@ -0,0 +1,64 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire + +Item { + id: root + required property PwNode node + PwObjectTracker { + objects: [root.node] + } + + implicitHeight: rowLayout.implicitHeight + + RowLayout { + id: rowLayout + anchors.fill: parent + spacing: 6 + + Image { + property real size: 36 + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + visible: source != "" + sourceSize.width: size + sourceSize.height: size + source: { + let icon; + icon = AppSearch.guessIcon(root.node?.properties["application.icon-name"] ?? ""); + if (AppSearch.iconExists(icon)) + return Quickshell.iconPath(icon, "image-missing"); + icon = AppSearch.guessIcon(root.node?.properties["node.name"] ?? ""); + return Quickshell.iconPath(icon, "image-missing"); + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: -4 + + StyledText { + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + elide: Text.ElideRight + text: { + // application.name -> description -> name + const app = Audio.appNodeDisplayName(root.node); + const media = root.node.properties["media.name"]; + return media != undefined ? `${app} โ€ข ${media}` : app; + } + } + + StyledSlider { + id: slider + value: root.node?.audio.volume ?? 0 + onMoved: root.node.audio.volume = value + configuration: StyledSlider.Configuration.S + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/sidebarRight/wifiNetworks/WifiDialog.qml b/modules/quickshell/config/modules/ii/sidebarRight/wifiNetworks/WifiDialog.qml new file mode 100644 index 0000000..f1de76a --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/wifiNetworks/WifiDialog.qml @@ -0,0 +1,67 @@ +import qs +import qs.services +import qs.services.network +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell + +WindowDialog { + id: root + backgroundHeight: 600 + + WindowDialogTitle { + text: Translation.tr("Connect to Wi-Fi") + } + WindowDialogSeparator { + visible: !Network.wifiScanning + } + StyledIndeterminateProgressBar { + visible: Network.wifiScanning + Layout.fillWidth: true + Layout.topMargin: -8 + Layout.bottomMargin: -8 + Layout.leftMargin: -Appearance.rounding.large + Layout.rightMargin: -Appearance.rounding.large + } + ListView { + Layout.fillHeight: true + Layout.fillWidth: true + Layout.topMargin: -15 + Layout.bottomMargin: -16 + Layout.leftMargin: -Appearance.rounding.large + Layout.rightMargin: -Appearance.rounding.large + + clip: true + spacing: 0 + + model: ScriptModel { + values: Network.friendlyWifiNetworks + } + delegate: WifiNetworkItem { + required property WifiAccessPoint modelData + wifiNetwork: modelData + width: ListView.view.width + } + } + WindowDialogSeparator {} + WindowDialogButtonRow { + DialogButton { + buttonText: Translation.tr("Details") + onClicked: { + Quickshell.execDetached(["bash", "-c", `${Network.ethernet ? Config.options.apps.networkEthernet : Config.options.apps.network}`]); + GlobalStates.sidebarRightOpen = false; + } + } + + Item { + Layout.fillWidth: true + } + + DialogButton { + buttonText: Translation.tr("Done") + onClicked: root.dismiss() + } + } +} \ No newline at end of file diff --git a/modules/quickshell/config/modules/ii/sidebarRight/wifiNetworks/WifiNetworkItem.qml b/modules/quickshell/config/modules/ii/sidebarRight/wifiNetworks/WifiNetworkItem.qml new file mode 100644 index 0000000..9f75224 --- /dev/null +++ b/modules/quickshell/config/modules/ii/sidebarRight/wifiNetworks/WifiNetworkItem.qml @@ -0,0 +1,118 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs.services.network +import QtQuick +import QtQuick.Layouts + +DialogListItem { + id: root + required property WifiAccessPoint wifiNetwork + enabled: !(Network.wifiConnectTarget === root.wifiNetwork && !wifiNetwork?.active) + + active: (wifiNetwork?.askingPassword || wifiNetwork?.active) ?? false + onClicked: { + Network.connectToWifiNetwork(wifiNetwork); + } + + contentItem: ColumnLayout { + anchors { + fill: parent + topMargin: root.verticalPadding + bottomMargin: root.verticalPadding + leftMargin: root.horizontalPadding + rightMargin: root.horizontalPadding + } + spacing: 0 + + RowLayout { + // Name + spacing: 10 + MaterialSymbol { + iconSize: Appearance.font.pixelSize.larger + property int strength: root.wifiNetwork?.strength ?? 0 + text: strength > 80 ? "signal_wifi_4_bar" : strength > 60 ? "network_wifi_3_bar" : strength > 40 ? "network_wifi_2_bar" : strength > 20 ? "network_wifi_1_bar" : "signal_wifi_0_bar" + color: Appearance.colors.colOnSurfaceVariant + } + StyledText { + Layout.fillWidth: true + color: Appearance.colors.colOnSurfaceVariant + elide: Text.ElideRight + text: root.wifiNetwork?.ssid ?? Translation.tr("Unknown") + } + MaterialSymbol { + visible: (root.wifiNetwork?.isSecure || root.wifiNetwork?.active) ?? false + text: root.wifiNetwork?.active ? "check" : Network.wifiConnectTarget === root.wifiNetwork ? "settings_ethernet" : "lock" + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnSurfaceVariant + } + } + + ColumnLayout { // Password + id: passwordPrompt + Layout.topMargin: 8 + visible: root.wifiNetwork?.askingPassword ?? false + + MaterialTextField { + id: passwordField + Layout.fillWidth: true + placeholderText: Translation.tr("Password") + + // Password + echoMode: TextInput.Password + inputMethodHints: Qt.ImhSensitiveData + + onAccepted: { + Network.changePassword(root.wifiNetwork, passwordField.text); + } + } + + RowLayout { + Layout.fillWidth: true + + Item { + Layout.fillWidth: true + } + + DialogButton { + buttonText: Translation.tr("Cancel") + onClicked: { + root.wifiNetwork.askingPassword = false; + } + } + + DialogButton { + buttonText: Translation.tr("Connect") + onClicked: { + Network.changePassword(root.wifiNetwork, passwordField.text); + } + } + } + } + + ColumnLayout { // Public wifi login page + id: publicWifiPortal + Layout.topMargin: 8 + visible: (root.wifiNetwork?.active && (root.wifiNetwork?.security ?? "").trim().length === 0) ?? false + + RowLayout { + DialogButton { + Layout.fillWidth: true + buttonText: Translation.tr("Open network portal") + colBackground: Appearance.colors.colLayer4 + colBackgroundHover: Appearance.colors.colLayer4Hover + colRipple: Appearance.colors.colLayer4Active + onClicked: { + Network.openPublicWifiPortal() + GlobalStates.sidebarRightOpen = false + } + } + } + } + + Item { + Layout.fillHeight: true + } + } +} diff --git a/modules/quickshell/config/modules/ii/verticalBar/BatteryIndicator.qml b/modules/quickshell/config/modules/ii/verticalBar/BatteryIndicator.qml new file mode 100644 index 0000000..3f20cbd --- /dev/null +++ b/modules/quickshell/config/modules/ii/verticalBar/BatteryIndicator.qml @@ -0,0 +1,64 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts +import qs.modules.ii.bar as Bar + +MouseArea { + id: root + property bool borderless: Config.options.bar.borderless + readonly property var chargeState: Battery.chargeState + readonly property bool isCharging: Battery.isCharging + readonly property bool isPluggedIn: Battery.isPluggedIn + readonly property real percentage: Battery.percentage + readonly property bool isLow: percentage <= Config.options.battery.low / 100 + + implicitHeight: batteryProgress.implicitHeight + hoverEnabled: !Config.options.bar.tooltips.clickToShow + + ClippedProgressBar { + id: batteryProgress + anchors.centerIn: parent + vertical: true + valueBarWidth: 21 + valueBarHeight: 40 + value: percentage + highlightColor: (isLow && !isCharging) ? Appearance.m3colors.m3error : Appearance.colors.colOnSecondaryContainer + + font { + pixelSize: text.length > 2 ? 11 : 13 + weight: text.length > 2 ? Font.Medium : Font.DemiBold + } + + textMask: Item { + anchors.centerIn: parent + width: batteryProgress.valueBarWidth + height: batteryProgress.valueBarHeight + + ColumnLayout { + anchors.centerIn: parent + spacing: 0 + + MaterialSymbol { + id: boltIcon + Layout.alignment: Qt.AlignHCenter + fill: 1 + text: isCharging ? "bolt" : "battery_android_full" + iconSize: Appearance.font.pixelSize.normal + animateChange: true + } + StyledText { + Layout.alignment: Qt.AlignHCenter + font: batteryProgress.font + text: batteryProgress.text + } + } + } + } + + Bar.BatteryPopup { + id: batteryPopup + hoverTarget: root + } +} diff --git a/modules/quickshell/config/modules/ii/verticalBar/Resource.qml b/modules/quickshell/config/modules/ii/verticalBar/Resource.qml new file mode 100644 index 0000000..fe128c2 --- /dev/null +++ b/modules/quickshell/config/modules/ii/verticalBar/Resource.qml @@ -0,0 +1,40 @@ +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import QtQuick + +Item { + id: root + required property string iconName + required property double percentage + property int warningThreshold: 100 + implicitHeight: resourceProgress.implicitHeight + implicitWidth: Appearance.sizes.verticalBarWidth + + property bool warning: percentage * 100 >= warningThreshold + + ClippedFilledCircularProgress { + id: resourceProgress + anchors.centerIn: parent + value: percentage + enableAnimation: false + colPrimary: root.warning ? Appearance.colors.colError : Appearance.colors.colOnSecondaryContainer + accountForLightBleeding: !root.warning + + MaterialSymbol { + font.weight: Font.Medium + fill: 1 + text: root.iconName + iconSize: 13 + color: Appearance.colors.colOnSecondaryContainer + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + enabled: root.visible + } +} diff --git a/modules/quickshell/config/modules/ii/verticalBar/Resources.qml b/modules/quickshell/config/modules/ii/verticalBar/Resources.qml new file mode 100644 index 0000000..1c38157 --- /dev/null +++ b/modules/quickshell/config/modules/ii/verticalBar/Resources.qml @@ -0,0 +1,45 @@ +import qs.services +import qs.modules.common +import QtQuick +import QtQuick.Layouts +import qs.modules.ii.bar as Bar + +MouseArea { + id: root + property bool alwaysShowAllResources: false + implicitHeight: columnLayout.implicitHeight + implicitWidth: columnLayout.implicitWidth + hoverEnabled: !Config.options.bar.tooltips.clickToShow + + ColumnLayout { + id: columnLayout + spacing: 10 + anchors.fill: parent + + Resource { + Layout.alignment: Qt.AlignHCenter + iconName: "memory" + percentage: ResourceUsage.memoryUsedPercentage + warningThreshold: Config.options.bar.resources.memoryWarningThreshold + } + + Resource { + Layout.alignment: Qt.AlignHCenter + iconName: "swap_horiz" + percentage: ResourceUsage.swapUsedPercentage + warningThreshold: Config.options.bar.resources.swapWarningThreshold + } + + Resource { + Layout.alignment: Qt.AlignHCenter + iconName: "planner_review" + percentage: ResourceUsage.cpuUsage + warningThreshold: Config.options.bar.resources.cpuWarningThreshold + } + + } + + Bar.ResourcesPopup { + hoverTarget: root + } +} diff --git a/modules/quickshell/config/modules/ii/verticalBar/VerticalBar.qml b/modules/quickshell/config/modules/ii/verticalBar/VerticalBar.qml new file mode 100644 index 0000000..3851f06 --- /dev/null +++ b/modules/quickshell/config/modules/ii/verticalBar/VerticalBar.qml @@ -0,0 +1,245 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland +import Quickshell.Services.UPower +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +Scope { + id: bar + property bool showBarBackground: Config.options.bar.showBackground + + Variants { + // For each monitor + model: { + const screens = Quickshell.screens; + const list = Config.options.bar.screenList; + if (!list || list.length === 0) + return screens; + return screens.filter(screen => list.includes(screen.name)); + } + LazyLoader { + id: barLoader + active: GlobalStates.barOpen && !GlobalStates.screenLocked + required property ShellScreen modelData + component: PanelWindow { // Bar window + id: barRoot + screen: barLoader.modelData + + property var brightnessMonitor: Brightness.getMonitorForScreen(barLoader.modelData) + + Timer { + id: showBarTimer + interval: (Config?.options.bar.autoHide.showWhenPressingSuper.delay ?? 100) + repeat: false + onTriggered: { + barRoot.superShow = true + } + } + Connections { + target: GlobalStates + function onSuperDownChanged() { + if (!Config?.options.bar.autoHide.showWhenPressingSuper.enable) return; + if (GlobalStates.superDown) showBarTimer.restart(); + else { + showBarTimer.stop(); + barRoot.superShow = false; + } + } + } + property bool superShow: false + property bool mustShow: hoverRegion.containsMouse || superShow + exclusionMode: ExclusionMode.Ignore + exclusiveZone: (Config?.options.bar.autoHide.enable && (!mustShow || !Config?.options.bar.autoHide.pushWindows)) ? 0 : + Appearance.sizes.baseVerticalBarWidth + (Config.options.bar.cornerStyle === 1 ? Appearance.sizes.hyprlandGapsOut : 0) + WlrLayershell.namespace: "quickshell:verticalBar" + // WlrLayershell.layer: WlrLayer.Overlay // TODO enable this when bar can hide when fullscreen + implicitWidth: Appearance.sizes.verticalBarWidth + Appearance.rounding.screenRounding + mask: Region { + item: hoverMaskRegion + } + color: "transparent" + + anchors { + left: !Config.options.bar.bottom + right: Config.options.bar.bottom + top: true + bottom: true + } + + MouseArea { + id: hoverRegion + hoverEnabled: true + anchors.fill: parent + + Item { + id: hoverMaskRegion + anchors { + fill: barContent + leftMargin: -Config.options.bar.autoHide.hoverRegionWidth + rightMargin: -Config.options.bar.autoHide.hoverRegionWidth + } + } + + VerticalBarContent { + id: barContent + + implicitWidth: Appearance.sizes.verticalBarWidth + anchors { + top: parent.top + bottom: parent.bottom + left: parent.left + right: undefined + leftMargin: (Config?.options.bar.autoHide.enable && !mustShow) ? -Appearance.sizes.verticalBarWidth : 0 + rightMargin: 0 + } + Behavior on anchors.leftMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on anchors.rightMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + states: State { + name: "right" + when: Config.options.bar.bottom + AnchorChanges { + target: barContent + anchors { + top: parent.top + bottom: parent.bottom + left: undefined + right: parent.right + } + } + PropertyChanges { + target: barContent + anchors.topMargin: 0 + anchors.rightMargin: (Config?.options.bar.autoHide.enable && !mustShow) ? -Appearance.sizes.barHeight : 0 + } + } + } + + // Round decorators + Loader { + id: roundDecorators + anchors { + top: parent.top + bottom: parent.bottom + left: barContent.right + right: undefined + } + width: Appearance.rounding.screenRounding + active: showBarBackground && Config.options.bar.cornerStyle === 0 // Hug + + states: State { + name: "right" + when: Config.options.bar.bottom + AnchorChanges { + target: roundDecorators + anchors { + top: parent.top + bottom: parent.bottom + left: undefined + right: barContent.left + } + } + } + + sourceComponent: Item { + implicitHeight: Appearance.rounding.screenRounding + RoundCorner { + id: topCorner + anchors { + left: parent.left + right: parent.right + top: parent.top + } + + implicitSize: Appearance.rounding.screenRounding + color: showBarBackground ? Appearance.colors.colLayer0 : "transparent" + + corner: RoundCorner.CornerEnum.TopLeft + states: State { + name: "bottom" + when: Config.options.bar.bottom + PropertyChanges { + topCorner.corner: RoundCorner.CornerEnum.TopRight + } + } + } + RoundCorner { + id: bottomCorner + anchors { + bottom: parent.bottom + left: !Config.options.bar.bottom ? parent.left : undefined + right: Config.options.bar.bottom ? parent.right : undefined + } + implicitSize: Appearance.rounding.screenRounding + color: showBarBackground ? Appearance.colors.colLayer0 : "transparent" + + corner: RoundCorner.CornerEnum.BottomLeft + states: State { + name: "bottom" + when: Config.options.bar.bottom + PropertyChanges { + bottomCorner.corner: RoundCorner.CornerEnum.BottomRight + } + } + } + } + } + } + } + } + } + + IpcHandler { + target: "bar" + + function toggle(): void { + GlobalStates.barOpen = !GlobalStates.barOpen + } + + function close(): void { + GlobalStates.barOpen = false + } + + function open(): void { + GlobalStates.barOpen = true + } + } + + GlobalShortcut { + name: "barToggle" + description: "Toggles bar on press" + + onPressed: { + GlobalStates.barOpen = !GlobalStates.barOpen; + } + } + + GlobalShortcut { + name: "barOpen" + description: "Opens bar on press" + + onPressed: { + GlobalStates.barOpen = true; + } + } + + GlobalShortcut { + name: "barClose" + description: "Closes bar on press" + + onPressed: { + GlobalStates.barOpen = false; + } + } +} diff --git a/modules/quickshell/config/modules/ii/verticalBar/VerticalBarContent.qml b/modules/quickshell/config/modules/ii/verticalBar/VerticalBarContent.qml new file mode 100644 index 0000000..69ffc4b --- /dev/null +++ b/modules/quickshell/config/modules/ii/verticalBar/VerticalBarContent.qml @@ -0,0 +1,298 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Services.UPower +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import qs.modules.ii.bar as Bar + +Item { // Bar content region + id: root + + property var screen: root.QsWindow.window?.screen + property var brightnessMonitor: Brightness.getMonitorForScreen(screen) + + component HorizontalBarSeparator: Rectangle { + Layout.leftMargin: Appearance.sizes.baseBarHeight / 3 + Layout.rightMargin: Appearance.sizes.baseBarHeight / 3 + Layout.fillWidth: true + implicitHeight: 1 + color: Appearance.colors.colOutlineVariant + } + + // Background shadow + Loader { + active: Config.options.bar.showBackground && Config.options.bar.cornerStyle === 1 + anchors.fill: barBackground + sourceComponent: StyledRectangularShadow { + anchors.fill: undefined // The loader's anchors act on this, and this should not have any anchor + target: barBackground + } + } + // Background + Rectangle { + id: barBackground + anchors { + fill: parent + margins: Config.options.bar.cornerStyle === 1 ? (Appearance.sizes.hyprlandGapsOut) : 0 // idk why but +1 is needed + } + color: Config.options.bar.showBackground ? Appearance.colors.colLayer0 : "transparent" + radius: Config.options.bar.cornerStyle === 1 ? Appearance.rounding.windowRounding : 0 + border.width: Config.options.bar.cornerStyle === 1 ? 1 : 0 + border.color: Appearance.colors.colLayer0Border + } + + FocusedScrollMouseArea { // Top section | scroll to change brightness + id: barTopSectionMouseArea + anchors.top: parent.top + implicitHeight: topSectionColumnLayout.implicitHeight + implicitWidth: Appearance.sizes.baseVerticalBarWidth + height: (root.height - middleSection.height) / 2 + width: Appearance.sizes.verticalBarWidth + + onScrollDown: root.brightnessMonitor.setBrightness(root.brightnessMonitor.brightness - 0.05) + onScrollUp: root.brightnessMonitor.setBrightness(root.brightnessMonitor.brightness + 0.05) + onMovedAway: GlobalStates.osdBrightnessOpen = false + onPressed: event => { + if (event.button === Qt.LeftButton) + GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen; + } + + ColumnLayout { // Content + id: topSectionColumnLayout + anchors.fill: parent + spacing: 10 + + Bar.LeftSidebarButton { // Left sidebar button + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: (Appearance.sizes.baseVerticalBarWidth - implicitWidth) / 2 + Appearance.sizes.hyprlandGapsOut + colBackground: barTopSectionMouseArea.hovered ? Appearance.colors.colLayer1Hover : ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1) + } + + Item { + Layout.fillHeight: true + } + + } + } + + Column { // Middle section + id: middleSection + anchors.centerIn: parent + spacing: 4 + + Bar.BarGroup { + vertical: true + padding: 8 + Resources { + Layout.fillWidth: true + Layout.fillHeight: false + } + + HorizontalBarSeparator {} + + VerticalMedia { + Layout.fillWidth: true + Layout.fillHeight: false + } + } + + HorizontalBarSeparator { + visible: Config.options?.bar.borderless + } + + Bar.BarGroup { + id: middleCenterGroup + vertical: true + padding: 6 + + Bar.Workspaces { + id: workspacesWidget + vertical: true + MouseArea { + // Right-click to toggle overview + anchors.fill: parent + acceptedButtons: Qt.RightButton + + onPressed: event => { + if (event.button === Qt.RightButton) { + GlobalStates.overviewOpen = !GlobalStates.overviewOpen; + } + } + } + } + } + + HorizontalBarSeparator { + visible: Config.options?.bar.borderless + } + + Bar.BarGroup { + vertical: true + padding: 8 + + VerticalClockWidget { + Layout.fillWidth: true + Layout.fillHeight: false + } + + HorizontalBarSeparator {} + + VerticalDateWidget { + Layout.fillWidth: true + Layout.fillHeight: false + } + + HorizontalBarSeparator { + visible: Battery.available + } + + BatteryIndicator { + visible: Battery.available + Layout.fillWidth: true + Layout.fillHeight: false + } + + } + } + + FocusedScrollMouseArea { // Bottom section | scroll to change volume + id: barBottomSectionMouseArea + + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + } + implicitWidth: Appearance.sizes.baseVerticalBarWidth + implicitHeight: bottomSectionColumnLayout.implicitHeight + + onScrollDown: Audio.decrementVolume(); + onScrollUp: Audio.incrementVolume(); + onMovedAway: GlobalStates.osdVolumeOpen = false; + onPressed: event => { + if (event.button === Qt.LeftButton) { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + } + } + + ColumnLayout { + id: bottomSectionColumnLayout + anchors.fill: parent + spacing: 4 + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + } + + Bar.SysTray { + vertical: true + Layout.fillWidth: true + Layout.fillHeight: false + invertSide: Config?.options.bar.bottom + } + + RippleButton { // Right sidebar button + id: rightSidebarButton + + Layout.alignment: Qt.AlignBottom | Qt.AlignHCenter + Layout.bottomMargin: Appearance.rounding.screenRounding + Layout.fillHeight: false + + implicitHeight: indicatorsColumnLayout.implicitHeight + 4 * 2 + implicitWidth: indicatorsColumnLayout.implicitWidth + 6 * 2 + + buttonRadius: Appearance.rounding.full + colBackground: barBottomSectionMouseArea.hovered ? Appearance.colors.colLayer1Hover : ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1) + colBackgroundHover: Appearance.colors.colLayer1Hover + colRipple: Appearance.colors.colLayer1Active + colBackgroundToggled: Appearance.colors.colSecondaryContainer + colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover + colRippleToggled: Appearance.colors.colSecondaryContainerActive + toggled: GlobalStates.sidebarRightOpen + property color colText: toggled ? Appearance.m3colors.m3onSecondaryContainer : Appearance.colors.colOnLayer0 + + Behavior on colText { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + onPressed: { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + } + + ColumnLayout { + id: indicatorsColumnLayout + anchors.centerIn: parent + property real realSpacing: 6 + spacing: 0 + + Revealer { + vertical: true + reveal: Audio.sink?.audio?.muted ?? false + Layout.fillWidth: true + Layout.bottomMargin: reveal ? indicatorsColumnLayout.realSpacing : 0 + Behavior on Layout.bottomMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + MaterialSymbol { + text: "volume_off" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + } + Revealer { + vertical: true + reveal: Audio.source?.audio?.muted ?? false + Layout.fillWidth: true + Layout.bottomMargin: reveal ? indicatorsColumnLayout.realSpacing : 0 + Behavior on Layout.topMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + MaterialSymbol { + text: "mic_off" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + } + Bar.HyprlandXkbIndicator { + vertical: true + Layout.alignment: Qt.AlignHCenter + Layout.bottomMargin: indicatorsColumnLayout.realSpacing + color: rightSidebarButton.colText + } + Revealer { + vertical: true + reveal: Notifications.silent || Notifications.unread > 0 + Layout.fillWidth: true + Layout.bottomMargin: reveal ? indicatorsColumnLayout.realSpacing : 0 + implicitHeight: reveal ? notificationUnreadCount.implicitHeight : 0 + implicitWidth: reveal ? notificationUnreadCount.implicitWidth : 0 + Behavior on Layout.bottomMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Bar.NotificationUnreadCount { + id: notificationUnreadCount + } + } + MaterialSymbol { + text: Network.materialSymbol + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + MaterialSymbol { + Layout.topMargin: indicatorsColumnLayout.realSpacing + visible: BluetoothStatus.available + text: BluetoothStatus.connected ? "bluetooth_connected" : BluetoothStatus.enabled ? "bluetooth" : "bluetooth_disabled" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + } + } + } + } +} diff --git a/modules/quickshell/config/modules/ii/verticalBar/VerticalClockWidget.qml b/modules/quickshell/config/modules/ii/verticalBar/VerticalClockWidget.qml new file mode 100644 index 0000000..3f9ca81 --- /dev/null +++ b/modules/quickshell/config/modules/ii/verticalBar/VerticalClockWidget.qml @@ -0,0 +1,42 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts +import qs.modules.ii.bar as Bar + +Item { + id: root + property bool borderless: Config.options.bar.borderless + implicitHeight: clockColumn.implicitHeight + implicitWidth: Appearance.sizes.verticalBarWidth + + ColumnLayout { + id: clockColumn + anchors.centerIn: parent + spacing: 0 + + Repeater { + model: DateTime.time.split(/[: ]/) + delegate: StyledText { + required property string modelData + Layout.alignment: Qt.AlignHCenter + font.pixelSize: modelData.match(/am|pm/i) ? + Appearance.font.pixelSize.smaller // Smaller "am"/"pm" text + : Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer1 + text: modelData.padStart(2, "0") + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: !Config.options.bar.tooltips.clickToShow + + Bar.ClockWidgetPopup { + hoverTarget: mouseArea + } + } +} diff --git a/modules/quickshell/config/modules/ii/verticalBar/VerticalDateWidget.qml b/modules/quickshell/config/modules/ii/verticalBar/VerticalDateWidget.qml new file mode 100644 index 0000000..bf76ebe --- /dev/null +++ b/modules/quickshell/config/modules/ii/verticalBar/VerticalDateWidget.qml @@ -0,0 +1,64 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Shapes +import QtQuick.Layouts +import qs.modules.ii.bar as Bar + +Item { // Full hitbox + id: root + + implicitHeight: content.implicitHeight + implicitWidth: Appearance.sizes.verticalBarWidth + property var dayOfMonth: DateTime.shortDate.split(/[-\/]/)[0] // What if ๐Ÿ”murica๐Ÿฆ…? good question + property var monthOfYear: DateTime.shortDate.split(/[-\/]/)[1] + + Item { // Boundaries for date numbers + id: content + anchors.centerIn: parent + implicitWidth: 24 + implicitHeight: 30 + + Shape { + id: diagonalLine + property real padding: 4 + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeWidth: 1.2 + strokeColor: Appearance.colors.colSubtext + fillColor: "transparent" + startX: content.width - diagonalLine.padding + startY: diagonalLine.padding + PathLine { + x: diagonalLine.padding + y: content.height - diagonalLine.padding + } + } + } + + StyledText { + id: dayText + anchors { + top: parent.top + left: parent.left + } + font.pixelSize: 13 + color: Appearance.colors.colOnLayer1 + text: dayOfMonth + } + + StyledText { + id: monthText + anchors { + bottom: parent.bottom + right: parent.right + } + font.pixelSize: 13 + color: Appearance.colors.colOnLayer1 + text: monthOfYear + } + } +} diff --git a/modules/quickshell/config/modules/ii/verticalBar/VerticalMedia.qml b/modules/quickshell/config/modules/ii/verticalBar/VerticalMedia.qml new file mode 100644 index 0000000..4db432b --- /dev/null +++ b/modules/quickshell/config/modules/ii/verticalBar/VerticalMedia.qml @@ -0,0 +1,89 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +import qs.modules.common.functions + +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Mpris + +import qs.modules.ii.bar as Bar + +MouseArea { + id: root + property bool borderless: Config.options.bar.borderless + readonly property MprisPlayer activePlayer: MprisController.activePlayer + readonly property string cleanedTitle: StringUtils.cleanMusicTitle(activePlayer?.trackTitle) || Translation.tr("No media") + + Layout.fillHeight: true + implicitHeight: mediaCircProg.implicitHeight + implicitWidth: Appearance.sizes.verticalBarWidth + + Timer { + running: activePlayer?.playbackState == MprisPlaybackState.Playing + interval: Config.options.resources.updateInterval + repeat: true + onTriggered: activePlayer.positionChanged() + } + + acceptedButtons: Qt.MiddleButton | Qt.BackButton | Qt.ForwardButton | Qt.RightButton | Qt.LeftButton + hoverEnabled: !Config.options.bar.tooltips.clickToShow + onPressed: (event) => { + if (event.button === Qt.MiddleButton) { + activePlayer.togglePlaying(); + } else if (event.button === Qt.BackButton) { + activePlayer.previous(); + } else if (event.button === Qt.ForwardButton || event.button === Qt.RightButton) { + activePlayer.next(); + } else if (event.button === Qt.LeftButton) { + GlobalStates.mediaControlsOpen = !GlobalStates.mediaControlsOpen + } + } + + ClippedFilledCircularProgress { + id: mediaCircProg + anchors.centerIn: parent + implicitSize: 20 + + lineWidth: Appearance.rounding.unsharpen + value: activePlayer?.position / activePlayer?.length + colPrimary: Appearance.colors.colOnSecondaryContainer + enableAnimation: false + + Item { + anchors.centerIn: parent + width: mediaCircProg.implicitSize + height: mediaCircProg.implicitSize + + MaterialSymbol { + anchors.centerIn: parent + fill: 1 + text: activePlayer?.isPlaying ? "pause" : "music_note" + iconSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onSecondaryContainer + } + } + } + + Bar.StyledPopup { + hoverTarget: root + active: GlobalStates.mediaControlsOpen ? false : root.containsMouse + + Column { + anchors.centerIn: parent + spacing: 4 + + Bar.StyledPopupHeaderRow { + icon: "music_note" + label: Translation.tr("Media") + } + + StyledText { + color: Appearance.colors.colOnSurfaceVariant + text: `${cleanedTitle}${activePlayer?.trackArtist ? '\n' + activePlayer.trackArtist : ''}` + } + } + } + +} diff --git a/modules/quickshell/config/modules/settings/About.qml b/modules/quickshell/config/modules/settings/About.qml new file mode 100644 index 0000000..adf1e1b --- /dev/null +++ b/modules/quickshell/config/modules/settings/About.qml @@ -0,0 +1,150 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + icon: "box" + title: Translation.tr("Distro") + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 20 + Layout.topMargin: 10 + Layout.bottomMargin: 10 + IconImage { + implicitSize: 80 + source: Quickshell.iconPath(SystemInfo.logo) + } + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + // spacing: 10 + StyledText { + text: SystemInfo.distroName + font.pixelSize: Appearance.font.pixelSize.title + } + StyledText { + font.pixelSize: Appearance.font.pixelSize.normal + text: SystemInfo.homeUrl + textFormat: Text.MarkdownText + onLinkActivated: (link) => { + Qt.openUrlExternally(link) + } + PointingHandLinkHover {} + } + } + } + + Flow { + Layout.fillWidth: true + spacing: 5 + + RippleButtonWithIcon { + materialIcon: "auto_stories" + mainText: Translation.tr("Documentation") + onClicked: { + Qt.openUrlExternally(SystemInfo.documentationUrl) + } + } + RippleButtonWithIcon { + materialIcon: "support" + mainText: Translation.tr("Help & Support") + onClicked: { + Qt.openUrlExternally(SystemInfo.supportUrl) + } + } + RippleButtonWithIcon { + materialIcon: "bug_report" + mainText: Translation.tr("Report a Bug") + onClicked: { + Qt.openUrlExternally(SystemInfo.bugReportUrl) + } + } + RippleButtonWithIcon { + materialIcon: "policy" + materialIconFill: false + mainText: Translation.tr("Privacy Policy") + onClicked: { + Qt.openUrlExternally(SystemInfo.privacyPolicyUrl) + } + } + + } + + } + ContentSection { + icon: "folder_managed" + title: Translation.tr("Dotfiles") + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 20 + Layout.topMargin: 10 + Layout.bottomMargin: 10 + IconImage { + implicitSize: 80 + source: Quickshell.iconPath("illogical-impulse") + } + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + // spacing: 10 + StyledText { + text: Translation.tr("illogical-impulse") + font.pixelSize: Appearance.font.pixelSize.title + } + StyledText { + text: "https://github.com/end-4/dots-hyprland" + font.pixelSize: Appearance.font.pixelSize.normal + textFormat: Text.MarkdownText + onLinkActivated: (link) => { + Qt.openUrlExternally(link) + } + PointingHandLinkHover {} + } + } + } + + Flow { + Layout.fillWidth: true + spacing: 5 + + RippleButtonWithIcon { + materialIcon: "auto_stories" + mainText: Translation.tr("Documentation") + onClicked: { + Qt.openUrlExternally("https://end-4.github.io/dots-hyprland-wiki/en/ii-qs/02usage/") + } + } + RippleButtonWithIcon { + materialIcon: "adjust" + materialIconFill: false + mainText: Translation.tr("Issues") + onClicked: { + Qt.openUrlExternally("https://github.com/end-4/dots-hyprland/issues") + } + } + RippleButtonWithIcon { + materialIcon: "forum" + mainText: Translation.tr("Discussions") + onClicked: { + Qt.openUrlExternally("https://github.com/end-4/dots-hyprland/discussions") + } + } + RippleButtonWithIcon { + materialIcon: "favorite" + mainText: Translation.tr("Donate") + onClicked: { + Qt.openUrlExternally("https://github.com/sponsors/end-4") + } + } + + + } + } +} diff --git a/modules/quickshell/config/modules/settings/AdvancedConfig.qml b/modules/quickshell/config/modules/settings/AdvancedConfig.qml new file mode 100644 index 0000000..7d51dbe --- /dev/null +++ b/modules/quickshell/config/modules/settings/AdvancedConfig.qml @@ -0,0 +1,95 @@ +import QtQuick +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + icon: "colors" + title: Translation.tr("Color generation") + + ConfigSwitch { + buttonIcon: "hardware" + text: Translation.tr("Shell & utilities") + checked: Config.options.appearance.wallpaperTheming.enableAppsAndShell + onCheckedChanged: { + Config.options.appearance.wallpaperTheming.enableAppsAndShell = checked; + } + } + ConfigSwitch { + buttonIcon: "tv_options_input_settings" + text: Translation.tr("Qt apps") + checked: Config.options.appearance.wallpaperTheming.enableQtApps + onCheckedChanged: { + Config.options.appearance.wallpaperTheming.enableQtApps = checked; + } + StyledToolTip { + text: Translation.tr("Shell & utilities theming must also be enabled") + } + } + ConfigSwitch { + buttonIcon: "terminal" + text: Translation.tr("Terminal") + checked: Config.options.appearance.wallpaperTheming.enableTerminal + onCheckedChanged: { + Config.options.appearance.wallpaperTheming.enableTerminal = checked; + } + StyledToolTip { + text: Translation.tr("Shell & utilities theming must also be enabled") + } + } + ConfigRow { + uniform: true + ConfigSwitch { + buttonIcon: "dark_mode" + text: Translation.tr("Force dark mode in terminal") + checked: Config.options.appearance.wallpaperTheming.terminalGenerationProps.forceDarkMode + onCheckedChanged: { + Config.options.appearance.wallpaperTheming.terminalGenerationProps.forceDarkMode= checked; + } + StyledToolTip { + text: Translation.tr("Ignored if terminal theming is not enabled") + } + } + } + + ConfigSpinBox { + icon: "invert_colors" + text: Translation.tr("Terminal: Harmony (%)") + value: Config.options.appearance.wallpaperTheming.terminalGenerationProps.harmony * 100 + from: 0 + to: 100 + stepSize: 10 + onValueChanged: { + Config.options.appearance.wallpaperTheming.terminalGenerationProps.harmony = value / 100; + } + } + ConfigSpinBox { + icon: "gradient" + text: Translation.tr("Terminal: Harmonize threshold") + value: Config.options.appearance.wallpaperTheming.terminalGenerationProps.harmonizeThreshold + from: 0 + to: 100 + stepSize: 10 + onValueChanged: { + Config.options.appearance.wallpaperTheming.terminalGenerationProps.harmonizeThreshold = value; + } + } + ConfigSpinBox { + icon: "format_color_text" + text: Translation.tr("Terminal: Foreground boost (%)") + value: Config.options.appearance.wallpaperTheming.terminalGenerationProps.termFgBoost * 100 + from: 0 + to: 100 + stepSize: 10 + onValueChanged: { + Config.options.appearance.wallpaperTheming.terminalGenerationProps.termFgBoost = value / 100; + } + } + } + + + +} diff --git a/modules/quickshell/config/modules/settings/BackgroundConfig.qml b/modules/quickshell/config/modules/settings/BackgroundConfig.qml new file mode 100644 index 0000000..161cd13 --- /dev/null +++ b/modules/quickshell/config/modules/settings/BackgroundConfig.qml @@ -0,0 +1,505 @@ +import QtQuick +import QtQuick.Layouts +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + icon: "sync_alt" + title: Translation.tr("Parallax") + + ConfigSwitch { + buttonIcon: "unfold_more_double" + text: Translation.tr("Vertical") + checked: Config.options.background.parallax.vertical + onCheckedChanged: { + Config.options.background.parallax.vertical = checked; + } + } + + ConfigRow { + uniform: true + ConfigSwitch { + buttonIcon: "counter_1" + text: Translation.tr("Depends on workspace") + checked: Config.options.background.parallax.enableWorkspace + onCheckedChanged: { + Config.options.background.parallax.enableWorkspace = checked; + } + } + ConfigSwitch { + buttonIcon: "side_navigation" + text: Translation.tr("Depends on sidebars") + checked: Config.options.background.parallax.enableSidebar + onCheckedChanged: { + Config.options.background.parallax.enableSidebar = checked; + } + } + } + ConfigSpinBox { + icon: "loupe" + text: Translation.tr("Preferred wallpaper zoom (%)") + value: Config.options.background.parallax.workspaceZoom * 100 + from: 100 + to: 150 + stepSize: 1 + onValueChanged: { + Config.options.background.parallax.workspaceZoom = value / 100; + } + } + } + + ContentSection { + icon: "clock_loader_40" + title: Translation.tr("Widget: Clock") + id: settingsClock + + function stylePresent(styleName) { + if (!Config.options.background.widgets.clock.showOnlyWhenLocked && Config.options.background.widgets.clock.style === styleName) { + return true; + } + if (Config.options.background.widgets.clock.styleLocked === styleName) { + return true; + } + return false; + } + + readonly property bool digitalPresent: stylePresent("digital") + readonly property bool cookiePresent: stylePresent("cookie") + + ConfigRow { + Layout.fillWidth: true + + ConfigSwitch { + Layout.fillWidth: false + buttonIcon: "check" + text: Translation.tr("Enable") + checked: Config.options.background.widgets.clock.enable + onCheckedChanged: { + Config.options.background.widgets.clock.enable = checked; + } + } + Item { + Layout.fillWidth: true + } + ConfigSelectionArray { + Layout.fillWidth: false + currentValue: Config.options.background.widgets.clock.placementStrategy + onSelected: newValue => { + Config.options.background.widgets.clock.placementStrategy = newValue; + } + options: [ + { + displayName: Translation.tr("Draggable"), + icon: "drag_pan", + value: "free" + }, + { + displayName: Translation.tr("Least busy"), + icon: "category", + value: "leastBusy" + }, + { + displayName: Translation.tr("Most busy"), + icon: "shapes", + value: "mostBusy" + }, + ] + } + } + + ConfigSwitch { + buttonIcon: "lock_clock" + text: Translation.tr("Show only when locked") + checked: Config.options.background.widgets.clock.showOnlyWhenLocked + onCheckedChanged: { + Config.options.background.widgets.clock.showOnlyWhenLocked = checked; + } + } + + ContentSubsection { + visible: !Config.options.background.widgets.clock.showOnlyWhenLocked + title: Translation.tr("Clock style") + ConfigSelectionArray { + currentValue: Config.options.background.widgets.clock.style + onSelected: newValue => { + Config.options.background.widgets.clock.style = newValue; + } + options: [ + { + displayName: Translation.tr("Digital"), + icon: "timer_10", + value: "digital" + }, + { + displayName: Translation.tr("Cookie"), + icon: "cookie", + value: "cookie" + } + ] + } + } + + ContentSubsection { + title: Translation.tr("Clock style (locked)") + ConfigSelectionArray { + currentValue: Config.options.background.widgets.clock.styleLocked + onSelected: newValue => { + Config.options.background.widgets.clock.styleLocked = newValue; + } + options: [ + { + displayName: Translation.tr("Digital"), + icon: "timer_10", + value: "digital" + }, + { + displayName: Translation.tr("Cookie"), + icon: "cookie", + value: "cookie" + } + ] + } + } + + ContentSubsection { + visible: settingsClock.digitalPresent + title: Translation.tr("Digital clock settings") + + ConfigSwitch { + buttonIcon: "animation" + text: Translation.tr("Animate time change") + checked: Config.options.background.widgets.clock.digital.animateChange + onCheckedChanged: { + Config.options.background.widgets.clock.digital.animateChange = checked; + } + } + } + + ContentSubsection { + visible: settingsClock.cookiePresent + title: Translation.tr("Cookie clock settings") + + ConfigSwitch { + buttonIcon: "airwave" + text: Translation.tr("Use old sine wave cookie implementation") + checked: Config.options.background.widgets.clock.cookie.useSineCookie + onCheckedChanged: { + Config.options.background.widgets.clock.cookie.useSineCookie = checked; + } + StyledToolTip { + text: "Looks a bit softer and more consistent with different number of sides,\nbut has less impressive morphing" + } + } + + ConfigSpinBox { + icon: "add_triangle" + text: Translation.tr("Sides") + value: Config.options.background.widgets.clock.cookie.sides + from: 0 + to: 40 + stepSize: 1 + onValueChanged: { + Config.options.background.widgets.clock.cookie.sides = value; + } + } + + ConfigSwitch { + buttonIcon: "autoplay" + text: Translation.tr("Constantly rotate") + checked: Config.options.background.widgets.clock.cookie.constantlyRotate + onCheckedChanged: { + Config.options.background.widgets.clock.cookie.constantlyRotate = checked; + } + StyledToolTip { + text: "Makes the clock always rotate. This is extremely expensive\n(expect 50% usage on Intel UHD Graphics) and thus impractical." + } + } + + ConfigRow { + + ConfigSwitch { + enabled: Config.options.background.widgets.clock.cookie.dialNumberStyle === "dots" || Config.options.background.widgets.clock.cookie.dialNumberStyle === "full" + buttonIcon: "brightness_7" + text: Translation.tr("Hour marks") + checked: Config.options.background.widgets.clock.cookie.hourMarks + onEnabledChanged: { + checked = Config.options.background.widgets.clock.cookie.hourMarks; + } + onCheckedChanged: { + Config.options.background.widgets.clock.cookie.hourMarks = checked; + } + StyledToolTip { + text: "Can only be turned on using the 'Dots' or 'Full' dial style for aesthetic reasons" + } + } + + ConfigSwitch { + enabled: Config.options.background.widgets.clock.cookie.dialNumberStyle !== "numbers" + buttonIcon: "timer_10" + text: Translation.tr("Digits in the middle") + checked: Config.options.background.widgets.clock.cookie.timeIndicators + onEnabledChanged: { + checked = Config.options.background.widgets.clock.cookie.timeIndicators; + } + onCheckedChanged: { + Config.options.background.widgets.clock.cookie.timeIndicators = checked; + } + StyledToolTip { + text: "Can't be turned on when using 'Numbers' dial style for aesthetic reasons" + } + } + } + } + + ContentSubsection { + visible: settingsClock.cookiePresent + title: Translation.tr("Dial style") + ConfigSelectionArray { + currentValue: Config.options.background.widgets.clock.cookie.dialNumberStyle + onSelected: newValue => { + Config.options.background.widgets.clock.cookie.dialNumberStyle = newValue; + if (newValue !== "dots" && newValue !== "full") { + Config.options.background.widgets.clock.cookie.hourMarks = false; + } + if (newValue === "numbers") { + Config.options.background.widgets.clock.cookie.timeIndicators = false; + } + } + options: [ + { + displayName: "", + icon: "block", + value: "none" + }, + { + displayName: Translation.tr("Dots"), + icon: "graph_6", + value: "dots" + }, + { + displayName: Translation.tr("Full"), + icon: "history_toggle_off", + value: "full" + }, + { + displayName: Translation.tr("Numbers"), + icon: "counter_1", + value: "numbers" + } + ] + } + } + + ContentSubsection { + visible: settingsClock.cookiePresent + title: Translation.tr("Hour hand") + ConfigSelectionArray { + currentValue: Config.options.background.widgets.clock.cookie.hourHandStyle + onSelected: newValue => { + Config.options.background.widgets.clock.cookie.hourHandStyle = newValue; + } + options: [ + { + displayName: "", + icon: "block", + value: "hide" + }, + { + displayName: Translation.tr("Classic"), + icon: "radio", + value: "classic" + }, + { + displayName: Translation.tr("Hollow"), + icon: "circle", + value: "hollow" + }, + { + displayName: Translation.tr("Fill"), + icon: "eraser_size_5", + value: "fill" + }, + ] + } + } + + ContentSubsection { + visible: settingsClock.cookiePresent + title: Translation.tr("Minute hand") + + ConfigSelectionArray { + currentValue: Config.options.background.widgets.clock.cookie.minuteHandStyle + onSelected: newValue => { + Config.options.background.widgets.clock.cookie.minuteHandStyle = newValue; + } + options: [ + { + displayName: "", + icon: "block", + value: "hide" + }, + { + displayName: Translation.tr("Classic"), + icon: "radio", + value: "classic" + }, + { + displayName: Translation.tr("Thin"), + icon: "line_end", + value: "thin" + }, + { + displayName: Translation.tr("Medium"), + icon: "eraser_size_2", + value: "medium" + }, + { + displayName: Translation.tr("Bold"), + icon: "eraser_size_4", + value: "bold" + }, + ] + } + } + + ContentSubsection { + visible: settingsClock.cookiePresent + title: Translation.tr("Second hand") + + ConfigSelectionArray { + currentValue: Config.options.background.widgets.clock.cookie.secondHandStyle + onSelected: newValue => { + Config.options.background.widgets.clock.cookie.secondHandStyle = newValue; + } + options: [ + { + displayName: "", + icon: "block", + value: "hide" + }, + { + displayName: Translation.tr("Classic"), + icon: "radio", + value: "classic" + }, + { + displayName: Translation.tr("Line"), + icon: "line_end", + value: "line" + }, + { + displayName: Translation.tr("Dot"), + icon: "adjust", + value: "dot" + }, + ] + } + } + + ContentSubsection { + visible: settingsClock.cookiePresent + title: Translation.tr("Date style") + + ConfigSelectionArray { + currentValue: Config.options.background.widgets.clock.cookie.dateStyle + onSelected: newValue => { + Config.options.background.widgets.clock.cookie.dateStyle = newValue; + } + options: [ + { + displayName: "", + icon: "block", + value: "hide" + }, + { + displayName: Translation.tr("Bubble"), + icon: "bubble_chart", + value: "bubble" + }, + { + displayName: Translation.tr("Border"), + icon: "rotate_right", + value: "border" + }, + { + displayName: Translation.tr("Rect"), + icon: "rectangle", + value: "rect" + } + ] + } + } + + ContentSubsection { + title: Translation.tr("Quote") + + ConfigSwitch { + buttonIcon: "check" + text: Translation.tr("Enable") + checked: Config.options.background.widgets.clock.quote.enable + onCheckedChanged: { + Config.options.background.widgets.clock.quote.enable = checked; + } + } + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Quote") + text: Config.options.background.widgets.clock.quote.text + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.background.widgets.clock.quote.text = text; + } + } + } + } + + ContentSection { + icon: "weather_mix" + title: Translation.tr("Widget: Weather") + + ConfigRow { + Layout.fillWidth: true + + ConfigSwitch { + Layout.fillWidth: false + buttonIcon: "check" + text: Translation.tr("Enable") + checked: Config.options.background.widgets.weather.enable + onCheckedChanged: { + Config.options.background.widgets.weather.enable = checked; + } + } + Item { + Layout.fillWidth: true + } + ConfigSelectionArray { + Layout.fillWidth: false + currentValue: Config.options.background.widgets.weather.placementStrategy + onSelected: newValue => { + Config.options.background.widgets.weather.placementStrategy = newValue; + } + options: [ + { + displayName: Translation.tr("Draggable"), + icon: "drag_pan", + value: "free" + }, + { + displayName: Translation.tr("Least busy"), + icon: "category", + value: "leastBusy" + }, + { + displayName: Translation.tr("Most busy"), + icon: "shapes", + value: "mostBusy" + }, + ] + } + } + } +} diff --git a/modules/quickshell/config/modules/settings/BarConfig.qml b/modules/quickshell/config/modules/settings/BarConfig.qml new file mode 100644 index 0000000..bfe4c25 --- /dev/null +++ b/modules/quickshell/config/modules/settings/BarConfig.qml @@ -0,0 +1,350 @@ +import QtQuick +import QtQuick.Layouts +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + icon: "notifications" + title: Translation.tr("Notifications") + ConfigSwitch { + buttonIcon: "counter_2" + text: Translation.tr("Unread indicator: show count") + checked: Config.options.bar.indicators.notifications.showUnreadCount + onCheckedChanged: { + Config.options.bar.indicators.notifications.showUnreadCount = checked; + } + } + } + + ContentSection { + icon: "spoke" + title: Translation.tr("Positioning") + + ConfigRow { + ContentSubsection { + title: Translation.tr("Bar position") + Layout.fillWidth: true + + ConfigSelectionArray { + currentValue: (Config.options.bar.bottom ? 1 : 0) | (Config.options.bar.vertical ? 2 : 0) + onSelected: newValue => { + Config.options.bar.bottom = (newValue & 1) !== 0; + Config.options.bar.vertical = (newValue & 2) !== 0; + } + options: [ + { + displayName: Translation.tr("Top"), + icon: "arrow_upward", + value: 0 // bottom: false, vertical: false + }, + { + displayName: Translation.tr("Left"), + icon: "arrow_back", + value: 2 // bottom: false, vertical: true + }, + { + displayName: Translation.tr("Bottom"), + icon: "arrow_downward", + value: 1 // bottom: true, vertical: false + }, + { + displayName: Translation.tr("Right"), + icon: "arrow_forward", + value: 3 // bottom: true, vertical: true + } + ] + } + } + ContentSubsection { + title: Translation.tr("Automatically hide") + Layout.fillWidth: false + + ConfigSelectionArray { + currentValue: Config.options.bar.autoHide.enable + onSelected: newValue => { + Config.options.bar.autoHide.enable = newValue; // Update local copy + } + options: [ + { + displayName: Translation.tr("No"), + icon: "close", + value: false + }, + { + displayName: Translation.tr("Yes"), + icon: "check", + value: true + } + ] + } + } + } + + ConfigRow { + + ContentSubsection { + title: Translation.tr("Corner style") + Layout.fillWidth: true + + ConfigSelectionArray { + currentValue: Config.options.bar.cornerStyle + onSelected: newValue => { + Config.options.bar.cornerStyle = newValue; // Update local copy + } + options: [ + { + displayName: Translation.tr("Hug"), + icon: "line_curve", + value: 0 + }, + { + displayName: Translation.tr("Float"), + icon: "page_header", + value: 1 + }, + { + displayName: Translation.tr("Rect"), + icon: "toolbar", + value: 2 + } + ] + } + } + + ContentSubsection { + title: Translation.tr("Group style") + Layout.fillWidth: false + + ConfigSelectionArray { + currentValue: Config.options.bar.borderless + onSelected: newValue => { + Config.options.bar.borderless = newValue; // Update local copy + } + options: [ + { + displayName: Translation.tr("Pills"), + icon: "location_chip", + value: false + }, + { + displayName: Translation.tr("Line-separated"), + icon: "split_scene", + value: true + } + ] + } + } + } + } + + ContentSection { + icon: "shelf_auto_hide" + title: Translation.tr("Tray") + + ConfigSwitch { + buttonIcon: "keep" + text: Translation.tr('Make icons pinned by default') + checked: Config.options.tray.invertPinnedItems + onCheckedChanged: { + Config.options.tray.invertPinnedItems = checked; + } + } + + ConfigSwitch { + buttonIcon: "colors" + text: Translation.tr('Tint icons') + checked: Config.options.tray.monochromeIcons + onCheckedChanged: { + Config.options.tray.monochromeIcons = checked; + } + } + } + + ContentSection { + icon: "widgets" + title: Translation.tr("Utility buttons") + + ConfigRow { + uniform: true + ConfigSwitch { + buttonIcon: "content_cut" + text: Translation.tr("Screen snip") + checked: Config.options.bar.utilButtons.showScreenSnip + onCheckedChanged: { + Config.options.bar.utilButtons.showScreenSnip = checked; + } + } + ConfigSwitch { + buttonIcon: "colorize" + text: Translation.tr("Color picker") + checked: Config.options.bar.utilButtons.showColorPicker + onCheckedChanged: { + Config.options.bar.utilButtons.showColorPicker = checked; + } + } + } + ConfigRow { + uniform: true + ConfigSwitch { + buttonIcon: "keyboard" + text: Translation.tr("Keyboard toggle") + checked: Config.options.bar.utilButtons.showKeyboardToggle + onCheckedChanged: { + Config.options.bar.utilButtons.showKeyboardToggle = checked; + } + } + ConfigSwitch { + buttonIcon: "mic" + text: Translation.tr("Mic toggle") + checked: Config.options.bar.utilButtons.showMicToggle + onCheckedChanged: { + Config.options.bar.utilButtons.showMicToggle = checked; + } + } + } + ConfigRow { + uniform: true + ConfigSwitch { + buttonIcon: "dark_mode" + text: Translation.tr("Dark/Light toggle") + checked: Config.options.bar.utilButtons.showDarkModeToggle + onCheckedChanged: { + Config.options.bar.utilButtons.showDarkModeToggle = checked; + } + } + ConfigSwitch { + buttonIcon: "speed" + text: Translation.tr("Performance Profile toggle") + checked: Config.options.bar.utilButtons.showPerformanceProfileToggle + onCheckedChanged: { + Config.options.bar.utilButtons.showPerformanceProfileToggle = checked; + } + } + } + ConfigRow { + uniform: true + ConfigSwitch { + buttonIcon: "videocam" + text: Translation.tr("Record") + checked: Config.options.bar.utilButtons.showScreenRecord + onCheckedChanged: { + Config.options.bar.utilButtons.showScreenRecord = checked; + } + } + } + } + + ContentSection { + icon: "cloud" + title: Translation.tr("Weather") + ConfigSwitch { + buttonIcon: "check" + text: Translation.tr("Enable") + checked: Config.options.bar.weather.enable + onCheckedChanged: { + Config.options.bar.weather.enable = checked; + } + } + } + + ContentSection { + icon: "workspaces" + title: Translation.tr("Workspaces") + + ConfigSwitch { + buttonIcon: "counter_1" + text: Translation.tr('Always show numbers') + checked: Config.options.bar.workspaces.alwaysShowNumbers + onCheckedChanged: { + Config.options.bar.workspaces.alwaysShowNumbers = checked; + } + } + + ConfigSwitch { + buttonIcon: "award_star" + text: Translation.tr('Show app icons') + checked: Config.options.bar.workspaces.showAppIcons + onCheckedChanged: { + Config.options.bar.workspaces.showAppIcons = checked; + } + } + + ConfigSwitch { + buttonIcon: "colors" + text: Translation.tr('Tint app icons') + checked: Config.options.bar.workspaces.monochromeIcons + onCheckedChanged: { + Config.options.bar.workspaces.monochromeIcons = checked; + } + } + + ConfigSpinBox { + icon: "view_column" + text: Translation.tr("Workspaces shown") + value: Config.options.bar.workspaces.shown + from: 1 + to: 30 + stepSize: 1 + onValueChanged: { + Config.options.bar.workspaces.shown = value; + } + } + + ConfigSpinBox { + icon: "touch_long" + text: Translation.tr("Number show delay when pressing Super (ms)") + value: Config.options.bar.workspaces.showNumberDelay + from: 0 + to: 1000 + stepSize: 50 + onValueChanged: { + Config.options.bar.workspaces.showNumberDelay = value; + } + } + + ContentSubsection { + title: Translation.tr("Number style") + + ConfigSelectionArray { + currentValue: JSON.stringify(Config.options.bar.workspaces.numberMap) + onSelected: newValue => { + Config.options.bar.workspaces.numberMap = JSON.parse(newValue) + } + options: [ + { + displayName: Translation.tr("Normal"), + icon: "timer_10", + value: '[]' + }, + { + displayName: Translation.tr("Han chars"), + icon: "square_dot", + value: '["ไธ€","ไบŒ","ไธ‰","ๅ››","ไบ”","ๅ…ญ","ไธƒ","ๅ…ซ","ไน","ๅ","ๅไธ€","ๅไบŒ","ๅไธ‰","ๅๅ››","ๅไบ”","ๅๅ…ญ","ๅไธƒ","ๅๅ…ซ","ๅไน","ไบŒๅ"]' + }, + { + displayName: Translation.tr("Roman"), + icon: "account_balance", + value: '["I","II","III","IV","V","VI","VII","VIII","IX","X","XI","XII","XIII","XIV","XV","XVI","XVII","XVIII","XIX","XX"]' + } + ] + } + } + } + + ContentSection { + icon: "tooltip" + title: Translation.tr("Tooltips") + ConfigSwitch { + buttonIcon: "ads_click" + text: Translation.tr("Click to show") + checked: Config.options.bar.tooltips.clickToShow + onCheckedChanged: { + Config.options.bar.tooltips.clickToShow = checked; + } + } + } +} diff --git a/modules/quickshell/config/modules/settings/GeneralConfig.qml b/modules/quickshell/config/modules/settings/GeneralConfig.qml new file mode 100644 index 0000000..3a9f4f9 --- /dev/null +++ b/modules/quickshell/config/modules/settings/GeneralConfig.qml @@ -0,0 +1,257 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import QtQuick.Layouts +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + icon: "volume_up" + title: Translation.tr("Audio") + + ConfigSwitch { + buttonIcon: "hearing" + text: Translation.tr("Earbang protection") + checked: Config.options.audio.protection.enable + onCheckedChanged: { + Config.options.audio.protection.enable = checked; + } + StyledToolTip { + text: Translation.tr("Prevents abrupt increments and restricts volume limit") + } + } + ConfigRow { + enabled: Config.options.audio.protection.enable + ConfigSpinBox { + icon: "arrow_warm_up" + text: Translation.tr("Max allowed increase") + value: Config.options.audio.protection.maxAllowedIncrease + from: 0 + to: 100 + stepSize: 2 + onValueChanged: { + Config.options.audio.protection.maxAllowedIncrease = value; + } + } + ConfigSpinBox { + icon: "vertical_align_top" + text: Translation.tr("Volume limit") + value: Config.options.audio.protection.maxAllowed + from: 0 + to: 154 // pavucontrol allows up to 153% + stepSize: 2 + onValueChanged: { + Config.options.audio.protection.maxAllowed = value; + } + } + } + } + + ContentSection { + icon: "battery_android_full" + title: Translation.tr("Battery") + + ConfigRow { + uniform: true + ConfigSpinBox { + icon: "warning" + text: Translation.tr("Low warning") + value: Config.options.battery.low + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.low = value; + } + } + ConfigSpinBox { + icon: "dangerous" + text: Translation.tr("Critical warning") + value: Config.options.battery.critical + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.critical = value; + } + } + } + ConfigRow { + uniform: false + Layout.fillWidth: false + ConfigSwitch { + buttonIcon: "pause" + text: Translation.tr("Automatic suspend") + checked: Config.options.battery.automaticSuspend + onCheckedChanged: { + Config.options.battery.automaticSuspend = checked; + } + StyledToolTip { + text: Translation.tr("Automatically suspends the system when battery is low") + } + } + ConfigSpinBox { + enabled: Config.options.battery.automaticSuspend + text: Translation.tr("at") + value: Config.options.battery.suspend + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.suspend = value; + } + } + } + ConfigRow { + uniform: true + ConfigSpinBox { + icon: "charger" + text: Translation.tr("Full warning") + value: Config.options.battery.full + from: 0 + to: 101 + stepSize: 5 + onValueChanged: { + Config.options.battery.full = value; + } + } + } + } + + ContentSection { + icon: "language" + title: Translation.tr("Language") + + ContentSubsection { + title: Translation.tr("Interface Language") + tooltip: Translation.tr("Select the language for the user interface.\n\"Auto\" will use your system's locale.") + + StyledComboBox { + id: languageSelector + buttonIcon: "language" + textRole: "displayName" + + model: [ + { + displayName: Translation.tr("Auto (System)"), + value: "auto" + }, + ...Translation.allAvailableLanguages.map(lang => { + return { + displayName: lang, + value: lang + }; + })] + + currentIndex: { + const index = model.findIndex(item => item.value === Config.options.language.ui); + return index !== -1 ? index : 0; + } + + onActivated: index => { + Config.options.language.ui = model[index].value; + } + } + } + } + + ContentSection { + icon: "notification_sound" + title: Translation.tr("Sounds") + ConfigRow { + uniform: true + ConfigSwitch { + buttonIcon: "battery_android_full" + text: Translation.tr("Battery") + checked: Config.options.sounds.battery + onCheckedChanged: { + Config.options.sounds.battery = checked; + } + } + ConfigSwitch { + buttonIcon: "av_timer" + text: Translation.tr("Pomodoro") + checked: Config.options.sounds.pomodoro + onCheckedChanged: { + Config.options.sounds.pomodoro = checked; + } + } + } + } + + ContentSection { + icon: "nest_clock_farsight_analog" + title: Translation.tr("Time") + + ConfigSwitch { + buttonIcon: "pace" + text: Translation.tr("Second precision") + checked: Config.options.time.secondPrecision + onCheckedChanged: { + Config.options.time.secondPrecision = checked; + } + StyledToolTip { + text: Translation.tr("Enable if you want clocks to show seconds accurately") + } + } + + ContentSubsection { + title: Translation.tr("Format") + tooltip: "" + + ConfigSelectionArray { + currentValue: Config.options.time.format + onSelected: newValue => { + if (newValue === "hh:mm") { + Quickshell.execDetached(["bash", "-c", `sed -i 's/\\TIME12\\b/TIME/' '${FileUtils.trimFileProtocol(Directories.config)}/hypr/hyprlock.conf'`]); + } else { + Quickshell.execDetached(["bash", "-c", `sed -i 's/\\TIME\\b/TIME12/' '${FileUtils.trimFileProtocol(Directories.config)}/hypr/hyprlock.conf'`]); + } + + Config.options.time.format = newValue; + } + options: [ + { + displayName: Translation.tr("24h"), + value: "hh:mm" + }, + { + displayName: Translation.tr("12h am/pm"), + value: "h:mm ap" + }, + { + displayName: Translation.tr("12h AM/PM"), + value: "h:mm AP" + }, + ] + } + } + } + + ContentSection { + icon: "work_alert" + title: Translation.tr("Work safety") + + ConfigSwitch { + buttonIcon: "assignment" + text: Translation.tr("Hide clipboard images") + checked: Config.options.workSafety.enable.clipboard + onCheckedChanged: { + Config.options.workSafety.enable.clipboard = checked; + } + } + ConfigSwitch { + buttonIcon: "wallpaper" + text: Translation.tr("Hide wallpapers") + checked: Config.options.workSafety.enable.wallpaper + onCheckedChanged: { + Config.options.workSafety.enable.wallpaper = checked; + } + } + } +} diff --git a/modules/quickshell/config/modules/settings/InterfaceConfig.qml b/modules/quickshell/config/modules/settings/InterfaceConfig.qml new file mode 100644 index 0000000..b99e291 --- /dev/null +++ b/modules/quickshell/config/modules/settings/InterfaceConfig.qml @@ -0,0 +1,868 @@ +import QtQuick +import QtQuick.Layouts +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + icon: "keyboard" + title: Translation.tr("Cheat sheet") + + ContentSubsection { + title: Translation.tr("Super key symbol") + tooltip: Translation.tr("You can also manually edit cheatsheet.superKey") + ConfigSelectionArray { + currentValue: Config.options.cheatsheet.superKey + onSelected: newValue => { + Config.options.cheatsheet.superKey = newValue; + } + // Use a nerdfont to see the icons + options: ([ + "๓ฐ–ณ", "๎ฃฅ", "๓ฐจก", "๎ฏ†", "๓ฐŒฝ", "๓ฐฃ‡", "๏Œข", "๏Œ’", "๎Ÿฆ", + "๎ฝ", "๎ฝฒ", "๓ฑ„›", "๎Ÿ™", "๏†ถ", "๎œ‘", "โŒ˜", "๓ฐ€ฒ", "๓ฐŸ", "๏‰จ" + ]).map(icon => { return { + displayName: icon, + value: icon + } + }) + } + } + + ConfigSwitch { + buttonIcon: "๓ฐ˜ต" + text: Translation.tr("Use macOS-like symbols for mods keys") + checked: Config.options.cheatsheet.useMacSymbol + onCheckedChanged: { + Config.options.cheatsheet.useMacSymbol = checked; + } + StyledToolTip { + text: Translation.tr("e.g. ๓ฐ˜ด for Ctrl, ๓ฐ˜ต for Alt, ๓ฐ˜ถ for Shift, etc") + } + } + + ConfigSwitch { + buttonIcon: "๓ฑŠถ" + text: Translation.tr("Use symbols for function keys") + checked: Config.options.cheatsheet.useFnSymbol + onCheckedChanged: { + Config.options.cheatsheet.useFnSymbol = checked; + } + StyledToolTip { + text: Translation.tr("e.g. ๓ฑŠซ for F1, ๓ฑŠถ for F12") + } + } + ConfigSwitch { + buttonIcon: "๓ฐฝ" + text: Translation.tr("Use symbols for mouse") + checked: Config.options.cheatsheet.useMouseSymbol + onCheckedChanged: { + Config.options.cheatsheet.useMouseSymbol = checked; + } + StyledToolTip { + text: Translation.tr("Replace ๓ฑ• for \"Scroll โ†“\", ๓ฑ•‘ \"Scroll โ†‘\", L๓ฐฝ \"LMB\", R๓ฐฝ \"RMB\", ๓ฑ•’ \"Scroll โ†‘/โ†“\" and โ‡ž/โ‡Ÿ for \"Page_โ†‘/โ†“\"") + } + } + ConfigSwitch { + buttonIcon: "highlight_keyboard_focus" + text: Translation.tr("Split buttons") + checked: Config.options.cheatsheet.splitButtons + onCheckedChanged: { + Config.options.cheatsheet.splitButtons = checked; + } + StyledToolTip { + text: Translation.tr("Display modifiers and keys in multiple keycap (e.g., \"Ctrl + A\" instead of \"Ctrl A\" or \"๓ฐ˜ด + A\" instead of \"๓ฐ˜ด A\")") + } + + } + + ConfigSpinBox { + text: Translation.tr("Keybind font size") + value: Config.options.cheatsheet.fontSize.key + from: 8 + to: 30 + stepSize: 1 + onValueChanged: { + Config.options.cheatsheet.fontSize.key = value; + } + } + ConfigSpinBox { + text: Translation.tr("Description font size") + value: Config.options.cheatsheet.fontSize.comment + from: 8 + to: 30 + stepSize: 1 + onValueChanged: { + Config.options.cheatsheet.fontSize.comment = value; + } + } + } + ContentSection { + icon: "call_to_action" + title: Translation.tr("Dock") + + ConfigSwitch { + buttonIcon: "check" + text: Translation.tr("Enable") + checked: Config.options.dock.enable + onCheckedChanged: { + Config.options.dock.enable = checked; + } + } + + ConfigRow { + uniform: true + ConfigSwitch { + buttonIcon: "highlight_mouse_cursor" + text: Translation.tr("Hover to reveal") + checked: Config.options.dock.hoverToReveal + onCheckedChanged: { + Config.options.dock.hoverToReveal = checked; + } + } + ConfigSwitch { + buttonIcon: "keep" + text: Translation.tr("Pinned on startup") + checked: Config.options.dock.pinnedOnStartup + onCheckedChanged: { + Config.options.dock.pinnedOnStartup = checked; + } + } + } + ConfigSwitch { + buttonIcon: "colors" + text: Translation.tr("Tint app icons") + checked: Config.options.dock.monochromeIcons + onCheckedChanged: { + Config.options.dock.monochromeIcons = checked; + } + } + } + + ContentSection { + icon: "lock" + title: Translation.tr("Lock screen") + + ConfigSwitch { + buttonIcon: "water_drop" + text: Translation.tr('Use Hyprlock (instead of Quickshell)') + checked: Config.options.lock.useHyprlock + onCheckedChanged: { + Config.options.lock.useHyprlock = checked; + } + StyledToolTip { + text: Translation.tr("If you want to somehow use fingerprint unlock...") + } + } + + ConfigSwitch { + buttonIcon: "account_circle" + text: Translation.tr('Launch on startup') + checked: Config.options.lock.launchOnStartup + onCheckedChanged: { + Config.options.lock.launchOnStartup = checked; + } + } + + ContentSubsection { + title: Translation.tr("Security") + + ConfigSwitch { + buttonIcon: "settings_power" + text: Translation.tr('Require password to power off/restart') + checked: Config.options.lock.security.requirePasswordToPower + onCheckedChanged: { + Config.options.lock.security.requirePasswordToPower = checked; + } + StyledToolTip { + text: Translation.tr("Remember that on most devices one can always hold the power button to force shutdown\nThis only makes it a tiny bit harder for accidents to happen") + } + } + + ConfigSwitch { + buttonIcon: "key_vertical" + text: Translation.tr('Also unlock keyring') + checked: Config.options.lock.security.unlockKeyring + onCheckedChanged: { + Config.options.lock.security.unlockKeyring = checked; + } + StyledToolTip { + text: Translation.tr("This is usually safe and needed for your browser and AI sidebar anyway\nMostly useful for those who use lock on startup instead of a display manager that does it (GDM, SDDM, etc.)") + } + } + } + + ContentSubsection { + title: Translation.tr("Style: general") + + ConfigSwitch { + buttonIcon: "center_focus_weak" + text: Translation.tr('Center clock') + checked: Config.options.lock.centerClock + onCheckedChanged: { + Config.options.lock.centerClock = checked; + } + } + + ConfigSwitch { + buttonIcon: "info" + text: Translation.tr('Show "Locked" text') + checked: Config.options.lock.showLockedText + onCheckedChanged: { + Config.options.lock.showLockedText = checked; + } + } + + ConfigSwitch { + buttonIcon: "shapes" + text: Translation.tr('Use varying shapes for password characters') + checked: Config.options.lock.materialShapeChars + onCheckedChanged: { + Config.options.lock.materialShapeChars = checked; + } + } + } + ContentSubsection { + title: Translation.tr("Style: Blurred") + + ConfigSwitch { + buttonIcon: "blur_on" + text: Translation.tr('Enable blur') + checked: Config.options.lock.blur.enable + onCheckedChanged: { + Config.options.lock.blur.enable = checked; + } + } + + ConfigSpinBox { + icon: "loupe" + text: Translation.tr("Extra wallpaper zoom (%)") + value: Config.options.lock.blur.extraZoom * 100 + from: 1 + to: 150 + stepSize: 2 + onValueChanged: { + Config.options.lock.blur.extraZoom = value / 100; + } + } + } + } + + ContentSection { + icon: "notifications" + title: Translation.tr("Notifications") + + ConfigSpinBox { + icon: "av_timer" + text: Translation.tr("Timeout duration (if not defined by notification) (ms)") + value: Config.options.notifications.timeout + from: 1000 + to: 60000 + stepSize: 1000 + onValueChanged: { + Config.options.notifications.timeout = value; + } + } + } + + ContentSection { + icon: "select_window" + title: Translation.tr("Overlay: General") + + ConfigSwitch { + buttonIcon: "high_density" + text: Translation.tr("Enable opening zoom animation") + checked: Config.options.overlay.openingZoomAnimation + onCheckedChanged: { + Config.options.overlay.openingZoomAnimation = checked; + } + } + ConfigSwitch { + buttonIcon: "texture" + text: Translation.tr("Darken screen") + checked: Config.options.overlay.darkenScreen + onCheckedChanged: { + Config.options.overlay.darkenScreen = checked; + } + } + } + + ContentSection { + icon: "point_scan" + title: Translation.tr("Overlay: Crosshair") + + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Crosshair code (in Valorant's format)") + text: Config.options.crosshair.code + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.crosshair.code = text; + } + } + + RowLayout { + StyledText { + Layout.leftMargin: 10 + color: Appearance.colors.colSubtext + font.pixelSize: Appearance.font.pixelSize.smallie + text: Translation.tr("Press Super+G to open the overlay and pin the crosshair") + } + Item { + Layout.fillWidth: true + } + RippleButtonWithIcon { + id: editorButton + buttonRadius: Appearance.rounding.full + materialIcon: "open_in_new" + mainText: Translation.tr("Open editor") + onClicked: { + Qt.openUrlExternally(`https://www.vcrdb.net/builder?c=${Config.options.crosshair.code}`); + } + StyledToolTip { + text: "www.vcrdb.net" + } + } + } + } + + ContentSection { + icon: "point_scan" + title: Translation.tr("Overlay: Floating Image") + + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Image source") + text: Config.options.overlay.floatingImage.imageSource + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.overlay.floatingImage.imageSource = text; + } + } + } + + ContentSection { + icon: "screenshot_frame_2" + title: Translation.tr("Region selector (screen snipping/Google Lens)") + + ContentSubsection { + title: Translation.tr("Hint target regions") + ConfigRow { + ConfigSwitch { + buttonIcon: "select_window" + text: Translation.tr('Windows') + checked: Config.options.regionSelector.targetRegions.windows + onCheckedChanged: { + Config.options.regionSelector.targetRegions.windows = checked; + } + } + ConfigSwitch { + buttonIcon: "right_panel_open" + text: Translation.tr('Layers') + checked: Config.options.regionSelector.targetRegions.layers + onCheckedChanged: { + Config.options.regionSelector.targetRegions.layers = checked; + } + } + ConfigSwitch { + buttonIcon: "nearby" + text: Translation.tr('Content') + checked: Config.options.regionSelector.targetRegions.content + onCheckedChanged: { + Config.options.regionSelector.targetRegions.content = checked; + } + StyledToolTip { + text: Translation.tr("Could be images or parts of the screen that have some containment.\nMight not always be accurate.\nThis is done with an image processing algorithm run locally and no AI is used.") + } + } + } + } + + ContentSubsection { + title: Translation.tr("Google Lens") + + ConfigSelectionArray { + currentValue: Config.options.search.imageSearch.useCircleSelection ? "circle" : "rectangles" + onSelected: newValue => { + Config.options.search.imageSearch.useCircleSelection = (newValue === "circle"); + } + options: [ + { icon: "activity_zone", value: "rectangles", displayName: Translation.tr("Rectangular selection") }, + { icon: "gesture", value: "circle", displayName: Translation.tr("Circle to Search") } + ] + } + } + + ContentSubsection { + title: Translation.tr("Rectangular selection") + + ConfigSwitch { + buttonIcon: "point_scan" + text: Translation.tr("Show aim lines") + checked: Config.options.regionSelector.rect.showAimLines + onCheckedChanged: { + Config.options.regionSelector.rect.showAimLines = checked; + } + } + } + + ContentSubsection { + title: Translation.tr("Circle selection") + + ConfigSpinBox { + icon: "eraser_size_3" + text: Translation.tr("Stroke width") + value: Config.options.regionSelector.circle.strokeWidth + from: 1 + to: 20 + stepSize: 1 + onValueChanged: { + Config.options.regionSelector.circle.strokeWidth = value; + } + } + + ConfigSpinBox { + icon: "screenshot_frame_2" + text: Translation.tr("Padding") + value: Config.options.regionSelector.circle.padding + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.regionSelector.circle.padding = value; + } + } + } + } + + ContentSection { + icon: "side_navigation" + title: Translation.tr("Sidebars") + + ConfigSwitch { + buttonIcon: "memory" + text: Translation.tr('Keep right sidebar loaded') + checked: Config.options.sidebar.keepRightSidebarLoaded + onCheckedChanged: { + Config.options.sidebar.keepRightSidebarLoaded = checked; + } + StyledToolTip { + text: Translation.tr("When enabled keeps the content of the right sidebar loaded to reduce the delay when opening,\nat the cost of around 15MB of consistent RAM usage. Delay significance depends on your system's performance.\nUsing a custom kernel like linux-cachyos might help") + } + } + + ConfigSwitch { + buttonIcon: "translate" + text: Translation.tr('Enable translator') + checked: Config.options.sidebar.translator.enable + onCheckedChanged: { + Config.options.sidebar.translator.enable = checked; + } + } + + ContentSubsection { + title: Translation.tr("Quick toggles") + + ConfigSelectionArray { + Layout.fillWidth: false + currentValue: Config.options.sidebar.quickToggles.style + onSelected: newValue => { + Config.options.sidebar.quickToggles.style = newValue; + } + options: [ + { + displayName: Translation.tr("Classic"), + icon: "password_2", + value: "classic" + }, + { + displayName: Translation.tr("Android"), + icon: "action_key", + value: "android" + } + ] + } + + ConfigSpinBox { + enabled: Config.options.sidebar.quickToggles.style === "android" + icon: "splitscreen_left" + text: Translation.tr("Columns") + value: Config.options.sidebar.quickToggles.android.columns + from: 1 + to: 8 + stepSize: 1 + onValueChanged: { + Config.options.sidebar.quickToggles.android.columns = value; + } + } + } + + ContentSubsection { + title: Translation.tr("Sliders") + + ConfigSwitch { + buttonIcon: "check" + text: Translation.tr("Enable") + checked: Config.options.sidebar.quickSliders.enable + onCheckedChanged: { + Config.options.sidebar.quickSliders.enable = checked; + } + } + + ConfigSwitch { + buttonIcon: "brightness_6" + text: Translation.tr("Brightness") + enabled: Config.options.sidebar.quickSliders.enable + checked: Config.options.sidebar.quickSliders.showBrightness + onCheckedChanged: { + Config.options.sidebar.quickSliders.showBrightness = checked; + } + } + + ConfigSwitch { + buttonIcon: "volume_up" + text: Translation.tr("Volume") + enabled: Config.options.sidebar.quickSliders.enable + checked: Config.options.sidebar.quickSliders.showVolume + onCheckedChanged: { + Config.options.sidebar.quickSliders.showVolume = checked; + } + } + + ConfigSwitch { + buttonIcon: "mic" + text: Translation.tr("Microphone") + enabled: Config.options.sidebar.quickSliders.enable + checked: Config.options.sidebar.quickSliders.showMic + onCheckedChanged: { + Config.options.sidebar.quickSliders.showMic = checked; + } + } + } + + ContentSubsection { + title: Translation.tr("Corner open") + tooltip: Translation.tr("Allows you to open sidebars by clicking or hovering screen corners regardless of bar position") + ConfigRow { + uniform: true + ConfigSwitch { + buttonIcon: "check" + text: Translation.tr("Enable") + checked: Config.options.sidebar.cornerOpen.enable + onCheckedChanged: { + Config.options.sidebar.cornerOpen.enable = checked; + } + } + } + ConfigSwitch { + buttonIcon: "highlight_mouse_cursor" + text: Translation.tr("Hover to trigger") + checked: Config.options.sidebar.cornerOpen.clickless + onCheckedChanged: { + Config.options.sidebar.cornerOpen.clickless = checked; + } + + StyledToolTip { + text: Translation.tr("When this is off you'll have to click") + } + } + Row { + ConfigSwitch { + enabled: !Config.options.sidebar.cornerOpen.clickless + text: Translation.tr("Force hover open at absolute corner") + checked: Config.options.sidebar.cornerOpen.clicklessCornerEnd + onCheckedChanged: { + Config.options.sidebar.cornerOpen.clicklessCornerEnd = checked; + } + + StyledToolTip { + text: Translation.tr("When the previous option is off and this is on,\nyou can still hover the corner's end to open sidebar,\nand the remaining area can be used for volume/brightness scroll") + } + } + ConfigSpinBox { + icon: "arrow_cool_down" + text: Translation.tr("with vertical offset") + value: Config.options.sidebar.cornerOpen.clicklessCornerVerticalOffset + from: 0 + to: 20 + stepSize: 1 + onValueChanged: { + Config.options.sidebar.cornerOpen.clicklessCornerVerticalOffset = value; + } + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + StyledToolTip { + extraVisibleCondition: mouseArea.containsMouse + text: Translation.tr("Why this is cool:\nFor non-0 values, it won't trigger when you reach the\nscreen corner along the horizontal edge, but it will when\nyou do along the vertical edge") + } + } + } + } + + ConfigRow { + uniform: true + ConfigSwitch { + buttonIcon: "vertical_align_bottom" + text: Translation.tr("Place at bottom") + checked: Config.options.sidebar.cornerOpen.bottom + onCheckedChanged: { + Config.options.sidebar.cornerOpen.bottom = checked; + } + + StyledToolTip { + text: Translation.tr("Place the corners to trigger at the bottom") + } + } + ConfigSwitch { + buttonIcon: "unfold_more_double" + text: Translation.tr("Value scroll") + checked: Config.options.sidebar.cornerOpen.valueScroll + onCheckedChanged: { + Config.options.sidebar.cornerOpen.valueScroll = checked; + } + + StyledToolTip { + text: Translation.tr("Brightness and volume") + } + } + } + ConfigSwitch { + buttonIcon: "visibility" + text: Translation.tr("Visualize region") + checked: Config.options.sidebar.cornerOpen.visualize + onCheckedChanged: { + Config.options.sidebar.cornerOpen.visualize = checked; + } + } + ConfigRow { + ConfigSpinBox { + icon: "arrow_range" + text: Translation.tr("Region width") + value: Config.options.sidebar.cornerOpen.cornerRegionWidth + from: 1 + to: 300 + stepSize: 1 + onValueChanged: { + Config.options.sidebar.cornerOpen.cornerRegionWidth = value; + } + } + ConfigSpinBox { + icon: "height" + text: Translation.tr("Region height") + value: Config.options.sidebar.cornerOpen.cornerRegionHeight + from: 1 + to: 300 + stepSize: 1 + onValueChanged: { + Config.options.sidebar.cornerOpen.cornerRegionHeight = value; + } + } + } + } + } + + ContentSection { + icon: "voting_chip" + title: Translation.tr("On-screen display") + + ConfigSpinBox { + icon: "av_timer" + text: Translation.tr("Timeout (ms)") + value: Config.options.osd.timeout + from: 100 + to: 3000 + stepSize: 100 + onValueChanged: { + Config.options.osd.timeout = value; + } + } + } + + ContentSection { + icon: "overview_key" + title: Translation.tr("Overview") + + ConfigSwitch { + buttonIcon: "check" + text: Translation.tr("Enable") + checked: Config.options.overview.enable + onCheckedChanged: { + Config.options.overview.enable = checked; + } + } + ConfigSwitch { + buttonIcon: "center_focus_strong" + text: Translation.tr("Center icons") + checked: Config.options.overview.centerIcons + onCheckedChanged: { + Config.options.overview.centerIcons = checked; + } + } + ConfigSpinBox { + icon: "loupe" + text: Translation.tr("Scale (%)") + value: Config.options.overview.scale * 100 + from: 1 + to: 100 + stepSize: 1 + onValueChanged: { + Config.options.overview.scale = value / 100; + } + } + ConfigRow { + uniform: true + ConfigSpinBox { + icon: "splitscreen_bottom" + text: Translation.tr("Rows") + value: Config.options.overview.rows + from: 1 + to: 20 + stepSize: 1 + onValueChanged: { + Config.options.overview.rows = value; + } + } + ConfigSpinBox { + icon: "splitscreen_right" + text: Translation.tr("Columns") + value: Config.options.overview.columns + from: 1 + to: 20 + stepSize: 1 + onValueChanged: { + Config.options.overview.columns = value; + } + } + } + } + + ContentSection { + icon: "wallpaper_slideshow" + title: Translation.tr("Wallpaper selector") + + ConfigSwitch { + buttonIcon: "ad" + text: Translation.tr('Use system file picker') + checked: Config.options.wallpaperSelector.useSystemFileDialog + onCheckedChanged: { + Config.options.wallpaperSelector.useSystemFileDialog = checked; + } + } + } + + ContentSection { + icon: "text_format" + title: Translation.tr("Fonts") + + ContentSubsection { + title: Translation.tr("Main font") + tooltip: Translation.tr("Used for general UI text") + + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Font family name (e.g., Google Sans Flex)") + text: Config.options.appearance.fonts.main + wrapMode: TextEdit.NoWrap + onTextChanged: { + Config.options.appearance.fonts.main = text; + } + } + } + + ContentSubsection { + title: Translation.tr("Numbers font") + tooltip: Translation.tr("Used for displaying numbers") + + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Font family name") + text: Config.options.appearance.fonts.numbers + wrapMode: TextEdit.NoWrap + onTextChanged: { + Config.options.appearance.fonts.numbers = text; + } + } + } + + ContentSubsection { + title: Translation.tr("Title font") + tooltip: Translation.tr("Used for headings and titles") + + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Font family name") + text: Config.options.appearance.fonts.title + wrapMode: TextEdit.NoWrap + onTextChanged: { + Config.options.appearance.fonts.title = text; + } + } + } + + ContentSubsection { + title: Translation.tr("Monospace font") + tooltip: Translation.tr("Used for code and terminal") + + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Font family name (e.g., JetBrains Mono NF)") + text: Config.options.appearance.fonts.monospace + wrapMode: TextEdit.NoWrap + onTextChanged: { + Config.options.appearance.fonts.monospace = text; + } + } + } + + ContentSubsection { + title: Translation.tr("Nerd font icons") + tooltip: Translation.tr("Font used for Nerd Font icons") + + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Font family name (e.g., JetBrains Mono NF)") + text: Config.options.appearance.fonts.iconNerd + wrapMode: TextEdit.NoWrap + onTextChanged: { + Config.options.appearance.fonts.iconNerd = text; + } + } + } + + ContentSubsection { + title: Translation.tr("Reading font") + tooltip: Translation.tr("Used for reading large blocks of text") + + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Font family name (e.g., Readex Pro)") + text: Config.options.appearance.fonts.reading + wrapMode: TextEdit.NoWrap + onTextChanged: { + Config.options.appearance.fonts.reading = text; + } + } + } + + ContentSubsection { + title: Translation.tr("Expressive font") + tooltip: Translation.tr("Used for decorative/expressive text") + + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Font family name (e.g., Space Grotesk)") + text: Config.options.appearance.fonts.expressive + wrapMode: TextEdit.NoWrap + onTextChanged: { + Config.options.appearance.fonts.expressive = text; + } + } + } + } + +} diff --git a/modules/quickshell/config/modules/settings/QuickConfig.qml b/modules/quickshell/config/modules/settings/QuickConfig.qml new file mode 100644 index 0000000..de4f463 --- /dev/null +++ b/modules/quickshell/config/modules/settings/QuickConfig.qml @@ -0,0 +1,334 @@ +import QtQuick +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Io +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +ContentPage { + forceWidth: true + + Process { + id: randomWallProc + property string status: "" + property string scriptPath: `${Directories.scriptPath}/colors/random/random_wall.sh` + command: ["bash", "-c", FileUtils.trimFileProtocol(randomWallProc.scriptPath)] + stdout: SplitParser { + onRead: data => { + randomWallProc.status = data.trim(); + } + } + } + + component SmallLightDarkPreferenceButton: RippleButton { + id: smallLightDarkPreferenceButton + required property bool dark + property color colText: toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnLayer2 + padding: 5 + Layout.fillWidth: true + toggled: Appearance.m3colors.darkmode === dark + colBackground: Appearance.colors.colLayer2 + onClicked: { + Quickshell.execDetached(["bash", "-c", `${Directories.wallpaperSwitchScriptPath} --mode ${dark ? "dark" : "light"} --noswitch`]); + } + contentItem: Item { + anchors.centerIn: parent + ColumnLayout { + anchors.centerIn: parent + spacing: 0 + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 30 + text: dark ? "dark_mode" : "light_mode" + color: smallLightDarkPreferenceButton.colText + } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: dark ? Translation.tr("Dark") : Translation.tr("Light") + font.pixelSize: Appearance.font.pixelSize.smaller + color: smallLightDarkPreferenceButton.colText + } + } + } + } + + // Wallpaper selection + ContentSection { + icon: "format_paint" + title: Translation.tr("Wallpaper & Colors") + Layout.fillWidth: true + + RowLayout { + Layout.fillWidth: true + + Item { + implicitWidth: 340 + implicitHeight: 200 + + StyledImage { + id: wallpaperPreview + anchors.fill: parent + sourceSize.width: parent.implicitWidth + sourceSize.height: parent.implicitHeight + fillMode: Image.PreserveAspectCrop + source: Config.options.background.wallpaperPath + cache: false + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: 360 + height: 200 + radius: Appearance.rounding.normal + } + } + } + } + + ColumnLayout { + RippleButtonWithIcon { + Layout.fillWidth: true + materialIcon: "wallpaper" + StyledToolTip { + text: Translation.tr("Pick wallpaper image on your system") + } + onClicked: { + Quickshell.execDetached(`${Directories.wallpaperSwitchScriptPath}`); + } + mainContentComponent: Component { + RowLayout { + spacing: 10 + StyledText { + font.pixelSize: Appearance.font.pixelSize.small + text: Translation.tr("Choose file") + color: Appearance.colors.colOnSecondaryContainer + } + RowLayout { + spacing: 3 + KeyboardKey { + key: "Ctrl" + } + KeyboardKey { + key: "๓ฐ–ณ" + } + StyledText { + Layout.alignment: Qt.AlignVCenter + text: "+" + } + KeyboardKey { + key: "T" + } + } + } + } + } + RowLayout { + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + Layout.fillHeight: true + uniformCellSizes: true + + SmallLightDarkPreferenceButton { + Layout.fillHeight: true + dark: false + } + SmallLightDarkPreferenceButton { + Layout.fillHeight: true + dark: true + } + } + } + } + + ConfigSelectionArray { + currentValue: Config.options.appearance.palette.type + onSelected: newValue => { + Config.options.appearance.palette.type = newValue; + Quickshell.execDetached(["bash", "-c", `${Directories.wallpaperSwitchScriptPath} --noswitch`]); + } + options: [ + { + "value": "auto", + "displayName": Translation.tr("Auto") + }, + { + "value": "scheme-content", + "displayName": Translation.tr("Content") + }, + { + "value": "scheme-expressive", + "displayName": Translation.tr("Expressive") + }, + { + "value": "scheme-fidelity", + "displayName": Translation.tr("Fidelity") + }, + { + "value": "scheme-fruit-salad", + "displayName": Translation.tr("Fruit Salad") + }, + { + "value": "scheme-monochrome", + "displayName": Translation.tr("Monochrome") + }, + { + "value": "scheme-neutral", + "displayName": Translation.tr("Neutral") + }, + { + "value": "scheme-rainbow", + "displayName": Translation.tr("Rainbow") + }, + { + "value": "scheme-tonal-spot", + "displayName": Translation.tr("Tonal Spot") + } + ] + } + + ConfigSwitch { + buttonIcon: "ev_shadow" + text: Translation.tr("Transparency") + checked: Config.options.appearance.transparency.enable + onCheckedChanged: { + Config.options.appearance.transparency.enable = checked; + } + StyledToolTip { + text: Translation.tr("Might look ass. Unsupported.") + } + } + } + + ContentSection { + icon: "screenshot_monitor" + title: Translation.tr("Bar & screen") + + ConfigRow { + ContentSubsection { + title: Translation.tr("Bar position") + ConfigSelectionArray { + currentValue: (Config.options.bar.bottom ? 1 : 0) | (Config.options.bar.vertical ? 2 : 0) + onSelected: newValue => { + Config.options.bar.bottom = (newValue & 1) !== 0; + Config.options.bar.vertical = (newValue & 2) !== 0; + } + options: [ + { + displayName: Translation.tr("Top"), + icon: "arrow_upward", + value: 0 // bottom: false, vertical: false + }, + { + displayName: Translation.tr("Left"), + icon: "arrow_back", + value: 2 // bottom: false, vertical: true + }, + { + displayName: Translation.tr("Bottom"), + icon: "arrow_downward", + value: 1 // bottom: true, vertical: false + }, + { + displayName: Translation.tr("Right"), + icon: "arrow_forward", + value: 3 // bottom: true, vertical: true + } + ] + } + } + ContentSubsection { + title: Translation.tr("Bar style") + + ConfigSelectionArray { + currentValue: Config.options.bar.cornerStyle + onSelected: newValue => { + Config.options.bar.cornerStyle = newValue; // Update local copy + } + options: [ + { + displayName: Translation.tr("Hug"), + icon: "line_curve", + value: 0 + }, + { + displayName: Translation.tr("Float"), + icon: "page_header", + value: 1 + }, + { + displayName: Translation.tr("Rect"), + icon: "toolbar", + value: 2 + } + ] + } + } + } + + ConfigRow { + ContentSubsection { + title: Translation.tr("Screen round corner") + + ConfigSelectionArray { + currentValue: Config.options.appearance.fakeScreenRounding + onSelected: newValue => { + Config.options.appearance.fakeScreenRounding = newValue; + } + options: [ + { + displayName: Translation.tr("No"), + icon: "close", + value: 0 + }, + { + displayName: Translation.tr("Yes"), + icon: "check", + value: 1 + }, + { + displayName: Translation.tr("When not fullscreen"), + icon: "fullscreen_exit", + value: 2 + } + ] + } + } + + } + } + + NoticeBox { + Layout.fillWidth: true + text: Translation.tr('Not all options are available in this app. You should also check the config file by hitting the "Config file" button on the topleft corner or opening %1 manually.').arg(Directories.shellConfigPath) + + Item { + Layout.fillWidth: true + } + RippleButtonWithIcon { + id: copyPathButton + property bool justCopied: false + Layout.fillWidth: false + buttonRadius: Appearance.rounding.small + materialIcon: justCopied ? "check" : "content_copy" + mainText: justCopied ? Translation.tr("Path copied") : Translation.tr("Copy path") + onClicked: { + copyPathButton.justCopied = true + Quickshell.clipboardText = FileUtils.trimFileProtocol(`${Directories.config}/illogical-impulse/config.json`); + revertTextTimer.restart(); + } + colBackground: ColorUtils.transparentize(Appearance.colors.colPrimaryContainer) + colBackgroundHover: Appearance.colors.colPrimaryContainerHover + colRipple: Appearance.colors.colPrimaryContainerActive + + Timer { + id: revertTextTimer + interval: 1500 + onTriggered: { + copyPathButton.justCopied = false + } + } + } + } +} diff --git a/modules/quickshell/config/modules/settings/ServicesConfig.qml b/modules/quickshell/config/modules/settings/ServicesConfig.qml new file mode 100644 index 0000000..8f1d975 --- /dev/null +++ b/modules/quickshell/config/modules/settings/ServicesConfig.qml @@ -0,0 +1,235 @@ +import QtQuick +import QtQuick.Layouts +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + icon: "music_cast" + title: Translation.tr("Music Recognition") + + ConfigSpinBox { + icon: "timer_off" + text: Translation.tr("Total duration timeout (s)") + value: Config.options.musicRecognition.timeout + from: 10 + to: 100 + stepSize: 2 + onValueChanged: { + Config.options.musicRecognition.timeout = value; + } + } + ConfigSpinBox { + icon: "av_timer" + text: Translation.tr("Polling interval (s)") + value: Config.options.musicRecognition.interval + from: 2 + to: 10 + stepSize: 1 + onValueChanged: { + Config.options.musicRecognition.interval = value; + } + } + } + + ContentSection { + icon: "cell_tower" + title: Translation.tr("Networking") + + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("User agent (for services that require it)") + text: Config.options.networking.userAgent + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.networking.userAgent = text; + } + } + } + + ContentSection { + icon: "memory" + title: Translation.tr("Resources") + + ConfigSpinBox { + icon: "av_timer" + text: Translation.tr("Polling interval (ms)") + value: Config.options.resources.updateInterval + from: 100 + to: 10000 + stepSize: 100 + onValueChanged: { + Config.options.resources.updateInterval = value; + } + } + + } + + ContentSection { + icon: "file_open" + title: Translation.tr("Save paths") + + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Video Recording Path") + text: Config.options.screenRecord.savePath + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.screenRecord.savePath = text; + } + } + + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Screenshot Path (leave empty to just copy)") + text: Config.options.screenSnip.savePath + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.screenSnip.savePath = text; + } + } + } + + ContentSection { + icon: "search" + title: Translation.tr("Search") + + ConfigSwitch { + text: Translation.tr("Use Levenshtein distance-based algorithm instead of fuzzy") + checked: Config.options.search.sloppy + onCheckedChanged: { + Config.options.search.sloppy = checked; + } + StyledToolTip { + text: Translation.tr("Could be better if you make a ton of typos,\nbut results can be weird and might not work with acronyms\n(e.g. \"GIMP\" might not give you the paint program)") + } + } + + ContentSubsection { + title: Translation.tr("Prefixes") + ConfigRow { + uniform: true + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Action") + text: Config.options.search.prefix.action + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.action = text; + } + } + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Clipboard") + text: Config.options.search.prefix.clipboard + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.clipboard = text; + } + } + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Emojis") + text: Config.options.search.prefix.emojis + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.emojis = text; + } + } + } + + ConfigRow { + uniform: true + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Math") + text: Config.options.search.prefix.math + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.math = text; + } + } + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Shell command") + text: Config.options.search.prefix.shellCommand + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.shellCommand = text; + } + } + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Web search") + text: Config.options.search.prefix.webSearch + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.webSearch = text; + } + } + } + } + ContentSubsection { + title: Translation.tr("Web search") + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Base URL") + text: Config.options.search.engineBaseUrl + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.engineBaseUrl = text; + } + } + } + } + + ContentSection { + icon: "weather_mix" + title: Translation.tr("Weather") + ConfigRow { + ConfigSwitch { + buttonIcon: "assistant_navigation" + text: Translation.tr("Enable GPS based location") + checked: Config.options.bar.weather.enableGPS + onCheckedChanged: { + Config.options.bar.weather.enableGPS = checked; + } + } + ConfigSwitch { + buttonIcon: "thermometer" + text: Translation.tr("Fahrenheit unit") + checked: Config.options.bar.weather.useUSCS + onCheckedChanged: { + Config.options.bar.weather.useUSCS = checked; + } + StyledToolTip { + text: Translation.tr("It may take a few seconds to update") + } + } + } + + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("City name") + text: Config.options.bar.weather.city + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.bar.weather.city = text; + } + } + ConfigSpinBox { + icon: "av_timer" + text: Translation.tr("Polling interval (m)") + value: Config.options.bar.weather.fetchInterval + from: 5 + to: 50 + stepSize: 5 + onValueChanged: { + Config.options.bar.weather.fetchInterval = value; + } + } + } +} diff --git a/modules/quickshell/config/scripts/cava/raw_output_config.txt b/modules/quickshell/config/scripts/cava/raw_output_config.txt new file mode 100644 index 0000000..7760e4e --- /dev/null +++ b/modules/quickshell/config/scripts/cava/raw_output_config.txt @@ -0,0 +1,17 @@ +[general] +mode = waves +framerate = 60 +autosens = 1 +bars = 50 + +[output] +method = raw +raw_target = /dev/stdout +data_format = ascii +channels = mono +mono_option = average + +[smoothing] +noise_reduction = 20 + + diff --git a/modules/quickshell/config/scripts/hyprland/get_keybinds.py b/modules/quickshell/config/scripts/hyprland/get_keybinds.py new file mode 100755 index 0000000..559ba8a --- /dev/null +++ b/modules/quickshell/config/scripts/hyprland/get_keybinds.py @@ -0,0 +1,222 @@ +#!/usr/bin/env -S\_/bin/sh\_-c\_"source\_\$(eval\_echo\_\$ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate&&exec\_python\_-E\_"\$0"\_"\$@"" +import argparse +import re +import os +from os.path import expandvars as os_expandvars +from typing import Dict, List + +TITLE_REGEX = "#+!" +HIDE_COMMENT = "[hidden]" +MOD_SEPARATORS = ['+', ' '] +COMMENT_BIND_PATTERN = "#/#" + +parser = argparse.ArgumentParser(description='Hyprland keybind reader') +parser.add_argument('--path', type=str, default="$HOME/.config/hypr/hyprland.conf", help='path to keybind file (sourcing isn\'t supported)') +args = parser.parse_args() +content_lines = [] +reading_line = 0 + +# Little Parser made for hyprland keybindings conf file +Variables: Dict[str, str] = {} + + +class KeyBinding(dict): + def __init__(self, mods, key, dispatcher, params, comment) -> None: + self["mods"] = mods + self["key"] = key + self["dispatcher"] = dispatcher + self["params"] = params + self["comment"] = comment + +class Section(dict): + def __init__(self, children, keybinds, name) -> None: + self["children"] = children + self["keybinds"] = keybinds + self["name"] = name + + +def read_content(path: str) -> str: + if (not os.access(os.path.expanduser(os.path.expandvars(path)), os.R_OK)): + return ("error") + with open(os.path.expanduser(os.path.expandvars(path)), "r") as file: + return file.read() + + +def autogenerate_comment(dispatcher: str, params: str = "") -> str: + match dispatcher: + + case "resizewindow": + return "Resize window" + + case "movewindow": + if(params == ""): + return "Move window" + else: + return "Window: move in {} direction".format({ + "l": "left", + "r": "right", + "u": "up", + "d": "down", + }.get(params, "null")) + + case "pin": + return "Window: pin (show on all workspaces)" + + case "splitratio": + return "Window split ratio {}".format(params) + + case "togglefloating": + return "Float/unfloat window" + + case "resizeactive": + return "Resize window by {}".format(params) + + case "killactive": + return "Close window" + + case "fullscreen": + return "Toggle {}".format( + { + "0": "fullscreen", + "1": "maximization", + "2": "fullscreen on Hyprland's side", + }.get(params, "null") + ) + + case "fakefullscreen": + return "Toggle fake fullscreen" + + case "workspace": + if params == "+1": + return "Workspace: focus right" + elif params == "-1": + return "Workspace: focus left" + return "Focus workspace {}".format(params) + + case "movefocus": + return "Window: move focus {}".format( + { + "l": "left", + "r": "right", + "u": "up", + "d": "down", + }.get(params, "null") + ) + + case "swapwindow": + return "Window: swap in {} direction".format( + { + "l": "left", + "r": "right", + "u": "up", + "d": "down", + }.get(params, "null") + ) + + case "movetoworkspace": + if params == "+1": + return "Window: move to right workspace (non-silent)" + elif params == "-1": + return "Window: move to left workspace (non-silent)" + return "Window: move to workspace {} (non-silent)".format(params) + + case "movetoworkspacesilent": + if params == "+1": + return "Window: move to right workspace" + elif params == "-1": + return "Window: move to right workspace" + return "Window: move to workspace {}".format(params) + + case "togglespecialworkspace": + return "Workspace: toggle special" + + case "exec": + return "Execute: {}".format(params) + + case _: + return "" + +def get_keybind_at_line(line_number, line_start = 0): + global content_lines + line = content_lines[line_number] + _, keys = line.split("=", 1) + keys, *comment = keys.split("#", 1) + + mods, key, dispatcher, *params = list(map(str.strip, keys.split(",", 4))) + params = "".join(map(str.strip, params)) + + # Remove empty spaces + comment = list(map(str.strip, comment)) + # Add comment if it exists, else generate it + if comment: + comment = comment[0] + if comment.startswith("[hidden]"): + return None + else: + comment = autogenerate_comment(dispatcher, params) + + if mods: + modstring = mods + MOD_SEPARATORS[0] # Add separator at end to ensure last mod is read + mods = [] + p = 0 + for index, char in enumerate(modstring): + if(char in MOD_SEPARATORS): + if(index - p > 1): + mods.append(modstring[p:index]) + p = index+1 + else: + mods = [] + + return KeyBinding(mods, key, dispatcher, params, comment) + +def get_binds_recursive(current_content, scope): + global content_lines + global reading_line + # print("get_binds_recursive({0}, {1}) [@L{2}]".format(current_content, scope, reading_line + 1)) + while reading_line < len(content_lines): # TODO: Adjust condition + line = content_lines[reading_line] + heading_search_result = re.search(TITLE_REGEX, line) + # print("Read line {0}: {1}\tisHeading: {2}".format(reading_line + 1, content_lines[reading_line], "[{0}, {1}, {2}]".format(heading_search_result.start(), heading_search_result.start() == 0, ((heading_search_result != None) and (heading_search_result.start() == 0))) if heading_search_result != None else "No")) + if ((heading_search_result != None) and (heading_search_result.start() == 0)): # Found title + # Determine scope + heading_scope = line.find('!') + # Lower? Return + if(heading_scope <= scope): + reading_line -= 1 + return current_content + + section_name = line[(heading_scope+1):].strip() + # print("[[ Found h{0} at line {1} ]] {2}".format(heading_scope, reading_line+1, content_lines[reading_line])) + reading_line += 1 + current_content["children"].append(get_binds_recursive(Section([], [], section_name), heading_scope)) + + elif line.startswith(COMMENT_BIND_PATTERN): + keybind = get_keybind_at_line(reading_line, line_start=len(COMMENT_BIND_PATTERN)) + if(keybind != None): + current_content["keybinds"].append(keybind) + + elif line == "" or not line.lstrip().startswith("bind"): # Comment, ignore + pass + + else: # Normal keybind + keybind = get_keybind_at_line(reading_line) + if(keybind != None): + current_content["keybinds"].append(keybind) + + reading_line += 1 + + return current_content; + +def parse_keys(path: str) -> Dict[str, List[KeyBinding]]: + global content_lines + content_lines = read_content(path).splitlines() + if content_lines[0] == "error": + return "error" + return get_binds_recursive(Section([], [], ""), 0) + + +if __name__ == "__main__": + import json + + ParsedKeys = parse_keys(args.path) + print(json.dumps(ParsedKeys)) diff --git a/modules/quickshell/config/scripts/images/find-regions-venv.sh b/modules/quickshell/config/scripts/images/find-regions-venv.sh new file mode 100755 index 0000000..d31fe4a --- /dev/null +++ b/modules/quickshell/config/scripts/images/find-regions-venv.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +source $(eval echo $ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate +"$SCRIPT_DIR/find_regions.py" "$@" +deactivate diff --git a/modules/quickshell/config/scripts/images/find_regions.py b/modules/quickshell/config/scripts/images/find_regions.py new file mode 100755 index 0000000..fe68a4d --- /dev/null +++ b/modules/quickshell/config/scripts/images/find_regions.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 + +import argparse +import cv2 +import json +import numpy as np +import sys + +DEFAULT_IMAGE_PATH = '/tmp/quickshell/media/screenshot/image' + +def iou(boxA, boxB): + # Compute intersection over union for two boxes + xA = max(boxA['x'], boxB['x']) + yA = max(boxA['y'], boxB['y']) + xB = min(boxA['x'] + boxA['width'], boxB['x'] + boxB['width']) + yB = min(boxA['y'] + boxA['height'], boxB['y'] + boxB['height']) + interW = max(0, xB - xA) + interH = max(0, yB - yA) + interArea = interW * interH + boxAArea = boxA['width'] * boxA['height'] + boxBArea = boxB['width'] * boxB['height'] + iou = interArea / float(boxAArea + boxBArea - interArea) if (boxAArea + boxBArea - interArea) > 0 else 0 + return iou + +def non_max_suppression(regions, iou_threshold=0.7): + # Sort by area (largest first) + regions = sorted(regions, key=lambda r: r['width'] * r['height'], reverse=True) + keep = [] + while regions: + current = regions.pop(0) + keep.append(current) + regions = [r for r in regions if iou(current, r) < iou_threshold] + return keep + +def find_regions(image_path, min_width, min_height, max_width=None, max_height=None, quality=False, k=150, min_size=20, sigma=0.8, resize_factor=1.0): + image = cv2.imread(image_path) + if image is None: + print(f'Error: Could not load image {image_path}', file=sys.stderr) + sys.exit(1) + orig_h, orig_w = image.shape[:2] + if resize_factor != 1.0: + image = cv2.resize(image, (int(orig_w * resize_factor), int(orig_h * resize_factor)), interpolation=cv2.INTER_AREA) + ss = cv2.ximgproc.segmentation.createSelectiveSearchSegmentation() + ss.setBaseImage(image) + if quality: + ss.switchToSelectiveSearchQuality(k, min_size, sigma) + else: + ss.switchToSelectiveSearchFast(k, min_size, sigma) + rects = ss.process() + regions = [] + for (x, y, w, h) in rects: + # Scale regions back to original image size if resized + if resize_factor != 1.0: + x = int(x / resize_factor) + y = int(y / resize_factor) + w = int(w / resize_factor) + h = int(h / resize_factor) + # Filter out region that is exactly the same size as the original image + if w == orig_w and h == orig_h and x == 0 and y == 0: + continue + if w > min_width and h > min_height: + if (max_width is None or w < max_width) and (max_height is None or h < max_height): + regions.append({'x': int(x), 'y': int(y), 'width': int(w), 'height': int(h)}) + # Remove duplicates/overlaps + regions = non_max_suppression(regions, iou_threshold=0.7) + return regions, cv2.imread(image_path) # Return original image for drawing + +def draw_regions(image, regions, output_path): + for region in regions: + if 'x' in region: + x, y, w, h = region['x'], region['y'], region['width'], region['height'] + elif 'at' in region and 'size' in region: + x, y = region['at'] + w, h = region['size'] + else: + continue + cv2.rectangle(image, (x, y), (x + w, y + h), (0, 0, 255), 2) + cv2.imwrite(output_path, image) + +def main(): + parser = argparse.ArgumentParser(description='Find regions of interest in an image using selective search.') + parser.add_argument('-i', '--image', default=DEFAULT_IMAGE_PATH, help='Path to input image') + parser.add_argument('-do', '--debug-output', help='Path to save debug image with rectangles') + parser.add_argument('--min-width', type=int, default=200, help='Minimum width of detected region') + parser.add_argument('--min-height', type=int, default=100, help='Minimum height of detected region') + parser.add_argument('--max-width', type=int, help='Maximum width of detected region') + parser.add_argument('--max-height', type=int, help='Maximum height of detected region') + parser.add_argument('--single', action='store_true', help='Only output the most likely (largest) region') + parser.add_argument('--quality', action='store_true', help='Use quality mode for selective search (slower, less sensitive)') + parser.add_argument('--k', type=int, default=3000, help='Segmentation parameter k (default: 150)') + parser.add_argument('--min-size', type=int, default=50, help='Segmentation parameter min_size (default: 20)') + parser.add_argument('--sigma', type=float, default=0.6, help='Segmentation parameter sigma (default: 0.8)') + parser.add_argument('--resize-factor', type=float, default=0.1, help='Resize factor for input image before processing (default: 1.0, e.g. 0.5 for half size)') + parser.add_argument('--hyprctl', action='store_true', help='Mimics hyprctl\'s window output, like {"at": [x, y], "size": [w, h]}') + args = parser.parse_args() + + regions, image = find_regions( + args.image, + min_width=args.min_width, + min_height=args.min_height, + max_width=args.max_width, + max_height=args.max_height, + quality=args.quality, + k=args.k, + min_size=args.min_size, + sigma=args.sigma, + resize_factor=args.resize_factor + ) + if args.single and regions: + largest = max(regions, key=lambda r: r['width'] * r['height']) + regions = [largest] + if args.hyprctl: + regions = [{"at": [r['x'], r['y']], "size": [r['width'], r['height']]} for r in regions] + print(json.dumps(regions)) + if args.debug_output: + draw_regions(image, regions, args.debug_output) + +if __name__ == '__main__': + main() + diff --git a/modules/quickshell/config/scripts/images/least-busy-region-venv.sh b/modules/quickshell/config/scripts/images/least-busy-region-venv.sh new file mode 100755 index 0000000..3a5e90d --- /dev/null +++ b/modules/quickshell/config/scripts/images/least-busy-region-venv.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +source $(eval echo $ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate +"$SCRIPT_DIR/least_busy_region.py" "$@" +deactivate diff --git a/modules/quickshell/config/scripts/images/least_busy_region.py b/modules/quickshell/config/scripts/images/least_busy_region.py new file mode 100755 index 0000000..b9e1c89 --- /dev/null +++ b/modules/quickshell/config/scripts/images/least_busy_region.py @@ -0,0 +1,399 @@ +#!/usr/bin/env python3 +# Disclaimer: This script was ai-generated and went through minimal revision. + +import os +os.environ["OPENCV_LOG_LEVEL"] = "SILENT" +import cv2 +import numpy as np +import argparse +import json + +def center_crop(img, target_w, target_h): + h, w = img.shape[:2] + if w == target_w and h == target_h: + return img + x1 = max(0, (w - target_w) // 2) + y1 = max(0, (h - target_h) // 2) + x2 = x1 + target_w + y2 = y1 + target_h + return img[y1:y2, x1:x2] + +def find_least_busy_region(image_path, region_width=300, region_height=200, screen_width=None, screen_height=None, verbose=False, stride=2, screen_mode="fill", horizontal_padding=50, vertical_padding=50, busiest=False): + img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) + if img is None: + raise FileNotFoundError(f"Image not found: {image_path}") + orig_h, orig_w = img.shape + scale = 1.0 + if screen_width is not None and screen_height is not None: + scale_w = screen_width / orig_w + scale_h = screen_height / orig_h + if screen_mode == "fill": + scale = max(scale_w, scale_h) + else: + scale = min(scale_w, scale_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + if verbose: + print(f"Scaling image from {orig_w}x{orig_h} to {new_w}x{new_h} (scale: {scale:.3f}, mode: {screen_mode})") + img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + img = center_crop(img, screen_width, screen_height) + if verbose: + print(f"Cropped image to {screen_width}x{screen_height}") + else: + if verbose: + print(f"Using original image size: {orig_w}x{orig_h}") + arr = img.astype(np.float64) + h, w = arr.shape + # Validate & adjust stride + stride = max(1, int(stride) if stride else 1) + # Adjust region size if it does not fit given padding + if horizontal_padding * 2 >= w or vertical_padding * 2 >= h: + # Reduce padding to fit at least a 1x1 region + horizontal_padding = max(0, min(horizontal_padding, (w - 1) // 2)) + vertical_padding = max(0, min(vertical_padding, (h - 1) // 2)) + max_region_w = w - 2 * horizontal_padding + max_region_h = h - 2 * vertical_padding + if max_region_w <= 0 or max_region_h <= 0: + raise ValueError("Image too small for the specified padding.") + if region_width > max_region_w: + if verbose: + print(f"Requested region_width {region_width} too large; clamping to {max_region_w}") + region_width = max_region_w + if region_height > max_region_h: + if verbose: + print(f"Requested region_height {region_height} too large; clamping to {max_region_h}") + region_height = max_region_h + # Use OpenCV's integral for fast computation + integral = cv2.integral(arr, sdepth=cv2.CV_64F)[1:,1:] + integral_sq = cv2.integral(arr**2, sdepth=cv2.CV_64F)[1:,1:] + def region_sum(ii, x1, y1, x2, y2): + # Assume bounds have been checked before calling + total = ii[y2, x2] + if x1 > 0: + total -= ii[y2, x1-1] + if y1 > 0: + total -= ii[y1-1, x2] + if x1 > 0 and y1 > 0: + total += ii[y1-1, x1-1] + return total + min_var = None + max_var = None + min_coords = (horizontal_padding, vertical_padding) + max_coords = (horizontal_padding, vertical_padding) + area = region_width * region_height + x_start = horizontal_padding + y_start = vertical_padding + x_end = w - region_width - horizontal_padding + 1 + y_end = h - region_height - vertical_padding + 1 + if x_end < x_start: + x_end = x_start + if y_end < y_start: + y_end = y_start + for y in range(y_start, y_end + 1, stride): + for x in range(x_start, x_end + 1, stride): + x1, y1 = x, y + x2, y2 = x + region_width - 1, y + region_height - 1 + if x2 >= w or y2 >= h: + continue # Skip out-of-bounds window + s = region_sum(integral, x1, y1, x2, y2) + s2 = region_sum(integral_sq, x1, y1, x2, y2) + mean = s / area + var = (s2 / area) - (mean ** 2) + if (min_var is None) or (var < min_var): + min_var = var + min_coords = (x, y) + if (max_var is None) or (var > max_var): + max_var = var + max_coords = (x, y) + if busiest: + return max_coords, max_var + else: + return min_coords, min_var + +def find_largest_region(image_path, screen_width=None, screen_height=None, verbose=False, stride=2, screen_mode="fill", threshold=100.0, aspect_ratio=1.0, horizontal_padding=50, vertical_padding=50): + img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) + if img is None: + raise FileNotFoundError(f"Image not found: {image_path}") + orig_h, orig_w = img.shape + # ...existing scaling logic... + scale = 1.0 + if screen_width is not None and screen_height is not None: + scale_w = screen_width / orig_w + scale_h = screen_height / orig_h + if screen_mode == "fill": + scale = max(scale_w, scale_h) + else: + scale = min(scale_w, scale_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + if verbose: + print(f"Scaling image from {orig_w}x{orig_h} to {new_w}x{new_h} (scale: {scale:.3f}, mode: {screen_mode})") + img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + img = center_crop(img, screen_width, screen_height) + if verbose: + print(f"Cropped image to {screen_width}x{screen_height}") + else: + if verbose: + print(f"Using original image size: {orig_w}x{orig_h}") + arr = img.astype(np.float64) + h, w = arr.shape + stride = max(1, int(stride) if stride else 1) + threshold = max(0.0, float(threshold)) + # Adjust padding if image too small + if horizontal_padding * 2 >= w or vertical_padding * 2 >= h: + horizontal_padding = max(0, min(horizontal_padding, (w - 1) // 2)) + vertical_padding = max(0, min(vertical_padding, (h - 1) // 2)) + # Use OpenCV's integral for fast computation + integral = cv2.integral(arr, sdepth=cv2.CV_64F)[1:,1:] + integral_sq = cv2.integral(arr**2, sdepth=cv2.CV_64F)[1:,1:] + def region_sum(ii, x1, y1, x2, y2): + total = ii[y2, x2] + if x1 > 0: + total -= ii[y2, x1-1] + if y1 > 0: + total -= ii[y1-1, x2] + if x1 > 0 and y1 > 0: + total += ii[y1-1, x1-1] + return total + min_size = 10 + # Determine maximum feasible size respecting padding + effective_w = w - 2 * horizontal_padding + effective_h = h - 2 * vertical_padding + if effective_w <= 0 or effective_h <= 0: + return None, (0, 0), None + # Largest square-ish dimension given aspect ratio and effective space + if aspect_ratio >= 1.0: + max_size = min(effective_h, int(effective_w / aspect_ratio)) + else: + max_size = min(int(effective_h * aspect_ratio), effective_w) + if max_size < min_size: + min_size = 1 + max_size = max(1, max_size) + best = None + while min_size <= max_size: + mid = (min_size + max_size) // 2 + if aspect_ratio >= 1.0: + region_h = mid + region_w = int(round(mid * aspect_ratio)) + else: + region_w = mid + region_h = int(round(mid / aspect_ratio if aspect_ratio != 0 else mid)) + if region_w <= 0 or region_h <= 0: + break + if region_w > effective_w or region_h > effective_h: + max_size = mid - 1 + continue + found = False + x_start = horizontal_padding + y_start = vertical_padding + x_end = w - region_w - horizontal_padding + y_end = h - region_h - vertical_padding + for y in range(y_start, y_end + 1, stride): + for x in range(x_start, x_end + 1, stride): + x1, y1 = x, y + x2, y2 = x + region_w - 1, y + region_h - 1 + if x2 >= w or y2 >= h: + continue + s = region_sum(integral, x1, y1, x2, y2) + s2 = region_sum(integral_sq, x1, y1, x2, y2) + area = region_w * region_h + mean = s / area + var = (s2 / area) - (mean ** 2) + if var <= threshold: + found = True + best = (x, y, region_w, region_h, var) + break + if found: + break + if found: + min_size = mid + 1 + else: + max_size = mid - 1 + if best: + x, y, region_w, region_h, var = best + center_x = x + region_w // 2 + center_y = y + region_h // 2 + return (center_x, center_y), (region_w, region_h), var + else: + return None, (0, 0), None + +def draw_region(image_path, coords, region_width=300, region_height=200, output_path='output.png', screen_width=None, screen_height=None, screen_mode="fill"): + img = cv2.imread(image_path) + if img is None: + raise FileNotFoundError(f"Image not found: {image_path}") + orig_h, orig_w = img.shape[:2] + if screen_width is not None and screen_height is not None: + scale_w = screen_width / orig_w + scale_h = screen_height / orig_h + if screen_mode == "fill": + scale = max(scale_w, scale_h) + else: + scale = min(scale_w, scale_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + img = center_crop(img, screen_width, screen_height) + x, y = coords + cv2.rectangle(img, (x, y), (x+region_width-1, y+region_height-1), (0,0,255), 3) + cv2.imwrite(output_path, img) + # print removed for quieter operation + +def draw_largest_region(image_path, center, size, output_path='output.png', screen_width=None, screen_height=None, screen_mode="fill"): + img = cv2.imread(image_path) + if img is None: + raise FileNotFoundError(f"Image not found: {image_path}") + orig_h, orig_w = img.shape[:2] + if screen_width is not None and screen_height is not None: + scale_w = screen_width / orig_w + scale_h = screen_height / orig_h + if screen_mode == "fill": + scale = max(scale_w, scale_h) + else: + scale = min(scale_w, scale_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + img = center_crop(img, screen_width, screen_height) + cx, cy = center + region_w, region_h = size + x1 = cx - region_w // 2 + y1 = cy - region_h // 2 + x2 = cx + region_w // 2 - 1 + y2 = cy + region_h // 2 - 1 + cv2.rectangle(img, (x1, y1), (x2, y2), (255,0,0), 3) + cv2.imwrite(output_path, img) + # print removed for quieter operation + +def get_dominant_color(image_path, x, y, w, h, screen_width=None, screen_height=None, screen_mode="fill"): + img = cv2.imread(image_path) + if img is None: + raise FileNotFoundError(f"Image not found: {image_path}") + orig_h, orig_w = img.shape[:2] + if screen_width is not None and screen_height is not None: + scale_w = screen_width / orig_w + scale_h = screen_height / orig_h + if screen_mode == "fill": + scale = max(scale_w, scale_h) + else: + scale = min(scale_w, scale_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + img = center_crop(img, screen_width, screen_height) + # Ensure region is within bounds + x = max(0, x) + y = max(0, y) + w = max(1, min(w, img.shape[1] - x)) + h = max(1, min(h, img.shape[0] - y)) + region = img[y:y+h, x:x+w] + if region.size == 0 or region.shape[0] == 0 or region.shape[1] == 0: + return [0, 0, 0] + region = region.reshape((-1, 3)) + # Filter out black pixels (optional, improves accuracy for some images) + non_black = region[np.any(region > 10, axis=1)] + if non_black.shape[0] == 0: + non_black = region + region = np.float32(non_black) + if region.shape[0] < 3: + return [int(x) for x in np.mean(region, axis=0)] + # K-means to find dominant color + criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0) + K = min(3, region.shape[0]) + _, labels, centers = cv2.kmeans(region, K, None, criteria, 10, cv2.KMEANS_RANDOM_CENTERS) + counts = np.bincount(labels.flatten()) + dominant = centers[np.argmax(counts)] + # Reverse from BGR to RGB + return [int(x) for x in reversed(dominant)] + +def main(): + parser = argparse.ArgumentParser(description="Find least busy region in an image and output a JSON. Made for determining a suitable position for a wallpaper widget.") + parser.add_argument("image_path", help="Path to the input image") + parser.add_argument("--width", type=int, default=300, help="Region width") + parser.add_argument("--height", type=int, default=200, help="Region height") + parser.add_argument("-v", "--visual-output", action="store_true", help="Output image with rectangle") + parser.add_argument("--screen-width", type=int, default=1920, help="Screen width for wallpaper scaling") + parser.add_argument("--screen-height", type=int, default=1080, help="Screen height for wallpaper scaling") + parser.add_argument("--stride", type=int, default=10, help="Step size for sliding window (higher is faster, less precise)") + parser.add_argument("--screen-mode", choices=["fill", "fit"], default="fill", help="Wallpaper scaling mode: 'fill' (default) or 'fit'") + parser.add_argument("--verbose", action="store_true", help="Print verbose output") + parser.add_argument("-l", "--largest-region", action="store_true", help="Find the largest region under the variance threshold and output its center") + parser.add_argument("-t", "--variance-threshold", type=float, default=1000.0, help="Variance threshold for largest region mode") + parser.add_argument("--aspect-ratio", type=float, default=1.78, help="Aspect ratio (width/height) for largest region mode") + parser.add_argument("--horizontal-padding", "-hp", type=int, default=50, help="Minimum horizontal distance from region to image edge") + parser.add_argument("--vertical-padding", "-vp", type=int, default=50, help="Minimum vertical distance from region to image edge") + parser.add_argument("--busiest", action="store_true", help="Find the busiest region instead of the least busy") + args = parser.parse_args() + + if args.largest_region: + center, size, var = find_largest_region( + args.image_path, + screen_width=args.screen_width, + screen_height=args.screen_height, + verbose=args.verbose, + stride=args.stride, + screen_mode=args.screen_mode, + threshold=args.variance_threshold, + aspect_ratio=args.aspect_ratio, + horizontal_padding=args.horizontal_padding, + vertical_padding=args.vertical_padding + ) + if center: + if args.visual_output: + draw_largest_region(args.image_path, center, size, screen_width=args.screen_width, screen_height=args.screen_height, screen_mode=args.screen_mode) + # Extract dominant color + cx, cy = center + region_w, region_h = size + x1 = cx - region_w // 2 + y1 = cy - region_h // 2 + dominant_color = get_dominant_color( + args.image_path, x1, y1, region_w, region_h, + screen_width=args.screen_width, screen_height=args.screen_height, screen_mode=args.screen_mode + ) + dominant_color_hex = '#{:02x}{:02x}{:02x}'.format(*dominant_color) + print(json.dumps({ + "center_x": center[0], + "center_y": center[1], + "width": size[0], + "height": size[1], + "variance": var, + "dominant_color": dominant_color_hex + })) + else: + print(json.dumps({"error": "No region found under the threshold."})) + return + + coords, variance = find_least_busy_region( + args.image_path, + region_width=args.width, + region_height=args.height, + screen_width=args.screen_width, + screen_height=args.screen_height, + verbose=args.verbose, + stride=args.stride, + screen_mode=args.screen_mode, + horizontal_padding=args.horizontal_padding, + vertical_padding=args.vertical_padding, + busiest=args.busiest + ) + if args.visual_output: + draw_region(args.image_path, coords, region_width=args.width, region_height=args.height, screen_width=args.screen_width, screen_height=args.screen_height, screen_mode=args.screen_mode) + # Output JSON with center point + center_x = coords[0] + args.width // 2 + center_y = coords[1] + args.height // 2 + dominant_color = get_dominant_color( + args.image_path, coords[0], coords[1], args.width, args.height, + screen_width=args.screen_width, screen_height=args.screen_height, screen_mode=args.screen_mode + ) + dominant_color_hex = '#{:02x}{:02x}{:02x}'.format(*dominant_color) + print(json.dumps({ + "center_x": center_x, + "center_y": center_y, + "width": args.width, + "height": args.height, + "variance": variance, + "dominant_color": dominant_color_hex + })) + +if __name__ == "__main__": + main() + diff --git a/modules/quickshell/config/scripts/keyring/is_unlocked.sh b/modules/quickshell/config/scripts/keyring/is_unlocked.sh new file mode 100755 index 0000000..d4063d8 --- /dev/null +++ b/modules/quickshell/config/scripts/keyring/is_unlocked.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +locked_state=$(busctl --user get-property org.freedesktop.secrets \ + /org/freedesktop/secrets/collection/login \ + org.freedesktop.Secret.Collection Locked) +if [[ "${locked_state}" == "b false" ]]; then + echo 'Keyring is unlocked' >&2 + exit 0 +else + echo 'Keyring is locked' >&2 + exit 1 +fi diff --git a/modules/quickshell/config/scripts/keyring/try_lookup.sh b/modules/quickshell/config/scripts/keyring/try_lookup.sh new file mode 100755 index 0000000..a076aac --- /dev/null +++ b/modules/quickshell/config/scripts/keyring/try_lookup.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +data=$(secret-tool lookup 'application' 'illogical-impulse') +if [[ -z "$data" ]]; then + if "${SCRIPT_DIR}/is_unlocked.sh"; then + echo 'not found' + exit 1 + else + echo 'locked' + exit 2 + fi +fi +echo "$data" diff --git a/modules/quickshell/config/scripts/keyring/unlock.sh b/modules/quickshell/config/scripts/keyring/unlock.sh new file mode 100755 index 0000000..30509aa --- /dev/null +++ b/modules/quickshell/config/scripts/keyring/unlock.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Based on https://unix.stackexchange.com/a/602935 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Skip if already unlocked +if "${SCRIPT_DIR}/is_unlocked.sh"; then + exit 1 +fi + +# Prompt for password if not provided +if [[ -z "${UNLOCK_PASSWORD}" ]]; then + echo -n 'Login password: ' >&2 + read -s UNLOCK_PASSWORD || return +fi + +# Unlock +killall -q -u "$(whoami)" gnome-keyring-daemon +eval $(echo -n "${UNLOCK_PASSWORD}" \ + | gnome-keyring-daemon --daemonize --login \ + | sed -e 's/^/export /') +unset UNLOCK_PASSWORD +echo '' >&2 diff --git a/modules/quickshell/config/scripts/musicRecognition/recognize-music.sh b/modules/quickshell/config/scripts/musicRecognition/recognize-music.sh new file mode 100755 index 0000000..183432b --- /dev/null +++ b/modules/quickshell/config/scripts/musicRecognition/recognize-music.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +INTERVAL=2 +TOTAL_DURATION=30 +MIN_VALID_RESULT_LENGTH=300 +SOURCE_TYPE="monitor" # monitor | input +TMP_PATH="/tmp/quickshell/media/songrec" +TMP_RAW="$TMP_PATH/recording.raw" +TMP_MP3="$TMP_PATH/recording.mp3" + +while getopts "i:t:s:" opt; do + case $opt in + i) INTERVAL=$OPTARG ;; + t) TOTAL_DURATION=$OPTARG ;; + s) SOURCE_TYPE=$OPTARG ;; + *) exit 1 ;; + esac +done +if [ "$SOURCE_TYPE" = "monitor" ]; then + MONITOR_SOURCE=$(pactl get-default-sink).monitor +elif [ "$SOURCE_TYPE" = "input" ]; then + MONITOR_SOURCE=$(pactl info | grep "Default Source:" | awk '{print $3}' || true) +else + echo "Invalid source type" + exit 1 +fi + +if ! command -v songrec >/dev/null 2>&1 || ! command -v parec >/dev/null 2>&1 || ! command -v ffmpeg >/dev/null 2>&1; then + exit 1 +fi + +if [ -z "$MONITOR_SOURCE" ] || ! pactl list short sources | grep -q "$MONITOR_SOURCE"; then + exit 1 +fi + +cleanup() { + rm -f "$TMP_RAW" "$TMP_MP3" + pkill -P $$ parec >/dev/null 2>&1 || true +} +trap cleanup EXIT + +mkdir -p "$TMP_PATH" +parec --device="$MONITOR_SOURCE" --format=s16le --rate=44100 --channels=2 > "$TMP_RAW" & +START_TIME=$(date +%s) + +while true; do + sleep "$INTERVAL" + CURRENT_TIME=$(date +%s) + ELAPSED=$((CURRENT_TIME - START_TIME)) + + if (( ELAPSED >= TOTAL_DURATION )); then + exit 0 + fi + + ffmpeg -f s16le -ar 44100 -ac 2 -i "$TMP_RAW" -acodec libmp3lame -y -hide_banner -loglevel error "$TMP_MP3" 2>/dev/null + RESULT=$(songrec audio-file-to-recognized-song "$TMP_MP3" 2>/dev/null || true) + + if echo "$RESULT" | grep -q '"matches": \[' && [ ${#RESULT} -gt $MIN_VALID_RESULT_LENGTH ]; then + echo "$RESULT" + exit 0 + fi +done diff --git a/modules/quickshell/config/scripts/thumbnails/generate-thumbnails-magick.sh b/modules/quickshell/config/scripts/thumbnails/generate-thumbnails-magick.sh new file mode 100755 index 0000000..a5c8581 --- /dev/null +++ b/modules/quickshell/config/scripts/thumbnails/generate-thumbnails-magick.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash + +# Generate thumbnails for files using ImageMagick, following Freedesktop spec +# Usage: +# ./generate-thumbnails-magick.sh --file +# ./generate-thumbnails-magick.sh --directory + +set -e + +# Thumbnail sizes mapping +get_thumbnail_size() { + case "$1" in + normal) echo 128 ;; + large) echo 256 ;; + x-large) echo 512 ;; + xx-large) echo 1024 ;; + *) echo 128 ;; + esac +} + +usage() { + echo "Usage: $0 --file | --directory " + exit 1 +} + +md5() { + # Calculate md5 hash of the file's absolute path + echo -n "$1" | md5sum | awk '{print $1}' +} + +urlencode() { + # Percent-encode a string for use in a URI, but do not encode slashes + local str="$1" + local encoded="" + local c + for ((i=0; i<${#str}; i++)); do + c="${str:$i:1}" + case "$c" in + [a-zA-Z0-9.~_-]|/) encoded+="$c" ;; + *) printf -v hex '%%%02X' "'${c}'"; encoded+="$hex" ;; + esac + done + echo "$encoded" +} + +generate_thumbnail() { + local src="$1" + local abs_path + abs_path="$(realpath "$src")" + # Skip files with multiple frames (GIFs, videos, etc.) + case "${abs_path,,}" in + *.gif|*.mp4|*.webm|*.mkv|*.avi|*.mov) + return + ;; + esac + local encoded_path + encoded_path="$(urlencode "$abs_path")" + local uri + uri="file://$encoded_path" + local hash + hash="$(md5 "$uri")" + local out="$CACHE_DIR/$hash.png" + mkdir -p "$CACHE_DIR" + if [ -f "$out" ]; then + return + fi + magick "$abs_path" -resize "${THUMBNAIL_SIZE}x${THUMBNAIL_SIZE}" "$out" +} + +# Parse arguments +SIZE_NAME="normal" +MODE="" +TARGET="" +while [[ $# -gt 0 ]]; do + case "$1" in + --file|-f) + MODE="file" + TARGET="$2" + shift 2 + ;; + --directory|-d) + MODE="dir" + TARGET="$2" + shift 2 + ;; + --size|-s) + SIZE_NAME="$2" + shift 2 + ;; + *) + usage + ;; + esac + # Only one mode allowed + [[ -n "$MODE" ]] && break +done + +THUMBNAIL_SIZE="$(get_thumbnail_size "$SIZE_NAME")" +CACHE_DIR="$HOME/.cache/thumbnails/$SIZE_NAME" + +if [ -z "$MODE" ] || [ -z "$TARGET" ]; then + usage +fi + +case "$MODE" in + file) + if [ ! -f "$TARGET" ]; then + echo "File not found: $TARGET" + exit 2 + fi + generate_thumbnail "$TARGET" + ;; + dir) + if [ ! -d "$TARGET" ]; then + echo "Directory not found: $TARGET" + exit 2 + fi + for f in "$TARGET"/*; do + [ -f "$f" ] || continue + generate_thumbnail "$f" & + done + wait + ;; + *) + usage + ;; +esac + diff --git a/modules/quickshell/config/scripts/thumbnails/thumbgen-venv.sh b/modules/quickshell/config/scripts/thumbnails/thumbgen-venv.sh new file mode 100755 index 0000000..fc7d872 --- /dev/null +++ b/modules/quickshell/config/scripts/thumbnails/thumbgen-venv.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +source $(eval echo $ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate +"$SCRIPT_DIR/thumbgen.py" "$@" +deactivate + diff --git a/modules/quickshell/config/scripts/thumbnails/thumbgen.py b/modules/quickshell/config/scripts/thumbnails/thumbgen.py new file mode 100755 index 0000000..69f6307 --- /dev/null +++ b/modules/quickshell/config/scripts/thumbnails/thumbgen.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 + +# From https://github.com/difference-engine/thumbnail-generator-ubuntu (MIT License) +# Since the script is small and the maintainers seem inactive to accept my PR (#11) I decided to just copy it over. +# When it gets merged and the python package gets updated we can just use it + +import os +import sys +from multiprocessing import Pool +from pathlib import Path +from typing import List, Union + +import click +import gi +from loguru import logger +from tqdm import tqdm + +gi.require_version("GnomeDesktop", "3.0") +from gi.repository import Gio, GnomeDesktop # isort:skip + +thumbnail_size_map = { + "normal": GnomeDesktop.DesktopThumbnailSize.NORMAL, + "large": GnomeDesktop.DesktopThumbnailSize.LARGE, + "x-large": GnomeDesktop.DesktopThumbnailSize.XLARGE, + "xx-large": GnomeDesktop.DesktopThumbnailSize.XXLARGE, +} + +factory = None +logger.remove() +logger.add(sys.stdout, level="INFO") +logger.add("/tmp/thumbgen.log", level="DEBUG", rotation="100 MB") + +def make_thumbnail(fpath: str) -> bool: + mtime = os.path.getmtime(fpath) + # Use Gio to determine the URI and mime type + f = Gio.file_new_for_path(str(fpath)) + uri = f.get_uri() + info = f.query_info("standard::content-type", Gio.FileQueryInfoFlags.NONE, None) + mime_type = info.get_content_type() + + if factory.lookup(uri, mtime) is not None: + logger.debug("FRESH {}".format(uri)) + return False + + if not factory.can_thumbnail(uri, mime_type, mtime): + logger.debug("UNSUPPORTED {}".format(uri)) + return False + + thumbnail = factory.generate_thumbnail(uri, mime_type) + if thumbnail is None: + logger.debug("ERROR {}".format(uri)) + return False + + logger.debug("OK {}".format(uri)) + factory.save_thumbnail(thumbnail, uri, mtime) + return True + + +@logger.catch() +def thumbnail_folder(*, dir_path: Path, workers: int, only_images: bool, recursive: bool, machine_progress: bool = False) -> None: + all_files = get_all_files(dir_path=dir_path, recursive=recursive) + if only_images: + all_files = get_all_images(all_files=all_files) + all_files = [str(fpath) for fpath in all_files] + if machine_progress: + completed = 0 + total = len(all_files) + with Pool(processes=workers) as p: + for result in p.imap(make_thumbnail, all_files): + completed += 1 + print(f"PROGRESS {completed}/{total} FILE {all_files[completed-1]}") + sys.stdout.flush() + else: + with Pool(processes=workers) as p: + list(tqdm(p.imap(make_thumbnail, all_files), total=len(all_files))) + + +def get_all_images(*, all_files: List[Path]) -> List[Path]: + img_suffixes = [".jpg", ".jpeg", ".png", ".gif"] + all_images = [fpath for fpath in all_files if fpath.suffix in img_suffixes] + print("Found {} images".format(len(all_images))) + return all_images + + +def get_all_files(*, dir_path: Path, recursive: bool) -> List[Path]: + if not (dir_path.exists() and dir_path.is_dir()): + raise ValueError("{} doesn't exist or isn't a valid directory!".format(dir_path.resolve())) + if recursive: + all_files = dir_path.rglob("*") + else: + all_files = dir_path.glob("*") + all_files = [fpath for fpath in all_files if fpath.is_file()] + print("Found {} files in the directory: {}".format(len(all_files), dir_path.resolve())) + return all_files + +@click.command() +@click.option( + "-d", "--img_dirs", required=True, help='directories to generate thumbnails seperated by space, eg: "dir1/dir2 dir3"' +) +@click.option( + "-s", "--size", default="normal", type=click.Choice(["normal", "large", "x-large", "xx-large"]), help="Thumbnail size: normal, large, x-large, xx-large" +) +@click.option("-w", "--workers", default=1, help="no of cpus to use for processing") +@click.option( + "-i", "--only_images", is_flag=True, default=False, help="Whether to only look for images to be thumbnailed" +) +@click.option("-r", "--recursive", is_flag=True, default=False, help="Whether to recursively look for files") +@click.option("--machine_progress", is_flag=True, default=False, help="Print machine-readable progress lines instead of a progress bar") +def main(img_dirs: str, size: str, workers: str, only_images: bool, recursive: bool, machine_progress: bool) -> None: + img_dirs = [Path(img_dir) for img_dir in img_dirs.split()] + global factory + factory = GnomeDesktop.DesktopThumbnailFactory.new(thumbnail_size_map[size]) + for img_dir in img_dirs: + thumbnail_folder(dir_path=img_dir, workers=workers, only_images=only_images, recursive=recursive, machine_progress=machine_progress) + print("Thumbnail Generation Completed!") + + +if __name__ == "__main__": + main() diff --git a/modules/quickshell/config/scripts/videos/record.sh b/modules/quickshell/config/scripts/videos/record.sh new file mode 100755 index 0000000..bf0ab50 --- /dev/null +++ b/modules/quickshell/config/scripts/videos/record.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +CONFIG_FILE="$HOME/.config/illogical-impulse/config.json" +JSON_PATH=".screenRecord.savePath" + +CUSTOM_PATH=$(jq -r "$JSON_PATH" "$CONFIG_FILE" 2>/dev/null) + +RECORDING_DIR="" + +if [[ -n "$CUSTOM_PATH" ]]; then + RECORDING_DIR="$CUSTOM_PATH" +else + RECORDING_DIR="$HOME/Videos" # Use default path +fi + +getdate() { + date '+%Y-%m-%d_%H.%M.%S' +} +getaudiooutput() { + pactl list sources | grep 'Name' | grep 'monitor' | cut -d ' ' -f2 +} +getactivemonitor() { + hyprctl monitors -j | jq -r '.[] | select(.focused == true) | .name' +} + +mkdir -p "$RECORDING_DIR" +cd "$RECORDING_DIR" || exit + +# parse --region without modifying $@ so other flags like --fullscreen still work +ARGS=("$@") +MANUAL_REGION="" +SOUND_FLAG=0 +FULLSCREEN_FLAG=0 +for ((i=0;i<${#ARGS[@]};i++)); do + if [[ "${ARGS[i]}" == "--region" ]]; then + if (( i+1 < ${#ARGS[@]} )); then + MANUAL_REGION="${ARGS[i+1]}" + else + notify-send "Recording cancelled" "No region specified for --region" -a 'Recorder' & disown + exit 1 + fi + elif [[ "${ARGS[i]}" == "--sound" ]]; then + SOUND_FLAG=1 + elif [[ "${ARGS[i]}" == "--fullscreen" ]]; then + FULLSCREEN_FLAG=1 + fi +done + +if pgrep wf-recorder > /dev/null; then + notify-send "Recording Stopped" "Stopped" -a 'Recorder' & + pkill wf-recorder & +else + if [[ $FULLSCREEN_FLAG -eq 1 ]]; then + notify-send "Starting recording" 'recording_'"$(getdate)"'.mp4' -a 'Recorder' & disown + if [[ $SOUND_FLAG -eq 1 ]]; then + wf-recorder -o "$(getactivemonitor)" --pixel-format yuv420p -f './recording_'"$(getdate)"'.mp4' -t --audio="$(getaudiooutput)" + else + wf-recorder -o "$(getactivemonitor)" --pixel-format yuv420p -f './recording_'"$(getdate)"'.mp4' -t + fi + else + # If a manual region was provided via --region, use it; otherwise run slurp as before. + if [[ -n "$MANUAL_REGION" ]]; then + region="$MANUAL_REGION" + else + if ! region="$(slurp 2>&1)"; then + notify-send "Recording cancelled" "Selection was cancelled" -a 'Recorder' & disown + exit 1 + fi + fi + + notify-send "Starting recording" 'recording_'"$(getdate)"'.mp4' -a 'Recorder' & disown + if [[ $SOUND_FLAG -eq 1 ]]; then + wf-recorder --pixel-format yuv420p -f './recording_'"$(getdate)"'.mp4' -t --geometry "$region" --audio="$(getaudiooutput)" + else + wf-recorder --pixel-format yuv420p -f './recording_'"$(getdate)"'.mp4' -t --geometry "$region" + fi + fi +fi \ No newline at end of file diff --git a/modules/quickshell/config/services/AppSearch.qml b/modules/quickshell/config/services/AppSearch.qml new file mode 100644 index 0000000..358b8d2 --- /dev/null +++ b/modules/quickshell/config/services/AppSearch.qml @@ -0,0 +1,164 @@ +pragma Singleton + +import qs.modules.common +import qs.modules.common.functions +import Quickshell + +/** + * - Eases fuzzy searching for applications by name + * - Guesses icon name for window class name + */ +Singleton { + id: root + property bool sloppySearch: Config.options?.search.sloppy ?? false + property real scoreThreshold: 0.2 + property var substitutions: ({ + "code-url-handler": "visual-studio-code", + "Code": "visual-studio-code", + "gnome-tweaks": "org.gnome.tweaks", + "pavucontrol-qt": "pavucontrol", + "wps": "wps-office2019-kprometheus", + "wpsoffice": "wps-office2019-kprometheus", + "footclient": "foot", + }) + property var regexSubstitutions: [ + { + "regex": /^steam_app_(\d+)$/, + "replace": "steam_icon_$1" + }, + { + "regex": /Minecraft.*/, + "replace": "minecraft" + }, + { + "regex": /.*polkit.*/, + "replace": "system-lock-screen" + }, + { + "regex": /gcr.prompter/, + "replace": "system-lock-screen" + } + ] + + // Deduped list to fix double icons + readonly property list list: Array.from(DesktopEntries.applications.values) + .filter((app, index, self) => + index === self.findIndex((t) => ( + t.id === app.id + )) + ) + + readonly property var preppedNames: list.map(a => ({ + name: Fuzzy.prepare(`${a.name} `), + entry: a + })) + + readonly property var preppedIcons: list.map(a => ({ + name: Fuzzy.prepare(`${a.icon} `), + entry: a + })) + + function fuzzyQuery(search: string): var { // Idk why list doesn't work + if (root.sloppySearch) { + const results = list.map(obj => ({ + entry: obj, + score: Levendist.computeScore(obj.name.toLowerCase(), search.toLowerCase()) + })).filter(item => item.score > root.scoreThreshold) + .sort((a, b) => b.score - a.score) + return results + .map(item => item.entry) + } + + return Fuzzy.go(search, preppedNames, { + all: true, + key: "name" + }).map(r => { + return r.obj.entry + }); + } + + function iconExists(iconName) { + if (!iconName || iconName.length == 0) return false; + return (Quickshell.iconPath(iconName, true).length > 0) + && !iconName.includes("image-missing"); + } + + function getReverseDomainNameAppName(str) { + return str.split('.').slice(-1)[0] + } + + function getKebabNormalizedAppName(str) { + return str.toLowerCase().replace(/\s+/g, "-"); + } + + function getUndescoreToKebabAppName(str) { + return str.toLowerCase().replace(/_/g, "-"); + } + + function guessIcon(str) { + if (!str || str.length == 0) return "image-missing"; + + // Quickshell's desktop entry lookup + const entry = DesktopEntries.byId(str); + if (entry) return entry.icon; + + // Normal substitutions + if (substitutions[str]) return substitutions[str]; + if (substitutions[str.toLowerCase()]) return substitutions[str.toLowerCase()]; + + // Regex substitutions + for (let i = 0; i < regexSubstitutions.length; i++) { + const substitution = regexSubstitutions[i]; + const replacedName = str.replace( + substitution.regex, + substitution.replace, + ); + if (replacedName != str) return replacedName; + } + + // Icon exists -> return as is + if (iconExists(str)) return str; + + + // Simple guesses + const lowercased = str.toLowerCase(); + if (iconExists(lowercased)) return lowercased; + + const reverseDomainNameAppName = getReverseDomainNameAppName(str); + if (iconExists(reverseDomainNameAppName)) return reverseDomainNameAppName; + + const lowercasedDomainNameAppName = reverseDomainNameAppName.toLowerCase(); + if (iconExists(lowercasedDomainNameAppName)) return lowercasedDomainNameAppName; + + const kebabNormalizedGuess = getKebabNormalizedAppName(str); + if (iconExists(kebabNormalizedGuess)) return kebabNormalizedGuess; + + const undescoreToKebabGuess = getUndescoreToKebabAppName(str); + if (iconExists(undescoreToKebabGuess)) return undescoreToKebabGuess; + + // Search in desktop entries + const iconSearchResults = Fuzzy.go(str, preppedIcons, { + all: true, + key: "name" + }).map(r => { + return r.obj.entry + }); + if (iconSearchResults.length > 0) { + const guess = iconSearchResults[0].icon + if (iconExists(guess)) return guess; + } + + const nameSearchResults = root.fuzzyQuery(str); + if (nameSearchResults.length > 0) { + const guess = nameSearchResults[0].icon + if (iconExists(guess)) return guess; + } + + // Quickshell's desktop entry lookup + const heuristicEntry = DesktopEntries.heuristicLookup(str); + if (heuristicEntry) return heuristicEntry.icon; + + // Give up + return "application-x-executable"; + } +} diff --git a/modules/quickshell/config/services/Audio.qml b/modules/quickshell/config/services/Audio.qml new file mode 100644 index 0000000..4b45701 --- /dev/null +++ b/modules/quickshell/config/services/Audio.qml @@ -0,0 +1,138 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire + +/** + * A nice wrapper for default Pipewire audio sink and source. + */ +Singleton { + id: root + + // Misc props + property bool ready: Pipewire.defaultAudioSink?.ready ?? false + property PwNode sink: Pipewire.defaultAudioSink + property PwNode source: Pipewire.defaultAudioSource + readonly property real hardMaxValue: 2.00 // People keep joking about setting volume to 5172% so... + property string audioTheme: Config.options.sounds.theme + property real value: sink?.audio.volume ?? 0 + function friendlyDeviceName(node) { + return (node.nickname || node.description || Translation.tr("Unknown")); + } + function appNodeDisplayName(node) { + return (node.properties["application.name"] || node.description || node.name) + } + + // Lists + function correctType(node, isSink) { + return (node.isSink === isSink) && node.audio + } + function appNodes(isSink) { + return Pipewire.nodes.values.filter((node) => { // Should be list but it breaks ScriptModel + return root.correctType(node, isSink) && node.isStream + }) + } + function devices(isSink) { + return Pipewire.nodes.values.filter(node => { + return root.correctType(node, isSink) && !node.isStream + }) + } + readonly property list outputAppNodes: root.appNodes(true) + readonly property list inputAppNodes: root.appNodes(false) + readonly property list outputDevices: root.devices(true) + readonly property list inputDevices: root.devices(false) + + // Signals + signal sinkProtectionTriggered(string reason); + + // Controls + function toggleMute() { + Audio.sink.audio.muted = !Audio.sink.audio.muted + } + + function toggleMicMute() { + Audio.source.audio.muted = !Audio.source.audio.muted + } + + function incrementVolume() { + const currentVolume = Audio.value; + const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2; + Audio.sink.audio.volume = Math.min(1, Audio.sink.audio.volume + step); + } + + function decrementVolume() { + const currentVolume = Audio.value; + const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2; + Audio.sink.audio.volume -= step; + } + + function setDefaultSink(node) { + Pipewire.preferredDefaultAudioSink = node; + } + + function setDefaultSource(node) { + Pipewire.preferredDefaultAudioSource = node; + } + + // Internals + PwObjectTracker { + objects: [sink, source] + } + + Connections { // Protection against sudden volume changes + target: sink?.audio ?? null + property bool lastReady: false + property real lastVolume: 0 + function onVolumeChanged() { + if (!Config.options.audio.protection.enable) return; + const newVolume = sink.audio.volume; + // when resuming from suspend, we should not write volume to avoid pipewire volume reset issues + if (isNaN(newVolume) || newVolume === undefined || newVolume === null) { + lastReady = false; + lastVolume = 0; + return; + } + if (!lastReady) { + lastVolume = newVolume; + lastReady = true; + return; + } + const maxAllowedIncrease = Config.options.audio.protection.maxAllowedIncrease / 100; + const maxAllowed = Config.options.audio.protection.maxAllowed / 100; + + if (newVolume - lastVolume > maxAllowedIncrease) { + sink.audio.volume = lastVolume; + root.sinkProtectionTriggered(Translation.tr("Illegal increment")); + } else if (newVolume > maxAllowed || newVolume > root.hardMaxValue) { + root.sinkProtectionTriggered(Translation.tr("Exceeded max allowed")); + sink.audio.volume = Math.min(lastVolume, maxAllowed); + } + lastVolume = sink.audio.volume; + } + } + + function playSystemSound(soundName) { + const ogaPath = `/usr/share/sounds/${root.audioTheme}/stereo/${soundName}.oga`; + const oggPath = `/usr/share/sounds/${root.audioTheme}/stereo/${soundName}.ogg`; + + // Try playing .oga first + let command = [ + "ffplay", + "-nodisp", + "-autoexit", + ogaPath + ]; + Quickshell.execDetached(command); + + // Also try playing .ogg (ffplay will just fail silently if file doesn't exist) + command = [ + "ffplay", + "-nodisp", + "-autoexit", + oggPath + ]; + Quickshell.execDetached(command); + } +} diff --git a/modules/quickshell/config/services/Battery.qml b/modules/quickshell/config/services/Battery.qml new file mode 100644 index 0000000..e2a285f --- /dev/null +++ b/modules/quickshell/config/services/Battery.qml @@ -0,0 +1,108 @@ +pragma Singleton + +import qs.services +import qs.modules.common +import Quickshell +import Quickshell.Services.UPower +import QtQuick +import Quickshell.Io + +Singleton { + id: root + property bool available: UPower.displayDevice.isLaptopBattery + property var chargeState: UPower.displayDevice.state + property bool isCharging: chargeState == UPowerDeviceState.Charging + property bool isPluggedIn: isCharging || chargeState == UPowerDeviceState.PendingCharge + property real percentage: UPower.displayDevice?.percentage ?? 1 + readonly property bool allowAutomaticSuspend: Config.options.battery.automaticSuspend + readonly property bool soundEnabled: Config.options.sounds.battery + + property bool isLow: available && (percentage <= Config.options.battery.low / 100) + property bool isCritical: available && (percentage <= Config.options.battery.critical / 100) + property bool isSuspending: available && (percentage <= Config.options.battery.suspend / 100) + property bool isFull: available && (percentage >= Config.options.battery.full / 100) + + property bool isLowAndNotCharging: isLow && !isCharging + property bool isCriticalAndNotCharging: isCritical && !isCharging + property bool isSuspendingAndNotCharging: allowAutomaticSuspend && isSuspending && !isCharging + property bool isFullAndCharging: isFull && isCharging + + property real energyRate: UPower.displayDevice.changeRate + property real timeToEmpty: UPower.displayDevice.timeToEmpty + property real timeToFull: UPower.displayDevice.timeToFull + + property real health: (function() { + const devList = UPower.devices.values; + for (let i = 0; i < devList.length; ++i) { + const dev = devList[i]; + if (dev.isLaptopBattery && dev.healthSupported) { + const health = dev.healthPercentage; + if (health === 0) { + return 0.01; + } else if (health < 1) { + return health * 100; + } else { + return health; + } + } + } + return 0; + })() + + + onIsLowAndNotChargingChanged: { + if (!root.available || !isLowAndNotCharging) return; + Quickshell.execDetached([ + "notify-send", + Translation.tr("Low battery"), + Translation.tr("Consider plugging in your device"), + "-u", "critical", + "-a", "Shell", + "--hint=int:transient:1", + ]) + + if (root.soundEnabled) Audio.playSystemSound("dialog-warning"); + } + + onIsCriticalAndNotChargingChanged: { + if (!root.available || !isCriticalAndNotCharging) return; + Quickshell.execDetached([ + "notify-send", + Translation.tr("Critically low battery"), + Translation.tr("Please charge!\nAutomatic suspend triggers at %1%").arg(Config.options.battery.suspend), + "-u", "critical", + "-a", "Shell", + "--hint=int:transient:1", + ]); + + if (root.soundEnabled) Audio.playSystemSound("suspend-error"); + } + + onIsSuspendingAndNotChargingChanged: { + if (root.available && isSuspendingAndNotCharging) { + Quickshell.execDetached(["bash", "-c", `systemctl suspend || loginctl suspend`]); + } + } + + onIsFullAndChargingChanged: { + if (!root.available || !isFullAndCharging) return; + Quickshell.execDetached([ + "notify-send", + Translation.tr("Battery full"), + Translation.tr("Please unplug the charger"), + "-a", "Shell", + "--hint=int:transient:1", + ]); + + if (root.soundEnabled) Audio.playSystemSound("complete"); + } + + onIsPluggedInChanged: { + if (!root.available || !root.soundEnabled) return; + if (isPluggedIn) { + Audio.playSystemSound("power-plug") + } else { + Audio.playSystemSound("power-unplug") + } + } +} diff --git a/modules/quickshell/config/services/BluetoothStatus.qml b/modules/quickshell/config/services/BluetoothStatus.qml new file mode 100644 index 0000000..479b9a9 --- /dev/null +++ b/modules/quickshell/config/services/BluetoothStatus.qml @@ -0,0 +1,37 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property bool available: Bluetooth.adapters.values.length > 0 + readonly property bool enabled: Bluetooth.defaultAdapter?.enabled ?? false + readonly property BluetoothDevice firstActiveDevice: Bluetooth.defaultAdapter?.devices.values.find(device => device.connected) ?? null + readonly property int activeDeviceCount: Bluetooth.defaultAdapter?.devices.values.filter(device => device.connected).length ?? 0 + readonly property bool connected: Bluetooth.devices.values.some(d => d.connected) + + function sortFunction(a, b) { + // Ones with meaningful names before MAC addresses + const macRegex = /^([0-9A-Fa-f]{2}-){5}[0-9A-Fa-f]{2}$/; + const aIsMac = macRegex.test(a.name); + const bIsMac = macRegex.test(b.name); + if (aIsMac !== bIsMac) + return aIsMac ? 1 : -1; + + // Alphabetical by name + return a.name.localeCompare(b.name); + } + property list connectedDevices: Bluetooth.devices.values.filter(d => d.connected).sort(sortFunction) + property list pairedButNotConnectedDevices: Bluetooth.devices.values.filter(d => d.paired && !d.connected).sort(sortFunction) + property list unpairedDevices: Bluetooth.devices.values.filter(d => !d.paired && !d.connected).sort(sortFunction) + property list friendlyDeviceList: [ + ...connectedDevices, + ...pairedButNotConnectedDevices, + ...unpairedDevices + ] +} diff --git a/modules/quickshell/config/services/Brightness.qml b/modules/quickshell/config/services/Brightness.qml new file mode 100644 index 0000000..cd99127 --- /dev/null +++ b/modules/quickshell/config/services/Brightness.qml @@ -0,0 +1,259 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +// From https://github.com/caelestia-dots/shell with modifications. +// License: GPLv3 + +import qs.modules.common +import qs.modules.common.functions +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import QtQuick + +/** + * For managing brightness of monitors. Supports both brightnessctl and ddcutil. + */ +Singleton { + id: root + signal brightnessChanged() + + property var ddcMonitors: [] + readonly property list monitors: Quickshell.screens.map(screen => monitorComp.createObject(root, { + screen + })) + + function getMonitorForScreen(screen: ShellScreen): var { + return monitors.find(m => m.screen === screen); + } + + function increaseBrightness(): void { + const focusedName = Hyprland.focusedMonitor.name; + const monitor = monitors.find(m => focusedName === m.screen.name); + if (monitor) + monitor.setBrightness(monitor.brightness + 0.05); + } + + function decreaseBrightness(): void { + const focusedName = Hyprland.focusedMonitor.name; + const monitor = monitors.find(m => focusedName === m.screen.name); + if (monitor) + monitor.setBrightness(monitor.brightness - 0.05); + } + + reloadableId: "brightness" + + onMonitorsChanged: { + ddcMonitors = []; + ddcProc.running = true; + } + + function initializeMonitor(i: int): void { + if (i >= monitors.length) + return; + monitors[i].initialize(); + } + + function ddcDetectFinished(): void { + initializeMonitor(0); + } + + Process { + id: ddcProc + + command: ["ddcutil", "detect", "--brief"] + stdout: SplitParser { + splitMarker: "\n\n" + onRead: data => { + if (data.startsWith("Display ")) { + const lines = data.split("\n").map(l => l.trim()); + root.ddcMonitors.push({ + name: lines.find(l => l.startsWith("DRM connector:")).split("-").slice(1).join('-'), + busNum: lines.find(l => l.startsWith("I2C bus:")).split("/dev/i2c-")[1] + }); + } + } + } + onExited: root.ddcDetectFinished() + } + + Process { + id: setProc + } + + component BrightnessMonitor: QtObject { + id: monitor + + required property ShellScreen screen + property bool isDdc + property string busNum + property int rawMaxBrightness: 100 + property real brightness + property real brightnessMultiplier: 1.0 + property real multipliedBrightness: Math.max(0, Math.min(1, brightness * (Config.options.light.antiFlashbang.enable ? brightnessMultiplier : 1))) + property bool ready: false + property bool animateChanges: !monitor.isDdc + + onBrightnessChanged: { + if (!monitor.ready) return; + root.brightnessChanged(); + } + + // Disable animation for more responsive brightness control + Behavior on multipliedBrightness { + enabled: false + } + + onMultipliedBrightnessChanged: { + setTimer.restart(); + } + + function initialize() { + monitor.ready = false; + const match = root.ddcMonitors.find(m => m.name === screen.name && !root.monitors.slice(0, root.monitors.indexOf(this)).some(mon => mon.busNum === m.busNum)); + isDdc = !!match; + busNum = match?.busNum ?? ""; + initProc.command = isDdc ? ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"] : ["sh", "-c", `echo "a b c $(brightnessctl g) $(brightnessctl m)"`]; + initProc.running = true; + } + + readonly property Process initProc: Process { + stdout: SplitParser { + onRead: data => { + const [, , , current, max] = data.split(" "); + monitor.rawMaxBrightness = parseInt(max); + monitor.brightness = parseInt(current) / monitor.rawMaxBrightness; + monitor.ready = true; + } + } + onExited: (exitCode, exitStatus) => { + initializeMonitor(root.monitors.indexOf(monitor) + 1); + } + } + + // Shorter delay for more responsive brightness changes + property var setTimer: Timer { + id: setTimer + interval: monitor.isDdc ? 300 : 50 + onTriggered: { + syncBrightness(); + } + } + + function syncBrightness() { + const brightnessValue = Math.max(monitor.multipliedBrightness, 0); + if (isDdc) { + const rawValueRounded = Math.max(Math.floor(brightnessValue * monitor.rawMaxBrightness), 1); + setProc.command = ["ddcutil", "-b", busNum, "setvcp", "10", rawValueRounded]; + setProc.startDetached(); + } else { + const valuePercentNumber = Math.floor(brightnessValue * 100); + let valuePercent = `${valuePercentNumber}%`; + if (valuePercentNumber == 0) valuePercent = "1"; // Prevent fully black + setProc.command = ["brightnessctl", "--class", "backlight", "s", valuePercent, "--quiet"]; + setProc.startDetached(); + } + } + + function setBrightness(value: real): void { + value = Math.max(0, Math.min(1, value)); + monitor.brightness = value; + } + + function setBrightnessMultiplier(value: real): void { + monitor.brightnessMultiplier = value; + } + } + + Component { + id: monitorComp + + BrightnessMonitor {} + } + + // Anti-flashbang + property int workspaceAnimationDelay: 500 + property int contentSwitchDelay: 30 + property string screenshotDir: "/tmp/quickshell/brightness/antiflashbang" + function brightnessMultiplierForLightness(x: real): real { + // I hand picked some values and fitted an exponential curve for this + // 6.600135 + 216.360356 * e^(-0.0811129189x) + // Division by 100 is to normalize to [0, 1] + return (6.600135 + 216.360356 * Math.pow(Math.E, -0.0811129189 * x)) / 100.0; + } + Variants { + model: Quickshell.screens + Scope { + id: screenScope + required property var modelData + property string screenName: modelData.name + property string screenshotPath: `${root.screenshotDir}/screenshot-${screenName}.png` + Connections { + enabled: Config.options.light.antiFlashbang.enable && Appearance.m3colors.darkmode + target: Hyprland + function onRawEvent(event) { + if (["activewindowv2", "windowtitlev2"].includes(event.name)) { + screenshotTimer.interval = root.contentSwitchDelay; + screenshotTimer.restart(); + } else if (["workspacev2"].includes(event.name)) { + screenshotTimer.interval = root.workspaceAnimationDelay; + screenshotTimer.restart(); + } + } + } + + Timer { + id: screenshotTimer + interval: 700 // This is what I have for a Hyprland ws anim + onTriggered: { + screenshotProc.running = false; + screenshotProc.running = true; + } + } + + Process { + id: screenshotProc + command: ["bash", "-c", + `mkdir -p '${StringUtils.shellSingleQuoteEscape(root.screenshotDir)}'` + + ` && grim -o '${StringUtils.shellSingleQuoteEscape(screenScope.screenName)}' -` + + ` | magick png:- -colorspace Gray -format "%[fx:mean*100]" info:` + ] + stdout: StdioCollector { + id: lightnessCollector + onStreamFinished: { + Quickshell.execDetached(["rm", screenScope.screenshotPath]); // Cleanup + const lightness = lightnessCollector.text + const newMultiplier = root.brightnessMultiplierForLightness(parseFloat(lightness)) + Brightness.getMonitorForScreen(screenScope.modelData).setBrightnessMultiplier(newMultiplier) + } + } + } + } + } + + // External trigger points + + IpcHandler { + target: "brightness" + + function increment() { + onPressed: root.increaseBrightness() + } + + function decrement() { + onPressed: root.decreaseBrightness() + } + } + + GlobalShortcut { + name: "brightnessIncrease" + description: "Increase brightness" + onPressed: root.increaseBrightness() + } + + GlobalShortcut { + name: "brightnessDecrease" + description: "Decrease brightness" + onPressed: root.decreaseBrightness() + } +} diff --git a/modules/quickshell/config/services/Cliphist.qml b/modules/quickshell/config/services/Cliphist.qml new file mode 100644 index 0000000..615e949 --- /dev/null +++ b/modules/quickshell/config/services/Cliphist.qml @@ -0,0 +1,157 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + // property string cliphistBinary: FileUtils.trimFileProtocol(`${Directories.home}/.cargo/bin/stash`) + property string cliphistBinary: "cliphist" + property real pasteDelay: 0.05 + property string pressPasteCommand: "ydotool key -d 1 29:1 47:1 47:0 29:0" + property bool sloppySearch: Config.options?.search.sloppy ?? false + property real scoreThreshold: 0.2 + property list entries: [] + readonly property var preparedEntries: entries.map(a => ({ + name: Fuzzy.prepare(`${a.replace(/^\s*\S+\s+/, "")}`), + entry: a + })) + function fuzzyQuery(search: string): var { + if (search.trim() === "") { + return entries; + } + if (root.sloppySearch) { + const results = entries.slice(0, 100).map(str => ({ + entry: str, + score: Levendist.computeTextMatchScore(str.toLowerCase(), search.toLowerCase()) + })).filter(item => item.score > root.scoreThreshold) + .sort((a, b) => b.score - a.score) + return results + .map(item => item.entry) + } + + return Fuzzy.go(search, preparedEntries, { + all: true, + key: "name" + }).map(r => { + return r.obj.entry + }); + } + + function entryIsImage(entry) { + return !!(/^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(entry)) + } + + function refresh() { + readProc.buffer = [] + readProc.running = true + } + + function copy(entry) { + if (root.cliphistBinary.includes("cliphist")) // Classic cliphist + Quickshell.execDetached(["bash", "-c", `printf '${StringUtils.shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy`]); + else { // Stash + const entryNumber = entry.split("\t")[0]; + Quickshell.execDetached(["bash", "-c", `${root.cliphistBinary} decode ${entryNumber} | wl-copy`]); + } + } + + function paste(entry) { + if (root.cliphistBinary.includes("cliphist")) // Classic cliphist + Quickshell.execDetached(["bash", "-c", `printf '${StringUtils.shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy && wl-paste`]); + else { // Stash + const entryNumber = entry.split("\t")[0]; + Quickshell.execDetached(["bash", "-c", `${root.cliphistBinary} decode ${entryNumber} | wl-copy; ${root.pressPasteCommand}`]); + } + } + + function superpaste(count, isImage = false) { + // Find entries + const targetEntries = entries.filter(entry => { + if (!isImage) return true; + return entryIsImage(entry); + }).slice(0, count) + const pasteCommands = [...targetEntries].reverse().map(entry => `printf '${StringUtils.shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy && sleep ${root.pasteDelay} && ${root.pressPasteCommand}`) + // Act + Quickshell.execDetached(["bash", "-c", pasteCommands.join(` && sleep ${root.pasteDelay} && `)]); + } + + Process { + id: deleteProc + property string entry: "" + command: ["bash", "-c", `echo '${StringUtils.shellSingleQuoteEscape(deleteProc.entry)}' | ${root.cliphistBinary} delete`] + function deleteEntry(entry) { + deleteProc.entry = entry; + deleteProc.running = true; + deleteProc.entry = ""; + } + onExited: (exitCode, exitStatus) => { + root.refresh(); + } + } + + function deleteEntry(entry) { + deleteProc.deleteEntry(entry); + } + + Process { + id: wipeProc + command: [root.cliphistBinary, "wipe"] + onExited: (exitCode, exitStatus) => { + root.refresh(); + } + } + + function wipe() { + wipeProc.running = true; + } + + Connections { + target: Quickshell + function onClipboardTextChanged() { + delayedUpdateTimer.restart() + } + } + + Timer { + id: delayedUpdateTimer + interval: Config.options.hacks.arbitraryRaceConditionDelay + repeat: false + onTriggered: { + root.refresh() + } + } + + Process { + id: readProc + property list buffer: [] + + command: [root.cliphistBinary, "list"] + + stdout: SplitParser { + onRead: (line) => { + readProc.buffer.push(line) + } + } + + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + root.entries = readProc.buffer + } else { + console.error("[Cliphist] Failed to refresh with code", exitCode, "and status", exitStatus) + } + } + } + + IpcHandler { + target: "cliphistService" + + function update(): void { + root.refresh() + } + } +} diff --git a/modules/quickshell/config/services/ConflictKiller.qml b/modules/quickshell/config/services/ConflictKiller.qml new file mode 100644 index 0000000..547a4d3 --- /dev/null +++ b/modules/quickshell/config/services/ConflictKiller.qml @@ -0,0 +1,48 @@ +pragma Singleton + +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + property string killDialogQmlPath: FileUtils.trimFileProtocol(Quickshell.shellPath("killDialog.qml")) + + function load() { + // dummy to force init + } + + Connections { + target: Config + function onReadyChanged() { + if (Config.ready) checkConflictsProc.running = true + } + } + + Process { + id: checkConflictsProc + command: ["bash", "-c", `echo "$(pidof kded6);$(pidof mako dunst)"`] + stdout: StdioCollector { + onStreamFinished: { + const output = this.text; + const conflictingTrays = output.split(";")[0].trim().length > 0; + const conflictingNotifications = output.split(";")[1].trim().length > 0; + var openDialog = false; + if (conflictingTrays) { + if (!Config.options.conflictKiller.autoKillTrays) openDialog = true; + else Quickshell.execDetached(["killall", "kded6"]) + } + if (conflictingNotifications) { + if (!Config.options.conflictKiller.autoKillNotificationDaemons) openDialog = true; + else Quickshell.execDetached(["killall", "mako", "dunst"]) + } + if (openDialog) { + Quickshell.execDetached(["qs", "-p", root.killDialogQmlPath]) + } + } + } + } +} diff --git a/modules/quickshell/config/services/DateTime.qml b/modules/quickshell/config/services/DateTime.qml new file mode 100644 index 0000000..e1a8300 --- /dev/null +++ b/modules/quickshell/config/services/DateTime.qml @@ -0,0 +1,60 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import qs +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Io + +/** + * A nice wrapper for date and time strings. + */ +Singleton { + property var clock: SystemClock { + id: clock + precision: { + if (Config.options.time.secondPrecision || GlobalStates.screenLocked) + return SystemClock.Seconds; + return SystemClock.Minutes; + } + } + property string time: Qt.locale().toString(clock.date, Config.options?.time.format ?? "hh:mm") + property string shortDate: Qt.locale().toString(clock.date, Config.options?.time.shortDateFormat ?? "dd/MM") + property string date: Qt.locale().toString(clock.date, Config.options?.time.dateWithYearFormat ?? "dd/MM/yyyy") + property string longDate: Qt.locale().toString(clock.date, Config.options?.time.dateFormat ?? "dddd, dd/MM") + property string collapsedCalendarFormat: Qt.locale().toString(clock.date, "dddd, MMMM dd") + property string uptime: "0h, 0m" + + Timer { + interval: 10 + running: true + repeat: true + onTriggered: { + fileUptime.reload(); + const textUptime = fileUptime.text(); + const uptimeSeconds = Number(textUptime.split(" ")[0] ?? 0); + + // Convert seconds to days, hours, and minutes + const days = Math.floor(uptimeSeconds / 86400); + const hours = Math.floor((uptimeSeconds % 86400) / 3600); + const minutes = Math.floor((uptimeSeconds % 3600) / 60); + + // Build the formatted uptime string + let formatted = ""; + if (days > 0) + formatted += `${days}d`; + if (hours > 0) + formatted += `${formatted ? ", " : ""}${hours}h`; + if (minutes > 0 || !formatted) + formatted += `${formatted ? ", " : ""}${minutes}m`; + uptime = formatted; + interval = Config.options?.resources?.updateInterval ?? 3000; + } + } + + FileView { + id: fileUptime + + path: "/proc/uptime" + } +} diff --git a/modules/quickshell/config/services/EasyEffects.qml b/modules/quickshell/config/services/EasyEffects.qml new file mode 100644 index 0000000..e21a0a6 --- /dev/null +++ b/modules/quickshell/config/services/EasyEffects.qml @@ -0,0 +1,61 @@ +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Pipewire +pragma Singleton +pragma ComponentBehavior: Bound + +/** + * Handles EasyEffects active state and presets. + */ +Singleton { + id: root + + property bool available: false + property bool active: false + + function fetchAvailability() { + fetchAvailabilityProc.running = true + } + + function fetchActiveState() { + fetchActiveStateProc.running = true + } + + function disable() { + root.active = false + Quickshell.execDetached(["bash", "-c", "pkill easyeffects || flatpak pkill com.github.wwmm.easyeffects"]) + } + + function enable() { + root.active = true + Quickshell.execDetached(["bash", "-c", "easyeffects --hide-window --service-mode || flatpak run com.github.wwmm.easyeffects --hide-window --service-mode"]) + } + + function toggle() { + if (root.active) { + root.disable() + } else { + root.enable() + } + } + + Process { + id: fetchAvailabilityProc + running: true + command: ["bash", "-c", "command -v easyeffects || flatpak info com.github.wwmm.easyeffects > /dev/null 2>&1"] + onExited: (exitCode, exitStatus) => { + root.available = exitCode === 0 + } + } + + Process { + id: fetchActiveStateProc + running: true + command: ["bash", "-c", "pidof easyeffects || flatpak ps | grep com.github.wwmm.easyeffects > /dev/null 2>&1"] + onExited: (exitCode, exitStatus) => { + root.active = exitCode === 0 + } + } +} diff --git a/modules/quickshell/config/services/Emojis.qml b/modules/quickshell/config/services/Emojis.qml new file mode 100644 index 0000000..436401b --- /dev/null +++ b/modules/quickshell/config/services/Emojis.qml @@ -0,0 +1,64 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import Quickshell +import Quickshell.Io + +/** + * Emojis. + */ +Singleton { + id: root + property string emojiScriptPath: `${Directories.config}/hypr/hyprland/scripts/fuzzel-emoji.sh` + property string lineBeforeData: "### DATA ###" + property list list + readonly property var preparedEntries: list.map(a => ({ + name: Fuzzy.prepare(`${a}`), + entry: a + })) + function fuzzyQuery(search: string): var { + if (root.sloppySearch) { + const results = entries.slice(0, 100).map(str => ({ + entry: str, + score: Levendist.computeTextMatchScore(str.toLowerCase(), search.toLowerCase()) + })).filter(item => item.score > root.scoreThreshold) + .sort((a, b) => b.score - a.score) + return results + .map(item => item.entry) + } + + return Fuzzy.go(search, preparedEntries, { + all: true, + key: "name" + }).map(r => { + return r.obj.entry + }); + } + + function load() { + emojiFileView.reload() + } + + function updateEmojis(fileContent) { + const lines = fileContent.split("\n") + const dataIndex = lines.indexOf(root.lineBeforeData) + if (dataIndex === -1) { + console.warn("No data section found in emoji script file.") + return + } + const emojis = lines.slice(dataIndex + 1).filter(line => line.trim() !== "") + root.list = emojis.map(line => line.trim()) + } + + FileView { + id: emojiFileView + path: Qt.resolvedUrl(root.emojiScriptPath) + onLoadedChanged: { + const fileContent = emojiFileView.text() + root.updateEmojis(fileContent) + } + } +} diff --git a/modules/quickshell/config/services/FirstRunExperience.qml b/modules/quickshell/config/services/FirstRunExperience.qml new file mode 100644 index 0000000..f23cce5 --- /dev/null +++ b/modules/quickshell/config/services/FirstRunExperience.qml @@ -0,0 +1,43 @@ +pragma Singleton + +import qs.modules.common +import qs.modules.common.functions +import Quickshell +import Quickshell.Io + +Singleton { + id: root + property string firstRunFilePath: `${Directories.state}/user/first_run.txt` + property string firstRunFileContent: "This file is just here to confirm you've been greeted :>" + property string firstRunNotifSummary: "Welcome!" + property string firstRunNotifBody: "Hit Super+/ for a list of keybinds" + property string defaultWallpaperPath: FileUtils.trimFileProtocol(`${Directories.assetsPath}/images/default_wallpaper.png`) + property string welcomeQmlPath: FileUtils.trimFileProtocol(Quickshell.shellPath("welcome.qml")) + + function load() { + firstRunFileView.reload() + } + + function enableNextTime() { + Quickshell.execDetached(["rm", "-f", root.firstRunFilePath]) + } + function disableNextTime() { + Quickshell.execDetached(["bash", "-c", `echo '${root.firstRunFileContent}' > '${root.firstRunFilePath}'`]) + } + + function handleFirstRun() { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, root.defaultWallpaperPath]) + Quickshell.execDetached(["bash", "-c", `qs -p '${root.welcomeQmlPath}'`]) + } + + FileView { + id: firstRunFileView + path: Qt.resolvedUrl(firstRunFilePath) + onLoadFailed: (error) => { + if (error == FileViewError.FileNotFound) { + firstRunFileView.setText(root.firstRunFileContent) + root.handleFirstRun() + } + } + } +} diff --git a/modules/quickshell/config/services/HyprlandData.qml b/modules/quickshell/config/services/HyprlandData.qml new file mode 100644 index 0000000..abbaaf5 --- /dev/null +++ b/modules/quickshell/config/services/HyprlandData.qml @@ -0,0 +1,138 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +/** + * Provides access to some Hyprland data not available in Quickshell.Hyprland. + */ +Singleton { + id: root + property var windowList: [] + property var addresses: [] + property var windowByAddress: ({}) + property var workspaces: [] + property var workspaceIds: [] + property var workspaceById: ({}) + property var activeWorkspace: null + property var monitors: [] + property var layers: ({}) + + function updateWindowList() { + getClients.running = true; + } + + function updateLayers() { + getLayers.running = true; + } + + function updateMonitors() { + getMonitors.running = true; + } + + function updateWorkspaces() { + getWorkspaces.running = true; + getActiveWorkspace.running = true; + } + + function updateAll() { + updateWindowList(); + updateMonitors(); + updateLayers(); + updateWorkspaces(); + } + + function biggestWindowForWorkspace(workspaceId) { + const windowsInThisWorkspace = HyprlandData.windowList.filter(w => w.workspace.id == workspaceId); + return windowsInThisWorkspace.reduce((maxWin, win) => { + const maxArea = (maxWin?.size?.[0] ?? 0) * (maxWin?.size?.[1] ?? 0); + const winArea = (win?.size?.[0] ?? 0) * (win?.size?.[1] ?? 0); + return winArea > maxArea ? win : maxWin; + }, null); + } + + Component.onCompleted: { + updateAll(); + } + + Connections { + target: Hyprland + + function onRawEvent(event) { + // console.log("Hyprland raw event:", event.name); + updateAll() + } + } + + Process { + id: getClients + command: ["hyprctl", "clients", "-j"] + stdout: StdioCollector { + id: clientsCollector + onStreamFinished: { + root.windowList = JSON.parse(clientsCollector.text) + let tempWinByAddress = {}; + for (var i = 0; i < root.windowList.length; ++i) { + var win = root.windowList[i]; + tempWinByAddress[win.address] = win; + } + root.windowByAddress = tempWinByAddress; + root.addresses = root.windowList.map(win => win.address); + } + } + } + + Process { + id: getMonitors + command: ["hyprctl", "monitors", "-j"] + stdout: StdioCollector { + id: monitorsCollector + onStreamFinished: { + root.monitors = JSON.parse(monitorsCollector.text); + } + } + } + + Process { + id: getLayers + command: ["hyprctl", "layers", "-j"] + stdout: StdioCollector { + id: layersCollector + onStreamFinished: { + root.layers = JSON.parse(layersCollector.text); + } + } + } + + Process { + id: getWorkspaces + command: ["hyprctl", "workspaces", "-j"] + stdout: StdioCollector { + id: workspacesCollector + onStreamFinished: { + root.workspaces = JSON.parse(workspacesCollector.text); + let tempWorkspaceById = {}; + for (var i = 0; i < root.workspaces.length; ++i) { + var ws = root.workspaces[i]; + tempWorkspaceById[ws.id] = ws; + } + root.workspaceById = tempWorkspaceById; + root.workspaceIds = root.workspaces.map(ws => ws.id); + } + } + } + + Process { + id: getActiveWorkspace + command: ["hyprctl", "activeworkspace", "-j"] + stdout: StdioCollector { + id: activeWorkspaceCollector + onStreamFinished: { + root.activeWorkspace = JSON.parse(activeWorkspaceCollector.text); + } + } + } +} diff --git a/modules/quickshell/config/services/HyprlandKeybinds.qml b/modules/quickshell/config/services/HyprlandKeybinds.qml new file mode 100644 index 0000000..3381926 --- /dev/null +++ b/modules/quickshell/config/services/HyprlandKeybinds.qml @@ -0,0 +1,72 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +/** + * A service that provides access to Hyprland keybinds. + * Uses the `get_keybinds.py` script to parse comments in config files in a certain format and convert to JSON. + */ +Singleton { + id: root + property string keybindParserPath: FileUtils.trimFileProtocol(`${Directories.scriptPath}/hyprland/get_keybinds.py`) + property string defaultKeybindConfigPath: FileUtils.trimFileProtocol(`${Directories.config}/hypr/hyprland/keybinds.conf`) + property string userKeybindConfigPath: FileUtils.trimFileProtocol(`${Directories.config}/hypr/custom/keybinds.conf`) + property var defaultKeybinds: {"children": []} + property var userKeybinds: {"children": []} + property var keybinds: ({ + children: [ + ...(defaultKeybinds.children ?? []), + ...(userKeybinds.children ?? []), + ] + }) + + Connections { + target: Hyprland + + function onRawEvent(event) { + if (event.name == "configreloaded") { + getDefaultKeybinds.running = true + getUserKeybinds.running = true + } + } + } + + Process { + id: getDefaultKeybinds + running: true + command: [root.keybindParserPath, "--path", root.defaultKeybindConfigPath] + + stdout: SplitParser { + onRead: data => { + try { + root.defaultKeybinds = JSON.parse(data) + } catch (e) { + console.error("[CheatsheetKeybinds] Error parsing keybinds:", e) + } + } + } + } + + Process { + id: getUserKeybinds + running: true + command: [root.keybindParserPath, "--path", root.userKeybindConfigPath] + + stdout: SplitParser { + onRead: data => { + try { + root.userKeybinds = JSON.parse(data) + } catch (e) { + console.error("[CheatsheetKeybinds] Error parsing keybinds:", e) + } + } + } + } +} + diff --git a/modules/quickshell/config/services/HyprlandXkb.qml b/modules/quickshell/config/services/HyprlandXkb.qml new file mode 100644 index 0000000..162015d --- /dev/null +++ b/modules/quickshell/config/services/HyprlandXkb.qml @@ -0,0 +1,119 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import qs.modules.common + +/** + * Exposes the active Hyprland Xkb keyboard layout name and code for indicators. + */ +Singleton { + id: root + // You can read these + property list layoutCodes: [] + property var cachedLayoutCodes: ({}) + property string currentLayoutName: "" + property string currentLayoutCode: "" + // For the service + property var baseLayoutFilePath: "/usr/share/X11/xkb/rules/base.lst" + property bool needsLayoutRefresh: false + + // Update the layout code according to the layout name (Hyprland gives the name not the code) + onCurrentLayoutNameChanged: root.updateLayoutCode() + function updateLayoutCode() { + if (cachedLayoutCodes.hasOwnProperty(currentLayoutName)) { + root.currentLayoutCode = cachedLayoutCodes[currentLayoutName]; + } else { + getLayoutProc.running = true; + } + } + + // Get the layout code from the base.lst file by grabbing the line with the current layout name + Process { + id: getLayoutProc + command: ["cat", root.baseLayoutFilePath] + + stdout: StdioCollector { + id: layoutCollector + + onStreamFinished: { + const lines = layoutCollector.text.split("\n"); + const targetDescription = root.currentLayoutName; + const foundLine = lines.find(line => { + // Skip comment lines and empty lines + if (!line.trim() || line.trim().startsWith('!')) + return false; + + // Match layout: (whitespace + ) key + whitespace + description + const matchLayout = line.match(/^\s*(\S+)\s+(.+)$/); + if (matchLayout && matchLayout[2] === targetDescription) { + root.cachedLayoutCodes[matchLayout[2]] = matchLayout[1]; + root.currentLayoutCode = matchLayout[1]; + return true; + } + + // Match variant: (whitespace + ) variant + whitespace + key + whitespace + description + const matchVariant = line.match(/^\s*(\S+)\s+(\S+)\s+(.+)$/); + if (matchVariant && matchVariant[3] === targetDescription) { + const complexLayout = matchVariant[2] + matchVariant[1]; + root.cachedLayoutCodes[matchVariant[3]] = complexLayout; + root.currentLayoutCode = complexLayout; + return true; + } + + return false; + }); + // console.log("[HyprlandXkb] Found line:", foundLine); + // console.log("[HyprlandXkb] Layout:", root.currentLayoutName, "| Code:", root.currentLayoutCode); + // console.log("[HyprlandXkb] Cached layout codes:", JSON.stringify(root.cachedLayoutCodes, null, 2)); + } + } + } + + // Find out available layouts and current active layout. Should only be necessary on init + Process { + id: fetchLayoutsProc + running: true + command: ["hyprctl", "-j", "devices"] + + stdout: StdioCollector { + id: devicesCollector + onStreamFinished: { + const parsedOutput = JSON.parse(devicesCollector.text); + const hyprlandKeyboard = parsedOutput["keyboards"].find(kb => kb.main === true); + root.layoutCodes = hyprlandKeyboard["layout"].split(","); + root.currentLayoutName = hyprlandKeyboard["active_keymap"]; + // console.log("[HyprlandXkb] Fetched | Layouts (multiple: " + (root.layoutCodes.length > 1) + "): " + // + root.layoutCodes.join(", ") + " | Active: " + root.currentLayoutName); + } + } + } + + // Update the layout name when it changes + Connections { + target: Hyprland + function onRawEvent(event) { + if (event.name === "activelayout") { + if (root.needsLayoutRefresh) { + root.needsLayoutRefresh = false; + fetchLayoutsProc.running = true; + } + + // If there's only one layout, the updated layout is always the same + if (root.layoutCodes.length <= 1) return; + + // Update when layout might have changed + const dataString = event.data; + root.currentLayoutName = dataString.substring(dataString.indexOf(",") + 1); + + // Update layout for on-screen keyboard (osk) + Config.options.osk.layout = root.currentLayoutName.split(" (")[0]; + } else if (event.name == "configreloaded") { + // Mark layout code list to be updated when config is reloaded + root.needsLayoutRefresh = true; + } + } + } +} diff --git a/modules/quickshell/config/services/Hyprsunset.qml b/modules/quickshell/config/services/Hyprsunset.qml new file mode 100644 index 0000000..8f0e365 --- /dev/null +++ b/modules/quickshell/config/services/Hyprsunset.qml @@ -0,0 +1,139 @@ +pragma Singleton + +import QtQuick +import qs.modules.common +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +/** + * Simple hyprsunset service with automatic mode. + * In theory we don't need this because hyprsunset has a config file, but it somehow doesn't work. + * It should also be possible to control it via hyprctl, but it doesn't work consistently either so we're just killing and launching. + */ +Singleton { + id: root + property string from: Config.options?.light?.night?.from ?? "19:00" + property string to: Config.options?.light?.night?.to ?? "06:30" + property bool automatic: Config.options?.light?.night?.automatic && (Config?.ready ?? true) + property int colorTemperature: Config.options?.light?.night?.colorTemperature ?? 5000 + property bool shouldBeOn + property bool firstEvaluation: true + property bool active: false + + property int fromHour: Number(from.split(":")[0]) + property int fromMinute: Number(from.split(":")[1]) + property int toHour: Number(to.split(":")[0]) + property int toMinute: Number(to.split(":")[1]) + + property int clockHour: DateTime.clock.hours + property int clockMinute: DateTime.clock.minutes + + property var manualActive + property int manualActiveHour + property int manualActiveMinute + + onClockMinuteChanged: reEvaluate() + onAutomaticChanged: { + root.manualActive = undefined; + root.firstEvaluation = true; + reEvaluate(); + } + + function inBetween(t, from, to) { + if (from < to) { + return (t >= from && t <= to); + } else { + // Wrapped around midnight + return (t >= from || t <= to); + } + } + + function reEvaluate() { + const t = clockHour * 60 + clockMinute; + const from = fromHour * 60 + fromMinute; + const to = toHour * 60 + toMinute; + const manualActive = manualActiveHour * 60 + manualActiveMinute; + + if (root.manualActive !== undefined && (inBetween(from, manualActive, t) || inBetween(to, manualActive, t))) { + root.manualActive = undefined; + } + root.shouldBeOn = inBetween(t, from, to); + if (firstEvaluation) { + firstEvaluation = false; + root.ensureState(); + } + } + + onShouldBeOnChanged: ensureState() + function ensureState() { + // console.log("[Hyprsunset] Ensuring state:", root.shouldBeOn, "Automatic mode:", root.automatic); + if (!root.automatic || root.manualActive !== undefined) + return; + if (root.shouldBeOn) { + root.enable(); + } else { + root.disable(); + } + } + + function load() { } // Dummy to force init + + function enable() { + root.active = true; + // console.log("[Hyprsunset] Enabling"); + Quickshell.execDetached(["bash", "-c", `pidof hyprsunset || hyprsunset --temperature ${root.colorTemperature}`]); + } + + function disable() { + root.active = false; + // console.log("[Hyprsunset] Disabling"); + Quickshell.execDetached(["bash", "-c", `pkill hyprsunset`]); + } + + function fetchState() { + fetchProc.running = true; + } + + Process { + id: fetchProc + running: true + command: ["bash", "-c", "hyprctl hyprsunset temperature"] + stdout: StdioCollector { + id: stateCollector + onStreamFinished: { + const output = stateCollector.text.trim(); + if (output.length == 0 || output.startsWith("Couldn't")) + root.active = false; + else + root.active = (output != "6500"); // 6500 is the default when off + // console.log("[Hyprsunset] Fetched state:", output, "->", root.active); + } + } + } + + function toggle(active = undefined) { + if (root.manualActive === undefined) { + root.manualActive = root.active; + root.manualActiveHour = root.clockHour; + root.manualActiveMinute = root.clockMinute; + } + + root.manualActive = active !== undefined ? active : !root.manualActive; + if (root.manualActive) { + root.enable(); + } else { + root.disable(); + } + } + + // Change temp + Connections { + target: Config.options.light.night + function onColorTemperatureChanged() { + if (!root.active) return; + Hyprland.dispatch(`hyprctl hyprsunset temperature ${Config.options.light.night.colorTemperature}`); + Quickshell.execDetached(["hyprctl", "hyprsunset", "temperature", `${Config.options.light.night.colorTemperature}`]); + } + } +} diff --git a/modules/quickshell/config/services/Idle.qml b/modules/quickshell/config/services/Idle.qml new file mode 100644 index 0000000..ad938f3 --- /dev/null +++ b/modules/quickshell/config/services/Idle.qml @@ -0,0 +1,51 @@ +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Wayland +pragma Singleton + +/** + * A nice wrapper for date and time strings. + */ +Singleton { + id: root + + property alias inhibit: idleInhibitor.enabled + inhibit: false + + Connections { + target: Persistent + function onReadyChanged() { + if (!Persistent.isNewHyprlandInstance) { + root.inhibit = Persistent.states.idle.inhibit + } else { + Persistent.states.idle.inhibit = root.inhibit + } + } + } + + function toggleInhibit() { + root.inhibit = !root.inhibit + Persistent.states.idle.inhibit = root.inhibit + } + + IdleInhibitor { + id: idleInhibitor + window: PanelWindow { // Inhibitor requires a "visible" surface + // Actually not lol + implicitWidth: 0 + implicitHeight: 0 + color: "transparent" + // Just in case... + anchors { + right: true + bottom: true + } + // Make it not interactable + mask: Region { + item: null + } + } + } + +} diff --git a/modules/quickshell/config/services/KeyringStorage.qml b/modules/quickshell/config/services/KeyringStorage.qml new file mode 100644 index 0000000..ae49496 --- /dev/null +++ b/modules/quickshell/config/services/KeyringStorage.qml @@ -0,0 +1,123 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs +import qs.modules.common +import qs.modules.common.functions +import Quickshell; +import Quickshell.Io; +import QtQuick; + +/** + * For storing sensitive data in the keyring. + * Use this for small data only, since it stores a JSON of the contents directly and doesn't use a database. + */ +Singleton { + id: root + + property bool loaded: false + property var keyringData: ({}) + + property var properties: { + "application": "illogical-impulse", + "explanation": Translation.tr("For storing API keys and other sensitive information"), + } + property var propertiesAsArgs: Object.keys(root.properties).reduce( + function(arr, key) { + return arr.concat([key, root.properties[key]]); + }, [] + ) + property string keyringLabel: Translation.tr("%1 Safe Storage").arg("illogical-impulse") + + function setNestedField(path, value) { + if (!root.keyringData) root.keyringData = {}; + let keys = path; + let obj = root.keyringData; + let parents = [obj]; + + // Traverse and collect parent objects + for (let i = 0; i < keys.length - 1; ++i) { + if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") { + obj[keys[i]] = {}; + } + obj = obj[keys[i]]; + parents.push(obj); + } + + // Set the value at the innermost key + obj[keys[keys.length - 1]] = value; + + // Reassign each parent object from the bottom up to trigger change notifications + for (let i = keys.length - 2; i >= 0; --i) { + let parent = parents[i]; + let key = keys[i]; + // Shallow clone to change object identity (spread replaced with Object.assign) + parent[key] = Object.assign({}, parent[key]); + } + + // Finally, reassign root.keyringData to trigger top-level change + root.keyringData = Object.assign({}, root.keyringData); + + saveKeyringData(); + } + + function fetchKeyringData() { + // console.log("[KeyringStorage] Fetching keyring data..."); + // console.log("[KeyringStorage] getData command:'" + getData.command.join("' '") + "'"); + getData.running = true; + } + + function saveKeyringData() { + saveData.stdinEnabled = true; + saveData.running = true; + } + + Process { + id: saveData + command: [ + "secret-tool", "store", "--label=" + keyringLabel, + ...propertiesAsArgs, + ] + onRunningChanged: { + if (saveData.running) { + // console.log("[KeyringStorage] Saving with command: '" + saveData.command.join("' '") + "'"); + saveData.write(JSON.stringify(root.keyringData)); + stdinEnabled = false // End input stream + } + } + } + + Process { + id: getData + command: [ // We need to use echo for a newline so splitparser does parse + "bash", "-c", `${Directories.scriptPath}/keyring/try_lookup.sh 2> /dev/null`, + ] + stdout: StdioCollector { + id: keyringDataOutputCollector + onStreamFinished: { + const data = keyringDataOutputCollector.text; + if (data.length === 0 || !data.startsWith("{")) return; + try { + root.keyringData = JSON.parse(data); + // console.log("[KeyringStorage] Keyring data fetched:", JSON.stringify(root.keyringData)); + } catch (e) { + console.error("[KeyringStorage] Failed to get keyring data, reinitializing."); + root.keyringData = {}; + saveKeyringData() + } + } + } + onExited: (exitCode, exitStatus) => { + // console.log("[KeyringStorage] Keyring data fetch process exited with code:", exitCode); + if (exitCode === 1) { + console.error("[KeyringStorage] Entry not found, initializing."); + root.keyringData = {}; + saveKeyringData() + } + if (exitCode !== 2) { + root.loaded = true; + } + } + } + +} diff --git a/modules/quickshell/config/services/LatexRenderer.qml b/modules/quickshell/config/services/LatexRenderer.qml new file mode 100644 index 0000000..5baf336 --- /dev/null +++ b/modules/quickshell/config/services/LatexRenderer.qml @@ -0,0 +1,83 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common.functions +import qs.modules.common +import QtQuick +import Quickshell + +/** + * Renders LaTeX snippets with MicroTeX. + * For every request: + * 1. Hash it + * 2. Check if the hash is already processed + * 3. If not, render it with MicroTeX and mark as processed + */ +Singleton { + id: root + + readonly property var renderPadding: 4 // This is to prevent cutoff in the rendered images + + property list processedHashes: [] + property var processedExpressions: ({}) + property var renderedImagePaths: ({}) + property string microtexBinaryDir: "/opt/MicroTeX" + property string microtexBinaryName: "LaTeX" + property string latexOutputPath: Directories.latexOutput + + signal renderFinished(string hash, string imagePath) + + /** + * Requests rendering of a LaTeX expression. + * Returns the [hash, isNew] + */ + function requestRender(expression) { + // 1. Hash it and initialize necessary variables + const hash = Qt.md5(expression) + const imagePath = `${latexOutputPath}/${hash}.svg` + + // 2. Check if the hash is already processed + if (processedHashes.includes(hash)) { + // console.log("Already processed: " + hash) + renderFinished(hash, imagePath) + return [hash, false] + } else { + root.processedHashes.push(hash) + root.processedExpressions[hash] = expression + // console.log("Rendering expression: " + expression) + } + + // 3. If not, render it with MicroTeX and mark as processed + // console.log(`[LatexRenderer] Rendering expression: ${expression} with hash: ${hash}`) + // console.log(` to file: ${imagePath}`) + // console.log(` with command: cd ${microtexBinaryDir} && ./${microtexBinaryName} -headless -input=${StringUtils.shellSingleQuoteEscape(expression)} -output=${imagePath} -textsize=${Appearance.font.pixelSize.normal} -padding=${renderPadding} -background=${Appearance.m3colors.m3tertiary} -foreground=${Appearance.m3colors.m3onTertiary} -maxwidth=0.85`) + const processQml = ` + import Quickshell.Io + Process { + id: microtexProcess${hash} + running: true + command: [ "bash", "-c", + "cd ${root.microtexBinaryDir} && ./${root.microtexBinaryName} -headless '-input=${StringUtils.shellSingleQuoteEscape(StringUtils.escapeBackslashes(expression))}' " + + "'-output=${imagePath}' " + + "'-textsize=${Appearance.font.pixelSize.normal}' " + + "'-padding=${renderPadding}' " + // + "'-background=${Appearance.m3colors.m3tertiary}' " + + "'-foreground=${Appearance.colors.colOnLayer1}' " + + "-maxwidth=0.85 " + ] + // stdout: SplitParser { + // onRead: data => { console.log("MicroTeX: " + data) } + // } + onExited: (exitCode, exitStatus) => { + // console.log("[LatexRenderer] MicroTeX process exited with code: " + exitCode + ", status: " + exitStatus) + renderedImagePaths["${hash}"] = "${imagePath}" + root.renderFinished("${hash}", "${imagePath}") + microtexProcess${hash}.destroy() + } + } + ` + // console.log("MicroTeX: " + processQml) + Qt.createQmlObject(processQml, root, `MicroTeXProcess_${hash}`) + return [hash, true] + } +} \ No newline at end of file diff --git a/modules/quickshell/config/services/MaterialThemeLoader.qml b/modules/quickshell/config/services/MaterialThemeLoader.qml new file mode 100644 index 0000000..cd81ca0 --- /dev/null +++ b/modules/quickshell/config/services/MaterialThemeLoader.qml @@ -0,0 +1,74 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Io + +/** + * Automatically reloads generated material colors. + * It is necessary to run reapplyTheme() on startup because Singletons are lazily loaded. + */ +Singleton { + id: root + property string filePath: Directories.generatedMaterialThemePath + + function reapplyTheme() { + themeFileView.reload() + } + + function applyColors(fileContent) { + const json = JSON.parse(fileContent) + for (const key in json) { + if (json.hasOwnProperty(key)) { + // Convert snake_case to CamelCase + const camelCaseKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase()) + const m3Key = `m3${camelCaseKey}` + Appearance.m3colors[m3Key] = json[key] + } + } + + Appearance.m3colors.darkmode = (Appearance.m3colors.m3background.hslLightness < 0.5) + } + + function resetFilePathNextTime() { + resetFilePathNextWallpaperChange.enabled = true + } + + Connections { + id: resetFilePathNextWallpaperChange + enabled: false + target: Config.options.background + function onWallpaperPathChanged() { + root.filePath = "" + root.filePath = Directories.generatedMaterialThemePath + resetFilePathNextWallpaperChange.enabled = false + } + } + + Timer { + id: delayedFileRead + interval: Config.options?.hacks?.arbitraryRaceConditionDelay ?? 100 + repeat: false + running: false + onTriggered: { + root.applyColors(themeFileView.text()) + } + } + + FileView { + id: themeFileView + path: Qt.resolvedUrl(root.filePath) + watchChanges: true + onFileChanged: { + this.reload() + delayedFileRead.start() + } + onLoadedChanged: { + const fileContent = themeFileView.text() + root.applyColors(fileContent) + } + onLoadFailed: root.resetFilePathNextTime(); + } +} diff --git a/modules/quickshell/config/services/MprisController.qml b/modules/quickshell/config/services/MprisController.qml new file mode 100644 index 0000000..02151f1 --- /dev/null +++ b/modules/quickshell/config/services/MprisController.qml @@ -0,0 +1,189 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +// From https://git.outfoxxed.me/outfoxxed/nixnew +// It does not have a license, but the author is okay with redistribution. + +import QtQml.Models +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris +import qs.modules.common + +/** + * A service that provides easy access to the active Mpris player. + */ +Singleton { + id: root; + property list players: Mpris.players.values.filter(player => isRealPlayer(player)); + property MprisPlayer trackedPlayer: null; + property MprisPlayer activePlayer: trackedPlayer ?? Mpris.players.values[0] ?? null; + signal trackChanged(reverse: bool); + + property bool __reverse: false; + + property var activeTrack; + + property bool hasPlasmaIntegration: false + Process { + id: plasmaIntegrationAvailabilityCheckProc + running: true + command: ["bash", "-c", "command -v plasma-browser-integration-host"] + onExited: (exitCode, exitStatus) => { + root.hasPlasmaIntegration = (exitCode === 0); + } + } + function isRealPlayer(player) { + if (!Config.options.media.filterDuplicatePlayers) { + return true; + } + return ( + // Remove unecessary native buses from browsers if there's plasma integration + !(hasPlasmaIntegration && player.dbusName.startsWith('org.mpris.MediaPlayer2.firefox')) && !(hasPlasmaIntegration && player.dbusName.startsWith('org.mpris.MediaPlayer2.chromium')) && + // playerctld just copies other buses and we don't need duplicates + !player.dbusName?.startsWith('org.mpris.MediaPlayer2.playerctld') && + // Non-instance mpd bus + !(player.dbusName?.endsWith('.mpd') && !player.dbusName.endsWith('MediaPlayer2.mpd'))); + } + + // Original stuff from fox below + Instantiator { + model: Mpris.players; + + Connections { + required property MprisPlayer modelData; + target: modelData; + + Component.onCompleted: { + if (root.trackedPlayer == null || modelData.isPlaying) { + root.trackedPlayer = modelData; + } + } + + Component.onDestruction: { + if (root.trackedPlayer == null || !root.trackedPlayer.isPlaying) { + for (const player of Mpris.players.values) { + if (player.playbackState.isPlaying) { + root.trackedPlayer = player; + break; + } + } + + if (trackedPlayer == null && Mpris.players.values.length != 0) { + trackedPlayer = Mpris.players.values[0]; + } + } + } + + function onPlaybackStateChanged() { + if (root.trackedPlayer !== modelData) root.trackedPlayer = modelData; + } + } + } + + Connections { + target: activePlayer + + function onPostTrackChanged() { + root.updateTrack(); + } + + function onTrackArtUrlChanged() { + // console.log("arturl:", activePlayer.trackArtUrl) + // root.updateTrack(); + if (root.activePlayer.uniqueId == root.activeTrack.uniqueId && root.activePlayer.trackArtUrl != root.activeTrack.artUrl) { + // cantata likes to send cover updates *BEFORE* updating the track info. + // as such, art url changes shouldn't be able to break the reverse animation + const r = root.__reverse; + root.updateTrack(); + root.__reverse = r; + + } + } + } + + onActivePlayerChanged: this.updateTrack(); + + function updateTrack() { + //console.log(`update: ${this.activePlayer?.trackTitle ?? ""} : ${this.activePlayer?.trackArtists}`) + this.activeTrack = { + uniqueId: this.activePlayer?.uniqueId ?? 0, + artUrl: this.activePlayer?.trackArtUrl ?? "", + title: this.activePlayer?.trackTitle || Translation.tr("Unknown Title"), + artist: this.activePlayer?.trackArtist || Translation.tr("Unknown Artist"), + album: this.activePlayer?.trackAlbum || Translation.tr("Unknown Album"), + }; + + this.trackChanged(__reverse); + this.__reverse = false; + } + + property bool isPlaying: this.activePlayer && this.activePlayer.isPlaying; + property bool canTogglePlaying: this.activePlayer?.canTogglePlaying ?? false; + function togglePlaying() { + if (this.canTogglePlaying) this.activePlayer.togglePlaying(); + } + + property bool canGoPrevious: this.activePlayer?.canGoPrevious ?? false; + function previous() { + if (this.canGoPrevious) { + this.__reverse = true; + this.activePlayer.previous(); + } + } + + property bool canGoNext: this.activePlayer?.canGoNext ?? false; + function next() { + if (this.canGoNext) { + this.__reverse = false; + this.activePlayer.next(); + } + } + + property bool canChangeVolume: this.activePlayer && this.activePlayer.volumeSupported && this.activePlayer.canControl; + + property bool loopSupported: this.activePlayer && this.activePlayer.loopSupported && this.activePlayer.canControl; + property var loopState: this.activePlayer?.loopState ?? MprisLoopState.None; + function setLoopState(loopState: var) { + if (this.loopSupported) { + this.activePlayer.loopState = loopState; + } + } + + property bool shuffleSupported: this.activePlayer && this.activePlayer.shuffleSupported && this.activePlayer.canControl; + property bool hasShuffle: this.activePlayer?.shuffle ?? false; + function setShuffle(shuffle: bool) { + if (this.shuffleSupported) { + this.activePlayer.shuffle = shuffle; + } + } + + function setActivePlayer(player: MprisPlayer) { + const targetPlayer = player ?? Mpris.players[0]; + console.log(`[Mpris] Active player ${targetPlayer} << ${activePlayer}`) + + if (targetPlayer && this.activePlayer) { + this.__reverse = Mpris.players.indexOf(targetPlayer) < Mpris.players.indexOf(this.activePlayer); + } else { + // always animate forward if going to null + this.__reverse = false; + } + + this.trackedPlayer = targetPlayer; + } + + IpcHandler { + target: "mpris" + + function pauseAll(): void { + for (const player of Mpris.players.values) { + if (player.canPause) player.pause(); + } + } + + function playPause(): void { root.togglePlaying(); } + function previous(): void { root.previous(); } + function next(): void { root.next(); } + } +} diff --git a/modules/quickshell/config/services/Network.qml b/modules/quickshell/config/services/Network.qml new file mode 100644 index 0000000..69bd533 --- /dev/null +++ b/modules/quickshell/config/services/Network.qml @@ -0,0 +1,331 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +// Took many bits from https://github.com/caelestia-dots/shell (GPLv3) + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.services.network + +/** + * Network service with nmcli. + */ +Singleton { + id: root + + property bool wifi: true + property bool ethernet: false + + property bool wifiEnabled: false + property bool wifiScanning: false + property bool wifiConnecting: connectProc.running + property WifiAccessPoint wifiConnectTarget + readonly property list wifiNetworks: [] + readonly property WifiAccessPoint active: wifiNetworks.find(n => n.active) ?? null + readonly property list friendlyWifiNetworks: [...wifiNetworks].sort((a, b) => { + if (a.active && !b.active) + return -1; + if (!a.active && b.active) + return 1; + return b.strength - a.strength; + }) + property string wifiStatus: "disconnected" + + property string networkName: "" + property int networkStrength + property string materialSymbol: root.ethernet + ? "lan" + : root.wifiEnabled + ? ( + Network.networkStrength > 83 ? "signal_wifi_4_bar" : + Network.networkStrength > 67 ? "network_wifi" : + Network.networkStrength > 50 ? "network_wifi_3_bar" : + Network.networkStrength > 33 ? "network_wifi_2_bar" : + Network.networkStrength > 17 ? "network_wifi_1_bar" : + "signal_wifi_0_bar" + ) + : (root.wifiStatus === "connecting") + ? "signal_wifi_statusbar_not_connected" + : (root.wifiStatus === "disconnected") + ? "wifi_find" + : (root.wifiStatus === "disabled") + ? "signal_wifi_off" + : "signal_wifi_bad" + + // Control + function enableWifi(enabled = true): void { + const cmd = enabled ? "on" : "off"; + enableWifiProc.exec(["nmcli", "radio", "wifi", cmd]); + } + + function toggleWifi(): void { + enableWifi(!wifiEnabled); + } + + function rescanWifi(): void { + wifiScanning = true; + rescanProcess.running = true; + } + + function connectToWifiNetwork(accessPoint: WifiAccessPoint): void { + accessPoint.askingPassword = false; + root.wifiConnectTarget = accessPoint; + // We use this instead of `nmcli connection up SSID` because this also creates a connection profile + connectProc.exec(["nmcli", "dev", "wifi", "connect", accessPoint.ssid]) + + } + + function disconnectWifiNetwork(): void { + if (active) disconnectProc.exec(["nmcli", "connection", "down", active.ssid]); + } + + function openPublicWifiPortal() { + Quickshell.execDetached(["xdg-open", "https://nmcheck.gnome.org/"]) // From some StackExchange thread, seems to work + } + + function changePassword(network: WifiAccessPoint, password: string, username = ""): void { + // TODO: enterprise wifi with username + network.askingPassword = false; + changePasswordProc.exec({ + "environment": { + "PASSWORD": password + }, + "command": ["bash", "-c", `nmcli connection modify ${network.ssid} wifi-sec.psk "$PASSWORD"`] + }) + } + + Process { + id: enableWifiProc + } + + Process { + id: connectProc + environment: ({ + LANG: "C", + LC_ALL: "C" + }) + stdout: SplitParser { + onRead: line => { + // print(line) + getNetworks.running = true + } + } + stderr: SplitParser { + onRead: line => { + // print("err:", line) + if (line.includes("Secrets were required")) { + root.wifiConnectTarget.askingPassword = true + } + } + } + onExited: (exitCode, exitStatus) => { + root.wifiConnectTarget.askingPassword = (exitCode !== 0) + root.wifiConnectTarget = null + } + } + + Process { + id: disconnectProc + stdout: SplitParser { + onRead: getNetworks.running = true + } + } + + Process { + id: changePasswordProc + onExited: { // Re-attempt connection after changing password + connectProc.running = false + connectProc.running = true + } + } + + Process { + id: rescanProcess + command: ["nmcli", "dev", "wifi", "list", "--rescan", "yes"] + stdout: SplitParser { + onRead: { + wifiScanning = false; + getNetworks.running = true; + } + } + } + + // Status update + function update() { + updateConnectionType.startCheck(); + wifiStatusProcess.running = true + updateNetworkName.running = true; + updateNetworkStrength.running = true; + } + + Process { + id: subscriber + running: true + command: ["nmcli", "monitor"] + stdout: SplitParser { + onRead: root.update() + } + } + + Process { + id: updateConnectionType + property string buffer + command: ["sh", "-c", "nmcli -t -f TYPE,STATE d status && nmcli -t -f CONNECTIVITY g"] + running: true + function startCheck() { + buffer = ""; + updateConnectionType.running = true; + } + stdout: SplitParser { + onRead: data => { + updateConnectionType.buffer += data + "\n"; + } + } + onExited: (exitCode, exitStatus) => { + const lines = updateConnectionType.buffer.trim().split('\n'); + const connectivity = lines.pop() // none, limited, full + let hasEthernet = false; + let hasWifi = false; + let wifiStatus = "disconnected"; + lines.forEach(line => { + if (line.includes("ethernet") && line.includes("connected")) + hasEthernet = true; + else if (line.includes("wifi:")) { + if (line.includes("disconnected")) { + wifiStatus = "disconnected" + } + else if (line.includes("connected")) { + hasWifi = true; + wifiStatus = "connected" + + if (connectivity === "limited") { + hasWifi = false; + wifiStatus = "limited" + } + } + else if (line.includes("connecting")) { + wifiStatus = "connecting" + } + else if (line.includes("unavailable")) { + wifiStatus = "disabled" + } + } + }); + root.wifiStatus = wifiStatus; + root.ethernet = hasEthernet; + root.wifi = hasWifi; + } + } + + Process { + id: updateNetworkName + command: ["sh", "-c", "nmcli -t -f NAME c show --active | head -1"] + running: true + stdout: SplitParser { + onRead: data => { + root.networkName = data; + } + } + } + + Process { + id: updateNetworkStrength + running: true + command: ["sh", "-c", "nmcli -f IN-USE,SIGNAL,SSID device wifi | awk '/^\*/{if (NR!=1) {print $2}}'"] + stdout: SplitParser { + onRead: data => { + root.networkStrength = parseInt(data); + } + } + } + + Process { + id: wifiStatusProcess + command: ["nmcli", "radio", "wifi"] + Component.onCompleted: running = true + environment: ({ + LANG: "C", + LC_ALL: "C" + }) + stdout: StdioCollector { + onStreamFinished: { + root.wifiEnabled = text.trim() === "enabled"; + } + } + } + + Process { + id: getNetworks + running: true + command: ["nmcli", "-g", "ACTIVE,SIGNAL,FREQ,SSID,BSSID,SECURITY", "d", "w"] + environment: ({ + LANG: "C", + LC_ALL: "C" + }) + stdout: StdioCollector { + onStreamFinished: { + const PLACEHOLDER = "STRINGWHICHHOPEFULLYWONTBEUSED"; + const rep = new RegExp("\\\\:", "g"); + const rep2 = new RegExp(PLACEHOLDER, "g"); + + const allNetworks = text.trim().split("\n").map(n => { + const net = n.replace(rep, PLACEHOLDER).split(":"); + return { + active: net[0] === "yes", + strength: parseInt(net[1]), + frequency: parseInt(net[2]), + ssid: net[3], + bssid: net[4]?.replace(rep2, ":") ?? "", + security: net[5] || "" + }; + }).filter(n => n.ssid && n.ssid.length > 0); + + // Group networks by SSID and prioritize connected ones + const networkMap = new Map(); + for (const network of allNetworks) { + const existing = networkMap.get(network.ssid); + if (!existing) { + networkMap.set(network.ssid, network); + } else { + // Prioritize active/connected networks + if (network.active && !existing.active) { + networkMap.set(network.ssid, network); + } else if (!network.active && !existing.active) { + // If both are inactive, keep the one with better signal + if (network.strength > existing.strength) { + networkMap.set(network.ssid, network); + } + } + // If existing is active and new is not, keep existing + } + } + + const wifiNetworks = Array.from(networkMap.values()); + + const rNetworks = root.wifiNetworks; + + const destroyed = rNetworks.filter(rn => !wifiNetworks.find(n => n.frequency === rn.frequency && n.ssid === rn.ssid && n.bssid === rn.bssid)); + for (const network of destroyed) + rNetworks.splice(rNetworks.indexOf(network), 1).forEach(n => n.destroy()); + + for (const network of wifiNetworks) { + const match = rNetworks.find(n => n.frequency === network.frequency && n.ssid === network.ssid && n.bssid === network.bssid); + if (match) { + match.lastIpcObject = network; + } else { + rNetworks.push(apComp.createObject(root, { + lastIpcObject: network + })); + } + } + } + } + } + + Component { + id: apComp + + WifiAccessPoint {} + } +} diff --git a/modules/quickshell/config/services/Notifications.qml b/modules/quickshell/config/services/Notifications.qml new file mode 100644 index 0000000..702da0f --- /dev/null +++ b/modules/quickshell/config/services/Notifications.qml @@ -0,0 +1,305 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Notifications + +/** + * Provides extra features not in Quickshell.Services.Notifications: + * - Persistent storage + * - Popup notifications, with timeout + * - Notification groups by app + */ +Singleton { + id: root + component Notif: QtObject { + id: wrapper + required property int notificationId // Could just be `id` but it conflicts with the default prop in QtObject + property Notification notification + property list actions: notification?.actions.map((action) => ({ + "identifier": action.identifier, + "text": action.text, + })) ?? [] + property bool popup: false + property bool isTransient: notification?.hints.transient ?? false + property string appIcon: notification?.appIcon ?? "" + property string appName: notification?.appName ?? "" + property string body: notification?.body ?? "" + property string image: notification?.image ?? "" + property string summary: notification?.summary ?? "" + property double time + property string urgency: notification?.urgency.toString() ?? "normal" + property Timer timer + + onNotificationChanged: { + if (notification === null) { + root.discardNotification(notificationId); + } + } + } + + function notifToJSON(notif) { + return { + "notificationId": notif.notificationId, + "actions": notif.actions, + "appIcon": notif.appIcon, + "appName": notif.appName, + "body": notif.body, + "image": notif.image, + "summary": notif.summary, + "time": notif.time, + "urgency": notif.urgency, + } + } + function notifToString(notif) { + return JSON.stringify(notifToJSON(notif), null, 2); + } + + component NotifTimer: Timer { + required property int notificationId + interval: 7000 + running: true + onTriggered: () => { + const index = root.list.findIndex((notif) => notif.notificationId === notificationId); + const notifObject = root.list[index]; + print("[Notifications] Notification timer triggered for ID: " + notificationId + ", transient: " + notifObject?.isTransient); + if (notifObject.isTransient) root.discardNotification(notificationId); + else root.timeoutNotification(notificationId); + destroy() + } + } + + property bool silent: false + property int unread: 0 + property var filePath: Directories.notificationsPath + property list list: [] + property var popupList: list.filter((notif) => notif.popup); + property bool popupInhibited: (GlobalStates?.sidebarRightOpen ?? false) || silent + property var latestTimeForApp: ({}) + Component { + id: notifComponent + Notif {} + } + Component { + id: notifTimerComponent + NotifTimer {} + } + + function stringifyList(list) { + return JSON.stringify(list.map((notif) => notifToJSON(notif)), null, 2); + } + + onListChanged: { + // Update latest time for each app + root.list.forEach((notif) => { + if (!root.latestTimeForApp[notif.appName] || notif.time > root.latestTimeForApp[notif.appName]) { + root.latestTimeForApp[notif.appName] = Math.max(root.latestTimeForApp[notif.appName] || 0, notif.time); + } + }); + // Remove apps that no longer have notifications + Object.keys(root.latestTimeForApp).forEach((appName) => { + if (!root.list.some((notif) => notif.appName === appName)) { + delete root.latestTimeForApp[appName]; + } + }); + } + + function appNameListForGroups(groups) { + return Object.keys(groups).sort((a, b) => { + // Sort by time, descending + return groups[b].time - groups[a].time; + }); + } + + function groupsForList(list) { + const groups = {}; + list.forEach((notif) => { + if (!groups[notif.appName]) { + groups[notif.appName] = { + appName: notif.appName, + appIcon: notif.appIcon, + notifications: [], + time: 0 + }; + } + groups[notif.appName].notifications.push(notif); + // Always set to the latest time in the group + groups[notif.appName].time = latestTimeForApp[notif.appName] || notif.time; + }); + return groups; + } + + property var groupsByAppName: groupsForList(root.list) + property var popupGroupsByAppName: groupsForList(root.popupList) + property var appNameList: appNameListForGroups(root.groupsByAppName) + property var popupAppNameList: appNameListForGroups(root.popupGroupsByAppName) + + // Quickshell's notification IDs starts at 1 on each run, while saved notifications + // can already contain higher IDs. This is for avoiding id collisions + property int idOffset + signal initDone(); + signal notify(notification: var); + signal discard(id: int); + signal discardAll(); + signal timeout(id: var); + + NotificationServer { + id: notifServer + // actionIconsSupported: true + actionsSupported: true + bodyHyperlinksSupported: true + bodyImagesSupported: true + bodyMarkupSupported: true + bodySupported: true + imageSupported: true + keepOnReload: false + persistenceSupported: true + + onNotification: (notification) => { + notification.tracked = true + const newNotifObject = notifComponent.createObject(root, { + "notificationId": notification.id + root.idOffset, + "notification": notification, + "time": Date.now(), + }); + root.list = [...root.list, newNotifObject]; + + // Popup + if (!root.popupInhibited) { + newNotifObject.popup = true; + if (notification.expireTimeout != 0) { + newNotifObject.timer = notifTimerComponent.createObject(root, { + "notificationId": newNotifObject.notificationId, + "interval": notification.expireTimeout < 0 ? (Config?.options.notifications.timeout ?? 7000) : notification.expireTimeout, + }); + } + root.unread++; + } + root.notify(newNotifObject); + // console.log(notifToString(newNotifObject)); + notifFileView.setText(stringifyList(root.list)); + } + } + + function markAllRead() { + root.unread = 0; + } + + function discardNotification(id) { + console.log("[Notifications] Discarding notification with ID: " + id); + const index = root.list.findIndex((notif) => notif.notificationId === id); + const notifServerIndex = notifServer.trackedNotifications.values.findIndex((notif) => notif.id + root.idOffset === id); + if (index !== -1) { + root.list.splice(index, 1); + notifFileView.setText(stringifyList(root.list)); + triggerListChange() + } + if (notifServerIndex !== -1) { + notifServer.trackedNotifications.values[notifServerIndex].dismiss() + } + root.discard(id); // Emit signal + } + + function discardAllNotifications() { + root.list = [] + triggerListChange() + notifFileView.setText(stringifyList(root.list)); + notifServer.trackedNotifications.values.forEach((notif) => { + notif.dismiss() + }) + root.discardAll(); + } + + function cancelTimeout(id) { + const index = root.list.findIndex((notif) => notif.notificationId === id); + if (root.list[index] != null) + root.list[index].timer.stop(); + } + + function timeoutNotification(id) { + const index = root.list.findIndex((notif) => notif.notificationId === id); + if (root.list[index] != null) + root.list[index].popup = false; + root.timeout(id); + } + + function timeoutAll() { + root.popupList.forEach((notif) => { + root.timeout(notif.notificationId); + }) + root.popupList.forEach((notif) => { + notif.popup = false; + }); + } + + function attemptInvokeAction(id, notifIdentifier) { + console.log("[Notifications] Attempting to invoke action with identifier: " + notifIdentifier + " for notification ID: " + id); + const notifServerIndex = notifServer.trackedNotifications.values.findIndex((notif) => notif.id + root.idOffset === id); + console.log("Notification server index: " + notifServerIndex); + if (notifServerIndex !== -1) { + const notifServerNotif = notifServer.trackedNotifications.values[notifServerIndex]; + const action = notifServerNotif.actions.find((action) => action.identifier === notifIdentifier); + // console.log("Action found: " + JSON.stringify(action)); + action.invoke() + } + else { + console.log("Notification not found in server: " + id) + } + root.discardNotification(id); + } + + function triggerListChange() { + root.list = root.list.slice(0) + } + + function refresh() { + notifFileView.reload() + } + + Component.onCompleted: { + refresh() + } + + FileView { + id: notifFileView + path: Qt.resolvedUrl(filePath) + onLoaded: { + const fileContents = notifFileView.text() + root.list = JSON.parse(fileContents).map((notif) => { + return notifComponent.createObject(root, { + "notificationId": notif.notificationId, + "actions": [], // Notification actions are meaningless if they're not tracked by the server or the sender is dead + "appIcon": notif.appIcon, + "appName": notif.appName, + "body": notif.body, + "image": notif.image, + "summary": notif.summary, + "time": notif.time, + "urgency": notif.urgency, + }); + }); + // Find largest notificationId + let maxId = 0 + root.list.forEach((notif) => { + maxId = Math.max(maxId, notif.notificationId) + }) + + console.log("[Notifications] File loaded") + root.idOffset = maxId + root.initDone() + } + onLoadFailed: (error) => { + if(error == FileViewError.FileNotFound) { + console.log("[Notifications] File not found, creating new file.") + root.list = [] + notifFileView.setText(stringifyList(root.list)); + } else { + console.log("[Notifications] Error loading file: " + error) + } + } + } +} diff --git a/modules/quickshell/config/services/PolkitService.qml b/modules/quickshell/config/services/PolkitService.qml new file mode 100644 index 0000000..56576f4 --- /dev/null +++ b/modules/quickshell/config/services/PolkitService.qml @@ -0,0 +1,37 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Services.Polkit + +Singleton { + id: root + property alias agent: polkitAgent + property alias active: polkitAgent.isActive + property alias flow: polkitAgent.flow + property bool interactionAvailable: false + + function cancel() { + root.flow.cancelAuthenticationRequest() + } + + function submit(string) { + root.flow.submit(string) + root.interactionAvailable = false + } + + Connections { + target: root.flow + function onAuthenticationFailed() { + root.interactionAvailable = true; + } + } + + PolkitAgent { + id: polkitAgent + onAuthenticationRequestStarted: { + root.interactionAvailable = true; + } + } +} diff --git a/modules/quickshell/config/services/ResourceUsage.qml b/modules/quickshell/config/services/ResourceUsage.qml new file mode 100644 index 0000000..df823ad --- /dev/null +++ b/modules/quickshell/config/services/ResourceUsage.qml @@ -0,0 +1,114 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Io + +/** + * Simple polled resource usage service with RAM, Swap, and CPU usage. + */ +Singleton { + id: root + property real memoryTotal: 1 + property real memoryFree: 0 + property real memoryUsed: memoryTotal - memoryFree + property real memoryUsedPercentage: memoryUsed / memoryTotal + property real swapTotal: 1 + property real swapFree: 0 + property real swapUsed: swapTotal - swapFree + property real swapUsedPercentage: swapTotal > 0 ? (swapUsed / swapTotal) : 0 + property real cpuUsage: 0 + property var previousCpuStats + + property string maxAvailableMemoryString: kbToGbString(ResourceUsage.memoryTotal) + property string maxAvailableSwapString: kbToGbString(ResourceUsage.swapTotal) + property string maxAvailableCpuString: "--" + + readonly property int historyLength: Config?.options.resources.historyLength ?? 60 + property list cpuUsageHistory: [] + property list memoryUsageHistory: [] + property list swapUsageHistory: [] + + function kbToGbString(kb) { + return (kb / (1024 * 1024)).toFixed(1) + " GB"; + } + + function updateMemoryUsageHistory() { + memoryUsageHistory = [...memoryUsageHistory, memoryUsedPercentage] + if (memoryUsageHistory.length > historyLength) { + memoryUsageHistory.shift() + } + } + function updateSwapUsageHistory() { + swapUsageHistory = [...swapUsageHistory, swapUsedPercentage] + if (swapUsageHistory.length > historyLength) { + swapUsageHistory.shift() + } + } + function updateCpuUsageHistory() { + cpuUsageHistory = [...cpuUsageHistory, cpuUsage] + if (cpuUsageHistory.length > historyLength) { + cpuUsageHistory.shift() + } + } + function updateHistories() { + updateMemoryUsageHistory() + updateSwapUsageHistory() + updateCpuUsageHistory() + } + + Timer { + interval: 1 + running: true + repeat: true + onTriggered: { + // Reload files + fileMeminfo.reload() + fileStat.reload() + + // Parse memory and swap usage + const textMeminfo = fileMeminfo.text() + memoryTotal = Number(textMeminfo.match(/MemTotal: *(\d+)/)?.[1] ?? 1) + memoryFree = Number(textMeminfo.match(/MemAvailable: *(\d+)/)?.[1] ?? 0) + swapTotal = Number(textMeminfo.match(/SwapTotal: *(\d+)/)?.[1] ?? 1) + swapFree = Number(textMeminfo.match(/SwapFree: *(\d+)/)?.[1] ?? 0) + + // Parse CPU usage + const textStat = fileStat.text() + const cpuLine = textStat.match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/) + if (cpuLine) { + const stats = cpuLine.slice(1).map(Number) + const total = stats.reduce((a, b) => a + b, 0) + const idle = stats[3] + + if (previousCpuStats) { + const totalDiff = total - previousCpuStats.total + const idleDiff = idle - previousCpuStats.idle + cpuUsage = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0 + } + + previousCpuStats = { total, idle } + } + + root.updateHistories() + interval = Config.options?.resources?.updateInterval ?? 3000 + } + } + + FileView { id: fileMeminfo; path: "/proc/meminfo" } + FileView { id: fileStat; path: "/proc/stat" } + + Process { + id: findCpuMaxFreqProc + command: ["bash", "-c", "lscpu | grep 'CPU max MHz' | awk '{print $4}'"] + running: true + stdout: StdioCollector { + id: outputCollector + onStreamFinished: { + root.maxAvailableCpuString = (parseFloat(outputCollector.text) / 1000).toFixed(0) + " GHz" + } + } + } +} diff --git a/modules/quickshell/config/services/SongRec.qml b/modules/quickshell/config/services/SongRec.qml new file mode 100644 index 0000000..0c75f5c --- /dev/null +++ b/modules/quickshell/config/services/SongRec.qml @@ -0,0 +1,103 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + enum MonitorSource { Monitor, Input } + + property var monitorSource: SongRec.MonitorSource.Monitor + property int timeoutInterval: Config.options.musicRecognition.interval + property int timeoutDuration: Config.options.musicRecognition.timeout + readonly property bool running: recognizeMusicProc.running + + function toggleRunning(running) { + if (recognizeMusicProc.running && !running === true) root.manuallyStopped = true; + if (running != undefined) { + recognizeMusicProc.running = running + } else { + recognizeMusicProc.running = !root.running + } + musicReconizedProc.running = false + } + + function toggleMonitorSource(source) { + if (source !== undefined) { + root.monitorSource = source + return + } + root.monitorSource = (root.monitorSource === SongRec.MonitorSource.Monitor) ? SongRec.MonitorSource.Input : SongRec.MonitorSource.Monitor + } + function monitorSourceToString(source) { + if (source === SongRec.MonitorSource.Monitor) { + return "monitor" + } else { + return "input" + } + } + readonly property string monitorSourceString: monitorSourceToString(monitorSource) + property var recognizedTrack: ({ title:"", subtitle:"", url:""}) + property bool manuallyStopped: false + + function handleRecognition(jsonText) { + try { + var obj = JSON.parse(jsonText) + root.recognizedTrack = { + title: obj.track.title, + subtitle: obj.track.subtitle, + url: obj.track.url + } + musicReconizedProc.running = true + } catch(e) { + Quickshell.execDetached(["notify-send", Translation.tr("Couldn't recognize music"), Translation.tr("Perhaps what you're listening to is too niche"), "-a", "Shell"]) + } + } + + Process { + id: recognizeMusicProc + running: false + command: [`${Directories.scriptPath}/musicRecognition/recognize-music.sh`, "-i", root.timeoutInterval, "-t", root.timeoutDuration, "-s", root.monitorSourceString] + stdout: StdioCollector { + onStreamFinished: { + if (root.manuallyStopped) { + root.manuallyStopped = false + return + } + handleRecognition(this.text) + } + } + onExited: (exitCode, exitStatus) => { + if (exitCode === 1) { + Quickshell.execDetached(["notify-send", Translation.tr("Couldn't recognize music"), Translation.tr("Make sure you have songrec installed"), "-a", "Shell"]) + } + } + } + + Process { + id: musicReconizedProc + running: false + command: [ + "notify-send", + Translation.tr("Music Recognized"), + root.recognizedTrack.title + " - " + root.recognizedTrack.subtitle, + "-A", "Shazam", + "-A", "YouTube", + "-a", "Shell" + ] + stdout: StdioCollector { + onStreamFinished: { + if (this.text === "") return + if (this.text == 0) { + Qt.openUrlExternally(root.recognizedTrack.url); + } else { + Qt.openUrlExternally("https://www.youtube.com/results?search_query=" + root.recognizedTrack.title + " - " + root.recognizedTrack.subtitle); + } + } + } + } +} \ No newline at end of file diff --git a/modules/quickshell/config/services/SystemInfo.qml b/modules/quickshell/config/services/SystemInfo.qml new file mode 100644 index 0000000..2b519a2 --- /dev/null +++ b/modules/quickshell/config/services/SystemInfo.qml @@ -0,0 +1,117 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io + +/** + * Provides some system info: distro, username. + */ +Singleton { + id: root + property string distroName: "Unknown" + property string distroId: "unknown" + property string distroIcon: "linux-symbolic" + property string username: "user" + property string homeUrl: "" + property string documentationUrl: "" + property string supportUrl: "" + property string bugReportUrl: "" + property string privacyPolicyUrl: "" + property string logo: "" + property string desktopEnvironment: "" + property string windowingSystem: "" + + Timer { + triggeredOnStart: true + interval: 1 + running: true + repeat: false + onTriggered: { + getUsername.running = true + fileOsRelease.reload() + const textOsRelease = fileOsRelease.text() + + // Extract the friendly name (PRETTY_NAME field, fallback to NAME) + const prettyNameMatch = textOsRelease.match(/^PRETTY_NAME="(.+?)"/m) + const nameMatch = textOsRelease.match(/^NAME="(.+?)"/m) + distroName = prettyNameMatch ? prettyNameMatch[1] : (nameMatch ? nameMatch[1].replace(/Linux/i, "").trim() : "Unknown") + + // Extract the ID + const idMatch = textOsRelease.match(/^ID="?(.+?)"?$/m) + distroId = idMatch ? idMatch[1] : "unknown" + + // Extract additional URLs and logo + const homeUrlMatch = textOsRelease.match(/^HOME_URL="(.+?)"/m) + homeUrl = homeUrlMatch ? homeUrlMatch[1] : "" + const documentationUrlMatch = textOsRelease.match(/^DOCUMENTATION_URL="(.+?)"/m) + documentationUrl = documentationUrlMatch ? documentationUrlMatch[1] : "" + const supportUrlMatch = textOsRelease.match(/^SUPPORT_URL="(.+?)"/m) + supportUrl = supportUrlMatch ? supportUrlMatch[1] : "" + const bugReportUrlMatch = textOsRelease.match(/^BUG_REPORT_URL="(.+?)"/m) + bugReportUrl = bugReportUrlMatch ? bugReportUrlMatch[1] : "" + const privacyPolicyUrlMatch = textOsRelease.match(/^PRIVACY_POLICY_URL="(.+?)"/m) + privacyPolicyUrl = privacyPolicyUrlMatch ? privacyPolicyUrlMatch[1] : "" + const logoFieldMatch = textOsRelease.match(/^LOGO="?(.+?)"?$/m) + logo = logoFieldMatch ? logoFieldMatch[1] : "" + + // Update the distroIcon property based on distroId + switch (distroId) { + case "artix": + case "arch": distroIcon = "arch-symbolic"; break; + case "endeavouros": distroIcon = "endeavouros-symbolic"; break; + case "cachyos": distroIcon = "cachyos-symbolic"; break; + case "nixos": distroIcon = "nixos-symbolic"; break; + case "fedora": distroIcon = "fedora-symbolic"; break; + case "linuxmint": + case "ubuntu": + case "zorin": + case "popos": distroIcon = "ubuntu-symbolic"; break; + case "debian": + case "raspbian": + case "kali": distroIcon = "debian-symbolic"; break; + case "funtoo": + case "gentoo": distroIcon = "gentoo-symbolic"; break; + default: distroIcon = "linux-symbolic"; break; + } + if (textOsRelease.toLowerCase().includes("nyarch")) { + distroIcon = "nyarch-symbolic" + } + + if (logo.trim().length === 0) { + logo = distroIcon + } + + } + } + + Process { + id: getUsername + command: ["whoami"] + stdout: SplitParser { + onRead: data => { + root.username = data.trim() + } + } + } + + Process { + id: getDesktopEnvironment + running: true + command: ["bash", "-c", "echo $XDG_CURRENT_DESKTOP,$WAYLAND_DISPLAY"] + stdout: StdioCollector { + id: deCollector + onStreamFinished: { + const [desktop, wayland] = deCollector.text.split(",") + root.desktopEnvironment = desktop.trim() + root.windowingSystem = wayland.trim().length > 0 ? "Wayland" : "X11" // Are there others? ๐Ÿค” + } + } + } + + FileView { + id: fileOsRelease + path: "/etc/os-release" + } +} \ No newline at end of file diff --git a/modules/quickshell/config/services/TaskbarApps.qml b/modules/quickshell/config/services/TaskbarApps.qml new file mode 100644 index 0000000..052abca --- /dev/null +++ b/modules/quickshell/config/services/TaskbarApps.qml @@ -0,0 +1,68 @@ +pragma Singleton + +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Wayland + +Singleton { + id: root + + function togglePin(appId) { + if (Config.options.dock.pinnedApps.indexOf(appId) !== -1) { + Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.filter(id => id !== appId) + } else { + Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.concat([appId]) + } + } + + property list apps: { + var map = new Map(); + + // Pinned apps + const pinnedApps = Config.options?.dock.pinnedApps ?? []; + for (const appId of pinnedApps) { + if (!map.has(appId.toLowerCase())) map.set(appId.toLowerCase(), ({ + pinned: true, + toplevels: [] + })); + } + + // Separator + if (pinnedApps.length > 0) { + map.set("SEPARATOR", { pinned: false, toplevels: [] }); + } + + // Ignored apps + const ignoredRegexStrings = Config.options?.dock.ignoredAppRegexes ?? []; + const ignoredRegexes = ignoredRegexStrings.map(pattern => new RegExp(pattern, "i")); + // Open windows + for (const toplevel of ToplevelManager.toplevels.values) { + if (ignoredRegexes.some(re => re.test(toplevel.appId))) continue; + if (!map.has(toplevel.appId.toLowerCase())) map.set(toplevel.appId.toLowerCase(), ({ + pinned: false, + toplevels: [] + })); + map.get(toplevel.appId.toLowerCase()).toplevels.push(toplevel); + } + + var values = []; + + for (const [key, value] of map) { + values.push(appEntryComp.createObject(null, { appId: key, toplevels: value.toplevels, pinned: value.pinned })); + } + + return values; + } + + component TaskbarAppEntry: QtObject { + id: wrapper + required property string appId + required property list toplevels + required property bool pinned + } + Component { + id: appEntryComp + TaskbarAppEntry {} + } +} diff --git a/modules/quickshell/config/services/TimerService.qml b/modules/quickshell/config/services/TimerService.qml new file mode 100644 index 0000000..69e5fe1 --- /dev/null +++ b/modules/quickshell/config/services/TimerService.qml @@ -0,0 +1,142 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.services +import qs.modules.common + +import Quickshell +import Quickshell.Io +import QtQuick + +/** + * Simple Pomodoro time manager. + */ +Singleton { + id: root + + property int focusTime: Config.options.time.pomodoro.focus + property int breakTime: Config.options.time.pomodoro.breakTime + property int longBreakTime: Config.options.time.pomodoro.longBreak + property int cyclesBeforeLongBreak: Config.options.time.pomodoro.cyclesBeforeLongBreak + + property bool pomodoroRunning: Persistent.states.timer.pomodoro.running + property bool pomodoroBreak: Persistent.states.timer.pomodoro.isBreak + property bool pomodoroLongBreak: Persistent.states.timer.pomodoro.isBreak && (pomodoroCycle + 1 == cyclesBeforeLongBreak); + property int pomodoroLapDuration: pomodoroLongBreak ? longBreakTime : pomodoroBreak ? breakTime : focusTime // This is a binding that's to be kept + property int pomodoroSecondsLeft: pomodoroLapDuration // Reasonable init value, to be changed + property int pomodoroCycle: Persistent.states.timer.pomodoro.cycle + + property bool stopwatchRunning: Persistent.states.timer.stopwatch.running + property int stopwatchTime: 0 + property int stopwatchStart: Persistent.states.timer.stopwatch.start + property var stopwatchLaps: Persistent.states.timer.stopwatch.laps + + // General + Component.onCompleted: { + if (!stopwatchRunning) + stopwatchReset(); + } + + function getCurrentTimeInSeconds() { // Pomodoro uses Seconds + return Math.floor(Date.now() / 1000); + } + + function getCurrentTimeIn10ms() { // Stopwatch uses 10ms + return Math.floor(Date.now() / 10); + } + + // Pomodoro + function refreshPomodoro() { + // Work <-> break ? + if (getCurrentTimeInSeconds() >= Persistent.states.timer.pomodoro.start + pomodoroLapDuration) { + // Reset counts + Persistent.states.timer.pomodoro.isBreak = !Persistent.states.timer.pomodoro.isBreak; + Persistent.states.timer.pomodoro.start = getCurrentTimeInSeconds(); + + // Send notification + let notificationMessage; + if (Persistent.states.timer.pomodoro.isBreak && (pomodoroCycle + 1 == cyclesBeforeLongBreak)) { + notificationMessage = Translation.tr(`๐ŸŒฟ Long break: %1 minutes`).arg(Math.floor(longBreakTime / 60)); + } else if (Persistent.states.timer.pomodoro.isBreak) { + notificationMessage = Translation.tr(`โ˜• Break: %1 minutes`).arg(Math.floor(breakTime / 60)); + } else { + notificationMessage = Translation.tr(`๐Ÿ”ด Focus: %1 minutes`).arg(Math.floor(focusTime / 60)); + } + + Quickshell.execDetached(["notify-send", "Pomodoro", notificationMessage, "-a", "Shell"]); + if (Config.options.sounds.pomodoro) { + Audio.playSystemSound("alarm-clock-elapsed") + } + + if (!pomodoroBreak) { + Persistent.states.timer.pomodoro.cycle = (Persistent.states.timer.pomodoro.cycle + 1) % root.cyclesBeforeLongBreak; + } + } + + pomodoroSecondsLeft = pomodoroLapDuration - (getCurrentTimeInSeconds() - Persistent.states.timer.pomodoro.start); + } + + Timer { + id: pomodoroTimer + interval: 200 + running: root.pomodoroRunning + repeat: true + onTriggered: refreshPomodoro() + } + + function togglePomodoro() { + Persistent.states.timer.pomodoro.running = !pomodoroRunning; + if (Persistent.states.timer.pomodoro.running) { + // Start/Resume + Persistent.states.timer.pomodoro.start = getCurrentTimeInSeconds() + pomodoroSecondsLeft - pomodoroLapDuration; + } + } + + function resetPomodoro() { + Persistent.states.timer.pomodoro.running = false; + Persistent.states.timer.pomodoro.isBreak = false; + Persistent.states.timer.pomodoro.start = getCurrentTimeInSeconds(); + Persistent.states.timer.pomodoro.cycle = 0; + refreshPomodoro(); + } + + // Stopwatch + function refreshStopwatch() { // Stopwatch stores time in 10ms + stopwatchTime = getCurrentTimeIn10ms() - stopwatchStart; + } + + Timer { + id: stopwatchTimer + interval: 10 + running: root.stopwatchRunning + repeat: true + onTriggered: refreshStopwatch() + } + + function toggleStopwatch() { + if (root.stopwatchRunning) + stopwatchPause(); + else + stopwatchResume(); + } + + function stopwatchPause() { + Persistent.states.timer.stopwatch.running = false; + } + + function stopwatchResume() { + if (stopwatchTime === 0) Persistent.states.timer.stopwatch.laps = []; + Persistent.states.timer.stopwatch.running = true; + Persistent.states.timer.stopwatch.start = getCurrentTimeIn10ms() - stopwatchTime; + } + + function stopwatchReset() { + stopwatchTime = 0; + Persistent.states.timer.stopwatch.laps = []; + Persistent.states.timer.stopwatch.running = false; + } + + function stopwatchRecordLap() { + Persistent.states.timer.stopwatch.laps.push(stopwatchTime); + } +} diff --git a/modules/quickshell/config/services/Todo.qml b/modules/quickshell/config/services/Todo.qml new file mode 100644 index 0000000..93227cb --- /dev/null +++ b/modules/quickshell/config/services/Todo.qml @@ -0,0 +1,87 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import Quickshell; +import Quickshell.Io; +import QtQuick; + +/** + * Simple to-do list manager. + * Each item is an object with "content" and "done" properties. + */ +Singleton { + id: root + property var filePath: Directories.todoPath + property var list: [] + + function addItem(item) { + list.push(item) + // Reassign to trigger onListChanged + root.list = list.slice(0) + todoFileView.setText(JSON.stringify(root.list)) + } + + function addTask(desc) { + const item = { + "content": desc, + "done": false, + } + addItem(item) + } + + function markDone(index) { + if (index >= 0 && index < list.length) { + list[index].done = true + // Reassign to trigger onListChanged + root.list = list.slice(0) + todoFileView.setText(JSON.stringify(root.list)) + } + } + + function markUnfinished(index) { + if (index >= 0 && index < list.length) { + list[index].done = false + // Reassign to trigger onListChanged + root.list = list.slice(0) + todoFileView.setText(JSON.stringify(root.list)) + } + } + + function deleteItem(index) { + if (index >= 0 && index < list.length) { + list.splice(index, 1) + // Reassign to trigger onListChanged + root.list = list.slice(0) + todoFileView.setText(JSON.stringify(root.list)) + } + } + + function refresh() { + todoFileView.reload() + } + + Component.onCompleted: { + refresh() + } + + FileView { + id: todoFileView + path: Qt.resolvedUrl(root.filePath) + onLoaded: { + const fileContents = todoFileView.text() + root.list = JSON.parse(fileContents) + console.log("[To Do] File loaded") + } + onLoadFailed: (error) => { + if(error == FileViewError.FileNotFound) { + console.log("[To Do] File not found, creating new file.") + root.list = [] + todoFileView.setText(JSON.stringify(root.list)) + } else { + console.log("[To Do] Error loading file: " + error) + } + } + } +} + diff --git a/modules/quickshell/config/services/Translation.qml b/modules/quickshell/config/services/Translation.qml new file mode 100644 index 0000000..5a21562 --- /dev/null +++ b/modules/quickshell/config/services/Translation.qml @@ -0,0 +1,17 @@ +pragma Singleton + +import QtQuick +import Quickshell + +/** + * Stub Translation service - just returns original English strings. + */ +Singleton { + id: root + property string languageCode: "en_US" + + function tr(text) { + if (!text) return ""; + return text.toString(); + } +} diff --git a/modules/quickshell/config/services/TrayService.qml b/modules/quickshell/config/services/TrayService.qml new file mode 100644 index 0000000..a874c6c --- /dev/null +++ b/modules/quickshell/config/services/TrayService.qml @@ -0,0 +1,45 @@ +pragma Singleton + +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Services.SystemTray + +Singleton { + id: root + + property bool smartTray: Config.options.tray.filterPassive + property list itemsInUserList: SystemTray.items.values.filter(i => (Config.options.tray.pinnedItems.includes(i.id) && (!smartTray || i.status !== Status.Passive))) + property list itemsNotInUserList: SystemTray.items.values.filter(i => (!Config.options.tray.pinnedItems.includes(i.id) && (!smartTray || i.status !== Status.Passive))) + + property bool invertPins: Config.options.tray.invertPinnedItems + property list pinnedItems: invertPins ? itemsNotInUserList : itemsInUserList + property list unpinnedItems: invertPins ? itemsInUserList : itemsNotInUserList + + function getTooltipForItem(item) { + var result = item.tooltipTitle.length > 0 ? item.tooltipTitle + : (item.title.length > 0 ? item.title : item.id); + if (item.tooltipDescription.length > 0) result += " โ€ข " + item.tooltipDescription; + if (Config.options.tray.showItemId) result += "\n[" + item.id + "]"; + return result; + } + + // Pinning + function pin(itemId) { + var pins = Config.options.tray.pinnedItems; + if (pins.includes(itemId)) return; + Config.options.tray.pinnedItems.push(itemId); + } + function unpin(itemId) { + Config.options.tray.pinnedItems = Config.options.tray.pinnedItems.filter(id => id !== itemId); + } + function togglePin(itemId) { + var pins = Config.options.tray.pinnedItems; + if (pins.includes(itemId)) { + unpin(itemId) + } else { + pin(itemId) + } + } + +} diff --git a/modules/quickshell/config/services/Updates.qml b/modules/quickshell/config/services/Updates.qml new file mode 100644 index 0000000..58b8be8 --- /dev/null +++ b/modules/quickshell/config/services/Updates.qml @@ -0,0 +1,57 @@ +pragma Singleton + +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import Quickshell +import Quickshell.Io + +/* + * System updates service. Currently only supports Arch. + */ +Singleton { + id: root + + property bool available: false + property int count: 0 + + readonly property bool updateAdvised: available && count > Config.options.updates.adviseUpdateThreshold + readonly property bool updateStronglyAdvised: available && count > Config.options.updates.stronglyAdviseUpdateThreshold + + function load() {} + function refresh() { + if (!available) return; + print("[Updates] Checking for system updates") + checkUpdatesProc.running = true; + } + + Timer { + interval: Config.options.updates.checkInterval * 60 * 1000 + repeat: true + running: Config.ready + onTriggered: { + print("[Updates] Periodic update check due") + root.refresh(); + } + } + + Process { + id: checkAvailabilityProc + running: true + command: ["which", "checkupdates"] + onExited: (exitCode, exitStatus) => { + root.available = (exitCode === 0); + root.refresh(); + } + } + + Process { + id: checkUpdatesProc + command: ["bash", "-c", "checkupdates | wc -l"] + stdout: StdioCollector { + onStreamFinished: { + root.count = parseInt(text.trim()); + } + } + } +} diff --git a/modules/quickshell/config/services/Wallpapers.qml b/modules/quickshell/config/services/Wallpapers.qml new file mode 100644 index 0000000..9624516 --- /dev/null +++ b/modules/quickshell/config/services/Wallpapers.qml @@ -0,0 +1,192 @@ +import qs.modules.common +import qs.modules.common.models +import qs.modules.common.functions +import QtQuick +import Qt.labs.folderlistmodel +import Quickshell +import Quickshell.Io +pragma Singleton +pragma ComponentBehavior: Bound + +/** + * Provides a list of wallpapers and an "apply" action that calls the existing + * switchwall.sh script. Pretty much a limited file browsing service. + */ +Singleton { + id: root + + property string thumbgenScriptPath: `${FileUtils.trimFileProtocol(Directories.scriptPath)}/thumbnails/thumbgen-venv.sh` + property string generateThumbnailsMagickScriptPath: `${FileUtils.trimFileProtocol(Directories.scriptPath)}/thumbnails/generate-thumbnails-magick.sh` + property alias directory: folderModel.folder + readonly property string effectiveDirectory: FileUtils.trimFileProtocol(folderModel.folder.toString()) + property url defaultFolder: Qt.resolvedUrl(`${Directories.pictures}/Wallpapers`) + property alias folderModel: folderModel // Expose for direct binding when needed + property string searchQuery: "" + readonly property list extensions: [ // TODO: add videos + "jpg", "jpeg", "png", "webp", "avif", "bmp", "svg" + ] + property list wallpapers: [] // List of absolute file paths (without file://) + readonly property bool thumbnailGenerationRunning: thumbgenProc.running + property real thumbnailGenerationProgress: 0 + + signal changed() + signal thumbnailGenerated(directory: string) + signal thumbnailGeneratedFile(filePath: string) + + function load () {} // For forcing initialization + + // Executions + Process { + id: applyProc + } + + function openFallbackPicker(darkMode = Appearance.m3colors.darkmode) { + applyProc.exec([ + Directories.wallpaperSwitchScriptPath, + "--mode", (darkMode ? "dark" : "light") + ]) + } + + function apply(path, darkMode = Appearance.m3colors.darkmode) { + if (!path || path.length === 0) return + applyProc.exec([ + Directories.wallpaperSwitchScriptPath, + "--image", path, + "--mode", (darkMode ? "dark" : "light") + ]) + root.changed() + } + + Process { + id: selectProc + property string filePath: "" + property bool darkMode: Appearance.m3colors.darkmode + function select(filePath, darkMode = Appearance.m3colors.darkmode) { + selectProc.filePath = filePath + selectProc.darkMode = darkMode + selectProc.exec(["test", "-d", FileUtils.trimFileProtocol(filePath)]) + } + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + setDirectory(selectProc.filePath); + return; + } + root.apply(selectProc.filePath, selectProc.darkMode); + } + } + + function select(filePath, darkMode = Appearance.m3colors.darkmode) { + selectProc.select(filePath, darkMode); + } + + function randomFromCurrentFolder(darkMode = Appearance.m3colors.darkmode) { + if (folderModel.count === 0) return; + const randomIndex = Math.floor(Math.random() * folderModel.count); + const filePath = folderModel.get(randomIndex, "filePath"); + print("Randomly selected wallpaper:", filePath); + root.select(filePath, darkMode); + } + + Process { + id: validateDirProc + property string nicePath: "" + function setDirectoryIfValid(path) { + validateDirProc.nicePath = FileUtils.trimFileProtocol(path).replace(/\/+$/, "") + if (/^\/*$/.test(validateDirProc.nicePath)) validateDirProc.nicePath = "/"; + validateDirProc.exec([ + "bash", "-c", + `if [ -d "${validateDirProc.nicePath}" ]; then echo dir; elif [ -f "${validateDirProc.nicePath}" ]; then echo file; else echo invalid; fi` + ]) + } + stdout: StdioCollector { + onStreamFinished: { + root.directory = Qt.resolvedUrl(validateDirProc.nicePath) + const result = text.trim() + if (result === "dir") { + } else if (result === "file") { + root.directory = Qt.resolvedUrl(FileUtils.parentDirectory(validateDirProc.nicePath)) + } else { + // Ignore + } + } + } + } + function setDirectory(path) { + validateDirProc.setDirectoryIfValid(path) + } + function navigateUp() { + folderModel.navigateUp() + } + function navigateBack() { + folderModel.navigateBack() + } + function navigateForward() { + folderModel.navigateForward() + } + + // Folder model + FolderListModelWithHistory { + id: folderModel + folder: Qt.resolvedUrl(root.defaultFolder) + caseSensitive: false + nameFilters: root.extensions.map(ext => `*${searchQuery.split(" ").filter(s => s.length > 0).map(s => `*${s}*`)}*.${ext}`) + showDirs: true + showDotAndDotDot: false + showOnlyReadable: true + sortField: FolderListModel.Time + sortReversed: false + onCountChanged: { + root.wallpapers = [] + for (let i = 0; i < folderModel.count; i++) { + const path = folderModel.get(i, "filePath") || FileUtils.trimFileProtocol(folderModel.get(i, "fileURL")) + if (path && path.length) root.wallpapers.push(path) + } + } + } + + // Thumbnail generation + function generateThumbnail(size: string) { + if (!["normal", "large", "x-large", "xx-large"].includes(size)) throw new Error("Invalid thumbnail size"); + thumbgenProc.directory = root.directory + thumbgenProc.running = false + thumbgenProc.command = [ + "bash", "-c", + `${thumbgenScriptPath} --size ${size} --machine_progress -d ${FileUtils.trimFileProtocol(root.directory)} || ${generateThumbnailsMagickScriptPath} --size ${size} -d ${FileUtils.trimFileProtocol(root.directory)}`, + ] + // console.log("[Wallpapers] Updating thumbnails with command ", thumbgenProc.command.join(" ")) + root.thumbnailGenerationProgress = 0 + thumbgenProc.running = true + } + Process { + id: thumbgenProc + property string directory + stdout: SplitParser { + onRead: data => { + // print("thumb gen proc:", data) + let match = data.match(/PROGRESS (\d+)\/(\d+)/) + if (match) { + const completed = parseInt(match[1]) + const total = parseInt(match[2]) + root.thumbnailGenerationProgress = completed / total + } + match = data.match(/FILE (.+)/) + if (match) { + const filePath = match[1] + root.thumbnailGeneratedFile(filePath) + } + } + } + onExited: (exitCode, exitStatus) => { + // print("[Wallpapers] Thumbnail generation completed with exit code", exitCode) + root.thumbnailGenerated(thumbgenProc.directory) + } + } + + IpcHandler { + target: "wallpapers" + + function apply(path: string): void { + root.apply(path); + } + } +} diff --git a/modules/quickshell/config/services/Weather.qml b/modules/quickshell/config/services/Weather.qml new file mode 100644 index 0000000..a7bb8d5 --- /dev/null +++ b/modules/quickshell/config/services/Weather.qml @@ -0,0 +1,165 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick +import QtPositioning + +import qs.modules.common + +Singleton { + id: root + // 10 minute + readonly property int fetchInterval: Config.options.bar.weather.fetchInterval * 60 * 1000 + readonly property string city: Config.options.bar.weather.city + readonly property bool useUSCS: Config.options.bar.weather.useUSCS + property bool gpsActive: Config.options.bar.weather.enableGPS + + onUseUSCSChanged: { + root.getData(); + } + onCityChanged: { + root.getData(); + } + + property var location: ({ + valid: false, + lat: 0, + lon: 0 + }) + + property var data: ({ + uv: 0, + humidity: 0, + sunrise: 0, + sunset: 0, + windDir: 0, + wCode: 0, + city: 0, + wind: 0, + precip: 0, + visib: 0, + press: 0, + temp: 0, + tempFeelsLike: 0 + }) + + function refineData(data) { + let temp = {}; + temp.uv = data?.current?.uvIndex || 0; + temp.humidity = (data?.current?.humidity || 0) + "%"; + temp.sunrise = data?.astronomy?.sunrise || "0.0"; + temp.sunset = data?.astronomy?.sunset || "0.0"; + temp.windDir = data?.current?.winddir16Point || "N"; + temp.wCode = data?.current?.weatherCode || "113"; + temp.city = data?.location?.areaName[0]?.value || "City"; + temp.temp = ""; + temp.tempFeelsLike = ""; + if (root.useUSCS) { + temp.wind = (data?.current?.windspeedMiles || 0) + " mph"; + temp.precip = (data?.current?.precipInches || 0) + " in"; + temp.visib = (data?.current?.visibilityMiles || 0) + " m"; + temp.press = (data?.current?.pressureInches || 0) + " psi"; + temp.temp += (data?.current?.temp_F || 0); + temp.tempFeelsLike += (data?.current?.FeelsLikeF || 0); + temp.temp += "ยฐF"; + temp.tempFeelsLike += "ยฐF"; + } else { + temp.wind = (data?.current?.windspeedKmph || 0) + " km/h"; + temp.precip = (data?.current?.precipMM || 0) + " mm"; + temp.visib = (data?.current?.visibility || 0) + " km"; + temp.press = (data?.current?.pressure || 0) + " hPa"; + temp.temp += (data?.current?.temp_C || 0); + temp.tempFeelsLike += (data?.current?.FeelsLikeC || 0); + temp.temp += "ยฐC"; + temp.tempFeelsLike += "ยฐC"; + } + root.data = temp; + } + + function getData() { + let command = "curl -s wttr.in"; + + if (root.gpsActive && root.location.valid) { + command += `/${root.location.lat},${root.location.long}`; + } else { + command += `/${formatCityName(root.city)}`; + } + + // format as json + command += "?format=j1"; + command += " | "; + // only take the current weather, location, asytronmy data + command += "jq '{current: .current_condition[0], location: .nearest_area[0], astronomy: .weather[0].astronomy[0]}'"; + fetcher.command[2] = command; + fetcher.running = true; + } + + function formatCityName(cityName) { + return cityName.trim().split(/\s+/).join('+'); + } + + Component.onCompleted: { + if (!root.gpsActive) return; + console.info("[WeatherService] Starting the GPS service."); + positionSource.start(); + } + + Process { + id: fetcher + command: ["bash", "-c", ""] + stdout: StdioCollector { + onStreamFinished: { + if (text.length === 0) + return; + try { + const parsedData = JSON.parse(text); + root.refineData(parsedData); + // console.info(`[ data: ${JSON.stringify(parsedData)}`); + } catch (e) { + console.error(`[WeatherService] ${e.message}`); + } + } + } + } + + PositionSource { + id: positionSource + updateInterval: root.fetchInterval + + onPositionChanged: { + // update the location if the given location is valid + // if it fails getting the location, use the last valid location + if (position.latitudeValid && position.longitudeValid) { + root.location.lat = position.coordinate.latitude; + root.location.long = position.coordinate.longitude; + root.location.valid = true; + // console.info(`๐Ÿ“ Location: ${position.coordinate.latitude}, ${position.coordinate.longitude}`); + root.getData(); + // if can't get initialized with valid location deactivate the GPS + } else { + root.gpsActive = root.location.valid ? true : false; + console.error("[WeatherService] Failed to get the GPS location."); + } + } + + onValidityChanged: { + if (!positionSource.valid) { + positionSource.stop(); + root.location.valid = false; + root.gpsActive = false; + Quickshell.execDetached(["notify-send", Translation.tr("Weather Service"), Translation.tr("Cannot find a GPS service. Using the fallback method instead."), "-a", "Shell"]); + console.error("[WeatherService] Could not aquire a valid backend plugin."); + } + } + } + + Timer { + running: !root.gpsActive + repeat: true + interval: root.fetchInterval + triggeredOnStart: !root.gpsActive + onTriggered: root.getData() + } +} diff --git a/modules/quickshell/config/services/Ydotool.qml b/modules/quickshell/config/services/Ydotool.qml new file mode 100644 index 0000000..f25b093 --- /dev/null +++ b/modules/quickshell/config/services/Ydotool.qml @@ -0,0 +1,47 @@ +pragma Singleton + +import qs.modules.common +import Quickshell + +Singleton { + id: root + property int shiftMode: 0 // 0: off, 1: on, 2: lock + property list shiftKeys: [42, 54] // Keycodes for Shift keys (left and right) + property list altKeys: [56, 100] // Keycodes for Alt keys (left and right) + property list ctrlKeys: [29, 97] // Keycodes for Ctrl keys (left and right) + + function releaseAllKeys() { + const keycodes = Array.from(Array(249).keys()); + Quickshell.execDetached([ + "ydotool", + "key", "--key-delay", "0", + ...keycodes.map(keycode => `${keycode}:0`) + ]) + root.shiftMode = 0; // Reset shift mode + } + + function releaseShiftKeys() { + Quickshell.execDetached([ + "ydotool", + "key", "--key-delay", "0", + ...root.shiftKeys.map(keycode => `${keycode}:0`) + ]) + root.shiftMode = 0; // Reset shift mode + } + + function press(keycode) { + Quickshell.execDetached([ + "ydotool", + "key", "--key-delay", "0", + `${keycode}:1` + ]); + } + + function release(keycode) { + Quickshell.execDetached([ + "ydotool", + "key", "--key-delay", "0", + `${keycode}:0` + ]); + } +} diff --git a/modules/quickshell/config/services/network/WifiAccessPoint.qml b/modules/quickshell/config/services/network/WifiAccessPoint.qml new file mode 100644 index 0000000..55ee811 --- /dev/null +++ b/modules/quickshell/config/services/network/WifiAccessPoint.qml @@ -0,0 +1,14 @@ +import QtQuick + +QtObject { + required property var lastIpcObject + readonly property string ssid: lastIpcObject.ssid + readonly property string bssid: lastIpcObject.bssid + readonly property int strength: lastIpcObject.strength + readonly property int frequency: lastIpcObject.frequency + readonly property bool active: lastIpcObject.active + readonly property string security: lastIpcObject.security + readonly property bool isSecure: security.length > 0 + + property bool askingPassword: false +} diff --git a/modules/quickshell/config/settings.qml b/modules/quickshell/config/settings.qml new file mode 100644 index 0000000..65c64c1 --- /dev/null +++ b/modules/quickshell/config/settings.qml @@ -0,0 +1,302 @@ +//@ pragma UseQApplication +//@ pragma Env QS_NO_RELOAD_POPUP=1 +//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic +//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 + +// Adjust this to make the app smaller or larger +//@ pragma Env QT_SCALE_FACTOR=1 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import Quickshell +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions as CF + +ApplicationWindow { + id: root + property string firstRunFilePath: CF.FileUtils.trimFileProtocol(`${Directories.state}/user/first_run.txt`) + property string firstRunFileContent: "This file is just here to confirm you've been greeted :>" + property real contentPadding: 8 + property bool showNextTime: false + property var pages: [ + { + name: Translation.tr("Quick"), + icon: "instant_mix", + component: "modules/settings/QuickConfig.qml" + }, + { + name: Translation.tr("General"), + icon: "browse", + component: "modules/settings/GeneralConfig.qml" + }, + { + name: Translation.tr("Bar"), + icon: "toast", + iconRotation: 180, + component: "modules/settings/BarConfig.qml" + }, + { + name: Translation.tr("Background"), + icon: "texture", + component: "modules/settings/BackgroundConfig.qml" + }, + { + name: Translation.tr("Interface"), + icon: "bottom_app_bar", + component: "modules/settings/InterfaceConfig.qml" + }, + { + name: Translation.tr("Services"), + icon: "settings", + component: "modules/settings/ServicesConfig.qml" + }, + { + name: Translation.tr("Advanced"), + icon: "construction", + component: "modules/settings/AdvancedConfig.qml" + }, + { + name: Translation.tr("About"), + icon: "info", + component: "modules/settings/About.qml" + } + ] + property int currentPage: 0 + + visible: true + onClosing: Qt.quit() + title: "illogical-impulse Settings" + + Component.onCompleted: { + MaterialThemeLoader.reapplyTheme() + Config.readWriteDelay = 0 // Settings app always only sets one var at a time so delay isn't needed + } + + minimumWidth: 750 + minimumHeight: 500 + width: 1100 + height: 750 + color: Appearance.m3colors.m3background + + ColumnLayout { + anchors { + fill: parent + margins: contentPadding + } + + Keys.onPressed: (event) => { + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_PageDown) { + root.currentPage = Math.min(root.currentPage + 1, root.pages.length - 1) + event.accepted = true; + } + else if (event.key === Qt.Key_PageUp) { + root.currentPage = Math.max(root.currentPage - 1, 0) + event.accepted = true; + } + else if (event.key === Qt.Key_Tab) { + root.currentPage = (root.currentPage + 1) % root.pages.length; + event.accepted = true; + } + else if (event.key === Qt.Key_Backtab) { + root.currentPage = (root.currentPage - 1 + root.pages.length) % root.pages.length; + event.accepted = true; + } + } + } + + Item { // Titlebar + visible: Config.options?.windows.showTitlebar + Layout.fillWidth: true + Layout.fillHeight: false + implicitHeight: Math.max(titleText.implicitHeight, windowControlsRow.implicitHeight) + StyledText { + id: titleText + anchors { + left: Config.options.windows.centerTitle ? undefined : parent.left + horizontalCenter: Config.options.windows.centerTitle ? parent.horizontalCenter : undefined + verticalCenter: parent.verticalCenter + leftMargin: 12 + } + color: Appearance.colors.colOnLayer0 + text: Translation.tr("Settings") + font { + family: Appearance.font.family.title + pixelSize: Appearance.font.pixelSize.title + variableAxes: Appearance.font.variableAxes.title + } + } + RowLayout { // Window controls row + id: windowControlsRow + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + RippleButton { + buttonRadius: Appearance.rounding.full + implicitWidth: 35 + implicitHeight: 35 + onClicked: root.close() + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "close" + iconSize: 20 + } + } + } + } + + RowLayout { // Window content with navigation rail and content pane + Layout.fillWidth: true + Layout.fillHeight: true + spacing: contentPadding + Item { + id: navRailWrapper + Layout.fillHeight: true + Layout.margins: 5 + implicitWidth: navRail.expanded ? 150 : fab.baseSize + Behavior on implicitWidth { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + NavigationRail { // Window content with navigation rail and content pane + id: navRail + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + spacing: 10 + expanded: root.width > 900 + + NavigationRailExpandButton { + focus: root.visible + } + + FloatingActionButton { + id: fab + property bool justCopied: false + iconText: justCopied ? "check" : "edit" + buttonText: justCopied ? Translation.tr("Path copied") : Translation.tr("Config file") + expanded: navRail.expanded + downAction: () => { + Qt.openUrlExternally(`${Directories.config}/illogical-impulse/config.json`); + } + altAction: () => { + Quickshell.clipboardText = CF.FileUtils.trimFileProtocol(`${Directories.config}/illogical-impulse/config.json`); + fab.justCopied = true; + revertTextTimer.restart() + } + + Timer { + id: revertTextTimer + interval: 1500 + onTriggered: { + fab.justCopied = false; + } + } + + StyledToolTip { + text: Translation.tr("Open the shell config file\nAlternatively right-click to copy path") + } + } + + NavigationRailTabArray { + currentIndex: root.currentPage + expanded: navRail.expanded + Repeater { + model: root.pages + NavigationRailButton { + required property var index + required property var modelData + toggled: root.currentPage === index + onPressed: root.currentPage = index; + expanded: navRail.expanded + buttonIcon: modelData.icon + buttonIconRotation: modelData.iconRotation || 0 + buttonText: modelData.name + showToggledHighlight: false + } + } + } + + Item { + Layout.fillHeight: true + } + } + } + Rectangle { // Content container + Layout.fillWidth: true + Layout.fillHeight: true + color: Appearance.m3colors.m3surfaceContainerLow + radius: Appearance.rounding.windowRounding - root.contentPadding + + Loader { + id: pageLoader + anchors.fill: parent + opacity: 1.0 + + active: Config.ready + Component.onCompleted: { + source = root.pages[0].component + } + + Connections { + target: root + function onCurrentPageChanged() { + switchAnim.complete(); + switchAnim.start(); + } + } + + SequentialAnimation { + id: switchAnim + + NumberAnimation { + target: pageLoader + properties: "opacity" + from: 1 + to: 0 + duration: 100 + easing.type: Appearance.animation.elementMoveExit.type + easing.bezierCurve: Appearance.animationCurves.emphasizedFirstHalf + } + ParallelAnimation { + PropertyAction { + target: pageLoader + property: "source" + value: root.pages[root.currentPage].component + } + PropertyAction { + target: pageLoader + property: "anchors.topMargin" + value: 20 + } + } + ParallelAnimation { + NumberAnimation { + target: pageLoader + properties: "opacity" + from: 0 + to: 1 + duration: 200 + easing.type: Appearance.animation.elementMoveEnter.type + easing.bezierCurve: Appearance.animationCurves.emphasizedLastHalf + } + NumberAnimation { + target: pageLoader + properties: "anchors.topMargin" + to: 0 + duration: 200 + easing.type: Appearance.animation.elementMoveEnter.type + easing.bezierCurve: Appearance.animationCurves.emphasizedLastHalf + } + } + } + } + } + } + } +} diff --git a/modules/quickshell/config/shell.qml b/modules/quickshell/config/shell.qml new file mode 100644 index 0000000..68c0fe5 --- /dev/null +++ b/modules/quickshell/config/shell.qml @@ -0,0 +1,101 @@ +//@ pragma UseQApplication +//@ pragma Env QS_NO_RELOAD_POPUP=1 +//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic +//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 + +// Adjust this to make the shell smaller or larger +//@ pragma Env QT_SCALE_FACTOR=1 + + +import qs.modules.common +import qs.modules.ii.background +import qs.modules.ii.bar +import qs.modules.ii.dock +import qs.modules.ii.lock +import qs.modules.ii.mediaControls +import qs.modules.ii.notificationPopup +import qs.modules.ii.onScreenDisplay +import qs.modules.ii.overview +import qs.modules.ii.polkit +import qs.modules.ii.regionSelector +import qs.modules.ii.screenCorners +import qs.modules.ii.sessionScreen +import qs.modules.ii.sidebarRight +import qs.modules.ii.overlay +import qs.modules.ii.verticalBar + +import QtQuick +import QtQuick.Window +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import qs.services + +ShellRoot { + id: root + + // Force initialization of some singletons + Component.onCompleted: { + MaterialThemeLoader.reapplyTheme() + Hyprsunset.load() + FirstRunExperience.load() + ConflictKiller.load() + Cliphist.refresh() + Wallpapers.load() + Updates.load() + } + + // Load enabled stuff + // Well, these loaders only *allow* them to be loaded, to always load or not is defined in each component + // The media controls for example is not loaded if it's not opened + PanelLoader { identifier: "iiBar"; extraCondition: !Config.options.bar.vertical; component: Bar {} } + PanelLoader { identifier: "iiBackground"; component: Background {} } + PanelLoader { identifier: "iiDock"; extraCondition: Config.options.dock.enable; component: Dock {} } + PanelLoader { identifier: "iiLock"; component: Lock {} } + PanelLoader { identifier: "iiMediaControls"; component: MediaControls {} } + PanelLoader { identifier: "iiNotificationPopup"; component: NotificationPopup {} } + PanelLoader { identifier: "iiOnScreenDisplay"; component: OnScreenDisplay {} } + PanelLoader { identifier: "iiOverlay"; component: Overlay {} } + PanelLoader { identifier: "iiOverview"; component: Overview {} } + PanelLoader { identifier: "iiPolkit"; component: Polkit {} } + PanelLoader { identifier: "iiRegionSelector"; component: RegionSelector {} } + PanelLoader { identifier: "iiScreenCorners"; component: ScreenCorners {} } + PanelLoader { identifier: "iiSessionScreen"; component: SessionScreen {} } + PanelLoader { identifier: "iiSidebarRight"; component: SidebarRight {} } + PanelLoader { identifier: "iiVerticalBar"; extraCondition: Config.options.bar.vertical; component: VerticalBar {} } + ReloadPopup {} + + component PanelLoader: LazyLoader { + required property string identifier + property bool extraCondition: true + active: Config.ready && Config.options.enabledPanels.includes(identifier) && extraCondition + } + + // Panel families + property list families: ["ii"] + property var panelFamilies: ({ + "ii": ["iiBar", "iiBackground", "iiDock", "iiLock", "iiMediaControls", "iiNotificationPopup", "iiOnScreenDisplay", "iiOverlay", "iiOverview", "iiPolkit", "iiRegionSelector", "iiScreenCorners", "iiSessionScreen", "iiSidebarRight", "iiVerticalBar"], + }) + function cyclePanelFamily() { + const currentIndex = families.indexOf(Config.options.panelFamily) + const nextIndex = (currentIndex + 1) % families.length + Config.options.panelFamily = families[nextIndex] + Config.options.enabledPanels = panelFamilies[Config.options.panelFamily] + } + + IpcHandler { + target: "panelFamily" + + function cycle(): void { + root.cyclePanelFamily() + } + } + + GlobalShortcut { + name: "panelFamilyCycle" + description: "Cycles panel family" + + onPressed: root.cyclePanelFamily() + } +} + diff --git a/modules/quickshell/generate-colors.py b/modules/quickshell/generate-colors.py new file mode 100644 index 0000000..11052bb --- /dev/null +++ b/modules/quickshell/generate-colors.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +""" +Generate Material Design 3 color scheme from base16/Stylix colors. +This script creates a colors.json file compatible with the end-4/dots-hyprland quickshell config. +""" + +import json +import sys +import colorsys + +def hex_to_rgb(hex_color): + """Convert hex color to RGB tuple (0-255)""" + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + +def rgb_to_hex(r, g, b): + """Convert RGB tuple to hex color""" + return "#{:02x}{:02x}{:02x}".format(int(r), int(g), int(b)) + +def rgb_to_hsl(r, g, b): + """Convert RGB (0-255) to HSL (0-1)""" + r, g, b = r/255, g/255, b/255 + h, l, s = colorsys.rgb_to_hls(r, g, b) + return h, s, l + +def hsl_to_rgb(h, s, l): + """Convert HSL (0-1) to RGB (0-255)""" + r, g, b = colorsys.hls_to_rgb(h, l, s) + return int(r*255), int(g*255), int(b*255) + +def adjust_lightness(hex_color, target_lightness): + """Adjust the lightness of a color while preserving hue and saturation""" + r, g, b = hex_to_rgb(hex_color) + h, s, l = rgb_to_hsl(r, g, b) + r, g, b = hsl_to_rgb(h, s, target_lightness) + return rgb_to_hex(r, g, b) + +def mix_colors(color1, color2, ratio=0.5): + """Mix two hex colors with given ratio (0 = all color1, 1 = all color2)""" + r1, g1, b1 = hex_to_rgb(color1) + r2, g2, b2 = hex_to_rgb(color2) + r = r1 * (1 - ratio) + r2 * ratio + g = g1 * (1 - ratio) + g2 * ratio + b = b1 * (1 - ratio) + b2 * ratio + return rgb_to_hex(r, g, b) + +def generate_material_colors(base16_colors): + """ + Generate Material Design 3 colors from base16 color scheme. + + Base16 mapping: + - base00: Background + - base01: Lighter background (panels) + - base02: Selection background + - base03: Comments, subtle + - base04: Dark foreground + - base05: Default foreground (text) + - base06: Light foreground + - base07: Lightest background + - base08: Red (errors) + - base09: Orange (warnings) + - base0A: Yellow (classes) + - base0B: Green (strings) + - base0C: Cyan (support) + - base0D: Blue (functions) - PRIMARY + - base0E: Purple (keywords) + - base0F: Brown (deprecated) + """ + + # Get base colors + bg = base16_colors['base00'] + bg_lighter = base16_colors['base01'] + selection = base16_colors['base02'] + comments = base16_colors['base03'] + fg_dark = base16_colors['base04'] + fg = base16_colors['base05'] + fg_light = base16_colors['base06'] + bg_lightest = base16_colors['base07'] + red = base16_colors['base08'] + orange = base16_colors['base09'] + yellow = base16_colors['base0A'] + green = base16_colors['base0B'] + cyan = base16_colors['base0C'] + blue = base16_colors['base0D'] # Primary accent + purple = base16_colors['base0E'] + brown = base16_colors['base0F'] + + # Determine if dark mode based on background lightness + bg_r, bg_g, bg_b = hex_to_rgb(bg) + _, _, bg_lightness = rgb_to_hsl(bg_r, bg_g, bg_b) + is_dark = bg_lightness < 0.5 + + # Generate surface containers (graduated lightness from background) + if is_dark: + surface_dim = adjust_lightness(bg, max(0.02, bg_lightness - 0.02)) + surface = bg + surface_bright = adjust_lightness(bg, min(0.25, bg_lightness + 0.12)) + surface_container_lowest = adjust_lightness(bg, max(0.01, bg_lightness - 0.03)) + surface_container_low = adjust_lightness(bg, bg_lightness + 0.02) + surface_container = adjust_lightness(bg, bg_lightness + 0.04) + surface_container_high = adjust_lightness(bg, bg_lightness + 0.06) + surface_container_highest = adjust_lightness(bg, bg_lightness + 0.09) + else: + surface_dim = adjust_lightness(bg, bg_lightness - 0.05) + surface = bg + surface_bright = adjust_lightness(bg, min(0.98, bg_lightness + 0.05)) + surface_container_lowest = adjust_lightness(bg, min(0.99, bg_lightness + 0.02)) + surface_container_low = adjust_lightness(bg, bg_lightness - 0.02) + surface_container = adjust_lightness(bg, bg_lightness - 0.04) + surface_container_high = adjust_lightness(bg, bg_lightness - 0.06) + surface_container_highest = adjust_lightness(bg, bg_lightness - 0.09) + + # Primary color variations + primary = blue + primary_r, primary_g, primary_b = hex_to_rgb(primary) + primary_h, primary_s, primary_l = rgb_to_hsl(primary_r, primary_g, primary_b) + + if is_dark: + on_primary = adjust_lightness(primary, 0.15) + primary_container = adjust_lightness(primary, 0.20) + on_primary_container = adjust_lightness(primary, 0.85) + else: + on_primary = adjust_lightness(primary, 0.95) + primary_container = adjust_lightness(primary, 0.85) + on_primary_container = adjust_lightness(primary, 0.15) + + # Secondary from purple/cyan mix + secondary = mix_colors(purple, cyan, 0.5) + secondary_r, secondary_g, secondary_b = hex_to_rgb(secondary) + secondary_h, secondary_s, secondary_l = rgb_to_hsl(secondary_r, secondary_g, secondary_b) + + if is_dark: + on_secondary = adjust_lightness(secondary, 0.15) + secondary_container = adjust_lightness(secondary, 0.25) + on_secondary_container = adjust_lightness(secondary, 0.85) + else: + on_secondary = adjust_lightness(secondary, 0.95) + secondary_container = adjust_lightness(secondary, 0.85) + on_secondary_container = adjust_lightness(secondary, 0.15) + + # Tertiary from green + tertiary = green + if is_dark: + on_tertiary = adjust_lightness(tertiary, 0.15) + tertiary_container = adjust_lightness(tertiary, 0.20) + on_tertiary_container = adjust_lightness(tertiary, 0.85) + else: + on_tertiary = adjust_lightness(tertiary, 0.95) + tertiary_container = adjust_lightness(tertiary, 0.85) + on_tertiary_container = adjust_lightness(tertiary, 0.15) + + # Error colors + error = red + if is_dark: + on_error = adjust_lightness(error, 0.10) + error_container = adjust_lightness(error, 0.20) + on_error_container = adjust_lightness(error, 0.90) + else: + on_error = adjust_lightness(error, 0.95) + error_container = adjust_lightness(error, 0.90) + on_error_container = adjust_lightness(error, 0.10) + + # Success colors (from green) + success = green + if is_dark: + on_success = adjust_lightness(success, 0.15) + success_container = adjust_lightness(success, 0.25) + on_success_container = adjust_lightness(success, 0.90) + else: + on_success = adjust_lightness(success, 0.95) + success_container = adjust_lightness(success, 0.85) + on_success_container = adjust_lightness(success, 0.10) + + # Surface/outline colors + on_surface = fg + on_surface_variant = comments if not is_dark else fg_dark + surface_variant = selection + outline = comments + outline_variant = selection + + # Inverse colors + if is_dark: + inverse_surface = fg + inverse_on_surface = bg + inverse_primary = adjust_lightness(primary, 0.35) + else: + inverse_surface = adjust_lightness(bg, 0.15) + inverse_on_surface = fg + inverse_primary = adjust_lightness(primary, 0.75) + + # Fixed colors (always same regardless of light/dark) + primary_fixed = adjust_lightness(primary, 0.85) + primary_fixed_dim = adjust_lightness(primary, 0.75) + on_primary_fixed = adjust_lightness(primary, 0.10) + on_primary_fixed_variant = adjust_lightness(primary, 0.25) + + secondary_fixed = adjust_lightness(secondary, 0.85) + secondary_fixed_dim = adjust_lightness(secondary, 0.75) + on_secondary_fixed = adjust_lightness(secondary, 0.10) + on_secondary_fixed_variant = adjust_lightness(secondary, 0.25) + + tertiary_fixed = adjust_lightness(tertiary, 0.85) + tertiary_fixed_dim = adjust_lightness(tertiary, 0.75) + on_tertiary_fixed = adjust_lightness(tertiary, 0.10) + on_tertiary_fixed_variant = adjust_lightness(tertiary, 0.25) + + # Terminal colors (keep close to original base16) + colors = { + "background": bg, + "on_background": fg, + "surface": surface, + "surface_dim": surface_dim, + "surface_bright": surface_bright, + "surface_container_lowest": surface_container_lowest, + "surface_container_low": surface_container_low, + "surface_container": surface_container, + "surface_container_high": surface_container_high, + "surface_container_highest": surface_container_highest, + "on_surface": on_surface, + "surface_variant": surface_variant, + "on_surface_variant": on_surface_variant, + "inverse_surface": inverse_surface, + "inverse_on_surface": inverse_on_surface, + "outline": outline, + "outline_variant": outline_variant, + "shadow": "#000000", + "scrim": "#000000", + "surface_tint": primary, + "primary": primary, + "on_primary": on_primary, + "primary_container": primary_container, + "on_primary_container": on_primary_container, + "inverse_primary": inverse_primary, + "secondary": secondary, + "on_secondary": on_secondary, + "secondary_container": secondary_container, + "on_secondary_container": on_secondary_container, + "tertiary": tertiary, + "on_tertiary": on_tertiary, + "tertiary_container": tertiary_container, + "on_tertiary_container": on_tertiary_container, + "error": error, + "on_error": on_error, + "error_container": error_container, + "on_error_container": on_error_container, + "primary_fixed": primary_fixed, + "primary_fixed_dim": primary_fixed_dim, + "on_primary_fixed": on_primary_fixed, + "on_primary_fixed_variant": on_primary_fixed_variant, + "secondary_fixed": secondary_fixed, + "secondary_fixed_dim": secondary_fixed_dim, + "on_secondary_fixed": on_secondary_fixed, + "on_secondary_fixed_variant": on_secondary_fixed_variant, + "tertiary_fixed": tertiary_fixed, + "tertiary_fixed_dim": tertiary_fixed_dim, + "on_tertiary_fixed": on_tertiary_fixed, + "on_tertiary_fixed_variant": on_tertiary_fixed_variant, + "success": success, + "on_success": on_success, + "success_container": success_container, + "on_success_container": on_success_container, + # Note: Terminal colors (term0-term15) are NOT included here because + # MaterialThemeLoader.qml adds "m3" prefix to all keys, but Appearance.qml + # expects terminal colors WITHOUT the m3 prefix (e.g., "term0" not "m3term0") + } + + return colors + +def main(): + if len(sys.argv) < 2: + print("Usage: generate-colors.py ", file=sys.stderr) + print("Base16 colors should be passed via environment variables BASE00-BASE0F", file=sys.stderr) + sys.exit(1) + + import os + + # Read base16 colors from environment + base16_colors = {} + for i in range(16): + # Generate both the environment variable name and the dict key + if i < 10: + env_key = f"BASE0{i}" + dict_key = f"base0{i}" + else: + hex_char = chr(ord('A') + i - 10) + env_key = f"BASE0{hex_char}" + dict_key = f"base0{hex_char}" + + color = os.environ.get(env_key) + if not color: + print(f"Missing environment variable: {env_key}", file=sys.stderr) + sys.exit(1) + base16_colors[dict_key] = f"#{color}" if not color.startswith('#') else color + + colors = generate_material_colors(base16_colors) + + output_path = sys.argv[1] + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + with open(output_path, 'w') as f: + json.dump(colors, f, indent=2) + + print(f"Generated colors at {output_path}") + +if __name__ == "__main__": + main() diff --git a/modules/quickshell/shell-config.nix b/modules/quickshell/shell-config.nix new file mode 100644 index 0000000..3307eee --- /dev/null +++ b/modules/quickshell/shell-config.nix @@ -0,0 +1,337 @@ +{ + config, + pkgs, + lib, + inputs, + ... +}: let + # Get quickshell from flake input directly (not from nixpkgs overlay) + quickshellPkg = inputs.quickshell.packages.${pkgs.system}.default; + + # Qt packages needed for QML imports + qtDeps = with pkgs; [ + qt6.qtbase + qt6.qtdeclarative + qt6.qt5compat + qt6.qtimageformats + qt6.qtmultimedia + qt6.qtpositioning + qt6.qtquicktimeline + qt6.qtsensors + qt6.qtsvg + qt6.qttools + qt6.qttranslations + qt6.qtvirtualkeyboard + qt6.qtwayland + kdePackages.kirigami.unwrapped # Use unwrapped to get actual QML files + kdePackages.syntax-highlighting + ]; + + # Build QML import paths from Qt dependencies + qmlImportPaths = lib.concatMapStringsSep ":" (pkg: "${pkg}/lib/qt-6/qml") qtDeps; + + # Wrap quickshell with QML import paths + quickshellWrapped = pkgs.symlinkJoin { + name = "quickshell-wrapped"; + paths = [quickshellPkg]; + buildInputs = [pkgs.makeWrapper]; + postBuild = '' + wrapProgram $out/bin/quickshell \ + --prefix QML2_IMPORT_PATH : "${qmlImportPaths}" \ + --prefix QML_IMPORT_PATH : "${qmlImportPaths}" + ''; + }; + + # Get stylix colors from the config + colors = config.lib.stylix.colors; + + # Generate the Material Design colors JSON from Stylix base16 colors + colorsJson = + pkgs.runCommand "quickshell-colors.json" { + nativeBuildInputs = [pkgs.python3]; + BASE00 = colors.base00; + BASE01 = colors.base01; + BASE02 = colors.base02; + BASE03 = colors.base03; + BASE04 = colors.base04; + BASE05 = colors.base05; + BASE06 = colors.base06; + BASE07 = colors.base07; + BASE08 = colors.base08; + BASE09 = colors.base09; + BASE0A = colors.base0A; + BASE0B = colors.base0B; + BASE0C = colors.base0C; + BASE0D = colors.base0D; + BASE0E = colors.base0E; + BASE0F = colors.base0F; + } '' + ${pkgs.python3}/bin/python3 ${./generate-colors.py} $out + ''; + + # Font configuration + fontFamily = config.stylix.fonts.monospace.name; + + # Quickshell config directory + quickshellConfigDir = ./config; + + # Generate the illogical-impulse config.json as a file in the nix store + illogicalImpulseConfig = pkgs.writeText "illogical-impulse-config.json" (builtins.toJSON { + # Minimal set of panels for better performance + enabledPanels = [ + "iiBar" + "iiNotificationPopup" + "iiOnScreenDisplay" + "iiOverview" + "iiSessionScreen" + "iiSidebarRight" + ]; + panelFamily = "ii"; + policies = {}; + appearance = { + extraBackgroundTint = true; + fakeScreenRounding = 2; + fonts = { + main = fontFamily; + numbers = fontFamily; + title = fontFamily; + iconNerd = fontFamily; + monospace = fontFamily; + reading = fontFamily; + expressive = fontFamily; + }; + transparency = { + enable = true; + automatic = false; + backgroundTransparency = 0.15; + contentTransparency = 0.45; + }; + wallpaperTheming = { + enableAppsAndShell = false; + enableQtApps = false; + enableTerminal = false; + }; + palette = { + type = "auto"; + }; + }; + apps = { + bluetooth = "blueman-manager"; + network = "kitty nmtui"; + networkEthernet = "nm-connection-editor"; + taskManager = "kitty htop"; + terminal = "kitty"; + update = "kitty --hold sudo nixos-rebuild switch"; + volumeMixer = "pavucontrol"; + }; + background = { + widgets = { + clock = { + enable = true; + showOnlyWhenLocked = false; + placementStrategy = "leastBusy"; + x = 100; + y = 100; + style = "cookie"; + styleLocked = "cookie"; + cookie = { + aiStyling = false; + sides = 14; + dialNumberStyle = "full"; + hourHandStyle = "fill"; + minuteHandStyle = "medium"; + secondHandStyle = "dot"; + dateStyle = "bubble"; + timeIndicators = true; + hourMarks = false; + dateInClock = true; + constantlyRotate = false; + useSineCookie = false; + }; + digital = { + animateChange = true; + }; + quote = { + enable = false; + text = ""; + }; + }; + weather = { + enable = false; + placementStrategy = "free"; + x = 400; + y = 100; + }; + }; + wallpaperPath = ""; + thumbnailPath = ""; + hideWhenFullscreen = true; + parallax = { + vertical = false; + autoVertical = false; + enableWorkspace = true; + workspaceZoom = 1.07; + enableSidebar = true; + widgetsFactor = 1.2; + }; + }; + bar = { + autoHide = { + enable = false; + hoverRegionWidth = 2; + pushWindows = false; + showWhenPressingSuper = { + enable = true; + delay = 140; + }; + }; + bottom = false; + cornerStyle = 0; + floatStyleShadow = true; + borderless = false; + topLeftIcon = "distro"; + showBackground = true; + verbose = true; + vertical = false; + resources = { + alwaysShowSwap = true; + alwaysShowCpu = true; + memoryWarningThreshold = 95; + swapWarningThreshold = 85; + cpuWarningThreshold = 90; + }; + screenList = []; + utilButtons = { + showScreenSnip = true; + showColorPicker = false; + showMicToggle = false; + showKeyboardToggle = false; + showDarkModeToggle = false; + showPerformanceProfileToggle = false; + showScreenRecord = false; + }; + weather = { + enable = true; + enableGPS = false; + city = "Aachen"; + useUSCS = false; + fetchInterval = 10; + }; + }; + dock = { + enable = false; + }; + hacks = { + arbitraryRaceConditionDelay = 100; + }; + interactions = { + deadPixelWorkaround = { + enable = false; + }; + }; + notifications = { + location = "topRight"; + silentAtNight = false; + silentAtNightFrom = "22:00"; + silentAtNightTo = "08:00"; + }; + overview = { + scale = 0.15; + }; + search = { + enableFeatures = { + actions = true; + aiChat = false; + apps = true; + commands = true; + files = true; + math = true; + translate = false; + web = true; + }; + maxResults = 10; + }; + sidebar = { + left = { + autoOpenWhenIdle = false; + autoOpenWhenIdleDelay = 300; + autoOpenWhenIdleOnBattery = false; + pages = ["home" "notifications"]; + }; + right = { + pages = ["calendar" "quickToggles"]; + }; + }; + time = { + secondPrecision = true; + format = { + clock = "HH:mm"; + date = "ddd, MMM d"; + }; + }; + }); +in { + # Install quickshell (wrapped with QML paths) and dependencies + home.packages = with pkgs; [ + quickshellWrapped + # Dependencies for the shell scripts + jq + socat + playerctl + pamixer + brightnessctl + wl-clipboard + cliphist + grim + slurp + libnotify + # Power management + upower + ddcutil + # For icons - only use papirus to avoid conflicts + papirus-icon-theme + # Note: Material Symbols and Rubik fonts are installed system-wide in configuration.nix + # Additional utilities + hyprpicker + # Calculator for search + libqalculate + # Audio visualizer + cava + # Secret storage (for keyring) + libsecret + ]; + + # Install fonts via fonts.fontconfig to ensure they're available system-wide + fonts.fontconfig.enable = true; + + # Create the quickshell configuration + xdg.configFile = { + # Main quickshell config directory + "quickshell" = { + source = quickshellConfigDir; + recursive = true; + }; + }; + + # Generate the colors.json file that quickshell reads + home.file.".local/state/quickshell/user/generated/colors.json".source = colorsJson; + + # Create required directories and copy mutable config files + home.activation.quickshellSetup = lib.hm.dag.entryAfter ["writeBoundary"] '' + # Create required directories + mkdir -p $HOME/.local/state/quickshell/user/generated + mkdir -p $HOME/.config/illogical-impulse/translations + mkdir -p $HOME/.cache/quickshell/notifications + + # Copy illogical-impulse config if it doesn't exist (makes it writable for quickshell) + if [ ! -f "$HOME/.config/illogical-impulse/config.json" ]; then + cp ${illogicalImpulseConfig} $HOME/.config/illogical-impulse/config.json + chmod 644 $HOME/.config/illogical-impulse/config.json + fi + + # Create empty en_US.json translation file if it doesn't exist + if [ ! -f "$HOME/.config/illogical-impulse/translations/en_US.json" ]; then + echo '{}' > $HOME/.config/illogical-impulse/translations/en_US.json + fi + ''; +} From 26570ba22823869288776763da05414da35f45ff Mon Sep 17 00:00:00 2001 From: FinnPL Date: Fri, 28 Nov 2025 14:04:35 +0100 Subject: [PATCH 02/18] update --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 87ade11..fcdca62 100644 --- a/flake.lock +++ b/flake.lock @@ -109,11 +109,11 @@ }, "locked": { "dir": "pkgs/firefox-addons", - "lastModified": 1764302674, - "narHash": "sha256-8/zVGvLL4+cb+5A3XWiZep8JdjbQhh50gyghAs61hP8=", + "lastModified": 1764332086, + "narHash": "sha256-sQTS3T4nNU/he+X62q5aflqSLx2zs4A8/cx3QsLc0Vw=", "owner": "rycee", "repo": "nur-expressions", - "rev": "de69b47670b359630d2be0ad4dbcab05554d8fb7", + "rev": "fdc5a0a8a7cf1eb4d134ee42f62c56f293781a0e", "type": "gitlab" }, "original": { From 97b0bacaa0346b74724720c08a6fe01a916be9bb Mon Sep 17 00:00:00 2001 From: FinnPL Date: Sat, 29 Nov 2025 17:53:21 +0100 Subject: [PATCH 03/18] fix wifi --- .../quickshell/config/services/Network.qml | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/modules/quickshell/config/services/Network.qml b/modules/quickshell/config/services/Network.qml index 69bd533..7c38457 100644 --- a/modules/quickshell/config/services/Network.qml +++ b/modules/quickshell/config/services/Network.qml @@ -87,11 +87,32 @@ Singleton { function changePassword(network: WifiAccessPoint, password: string, username = ""): void { // TODO: enterprise wifi with username network.askingPassword = false; + root.wifiConnectTarget = network; + // Determine key management based on security type + // WPA3 uses SAE, WPA2 uses wpa-psk + const security = network.security?.toUpperCase() ?? ""; + const keyMgmt = security.includes("WPA3") ? "sae" : "wpa-psk"; + // Create connection profile and connect using BSSID to avoid SSID encoding issues + // The SSID is fetched fresh from nmcli to ensure correct encoding + // BSSID_ESC has escaped colons for grep -F matching (nmcli -t output escapes colons with backslash) + const bssidEsc = network.bssid.replace(/:/g, "\\:"); + print("changePassword called - BSSID:", network.bssid, "BSSID_ESC:", bssidEsc, "keyMgmt:", keyMgmt); changePasswordProc.exec({ "environment": { - "PASSWORD": password + "PASSWORD": password, + "BSSID": network.bssid, + "BSSID_ESC": bssidEsc, + "KEY_MGMT": keyMgmt, + "LANG": "C", + "LC_ALL": "C" }, - "command": ["bash", "-c", `nmcli connection modify ${network.ssid} wifi-sec.psk "$PASSWORD"`] + "command": ["bash", "-c", ` + SSID=$(nmcli -t -f SSID,BSSID dev wifi list | grep -F "$BSSID_ESC" | head -1 | sed 's/:[^:]*\\\\:[^:]*\\\\:[^:]*\\\\:[^:]*\\\\:[^:]*\\\\:[^:]*$//') + echo "Connecting to SSID: $SSID" + nmcli c delete "$SSID" 2>/dev/null + nmcli c add type wifi con-name "$SSID" ssid "$SSID" wifi.bssid "$BSSID" wifi-sec.key-mgmt "$KEY_MGMT" wifi-sec.psk "$PASSWORD" && + nmcli c up "$SSID" + `] }) } @@ -134,9 +155,24 @@ Singleton { Process { id: changePasswordProc - onExited: { // Re-attempt connection after changing password - connectProc.running = false - connectProc.running = true + stdout: SplitParser { + onRead: line => { + print("changePasswordProc stdout:", line) + getNetworks.running = true + } + } + stderr: SplitParser { + onRead: line => { + print("changePasswordProc stderr:", line) + } + } + onExited: (exitCode, exitStatus) => { + print("changePasswordProc exited with code:", exitCode) + if (root.wifiConnectTarget) { + root.wifiConnectTarget.askingPassword = (exitCode !== 0) + } + root.wifiConnectTarget = null + getNetworks.running = true } } From cbeda409e8e388aefcf17f8bc9f8d8df717eec75 Mon Sep 17 00:00:00 2001 From: FinnPL Date: Sat, 29 Nov 2025 18:12:40 +0100 Subject: [PATCH 04/18] cleanup --- modules/quickshell/config/services/Network.qml | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/modules/quickshell/config/services/Network.qml b/modules/quickshell/config/services/Network.qml index 7c38457..2129e25 100644 --- a/modules/quickshell/config/services/Network.qml +++ b/modules/quickshell/config/services/Network.qml @@ -96,7 +96,6 @@ Singleton { // The SSID is fetched fresh from nmcli to ensure correct encoding // BSSID_ESC has escaped colons for grep -F matching (nmcli -t output escapes colons with backslash) const bssidEsc = network.bssid.replace(/:/g, "\\:"); - print("changePassword called - BSSID:", network.bssid, "BSSID_ESC:", bssidEsc, "keyMgmt:", keyMgmt); changePasswordProc.exec({ "environment": { "PASSWORD": password, @@ -108,7 +107,6 @@ Singleton { }, "command": ["bash", "-c", ` SSID=$(nmcli -t -f SSID,BSSID dev wifi list | grep -F "$BSSID_ESC" | head -1 | sed 's/:[^:]*\\\\:[^:]*\\\\:[^:]*\\\\:[^:]*\\\\:[^:]*\\\\:[^:]*$//') - echo "Connecting to SSID: $SSID" nmcli c delete "$SSID" 2>/dev/null nmcli c add type wifi con-name "$SSID" ssid "$SSID" wifi.bssid "$BSSID" wifi-sec.key-mgmt "$KEY_MGMT" wifi-sec.psk "$PASSWORD" && nmcli c up "$SSID" @@ -156,18 +154,9 @@ Singleton { Process { id: changePasswordProc stdout: SplitParser { - onRead: line => { - print("changePasswordProc stdout:", line) - getNetworks.running = true - } - } - stderr: SplitParser { - onRead: line => { - print("changePasswordProc stderr:", line) - } + onRead: getNetworks.running = true } onExited: (exitCode, exitStatus) => { - print("changePasswordProc exited with code:", exitCode) if (root.wifiConnectTarget) { root.wifiConnectTarget.askingPassword = (exitCode !== 0) } From a808d5b8e20eb7c332d87b9434ccd89c7257b48d Mon Sep 17 00:00:00 2001 From: FinnPL Date: Sat, 29 Nov 2025 18:27:27 +0100 Subject: [PATCH 05/18] fix warnings --- hosts/centaur/configuration.nix | 4 ++-- modules/home-manager/cli-tools.nix | 6 ++++-- modules/home-manager/discord.nix | 2 +- modules/quickshell/shell-config.nix | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/hosts/centaur/configuration.nix b/hosts/centaur/configuration.nix index b9d9428..235af67 100644 --- a/hosts/centaur/configuration.nix +++ b/hosts/centaur/configuration.nix @@ -112,7 +112,7 @@ enable = true; settings = { default_session = let - tuigreet = "${lib.getExe pkgs.greetd.tuigreet}"; + tuigreet = "${lib.getExe pkgs.tuigreet}"; baseSessionsDir = "${config.services.displayManager.sessionData.desktops}"; xSessions = "${baseSessionsDir}/share/xsessions"; waylandSessions = "${baseSessionsDir}/share/wayland-sessions"; @@ -183,7 +183,7 @@ # SYSTEM PACKAGES # ============================================================================ environment.systemPackages = with pkgs; [ - greetd.tuigreet + tuigreet xdg-desktop-portal xdg-desktop-portal-hyprland xdg-desktop-portal-gtk diff --git a/modules/home-manager/cli-tools.nix b/modules/home-manager/cli-tools.nix index 1e53e5e..0a6dd83 100644 --- a/modules/home-manager/cli-tools.nix +++ b/modules/home-manager/cli-tools.nix @@ -17,8 +17,10 @@ programs.git = { enable = true; - userName = "FinnPL"; - userEmail = "contact@lippok.eu"; + settings.user = { + name = "FinnPL"; + email = "contact@lippok.eu"; + }; }; programs.btop = { diff --git a/modules/home-manager/discord.nix b/modules/home-manager/discord.nix index c5c4410..68ef06f 100644 --- a/modules/home-manager/discord.nix +++ b/modules/home-manager/discord.nix @@ -136,7 +136,7 @@ in { biggerStreamPreview.enable = true; messageLogger.enable = true; callTimer.enable = true; - clearURLs.enable = true; + clearUrLs.enable = true; permissionsViewer.enable = true; platformIndicators.enable = true; relationshipNotifier.enable = true; diff --git a/modules/quickshell/shell-config.nix b/modules/quickshell/shell-config.nix index 3307eee..fbb21db 100644 --- a/modules/quickshell/shell-config.nix +++ b/modules/quickshell/shell-config.nix @@ -6,7 +6,7 @@ ... }: let # Get quickshell from flake input directly (not from nixpkgs overlay) - quickshellPkg = inputs.quickshell.packages.${pkgs.system}.default; + quickshellPkg = inputs.quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default; # Qt packages needed for QML imports qtDeps = with pkgs; [ From 107af30b138958ded1d2d6ea8261fb61b733a9c0 Mon Sep 17 00:00:00 2001 From: FinnPL Date: Sat, 29 Nov 2025 19:37:48 +0100 Subject: [PATCH 06/18] update --- flake.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/flake.lock b/flake.lock index fcdca62..522b2dd 100644 --- a/flake.lock +++ b/flake.lock @@ -109,11 +109,11 @@ }, "locked": { "dir": "pkgs/firefox-addons", - "lastModified": 1764332086, - "narHash": "sha256-sQTS3T4nNU/he+X62q5aflqSLx2zs4A8/cx3QsLc0Vw=", + "lastModified": 1764389080, + "narHash": "sha256-BEn1Z9Uv20u2DS6wzLKdzx5kAzynM3wMQ9JnGf3VJvI=", "owner": "rycee", "repo": "nur-expressions", - "rev": "fdc5a0a8a7cf1eb4d134ee42f62c56f293781a0e", + "rev": "897437c09bf22ce59efb3370f0783d0c662dba31", "type": "gitlab" }, "original": { @@ -272,11 +272,11 @@ ] }, "locked": { - "lastModified": 1764304195, - "narHash": "sha256-bO7FN/bF6gG7TlZpKAZjO3VvfsLaPFkefeUfJJ7F/7w=", + "lastModified": 1764361670, + "narHash": "sha256-jgWzgpIaHbL3USIq0gihZeuy1lLf2YSfwvWEwnfAJUw=", "owner": "nix-community", "repo": "home-manager", - "rev": "86ff0ef506c209bb397849706e85cc3a913cb577", + "rev": "780be8ef503a28939cf9dc7996b48ffb1a3e04c6", "type": "github" }, "original": { @@ -359,11 +359,11 @@ "xdph": "xdph" }, "locked": { - "lastModified": 1764283894, - "narHash": "sha256-5BWYZDmJKwUGxhY+43obUZItkAL6rm3xkvBYdltUWz4=", + "lastModified": 1764436644, + "narHash": "sha256-P+PVRO3o162fMIcOVA581PMSvU+Z11J80ppdGqwGQl4=", "owner": "hyprwm", "repo": "Hyprland", - "rev": "7e1e24fea615503a3cc05218c12b06c1b6cabdc7", + "rev": "574ee71d568a95101320f264d7afb25034b8faa3", "type": "github" }, "original": { @@ -611,11 +611,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1764317078, - "narHash": "sha256-U8V+hb1fXUn/NtR5C//whFPo9Uh7JgqbGgZFndXm+Tk=", + "lastModified": 1764432271, + "narHash": "sha256-XCZxe+UAiutOpR4vd2iT02uVw4Yb8UogNu4+ystCInE=", "owner": "kaylorben", "repo": "nixcord", - "rev": "162347700710442a64b3fc972adc979f8581b382", + "rev": "5d37818d54213354ede10445c330104a25eeccf4", "type": "github" }, "original": { From 1cb773864ce9996abc9c6b1651c5816aec005c72 Mon Sep 17 00:00:00 2001 From: FinnPL Date: Sat, 29 Nov 2025 21:43:52 +0100 Subject: [PATCH 07/18] fixed time --- .../config/modules/ii/bar/ClockWidgetPopup.qml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/modules/quickshell/config/modules/ii/bar/ClockWidgetPopup.qml b/modules/quickshell/config/modules/ii/bar/ClockWidgetPopup.qml index 294ccf0..fe1a493 100644 --- a/modules/quickshell/config/modules/ii/bar/ClockWidgetPopup.qml +++ b/modules/quickshell/config/modules/ii/bar/ClockWidgetPopup.qml @@ -3,11 +3,17 @@ import qs.modules.common.widgets import qs.services import QtQuick import QtQuick.Layouts +import Quickshell StyledPopup { id: root - property string formattedDate: Qt.locale().toString(DateTime.clock.date, "dddd, MMMM dd, yyyy") - property string formattedTime: Qt.locale().toString(DateTime.clock.date, "HH:mm:ss") + + property var popupClock: SystemClock { + precision: SystemClock.Seconds + } + + property string formattedDate: Qt.locale().toString(popupClock.date, "dddd, MMMM dd, yyyy") + property string formattedTime: Qt.locale().toString(popupClock.date, "HH:mm:ss") property string formattedUptime: DateTime.uptime property string todosSection: getUpcomingTodos() From 6b57b4aa7417b9e7c5e10f65d37d57e7b4a4743e Mon Sep 17 00:00:00 2001 From: FinnPL Date: Sat, 29 Nov 2025 22:20:11 +0100 Subject: [PATCH 08/18] update lock function to use ipc call and add iiLock panel for performance --- modules/quickshell/config/modules/common/functions/Session.qml | 2 +- modules/quickshell/shell-config.nix | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/quickshell/config/modules/common/functions/Session.qml b/modules/quickshell/config/modules/common/functions/Session.qml index bbb9932..b546a71 100644 --- a/modules/quickshell/config/modules/common/functions/Session.qml +++ b/modules/quickshell/config/modules/common/functions/Session.qml @@ -13,7 +13,7 @@ Singleton { } function lock() { - Quickshell.execDetached(["loginctl", "lock-session"]); + Quickshell.execDetached(["qs", "-p", Quickshell.shellPath(""), "ipc", "call", "lock", "activate"]); } function suspend() { diff --git a/modules/quickshell/shell-config.nix b/modules/quickshell/shell-config.nix index fbb21db..9f36184 100644 --- a/modules/quickshell/shell-config.nix +++ b/modules/quickshell/shell-config.nix @@ -80,6 +80,7 @@ # Minimal set of panels for better performance enabledPanels = [ "iiBar" + "iiLock" "iiNotificationPopup" "iiOnScreenDisplay" "iiOverview" From ad46d38b2e5779e00fab6526fc4de4e001e9467f Mon Sep 17 00:00:00 2001 From: FinnPL Date: Sun, 30 Nov 2025 00:13:03 +0100 Subject: [PATCH 09/18] add logind service configuration and update quickshell key bindings --- hosts/centaur/configuration.nix | 5 +++++ modules/hyprland/hyprland-config.nix | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/hosts/centaur/configuration.nix b/hosts/centaur/configuration.nix index 235af67..3b35ec8 100644 --- a/hosts/centaur/configuration.nix +++ b/hosts/centaur/configuration.nix @@ -158,6 +158,11 @@ # UPower (for battery indicator) services.upower.enable = true; + services.logind = { + powerKey = "ignore"; + powerKeyLongPress = "poweroff"; + }; + # ============================================================================ # HARDWARE CONFIGURATION # ============================================================================ diff --git a/modules/hyprland/hyprland-config.nix b/modules/hyprland/hyprland-config.nix index c98ba99..a679bd7 100644 --- a/modules/hyprland/hyprland-config.nix +++ b/modules/hyprland/hyprland-config.nix @@ -86,8 +86,9 @@ # Quickshell toggles bind = , XF86PowerOff, exec, quickshell msg -p ~/.config/quickshell session toggle - bind = $mod, Tab, exec, quickshell msg -p ~/.config/quickshell overview toggle - bind = $mod, N, exec, quickshell msg -p ~/.config/quickshell sidebarRight toggle + bind = $mod, L, exec, quickshell msg -p ~/.config/quickshell lock activate + bind = ALT, Tab, exec, quickshell msg -p ~/.config/quickshell overview toggle + bind = ALT, C, exec, quickshell msg -p ~/.config/quickshell sidebarRight toggle # Overview/App launcher via ALT+SPACE bind = ALT, SPACE, exec, quickshell msg -p ~/.config/quickshell overview toggle From 755adb144edced6c6cfe490180bcdf21d5529674 Mon Sep 17 00:00:00 2001 From: FinnPL Date: Sun, 30 Nov 2025 00:24:33 +0100 Subject: [PATCH 10/18] update layersOut animation effect from popin to fade --- modules/hyprland/hyprland-config.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/hyprland/hyprland-config.nix b/modules/hyprland/hyprland-config.nix index a679bd7..e5314b2 100644 --- a/modules/hyprland/hyprland-config.nix +++ b/modules/hyprland/hyprland-config.nix @@ -64,7 +64,7 @@ animation = workspacesIn, 1, 8, winIn, slide animation = workspacesOut, 1, 8, winOut, slide animation = layersIn, 1, 10, winIn, slide - animation = layersOut, 1, 3, layerOut, popin 50% + animation = layersOut, 1, 3, layerOut, fade } dwindle { From d5d30b68a727f511c9f98b2722e6e695f47103fc Mon Sep 17 00:00:00 2001 From: FinnPL Date: Mon, 1 Dec 2025 01:16:49 +0100 Subject: [PATCH 11/18] update logind service configuration and add missing dependencies for quickshell --- hosts/centaur/configuration.nix | 6 +++--- modules/hyprland/hyprland-config.nix | 4 ++-- modules/quickshell/config/services/SongRec.qml | 2 +- modules/quickshell/shell-config.nix | 8 ++++++++ 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/hosts/centaur/configuration.nix b/hosts/centaur/configuration.nix index 3b35ec8..18db773 100644 --- a/hosts/centaur/configuration.nix +++ b/hosts/centaur/configuration.nix @@ -158,9 +158,9 @@ # UPower (for battery indicator) services.upower.enable = true; - services.logind = { - powerKey = "ignore"; - powerKeyLongPress = "poweroff"; + services.logind.settings.Login = { + HandlePowerKey = "ignore"; + HandlePowerKeyLongPress = "poweroff"; }; # ============================================================================ diff --git a/modules/hyprland/hyprland-config.nix b/modules/hyprland/hyprland-config.nix index e5314b2..45b5916 100644 --- a/modules/hyprland/hyprland-config.nix +++ b/modules/hyprland/hyprland-config.nix @@ -63,8 +63,8 @@ animation = windowsMove, 1, 7, winIn, slide animation = workspacesIn, 1, 8, winIn, slide animation = workspacesOut, 1, 8, winOut, slide - animation = layersIn, 1, 10, winIn, slide - animation = layersOut, 1, 3, layerOut, fade + animation = layersIn, 1, 7, winIn, slide + animation = layersOut, 1, 3, layerOut, slide } dwindle { diff --git a/modules/quickshell/config/services/SongRec.qml b/modules/quickshell/config/services/SongRec.qml index 0c75f5c..05c6da9 100644 --- a/modules/quickshell/config/services/SongRec.qml +++ b/modules/quickshell/config/services/SongRec.qml @@ -61,7 +61,7 @@ Singleton { Process { id: recognizeMusicProc running: false - command: [`${Directories.scriptPath}/musicRecognition/recognize-music.sh`, "-i", root.timeoutInterval, "-t", root.timeoutDuration, "-s", root.monitorSourceString] + command: ["bash", `${Directories.scriptPath}/musicRecognition/recognize-music.sh`, "-i", root.timeoutInterval, "-t", root.timeoutDuration, "-s", root.monitorSourceString] stdout: StdioCollector { onStreamFinished: { if (root.manuallyStopped) { diff --git a/modules/quickshell/shell-config.nix b/modules/quickshell/shell-config.nix index 9f36184..5ccd3a0 100644 --- a/modules/quickshell/shell-config.nix +++ b/modules/quickshell/shell-config.nix @@ -286,6 +286,10 @@ in { grim slurp libnotify + # Screenshot tools + imagemagick + swappy + tesseract # Power management upower ddcutil @@ -300,6 +304,10 @@ in { cava # Secret storage (for keyring) libsecret + # Music recognition + songrec + ffmpeg + pulseaudio ]; # Install fonts via fonts.fontconfig to ensure they're available system-wide From d4b7e0699b1cd4002c81578dcbd1ad903d524e9e Mon Sep 17 00:00:00 2001 From: FinnPL Date: Mon, 1 Dec 2025 01:22:47 +0100 Subject: [PATCH 12/18] fix: hyprsunset --- modules/quickshell/config/services/Hyprsunset.qml | 9 +++------ modules/quickshell/shell-config.nix | 2 ++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/modules/quickshell/config/services/Hyprsunset.qml b/modules/quickshell/config/services/Hyprsunset.qml index 8f0e365..0aade2d 100644 --- a/modules/quickshell/config/services/Hyprsunset.qml +++ b/modules/quickshell/config/services/Hyprsunset.qml @@ -98,15 +98,13 @@ Singleton { Process { id: fetchProc running: true - command: ["bash", "-c", "hyprctl hyprsunset temperature"] + command: ["pidof", "hyprsunset"] stdout: StdioCollector { id: stateCollector onStreamFinished: { const output = stateCollector.text.trim(); - if (output.length == 0 || output.startsWith("Couldn't")) - root.active = false; - else - root.active = (output != "6500"); // 6500 is the default when off + // pidof returns PID if running, empty if not + root.active = output.length > 0; // console.log("[Hyprsunset] Fetched state:", output, "->", root.active); } } @@ -132,7 +130,6 @@ Singleton { target: Config.options.light.night function onColorTemperatureChanged() { if (!root.active) return; - Hyprland.dispatch(`hyprctl hyprsunset temperature ${Config.options.light.night.colorTemperature}`); Quickshell.execDetached(["hyprctl", "hyprsunset", "temperature", `${Config.options.light.night.colorTemperature}`]); } } diff --git a/modules/quickshell/shell-config.nix b/modules/quickshell/shell-config.nix index 5ccd3a0..a43ad1e 100644 --- a/modules/quickshell/shell-config.nix +++ b/modules/quickshell/shell-config.nix @@ -308,6 +308,8 @@ in { songrec ffmpeg pulseaudio + # Night light / blue light filter + hyprsunset ]; # Install fonts via fonts.fontconfig to ensure they're available system-wide From 66664137840d17eda34d9bde57a40d720d2d1246 Mon Sep 17 00:00:00 2001 From: FinnPL Date: Mon, 1 Dec 2025 01:35:08 +0100 Subject: [PATCH 13/18] fix emojis --- .../quickshell/config/assets/data/emojis.txt | 1864 +++++++++++++++++ modules/quickshell/config/services/Emojis.qml | 18 +- 2 files changed, 1872 insertions(+), 10 deletions(-) create mode 100644 modules/quickshell/config/assets/data/emojis.txt diff --git a/modules/quickshell/config/assets/data/emojis.txt b/modules/quickshell/config/assets/data/emojis.txt new file mode 100644 index 0000000..817bf68 --- /dev/null +++ b/modules/quickshell/config/assets/data/emojis.txt @@ -0,0 +1,1864 @@ +### DATA ### +๐Ÿ˜€ grinning face face smile happy joy :D grin +๐Ÿ˜ƒ grinning face with big eyes face happy joy haha :D :) smile funny +๐Ÿ˜„ grinning face with smiling eyes face happy joy funny haha laugh like :D :) smile +๐Ÿ˜ beaming face with smiling eyes face happy smile joy kawaii +๐Ÿ˜† grinning squinting face happy joy lol satisfied haha face glad XD laugh +๐Ÿ˜… grinning face with sweat face hot happy laugh sweat smile relief +๐Ÿคฃ rolling on the floor laughing face rolling floor laughing lol haha rofl +๐Ÿ˜‚ face with tears of joy face cry tears weep happy happytears haha +๐Ÿ™‚ slightly smiling face face smile +๐Ÿ™ƒ upside down face face flipped silly smile +๐Ÿ˜‰ winking face face happy mischievous secret ;) smile eye +๐Ÿ˜Š smiling face with smiling eyes face smile happy flushed crush embarrassed shy joy +๐Ÿ˜‡ smiling face with halo face angel heaven halo +๐Ÿฅฐ smiling face with hearts face love like affection valentines infatuation crush hearts adore +๐Ÿ˜ smiling face with heart eyes face love like affection valentines infatuation crush heart +๐Ÿคฉ star struck face smile starry eyes grinning +๐Ÿ˜˜ face blowing a kiss face love like affection valentines infatuation kiss +๐Ÿ˜— kissing face love like face 3 valentines infatuation kiss +โ˜บ๏ธ smiling face face blush massage happiness +๐Ÿ˜š kissing face with closed eyes face love like affection valentines infatuation kiss +๐Ÿ˜™ kissing face with smiling eyes face affection valentines infatuation kiss +๐Ÿ˜‹ face savoring food happy joy tongue smile face silly yummy nom delicious savouring +๐Ÿ˜› face with tongue face prank childish playful mischievous smile tongue +๐Ÿ˜œ winking face with tongue face prank childish playful mischievous smile wink tongue +๐Ÿคช zany face face goofy crazy +๐Ÿ˜ squinting face with tongue face prank playful mischievous smile tongue +๐Ÿค‘ money mouth face face rich dollar money +๐Ÿค— hugging face face smile hug +๐Ÿคญ face with hand over mouth face whoops shock surprise +๐Ÿคซ shushing face face quiet shhh +๐Ÿค” thinking face face hmmm think consider +๐Ÿค zipper mouth face face sealed zipper secret +๐Ÿคจ face with raised eyebrow face distrust scepticism disapproval disbelief surprise +๐Ÿ˜ neutral face indifference meh :| neutral +๐Ÿ˜‘ expressionless face face indifferent - - meh deadpan +๐Ÿ˜ถ face without mouth face hellokitty +๐Ÿ˜ smirking face face smile mean prank smug sarcasm +๐Ÿ˜’ unamused face indifference bored straight face serious sarcasm unimpressed skeptical dubious side eye +๐Ÿ™„ face with rolling eyes face eyeroll frustrated +๐Ÿ˜ฌ grimacing face face grimace teeth +๐Ÿคฅ lying face face lie pinocchio +๐Ÿ˜Œ relieved face face relaxed phew massage happiness +๐Ÿ˜” pensive face face sad depressed upset +๐Ÿ˜ช sleepy face face tired rest nap +๐Ÿคค drooling face face +๐Ÿ˜ด sleeping face face tired sleepy night zzz +๐Ÿ˜ท face with medical mask face sick ill disease +๐Ÿค’ face with thermometer sick temperature thermometer cold fever +๐Ÿค• face with head bandage injured clumsy bandage hurt +๐Ÿคข nauseated face face vomit gross green sick throw up ill +๐Ÿคฎ face vomiting face sick +๐Ÿคง sneezing face face gesundheit sneeze sick allergy +๐Ÿฅต hot face face feverish heat red sweating +๐Ÿฅถ cold face face blue freezing frozen frostbite icicles +๐Ÿฅด woozy face face dizzy intoxicated tipsy wavy +๐Ÿ˜ต dizzy face spent unconscious xox dizzy +๐Ÿคฏ exploding head face shocked mind blown +๐Ÿค  cowboy hat face face cowgirl hat +๐Ÿฅณ partying face face celebration woohoo +๐Ÿ˜Ž smiling face with sunglasses face cool smile summer beach sunglass +๐Ÿค“ nerd face face nerdy geek dork +๐Ÿง face with monocle face stuffy wealthy +๐Ÿ˜• confused face face indifference huh weird hmmm :/ +๐Ÿ˜Ÿ worried face face concern nervous :( +๐Ÿ™ slightly frowning face face frowning disappointed sad upset +โ˜น๏ธ frowning face face sad upset frown +๐Ÿ˜ฎ face with open mouth face surprise impressed wow whoa :O +๐Ÿ˜ฏ hushed face face woo shh +๐Ÿ˜ฒ astonished face face xox surprised poisoned +๐Ÿ˜ณ flushed face face blush shy flattered sex +๐Ÿฅบ pleading face face begging mercy +๐Ÿ˜ฆ frowning face with open mouth face aw what +๐Ÿ˜ง anguished face face stunned nervous +๐Ÿ˜จ fearful face face scared terrified nervous oops huh +๐Ÿ˜ฐ anxious face with sweat face nervous sweat +๐Ÿ˜ฅ sad but relieved face face phew sweat nervous +๐Ÿ˜ข crying face face tears sad depressed upset :'( +๐Ÿ˜ญ loudly crying face face cry tears sad upset depressed sob +๐Ÿ˜ฑ face screaming in fear face munch scared omg +๐Ÿ˜– confounded face face confused sick unwell oops :S +๐Ÿ˜ฃ persevering face face sick no upset oops +๐Ÿ˜ž disappointed face face sad upset depressed :( +๐Ÿ˜“ downcast face with sweat face hot sad tired exercise +๐Ÿ˜ฉ weary face face tired sleepy sad frustrated upset +๐Ÿ˜ซ tired face sick whine upset frustrated +๐Ÿฅฑ yawning face tired sleepy +๐Ÿ˜ค face with steam from nose face gas phew proud pride +๐Ÿ˜ก pouting face angry mad hate despise +๐Ÿ˜  angry face mad face annoyed frustrated +๐Ÿคฌ face with symbols on mouth face swearing cursing cussing profanity expletive +๐Ÿ˜ˆ smiling face with horns devil horns +๐Ÿ‘ฟ angry face with horns devil angry horns +๐Ÿ’€ skull dead skeleton creepy death +โ˜ ๏ธ skull and crossbones poison danger deadly scary death pirate evil +๐Ÿ’ฉ pile of poo hankey shitface fail turd shit +๐Ÿคก clown face face +๐Ÿ‘น ogre monster red mask halloween scary creepy devil demon japanese ogre +๐Ÿ‘บ goblin red evil mask monster scary creepy japanese goblin +๐Ÿ‘ป ghost halloween spooky scary +๐Ÿ‘ฝ alien UFO paul weird outer space +๐Ÿ‘พ alien monster game arcade play +๐Ÿค– robot computer machine bot +๐Ÿ˜บ grinning cat animal cats happy smile +๐Ÿ˜ธ grinning cat with smiling eyes animal cats smile +๐Ÿ˜น cat with tears of joy animal cats haha happy tears +๐Ÿ˜ป smiling cat with heart eyes animal love like affection cats valentines heart +๐Ÿ˜ผ cat with wry smile animal cats smirk +๐Ÿ˜ฝ kissing cat animal cats kiss +๐Ÿ™€ weary cat animal cats munch scared scream +๐Ÿ˜ฟ crying cat animal tears weep sad cats upset cry +๐Ÿ˜พ pouting cat animal cats +๐Ÿ™ˆ see no evil monkey monkey animal nature haha +๐Ÿ™‰ hear no evil monkey animal monkey nature +๐Ÿ™Š speak no evil monkey monkey animal nature omg +๐Ÿ’‹ kiss mark face lips love like affection valentines +๐Ÿ’Œ love letter email like affection envelope valentines +๐Ÿ’˜ heart with arrow love like heart affection valentines +๐Ÿ’ heart with ribbon love valentines +๐Ÿ’– sparkling heart love like affection valentines +๐Ÿ’— growing heart like love affection valentines pink +๐Ÿ’“ beating heart love like affection valentines pink heart +๐Ÿ’ž revolving hearts love like affection valentines +๐Ÿ’• two hearts love like affection valentines heart +๐Ÿ’Ÿ heart decoration purple-square love like +โฃ๏ธ heart exclamation decoration love +๐Ÿ’” broken heart sad sorry break heart heartbreak +โค๏ธ red heart love like valentines +๐Ÿงก orange heart love like affection valentines +๐Ÿ’› yellow heart love like affection valentines +๐Ÿ’š green heart love like affection valentines +๐Ÿ’™ blue heart love like affection valentines +๐Ÿ’œ purple heart love like affection valentines +๐ŸคŽ brown heart coffee +๐Ÿ–ค black heart evil +๐Ÿค white heart pure +๐Ÿ’ฏ hundred points score perfect numbers century exam quiz test pass hundred +๐Ÿ’ข anger symbol angry mad +๐Ÿ’ฅ collision bomb explode explosion collision blown +๐Ÿ’ซ dizzy star sparkle shoot magic +๐Ÿ’ฆ sweat droplets water drip oops +๐Ÿ’จ dashing away wind air fast shoo fart smoke puff +๐Ÿ•ณ๏ธ hole embarrassing +๐Ÿ’ฃ bomb boom explode explosion terrorism +๐Ÿ’ฌ speech balloon bubble words message talk chatting +๐Ÿ‘๏ธโ€๐Ÿ—จ๏ธ eye in speech bubble info +๐Ÿ—จ๏ธ left speech bubble words message talk chatting +๐Ÿ—ฏ๏ธ right anger bubble caption speech thinking mad +๐Ÿ’ญ thought balloon bubble cloud speech thinking dream +๐Ÿ’ค zzz sleepy tired dream +๐Ÿ‘‹ waving hand hands gesture goodbye solong farewell hello hi palm +๐Ÿคš raised back of hand fingers raised backhand +๐Ÿ–๏ธ hand with fingers splayed hand fingers palm +โœ‹ raised hand fingers stop highfive palm ban +๐Ÿ–– vulcan salute hand fingers spock star trek +๐Ÿ‘Œ ok hand fingers limbs perfect ok okay +๐Ÿค pinching hand tiny small size +โœŒ๏ธ victory hand fingers ohyeah hand peace victory two +๐Ÿคž crossed fingers good lucky +๐ŸคŸ love you gesture hand fingers gesture +๐Ÿค˜ sign of the horns hand fingers evil eye sign of horns rock on +๐Ÿค™ call me hand hands gesture shaka +๐Ÿ‘ˆ backhand index pointing left direction fingers hand left +๐Ÿ‘‰ backhand index pointing right fingers hand direction right +๐Ÿ‘† backhand index pointing up fingers hand direction up +๐Ÿ–• middle finger hand fingers rude middle flipping +๐Ÿ‘‡ backhand index pointing down fingers hand direction down +โ˜๏ธ index pointing up hand fingers direction up +๐Ÿ‘ thumbs up thumbsup yes awesome good agree accept cool hand like +1 +๐Ÿ‘Ž thumbs down thumbsdown no dislike hand -1 +โœŠ raised fist fingers hand grasp +๐Ÿ‘Š oncoming fist angry violence fist hit attack hand +๐Ÿค› left facing fist hand fistbump +๐Ÿคœ right facing fist hand fistbump +๐Ÿ‘ clapping hands hands praise applause congrats yay +๐Ÿ™Œ raising hands gesture hooray yea celebration hands +๐Ÿ‘ open hands fingers butterfly hands open +๐Ÿคฒ palms up together hands gesture cupped prayer +๐Ÿค handshake agreement shake +๐Ÿ™ folded hands please hope wish namaste highfive pray +โœ๏ธ writing hand lower left ballpoint pen stationery write compose +๐Ÿ’… nail polish beauty manicure finger fashion nail +๐Ÿคณ selfie camera phone +๐Ÿ’ช flexed biceps arm flex hand summer strong biceps +๐Ÿฆพ mechanical arm accessibility +๐Ÿฆฟ mechanical leg accessibility +๐Ÿฆต leg kick limb +๐Ÿฆถ foot kick stomp +๐Ÿ‘‚ ear face hear sound listen +๐Ÿฆป ear with hearing aid accessibility +๐Ÿ‘ƒ nose smell sniff +๐Ÿง  brain smart intelligent +๐Ÿฆท tooth teeth dentist +๐Ÿฆด bone skeleton +๐Ÿ‘€ eyes look watch stalk peek see +๐Ÿ‘๏ธ eye face look see watch stare +๐Ÿ‘… tongue mouth playful +๐Ÿ‘„ mouth mouth kiss +๐Ÿ‘ถ baby child boy girl toddler +๐Ÿง’ child gender-neutral young +๐Ÿ‘ฆ boy man male guy teenager +๐Ÿ‘ง girl female woman teenager +๐Ÿง‘ person gender-neutral person +๐Ÿ‘ฑ person blond hair hairstyle +๐Ÿ‘จ man mustache father dad guy classy sir moustache +๐Ÿง” man beard person bewhiskered +๐Ÿ‘จโ€๐Ÿฆฐ man red hair hairstyle +๐Ÿ‘จโ€๐Ÿฆฑ man curly hair hairstyle +๐Ÿ‘จโ€๐Ÿฆณ man white hair old elder +๐Ÿ‘จโ€๐Ÿฆฒ man bald hairless +๐Ÿ‘ฉ woman female girls lady +๐Ÿ‘ฉโ€๐Ÿฆฐ woman red hair hairstyle +๐Ÿง‘โ€๐Ÿฆฐ person red hair hairstyle +๐Ÿ‘ฉโ€๐Ÿฆฑ woman curly hair hairstyle +๐Ÿง‘โ€๐Ÿฆฑ person curly hair hairstyle +๐Ÿ‘ฉโ€๐Ÿฆณ woman white hair old elder +๐Ÿง‘โ€๐Ÿฆณ person white hair elder old +๐Ÿ‘ฉโ€๐Ÿฆฒ woman bald hairless +๐Ÿง‘โ€๐Ÿฆฒ person bald hairless +๐Ÿ‘ฑโ€โ™€๏ธ woman blond hair woman female girl blonde person +๐Ÿ‘ฑโ€โ™‚๏ธ man blond hair man male boy blonde guy person +๐Ÿง“ older person human elder senior gender-neutral +๐Ÿ‘ด old man human male men old elder senior +๐Ÿ‘ต old woman human female women lady old elder senior +๐Ÿ™ person frowning worried +๐Ÿ™โ€โ™‚๏ธ man frowning male boy man sad depressed discouraged unhappy +๐Ÿ™โ€โ™€๏ธ woman frowning female girl woman sad depressed discouraged unhappy +๐Ÿ™Ž person pouting upset +๐Ÿ™Žโ€โ™‚๏ธ man pouting male boy man +๐Ÿ™Žโ€โ™€๏ธ woman pouting female girl woman +๐Ÿ™… person gesturing no decline +๐Ÿ™…โ€โ™‚๏ธ man gesturing no male boy man nope +๐Ÿ™…โ€โ™€๏ธ woman gesturing no female girl woman nope +๐Ÿ™† person gesturing ok agree +๐Ÿ™†โ€โ™‚๏ธ man gesturing ok men boy male blue human man +๐Ÿ™†โ€โ™€๏ธ woman gesturing ok women girl female pink human woman +๐Ÿ’ person tipping hand information +๐Ÿ’โ€โ™‚๏ธ man tipping hand male boy man human information +๐Ÿ’โ€โ™€๏ธ woman tipping hand female girl woman human information +๐Ÿ™‹ person raising hand question +๐Ÿ™‹โ€โ™‚๏ธ man raising hand male boy man +๐Ÿ™‹โ€โ™€๏ธ woman raising hand female girl woman +๐Ÿง deaf person accessibility +๐Ÿงโ€โ™‚๏ธ deaf man accessibility +๐Ÿงโ€โ™€๏ธ deaf woman accessibility +๐Ÿ™‡ person bowing respectiful +๐Ÿ™‡โ€โ™‚๏ธ man bowing man male boy +๐Ÿ™‡โ€โ™€๏ธ woman bowing woman female girl +๐Ÿคฆ person facepalming disappointed +๐Ÿคฆโ€โ™‚๏ธ man facepalming man male boy disbelief +๐Ÿคฆโ€โ™€๏ธ woman facepalming woman female girl disbelief +๐Ÿคท person shrugging regardless +๐Ÿคทโ€โ™‚๏ธ man shrugging man male boy confused indifferent doubt +๐Ÿคทโ€โ™€๏ธ woman shrugging woman female girl confused indifferent doubt +๐Ÿง‘โ€โš•๏ธ health worker hospital +๐Ÿ‘จโ€โš•๏ธ man health worker doctor nurse therapist healthcare man human +๐Ÿ‘ฉโ€โš•๏ธ woman health worker doctor nurse therapist healthcare woman human +๐Ÿง‘โ€๐ŸŽ“ student learn +๐Ÿ‘จโ€๐ŸŽ“ man student graduate man human +๐Ÿ‘ฉโ€๐ŸŽ“ woman student graduate woman human +๐Ÿง‘โ€๐Ÿซ teacher professor +๐Ÿ‘จโ€๐Ÿซ man teacher instructor professor man human +๐Ÿ‘ฉโ€๐Ÿซ woman teacher instructor professor woman human +๐Ÿง‘โ€โš–๏ธ judge law +๐Ÿ‘จโ€โš–๏ธ man judge justice court man human +๐Ÿ‘ฉโ€โš–๏ธ woman judge justice court woman human +๐Ÿง‘โ€๐ŸŒพ farmer crops +๐Ÿ‘จโ€๐ŸŒพ man farmer rancher gardener man human +๐Ÿ‘ฉโ€๐ŸŒพ woman farmer rancher gardener woman human +๐Ÿง‘โ€๐Ÿณ cook food kitchen culinary +๐Ÿ‘จโ€๐Ÿณ man cook chef man human +๐Ÿ‘ฉโ€๐Ÿณ woman cook chef woman human +๐Ÿง‘โ€๐Ÿ”ง mechanic worker technician +๐Ÿ‘จโ€๐Ÿ”ง man mechanic plumber man human wrench +๐Ÿ‘ฉโ€๐Ÿ”ง woman mechanic plumber woman human wrench +๐Ÿง‘โ€๐Ÿญ factory worker labor +๐Ÿ‘จโ€๐Ÿญ man factory worker assembly industrial man human +๐Ÿ‘ฉโ€๐Ÿญ woman factory worker assembly industrial woman human +๐Ÿง‘โ€๐Ÿ’ผ office worker business +๐Ÿ‘จโ€๐Ÿ’ผ man office worker business manager man human +๐Ÿ‘ฉโ€๐Ÿ’ผ woman office worker business manager woman human +๐Ÿง‘โ€๐Ÿ”ฌ scientist chemistry +๐Ÿ‘จโ€๐Ÿ”ฌ man scientist biologist chemist engineer physicist man human +๐Ÿ‘ฉโ€๐Ÿ”ฌ woman scientist biologist chemist engineer physicist woman human +๐Ÿง‘โ€๐Ÿ’ป technologist computer +๐Ÿ‘จโ€๐Ÿ’ป man technologist coder developer engineer programmer software man human laptop computer +๐Ÿ‘ฉโ€๐Ÿ’ป woman technologist coder developer engineer programmer software woman human laptop computer +๐Ÿง‘โ€๐ŸŽค singer song artist performer +๐Ÿ‘จโ€๐ŸŽค man singer rockstar entertainer man human +๐Ÿ‘ฉโ€๐ŸŽค woman singer rockstar entertainer woman human +๐Ÿง‘โ€๐ŸŽจ artist painting draw creativity +๐Ÿ‘จโ€๐ŸŽจ man artist painter man human +๐Ÿ‘ฉโ€๐ŸŽจ woman artist painter woman human +๐Ÿง‘โ€โœˆ๏ธ pilot fly plane airplane +๐Ÿ‘จโ€โœˆ๏ธ man pilot aviator plane man human +๐Ÿ‘ฉโ€โœˆ๏ธ woman pilot aviator plane woman human +๐Ÿง‘โ€๐Ÿš€ astronaut outerspace +๐Ÿ‘จโ€๐Ÿš€ man astronaut space rocket man human +๐Ÿ‘ฉโ€๐Ÿš€ woman astronaut space rocket woman human +๐Ÿง‘โ€๐Ÿš’ firefighter fire +๐Ÿ‘จโ€๐Ÿš’ man firefighter fireman man human +๐Ÿ‘ฉโ€๐Ÿš’ woman firefighter fireman woman human +๐Ÿ‘ฎ police officer cop +๐Ÿ‘ฎโ€โ™‚๏ธ man police officer man police law legal enforcement arrest 911 +๐Ÿ‘ฎโ€โ™€๏ธ woman police officer woman police law legal enforcement arrest 911 female +๐Ÿ•ต๏ธ detective human spy detective +๐Ÿ•ต๏ธโ€โ™‚๏ธ man detective crime +๐Ÿ•ต๏ธโ€โ™€๏ธ woman detective human spy detective female woman +๐Ÿ’‚ guard protect +๐Ÿ’‚โ€โ™‚๏ธ man guard uk gb british male guy royal +๐Ÿ’‚โ€โ™€๏ธ woman guard uk gb british female royal woman +๐Ÿ‘ท construction worker labor build +๐Ÿ‘ทโ€โ™‚๏ธ man construction worker male human wip guy build construction worker labor +๐Ÿ‘ทโ€โ™€๏ธ woman construction worker female human wip build construction worker labor woman +๐Ÿคด prince boy man male crown royal king +๐Ÿ‘ธ princess girl woman female blond crown royal queen +๐Ÿ‘ณ person wearing turban headdress +๐Ÿ‘ณโ€โ™‚๏ธ man wearing turban male indian hinduism arabs +๐Ÿ‘ณโ€โ™€๏ธ woman wearing turban female indian hinduism arabs woman +๐Ÿ‘ฒ man with skullcap male boy chinese +๐Ÿง• woman with headscarf female hijab mantilla tichel +๐Ÿคต man in tuxedo couple marriage wedding groom +๐Ÿ‘ฐ bride with veil couple marriage wedding woman bride +๐Ÿคฐ pregnant woman baby +๐Ÿคฑ breast feeding nursing baby +๐Ÿ‘ผ baby angel heaven wings halo +๐ŸŽ… santa claus festival man male xmas father christmas +๐Ÿคถ mrs claus woman female xmas mother christmas +๐Ÿฆธ superhero marvel +๐Ÿฆธโ€โ™‚๏ธ man superhero man male good hero superpowers +๐Ÿฆธโ€โ™€๏ธ woman superhero woman female good heroine superpowers +๐Ÿฆน supervillain marvel +๐Ÿฆนโ€โ™‚๏ธ man supervillain man male evil bad criminal hero superpowers +๐Ÿฆนโ€โ™€๏ธ woman supervillain woman female evil bad criminal heroine superpowers +๐Ÿง™ mage magic +๐Ÿง™โ€โ™‚๏ธ man mage man male mage sorcerer +๐Ÿง™โ€โ™€๏ธ woman mage woman female mage witch +๐Ÿงš fairy wings magical +๐Ÿงšโ€โ™‚๏ธ man fairy man male +๐Ÿงšโ€โ™€๏ธ woman fairy woman female +๐Ÿง› vampire blood twilight +๐Ÿง›โ€โ™‚๏ธ man vampire man male dracula +๐Ÿง›โ€โ™€๏ธ woman vampire woman female +๐Ÿงœ merperson sea +๐Ÿงœโ€โ™‚๏ธ merman man male triton +๐Ÿงœโ€โ™€๏ธ mermaid woman female merwoman ariel +๐Ÿง elf magical +๐Ÿงโ€โ™‚๏ธ man elf man male +๐Ÿงโ€โ™€๏ธ woman elf woman female +๐Ÿงž genie magical wishes +๐Ÿงžโ€โ™‚๏ธ man genie man male +๐Ÿงžโ€โ™€๏ธ woman genie woman female +๐ŸงŸ zombie dead +๐ŸงŸโ€โ™‚๏ธ man zombie man male dracula undead walking dead +๐ŸงŸโ€โ™€๏ธ woman zombie woman female undead walking dead +๐Ÿ’† person getting massage relax +๐Ÿ’†โ€โ™‚๏ธ man getting massage male boy man head +๐Ÿ’†โ€โ™€๏ธ woman getting massage female girl woman head +๐Ÿ’‡ person getting haircut hairstyle +๐Ÿ’‡โ€โ™‚๏ธ man getting haircut male boy man +๐Ÿ’‡โ€โ™€๏ธ woman getting haircut female girl woman +๐Ÿšถ person walking move +๐Ÿšถโ€โ™‚๏ธ man walking human feet steps +๐Ÿšถโ€โ™€๏ธ woman walking human feet steps woman female +๐Ÿง person standing still +๐Ÿงโ€โ™‚๏ธ man standing still +๐Ÿงโ€โ™€๏ธ woman standing still +๐ŸงŽ person kneeling pray respectful +๐ŸงŽโ€โ™‚๏ธ man kneeling pray respectful +๐ŸงŽโ€โ™€๏ธ woman kneeling respectful pray +๐Ÿง‘โ€๐Ÿฆฏ person with probing cane blind +๐Ÿ‘จโ€๐Ÿฆฏ man with probing cane blind +๐Ÿ‘ฉโ€๐Ÿฆฏ woman with probing cane blind +๐Ÿง‘โ€๐Ÿฆผ person in motorized wheelchair disability accessibility +๐Ÿ‘จโ€๐Ÿฆผ man in motorized wheelchair disability accessibility +๐Ÿ‘ฉโ€๐Ÿฆผ woman in motorized wheelchair disability accessibility +๐Ÿง‘โ€๐Ÿฆฝ person in manual wheelchair disability accessibility +๐Ÿ‘จโ€๐Ÿฆฝ man in manual wheelchair disability accessibility +๐Ÿ‘ฉโ€๐Ÿฆฝ woman in manual wheelchair disability accessibility +๐Ÿƒ person running move +๐Ÿƒโ€โ™‚๏ธ man running man walking exercise race running +๐Ÿƒโ€โ™€๏ธ woman running woman walking exercise race running female +๐Ÿ’ƒ woman dancing female girl woman fun +๐Ÿ•บ man dancing male boy fun dancer +๐Ÿ•ด๏ธ man in suit levitating suit business levitate hover jump +๐Ÿ‘ฏ people with bunny ears perform costume +๐Ÿ‘ฏโ€โ™‚๏ธ men with bunny ears male bunny men boys +๐Ÿ‘ฏโ€โ™€๏ธ women with bunny ears female bunny women girls +๐Ÿง– person in steamy room relax spa +๐Ÿง–โ€โ™‚๏ธ man in steamy room male man spa steamroom sauna +๐Ÿง–โ€โ™€๏ธ woman in steamy room female woman spa steamroom sauna +๐Ÿง— person climbing sport +๐Ÿง—โ€โ™‚๏ธ man climbing sports hobby man male rock +๐Ÿง—โ€โ™€๏ธ woman climbing sports hobby woman female rock +๐Ÿคบ person fencing sports fencing sword +๐Ÿ‡ horse racing animal betting competition gambling luck +โ›ท๏ธ skier sports winter snow +๐Ÿ‚ snowboarder sports winter +๐ŸŒ๏ธ person golfing sports business +๐ŸŒ๏ธโ€โ™‚๏ธ man golfing sport +๐ŸŒ๏ธโ€โ™€๏ธ woman golfing sports business woman female +๐Ÿ„ person surfing sport sea +๐Ÿ„โ€โ™‚๏ธ man surfing sports ocean sea summer beach +๐Ÿ„โ€โ™€๏ธ woman surfing sports ocean sea summer beach woman female +๐Ÿšฃ person rowing boat sport move +๐Ÿšฃโ€โ™‚๏ธ man rowing boat sports hobby water ship +๐Ÿšฃโ€โ™€๏ธ woman rowing boat sports hobby water ship woman female +๐ŸŠ person swimming sport pool +๐ŸŠโ€โ™‚๏ธ man swimming sports exercise human athlete water summer +๐ŸŠโ€โ™€๏ธ woman swimming sports exercise human athlete water summer woman female +โ›น๏ธ person bouncing ball sports human +โ›น๏ธโ€โ™‚๏ธ man bouncing ball sport +โ›น๏ธโ€โ™€๏ธ woman bouncing ball sports human woman female +๐Ÿ‹๏ธ person lifting weights sports training exercise +๐Ÿ‹๏ธโ€โ™‚๏ธ man lifting weights sport +๐Ÿ‹๏ธโ€โ™€๏ธ woman lifting weights sports training exercise woman female +๐Ÿšด person biking sport move +๐Ÿšดโ€โ™‚๏ธ man biking sports bike exercise hipster +๐Ÿšดโ€โ™€๏ธ woman biking sports bike exercise hipster woman female +๐Ÿšต person mountain biking sport move +๐Ÿšตโ€โ™‚๏ธ man mountain biking transportation sports human race bike +๐Ÿšตโ€โ™€๏ธ woman mountain biking transportation sports human race bike woman female +๐Ÿคธ person cartwheeling sport gymnastic +๐Ÿคธโ€โ™‚๏ธ man cartwheeling gymnastics +๐Ÿคธโ€โ™€๏ธ woman cartwheeling gymnastics +๐Ÿคผ people wrestling sport +๐Ÿคผโ€โ™‚๏ธ men wrestling sports wrestlers +๐Ÿคผโ€โ™€๏ธ women wrestling sports wrestlers +๐Ÿคฝ person playing water polo sport +๐Ÿคฝโ€โ™‚๏ธ man playing water polo sports pool +๐Ÿคฝโ€โ™€๏ธ woman playing water polo sports pool +๐Ÿคพ person playing handball sport +๐Ÿคพโ€โ™‚๏ธ man playing handball sports +๐Ÿคพโ€โ™€๏ธ woman playing handball sports +๐Ÿคน person juggling performance balance +๐Ÿคนโ€โ™‚๏ธ man juggling juggle balance skill multitask +๐Ÿคนโ€โ™€๏ธ woman juggling juggle balance skill multitask +๐Ÿง˜ person in lotus position meditate +๐Ÿง˜โ€โ™‚๏ธ man in lotus position man male meditation yoga serenity zen mindfulness +๐Ÿง˜โ€โ™€๏ธ woman in lotus position woman female meditation yoga serenity zen mindfulness +๐Ÿ›€ person taking bath clean shower bathroom +๐Ÿ›Œ person in bed bed rest +๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ people holding hands friendship +๐Ÿ‘ญ women holding hands pair friendship couple love like female people human +๐Ÿ‘ซ woman and man holding hands pair people human love date dating like affection valentines marriage +๐Ÿ‘ฌ men holding hands pair couple love like bromance friendship people human +๐Ÿ’ kiss pair valentines love like dating marriage +๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ kiss woman man love +๐Ÿ‘จโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ kiss man man pair valentines love like dating marriage +๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ kiss woman woman pair valentines love like dating marriage +๐Ÿ’‘ couple with heart pair love like affection human dating valentines marriage +๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘จ couple with heart woman man love +๐Ÿ‘จโ€โค๏ธโ€๐Ÿ‘จ couple with heart man man pair love like affection human dating valentines marriage +๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘ฉ couple with heart woman woman pair love like affection human dating valentines marriage +๐Ÿ‘ช family home parents child mom dad father mother people human +๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ family man woman boy love +๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง family man woman girl home parents people human child +๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family man woman girl boy home parents people human children +๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ family man woman boy boy home parents people human children +๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง family man woman girl girl home parents people human children +๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆ family man man boy home parents people human children +๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ง family man man girl home parents people human children +๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family man man girl boy home parents people human children +๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ family man man boy boy home parents people human children +๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง family man man girl girl home parents people human children +๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ family woman woman boy home parents people human children +๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ง family woman woman girl home parents people human children +๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family woman woman girl boy home parents people human children +๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ family woman woman boy boy home parents people human children +๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง family woman woman girl girl home parents people human children +๐Ÿ‘จโ€๐Ÿ‘ฆ family man boy home parent people human child +๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ family man boy boy home parent people human children +๐Ÿ‘จโ€๐Ÿ‘ง family man girl home parent people human child +๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family man girl boy home parent people human children +๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง family man girl girl home parent people human children +๐Ÿ‘ฉโ€๐Ÿ‘ฆ family woman boy home parent people human child +๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ family woman boy boy home parent people human children +๐Ÿ‘ฉโ€๐Ÿ‘ง family woman girl home parent people human child +๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family woman girl boy home parent people human children +๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง family woman girl girl home parent people human children +๐Ÿ—ฃ๏ธ speaking head user person human sing say talk +๐Ÿ‘ค bust in silhouette user person human +๐Ÿ‘ฅ busts in silhouette user person human group team +๐Ÿ‘ฃ footprints feet tracking walking beach +๐Ÿต monkey face animal nature circus +๐Ÿ’ monkey animal nature banana circus +๐Ÿฆ gorilla animal nature circus +๐Ÿฆง orangutan animal +๐Ÿถ dog face animal friend nature woof puppy pet faithful +๐Ÿ• dog animal nature friend doge pet faithful +๐Ÿฆฎ guide dog animal blind +๐Ÿ•โ€๐Ÿฆบ service dog blind animal +๐Ÿฉ poodle dog animal 101 nature pet +๐Ÿบ wolf animal nature wild +๐ŸฆŠ fox animal nature face +๐Ÿฆ raccoon animal nature +๐Ÿฑ cat face animal meow nature pet kitten +๐Ÿˆ cat animal meow pet cats +๐Ÿฆ lion animal nature +๐Ÿฏ tiger face animal cat danger wild nature roar +๐Ÿ… tiger animal nature roar +๐Ÿ† leopard animal nature +๐Ÿด horse face animal brown nature +๐ŸŽ horse animal gamble luck +๐Ÿฆ„ unicorn animal nature mystical +๐Ÿฆ“ zebra animal nature stripes safari +๐ŸฆŒ deer animal nature horns venison +๐Ÿฎ cow face beef ox animal nature moo milk +๐Ÿ‚ ox animal cow beef +๐Ÿƒ water buffalo animal nature ox cow +๐Ÿ„ cow beef ox animal nature moo milk +๐Ÿท pig face animal oink nature +๐Ÿ– pig animal nature +๐Ÿ— boar animal nature +๐Ÿฝ pig nose animal oink +๐Ÿ ram animal sheep nature +๐Ÿ‘ ewe animal nature wool shipit +๐Ÿ goat animal nature +๐Ÿช camel animal hot desert hump +๐Ÿซ two hump camel animal nature hot desert hump +๐Ÿฆ™ llama animal nature alpaca +๐Ÿฆ’ giraffe animal nature spots safari +๐Ÿ˜ elephant animal nature nose th circus +๐Ÿฆ rhinoceros animal nature horn +๐Ÿฆ› hippopotamus animal nature +๐Ÿญ mouse face animal nature cheese wedge rodent +๐Ÿ mouse animal nature rodent +๐Ÿ€ rat animal mouse rodent +๐Ÿน hamster animal nature +๐Ÿฐ rabbit face animal nature pet spring magic bunny +๐Ÿ‡ rabbit animal nature pet magic spring +๐Ÿฟ๏ธ chipmunk animal nature rodent squirrel +๐Ÿฆ” hedgehog animal nature spiny +๐Ÿฆ‡ bat animal nature blind vampire +๐Ÿป bear animal nature wild +๐Ÿจ koala animal nature +๐Ÿผ panda animal nature panda +๐Ÿฆฅ sloth animal +๐Ÿฆฆ otter animal +๐Ÿฆจ skunk animal +๐Ÿฆ˜ kangaroo animal nature australia joey hop marsupial +๐Ÿฆก badger animal nature honey +๐Ÿพ paw prints animal tracking footprints dog cat pet feet +๐Ÿฆƒ turkey animal bird +๐Ÿ” chicken animal cluck nature bird +๐Ÿ“ rooster animal nature chicken +๐Ÿฃ hatching chick animal chicken egg born baby bird +๐Ÿค baby chick animal chicken bird +๐Ÿฅ front facing baby chick animal chicken baby bird +๐Ÿฆ bird animal nature fly tweet spring +๐Ÿง penguin animal nature +๐Ÿ•Š๏ธ dove animal bird +๐Ÿฆ… eagle animal nature bird +๐Ÿฆ† duck animal nature bird mallard +๐Ÿฆข swan animal nature bird +๐Ÿฆ‰ owl animal nature bird hoot +๐Ÿฆฉ flamingo animal +๐Ÿฆš peacock animal nature peahen bird +๐Ÿฆœ parrot animal nature bird pirate talk +๐Ÿธ frog animal nature croak toad +๐ŸŠ crocodile animal nature reptile lizard alligator +๐Ÿข turtle animal slow nature tortoise +๐ŸฆŽ lizard animal nature reptile +๐Ÿ snake animal evil nature hiss python +๐Ÿฒ dragon face animal myth nature chinese green +๐Ÿ‰ dragon animal myth nature chinese green +๐Ÿฆ• sauropod animal nature dinosaur brachiosaurus brontosaurus diplodocus extinct +๐Ÿฆ– t rex animal nature dinosaur tyrannosaurus extinct +๐Ÿณ spouting whale animal nature sea ocean +๐Ÿ‹ whale animal nature sea ocean +๐Ÿฌ dolphin animal nature fish sea ocean flipper fins beach +๐ŸŸ fish animal food nature +๐Ÿ  tropical fish animal swim ocean beach nemo +๐Ÿก blowfish animal nature food sea ocean +๐Ÿฆˆ shark animal nature fish sea ocean jaws fins beach +๐Ÿ™ octopus animal creature ocean sea nature beach +๐Ÿš spiral shell nature sea beach +๐ŸŒ snail slow animal shell +๐Ÿฆ‹ butterfly animal insect nature caterpillar +๐Ÿ› bug animal insect nature worm +๐Ÿœ ant animal insect nature bug +๐Ÿ honeybee animal insect nature bug spring honey +๐Ÿž lady beetle animal insect nature ladybug +๐Ÿฆ— cricket animal cricket chirp +๐Ÿ•ท๏ธ spider animal arachnid +๐Ÿ•ธ๏ธ spider web animal insect arachnid silk +๐Ÿฆ‚ scorpion animal arachnid +๐ŸฆŸ mosquito animal nature insect malaria +๐Ÿฆ  microbe amoeba bacteria germs virus +๐Ÿ’ bouquet flowers nature spring +๐ŸŒธ cherry blossom nature plant spring flower +๐Ÿ’ฎ white flower japanese spring +๐Ÿต๏ธ rosette flower decoration military +๐ŸŒน rose flowers valentines love spring +๐Ÿฅ€ wilted flower plant nature flower +๐ŸŒบ hibiscus plant vegetable flowers beach +๐ŸŒป sunflower nature plant fall +๐ŸŒผ blossom nature flowers yellow +๐ŸŒท tulip flowers plant nature summer spring +๐ŸŒฑ seedling plant nature grass lawn spring +๐ŸŒฒ evergreen tree plant nature +๐ŸŒณ deciduous tree plant nature +๐ŸŒด palm tree plant vegetable nature summer beach mojito tropical +๐ŸŒต cactus vegetable plant nature +๐ŸŒพ sheaf of rice nature plant +๐ŸŒฟ herb vegetable plant medicine weed grass lawn +โ˜˜๏ธ shamrock vegetable plant nature irish clover +๐Ÿ€ four leaf clover vegetable plant nature lucky irish +๐Ÿ maple leaf nature plant vegetable ca fall +๐Ÿ‚ fallen leaf nature plant vegetable leaves +๐Ÿƒ leaf fluttering in wind nature plant tree vegetable grass lawn spring +๐Ÿ‡ grapes fruit food wine +๐Ÿˆ melon fruit nature food +๐Ÿ‰ watermelon fruit food picnic summer +๐ŸŠ tangerine food fruit nature orange +๐Ÿ‹ lemon fruit nature +๐ŸŒ banana fruit food monkey +๐Ÿ pineapple fruit nature food +๐Ÿฅญ mango fruit food tropical +๐ŸŽ red apple fruit mac school +๐Ÿ green apple fruit nature +๐Ÿ pear fruit nature food +๐Ÿ‘ peach fruit nature food +๐Ÿ’ cherries food fruit +๐Ÿ“ strawberry fruit food nature +๐Ÿฅ kiwi fruit fruit food +๐Ÿ… tomato fruit vegetable nature food +๐Ÿฅฅ coconut fruit nature food palm +๐Ÿฅ‘ avocado fruit food +๐Ÿ† eggplant vegetable nature food aubergine +๐Ÿฅ” potato food tuber vegatable starch +๐Ÿฅ• carrot vegetable food orange +๐ŸŒฝ ear of corn food vegetable plant +๐ŸŒถ๏ธ hot pepper food spicy chilli chili +๐Ÿฅ’ cucumber fruit food pickle +๐Ÿฅฌ leafy green food vegetable plant bok choy cabbage kale lettuce +๐Ÿฅฆ broccoli fruit food vegetable +๐Ÿง„ garlic food spice cook +๐Ÿง… onion cook food spice +๐Ÿ„ mushroom plant vegetable +๐Ÿฅœ peanuts food nut +๐ŸŒฐ chestnut food squirrel +๐Ÿž bread food wheat breakfast toast +๐Ÿฅ croissant food bread french +๐Ÿฅ– baguette bread food bread french +๐Ÿฅจ pretzel food bread twisted +๐Ÿฅฏ bagel food bread bakery schmear +๐Ÿฅž pancakes food breakfast flapjacks hotcakes +๐Ÿง‡ waffle food breakfast +๐Ÿง€ cheese wedge food chadder +๐Ÿ– meat on bone good food drumstick +๐Ÿ— poultry leg food meat drumstick bird chicken turkey +๐Ÿฅฉ cut of meat food cow meat cut chop lambchop porkchop +๐Ÿฅ“ bacon food breakfast pork pig meat +๐Ÿ” hamburger meat fast food beef cheeseburger mcdonalds burger king +๐ŸŸ french fries chips snack fast food +๐Ÿ• pizza food party +๐ŸŒญ hot dog food frankfurter +๐Ÿฅช sandwich food lunch bread +๐ŸŒฎ taco food mexican +๐ŸŒฏ burrito food mexican +๐Ÿฅ™ stuffed flatbread food flatbread stuffed gyro +๐Ÿง† falafel food +๐Ÿฅš egg food chicken breakfast +๐Ÿณ cooking food breakfast kitchen egg +๐Ÿฅ˜ shallow pan of food food cooking casserole paella +๐Ÿฒ pot of food food meat soup +๐Ÿฅฃ bowl with spoon food breakfast cereal oatmeal porridge +๐Ÿฅ— green salad food healthy lettuce +๐Ÿฟ popcorn food movie theater films snack +๐Ÿงˆ butter food cook +๐Ÿง‚ salt condiment shaker +๐Ÿฅซ canned food food soup +๐Ÿฑ bento box food japanese box +๐Ÿ˜ rice cracker food japanese +๐Ÿ™ rice ball food japanese +๐Ÿš cooked rice food china asian +๐Ÿ› curry rice food spicy hot indian +๐Ÿœ steaming bowl food japanese noodle chopsticks +๐Ÿ spaghetti food italian noodle +๐Ÿ  roasted sweet potato food nature +๐Ÿข oden food japanese +๐Ÿฃ sushi food fish japanese rice +๐Ÿค fried shrimp food animal appetizer summer +๐Ÿฅ fish cake with swirl food japan sea beach narutomaki pink swirl kamaboko surimi ramen +๐Ÿฅฎ moon cake food autumn +๐Ÿก dango food dessert sweet japanese barbecue meat +๐ŸฅŸ dumpling food empanada pierogi potsticker +๐Ÿฅ  fortune cookie food prophecy +๐Ÿฅก takeout box food leftovers +๐Ÿฆ€ crab animal crustacean +๐Ÿฆž lobster animal nature bisque claws seafood +๐Ÿฆ shrimp animal ocean nature seafood +๐Ÿฆ‘ squid animal nature ocean sea +๐Ÿฆช oyster food +๐Ÿฆ soft ice cream food hot dessert summer +๐Ÿง shaved ice hot dessert summer +๐Ÿจ ice cream food hot dessert +๐Ÿฉ doughnut food dessert snack sweet donut +๐Ÿช cookie food snack oreo chocolate sweet dessert +๐ŸŽ‚ birthday cake food dessert cake +๐Ÿฐ shortcake food dessert +๐Ÿง cupcake food dessert bakery sweet +๐Ÿฅง pie food dessert pastry +๐Ÿซ chocolate bar food snack dessert sweet +๐Ÿฌ candy snack dessert sweet lolly +๐Ÿญ lollipop food snack candy sweet +๐Ÿฎ custard dessert food +๐Ÿฏ honey pot bees sweet kitchen +๐Ÿผ baby bottle food container milk +๐Ÿฅ› glass of milk beverage drink cow +โ˜• hot beverage beverage caffeine latte espresso coffee +๐Ÿต teacup without handle drink bowl breakfast green british +๐Ÿถ sake wine drink drunk beverage japanese alcohol booze +๐Ÿพ bottle with popping cork drink wine bottle celebration +๐Ÿท wine glass drink beverage drunk alcohol booze +๐Ÿธ cocktail glass drink drunk alcohol beverage booze mojito +๐Ÿน tropical drink beverage cocktail summer beach alcohol booze mojito +๐Ÿบ beer mug relax beverage drink drunk party pub summer alcohol booze +๐Ÿป clinking beer mugs relax beverage drink drunk party pub summer alcohol booze +๐Ÿฅ‚ clinking glasses beverage drink party alcohol celebrate cheers wine champagne toast +๐Ÿฅƒ tumbler glass drink beverage drunk alcohol liquor booze bourbon scotch whisky glass shot +๐Ÿฅค cup with straw drink soda +๐Ÿงƒ beverage box drink +๐Ÿง‰ mate drink tea beverage +๐ŸงŠ ice water cold +๐Ÿฅข chopsticks food +๐Ÿฝ๏ธ fork and knife with plate food eat meal lunch dinner restaurant +๐Ÿด fork and knife cutlery kitchen +๐Ÿฅ„ spoon cutlery kitchen tableware +๐Ÿ”ช kitchen knife knife blade cutlery kitchen weapon +๐Ÿบ amphora vase jar +๐ŸŒ globe showing europe africa globe world international +๐ŸŒŽ globe showing americas globe world USA international +๐ŸŒ globe showing asia australia globe world east international +๐ŸŒ globe with meridians earth international world internet interweb i18n +๐Ÿ—บ๏ธ world map location direction +๐Ÿ—พ map of japan nation country japanese asia +๐Ÿงญ compass magnetic navigation orienteering +๐Ÿ”๏ธ snow capped mountain photo nature environment winter cold +โ›ฐ๏ธ mountain photo nature environment +๐ŸŒ‹ volcano photo nature disaster +๐Ÿ—ป mount fuji photo mountain nature japanese +๐Ÿ•๏ธ camping photo outdoors tent +๐Ÿ–๏ธ beach with umbrella weather summer sunny sand mojito +๐Ÿœ๏ธ desert photo warm saharah +๐Ÿ๏ธ desert island photo tropical mojito +๐Ÿž๏ธ national park photo environment nature +๐ŸŸ๏ธ stadium photo place sports concert venue +๐Ÿ›๏ธ classical building art culture history +๐Ÿ—๏ธ building construction wip working progress +๐Ÿงฑ brick bricks +๐Ÿ˜๏ธ houses buildings photo +๐Ÿš๏ธ derelict house abandon evict broken building +๐Ÿ  house building home +๐Ÿก house with garden home plant nature +๐Ÿข office building building bureau work +๐Ÿฃ japanese post office building envelope communication +๐Ÿค post office building email +๐Ÿฅ hospital building health surgery doctor +๐Ÿฆ bank building money sales cash business enterprise +๐Ÿจ hotel building accomodation checkin +๐Ÿฉ love hotel like affection dating +๐Ÿช convenience store building shopping groceries +๐Ÿซ school building student education learn teach +๐Ÿฌ department store building shopping mall +๐Ÿญ factory building industry pollution smoke +๐Ÿฏ japanese castle photo building +๐Ÿฐ castle building royalty history +๐Ÿ’’ wedding love like affection couple marriage bride groom +๐Ÿ—ผ tokyo tower photo japanese +๐Ÿ—ฝ statue of liberty american newyork +โ›ช church building religion christ +๐Ÿ•Œ mosque islam worship minaret +๐Ÿ›• hindu temple religion +๐Ÿ• synagogue judaism worship temple jewish +โ›ฉ๏ธ shinto shrine temple japan kyoto +๐Ÿ•‹ kaaba mecca mosque islam +โ›ฒ fountain photo summer water fresh +โ›บ tent photo camping outdoors +๐ŸŒ foggy photo mountain +๐ŸŒƒ night with stars evening city downtown +๐Ÿ™๏ธ cityscape photo night life urban +๐ŸŒ„ sunrise over mountains view vacation photo +๐ŸŒ… sunrise morning view vacation photo +๐ŸŒ† cityscape at dusk photo evening sky buildings +๐ŸŒ‡ sunset photo good morning dawn +๐ŸŒ‰ bridge at night photo sanfrancisco +โ™จ๏ธ hot springs bath warm relax +๐ŸŽ  carousel horse photo carnival +๐ŸŽก ferris wheel photo carnival londoneye +๐ŸŽข roller coaster carnival playground photo fun +๐Ÿ’ˆ barber pole hair salon style +๐ŸŽช circus tent festival carnival party +๐Ÿš‚ locomotive transportation vehicle train +๐Ÿšƒ railway car transportation vehicle +๐Ÿš„ high speed train transportation vehicle +๐Ÿš… bullet train transportation vehicle speed fast public travel +๐Ÿš† train transportation vehicle +๐Ÿš‡ metro transportation blue-square mrt underground tube +๐Ÿšˆ light rail transportation vehicle +๐Ÿš‰ station transportation vehicle public +๐ŸšŠ tram transportation vehicle +๐Ÿš monorail transportation vehicle +๐Ÿšž mountain railway transportation vehicle +๐Ÿš‹ tram car transportation vehicle carriage public travel +๐ŸšŒ bus car vehicle transportation +๐Ÿš oncoming bus vehicle transportation +๐ŸšŽ trolleybus bart transportation vehicle +๐Ÿš minibus vehicle car transportation +๐Ÿš‘ ambulance health 911 hospital +๐Ÿš’ fire engine transportation cars vehicle +๐Ÿš“ police car vehicle cars transportation law legal enforcement +๐Ÿš” oncoming police car vehicle law legal enforcement 911 +๐Ÿš• taxi uber vehicle cars transportation +๐Ÿš– oncoming taxi vehicle cars uber +๐Ÿš— automobile red transportation vehicle +๐Ÿš˜ oncoming automobile car vehicle transportation +๐Ÿš™ sport utility vehicle transportation vehicle +๐Ÿšš delivery truck cars transportation +๐Ÿš› articulated lorry vehicle cars transportation express +๐Ÿšœ tractor vehicle car farming agriculture +๐ŸŽ๏ธ racing car sports race fast formula f1 +๐Ÿ๏ธ motorcycle race sports fast +๐Ÿ›ต motor scooter vehicle vespa sasha +๐Ÿฆฝ manual wheelchair accessibility +๐Ÿฆผ motorized wheelchair accessibility +๐Ÿ›บ auto rickshaw move transportation +๐Ÿšฒ bicycle sports bicycle exercise hipster +๐Ÿ›ด kick scooter vehicle kick razor +๐Ÿ›น skateboard board +๐Ÿš bus stop transportation wait +๐Ÿ›ฃ๏ธ motorway road cupertino interstate highway +๐Ÿ›ค๏ธ railway track train transportation +๐Ÿ›ข๏ธ oil drum barrell +โ›ฝ fuel pump gas station petroleum +๐Ÿšจ police car light police ambulance 911 emergency alert error pinged law legal +๐Ÿšฅ horizontal traffic light transportation signal +๐Ÿšฆ vertical traffic light transportation driving +๐Ÿ›‘ stop sign stop +๐Ÿšง construction wip progress caution warning +โš“ anchor ship ferry sea boat +โ›ต sailboat ship summer transportation water sailing +๐Ÿ›ถ canoe boat paddle water ship +๐Ÿšค speedboat ship transportation vehicle summer +๐Ÿ›ณ๏ธ passenger ship yacht cruise ferry +โ›ด๏ธ ferry boat ship yacht +๐Ÿ›ฅ๏ธ motor boat ship +๐Ÿšข ship transportation titanic deploy +โœˆ๏ธ airplane vehicle transportation flight fly +๐Ÿ›ฉ๏ธ small airplane flight transportation fly vehicle +๐Ÿ›ซ airplane departure airport flight landing +๐Ÿ›ฌ airplane arrival airport flight boarding +๐Ÿช‚ parachute fly glide +๐Ÿ’บ seat sit airplane transport bus flight fly +๐Ÿš helicopter transportation vehicle fly +๐ŸšŸ suspension railway vehicle transportation +๐Ÿš  mountain cableway transportation vehicle ski +๐Ÿšก aerial tramway transportation vehicle ski +๐Ÿ›ฐ๏ธ satellite communication gps orbit spaceflight NASA ISS +๐Ÿš€ rocket launch ship staffmode NASA outer space outer space fly +๐Ÿ›ธ flying saucer transportation vehicle ufo +๐Ÿ›Ž๏ธ bellhop bell service +๐Ÿงณ luggage packing travel +โŒ› hourglass done time clock oldschool limit exam quiz test +โณ hourglass not done oldschool time countdown +โŒš watch time accessories +โฐ alarm clock time wake +โฑ๏ธ stopwatch time deadline +โฒ๏ธ timer clock alarm +๐Ÿ•ฐ๏ธ mantelpiece clock time +๐Ÿ•› twelve o clock time noon midnight midday late early schedule +๐Ÿ•ง twelve thirty time late early schedule +๐Ÿ• one o clock time late early schedule +๐Ÿ•œ one thirty time late early schedule +๐Ÿ•‘ two o clock time late early schedule +๐Ÿ• two thirty time late early schedule +๐Ÿ•’ three o clock time late early schedule +๐Ÿ•ž three thirty time late early schedule +๐Ÿ•“ four o clock time late early schedule +๐Ÿ•Ÿ four thirty time late early schedule +๐Ÿ•” five o clock time late early schedule +๐Ÿ•  five thirty time late early schedule +๐Ÿ•• six o clock time late early schedule dawn dusk +๐Ÿ•ก six thirty time late early schedule +๐Ÿ•– seven o clock time late early schedule +๐Ÿ•ข seven thirty time late early schedule +๐Ÿ•— eight o clock time late early schedule +๐Ÿ•ฃ eight thirty time late early schedule +๐Ÿ•˜ nine o clock time late early schedule +๐Ÿ•ค nine thirty time late early schedule +๐Ÿ•™ ten o clock time late early schedule +๐Ÿ•ฅ ten thirty time late early schedule +๐Ÿ•š eleven o clock time late early schedule +๐Ÿ•ฆ eleven thirty time late early schedule +๐ŸŒ‘ new moon nature twilight planet space night evening sleep +๐ŸŒ’ waxing crescent moon nature twilight planet space night evening sleep +๐ŸŒ“ first quarter moon nature twilight planet space night evening sleep +๐ŸŒ” waxing gibbous moon nature night sky gray twilight planet space evening sleep +๐ŸŒ• full moon nature yellow twilight planet space night evening sleep +๐ŸŒ– waning gibbous moon nature twilight planet space night evening sleep waxing gibbous moon +๐ŸŒ— last quarter moon nature twilight planet space night evening sleep +๐ŸŒ˜ waning crescent moon nature twilight planet space night evening sleep +๐ŸŒ™ crescent moon night sleep sky evening magic +๐ŸŒš new moon face nature twilight planet space night evening sleep +๐ŸŒ› first quarter moon face nature twilight planet space night evening sleep +๐ŸŒœ last quarter moon face nature twilight planet space night evening sleep +๐ŸŒก๏ธ thermometer weather temperature hot cold +โ˜€๏ธ sun weather nature brightness summer beach spring +๐ŸŒ full moon face nature twilight planet space night evening sleep +๐ŸŒž sun with face nature morning sky +๐Ÿช ringed planet outerspace +โญ star night yellow +๐ŸŒŸ glowing star night sparkle awesome good magic +๐ŸŒ  shooting star night photo +๐ŸŒŒ milky way photo space stars +โ˜๏ธ cloud weather sky +โ›… sun behind cloud weather nature cloudy morning fall spring +โ›ˆ๏ธ cloud with lightning and rain weather lightning +๐ŸŒค๏ธ sun behind small cloud weather +๐ŸŒฅ๏ธ sun behind large cloud weather +๐ŸŒฆ๏ธ sun behind rain cloud weather +๐ŸŒง๏ธ cloud with rain weather +๐ŸŒจ๏ธ cloud with snow weather +๐ŸŒฉ๏ธ cloud with lightning weather thunder +๐ŸŒช๏ธ tornado weather cyclone twister +๐ŸŒซ๏ธ fog weather +๐ŸŒฌ๏ธ wind face gust air +๐ŸŒ€ cyclone weather swirl blue cloud vortex spiral whirlpool spin tornado hurricane typhoon +๐ŸŒˆ rainbow nature happy unicorn face photo sky spring +๐ŸŒ‚ closed umbrella weather rain drizzle +โ˜‚๏ธ umbrella weather spring +โ˜” umbrella with rain drops rainy weather spring +โ›ฑ๏ธ umbrella on ground weather summer +โšก high voltage thunder weather lightning bolt fast +โ„๏ธ snowflake winter season cold weather christmas xmas +โ˜ƒ๏ธ snowman winter season cold weather christmas xmas frozen +โ›„ snowman without snow winter season cold weather christmas xmas frozen without snow +โ˜„๏ธ comet space +๐Ÿ”ฅ fire hot cook flame +๐Ÿ’ง droplet water drip faucet spring +๐ŸŒŠ water wave sea water wave nature tsunami disaster +๐ŸŽƒ jack o lantern halloween light pumpkin creepy fall +๐ŸŽ„ christmas tree festival vacation december xmas celebration +๐ŸŽ† fireworks photo festival carnival congratulations +๐ŸŽ‡ sparkler stars night shine +๐Ÿงจ firecracker dynamite boom explode explosion explosive +โœจ sparkles stars shine shiny cool awesome good magic +๐ŸŽˆ balloon party celebration birthday circus +๐ŸŽ‰ party popper party congratulations birthday magic circus celebration tada +๐ŸŽŠ confetti ball festival party birthday circus +๐ŸŽ‹ tanabata tree plant nature branch summer +๐ŸŽ pine decoration plant nature vegetable panda pine decoration +๐ŸŽŽ japanese dolls japanese toy kimono +๐ŸŽ carp streamer fish japanese koinobori carp banner +๐ŸŽ wind chime nature ding spring bell +๐ŸŽ‘ moon viewing ceremony photo japan asia tsukimi +๐Ÿงง red envelope gift +๐ŸŽ€ ribbon decoration pink girl bowtie +๐ŸŽ wrapped gift present birthday christmas xmas +๐ŸŽ—๏ธ reminder ribbon sports cause support awareness +๐ŸŽŸ๏ธ admission tickets sports concert entrance +๐ŸŽซ ticket event concert pass +๐ŸŽ–๏ธ military medal award winning army +๐Ÿ† trophy win award contest place ftw ceremony +๐Ÿ… sports medal award winning +๐Ÿฅ‡ 1st place medal award winning first +๐Ÿฅˆ 2nd place medal award second +๐Ÿฅ‰ 3rd place medal award third +โšฝ soccer ball sports football +โšพ baseball sports balls +๐ŸฅŽ softball sports balls +๐Ÿ€ basketball sports balls NBA +๐Ÿ volleyball sports balls +๐Ÿˆ american football sports balls NFL +๐Ÿ‰ rugby football sports team +๐ŸŽพ tennis sports balls green +๐Ÿฅ flying disc sports frisbee ultimate +๐ŸŽณ bowling sports fun play +๐Ÿ cricket game sports +๐Ÿ‘ field hockey sports +๐Ÿ’ ice hockey sports +๐Ÿฅ lacrosse sports ball stick +๐Ÿ“ ping pong sports pingpong +๐Ÿธ badminton sports +๐ŸฅŠ boxing glove sports fighting +๐Ÿฅ‹ martial arts uniform judo karate taekwondo +๐Ÿฅ… goal net sports +โ›ณ flag in hole sports business flag hole summer +โ›ธ๏ธ ice skate sports +๐ŸŽฃ fishing pole food hobby summer +๐Ÿคฟ diving mask sport ocean +๐ŸŽฝ running shirt play pageant +๐ŸŽฟ skis sports winter cold snow +๐Ÿ›ท sled sleigh luge toboggan +๐ŸฅŒ curling stone sports +๐ŸŽฏ direct hit game play bar target bullseye +๐Ÿช€ yo yo toy +๐Ÿช kite wind fly +๐ŸŽฑ pool 8 ball pool hobby game luck magic +๐Ÿ”ฎ crystal ball disco party magic circus fortune teller +๐Ÿงฟ nazar amulet bead charm +๐ŸŽฎ video game play console PS4 Wii GameCube controller +๐Ÿ•น๏ธ joystick game play +๐ŸŽฐ slot machine bet gamble vegas fruit machine luck casino +๐ŸŽฒ game die dice random tabletop play luck +๐Ÿงฉ puzzle piece interlocking puzzle piece +๐Ÿงธ teddy bear plush stuffed +โ™ ๏ธ spade suit poker cards suits magic +โ™ฅ๏ธ heart suit poker cards magic suits +โ™ฆ๏ธ diamond suit poker cards magic suits +โ™ฃ๏ธ club suit poker cards magic suits +โ™Ÿ๏ธ chess pawn expendable +๐Ÿƒ joker poker cards game play magic +๐Ÿ€„ mahjong red dragon game play chinese kanji +๐ŸŽด flower playing cards game sunset red +๐ŸŽญ performing arts acting theater drama +๐Ÿ–ผ๏ธ framed picture photography +๐ŸŽจ artist palette design paint draw colors +๐Ÿงต thread needle sewing spool string +๐Ÿงถ yarn ball crochet knit +๐Ÿ‘“ glasses fashion accessories eyesight nerdy dork geek +๐Ÿ•ถ๏ธ sunglasses face cool accessories +๐Ÿฅฝ goggles eyes protection safety +๐Ÿฅผ lab coat doctor experiment scientist chemist +๐Ÿฆบ safety vest protection +๐Ÿ‘” necktie shirt suitup formal fashion cloth business +๐Ÿ‘• t shirt fashion cloth casual shirt tee +๐Ÿ‘– jeans fashion shopping +๐Ÿงฃ scarf neck winter clothes +๐Ÿงค gloves hands winter clothes +๐Ÿงฅ coat jacket +๐Ÿงฆ socks stockings clothes +๐Ÿ‘— dress clothes fashion shopping +๐Ÿ‘˜ kimono dress fashion women female japanese +๐Ÿฅป sari dress +๐Ÿฉฑ one piece swimsuit fashion +๐Ÿฉฒ briefs clothing +๐Ÿฉณ shorts clothing +๐Ÿ‘™ bikini swimming female woman girl fashion beach summer +๐Ÿ‘š woman s clothes fashion shopping bags female +๐Ÿ‘› purse fashion accessories money sales shopping +๐Ÿ‘œ handbag fashion accessory accessories shopping +๐Ÿ‘ clutch bag bag accessories shopping +๐Ÿ›๏ธ shopping bags mall buy purchase +๐ŸŽ’ backpack student education bag backpack +๐Ÿ‘ž man s shoe fashion male +๐Ÿ‘Ÿ running shoe shoes sports sneakers +๐Ÿฅพ hiking boot backpacking camping hiking +๐Ÿฅฟ flat shoe ballet slip-on slipper +๐Ÿ‘  high heeled shoe fashion shoes female pumps stiletto +๐Ÿ‘ก woman s sandal shoes fashion flip flops +๐Ÿฉฐ ballet shoes dance +๐Ÿ‘ข woman s boot shoes fashion +๐Ÿ‘‘ crown king kod leader royalty lord +๐Ÿ‘’ woman s hat fashion accessories female lady spring +๐ŸŽฉ top hat magic gentleman classy circus +๐ŸŽ“ graduation cap school college degree university graduation cap hat legal learn education +๐Ÿงข billed cap cap baseball +โ›‘๏ธ rescue worker s helmet construction build +๐Ÿ“ฟ prayer beads dhikr religious +๐Ÿ’„ lipstick female girl fashion woman +๐Ÿ’ ring wedding propose marriage valentines diamond fashion jewelry gem engagement +๐Ÿ’Ž gem stone blue ruby diamond jewelry +๐Ÿ”‡ muted speaker sound volume silence quiet +๐Ÿ”ˆ speaker low volume sound volume silence broadcast +๐Ÿ”‰ speaker medium volume volume speaker broadcast +๐Ÿ”Š speaker high volume volume noise noisy speaker broadcast +๐Ÿ“ข loudspeaker volume sound +๐Ÿ“ฃ megaphone sound speaker volume +๐Ÿ“ฏ postal horn instrument music +๐Ÿ”” bell sound notification christmas xmas chime +๐Ÿ”• bell with slash sound volume mute quiet silent +๐ŸŽผ musical score treble clef compose +๐ŸŽต musical note score tone sound +๐ŸŽถ musical notes music score +๐ŸŽ™๏ธ studio microphone sing recording artist talkshow +๐ŸŽš๏ธ level slider scale +๐ŸŽ›๏ธ control knobs dial +๐ŸŽค microphone sound music PA sing talkshow +๐ŸŽง headphone music score gadgets +๐Ÿ“ป radio communication music podcast program +๐ŸŽท saxophone music instrument jazz blues +๐ŸŽธ guitar music instrument +๐ŸŽน musical keyboard piano instrument compose +๐ŸŽบ trumpet music brass +๐ŸŽป violin music instrument orchestra symphony +๐Ÿช• banjo music instructment +๐Ÿฅ drum music instrument drumsticks snare +๐Ÿ“ฑ mobile phone technology apple gadgets dial +๐Ÿ“ฒ mobile phone with arrow iphone incoming +โ˜Ž๏ธ telephone technology communication dial telephone +๐Ÿ“ž telephone receiver technology communication dial +๐Ÿ“Ÿ pager bbcall oldschool 90s +๐Ÿ“  fax machine communication technology +๐Ÿ”‹ battery power energy sustain +๐Ÿ”Œ electric plug charger power +๐Ÿ’ป laptop technology laptop screen display monitor +๐Ÿ–ฅ๏ธ desktop computer technology computing screen +๐Ÿ–จ๏ธ printer paper ink +โŒจ๏ธ keyboard technology computer type input text +๐Ÿ–ฑ๏ธ computer mouse click +๐Ÿ–ฒ๏ธ trackball technology trackpad +๐Ÿ’ฝ computer disk technology record data disk 90s +๐Ÿ’พ floppy disk oldschool technology save 90s 80s +๐Ÿ’ฟ optical disk technology dvd disk disc 90s +๐Ÿ“€ dvd cd disk disc +๐Ÿงฎ abacus calculation +๐ŸŽฅ movie camera film record +๐ŸŽž๏ธ film frames movie +๐Ÿ“ฝ๏ธ film projector video tape record movie +๐ŸŽฌ clapper board movie film record +๐Ÿ“บ television technology program oldschool show television +๐Ÿ“ท camera gadgets photography +๐Ÿ“ธ camera with flash photography gadgets +๐Ÿ“น video camera film record +๐Ÿ“ผ videocassette record video oldschool 90s 80s +๐Ÿ” magnifying glass tilted left search zoom find detective +๐Ÿ”Ž magnifying glass tilted right search zoom find detective +๐Ÿ•ฏ๏ธ candle fire wax +๐Ÿ’ก light bulb light electricity idea +๐Ÿ”ฆ flashlight dark camping sight night +๐Ÿฎ red paper lantern light paper halloween spooky +๐Ÿช” diya lamp lighting +๐Ÿ“” notebook with decorative cover classroom notes record paper study +๐Ÿ“• closed book read library knowledge textbook learn +๐Ÿ“– open book book read library knowledge literature learn study +๐Ÿ“— green book read library knowledge study +๐Ÿ“˜ blue book read library knowledge learn study +๐Ÿ“™ orange book read library knowledge textbook study +๐Ÿ“š books literature library study +๐Ÿ““ notebook stationery record notes paper study +๐Ÿ“’ ledger notes paper +๐Ÿ“ƒ page with curl documents office paper +๐Ÿ“œ scroll documents ancient history paper +๐Ÿ“„ page facing up documents office paper information +๐Ÿ“ฐ newspaper press headline +๐Ÿ—ž๏ธ rolled up newspaper press headline +๐Ÿ“‘ bookmark tabs favorite save order tidy +๐Ÿ”– bookmark favorite label save +๐Ÿท๏ธ label sale tag +๐Ÿ’ฐ money bag dollar payment coins sale +๐Ÿ’ด yen banknote money sales japanese dollar currency +๐Ÿ’ต dollar banknote money sales bill currency +๐Ÿ’ถ euro banknote money sales dollar currency +๐Ÿ’ท pound banknote british sterling money sales bills uk england currency +๐Ÿ’ธ money with wings dollar bills payment sale +๐Ÿ’ณ credit card money sales dollar bill payment shopping +๐Ÿงพ receipt accounting expenses +๐Ÿ’น chart increasing with yen green-square graph presentation stats +๐Ÿ’ฑ currency exchange money sales dollar travel +๐Ÿ’ฒ heavy dollar sign money sales payment currency buck +โœ‰๏ธ envelope letter postal inbox communication +๐Ÿ“ง e mail communication inbox +๐Ÿ“จ incoming envelope email inbox +๐Ÿ“ฉ envelope with arrow email communication +๐Ÿ“ค outbox tray inbox email +๐Ÿ“ฅ inbox tray email documents +๐Ÿ“ฆ package mail gift cardboard box moving +๐Ÿ“ซ closed mailbox with raised flag email inbox communication +๐Ÿ“ช closed mailbox with lowered flag email communication inbox +๐Ÿ“ฌ open mailbox with raised flag email inbox communication +๐Ÿ“ญ open mailbox with lowered flag email inbox +๐Ÿ“ฎ postbox email letter envelope +๐Ÿ—ณ๏ธ ballot box with ballot election vote +โœ๏ธ pencil stationery write paper writing school study +โœ’๏ธ black nib pen stationery writing write +๐Ÿ–‹๏ธ fountain pen stationery writing write +๐Ÿ–Š๏ธ pen stationery writing write +๐Ÿ–Œ๏ธ paintbrush drawing creativity art +๐Ÿ–๏ธ crayon drawing creativity +๐Ÿ“ memo write documents stationery pencil paper writing legal exam quiz test study compose +๐Ÿ’ผ briefcase business documents work law legal job career +๐Ÿ“ file folder documents business office +๐Ÿ“‚ open file folder documents load +๐Ÿ—‚๏ธ card index dividers organizing business stationery +๐Ÿ“… calendar calendar schedule +๐Ÿ“† tear off calendar schedule date planning +๐Ÿ—’๏ธ spiral notepad memo stationery +๐Ÿ—“๏ธ spiral calendar date schedule planning +๐Ÿ“‡ card index business stationery +๐Ÿ“ˆ chart increasing graph presentation stats recovery business economics money sales good success +๐Ÿ“‰ chart decreasing graph presentation stats recession business economics money sales bad failure +๐Ÿ“Š bar chart graph presentation stats +๐Ÿ“‹ clipboard stationery documents +๐Ÿ“Œ pushpin stationery mark here +๐Ÿ“ round pushpin stationery location map here +๐Ÿ“Ž paperclip documents stationery +๐Ÿ–‡๏ธ linked paperclips documents stationery +๐Ÿ“ straight ruler stationery calculate length math school drawing architect sketch +๐Ÿ“ triangular ruler stationery math architect sketch +โœ‚๏ธ scissors stationery cut +๐Ÿ—ƒ๏ธ card file box business stationery +๐Ÿ—„๏ธ file cabinet filing organizing +๐Ÿ—‘๏ธ wastebasket bin trash rubbish garbage toss +๐Ÿ”’ locked security password padlock +๐Ÿ”“ unlocked privacy security +๐Ÿ” locked with pen security secret +๐Ÿ” locked with key security privacy +๐Ÿ”‘ key lock door password +๐Ÿ—๏ธ old key lock door password +๐Ÿ”จ hammer tools build create +๐Ÿช“ axe tool chop cut +โ›๏ธ pick tools dig +โš’๏ธ hammer and pick tools build create +๐Ÿ› ๏ธ hammer and wrench tools build create +๐Ÿ—ก๏ธ dagger weapon +โš”๏ธ crossed swords weapon +๐Ÿ”ซ pistol violence weapon pistol revolver +๐Ÿน bow and arrow sports +๐Ÿ›ก๏ธ shield protection security +๐Ÿ”ง wrench tools diy ikea fix maintainer +๐Ÿ”ฉ nut and bolt handy tools fix +โš™๏ธ gear cog +๐Ÿ—œ๏ธ clamp tool +โš–๏ธ balance scale law fairness weight +๐Ÿฆฏ probing cane accessibility +๐Ÿ”— link rings url +โ›“๏ธ chains lock arrest +๐Ÿงฐ toolbox tools diy fix maintainer mechanic +๐Ÿงฒ magnet attraction magnetic +โš—๏ธ alembic distilling science experiment chemistry +๐Ÿงช test tube chemistry experiment lab science +๐Ÿงซ petri dish bacteria biology culture lab +๐Ÿงฌ dna biologist genetics life +๐Ÿ”ฌ microscope laboratory experiment zoomin science study +๐Ÿ”ญ telescope stars space zoom science astronomy +๐Ÿ“ก satellite antenna communication future radio space +๐Ÿ’‰ syringe health hospital drugs blood medicine needle doctor nurse +๐Ÿฉธ drop of blood period hurt harm wound +๐Ÿ’Š pill health medicine doctor pharmacy drug +๐Ÿฉน adhesive bandage heal +๐Ÿฉบ stethoscope health +๐Ÿšช door house entry exit +๐Ÿ›๏ธ bed sleep rest +๐Ÿ›‹๏ธ couch and lamp read chill +๐Ÿช‘ chair sit furniture +๐Ÿšฝ toilet restroom wc washroom bathroom potty +๐Ÿšฟ shower clean water bathroom +๐Ÿ› bathtub clean shower bathroom +๐Ÿช’ razor cut +๐Ÿงด lotion bottle moisturizer sunscreen +๐Ÿงท safety pin diaper +๐Ÿงน broom cleaning sweeping witch +๐Ÿงบ basket laundry +๐Ÿงป roll of paper roll +๐Ÿงผ soap bar bathing cleaning lather +๐Ÿงฝ sponge absorbing cleaning porous +๐Ÿงฏ fire extinguisher quench +๐Ÿ›’ shopping cart trolley +๐Ÿšฌ cigarette kills tobacco cigarette joint smoke +โšฐ๏ธ coffin vampire dead die death rip graveyard cemetery casket funeral box +โšฑ๏ธ funeral urn dead die death rip ashes +๐Ÿ—ฟ moai rock easter island moai +๐Ÿง atm sign money sales cash blue-square payment bank +๐Ÿšฎ litter in bin sign blue-square sign human info +๐Ÿšฐ potable water blue-square liquid restroom cleaning faucet +โ™ฟ wheelchair symbol blue-square disabled accessibility +๐Ÿšน men s room toilet restroom wc blue-square gender male +๐Ÿšบ women s room purple-square woman female toilet loo restroom gender +๐Ÿšป restroom blue-square toilet refresh wc gender +๐Ÿšผ baby symbol orange-square child +๐Ÿšพ water closet toilet restroom blue-square +๐Ÿ›‚ passport control custom blue-square +๐Ÿ›ƒ customs passport border blue-square +๐Ÿ›„ baggage claim blue-square airport transport +๐Ÿ›… left luggage blue-square travel +โš ๏ธ warning exclamation wip alert error problem issue +๐Ÿšธ children crossing school warning danger sign driving yellow-diamond +โ›” no entry limit security privacy bad denied stop circle +๐Ÿšซ prohibited forbid stop limit denied disallow circle +๐Ÿšณ no bicycles cyclist prohibited circle +๐Ÿšญ no smoking cigarette blue-square smell smoke +๐Ÿšฏ no littering trash bin garbage circle +๐Ÿšฑ non potable water drink faucet tap circle +๐Ÿšท no pedestrians rules crossing walking circle +๐Ÿ“ต no mobile phones iphone mute circle +๐Ÿ”ž no one under eighteen 18 drink pub night minor circle +โ˜ข๏ธ radioactive nuclear danger +โ˜ฃ๏ธ biohazard danger +โฌ†๏ธ up arrow blue-square continue top direction +โ†—๏ธ up right arrow blue-square point direction diagonal northeast +โžก๏ธ right arrow blue-square next +โ†˜๏ธ down right arrow blue-square direction diagonal southeast +โฌ‡๏ธ down arrow blue-square direction bottom +โ†™๏ธ down left arrow blue-square direction diagonal southwest +โฌ…๏ธ left arrow blue-square previous back +โ†–๏ธ up left arrow blue-square point direction diagonal northwest +โ†•๏ธ up down arrow blue-square direction way vertical +โ†”๏ธ left right arrow shape direction horizontal sideways +โ†ฉ๏ธ right arrow curving left back return blue-square undo enter +โ†ช๏ธ left arrow curving right blue-square return rotate direction +โคด๏ธ right arrow curving up blue-square direction top +โคต๏ธ right arrow curving down blue-square direction bottom +๐Ÿ”ƒ clockwise vertical arrows sync cycle round repeat +๐Ÿ”„ counterclockwise arrows button blue-square sync cycle +๐Ÿ”™ back arrow arrow words return +๐Ÿ”š end arrow words arrow +๐Ÿ”› on arrow arrow words +๐Ÿ”œ soon arrow arrow words +๐Ÿ” top arrow words blue-square +๐Ÿ› place of worship religion church temple prayer +โš›๏ธ atom symbol science physics chemistry +๐Ÿ•‰๏ธ om hinduism buddhism sikhism jainism +โœก๏ธ star of david judaism +โ˜ธ๏ธ wheel of dharma hinduism buddhism sikhism jainism +โ˜ฏ๏ธ yin yang balance +โœ๏ธ latin cross christianity +โ˜ฆ๏ธ orthodox cross suppedaneum religion +โ˜ช๏ธ star and crescent islam +โ˜ฎ๏ธ peace symbol hippie +๐Ÿ•Ž menorah hanukkah candles jewish +๐Ÿ”ฏ dotted six pointed star purple-square religion jewish hexagram +โ™ˆ aries sign purple-square zodiac astrology +โ™‰ taurus purple-square sign zodiac astrology +โ™Š gemini sign zodiac purple-square astrology +โ™‹ cancer sign zodiac purple-square astrology +โ™Œ leo sign purple-square zodiac astrology +โ™ virgo sign zodiac purple-square astrology +โ™Ž libra sign purple-square zodiac astrology +โ™ scorpio sign zodiac purple-square astrology scorpio +โ™ sagittarius sign zodiac purple-square astrology +โ™‘ capricorn sign zodiac purple-square astrology +โ™’ aquarius sign purple-square zodiac astrology +โ™“ pisces purple-square sign zodiac astrology +โ›Ž ophiuchus sign purple-square constellation astrology +๐Ÿ”€ shuffle tracks button blue-square shuffle music random +๐Ÿ” repeat button loop record +๐Ÿ”‚ repeat single button blue-square loop +โ–ถ๏ธ play button blue-square right direction play +โฉ fast forward button blue-square play speed continue +โญ๏ธ next track button forward next blue-square +โฏ๏ธ play or pause button blue-square play pause +โ—€๏ธ reverse button blue-square left direction +โช fast reverse button play blue-square +โฎ๏ธ last track button backward +๐Ÿ”ผ upwards button blue-square triangle direction point forward top +โซ fast up button blue-square direction top +๐Ÿ”ฝ downwards button blue-square direction bottom +โฌ fast down button blue-square direction bottom +โธ๏ธ pause button pause blue-square +โน๏ธ stop button blue-square +โบ๏ธ record button blue-square +โ๏ธ eject button blue-square +๐ŸŽฆ cinema blue-square record film movie curtain stage theater +๐Ÿ”… dim button sun afternoon warm summer +๐Ÿ”† bright button sun light +๐Ÿ“ถ antenna bars blue-square reception phone internet connection wifi bluetooth bars +๐Ÿ“ณ vibration mode orange-square phone +๐Ÿ“ด mobile phone off mute orange-square silence quiet +โ™€๏ธ female sign woman women lady girl +โ™‚๏ธ male sign man boy men +โš•๏ธ medical symbol health hospital +โ™พ๏ธ infinity forever +โ™ป๏ธ recycling symbol arrow environment garbage trash +โšœ๏ธ fleur de lis decorative scout +๐Ÿ”ฑ trident emblem weapon spear +๐Ÿ“› name badge fire forbid +๐Ÿ”ฐ japanese symbol for beginner badge shield +โญ• hollow red circle circle round +โœ… check mark button green-square ok agree vote election answer tick +โ˜‘๏ธ check box with check ok agree confirm black-square vote election yes tick +โœ”๏ธ check mark ok nike answer yes tick +โœ–๏ธ multiplication sign math calculation +โŒ cross mark no delete remove cancel red +โŽ cross mark button x green-square no deny +โž• plus sign math calculation addition more increase +โž– minus sign math calculation subtract less +โž— division sign divide math calculation +โžฐ curly loop scribble draw shape squiggle +โžฟ double curly loop tape cassette +ใ€ฝ๏ธ part alternation mark graph presentation stats business economics bad +โœณ๏ธ eight spoked asterisk star sparkle green-square +โœด๏ธ eight pointed star orange-square shape polygon +โ‡๏ธ sparkle stars green-square awesome good fireworks +โ€ผ๏ธ double exclamation mark exclamation surprise +โ‰๏ธ exclamation question mark wat punctuation surprise +โ“ question mark doubt confused +โ” white question mark doubts gray huh confused +โ• white exclamation mark surprise punctuation gray wow warning +โ— exclamation mark heavy exclamation mark danger surprise punctuation wow warning +ใ€ฐ๏ธ wavy dash draw line moustache mustache squiggle scribble +ยฉ๏ธ copyright ip license circle law legal +ยฎ๏ธ registered alphabet circle +โ„ข๏ธ trade mark trademark brand law legal +#๏ธโƒฃ keycap symbol blue-square twitter +*๏ธโƒฃ keycap star keycap +0๏ธโƒฃ keycap 0 0 numbers blue-square null +1๏ธโƒฃ keycap 1 blue-square numbers 1 +2๏ธโƒฃ keycap 2 numbers 2 prime blue-square +3๏ธโƒฃ keycap 3 3 numbers prime blue-square +4๏ธโƒฃ keycap 4 4 numbers blue-square +5๏ธโƒฃ keycap 5 5 numbers blue-square prime +6๏ธโƒฃ keycap 6 6 numbers blue-square +7๏ธโƒฃ keycap 7 7 numbers blue-square prime +8๏ธโƒฃ keycap 8 8 blue-square numbers +9๏ธโƒฃ keycap 9 blue-square numbers 9 +๐Ÿ”Ÿ keycap 10 numbers 10 blue-square +๐Ÿ”  input latin uppercase alphabet words blue-square +๐Ÿ”ก input latin lowercase blue-square alphabet +๐Ÿ”ข input numbers numbers blue-square +๐Ÿ”ฃ input symbols blue-square music note ampersand percent glyphs characters +๐Ÿ”ค input latin letters blue-square alphabet +๐Ÿ…ฐ๏ธ a button red-square alphabet letter +๐Ÿ†Ž ab button red-square alphabet +๐Ÿ…ฑ๏ธ b button red-square alphabet letter +๐Ÿ†‘ cl button alphabet words red-square +๐Ÿ†’ cool button words blue-square +๐Ÿ†“ free button blue-square words +โ„น๏ธ information blue-square alphabet letter +๐Ÿ†” id button purple-square words +โ“‚๏ธ circled m alphabet blue-circle letter +๐Ÿ†• new button blue-square words start +๐Ÿ†– ng button blue-square words shape icon +๐Ÿ…พ๏ธ o button alphabet red-square letter +๐Ÿ†— ok button good agree yes blue-square +๐Ÿ…ฟ๏ธ p button cars blue-square alphabet letter +๐Ÿ†˜ sos button help red-square words emergency 911 +๐Ÿ†™ up button blue-square above high +๐Ÿ†š vs button words orange-square +๐Ÿˆ japanese here button blue-square here katakana japanese destination +๐Ÿˆ‚๏ธ japanese service charge button japanese blue-square katakana +๐Ÿˆท๏ธ japanese monthly amount button chinese month moon japanese orange-square kanji +๐Ÿˆถ japanese not free of charge button orange-square chinese have kanji +๐Ÿˆฏ japanese reserved button chinese point green-square kanji +๐Ÿ‰ japanese bargain button chinese kanji obtain get circle +๐Ÿˆน japanese discount button cut divide chinese kanji pink-square +๐Ÿˆš japanese free of charge button nothing chinese kanji japanese orange-square +๐Ÿˆฒ japanese prohibited button kanji japanese chinese forbidden limit restricted red-square +๐Ÿ‰‘ japanese acceptable button ok good chinese kanji agree yes orange-circle +๐Ÿˆธ japanese application button chinese japanese kanji orange-square +๐Ÿˆด japanese passing grade button japanese chinese join kanji red-square +๐Ÿˆณ japanese vacancy button kanji japanese chinese empty sky blue-square +ใŠ—๏ธ japanese congratulations button chinese kanji japanese red-circle +ใŠ™๏ธ japanese secret button privacy chinese sshh kanji red-circle +๐Ÿˆบ japanese open for business button japanese opening hours orange-square +๐Ÿˆต japanese no vacancy button full chinese japanese red-square kanji +๐Ÿ”ด red circle shape error danger +๐ŸŸ  orange circle round +๐ŸŸก yellow circle round +๐ŸŸข green circle round +๐Ÿ”ต blue circle shape icon button +๐ŸŸฃ purple circle round +๐ŸŸค brown circle round +โšซ black circle shape button round +โšช white circle shape round +๐ŸŸฅ red square +๐ŸŸง orange square +๐ŸŸจ yellow square +๐ŸŸฉ green square +๐ŸŸฆ blue square +๐ŸŸช purple square +๐ŸŸซ brown square +โฌ› black large square shape icon button +โฌœ white large square shape icon stone button +โ—ผ๏ธ black medium square shape button icon +โ—ป๏ธ white medium square shape stone icon +โ—พ black medium small square icon shape button +โ—ฝ white medium small square shape stone icon button +โ–ช๏ธ black small square shape icon +โ–ซ๏ธ white small square shape icon +๐Ÿ”ถ large orange diamond shape jewel gem +๐Ÿ”ท large blue diamond shape jewel gem +๐Ÿ”ธ small orange diamond shape jewel gem +๐Ÿ”น small blue diamond shape jewel gem +๐Ÿ”บ red triangle pointed up shape direction up top +๐Ÿ”ป red triangle pointed down shape direction bottom +๐Ÿ’  diamond with a dot jewel blue gem crystal fancy +๐Ÿ”˜ radio button input old music circle +๐Ÿ”ณ white square button shape input +๐Ÿ”ฒ black square button shape input frame +๐Ÿ chequered flag contest finishline race gokart +๐Ÿšฉ triangular flag mark milestone place +๐ŸŽŒ crossed flags japanese nation country border +๐Ÿด black flag pirate +๐Ÿณ๏ธ white flag losing loser lost surrender give up fail +๐Ÿณ๏ธโ€๐ŸŒˆ rainbow flag flag rainbow pride gay lgbt glbt queer homosexual lesbian bisexual transgender +๐Ÿดโ€โ˜ ๏ธ pirate flag skull crossbones flag banner +๐Ÿ‡ฆ๐Ÿ‡จ flag ascension island +๐Ÿ‡ฆ๐Ÿ‡ฉ flag andorra ad flag nation country banner andorra +๐Ÿ‡ฆ๐Ÿ‡ช flag united arab emirates united arab emirates flag nation country banner united arab emirates +๐Ÿ‡ฆ๐Ÿ‡ซ flag afghanistan af flag nation country banner afghanistan +๐Ÿ‡ฆ๐Ÿ‡ฌ flag antigua barbuda antigua barbuda flag nation country banner antigua barbuda +๐Ÿ‡ฆ๐Ÿ‡ฎ flag anguilla ai flag nation country banner anguilla +๐Ÿ‡ฆ๐Ÿ‡ฑ flag albania al flag nation country banner albania +๐Ÿ‡ฆ๐Ÿ‡ฒ flag armenia am flag nation country banner armenia +๐Ÿ‡ฆ๐Ÿ‡ด flag angola ao flag nation country banner angola +๐Ÿ‡ฆ๐Ÿ‡ถ flag antarctica aq flag nation country banner antarctica +๐Ÿ‡ฆ๐Ÿ‡ท flag argentina ar flag nation country banner argentina +๐Ÿ‡ฆ๐Ÿ‡ธ flag american samoa american ws flag nation country banner american samoa +๐Ÿ‡ฆ๐Ÿ‡น flag austria at flag nation country banner austria +๐Ÿ‡ฆ๐Ÿ‡บ flag australia au flag nation country banner australia +๐Ÿ‡ฆ๐Ÿ‡ผ flag aruba aw flag nation country banner aruba +๐Ÿ‡ฆ๐Ÿ‡ฝ flag aland islands ร…land islands flag nation country banner aland islands +๐Ÿ‡ฆ๐Ÿ‡ฟ flag azerbaijan az flag nation country banner azerbaijan +๐Ÿ‡ง๐Ÿ‡ฆ flag bosnia herzegovina bosnia herzegovina flag nation country banner bosnia herzegovina +๐Ÿ‡ง๐Ÿ‡ง flag barbados bb flag nation country banner barbados +๐Ÿ‡ง๐Ÿ‡ฉ flag bangladesh bd flag nation country banner bangladesh +๐Ÿ‡ง๐Ÿ‡ช flag belgium be flag nation country banner belgium +๐Ÿ‡ง๐Ÿ‡ซ flag burkina faso burkina faso flag nation country banner burkina faso +๐Ÿ‡ง๐Ÿ‡ฌ flag bulgaria bg flag nation country banner bulgaria +๐Ÿ‡ง๐Ÿ‡ญ flag bahrain bh flag nation country banner bahrain +๐Ÿ‡ง๐Ÿ‡ฎ flag burundi bi flag nation country banner burundi +๐Ÿ‡ง๐Ÿ‡ฏ flag benin bj flag nation country banner benin +๐Ÿ‡ง๐Ÿ‡ฑ flag st barthelemy saint barthรฉlemy flag nation country banner st barthelemy +๐Ÿ‡ง๐Ÿ‡ฒ flag bermuda bm flag nation country banner bermuda +๐Ÿ‡ง๐Ÿ‡ณ flag brunei bn darussalam flag nation country banner brunei +๐Ÿ‡ง๐Ÿ‡ด flag bolivia bo flag nation country banner bolivia +๐Ÿ‡ง๐Ÿ‡ถ flag caribbean netherlands bonaire flag nation country banner caribbean netherlands +๐Ÿ‡ง๐Ÿ‡ท flag brazil br flag nation country banner brazil +๐Ÿ‡ง๐Ÿ‡ธ flag bahamas bs flag nation country banner bahamas +๐Ÿ‡ง๐Ÿ‡น flag bhutan bt flag nation country banner bhutan +๐Ÿ‡ง๐Ÿ‡ป flag bouvet island norway +๐Ÿ‡ง๐Ÿ‡ผ flag botswana bw flag nation country banner botswana +๐Ÿ‡ง๐Ÿ‡พ flag belarus by flag nation country banner belarus +๐Ÿ‡ง๐Ÿ‡ฟ flag belize bz flag nation country banner belize +๐Ÿ‡จ๐Ÿ‡ฆ flag canada ca flag nation country banner canada +๐Ÿ‡จ๐Ÿ‡จ flag cocos islands cocos keeling islands flag nation country banner cocos islands +๐Ÿ‡จ๐Ÿ‡ฉ flag congo kinshasa congo democratic republic flag nation country banner congo kinshasa +๐Ÿ‡จ๐Ÿ‡ซ flag central african republic central african republic flag nation country banner central african republic +๐Ÿ‡จ๐Ÿ‡ฌ flag congo brazzaville congo flag nation country banner congo brazzaville +๐Ÿ‡จ๐Ÿ‡ญ flag switzerland ch flag nation country banner switzerland +๐Ÿ‡จ๐Ÿ‡ฎ flag cote d ivoire ivory coast flag nation country banner cote d ivoire +๐Ÿ‡จ๐Ÿ‡ฐ flag cook islands cook islands flag nation country banner cook islands +๐Ÿ‡จ๐Ÿ‡ฑ flag chile flag nation country banner chile +๐Ÿ‡จ๐Ÿ‡ฒ flag cameroon cm flag nation country banner cameroon +๐Ÿ‡จ๐Ÿ‡ณ flag china china chinese prc flag country nation banner china +๐Ÿ‡จ๐Ÿ‡ด flag colombia co flag nation country banner colombia +๐Ÿ‡จ๐Ÿ‡ต flag clipperton island +๐Ÿ‡จ๐Ÿ‡ท flag costa rica costa rica flag nation country banner costa rica +๐Ÿ‡จ๐Ÿ‡บ flag cuba cu flag nation country banner cuba +๐Ÿ‡จ๐Ÿ‡ป flag cape verde cabo verde flag nation country banner cape verde +๐Ÿ‡จ๐Ÿ‡ผ flag curacao curaรงao flag nation country banner curacao +๐Ÿ‡จ๐Ÿ‡ฝ flag christmas island christmas island flag nation country banner christmas island +๐Ÿ‡จ๐Ÿ‡พ flag cyprus cy flag nation country banner cyprus +๐Ÿ‡จ๐Ÿ‡ฟ flag czechia cz flag nation country banner czechia +๐Ÿ‡ฉ๐Ÿ‡ช flag germany german nation flag country banner germany +๐Ÿ‡ฉ๐Ÿ‡ฌ flag diego garcia +๐Ÿ‡ฉ๐Ÿ‡ฏ flag djibouti dj flag nation country banner djibouti +๐Ÿ‡ฉ๐Ÿ‡ฐ flag denmark dk flag nation country banner denmark +๐Ÿ‡ฉ๐Ÿ‡ฒ flag dominica dm flag nation country banner dominica +๐Ÿ‡ฉ๐Ÿ‡ด flag dominican republic dominican republic flag nation country banner dominican republic +๐Ÿ‡ฉ๐Ÿ‡ฟ flag algeria dz flag nation country banner algeria +๐Ÿ‡ช๐Ÿ‡ฆ flag ceuta melilla +๐Ÿ‡ช๐Ÿ‡จ flag ecuador ec flag nation country banner ecuador +๐Ÿ‡ช๐Ÿ‡ช flag estonia ee flag nation country banner estonia +๐Ÿ‡ช๐Ÿ‡ฌ flag egypt eg flag nation country banner egypt +๐Ÿ‡ช๐Ÿ‡ญ flag western sahara western sahara flag nation country banner western sahara +๐Ÿ‡ช๐Ÿ‡ท flag eritrea er flag nation country banner eritrea +๐Ÿ‡ช๐Ÿ‡ธ flag spain spain flag nation country banner spain +๐Ÿ‡ช๐Ÿ‡น flag ethiopia et flag nation country banner ethiopia +๐Ÿ‡ช๐Ÿ‡บ flag european union european union flag banner +๐Ÿ‡ซ๐Ÿ‡ฎ flag finland fi flag nation country banner finland +๐Ÿ‡ซ๐Ÿ‡ฏ flag fiji fj flag nation country banner fiji +๐Ÿ‡ซ๐Ÿ‡ฐ flag falkland islands falkland islands malvinas flag nation country banner falkland islands +๐Ÿ‡ซ๐Ÿ‡ฒ flag micronesia micronesia federated states flag nation country banner micronesia +๐Ÿ‡ซ๐Ÿ‡ด flag faroe islands faroe islands flag nation country banner faroe islands +๐Ÿ‡ซ๐Ÿ‡ท flag france banner flag nation france french country france +๐Ÿ‡ฌ๐Ÿ‡ฆ flag gabon ga flag nation country banner gabon +๐Ÿ‡ฌ๐Ÿ‡ง flag united kingdom united kingdom great britain northern ireland flag nation country banner british UK english england union jack united kingdom +๐Ÿ‡ฌ๐Ÿ‡ฉ flag grenada gd flag nation country banner grenada +๐Ÿ‡ฌ๐Ÿ‡ช flag georgia ge flag nation country banner georgia +๐Ÿ‡ฌ๐Ÿ‡ซ flag french guiana french guiana flag nation country banner french guiana +๐Ÿ‡ฌ๐Ÿ‡ฌ flag guernsey gg flag nation country banner guernsey +๐Ÿ‡ฌ๐Ÿ‡ญ flag ghana gh flag nation country banner ghana +๐Ÿ‡ฌ๐Ÿ‡ฎ flag gibraltar gi flag nation country banner gibraltar +๐Ÿ‡ฌ๐Ÿ‡ฑ flag greenland gl flag nation country banner greenland +๐Ÿ‡ฌ๐Ÿ‡ฒ flag gambia gm flag nation country banner gambia +๐Ÿ‡ฌ๐Ÿ‡ณ flag guinea gn flag nation country banner guinea +๐Ÿ‡ฌ๐Ÿ‡ต flag guadeloupe gp flag nation country banner guadeloupe +๐Ÿ‡ฌ๐Ÿ‡ถ flag equatorial guinea equatorial gn flag nation country banner equatorial guinea +๐Ÿ‡ฌ๐Ÿ‡ท flag greece gr flag nation country banner greece +๐Ÿ‡ฌ๐Ÿ‡ธ flag south georgia south sandwich islands south georgia sandwich islands flag nation country banner south georgia south sandwich islands +๐Ÿ‡ฌ๐Ÿ‡น flag guatemala gt flag nation country banner guatemala +๐Ÿ‡ฌ๐Ÿ‡บ flag guam gu flag nation country banner guam +๐Ÿ‡ฌ๐Ÿ‡ผ flag guinea bissau gw bissau flag nation country banner guinea bissau +๐Ÿ‡ฌ๐Ÿ‡พ flag guyana gy flag nation country banner guyana +๐Ÿ‡ญ๐Ÿ‡ฐ flag hong kong sar china hong kong flag nation country banner hong kong sar china +๐Ÿ‡ญ๐Ÿ‡ฒ flag heard mcdonald islands +๐Ÿ‡ญ๐Ÿ‡ณ flag honduras hn flag nation country banner honduras +๐Ÿ‡ญ๐Ÿ‡ท flag croatia hr flag nation country banner croatia +๐Ÿ‡ญ๐Ÿ‡น flag haiti ht flag nation country banner haiti +๐Ÿ‡ญ๐Ÿ‡บ flag hungary hu flag nation country banner hungary +๐Ÿ‡ฎ๐Ÿ‡จ flag canary islands canary islands flag nation country banner canary islands +๐Ÿ‡ฎ๐Ÿ‡ฉ flag indonesia flag nation country banner indonesia +๐Ÿ‡ฎ๐Ÿ‡ช flag ireland ie flag nation country banner ireland +๐Ÿ‡ฎ๐Ÿ‡ฑ flag israel il flag nation country banner israel +๐Ÿ‡ฎ๐Ÿ‡ฒ flag isle of man isle man flag nation country banner isle of man +๐Ÿ‡ฎ๐Ÿ‡ณ flag india in flag nation country banner india +๐Ÿ‡ฎ๐Ÿ‡ด flag british indian ocean territory british indian ocean territory flag nation country banner british indian ocean territory +๐Ÿ‡ฎ๐Ÿ‡ถ flag iraq iq flag nation country banner iraq +๐Ÿ‡ฎ๐Ÿ‡ท flag iran iran islamic republic flag nation country banner iran +๐Ÿ‡ฎ๐Ÿ‡ธ flag iceland is flag nation country banner iceland +๐Ÿ‡ฎ๐Ÿ‡น flag italy italy flag nation country banner italy +๐Ÿ‡ฏ๐Ÿ‡ช flag jersey je flag nation country banner jersey +๐Ÿ‡ฏ๐Ÿ‡ฒ flag jamaica jm flag nation country banner jamaica +๐Ÿ‡ฏ๐Ÿ‡ด flag jordan jo flag nation country banner jordan +๐Ÿ‡ฏ๐Ÿ‡ต flag japan japanese nation flag country banner japan +๐Ÿ‡ฐ๐Ÿ‡ช flag kenya ke flag nation country banner kenya +๐Ÿ‡ฐ๐Ÿ‡ฌ flag kyrgyzstan kg flag nation country banner kyrgyzstan +๐Ÿ‡ฐ๐Ÿ‡ญ flag cambodia kh flag nation country banner cambodia +๐Ÿ‡ฐ๐Ÿ‡ฎ flag kiribati ki flag nation country banner kiribati +๐Ÿ‡ฐ๐Ÿ‡ฒ flag comoros km flag nation country banner comoros +๐Ÿ‡ฐ๐Ÿ‡ณ flag st kitts nevis saint kitts nevis flag nation country banner st kitts nevis +๐Ÿ‡ฐ๐Ÿ‡ต flag north korea north korea nation flag country banner north korea +๐Ÿ‡ฐ๐Ÿ‡ท flag south korea south korea nation flag country banner south korea +๐Ÿ‡ฐ๐Ÿ‡ผ flag kuwait kw flag nation country banner kuwait +๐Ÿ‡ฐ๐Ÿ‡พ flag cayman islands cayman islands flag nation country banner cayman islands +๐Ÿ‡ฐ๐Ÿ‡ฟ flag kazakhstan kz flag nation country banner kazakhstan +๐Ÿ‡ฑ๐Ÿ‡ฆ flag laos lao democratic republic flag nation country banner laos +๐Ÿ‡ฑ๐Ÿ‡ง flag lebanon lb flag nation country banner lebanon +๐Ÿ‡ฑ๐Ÿ‡จ flag st lucia saint lucia flag nation country banner st lucia +๐Ÿ‡ฑ๐Ÿ‡ฎ flag liechtenstein li flag nation country banner liechtenstein +๐Ÿ‡ฑ๐Ÿ‡ฐ flag sri lanka sri lanka flag nation country banner sri lanka +๐Ÿ‡ฑ๐Ÿ‡ท flag liberia lr flag nation country banner liberia +๐Ÿ‡ฑ๐Ÿ‡ธ flag lesotho ls flag nation country banner lesotho +๐Ÿ‡ฑ๐Ÿ‡น flag lithuania lt flag nation country banner lithuania +๐Ÿ‡ฑ๐Ÿ‡บ flag luxembourg lu flag nation country banner luxembourg +๐Ÿ‡ฑ๐Ÿ‡ป flag latvia lv flag nation country banner latvia +๐Ÿ‡ฑ๐Ÿ‡พ flag libya ly flag nation country banner libya +๐Ÿ‡ฒ๐Ÿ‡ฆ flag morocco ma flag nation country banner morocco +๐Ÿ‡ฒ๐Ÿ‡จ flag monaco mc flag nation country banner monaco +๐Ÿ‡ฒ๐Ÿ‡ฉ flag moldova moldova republic flag nation country banner moldova +๐Ÿ‡ฒ๐Ÿ‡ช flag montenegro me flag nation country banner montenegro +๐Ÿ‡ฒ๐Ÿ‡ซ flag st martin +๐Ÿ‡ฒ๐Ÿ‡ฌ flag madagascar mg flag nation country banner madagascar +๐Ÿ‡ฒ๐Ÿ‡ญ flag marshall islands marshall islands flag nation country banner marshall islands +๐Ÿ‡ฒ๐Ÿ‡ฐ flag north macedonia macedonia flag nation country banner north macedonia +๐Ÿ‡ฒ๐Ÿ‡ฑ flag mali ml flag nation country banner mali +๐Ÿ‡ฒ๐Ÿ‡ฒ flag myanmar mm flag nation country banner myanmar +๐Ÿ‡ฒ๐Ÿ‡ณ flag mongolia mn flag nation country banner mongolia +๐Ÿ‡ฒ๐Ÿ‡ด flag macao sar china macao flag nation country banner macao sar china +๐Ÿ‡ฒ๐Ÿ‡ต flag northern mariana islands northern mariana islands flag nation country banner northern mariana islands +๐Ÿ‡ฒ๐Ÿ‡ถ flag martinique mq flag nation country banner martinique +๐Ÿ‡ฒ๐Ÿ‡ท flag mauritania mr flag nation country banner mauritania +๐Ÿ‡ฒ๐Ÿ‡ธ flag montserrat ms flag nation country banner montserrat +๐Ÿ‡ฒ๐Ÿ‡น flag malta mt flag nation country banner malta +๐Ÿ‡ฒ๐Ÿ‡บ flag mauritius mu flag nation country banner mauritius +๐Ÿ‡ฒ๐Ÿ‡ป flag maldives mv flag nation country banner maldives +๐Ÿ‡ฒ๐Ÿ‡ผ flag malawi mw flag nation country banner malawi +๐Ÿ‡ฒ๐Ÿ‡ฝ flag mexico mx flag nation country banner mexico +๐Ÿ‡ฒ๐Ÿ‡พ flag malaysia my flag nation country banner malaysia +๐Ÿ‡ฒ๐Ÿ‡ฟ flag mozambique mz flag nation country banner mozambique +๐Ÿ‡ณ๐Ÿ‡ฆ flag namibia na flag nation country banner namibia +๐Ÿ‡ณ๐Ÿ‡จ flag new caledonia new caledonia flag nation country banner new caledonia +๐Ÿ‡ณ๐Ÿ‡ช flag niger ne flag nation country banner niger +๐Ÿ‡ณ๐Ÿ‡ซ flag norfolk island norfolk island flag nation country banner norfolk island +๐Ÿ‡ณ๐Ÿ‡ฌ flag nigeria flag nation country banner nigeria +๐Ÿ‡ณ๐Ÿ‡ฎ flag nicaragua ni flag nation country banner nicaragua +๐Ÿ‡ณ๐Ÿ‡ฑ flag netherlands nl flag nation country banner netherlands +๐Ÿ‡ณ๐Ÿ‡ด flag norway no flag nation country banner norway +๐Ÿ‡ณ๐Ÿ‡ต flag nepal np flag nation country banner nepal +๐Ÿ‡ณ๐Ÿ‡ท flag nauru nr flag nation country banner nauru +๐Ÿ‡ณ๐Ÿ‡บ flag niue nu flag nation country banner niue +๐Ÿ‡ณ๐Ÿ‡ฟ flag new zealand new zealand flag nation country banner new zealand +๐Ÿ‡ด๐Ÿ‡ฒ flag oman om symbol flag nation country banner oman +๐Ÿ‡ต๐Ÿ‡ฆ flag panama pa flag nation country banner panama +๐Ÿ‡ต๐Ÿ‡ช flag peru pe flag nation country banner peru +๐Ÿ‡ต๐Ÿ‡ซ flag french polynesia french polynesia flag nation country banner french polynesia +๐Ÿ‡ต๐Ÿ‡ฌ flag papua new guinea papua new guinea flag nation country banner papua new guinea +๐Ÿ‡ต๐Ÿ‡ญ flag philippines ph flag nation country banner philippines +๐Ÿ‡ต๐Ÿ‡ฐ flag pakistan pk flag nation country banner pakistan +๐Ÿ‡ต๐Ÿ‡ฑ flag poland pl flag nation country banner poland +๐Ÿ‡ต๐Ÿ‡ฒ flag st pierre miquelon saint pierre miquelon flag nation country banner st pierre miquelon +๐Ÿ‡ต๐Ÿ‡ณ flag pitcairn islands pitcairn flag nation country banner pitcairn islands +๐Ÿ‡ต๐Ÿ‡ท flag puerto rico puerto rico flag nation country banner puerto rico +๐Ÿ‡ต๐Ÿ‡ธ flag palestinian territories palestine palestinian territories flag nation country banner palestinian territories +๐Ÿ‡ต๐Ÿ‡น flag portugal pt flag nation country banner portugal +๐Ÿ‡ต๐Ÿ‡ผ flag palau pw flag nation country banner palau +๐Ÿ‡ต๐Ÿ‡พ flag paraguay py flag nation country banner paraguay +๐Ÿ‡ถ๐Ÿ‡ฆ flag qatar qa flag nation country banner qatar +๐Ÿ‡ท๐Ÿ‡ช flag reunion rรฉunion flag nation country banner reunion +๐Ÿ‡ท๐Ÿ‡ด flag romania ro flag nation country banner romania +๐Ÿ‡ท๐Ÿ‡ธ flag serbia rs flag nation country banner serbia +๐Ÿ‡ท๐Ÿ‡บ flag russia russian federation flag nation country banner russia +๐Ÿ‡ท๐Ÿ‡ผ flag rwanda rw flag nation country banner rwanda +๐Ÿ‡ธ๐Ÿ‡ฆ flag saudi arabia flag nation country banner saudi arabia +๐Ÿ‡ธ๐Ÿ‡ง flag solomon islands solomon islands flag nation country banner solomon islands +๐Ÿ‡ธ๐Ÿ‡จ flag seychelles sc flag nation country banner seychelles +๐Ÿ‡ธ๐Ÿ‡ฉ flag sudan sd flag nation country banner sudan +๐Ÿ‡ธ๐Ÿ‡ช flag sweden se flag nation country banner sweden +๐Ÿ‡ธ๐Ÿ‡ฌ flag singapore sg flag nation country banner singapore +๐Ÿ‡ธ๐Ÿ‡ญ flag st helena saint helena ascension tristan cunha flag nation country banner st helena +๐Ÿ‡ธ๐Ÿ‡ฎ flag slovenia si flag nation country banner slovenia +๐Ÿ‡ธ๐Ÿ‡ฏ flag svalbard jan mayen +๐Ÿ‡ธ๐Ÿ‡ฐ flag slovakia sk flag nation country banner slovakia +๐Ÿ‡ธ๐Ÿ‡ฑ flag sierra leone sierra leone flag nation country banner sierra leone +๐Ÿ‡ธ๐Ÿ‡ฒ flag san marino san marino flag nation country banner san marino +๐Ÿ‡ธ๐Ÿ‡ณ flag senegal sn flag nation country banner senegal +๐Ÿ‡ธ๐Ÿ‡ด flag somalia so flag nation country banner somalia +๐Ÿ‡ธ๐Ÿ‡ท flag suriname sr flag nation country banner suriname +๐Ÿ‡ธ๐Ÿ‡ธ flag south sudan south sd flag nation country banner south sudan +๐Ÿ‡ธ๐Ÿ‡น flag sao tome principe sao tome principe flag nation country banner sao tome principe +๐Ÿ‡ธ๐Ÿ‡ป flag el salvador el salvador flag nation country banner el salvador +๐Ÿ‡ธ๐Ÿ‡ฝ flag sint maarten sint maarten dutch flag nation country banner sint maarten +๐Ÿ‡ธ๐Ÿ‡พ flag syria syrian arab republic flag nation country banner syria +๐Ÿ‡ธ๐Ÿ‡ฟ flag eswatini sz flag nation country banner eswatini +๐Ÿ‡น๐Ÿ‡ฆ flag tristan da cunha +๐Ÿ‡น๐Ÿ‡จ flag turks caicos islands turks caicos islands flag nation country banner turks caicos islands +๐Ÿ‡น๐Ÿ‡ฉ flag chad td flag nation country banner chad +๐Ÿ‡น๐Ÿ‡ซ flag french southern territories french southern territories flag nation country banner french southern territories +๐Ÿ‡น๐Ÿ‡ฌ flag togo tg flag nation country banner togo +๐Ÿ‡น๐Ÿ‡ญ flag thailand th flag nation country banner thailand +๐Ÿ‡น๐Ÿ‡ฏ flag tajikistan tj flag nation country banner tajikistan +๐Ÿ‡น๐Ÿ‡ฐ flag tokelau tk flag nation country banner tokelau +๐Ÿ‡น๐Ÿ‡ฑ flag timor leste timor leste flag nation country banner timor leste +๐Ÿ‡น๐Ÿ‡ฒ flag turkmenistan flag nation country banner turkmenistan +๐Ÿ‡น๐Ÿ‡ณ flag tunisia tn flag nation country banner tunisia +๐Ÿ‡น๐Ÿ‡ด flag tonga to flag nation country banner tonga +๐Ÿ‡น๐Ÿ‡ท flag turkey turkey flag nation country banner turkey +๐Ÿ‡น๐Ÿ‡น flag trinidad tobago trinidad tobago flag nation country banner trinidad tobago +๐Ÿ‡น๐Ÿ‡ป flag tuvalu flag nation country banner tuvalu +๐Ÿ‡น๐Ÿ‡ผ flag taiwan tw flag nation country banner taiwan +๐Ÿ‡น๐Ÿ‡ฟ flag tanzania tanzania united republic flag nation country banner tanzania +๐Ÿ‡บ๐Ÿ‡ฆ flag ukraine ua flag nation country banner ukraine +๐Ÿ‡บ๐Ÿ‡ฌ flag uganda ug flag nation country banner uganda +๐Ÿ‡บ๐Ÿ‡ฒ flag u s outlying islands +๐Ÿ‡บ๐Ÿ‡ณ flag united nations un flag banner +๐Ÿ‡บ๐Ÿ‡ธ flag united states united states america flag nation country banner united states +๐Ÿ‡บ๐Ÿ‡พ flag uruguay uy flag nation country banner uruguay +๐Ÿ‡บ๐Ÿ‡ฟ flag uzbekistan uz flag nation country banner uzbekistan +๐Ÿ‡ป๐Ÿ‡ฆ flag vatican city vatican city flag nation country banner vatican city +๐Ÿ‡ป๐Ÿ‡จ flag st vincent grenadines saint vincent grenadines flag nation country banner st vincent grenadines +๐Ÿ‡ป๐Ÿ‡ช flag venezuela ve bolivarian republic flag nation country banner venezuela +๐Ÿ‡ป๐Ÿ‡ฌ flag british virgin islands british virgin islands bvi flag nation country banner british virgin islands +๐Ÿ‡ป๐Ÿ‡ฎ flag u s virgin islands virgin islands us flag nation country banner u s virgin islands +๐Ÿ‡ป๐Ÿ‡ณ flag vietnam viet nam flag nation country banner vietnam +๐Ÿ‡ป๐Ÿ‡บ flag vanuatu vu flag nation country banner vanuatu +๐Ÿ‡ผ๐Ÿ‡ซ flag wallis futuna wallis futuna flag nation country banner wallis futuna +๐Ÿ‡ผ๐Ÿ‡ธ flag samoa ws flag nation country banner samoa +๐Ÿ‡ฝ๐Ÿ‡ฐ flag kosovo xk flag nation country banner kosovo +๐Ÿ‡พ๐Ÿ‡ช flag yemen ye flag nation country banner yemen +๐Ÿ‡พ๐Ÿ‡น flag mayotte yt flag nation country banner mayotte +๐Ÿ‡ฟ๐Ÿ‡ฆ flag south africa south africa flag nation country banner south africa +๐Ÿ‡ฟ๐Ÿ‡ฒ flag zambia zm flag nation country banner zambia +๐Ÿ‡ฟ๐Ÿ‡ผ flag zimbabwe zw flag nation country banner zimbabwe +๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ flag england flag english +๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ flag scotland flag scottish +๐Ÿด๓ ง๓ ข๓ ท๓ ฌ๓ ณ๓ ฟ flag wales flag welsh +๐Ÿฅฒ smiling face with tear sad cry pretend +๐Ÿฅธ disguised face pretent brows glasses moustache +๐ŸคŒ pinched fingers size tiny small +๐Ÿซ€ anatomical heart health heartbeat +๐Ÿซ lungs breathe +๐Ÿฅท ninja ninjutsu skills japanese +๐Ÿคตโ€โ™‚๏ธ man in tuxedo formal fashion +๐Ÿคตโ€โ™€๏ธ woman in tuxedo formal fashion +๐Ÿ‘ฐโ€โ™‚๏ธ man with veil wedding marriage +๐Ÿ‘ฐโ€โ™€๏ธ woman with veil wedding marriage +๐Ÿ‘ฉโ€๐Ÿผ woman feeding baby birth food +๐Ÿ‘จโ€๐Ÿผ man feeding baby birth food +๐Ÿง‘โ€๐Ÿผ person feeding baby birth food +๐Ÿง‘โ€๐ŸŽ„ mx claus christmas +๐Ÿซ‚ people hugging care +๐Ÿˆโ€โฌ› black cat superstition luck +๐Ÿฆฌ bison ox +๐Ÿฆฃ mammoth elephant tusks +๐Ÿฆซ beaver animal rodent +๐Ÿปโ€โ„๏ธ polar bear animal arctic +๐Ÿฆค dodo animal bird +๐Ÿชถ feather bird fly +๐Ÿฆญ seal animal creature sea +๐Ÿชฒ beetle insect +๐Ÿชณ cockroach insect pests +๐Ÿชฐ fly insect +๐Ÿชฑ worm animal +๐Ÿชด potted plant greenery house +๐Ÿซ blueberries fruit +๐Ÿซ’ olive fruit +๐Ÿซ‘ bell pepper fruit plant +๐Ÿซ“ flatbread flour food +๐Ÿซ” tamale food masa +๐Ÿซ• fondue cheese pot food +๐Ÿซ– teapot drink hot +๐Ÿง‹ bubble tea taiwan boba milk tea straw +๐Ÿชจ rock stone +๐Ÿชต wood nature timber trunk +๐Ÿ›– hut house structure +๐Ÿ›ป pickup truck car transportation +๐Ÿ›ผ roller skate footwear sports +๐Ÿช„ magic wand supernature power +๐Ÿช… pinata mexico candy celebration +๐Ÿช† nesting dolls matryoshka toy +๐Ÿชก sewing needle stitches +๐Ÿชข knot rope scout +๐Ÿฉด thong sandal footwear summer +๐Ÿช– military helmet army protection +๐Ÿช— accordion music +๐Ÿช˜ long drum music +๐Ÿช™ coin money currency +๐Ÿชƒ boomerang weapon +๐Ÿชš carpentry saw cut chop +๐Ÿช› screwdriver tools +๐Ÿช hook tools +๐Ÿชœ ladder tools +๐Ÿ›— elevator lift +๐Ÿชž mirror reflection +๐ŸชŸ window scenery +๐Ÿช  plunger toilet +๐Ÿชค mouse trap cheese +๐Ÿชฃ bucket water container +๐Ÿชฅ toothbrush hygiene dental +๐Ÿชฆ headstone death rip grave +๐Ÿชง placard announcement +โšง๏ธ transgender symbol lgbtq +๐Ÿณ๏ธโ€โšง๏ธ transgender flag lgbtq +๐Ÿ˜ถโ€๐ŸŒซ๏ธ face in clouds shower steam dream +๐Ÿ˜ฎโ€๐Ÿ’จ face exhaling relieve relief tired sigh +๐Ÿ˜ตโ€๐Ÿ’ซ face with spiral eyes sick ill confused nauseous nausea +โค๏ธโ€๐Ÿ”ฅ heart on fire passionate enthusiastic +โค๏ธโ€๐Ÿฉน mending heart broken heart bandage wounded +๐Ÿง”โ€โ™‚๏ธ man beard facial hair +๐Ÿง”โ€โ™€๏ธ woman beard facial hair +๐Ÿซ  melting face hot heat +๐Ÿซข face with open eyes and hand over mouth silence secret shock surprise +๐Ÿซฃ face with peeking eye scared frightening embarrassing +๐Ÿซก saluting face respect salute +๐Ÿซฅ dotted line face invisible lonely isolation depression +๐Ÿซค face with diagonal mouth skeptic confuse frustrated indifferent +๐Ÿฅน face holding back tears touched gratitude +๐Ÿซฑ rightwards hand palm offer +๐Ÿซฒ leftwards hand palm offer +๐Ÿซณ palm down hand palm drop +๐Ÿซด palm up hand lift offer demand +๐Ÿซฐ hand with index finger and thumb crossed heart love money expensive +๐Ÿซต index pointing at the viewer you recruit +๐Ÿซถ heart hands love appreciation support +๐Ÿซฆ biting lip flirt sexy pain worry +๐Ÿซ… person with crown royalty power +๐Ÿซƒ pregnant man baby belly +๐Ÿซ„ pregnant person baby belly +๐ŸงŒ troll mystical monster +๐Ÿชธ coral ocean sea reef +๐Ÿชท lotus flower calm meditation +๐Ÿชน empty nest bird +๐Ÿชบ nest with eggs bird +๐Ÿซ˜ beans food +๐Ÿซ— pouring liquid cup water +๐Ÿซ™ jar container sauce +๐Ÿ› playground slide fun park +๐Ÿ›ž wheel car transport +๐Ÿ›Ÿ ring buoy life saver life preserver +๐Ÿชฌ hamsa religion protection +๐Ÿชฉ mirror ball disco dance party +๐Ÿชซ low battery drained dead +๐Ÿฉผ crutch accessibility assist +๐Ÿฉป x-ray skeleton medicine +๐Ÿซง bubbles soap fun carbonation sparkling +๐Ÿชช identification card document +๐ŸŸฐ heavy equals sign math +ยฟ? question upside down reversed spanish +โ† left arrow +โ†‘ up arrow +โ†’ right arrow +โ†“ down arrow +โ†โ†‘โ†’โ†“ all directions up down left right arrows +AHโ†—๏ธHAโ†˜๏ธHAโ†—๏ธHAโ†˜๏ธ pekora arrows hahaha rabbit +โ€ข dot circle separator +ใ€Œใ€ japanese quote square bracket +ยฏ\_(ใƒ„)_/ยฏ shrug idk i dont know +โ†ต enter key return +๐• twitter x logo +๐Ÿ‘‰๐Ÿ‘ˆ etou ughhhhhhh shy +๐Ÿ‘‰๐Ÿ‘Œ put it in imagination perv \ No newline at end of file diff --git a/modules/quickshell/config/services/Emojis.qml b/modules/quickshell/config/services/Emojis.qml index 436401b..a69c140 100644 --- a/modules/quickshell/config/services/Emojis.qml +++ b/modules/quickshell/config/services/Emojis.qml @@ -12,8 +12,9 @@ import Quickshell.Io */ Singleton { id: root - property string emojiScriptPath: `${Directories.config}/hypr/hyprland/scripts/fuzzel-emoji.sh` - property string lineBeforeData: "### DATA ###" + property bool sloppySearch: Config.options?.search.sloppy ?? false + property real scoreThreshold: 0.2 + property string emojiDataPath: Quickshell.shellPath("assets/data/emojis.txt") property list list readonly property var preparedEntries: list.map(a => ({ name: Fuzzy.prepare(`${a}`), @@ -21,7 +22,7 @@ Singleton { })) function fuzzyQuery(search: string): var { if (root.sloppySearch) { - const results = entries.slice(0, 100).map(str => ({ + const results = list.slice(0, 100).map(str => ({ entry: str, score: Levendist.computeTextMatchScore(str.toLowerCase(), search.toLowerCase()) })).filter(item => item.score > root.scoreThreshold) @@ -44,18 +45,15 @@ Singleton { function updateEmojis(fileContent) { const lines = fileContent.split("\n") - const dataIndex = lines.indexOf(root.lineBeforeData) - if (dataIndex === -1) { - console.warn("No data section found in emoji script file.") - return - } - const emojis = lines.slice(dataIndex + 1).filter(line => line.trim() !== "") + // Skip the first line if it's "### DATA ###" + const startIndex = lines[0]?.trim() === "### DATA ###" ? 1 : 0 + const emojis = lines.slice(startIndex).filter(line => line.trim() !== "") root.list = emojis.map(line => line.trim()) } FileView { id: emojiFileView - path: Qt.resolvedUrl(root.emojiScriptPath) + path: Qt.resolvedUrl(root.emojiDataPath) onLoadedChanged: { const fileContent = emojiFileView.text() root.updateEmojis(fileContent) From e52b4680a9dbe3aeb34c5ff3b751f9c37c16bc9b Mon Sep 17 00:00:00 2001 From: FinnPL Date: Mon, 1 Dec 2025 22:01:47 +0100 Subject: [PATCH 14/18] update --- flake.lock | 68 ++++++++++++++++++++++-------------------------------- flake.nix | 5 +++- 2 files changed, 31 insertions(+), 42 deletions(-) diff --git a/flake.lock b/flake.lock index 522b2dd..fcd7d64 100644 --- a/flake.lock +++ b/flake.lock @@ -109,11 +109,11 @@ }, "locked": { "dir": "pkgs/firefox-addons", - "lastModified": 1764389080, - "narHash": "sha256-BEn1Z9Uv20u2DS6wzLKdzx5kAzynM3wMQ9JnGf3VJvI=", + "lastModified": 1764561884, + "narHash": "sha256-vQ3iFPPhxsLqV3c5kgmYP53mVD6id6gsP0tN+oTmqok=", "owner": "rycee", "repo": "nur-expressions", - "rev": "897437c09bf22ce59efb3370f0783d0c662dba31", + "rev": "aba4621459aec251d90d6452e3495b58a8a5e185", "type": "gitlab" }, "original": { @@ -272,11 +272,11 @@ ] }, "locked": { - "lastModified": 1764361670, - "narHash": "sha256-jgWzgpIaHbL3USIq0gihZeuy1lLf2YSfwvWEwnfAJUw=", + "lastModified": 1764603455, + "narHash": "sha256-Q70rxlbrxPcTtqWIb9+71rkJESxIOou5isZBvyOieXw=", "owner": "nix-community", "repo": "home-manager", - "rev": "780be8ef503a28939cf9dc7996b48ffb1a3e04c6", + "rev": "effe4c007d6243d9e69ce2242d76a2471c1b8d5c", "type": "github" }, "original": { @@ -359,11 +359,11 @@ "xdph": "xdph" }, "locked": { - "lastModified": 1764436644, - "narHash": "sha256-P+PVRO3o162fMIcOVA581PMSvU+Z11J80ppdGqwGQl4=", + "lastModified": 1764607679, + "narHash": "sha256-1Bsem6lhJWMQmkLIml5oKOL+z1dYMewZ6ql0K35Y3TA=", "owner": "hyprwm", "repo": "Hyprland", - "rev": "574ee71d568a95101320f264d7afb25034b8faa3", + "rev": "f82a8630d7a51dab4cc70924f500bf70e723db12", "type": "github" }, "original": { @@ -435,11 +435,11 @@ ] }, "locked": { - "lastModified": 1764195033, - "narHash": "sha256-ALRU1VfTv+Vld0bEq3UHSiM6vYxALWvss7d2eOymqbM=", + "lastModified": 1764607625, + "narHash": "sha256-Hrsqq3tWmnPdhqk1dtZPpglsWC+sSLlxV++ry+Zn6T4=", "owner": "hyprwm", "repo": "Hyprland-Plugins", - "rev": "84659a2502df6b2fd245441c16a8365f5e1cd16d", + "rev": "be3fac629c964c74dd1fcb89a9e9bcb1ffd80f31", "type": "github" }, "original": { @@ -611,11 +611,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1764432271, - "narHash": "sha256-XCZxe+UAiutOpR4vd2iT02uVw4Yb8UogNu4+ystCInE=", + "lastModified": 1764576281, + "narHash": "sha256-f6vfwmIb9C3brI4/KJ9MFUDWt6FsKQ0dbMO6AuFc7E0=", "owner": "kaylorben", "repo": "nixcord", - "rev": "5d37818d54213354ede10445c330104a25eeccf4", + "rev": "c8f47894134a4984acd319e66c4384eb1ff886e2", "type": "github" }, "original": { @@ -673,11 +673,11 @@ }, "nixpkgs_3": { "locked": { - "lastModified": 1764242076, - "narHash": "sha256-sKoIWfnijJ0+9e4wRvIgm/HgE27bzwQxcEmo2J/gNpI=", + "lastModified": 1764517877, + "narHash": "sha256-pp3uT4hHijIC8JUK5MEqeAWmParJrgBVzHLNfJDZxg4=", "owner": "nixos", "repo": "nixpkgs", - "rev": "2fad6eac6077f03fe109c4d4eb171cf96791faa4", + "rev": "2d293cbfa5a793b4c50d17c05ef9e385b90edf6c", "type": "github" }, "original": { @@ -687,22 +687,6 @@ "type": "github" } }, - "nixpkgs_4": { - "locked": { - "lastModified": 1762977756, - "narHash": "sha256-4PqRErxfe+2toFJFgcRKZ0UI9NSIOJa+7RXVtBhy4KE=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "c5ae371f1a6a7fd27823bc500d9390b38c05fa55", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, "nur": { "inputs": { "flake-parts": [ @@ -758,11 +742,11 @@ ] }, "locked": { - "lastModified": 1764045583, - "narHash": "sha256-W24ReyRrhOKTKIsuAMkY5hnVlCufGoONM79sjUoyQkk=", + "lastModified": 1764482797, + "narHash": "sha256-ynV90KoBrPe38YFlKAHtPFk4Ee3IANUsIFGxRaq7H/s=", "owner": "quickshell-mirror", "repo": "quickshell", - "rev": "e9bad67619ee9937a1bbecfc6ad3b4231d2ecdc3", + "rev": "d24e8e9736287d01ee73ef9d573d2bc316a62d5c", "type": "github" }, "original": { @@ -793,7 +777,9 @@ "firefox-gnome-theme": "firefox-gnome-theme", "flake-parts": "flake-parts_2", "gnome-shell": "gnome-shell", - "nixpkgs": "nixpkgs_4", + "nixpkgs": [ + "nixpkgs" + ], "nur": "nur", "systems": "systems_2", "tinted-foot": "tinted-foot", @@ -803,11 +789,11 @@ "tinted-zed": "tinted-zed" }, "locked": { - "lastModified": 1764254063, - "narHash": "sha256-V22JzkaTLF/GAL2LgqvOsJhAr8JbJsKaD8hnHjGwXfE=", + "lastModified": 1764550443, + "narHash": "sha256-ArO2V1YEHmEILilTj4KPtqF4gqc1q2HBrrrmygQ/UyU=", "owner": "danth", "repo": "stylix", - "rev": "a1451bc40413870f0c7b576b751c1ca92055e323", + "rev": "794b6e1fa75177ebfeb32967f135858a1ab1ba15", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index e7a858b..27652ff 100644 --- a/flake.nix +++ b/flake.nix @@ -14,7 +14,10 @@ inputs.nixpkgs.follows = "nixpkgs"; }; - stylix.url = "github:danth/stylix"; + stylix = { + url = "github:danth/stylix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; nixcord.url = "github:kaylorben/nixcord"; From 4d647caa2540e228f3e094fc0b25a024e9e91de5 Mon Sep 17 00:00:00 2001 From: FinnPL Date: Mon, 1 Dec 2025 22:18:37 +0100 Subject: [PATCH 15/18] fix: update Weather service and shell configuration for GPS and color picker --- .../quickshell/config/services/Weather.qml | 7 ++--- modules/quickshell/shell-config.nix | 27 +++++-------------- 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/modules/quickshell/config/services/Weather.qml b/modules/quickshell/config/services/Weather.qml index a7bb8d5..0c2d907 100644 --- a/modules/quickshell/config/services/Weather.qml +++ b/modules/quickshell/config/services/Weather.qml @@ -26,7 +26,7 @@ Singleton { property var location: ({ valid: false, lat: 0, - lon: 0 + long: 0 }) property var data: ({ @@ -101,6 +101,8 @@ Singleton { } Component.onCompleted: { + root.getData(); + if (!root.gpsActive) return; console.info("[WeatherService] Starting the GPS service."); positionSource.start(); @@ -156,10 +158,9 @@ Singleton { } Timer { - running: !root.gpsActive + running: true repeat: true interval: root.fetchInterval - triggeredOnStart: !root.gpsActive onTriggered: root.getData() } } diff --git a/modules/quickshell/shell-config.nix b/modules/quickshell/shell-config.nix index a43ad1e..a53528c 100644 --- a/modules/quickshell/shell-config.nix +++ b/modules/quickshell/shell-config.nix @@ -204,7 +204,7 @@ screenList = []; utilButtons = { showScreenSnip = true; - showColorPicker = false; + showColorPicker = true; showMicToggle = false; showKeyboardToggle = false; showDarkModeToggle = false; @@ -213,8 +213,8 @@ }; weather = { enable = true; - enableGPS = false; - city = "Aachen"; + enableGPS = true; + city = ""; useUSCS = false; fetchInterval = 10; }; @@ -327,22 +327,7 @@ in { # Generate the colors.json file that quickshell reads home.file.".local/state/quickshell/user/generated/colors.json".source = colorsJson; - # Create required directories and copy mutable config files - home.activation.quickshellSetup = lib.hm.dag.entryAfter ["writeBoundary"] '' - # Create required directories - mkdir -p $HOME/.local/state/quickshell/user/generated - mkdir -p $HOME/.config/illogical-impulse/translations - mkdir -p $HOME/.cache/quickshell/notifications - - # Copy illogical-impulse config if it doesn't exist (makes it writable for quickshell) - if [ ! -f "$HOME/.config/illogical-impulse/config.json" ]; then - cp ${illogicalImpulseConfig} $HOME/.config/illogical-impulse/config.json - chmod 644 $HOME/.config/illogical-impulse/config.json - fi - - # Create empty en_US.json translation file if it doesn't exist - if [ ! -f "$HOME/.config/illogical-impulse/translations/en_US.json" ]; then - echo '{}' > $HOME/.config/illogical-impulse/translations/en_US.json - fi - ''; + # Declarative illogical-impulse configuration (managed by Nix) + home.file.".config/illogical-impulse/config.json".source = illogicalImpulseConfig; + home.file.".config/illogical-impulse/translations/en_US.json".text = "{}"; } From 77477e38ad49645783116c84bb89d0b9abab1799 Mon Sep 17 00:00:00 2001 From: FinnPL Date: Mon, 1 Dec 2025 23:20:29 +0100 Subject: [PATCH 16/18] fix: disable fill behavior to improve performance and avoid lag in MaterialSymbol --- .../modules/common/widgets/MaterialSymbol.qml | 15 ++++++++------- .../modules/ii/sidebarRight/SidebarRight.qml | 5 ++++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/modules/quickshell/config/modules/common/widgets/MaterialSymbol.qml b/modules/quickshell/config/modules/common/widgets/MaterialSymbol.qml index d117bb6..26e8f9c 100644 --- a/modules/quickshell/config/modules/common/widgets/MaterialSymbol.qml +++ b/modules/quickshell/config/modules/common/widgets/MaterialSymbol.qml @@ -20,11 +20,12 @@ StyledText { } } - Behavior on fill { // Leaky leaky, no good - NumberAnimation { - duration: Appearance?.animation.elementMoveFast.duration ?? 200 - easing.type: Appearance?.animation.elementMoveFast.type ?? Easing.BezierSpline - easing.bezierCurve: Appearance?.animation.elementMoveFast.bezierCurve ?? [0.34, 0.80, 0.34, 1.00, 1, 1] - } - } + // Behavior on fill disabled - causes lag due to font remapping every frame + // Behavior on fill { + // NumberAnimation { + // duration: Appearance?.animation.elementMoveFast.duration ?? 200 + // easing.type: Appearance?.animation.elementMoveFast.type ?? Easing.BezierSpline + // easing.bezierCurve: Appearance?.animation.elementMoveFast.bezierCurve ?? [0.34, 0.80, 0.34, 1.00, 1, 1] + // } + // } } diff --git a/modules/quickshell/config/modules/ii/sidebarRight/SidebarRight.qml b/modules/quickshell/config/modules/ii/sidebarRight/SidebarRight.qml index fcba767..3c97c7a 100644 --- a/modules/quickshell/config/modules/ii/sidebarRight/SidebarRight.qml +++ b/modules/quickshell/config/modules/ii/sidebarRight/SidebarRight.qml @@ -13,7 +13,10 @@ Scope { PanelWindow { id: sidebarRoot - visible: GlobalStates.sidebarRightOpen + visible: true // Always visible to avoid window mapping delay + + // Use margins to slide off-screen when closed + WlrLayershell.margins.right: GlobalStates.sidebarRightOpen ? 0 : -sidebarWidth function hide() { GlobalStates.sidebarRightOpen = false From f56bb0395d292d1c2e059447266e337de2ddde8b Mon Sep 17 00:00:00 2001 From: FinnPL Date: Mon, 1 Dec 2025 23:26:50 +0100 Subject: [PATCH 17/18] fix: update README to include Quickshell details and integration --- README.md | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5a7e30b..7d87d16 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,15 @@ [![Test NixOS Configuration](https://github.com/FinnPL/NixOS-Configuration/actions/workflows/test.yml/badge.svg)](https://github.com/FinnPL/NixOS-Configuration/actions/workflows/test.yml) [![Evaluate NixOS Configuration](https://github.com/FinnPL/NixOS-Configuration/actions/workflows/build.yml/badge.svg)](https://github.com/FinnPL/NixOS-Configuration/actions/workflows/build.yml) -My NixOS configuration using flakes with Hyprland, Home Manager, and modular structure. +My NixOS configuration using flakes with Hyprland, Quickshell, Home Manager, and modular structure. ## Overview This repository contains a complete NixOS configuration featuring: - **Hyprland** - Modern Wayland compositor +- **Quickshell** - Modern Qt6/QML-based desktop shell - **Home Manager** - Declarative user environment management -- **Stylix** - System-wide theming +- **Stylix** - System-wide theming with automatic Quickshell integration - **Modular structure** - Clean separation of concerns ## Quick Start @@ -66,7 +67,8 @@ The configuration is built using Nix flakes with the following inputs: โ”œโ”€โ”€ modules/ # Modular configurations โ”‚ โ”œโ”€โ”€ home-manager/ # User-level modules โ”‚ โ”œโ”€โ”€ hyprland/ # Hyprland-specific modules -โ”‚ โ””โ”€โ”€ nixos/ # System-level modules +โ”‚ โ”œโ”€โ”€ nixos/ # System-level modules +โ”‚ โ””โ”€โ”€ quickshell/ # Quickshell desktop shell โ””โ”€โ”€ none-nix/ # Non-Nix configuration files ``` @@ -99,6 +101,30 @@ Located in `modules/hyprland/`: - **hyprpaper.nix** - Wallpaper management - **cliphist.nix** - Clipboard history +### Quickshell Modules + +Located in `modules/quickshell/`: + +This is a complete Qt6/QML desktop shell based on [illogical-impulse](https://github.com/end-4/dots-hyprland), featuring: + +- **shell-config.nix** - Main Quickshell configuration and Nix integration +- **generate-colors.py** - Automatic Material Design color generation from Stylix theme +- **config/** - QML shell components including: + - **Bar** - Status bar with system info, weather, workspaces + - **Overview** - Window overview and app launcher + - **Sidebar** - Notifications, calendar, quick toggles + - **Lock screen** - Session lock with customizable clock widgets + - **On-screen display** - Volume, brightness indicators + - **Notification popups** - Desktop notifications + - **Session screen** - Power/logout menu + +#### Quickshell Features + +- **Stylix Integration** - Colors automatically generated from your theme +- **Declarative Config** - All settings managed through Nix +- **Services** - Weather, battery, audio, Bluetooth, network status, and more +- **Search** - App launcher with math, web search, and file search capabilities + ### NixOS Modules Located in `modules/nixos/`: From 1bee9ab6fb6c69e56c37fbbd76a8df1da82922ae Mon Sep 17 00:00:00 2001 From: FinnPL Date: Mon, 1 Dec 2025 23:58:38 +0100 Subject: [PATCH 18/18] feat: add nix-ld support for JetBrains IDEs and register jetbrains:// URL scheme handler --- hosts/centaur/configuration.nix | 40 ++++++++++++++++++++++++++++++ modules/home-manager/jetbrains.nix | 14 +++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/hosts/centaur/configuration.nix b/hosts/centaur/configuration.nix index 18db773..2c8f5c5 100644 --- a/hosts/centaur/configuration.nix +++ b/hosts/centaur/configuration.nix @@ -207,6 +207,46 @@ # ============================================================================ programs.zsh.enable = true; + # Enable nix-ld for running unpatched dynamic binaries (JetBrains IDEs from Toolbox, etc.) + programs.nix-ld = { + enable = true; + libraries = with pkgs; [ + # Common libraries needed by JetBrains IDEs and other binaries + stdenv.cc.cc.lib + zlib + glib + gtk3 + libGL + libdrm + mesa + xorg.libX11 + xorg.libXcursor + xorg.libXi + xorg.libXrandr + xorg.libXrender + xorg.libXext + xorg.libXfixes + xorg.libXtst + xorg.libxcb + freetype + fontconfig + libxkbcommon + wayland + expat + nss + nspr + dbus + ]; + }; + + # Symlink /bin/bash for compatibility with scripts that use #!/bin/bash (JetBrains Toolbox scripts, etc.) + system.activationScripts.binbash = { + text = '' + ln -sf ${pkgs.bash}/bin/bash /bin/bash + ''; + deps = []; + }; + # ============================================================================ # USER CONFIGURATION # ============================================================================ diff --git a/modules/home-manager/jetbrains.nix b/modules/home-manager/jetbrains.nix index 06bfefd..bf7dde0 100644 --- a/modules/home-manager/jetbrains.nix +++ b/modules/home-manager/jetbrains.nix @@ -18,16 +18,17 @@ mkdir -p $out/share/applications chmod u+w $out/share/applications - # Create the desktop file + # Create the desktop file with URL handler for jetbrains:// scheme cat > $out/share/applications/jetbrains-toolbox.desktop <